mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 12:52:13 +02:00
Replace the Go backend with the Rust workspace and route Android/iOS through UniFFI bindings. Include the migrated extension runtime, network, providers, media metadata, downloads and library operations, with version 5.0.0+147. Remove Go sources, adapters, native build selection and CI dependencies. Build Rust unconditionally and lock the iOS Rust pod using a relative path. Retain legacy source and migration evidence in a local ignored archive. Validation: Android Kotlin compile and 53 native tests; Swift Rust-branch equivalence and syntax; CocoaPods install; workflow, shell, Ruby, plist and diff checks. Reuse the preceding 100 Rust tests, fmt/Clippy and five-ABI build checkpoint; no new APK or iOS application build for this cleanup. Known follow-up: URL/URLSearchParams globals are missing from the Rust JS runtime. A controlled extension replay confirms a URL-resolution regression; this commit does not fix that runtime gap.
529 lines
18 KiB
Dart
529 lines
18 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:spotiflac_android/models/settings.dart';
|
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
|
import 'package:spotiflac_android/providers/repo_provider.dart';
|
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
|
import 'package:spotiflac_android/services/extension_storage_service.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
|
|
class _DelayedSettingsNotifier extends SettingsNotifier {
|
|
final _ready = Completer<void>();
|
|
int _loads = 0;
|
|
|
|
@override
|
|
AppSettings build() => const AppSettings();
|
|
|
|
@override
|
|
Future<void> ensureLoaded() async {
|
|
_loads++;
|
|
await _ready.future;
|
|
}
|
|
|
|
void complete() {
|
|
if (!_ready.isCompleted) _ready.complete();
|
|
}
|
|
}
|
|
|
|
class _RetryingSettingsNotifier extends SettingsNotifier {
|
|
int _syncAttempts = 0;
|
|
|
|
@override
|
|
AppSettings build() => const AppSettings();
|
|
|
|
@override
|
|
Future<void> syncLyricsSettingsToBackend({AppSettings? settings}) async {
|
|
_syncAttempts++;
|
|
if (_syncAttempts == 1) {
|
|
throw StateError('Settings load failed');
|
|
}
|
|
}
|
|
}
|
|
|
|
class _FailingSettingsSyncNotifier extends SettingsNotifier {
|
|
int _syncs = 0;
|
|
|
|
@override
|
|
Future<void> syncLyricsSettingsToBackend({AppSettings? settings}) async {
|
|
if (++_syncs == 1) throw StateError('Settings sync unavailable');
|
|
await super.syncLyricsSettingsToBackend(settings: settings);
|
|
}
|
|
}
|
|
|
|
class _DelayedExtensionNotifier extends ExtensionNotifier {
|
|
final _started = Completer<void>();
|
|
final _ready = Completer<void>();
|
|
int _initializations = 0;
|
|
bool _failInitialization = false;
|
|
|
|
@override
|
|
Future<void> initialize(
|
|
String extensionsDir,
|
|
String dataDir, {
|
|
required String masterKey,
|
|
}) async {
|
|
_initializations++;
|
|
expect(await Directory(extensionsDir).exists(), isTrue);
|
|
expect(await Directory(dataDir).exists(), isTrue);
|
|
expect(base64Decode(masterKey), hasLength(32));
|
|
if (!_started.isCompleted) _started.complete();
|
|
await _ready.future;
|
|
state = state.copyWith(
|
|
isInitialized: !_failInitialization,
|
|
error: _failInitialization ? 'Native initialization failed' : null,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> refreshExtensions() async {}
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
const paths = MethodChannel('plugins.flutter.io/path_provider');
|
|
const secure = MethodChannel('plugins.it_nomads.com/flutter_secure_storage');
|
|
const backend = MethodChannel('com.zarz.spotiflac/backend');
|
|
late Directory root;
|
|
final messenger =
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
|
|
|
setUpAll(() async {
|
|
root = await Directory.systemTemp.createTemp('extension-initialization-');
|
|
messenger.setMockMethodCallHandler(paths, (call) async {
|
|
return call.method == 'getApplicationSupportDirectory'
|
|
? '${root.path}/support'
|
|
: '${root.path}/documents';
|
|
});
|
|
messenger.setMockMethodCallHandler(secure, (call) async {
|
|
if (call.method == 'read') return base64Encode(List.filled(32, 1));
|
|
return null;
|
|
});
|
|
});
|
|
tearDownAll(() async {
|
|
messenger.setMockMethodCallHandler(paths, null);
|
|
messenger.setMockMethodCallHandler(secure, null);
|
|
messenger.setMockMethodCallHandler(backend, null);
|
|
await root.delete(recursive: true);
|
|
});
|
|
|
|
test(
|
|
'storage preparation retries a transient failure and coalesces callers',
|
|
() async {
|
|
var supportReads = 0;
|
|
messenger.setMockMethodCallHandler(paths, (call) async {
|
|
if (call.method == 'getApplicationSupportDirectory') {
|
|
supportReads++;
|
|
if (supportReads == 1) {
|
|
throw PlatformException(code: 'temporarily_unavailable');
|
|
}
|
|
return '${root.path}/support';
|
|
}
|
|
return '${root.path}/documents';
|
|
});
|
|
await expectLater(
|
|
ExtensionStorageService.prepare(),
|
|
throwsA(isA<PlatformException>()),
|
|
);
|
|
final first = ExtensionStorageService.prepare();
|
|
final second = ExtensionStorageService.prepare();
|
|
expect(identical(first, second), isTrue);
|
|
final storage = await first;
|
|
expect(supportReads, 2);
|
|
expect(storage.extensionsDir, '${root.path}/support/extensions');
|
|
expect(storage.dataDir, '${root.path}/support/extension_data');
|
|
},
|
|
);
|
|
|
|
test(
|
|
'delayed settings load gates startup, install, upgrade, and repository init',
|
|
() async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final settings = _DelayedSettingsNotifier();
|
|
final notifier = _DelayedExtensionNotifier();
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
extensionProvider.overrideWith(() => notifier),
|
|
settingsProvider.overrideWith(() => settings),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
container.read(extensionProvider);
|
|
final calls = <String>[];
|
|
messenger.setMockMethodCallHandler(backend, (call) async {
|
|
calls.add(call.method);
|
|
return {
|
|
'name': 'example-extension',
|
|
'display_name': 'Example',
|
|
'version': '1.0.0',
|
|
};
|
|
});
|
|
|
|
final startup = notifier.ensureInitialized();
|
|
final install = notifier.installExtension(
|
|
'/cache/example-extension.sflx',
|
|
);
|
|
final upgrade = notifier.upgradeExtension(
|
|
'/cache/example-extension.sflx',
|
|
);
|
|
final repository = container
|
|
.read(repoProvider.notifier)
|
|
.initialize('/cache/store');
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(settings._loads, 1);
|
|
expect(notifier._initializations, 0);
|
|
expect(notifier._started.isCompleted, isFalse);
|
|
expect(calls, isEmpty);
|
|
|
|
settings.complete();
|
|
await notifier._started.future;
|
|
expect(notifier._initializations, 1);
|
|
expect(calls, isEmpty);
|
|
|
|
notifier._ready.complete();
|
|
await startup;
|
|
expect(await install, isTrue);
|
|
expect(await upgrade, isTrue);
|
|
await repository;
|
|
expect(container.read(repoProvider).isInitialized, isTrue);
|
|
expect(settings._loads, 1);
|
|
expect(notifier._initializations, 1);
|
|
expect(
|
|
calls,
|
|
containsAll([
|
|
'loadExtensionFromPath',
|
|
'upgradeExtension',
|
|
'initExtensionRepo',
|
|
]),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('startup, install and upgrade wait for one initialization', () async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final notifier = _DelayedExtensionNotifier();
|
|
final container = ProviderContainer(
|
|
overrides: [extensionProvider.overrideWith(() => notifier)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
container.read(extensionProvider);
|
|
final calls = <String>[];
|
|
messenger.setMockMethodCallHandler(backend, (call) async {
|
|
if (call.method == 'setLoggingEnabled') return null;
|
|
expect(container.read(extensionProvider).isInitialized, isTrue);
|
|
calls.add(call.method);
|
|
return {
|
|
'name': 'example-extension',
|
|
'display_name': 'Example',
|
|
'version': '1.0.0',
|
|
};
|
|
});
|
|
|
|
final startup = notifier.ensureInitialized();
|
|
await notifier._started.future;
|
|
final install = notifier.installExtension('/cache/example-extension.sflx');
|
|
final batch = notifier.installExtensions(['/cache/example-b.sflx']);
|
|
final check = notifier.checkExtensionUpgrade(
|
|
'/cache/example-extension.sflx',
|
|
);
|
|
final upgrade = notifier.upgradeExtension('/cache/example-extension.sflx');
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(calls, isEmpty);
|
|
notifier._ready.complete();
|
|
await startup;
|
|
expect(await install, isTrue);
|
|
expect((await batch).installed, 1);
|
|
expect((await check)['error'], isNull);
|
|
expect(await upgrade, isTrue);
|
|
expect(notifier._initializations, 1);
|
|
expect(
|
|
calls,
|
|
containsAll([
|
|
'loadExtensionFromPath',
|
|
'checkExtensionUpgrade',
|
|
'upgradeExtension',
|
|
]),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'failed initialization prevents installation and can be retried',
|
|
() async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final notifier = _DelayedExtensionNotifier().._failInitialization = true;
|
|
notifier._ready.complete();
|
|
final container = ProviderContainer(
|
|
overrides: [extensionProvider.overrideWith(() => notifier)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
container.read(extensionProvider);
|
|
var installs = 0;
|
|
messenger.setMockMethodCallHandler(backend, (call) async {
|
|
if (call.method == 'setLoggingEnabled') return null;
|
|
expect(call.method, 'loadExtensionFromPath');
|
|
installs++;
|
|
return {'name': 'example-extension'};
|
|
});
|
|
expect(await notifier.installExtension('/cache/example.sflx'), isFalse);
|
|
expect(installs, 0);
|
|
expect(
|
|
container.read(extensionProvider).error,
|
|
contains('Native initialization failed'),
|
|
);
|
|
notifier._failInitialization = false;
|
|
expect(await notifier.installExtension('/cache/example.sflx'), isTrue);
|
|
expect(installs, 1);
|
|
expect(notifier._initializations, 2);
|
|
},
|
|
);
|
|
|
|
test('repository waits for a successful owner and retries failure', () async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final notifier = _DelayedExtensionNotifier().._failInitialization = true;
|
|
notifier._ready.complete();
|
|
final container = ProviderContainer(
|
|
overrides: [extensionProvider.overrideWith(() => notifier)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
var repositoryInitializations = 0;
|
|
messenger.setMockMethodCallHandler(backend, (call) async {
|
|
if (call.method == 'setLoggingEnabled') return null;
|
|
expect(call.method, 'initExtensionRepo');
|
|
expect(container.read(extensionProvider).isInitialized, isTrue);
|
|
repositoryInitializations++;
|
|
return null;
|
|
});
|
|
final repository = container.read(repoProvider.notifier);
|
|
await repository.initialize('/cache/store');
|
|
expect(repositoryInitializations, 0);
|
|
expect(container.read(repoProvider).isInitialized, isFalse);
|
|
expect(
|
|
container.read(repoProvider).error,
|
|
contains('Native initialization failed'),
|
|
);
|
|
notifier._failInitialization = false;
|
|
await repository.initialize('/cache/store');
|
|
expect(repositoryInitializations, 1);
|
|
expect(container.read(repoProvider).isInitialized, isTrue);
|
|
expect(container.read(repoProvider).error, isNull);
|
|
});
|
|
|
|
for (final failureMethod in const [
|
|
'loadExtensionsFromDir',
|
|
'getInstalledExtensions',
|
|
]) {
|
|
test('extension refresh failure remains visible and can be retried '
|
|
'($failureMethod)', () async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final container = ProviderContainer();
|
|
addTearDown(container.dispose);
|
|
final notifier = container.read(extensionProvider.notifier);
|
|
final extensionsDir = Directory('${root.path}/production-$failureMethod');
|
|
await extensionsDir.create(recursive: true);
|
|
|
|
var targetAttempts = 0;
|
|
var loadAttempts = 0;
|
|
var refreshAttempts = 0;
|
|
final expectedError = failureMethod == 'loadExtensionsFromDir'
|
|
? 'transient_load'
|
|
: 'transient_refresh';
|
|
messenger.setMockMethodCallHandler(backend, (call) async {
|
|
switch (call.method) {
|
|
case 'loadExtensionsFromDir':
|
|
loadAttempts++;
|
|
if (failureMethod == call.method && ++targetAttempts == 1) {
|
|
throw PlatformException(code: 'transient_load');
|
|
}
|
|
return {'loaded': <String>[], 'errors': <Map<String, dynamic>>[]};
|
|
case 'getInstalledExtensions':
|
|
refreshAttempts++;
|
|
if (failureMethod == call.method && ++targetAttempts == 1) {
|
|
throw PlatformException(code: 'transient_refresh');
|
|
}
|
|
return <Map<String, dynamic>>[];
|
|
default:
|
|
throw StateError('Unexpected backend method: ${call.method}');
|
|
}
|
|
});
|
|
|
|
await notifier.loadExtensions(extensionsDir.path);
|
|
expect(notifier.state.isInitialized, isFalse);
|
|
expect(notifier.state.error, contains(expectedError));
|
|
expect(targetAttempts, 1);
|
|
|
|
await notifier.loadExtensions(extensionsDir.path);
|
|
expect(notifier.state.isInitialized, isFalse);
|
|
expect(notifier.state.error, isNull);
|
|
expect(loadAttempts, 2);
|
|
expect(
|
|
refreshAttempts,
|
|
failureMethod == 'getInstalledExtensions' ? 2 : 1,
|
|
);
|
|
expect(targetAttempts, 2);
|
|
});
|
|
}
|
|
|
|
test(
|
|
'settings load failure prevents initialization and a retry works',
|
|
() async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final settings = _RetryingSettingsNotifier();
|
|
final notifier = _DelayedExtensionNotifier().._ready.complete();
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
extensionProvider.overrideWith(() => notifier),
|
|
settingsProvider.overrideWith(() => settings),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
container.read(extensionProvider);
|
|
var installs = 0;
|
|
messenger.setMockMethodCallHandler(backend, (call) async {
|
|
if (call.method == 'loadExtensionFromPath') installs++;
|
|
return {'name': 'example-extension'};
|
|
});
|
|
|
|
await expectLater(
|
|
notifier.ensureInitialized(),
|
|
throwsA(
|
|
isA<StateError>().having(
|
|
(error) => error.message,
|
|
'message',
|
|
'Settings load failed',
|
|
),
|
|
),
|
|
);
|
|
expect(settings._syncAttempts, 1);
|
|
expect(notifier._initializations, 0);
|
|
expect(container.read(extensionProvider).isInitialized, isFalse);
|
|
expect(installs, 0);
|
|
|
|
expect(
|
|
await notifier.installExtension('/cache/example-extension.sflx'),
|
|
isTrue,
|
|
);
|
|
expect(settings._syncAttempts, 2);
|
|
expect(notifier._initializations, 1);
|
|
expect(installs, 1);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'SettingsNotifier coalesces load and restores persisted lyrics settings',
|
|
() async {
|
|
const providers = [
|
|
'extension:example.provider.primary',
|
|
'extension:example.provider.fallback',
|
|
];
|
|
const persisted = AppSettings(
|
|
lyricsProviders: providers,
|
|
lyricsIncludeTranslationNetease: true,
|
|
lyricsIncludeRomanizationNetease: true,
|
|
lyricsMultiPersonWordByWord: true,
|
|
lyricsAppleElrcWordSync: true,
|
|
musixmatchLanguage: 'id',
|
|
);
|
|
SharedPreferences.setMockInitialValues({
|
|
'app_settings': jsonEncode(persisted.toJson()),
|
|
});
|
|
final container = ProviderContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
final notifier = container.read(settingsProvider.notifier);
|
|
final first = notifier.ensureLoaded();
|
|
final second = notifier.ensureLoaded();
|
|
expect(identical(first, second), isTrue);
|
|
|
|
await first;
|
|
final loaded = container.read(settingsProvider);
|
|
expect(loaded.lyricsProviders, providers);
|
|
expect(loaded.lyricsIncludeTranslationNetease, isTrue);
|
|
expect(loaded.lyricsIncludeRomanizationNetease, isTrue);
|
|
expect(loaded.lyricsMultiPersonWordByWord, isTrue);
|
|
expect(loaded.lyricsAppleElrcWordSync, isTrue);
|
|
expect(loaded.musixmatchLanguage, 'id');
|
|
expect(loaded.lyricsFetchOptions, {
|
|
'include_translation_netease': true,
|
|
'include_romanization_netease': true,
|
|
'multi_person_word_by_word': true,
|
|
'apple_elrc_word_sync': true,
|
|
'musixmatch_language': 'id',
|
|
});
|
|
},
|
|
);
|
|
|
|
test('real settings loader retries a failed shared future', () async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final notifier = _FailingSettingsSyncNotifier();
|
|
final container = ProviderContainer(
|
|
overrides: [settingsProvider.overrideWith(() => notifier)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
container.read(settingsProvider);
|
|
final failed = notifier.ensureLoaded();
|
|
expect(identical(failed, notifier.ensureLoaded()), isTrue);
|
|
await expectLater(
|
|
failed,
|
|
throwsA(
|
|
isA<StateError>().having(
|
|
(error) => error.message,
|
|
'message',
|
|
'Settings sync unavailable',
|
|
),
|
|
),
|
|
);
|
|
final retry = notifier.ensureLoaded();
|
|
expect(identical(failed, retry), isFalse);
|
|
expect(identical(retry, notifier.ensureLoaded()), isTrue);
|
|
await retry;
|
|
expect(notifier._syncs, 2);
|
|
expect(identical(retry, notifier.ensureLoaded()), isTrue);
|
|
});
|
|
|
|
test(
|
|
'init channel encodes nondefault lyrics providers and options',
|
|
() async {
|
|
late Map<String, dynamic> arguments;
|
|
messenger.setMockMethodCallHandler(backend, (call) async {
|
|
expect(call.method, 'initExtensionSystem');
|
|
arguments = Map<String, dynamic>.from(call.arguments as Map);
|
|
return null;
|
|
});
|
|
const providers = [
|
|
'extension:example.provider.primary',
|
|
'extension:example.provider.fallback',
|
|
];
|
|
const options = <String, dynamic>{
|
|
'include_translation_netease': true,
|
|
'musixmatch_language': 'id',
|
|
};
|
|
final masterKey = base64Encode(List.filled(32, 7));
|
|
|
|
await PlatformBridge.initExtensionSystem(
|
|
'/tmp/example-extensions',
|
|
'/tmp/example-extension-data',
|
|
masterKey: masterKey,
|
|
lyricsProviders: providers,
|
|
lyricsFetchOptions: options,
|
|
allowedDirectories: const ['/tmp/example-output'],
|
|
);
|
|
|
|
expect(arguments['extensions_dir'], '/tmp/example-extensions');
|
|
expect(arguments['data_dir'], '/tmp/example-extension-data');
|
|
expect(arguments['master_key'], masterKey);
|
|
expect(arguments['allowed_directories'], ['/tmp/example-output']);
|
|
expect(
|
|
jsonDecode(arguments['lyrics_providers_json'] as String),
|
|
providers,
|
|
);
|
|
expect(jsonDecode(arguments['lyrics_options_json'] as String), options);
|
|
},
|
|
);
|
|
}
|