mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
feat(extensions): support personal account forms and direct downloads
This commit is contained in:
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", "[]")))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: { ... } }.
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user