diff --git a/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt b/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt index 9331de56..64e832fe 100644 --- a/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt +++ b/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt @@ -709,7 +709,10 @@ internal object RustCoreBackend : CoreBackend { current.updateSettings(string("extension_id"), string("settings", "{}")) null } - "invokeExtensionAction" -> current.invokeAction(string("extension_id"), string("action")) + "invokeExtensionAction" -> if (args.containsKey("arguments_json")) { + // Transient form input bypasses persistent extension settings. + current.call(string("extension_id"), string("action"), string("arguments_json", "[]"), null, 120_000uL) + } else current.invokeAction(string("extension_id"), string("action")) "checkExtensionHealth" -> current.checkExtensionHealthJson(string("extension_id")) "setProviderPriority", "setMetadataProviderPriority" -> { current.setProviderPriority(if (method == "setProviderPriority") "download" else "metadata", ids(string("priority", "[]"))) diff --git a/ios/Runner/CoreBackend.swift b/ios/Runner/CoreBackend.swift index 88e7a143..5160d919 100644 --- a/ios/Runner/CoreBackend.swift +++ b/ios/Runner/CoreBackend.swift @@ -710,7 +710,12 @@ final class RustCoreBackend: CoreBackend { case "removeExtension": try current.remove(extensionId: string("extension_id")); return nil case "getExtensionSettings": return try current.environment().settings(extensionId: string("extension_id")) case "setExtensionSettings": try current.updateSettings(extensionId: string("extension_id"), settingsJson: string("settings", "{}")); return nil - case "invokeExtensionAction": return try current.invokeAction(extensionId: string("extension_id"), action: string("action")) + case "invokeExtensionAction": + if args["arguments_json"] != nil { + // Transient form input bypasses persistent extension settings. + return try current.call(extensionId: string("extension_id"), method: string("action"), argumentsJson: string("arguments_json", "[]"), lease: nil, timeoutMs: 120000) + } + return try current.invokeAction(extensionId: string("extension_id"), action: string("action")) case "checkExtensionHealth": return try current.checkExtensionHealthJson(extensionId: string("extension_id")) case "searchTracksWithMetadataProviders": return try current.searchMetadataProviders(query: string("query"), limit: (args["limit"] as? NSNumber)?.int64Value ?? 20, includeExtensions: args["include_extensions"] as? Bool ?? true, itemId: "", timeoutMs: 30000) diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 66a3c3f9..e869d47a 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,4 +1,9 @@ { + "extensionAccountTitle": "Extension account", + "extensionAccountInvalidForm": "The extension returned an invalid account form.", + "extensionAccountRestartLogin": "Restart account login to continue.", + "extensionAccountRequiredValue": "Enter a value.", + "extensionAccountInvalidNumber": "Enter a valid number.", "settingsPlaybackSubtitle": "Player, volume, AutoMix, and audio output", "sectionPlayer": "Player", "sectionVolumeAndTransitions": "Volume and transitions", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index a4fc8fc0..7ccd4f4f 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1,4 +1,9 @@ { + "extensionAccountTitle": "Akun extension", + "extensionAccountInvalidForm": "Extension mengembalikan formulir akun yang tidak valid.", + "extensionAccountRestartLogin": "Mulai ulang login akun untuk melanjutkan.", + "extensionAccountRequiredValue": "Masukkan nilai.", + "extensionAccountInvalidNumber": "Masukkan angka yang valid.", "settingsPlaybackSubtitle": "Pemutar, volume, AutoMix, dan keluaran audio", "sectionPlayer": "Pemutar", "sectionVolumeAndTransitions": "Volume dan transisi", diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 2dee5b9b..57765f8f 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -14,8 +14,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/repo_provider.dart'; -import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; +import 'package:spotiflac_android/utils/extension_action_forms.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; @@ -1223,10 +1223,12 @@ class _SettingItemState extends State<_SettingItem> { setState(() => _isLoading = true); try { - final result = await PlatformBridge.invokeExtensionAction( + final result = await runExtensionActionWithForms( + context, widget.extensionId, widget.setting.action!, ); + if (result == null) return; if (context.mounted) { // Go may return either a flat map or { success, result: { ... } }. diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 6c059dd8..0b30974e 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1795,12 +1795,15 @@ class PlatformBridge { static Future> invokeExtensionAction( String extensionId, - String actionName, - ) async { + String actionName, { + Map? input, + }) async { + // Log action identity only. Input may contain account credentials or OTP. _log.d('invokeExtensionAction: $extensionId.$actionName'); final result = await _channel.invokeMethod('invokeExtensionAction', { 'extension_id': extensionId, 'action': actionName, + if (input != null) 'arguments_json': jsonEncode([input]), }); if (result == null || (result as String).isEmpty) { return {'success': true}; diff --git a/lib/utils/extension_action_forms.dart b/lib/utils/extension_action_forms.dart new file mode 100644 index 00000000..b168fd78 --- /dev/null +++ b/lib/utils/extension_action_forms.dart @@ -0,0 +1,279 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; + +/// Account input goes straight to the action, never into extension settings. +Future?> runExtensionActionWithForms( + BuildContext context, + String extensionId, + String action, +) async { + Map? input; + for (var step = 0; step < 8; step++) { + Map result; + try { + result = await PlatformBridge.invokeExtensionAction( + extensionId, + action, + input: input, + ); + } finally { + input?.updateAll((_, value) => value is String ? '' : null); + input = null; + } + if (!context.mounted) return null; + final nested = result['result']; + final payload = nested is Map ? nested : result; + // Accept the supplied protocol while exposing a provider-neutral form API. + final raw = payload['action_form'] ?? payload['byoa_form']; + if (payload['success'] != true || raw == null) return result; + final form = _AccountForm.parse(raw); + if (form == null) { + return { + 'success': false, + 'error': context.l10n.extensionAccountInvalidForm, + }; + } + input = await showAppDialog>( + context: context, + builder: (_) => _AccountFormDialog(form: form), + ); + if (input == null) { + if (form.cancelAction != null) { + await PlatformBridge.invokeExtensionAction( + extensionId, + form.cancelAction!, + ); + } + return null; + } + action = form.submitAction; + } + input?.updateAll((_, value) => value is String ? '' : null); + if (!context.mounted) return null; + return {'success': false, 'error': context.l10n.extensionAccountRestartLogin}; +} + +class _AccountForm { + const _AccountForm( + this.submitAction, + this.cancelAction, + this.title, + this.description, + this.fields, + ); + final String submitAction; + final String? cancelAction; + final String? title; + final String? description; + final List> fields; + static final _identifier = RegExp(r'^[A-Za-z][A-Za-z0-9_]{0,63}$'); + static bool _action(dynamic value) => + value is String && _identifier.hasMatch(value); + static bool _text(dynamic value, int max) => + value == null || (value is String && value.length <= max); + + static _AccountForm? parse(dynamic raw) { + if (raw is! Map || + raw['version'] != 1 || + !_action(raw['submit_action']) || + (raw['cancel_action'] != null && !_action(raw['cancel_action'])) || + !_text(raw['title'], 256) || + !_text(raw['description'], 4096)) { + return null; + } + final fields = raw['fields']; + if (fields is! List || fields.isEmpty || fields.length > 12) return null; + final validated = >[]; + final keys = {}; + for (final item in fields) { + if (item is! Map || item.keys.any((key) => key is! String)) return null; + final key = item['key']; + if (!_action(key) || + !keys.add(key as String) || + !_text(item['label'], 256) || + !const { + 'string', + 'password', + 'otp', + 'number', + 'select', + }.contains(item['type'])) { + return null; + } + if (item['type'] == 'select') { + final options = item['options']; + if (options is! List || + options.isEmpty || + options.length > 30 || + options.any( + (option) => + option is! String || option.isEmpty || option.length > 256, + ) || + options.toSet().length != options.length || + (item['default'] != null && !options.contains(item['default']))) { + return null; + } + } + validated.add(Map.from(item)); + } + return _AccountForm( + raw['submit_action'] as String, + raw['cancel_action'] as String?, + raw['title'] as String?, + raw['description'] as String?, + validated, + ); + } +} + +class _AccountFormDialog extends StatefulWidget { + const _AccountFormDialog({required this.form}); + final _AccountForm form; + @override + State<_AccountFormDialog> createState() => _AccountFormDialogState(); +} + +class _AccountFormDialogState extends State<_AccountFormDialog> { + final _controllers = {}; + final _choices = {}; + final _formKey = GlobalKey(); + @override + void initState() { + super.initState(); + for (final field in widget.form.fields) { + final key = field['key'] as String; + if (field['type'] == 'select') { + _choices[key] = + field['default'] as String? ?? + (field['options'] as List).first as String; + } else { + _controllers[key] = TextEditingController(); + } + } + } + + void _clear() { + for (final controller in _controllers.values) { + controller.clear(); + } + } + + @override + void dispose() { + for (final controller in _controllers.values) { + controller.clear(); + controller.dispose(); + } + super.dispose(); + } + + void _submit() { + if (_formKey.currentState?.validate() != true) return; + final values = {..._choices}; + for (final field in widget.form.fields) { + final key = field['key'] as String; + final controller = _controllers[key]; + if (controller != null) { + values[key] = field['type'] == 'number' && controller.text.isNotEmpty + ? num.parse(controller.text) + : controller.text; + } + } + _clear(); + Navigator.pop(context, values); + } + + @override + Widget build(BuildContext context) => AppAlertDialog( + title: Text(widget.form.title ?? context.l10n.extensionAccountTitle), + content: SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.form.description != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text(widget.form.description!), + ), + for (final field in widget.form.fields) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: field['type'] == 'select' + ? DropdownButtonFormField( + initialValue: _choices[field['key']], + decoration: InputDecoration( + labelText: field['label'] as String?, + ), + items: (field['options'] as List) + .cast() + .map( + (option) => DropdownMenuItem( + value: option, + child: Text(option), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + setState( + () => _choices[field['key'] as String] = value, + ); + } + }, + ) + : TextFormField( + controller: _controllers[field['key']], + obscureText: + field['type'] == 'password' || + field['secret'] == true, + autocorrect: false, + enableSuggestions: false, + enableIMEPersonalizedLearning: false, + maxLength: 4096, + keyboardType: field['type'] == 'number' + ? TextInputType.number + : TextInputType.text, + decoration: InputDecoration( + labelText: field['label'] as String?, + counterText: '', + ), + validator: (value) { + if (field['required'] == true && + (value == null || value.isEmpty)) { + return context.l10n.extensionAccountRequiredValue; + } + if (field['type'] == 'number' && + value != null && + value.isNotEmpty && + (num.tryParse(value)?.isFinite != true)) { + return context.l10n.extensionAccountInvalidNumber; + } + return null; + }, + ), + ), + ], + ), + ), + ), + actions: [ + AppDialogAction( + onPressed: () { + _clear(); + Navigator.pop(context); + }, + child: Text(context.l10n.dialogCancel), + ), + AppDialogAction( + filled: true, + isDefault: true, + onPressed: _submit, + child: Text(context.l10n.upgradeIntroContinue), + ), + ], + ); +} diff --git a/rust_backend/crates/extensions/src/backend/download_account_tests.rs b/rust_backend/crates/extensions/src/backend/download_account_tests.rs new file mode 100644 index 00000000..e27827ae --- /dev/null +++ b/rust_backend/crates/extensions/src/backend/download_account_tests.rs @@ -0,0 +1,85 @@ +use super::*; +use crate::RuntimeLimits; + +#[test] +fn personal_account_failures_never_attempt_another_download_provider() { + for availability in [ + "return {available:false,reason:'not_found'};", + "throw new Error('account catalog unavailable');", + "return {available:true,track_id:'track'};", + ] { + let directory = tempfile::tempdir().unwrap(); + let sources = directory.path().join("sources"); + let data = directory.path().join("data"); + for id in ["example.account", "example.fallback"] { + let source = sources.join(id); + std::fs::create_dir_all(&source).unwrap(); + let capability = if id == "example.account" { + json!({"accountDownloadMode":{"setting":"accountMode","value":"personal"}}) + } else { + json!({}) + }; + std::fs::write( + source.join("manifest.json"), + json!({"name":id,"version":"1", + "description":"Generic account fallback fixture","type":["download_provider"], + "permissions":{"file":true,"storage":true},"capabilities":capability}) + .to_string(), + ) + .unwrap(); + let behavior = if id == "example.account" { + availability + } else { + "storage.set('attempts',1);return {available:true,track_id:'track'};" + }; + std::fs::write(source.join("index.js"), format!( + "registerExtension({{checkAvailability(){{{behavior}}},download(){{return {{success:false,error_message:'account entitlement required',error_type:'subscription_required'}};}},attempts(){{return storage.get('attempts')||0;}}}});" + )).unwrap(); + } + let backend = Backend::new( + &sources, + &data, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "1", + RuntimeLimits::default(), + ) + .unwrap(); + backend.load_all().unwrap(); + for id in ["example.account", "example.fallback"] { + backend.set_enabled(id, true).unwrap(); + } + backend + .update_settings( + "example.account", + serde_json::from_value(json!({"accountMode":"personal"})).unwrap(), + ) + .unwrap(); + backend + .set_provider_priority( + "download", + vec!["example.account".into(), "example.fallback".into()], + ) + .unwrap(); + let response = backend + .download_by_strategy( + &json!({"service":"example.account", + "use_extensions":true,"use_fallback":true,"output_dir":directory.path().join("output"), + "track_name":"Example Track","quality":"best"}) + .to_string(), + &|| Ok(()), + ) + .unwrap(); + assert_eq!( + serde_json::from_str::(&response).unwrap()["success"], + false, + "{response}" + ); + assert_eq!( + backend + .call("example.fallback", "attempts", "[]", None, 1000) + .unwrap(), + "0" + ); + backend.shutdown(); + } +} diff --git a/rust_backend/crates/extensions/src/backend/downloads.rs b/rust_backend/crates/extensions/src/backend/downloads.rs index 27b163b0..153e0aae 100644 --- a/rust_backend/crates/extensions/src/backend/downloads.rs +++ b/rust_backend/crates/extensions/src/backend/downloads.rs @@ -282,6 +282,9 @@ impl Backend { if !manifest.has_type("download_provider") { continue; } + let account_mode = self + .account_download_enabled(&id) + .map_err(|error| error.to_string())?; let availability = if direct { source_availability .clone() @@ -297,6 +300,9 @@ impl Backend { Ok(value) => value, Err(error) => { let response = failure(&id, &error, "", 0); + if account_mode { + return Ok(response); + } if response["error_type"] == "verification_required" { self.cache_prepared(&key, &request, true); return Ok(failure( @@ -311,7 +317,7 @@ impl Backend { } } }; - let stop = availability["skip_fallback"] == true; + let stop = availability["skip_fallback"] == true || account_mode; if availability["available"] != true { if stop { return Ok(stopped(&id, &availability, "")); @@ -339,6 +345,18 @@ impl Backend { let message = text(&response, "error"); let kind = text(&response, "error_type"); let retry = response["retry_after_seconds"].as_i64().unwrap_or_default(); + if account_mode + || self + .account_download_enabled(&id) + .map_err(|error| error.to_string())? + { + return Ok(failure( + &id, + &format!("Download failed: {message}"), + kind, + retry, + )); + } if kind == "verification_required" { self.cache_prepared(&key, &request, true); return Ok(failure( @@ -1770,3 +1788,7 @@ fn storage_failure(kind: &str, message: &str) -> bool { #[cfg(test)] #[path = "download_latency_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "download_account_tests.rs"] +mod account_tests; diff --git a/rust_backend/crates/extensions/src/download_host.rs b/rust_backend/crates/extensions/src/download_host.rs index 0a9b0e92..e21020ca 100644 --- a/rust_backend/crates/extensions/src/download_host.rs +++ b/rust_backend/crates/extensions/src/download_host.rs @@ -40,6 +40,12 @@ pub(crate) fn register<'js>( |object| object.get(key).map_err(|error| error.to_string()), ) }; + let direct_network = if value("directMedia")?.as_bool() == Some(true) { + Some(network.direct_media()) + } else { + None + }; + let network = direct_network.as_ref().unwrap_or(network.as_ref()); let chunked = value("chunked")?; let chunk_size = if chunked.as_bool() == Some(true) { Some(1 << 20) diff --git a/rust_backend/crates/extensions/src/environment.rs b/rust_backend/crates/extensions/src/environment.rs index efed36d0..74878f4d 100644 --- a/rust_backend/crates/extensions/src/environment.rs +++ b/rust_backend/crates/extensions/src/environment.rs @@ -37,6 +37,9 @@ pub(crate) static SUPPORTED_RUNTIME_FEATURES: LazyLock> ("downloadSegments", 1), ("patternedFileTransform", 1), ("preparedContext", 1), + ("accountForms", 1), + ("accountDownloadMode", 1), + ("directMedia", 1), ] .into_iter() .map(|(name, version)| (name.to_owned(), version)) diff --git a/rust_backend/crates/extensions/src/manager.rs b/rust_backend/crates/extensions/src/manager.rs index 65445a54..b55473ad 100644 --- a/rust_backend/crates/extensions/src/manager.rs +++ b/rust_backend/crates/extensions/src/manager.rs @@ -561,8 +561,9 @@ impl ExtensionManager { let runtime = self .ready(&entry, &mut engine, true) .inspect_err(|e| self.failed(&entry, e))?; + let timeout = entry.manifest.action_timeout_ms(self.limits.timeout_ms); runtime - .managed_call(action, "[]", true, self.limits.timeout_ms) + .managed_call(action, "[]", true, timeout) .map_err(|e| cause("action failed", e)) } diff --git a/rust_backend/crates/extensions/src/manager/downloads.rs b/rust_backend/crates/extensions/src/manager/downloads.rs index 8bac7409..ed0676d0 100644 --- a/rust_backend/crates/extensions/src/manager/downloads.rs +++ b/rust_backend/crates/extensions/src/manager/downloads.rs @@ -72,6 +72,24 @@ impl ExtensionManager { Ok(entry.manifest.clone()) } + /// Extensions may opt into account-owned downloads using a declared setting. + /// That mode owns authentication and must never enter shared-account fallback. + pub(crate) fn account_download_enabled(&self, id: &str) -> Result { + let entry = self.get(id)?; + if !entry + .manifest + .capabilities + .contains_key("accountDownloadMode") + { + return Ok(false); + } + let settings = self + .environment + .settings(&entry.manifest.name) + .map_err(error_from_environment)?; + Ok(entry.manifest.account_download_enabled(&settings)) + } + pub(crate) fn preflight_download( &self, id: &str, @@ -104,6 +122,9 @@ impl ExtensionManager { { return Ok(false); } + if self.account_download_enabled(id)? { + return Ok(false); + } let runtime = { let mut engine = self.download_engine(&entry, Some(&lease))?; if !self.current(&entry) || !entry.status.read().expect("extension status lock").enabled @@ -421,6 +442,63 @@ fn normalize_decryption(value: &mut Value) { mod tests { use super::*; + #[test] + fn account_download_mode_is_declared_and_selected_by_settings() { + let directory = tempfile::tempdir().unwrap(); + let sources = directory.path().join("sources"); + let manager = ExtensionManager::new( + &sources, + &directory.path().join("data"), + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "1", + RuntimeLimits::default(), + ) + .unwrap(); + let source = sources.join("example.account"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("manifest.json"), json!({"name":"example.account","version":"1", + "description":"Generic account mode fixture","type":["download_provider"], + "permissions":{"storage":true,"network":["accounts.example.test"]}, + "signedSession":{"namespace":"example-account","baseUrl":"https://accounts.example.test","appVersion":"1","platform":"extension", + "endpoints":{"bootstrap":"/bootstrap","challenge":"/challenge","exchange":"/exchange","refresh":"/refresh"}}, + "capabilities":{"accountDownloadMode":{"setting":"accountMode","value":"personal"}}}).to_string()).unwrap(); + fs::write( + source.join("index.js"), + "registerExtension({download(){return {success:false}}});", + ) + .unwrap(); + manager.load_all().unwrap(); + manager.set_enabled("example.account", true).unwrap(); + assert!(!manager.account_download_enabled("example.account").unwrap()); + manager + .update_settings( + "example.account", + serde_json::from_value(json!({"accountMode":"personal"})).unwrap(), + ) + .unwrap(); + assert!(manager.account_download_enabled("example.account").unwrap()); + // No signed-session service is contacted in personal account mode. + let lease = Arc::new( + manager + .environment + .download_state() + .acquire("test-account") + .unwrap(), + ); + assert!( + !manager + .preflight_download("example.account", lease) + .unwrap() + ); + manager + .update_settings( + "example.account", + serde_json::from_value(json!({"accountMode":"community"})).unwrap(), + ) + .unwrap(); + assert!(!manager.account_download_enabled("example.account").unwrap()); + } + #[test] fn returning_a_borrow_after_uninstall_cannot_recreate_extension_data() { let directory = tempfile::tempdir().unwrap(); diff --git a/rust_backend/crates/extensions/src/manifest.rs b/rust_backend/crates/extensions/src/manifest.rs index bf548da3..4675f021 100644 --- a/rust_backend/crates/extensions/src/manifest.rs +++ b/rust_backend/crates/extensions/src/manifest.rs @@ -462,6 +462,33 @@ impl ExtensionManifest { } crate::transfer_policy::validate(&self.capabilities) .map_err(|message| invalid("capabilities.downloadTransfer", message))?; + if let Some(mode) = self.capabilities.get("accountDownloadMode") { + let valid = mode.as_object().is_some_and(|mode| { + mode.get("setting") + .and_then(Value::as_str) + .is_some_and(|key| !key.is_empty() && !key.starts_with('_') && key.len() <= 64) + && mode + .get("value") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty() && value.len() <= 64) + }); + if !valid { + return Err(invalid( + "capabilities.accountDownloadMode", + "expected a setting and value", + )); + } + } + if let Some(timeout) = self.capabilities.get("actionTimeoutSeconds") + && !timeout + .as_u64() + .is_some_and(|seconds| (1..=120).contains(&seconds)) + { + return Err(invalid( + "capabilities.actionTimeoutSeconds", + "expected 1 to 120 seconds", + )); + } Ok(()) } @@ -469,6 +496,27 @@ impl ExtensionManifest { self.capabilities.get(name) == Some(&Value::Bool(true)) } + pub(crate) fn account_download_enabled(&self, settings: &Map) -> bool { + self.capabilities + .get("accountDownloadMode") + .is_some_and(|mode| { + let Some(setting) = mode["setting"].as_str() else { + return false; + }; + let Some(value) = mode["value"].as_str() else { + return false; + }; + settings.get(setting).and_then(Value::as_str) == Some(value) + }) + } + + pub(crate) fn action_timeout_ms(&self, default: u64) -> u64 { + self.capabilities + .get("actionTimeoutSeconds") + .and_then(Value::as_u64) + .map_or(default, |seconds| seconds.clamp(1, 120) * 1000) + } + pub fn find_quality(&self, requested: &str) -> Option<&QualityOption> { let requested = requested.trim(); if requested.is_empty() { diff --git a/rust_backend/crates/network/src/client.rs b/rust_backend/crates/network/src/client.rs index 54a2b39d..0abd47af 100644 --- a/rust_backend/crates/network/src/client.rs +++ b/rust_backend/crates/network/src/client.rs @@ -317,6 +317,7 @@ impl NetworkService { service: Arc::clone(self), permissions: Some(permissions), native_media: false, + follow_redirects: true, timeout, cookies: Mutex::default(), }) @@ -329,6 +330,7 @@ impl NetworkService { service: Arc::clone(self), permissions: None, native_media: false, + follow_redirects: true, timeout, cookies: Mutex::default(), }) @@ -342,6 +344,7 @@ impl NetworkService { service: Arc::clone(self), permissions: None, native_media: true, + follow_redirects: true, timeout, cookies: Mutex::default(), }) @@ -514,11 +517,25 @@ pub struct NetworkSession { service: Arc, permissions: Option, native_media: bool, + follow_redirects: bool, timeout: Duration, cookies: Mutex, } impl NetworkSession { + /// Account-owned media must not carry metadata cookies or follow a redirect + /// outside its descriptor. Keep the original manifest/DNS/TLS permissions. + pub fn direct_media(&self) -> Self { + Self { + service: Arc::clone(&self.service), + permissions: self.permissions.clone(), + native_media: true, + follow_redirects: false, + timeout: self.timeout, + cookies: Mutex::default(), + } + } + pub fn reset_connections(&self) { self.service.reset_connections(); } @@ -833,6 +850,9 @@ impl NetworkSession { .store(&url, response.headers()); } let status = response.status(); + if !self.follow_redirects && matches!(status.as_u16(), 301 | 302 | 303 | 307 | 308) { + return Err("media redirect blocked: request a fresh descriptor".into()); + } if matches!(status.as_u16(), 301 | 302 | 303 | 307 | 308) && let Some(location) = response .headers() @@ -1038,6 +1058,90 @@ mod tests { use std::future::poll_fn; use std::sync::{atomic::AtomicBool, mpsc}; + #[test] + fn direct_media_keeps_manifest_permissions_and_https_requirement() { + let service = NetworkService::new().unwrap(); + let original = service.session( + NetworkPermissions { + domains: vec!["cdn.example.test".into()], + allow_http: false, + }, + Duration::from_secs(30), + ); + let media = original.direct_media(); + assert!(media.validate_url("https://cdn.example.test/audio").is_ok()); + assert!( + media + .validate_url("https://unlisted.example.test/audio") + .is_err() + ); + assert!(media.validate_url("http://cdn.example.test/audio").is_err()); + assert!(!media.follow_redirects); + assert!(media.native_media); + } + + #[test] + fn direct_media_does_not_send_metadata_cookies_or_follow_redirects() { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + for response in [ + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok", + "HTTP/1.1 302 Found\r\nLocation: /unexpected\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ] { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + stream.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + assert!(request.len() < 8192); + } + assert!( + !String::from_utf8(request) + .unwrap() + .to_lowercase() + .contains("\r\ncookie:") + ); + stream.write_all(response.as_bytes()).unwrap(); + } + }); + let service = NetworkService::new().unwrap(); + service.set_allow_private_network(true); + let original = service.session( + NetworkPermissions { + domains: vec!["127.0.0.1".into()], + allow_http: true, + }, + Duration::from_secs(3), + ); + let mut cookies = http::HeaderMap::new(); + cookies.insert( + http::header::SET_COOKIE, + "metadata=private; Path=/".parse().unwrap(), + ); + original + .cookies + .lock() + .unwrap() + .store(&UrlParts::parse(&base).unwrap(), &cookies); + let media = original.direct_media(); + let request = + || serde_json::from_value(serde_json::json!({"url":format!("{base}/media")})).unwrap(); + assert_eq!(media.request(request(), || Ok(())).unwrap().body, b"ok"); + assert!( + media + .request(request(), || Ok(())) + .unwrap_err() + .contains("media redirect blocked") + ); + server.join().unwrap(); + } + #[test] fn cancellation_after_operation_is_pending_drops_it_before_return() { struct Dropped<'a>(&'a AtomicBool); diff --git a/test/extension_action_forms_test.dart b/test/extension_action_forms_test.dart new file mode 100644 index 00000000..5d6d52da --- /dev/null +++ b/test/extension_action_forms_test.dart @@ -0,0 +1,224 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/app_localizations.dart'; +import 'package:spotiflac_android/utils/extension_action_forms.dart'; + +void main() { + const channel = MethodChannel('com.zarz.spotiflac/backend'); + tearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + + Future showHost( + WidgetTester tester, + Future Function(BuildContext) action, + ) { + return tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => action(context), + child: const Text('Connect'), + ), + ), + ), + ), + ); + } + + testWidgets('password form submits transient input and clears its text', ( + tester, + ) async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if ((call.arguments as Map)['arguments_json'] != null) { + return '{"success":true,"state":"active"}'; + } + return '{"success":true,"action_form":{"version":1,"title":"Account login",' + '"submit_action":"loginAccount","fields":[{"key":"password",' + '"label":"Password","type":"password","required":true,"secret":true}]}}'; + }); + Map? result; + await showHost(tester, (context) async { + result = await runExtensionActionWithForms( + context, + 'example.account', + 'loginAccount', + ); + }); + await tester.tap(find.text('Connect')); + await tester.pumpAndSettle(); + final field = tester.widget(find.byType(TextFormField)); + final controller = field.controller!; + expect( + tester.widget(find.byType(TextField)).obscureText, + isTrue, + ); + await tester.enterText(find.byType(TextFormField), 'fixture-password'); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + expect(result!['state'], 'active'); + expect(controller.text, isEmpty); + expect(calls.map((call) => call.method), [ + 'invokeExtensionAction', + 'invokeExtensionAction', + ]); + expect( + (calls.last.arguments as Map)['arguments_json'], + contains('fixture-password'), + ); + }); + + testWidgets('malformed forms fail without opening a dialog', (tester) async { + for (final fields in [ + [ + {'key': 'code', 'type': 'otp', 'label': 42}, + ], + [ + { + 'key': 'region', + 'type': 'select', + 'options': ['one', 'one'], + }, + ], + [ + { + 'key': 'region', + 'type': 'select', + 'options': ['one', 42], + }, + ], + [ + {'key': 'code', 'type': 'otp'}, + {'key': 'code', 'type': 'otp'}, + ], + ]) { + var calls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls++; + return jsonEncode({ + 'success': true, + 'action_form': { + 'version': 1, + 'submit_action': 'loginAccount', + 'fields': fields, + }, + }); + }); + Map? result; + await showHost(tester, (context) async { + result = await runExtensionActionWithForms( + context, + 'example.account', + 'loginAccount', + ); + }); + await tester.tap(find.text('Connect')); + await tester.pumpAndSettle(); + expect(result!['success'], isFalse); + expect(find.byType(TextFormField), findsNothing); + expect(calls, 1); + expect(tester.takeException(), isNull); + } + }); + + testWidgets('password and OTP are sent as separate transient actions', ( + tester, + ) async { + final inputs = >[]; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + final args = call.arguments as Map; + final raw = args['arguments_json']; + if (raw != null) { + inputs.add( + Map.from( + (jsonDecode(raw as String) as List).single as Map, + ), + ); + } + if (args['action'] == 'submitCode') { + return '{"success":true,"state":"active"}'; + } + final otp = raw != null; + return jsonEncode({ + 'success': true, + 'action_form': { + 'version': 1, + 'submit_action': otp ? 'submitCode' : 'loginAccount', + 'fields': [ + { + 'key': otp ? 'code' : 'password', + 'type': otp ? 'otp' : 'password', + 'secret': true, + }, + ], + }, + }); + }); + Map? result; + await showHost(tester, (context) async { + result = await runExtensionActionWithForms( + context, + 'example.account', + 'loginAccount', + ); + }); + await tester.tap(find.text('Connect')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField), 'fixture-secret'); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(TextFormField)).controller!.text, + isEmpty, + ); + await tester.enterText(find.byType(TextFormField), 'A12345'); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + expect(inputs, [ + {'password': 'fixture-secret'}, + {'code': 'A12345'}, + ]); + expect(result!['state'], 'active'); + expect(tester.takeException(), isNull); + }); + + testWidgets('cancelled OTP form cancels auth without submitting its code', ( + tester, + ) async { + final actions = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + final args = call.arguments as Map; + actions.add(args['action'] as String); + expect(args.containsKey('arguments_json'), isFalse); + if (args['action'] == 'cancelAccountAuth') return '{"success":true}'; + return '{"success":true,"byoa_form":{"version":1,"title":"OTP",' + '"submit_action":"submitAccountOTP","cancel_action":"cancelAccountAuth",' + '"fields":[{"key":"code","label":"Code","type":"otp","secret":true}]}}'; + }); + await showHost(tester, (context) async { + await runExtensionActionWithForms( + context, + 'example.account', + 'submitAccountOTP', + ); + }); + await tester.tap(find.text('Connect')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField), '123456'); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(actions, ['submitAccountOTP', 'cancelAccountAuth']); + }); +} diff --git a/test/platform_bridge_account_action_test.dart b/test/platform_bridge_account_action_test.dart new file mode 100644 index 00000000..3b5ff18f --- /dev/null +++ b/test/platform_bridge_account_action_test.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.zarz.spotiflac/backend'); + tearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + + test( + 'account form input is passed to the action without saving settings', + () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return '{"success":true,"state":"active"}'; + }); + final result = await PlatformBridge.invokeExtensionAction( + 'example.account', + 'loginAccount', + input: { + 'username': 'fixture@example.test', + 'password': 'fixture-secret', + }, + ); + expect(result['state'], 'active'); + expect(calls, hasLength(1)); + expect(calls.single.method, 'invokeExtensionAction'); + final args = calls.single.arguments as Map; + expect(jsonDecode(args['arguments_json'] as String), [ + {'username': 'fixture@example.test', 'password': 'fixture-secret'}, + ]); + }, + ); + + test('existing actions keep their original channel payload', () async { + Map? arguments; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + arguments = call.arguments as Map; + return '{"success":true}'; + }); + await PlatformBridge.invokeExtensionAction( + 'example.account', + 'getAccountStatus', + ); + expect(arguments!.containsKey('arguments_json'), isFalse); + }); +}