From 23c662155239791a64df6439594bd0ce09676be1 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:19:36 +0700 Subject: [PATCH] refactor(bridge): move platform_bridge helpers into part files Top-level helpers and secondary classes (codec isolates, cache entries, event and SAF models, the native worker job state and the NDJSON scan file) move verbatim into part files grouped by concern. The PlatformBridge class body is unchanged apart from three region comments over groups that were already contiguous. platform_bridge.dart was one 2900-line file mixing eight concerns; the static class cannot be split without changing its type, so this moves what can move mechanically and leaves the class intact for call sites and the channel mocks in tests. --- lib/services/platform_bridge.dart | 166 ++---------------- .../platform_bridge/bridge_codec.dart | 32 ++++ .../platform_bridge/bridge_events.dart | 11 ++ .../platform_bridge/bridge_library_scan.dart | 39 ++++ .../platform_bridge/bridge_lookup_cache.dart | 41 +++++ .../platform_bridge/bridge_native_worker.dart | 6 + lib/services/platform_bridge/bridge_saf.dart | 30 ++++ 7 files changed, 172 insertions(+), 153 deletions(-) create mode 100644 lib/services/platform_bridge/bridge_codec.dart create mode 100644 lib/services/platform_bridge/bridge_events.dart create mode 100644 lib/services/platform_bridge/bridge_library_scan.dart create mode 100644 lib/services/platform_bridge/bridge_lookup_cache.dart create mode 100644 lib/services/platform_bridge/bridge_native_worker.dart create mode 100644 lib/services/platform_bridge/bridge_saf.dart 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, + }); +}