feat(extensions): support personal account forms and direct downloads

This commit is contained in:
zarzet committed 2026-10-02 19:21:58 +07:00
1 parent 253a19f17f
commit 70f8bd7f35
17 files changed
+935 -8

No files matched your search

@@ -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", "[]")))
+6 -1
View File
@@ -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)
+5
View File
@@ -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",
+5
View File
@@ -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",
@@ -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: { ... } }.
+5 -2
View File
@@ -1795,12 +1795,15 @@ class PlatformBridge {
static Future<Map<String, dynamic>> invokeExtensionAction(
String extensionId,
String actionName,
) async {
String actionName, {
Map<String, dynamic>? 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};
+279
View File
@@ -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<Map<String, dynamic>?> runExtensionActionWithForms(
BuildContext context,
String extensionId,
String action,
) async {
Map<String, dynamic>? input;
for (var step = 0; step < 8; step++) {
Map<String, dynamic> 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<Map<String, dynamic>>(
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<Map<String, dynamic>> 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 = <Map<String, dynamic>>[];
final keys = <String>{};
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<String, dynamic>.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 = <String, TextEditingController>{};
final _choices = <String, String>{};
final _formKey = GlobalKey<FormState>();
@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 = <String, dynamic>{..._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<String>(
initialValue: _choices[field['key']],
decoration: InputDecoration(
labelText: field['label'] as String?,
),
items: (field['options'] as List)
.cast<String>()
.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),
),
],
);
}
@@ -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::<Value>(&response).unwrap()["success"],
false,
"{response}"
);
assert_eq!(
backend
.call("example.fallback", "attempts", "[]", None, 1000)
.unwrap(),
"0"
);
backend.shutdown();
}
}
@@ -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;
@@ -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)
@@ -37,6 +37,9 @@ pub(crate) static SUPPORTED_RUNTIME_FEATURES: LazyLock<BTreeMap<String, isize>>
("downloadSegments", 1),
("patternedFileTransform", 1),
("preparedContext", 1),
("accountForms", 1),
("accountDownloadMode", 1),
("directMedia", 1),
]
.into_iter()
.map(|(name, version)| (name.to_owned(), version))
@@ -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))
}
@@ -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<bool, ManagerError> {
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();
@@ -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<String, Value>) -> 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() {
+104
View File
@@ -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<NetworkService>,
permissions: Option<NetworkPermissions>,
native_media: bool,
follow_redirects: bool,
timeout: Duration,
cookies: Mutex<CookieJar>,
}
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);
+224
View File
@@ -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<void> showHost(
WidgetTester tester,
Future<void> 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 = <MethodCall>[];
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<String, dynamic>? 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<TextFormField>(find.byType(TextFormField));
final controller = field.controller!;
expect(
tester.widget<TextField>(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<String, dynamic>? 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 = <Map<String, dynamic>>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
final args = call.arguments as Map;
final raw = args['arguments_json'];
if (raw != null) {
inputs.add(
Map<String, dynamic>.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<String, dynamic>? 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<TextFormField>(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 = <String>[];
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']);
});
}
@@ -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 = <MethodCall>[];
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<dynamic, dynamic>? 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);
});
}