diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 3ec423a3..a08cbd0a 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -9,6 +9,13 @@ import 'package:spotiflac_android/services/download_request_payload.dart'; import 'package:spotiflac_android/utils/audio_format_utils.dart'; import 'package:spotiflac_android/utils/logger.dart'; +part 'platform_bridge/bridge_codec.dart'; +part 'platform_bridge/bridge_events.dart'; +part 'platform_bridge/bridge_library_scan.dart'; +part 'platform_bridge/bridge_lookup_cache.dart'; +part 'platform_bridge/bridge_native_worker.dart'; +part 'platform_bridge/bridge_saf.dart'; + final _log = AppLogger('PlatformBridge'); const foregroundServiceStartNotAllowedCode = @@ -19,119 +26,6 @@ bool isForegroundServiceStartNotAllowed(Object error) { error.code == foregroundServiceStartNotAllowedCode; } -Object? _decodeJsonInBackground(String json) => jsonDecode(json); -String _encodeJsonInBackground(Object? value) => jsonEncode(value); - -class _NativeDataJobState { - bool dispatched = false; - bool cancelled = false; -} - -String _encodeBoundedLookupCacheInBackground(Map entries) { - final result = StringBuffer('{'); - var bytes = 2; // Opening and closing braces. - var count = 0; - for (final entry in entries.entries) { - final fragment = - '${_encodeJsonInBackground(entry.key)}:${_encodeJsonInBackground(entry.value)}'; - final entryBytes = utf8.encode(fragment).length; - final separatorBytes = count == 0 ? 0 : 1; - if (entryBytes > PlatformBridge._persistentCacheMaxEntryBytes || - bytes + separatorBytes + entryBytes > - PlatformBridge._persistentCacheMaxBytes) { - continue; - } - if (count > 0) result.write(','); - result.write(fragment); - bytes += separatorBytes + entryBytes; - count++; - } - result.write('}'); - return result.toString(); -} - -Object? _decodeJsonFileInBackground(String path) { - final contents = File(path).readAsStringSync(); - return contents.isEmpty ? null : jsonDecode(contents); -} - -class LibraryScanNDJSONFile { - final File file; - final int expectedCount; - final int errorCount; - - const LibraryScanNDJSONFile({ - required this.file, - required this.expectedCount, - this.errorCount = 0, - }); - - Stream> rows() async* { - await for (final line - in file - .openRead() - .transform(utf8.decoder) - .transform(const LineSplitter())) { - if (line.trim().isEmpty) continue; - final decoded = jsonDecode(line); - if (decoded is! Map) { - throw const FormatException('Library scan NDJSON row is not an object'); - } - yield Map.from(decoded); - } - } - - Future delete() async { - try { - if (await file.exists()) await file.delete(); - } catch (_) {} - // Remove the resumable scan checkpoint after ingestion. - try { - final checkpoint = File('${file.path}.state'); - if (await checkpoint.exists()) await checkpoint.delete(); - } catch (_) {} - } -} - -class ExtensionSessionGrantEvent { - final String extensionId; - final bool success; - - const ExtensionSessionGrantEvent({ - required this.extensionId, - required this.success, - }); -} - -/// Folder picked via the native iOS document picker: the filesystem path plus -/// the security-scoped bookmark that re-grants access across app launches. -class IosPickedDirectory { - final String path; - final String bookmark; - - const IosPickedDirectory({required this.path, required this.bookmark}); -} - -class IosSecurityScopedAccess { - final String path; - final String token; - - const IosSecurityScopedAccess({required this.path, required this.token}); -} - -class ContentUriPlaybackLease { - final String path; - final String token; - - final String? displayName; - - const ContentUriPlaybackLease({ - required this.path, - required this.token, - this.displayName, - }); -} - class InstallationState { final bool markerExisted; final bool markerCreated; @@ -167,46 +61,6 @@ bool shouldResetRestoredInstallation({ installState.freshPackageInstall; } -class _BridgeCacheEntry { - final Map value; - final DateTime expiresAt; - final int bytes; - - const _BridgeCacheEntry({ - required this.value, - required this.expiresAt, - required this.bytes, - }); - - bool get isExpired => DateTime.now().isAfter(expiresAt); -} - -class _BridgeListCacheEntry { - final List> value; - final DateTime expiresAt; - final int bytes; - - const _BridgeListCacheEntry({ - required this.value, - required this.expiresAt, - required this.bytes, - }); - - bool get isExpired => DateTime.now().isAfter(expiresAt); -} - -class _BridgeInFlight { - final String requestId; - final String scopeKey; - final Future future; - - const _BridgeInFlight({ - required this.requestId, - required this.scopeKey, - required this.future, - }); -} - class PlatformBridge { /// Sentinel shared with Android's foreground service for progress reports /// that contain a percentage but no byte counts. @@ -1022,6 +876,8 @@ class PlatformBridge { return result as String; } + // ---- SAF ---- + static Future?> pickSafTree({ bool forSetup = false, }) async { @@ -1262,6 +1118,8 @@ class PlatformBridge { return result as bool? ?? false; } + // ---- Media files ---- + static Future getLyricsLRC( String spotifyId, String trackName, @@ -2396,6 +2254,8 @@ class PlatformBridge { } } + // ---- Library scan ---- + static Future setLibraryCoverCacheDir(String cacheDir) async { await _channel.invokeMethod('setLibraryCoverCacheDir', { 'cache_dir': cacheDir, diff --git a/lib/services/platform_bridge/bridge_codec.dart b/lib/services/platform_bridge/bridge_codec.dart new file mode 100644 index 00000000..8734730e --- /dev/null +++ b/lib/services/platform_bridge/bridge_codec.dart @@ -0,0 +1,32 @@ +part of '../platform_bridge.dart'; + +Object? _decodeJsonInBackground(String json) => jsonDecode(json); +String _encodeJsonInBackground(Object? value) => jsonEncode(value); + +String _encodeBoundedLookupCacheInBackground(Map entries) { + final result = StringBuffer('{'); + var bytes = 2; // Opening and closing braces. + var count = 0; + for (final entry in entries.entries) { + final fragment = + '${_encodeJsonInBackground(entry.key)}:${_encodeJsonInBackground(entry.value)}'; + final entryBytes = utf8.encode(fragment).length; + final separatorBytes = count == 0 ? 0 : 1; + if (entryBytes > PlatformBridge._persistentCacheMaxEntryBytes || + bytes + separatorBytes + entryBytes > + PlatformBridge._persistentCacheMaxBytes) { + continue; + } + if (count > 0) result.write(','); + result.write(fragment); + bytes += separatorBytes + entryBytes; + count++; + } + result.write('}'); + return result.toString(); +} + +Object? _decodeJsonFileInBackground(String path) { + final contents = File(path).readAsStringSync(); + return contents.isEmpty ? null : jsonDecode(contents); +} diff --git a/lib/services/platform_bridge/bridge_events.dart b/lib/services/platform_bridge/bridge_events.dart new file mode 100644 index 00000000..89762368 --- /dev/null +++ b/lib/services/platform_bridge/bridge_events.dart @@ -0,0 +1,11 @@ +part of '../platform_bridge.dart'; + +class ExtensionSessionGrantEvent { + final String extensionId; + final bool success; + + const ExtensionSessionGrantEvent({ + required this.extensionId, + required this.success, + }); +} diff --git a/lib/services/platform_bridge/bridge_library_scan.dart b/lib/services/platform_bridge/bridge_library_scan.dart new file mode 100644 index 00000000..69fd49f9 --- /dev/null +++ b/lib/services/platform_bridge/bridge_library_scan.dart @@ -0,0 +1,39 @@ +part of '../platform_bridge.dart'; + +class LibraryScanNDJSONFile { + final File file; + final int expectedCount; + final int errorCount; + + const LibraryScanNDJSONFile({ + required this.file, + required this.expectedCount, + this.errorCount = 0, + }); + + Stream> rows() async* { + await for (final line + in file + .openRead() + .transform(utf8.decoder) + .transform(const LineSplitter())) { + if (line.trim().isEmpty) continue; + final decoded = jsonDecode(line); + if (decoded is! Map) { + throw const FormatException('Library scan NDJSON row is not an object'); + } + yield Map.from(decoded); + } + } + + Future delete() async { + try { + if (await file.exists()) await file.delete(); + } catch (_) {} + // Remove the resumable scan checkpoint after ingestion. + try { + final checkpoint = File('${file.path}.state'); + if (await checkpoint.exists()) await checkpoint.delete(); + } catch (_) {} + } +} diff --git a/lib/services/platform_bridge/bridge_lookup_cache.dart b/lib/services/platform_bridge/bridge_lookup_cache.dart new file mode 100644 index 00000000..7d694484 --- /dev/null +++ b/lib/services/platform_bridge/bridge_lookup_cache.dart @@ -0,0 +1,41 @@ +part of '../platform_bridge.dart'; + +class _BridgeCacheEntry { + final Map value; + final DateTime expiresAt; + final int bytes; + + const _BridgeCacheEntry({ + required this.value, + required this.expiresAt, + required this.bytes, + }); + + bool get isExpired => DateTime.now().isAfter(expiresAt); +} + +class _BridgeListCacheEntry { + final List> value; + final DateTime expiresAt; + final int bytes; + + const _BridgeListCacheEntry({ + required this.value, + required this.expiresAt, + required this.bytes, + }); + + bool get isExpired => DateTime.now().isAfter(expiresAt); +} + +class _BridgeInFlight { + final String requestId; + final String scopeKey; + final Future future; + + const _BridgeInFlight({ + required this.requestId, + required this.scopeKey, + required this.future, + }); +} diff --git a/lib/services/platform_bridge/bridge_native_worker.dart b/lib/services/platform_bridge/bridge_native_worker.dart new file mode 100644 index 00000000..c17b4224 --- /dev/null +++ b/lib/services/platform_bridge/bridge_native_worker.dart @@ -0,0 +1,6 @@ +part of '../platform_bridge.dart'; + +class _NativeDataJobState { + bool dispatched = false; + bool cancelled = false; +} diff --git a/lib/services/platform_bridge/bridge_saf.dart b/lib/services/platform_bridge/bridge_saf.dart new file mode 100644 index 00000000..50c538b1 --- /dev/null +++ b/lib/services/platform_bridge/bridge_saf.dart @@ -0,0 +1,30 @@ +part of '../platform_bridge.dart'; + +/// Folder picked via the native iOS document picker: the filesystem path plus +/// the security-scoped bookmark that re-grants access across app launches. +class IosPickedDirectory { + final String path; + final String bookmark; + + const IosPickedDirectory({required this.path, required this.bookmark}); +} + +class IosSecurityScopedAccess { + final String path; + final String token; + + const IosSecurityScopedAccess({required this.path, required this.token}); +} + +class ContentUriPlaybackLease { + final String path; + final String token; + + final String? displayName; + + const ContentUriPlaybackLease({ + required this.path, + required this.token, + this.displayName, + }); +}