diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index be55ed7d..037701a3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -126,6 +126,7 @@ dependencies { implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0") implementation("androidx.documentfile:documentfile:1.1.0") implementation("androidx.activity:activity-ktx:1.13.0") + implementation("com.google.android.gms:play-services-auth-blockstore:16.4.0") // NativeDownloadFinalizer imports FFmpegKit APIs directly. The Flutter // plugin owns the runtime AAR; compileOnly avoids packaging it twice here. diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 0dd78b8c..8a5d0c70 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -9,6 +9,10 @@ import android.os.Bundle import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile +import com.google.android.gms.auth.blockstore.Blockstore +import com.google.android.gms.auth.blockstore.RetrieveBytesRequest +import com.google.android.gms.auth.blockstore.StoreBytesData +import com.google.android.gms.tasks.Tasks import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode import io.flutter.embedding.android.FlutterFragment @@ -35,6 +39,7 @@ import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.security.MessageDigest +import java.security.SecureRandom import java.util.Locale class MainActivity: FlutterFragmentActivity() { @@ -578,6 +583,104 @@ class MainActivity: FlutterFragmentActivity() { ) } + private fun prepareRuntimeState(extensionDataDir: String): String { + val storeKey = "com.zarz.spotiflac.rs.v1" + val pattern = Regex("^[0-9a-f]{32}$") + fun normalize(value: String?): String? { + val normalized = value?.trim()?.lowercase().orEmpty() + return normalized.takeIf(pattern::matches) + } + fun ByteArray.hex(): String = buildString(size * 2) { + for (byte in this@hex) { + append(((byte.toInt() ushr 4) and 0x0f).toString(16)) + append((byte.toInt() and 0x0f).toString(16)) + } + } + + val client = Blockstore.getClient(this) + val restored = try { + val request = RetrieveBytesRequest.Builder() + .setKeys(listOf(storeKey)) + .build() + val response = Tasks.await(client.retrieveBytes(request)) + val bytes = response.blockstoreDataMap[storeKey]?.bytes + if (bytes == null || bytes.isEmpty()) { + null + } else { + JSONObject(bytes.toString(Charsets.UTF_8)) + } + } catch (error: Exception) { + android.util.Log.w("SpotiFLAC", "Runtime restore unavailable: ${error.message}") + null + } + + val values = LinkedHashMap() + restored?.optJSONObject("s")?.let { entries -> + val keys = entries.keys() + while (keys.hasNext()) { + val rawKey = keys.next() + val key = File(rawKey).name + val value = normalize(entries.optString(rawKey)) ?: continue + if (key.isNotBlank()) values[key] = value + } + } + File(extensionDataDir, "signed_sessions") + .listFiles { file -> file.isFile && file.name.endsWith(".json") } + ?.forEach { record -> + try { + if (record.length() <= 64 * 1024L) { + normalize( + JSONObject(record.readText()).optString("install_id"), + )?.let { values[record.name] = it } + } + } catch (_: Exception) { + // Invalid records are handled by the normal session loader. + } + } + + val defaultValue = normalize(restored?.optString("d")) ?: run { + val androidID = android.provider.Settings.Secure.getString( + contentResolver, + android.provider.Settings.Secure.ANDROID_ID, + )?.trim().orEmpty() + val domain = "com.zarz.spotiflac/runtime/v1:" + val source = if (androidID.isNotEmpty()) { + domain + androidID + } else { + val bytes = ByteArray(16) + SecureRandom().nextBytes(bytes) + domain + bytes.hex() + } + MessageDigest.getInstance("SHA-256") + .digest(source.toByteArray(Charsets.UTF_8)) + .copyOf(16) + .hex() + } + val entries = JSONObject() + for ((key, value) in values.toSortedMap()) entries.put(key, value) + val payload = JSONObject() + .put("v", 1) + .put("d", defaultValue) + .put("s", entries) + .toString() + + try { + val builder = StoreBytesData.Builder() + .setBytes(payload.toByteArray(Charsets.UTF_8)) + .setKey(storeKey) + val encrypted = try { + Tasks.await(client.isEndToEndEncryptionAvailable) + } catch (_: Exception) { + false + } + builder.setShouldBackupToCloud(encrypted) + Tasks.await(client.storeBytes(builder.build())) + } catch (error: Exception) { + android.util.Log.w("SpotiFLAC", "Runtime save unavailable: ${error.message}") + } + return payload + } + override fun onCreate(savedInstanceState: Bundle?) { // Ensure the shared audio_service engine exists before the activity // delegate looks it up by cached id (see getCachedEngineId above). @@ -771,6 +874,18 @@ class MainActivity: FlutterFragmentActivity() { } result.success(installState) } + "prepareRuntimeState" -> { + val dataDir = call.argument("data_dir") ?: "" + val runtimeState = withContext(Dispatchers.IO) { + require(dataDir.isNotBlank()) { + "Extension data directory is required" + } + val payload = prepareRuntimeState(dataDir) + Gobackend.setRuntimeState(payload) + mapOf("ready" to true) + } + result.success(runtimeState) + } "exitApp" -> { flutterBackCallback?.isEnabled = false if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { diff --git a/go_backend/extension_signed_session.go b/go_backend/extension_signed_session.go index 90b0e393..b980794c 100644 --- a/go_backend/extension_signed_session.go +++ b/go_backend/extension_signed_session.go @@ -15,6 +15,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strconv" "strings" "sync" @@ -42,6 +43,59 @@ var ( signedSessionRequestNow = time.Now ) +var sessionHintPattern = regexp.MustCompile(`^[0-9a-f]{32}$`) + +type signedSessionHints struct { + Default string `json:"d"` + Values map[string]string `json:"s"` +} + +var signedSessionHintState = struct { + sync.RWMutex + state signedSessionHints +}{ + state: signedSessionHints{Values: map[string]string{}}, +} + +func SetRuntimeState(raw string) { + next := signedSessionHints{Values: map[string]string{}} + if err := json.Unmarshal([]byte(raw), &next); err != nil { + next = signedSessionHints{Values: map[string]string{}} + } + next.Default = normalizeSessionHint(next.Default) + values := make(map[string]string, len(next.Values)) + for key, value := range next.Values { + key = filepath.Base(strings.TrimSpace(key)) + value = normalizeSessionHint(value) + if key != "" && key != "." && value != "" { + values[key] = value + } + } + next.Values = values + + signedSessionHintState.Lock() + signedSessionHintState.state = next + signedSessionHintState.Unlock() +} + +func normalizeSessionHint(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if !sessionHintPattern.MatchString(value) { + return "" + } + return value +} + +func signedSessionHintFor(path string) string { + key := filepath.Base(strings.TrimSpace(path)) + signedSessionHintState.RLock() + defer signedSessionHintState.RUnlock() + if value := signedSessionHintState.state.Values[key]; value != "" { + return value + } + return signedSessionHintState.state.Default +} + // signedSessionCoordinator serializes authentication state for every runtime // that shares one persisted signed-session file. Parallel downloads use // isolated extension runtimes, so a runtime-local mutex cannot prevent two @@ -224,7 +278,10 @@ func (r *extensionRuntime) loadSignedSession(config SignedSessionConfig) (*signe } changed := false if strings.TrimSpace(record.InstallID) == "" { - record.InstallID = randomHex(16) + record.InstallID = signedSessionHintFor(path) + if record.InstallID == "" { + record.InstallID = randomHex(16) + } changed = true } if normalizeSignedSessionRecordScope(config, record) { diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index cd2c8b0e..ccc65b8b 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -93,6 +93,13 @@ class ExtensionNotifier extends Notifier { } try { + if (Platform.isAndroid) { + try { + await PlatformBridge.prepareRuntimeState(dataDir); + } catch (e) { + _log.w('Runtime state restore unavailable: $e'); + } + } await PlatformBridge.initExtensionSystem(extensionsDir, dataDir); await loadExtensions(extensionsDir); await loadProviderPriority(); diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 1358fecf..73e38bf1 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -39,6 +39,7 @@ import 'package:spotiflac_android/utils/user_facing_error.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/re_enrich_release_policy.dart'; +import 'package:spotiflac_android/utils/saf_display_path.dart'; import 'package:spotiflac_android/theme/cover_palette.dart' show HeaderPalette; import 'package:spotiflac_android/widgets/album_detail_header.dart' show HeaderMetaRow, HeaderMetaItem; diff --git a/lib/screens/track_metadata_screen_display.dart b/lib/screens/track_metadata_screen_display.dart index b7b984eb..5e6fd76d 100644 --- a/lib/screens/track_metadata_screen_display.dart +++ b/lib/screens/track_metadata_screen_display.dart @@ -182,46 +182,22 @@ extension _TrackMetadataDisplay on _TrackMetadataScreenState { } String _formatPathForDisplay(String pathOrUri) { - if (pathOrUri.isEmpty || !pathOrUri.startsWith('content://')) { - return pathOrUri; + if (_isLocalItem || !pathOrUri.startsWith('content://')) { + return formatSafUriForDisplay(pathOrUri); } - try { - final uri = Uri.parse(pathOrUri); - final segments = uri.pathSegments; - String? documentId; - - final documentIndex = segments.indexOf('document'); - if (documentIndex != -1 && documentIndex + 1 < segments.length) { - documentId = Uri.decodeComponent(segments[documentIndex + 1]); - } - - if (documentId == null || documentId.isEmpty) { - final treeIndex = segments.indexOf('tree'); - if (treeIndex != -1 && treeIndex + 1 < segments.length) { - documentId = Uri.decodeComponent(segments[treeIndex + 1]); - } - } - - if (documentId == null || documentId.isEmpty) return pathOrUri; - - final separatorIndex = documentId.indexOf(':'); - if (separatorIndex <= 0) return documentId; - - final volumeId = documentId.substring(0, separatorIndex); - final relativePath = documentId - .substring(separatorIndex + 1) - .replaceAll('\\', '/'); - - if (volumeId.toLowerCase() == 'primary') { - if (relativePath.isEmpty) return '/storage/emulated/0'; - return '/storage/emulated/0/$relativePath'; - } - - if (relativePath.isEmpty) return volumeId; - return 'SD Card/$relativePath'; - } catch (_) { - return pathOrUri; - } + final item = _downloadItem!; + final settings = ref.read(settingsProvider); + final sameSelectedTree = + item.downloadTreeUri != null && + item.downloadTreeUri!.isNotEmpty && + item.downloadTreeUri == settings.downloadTreeUri; + return buildSafFileDisplayPath( + pathOrUri: pathOrUri, + treeUri: item.downloadTreeUri, + treeDisplayPath: sameSelectedTree ? settings.downloadDirectory : null, + relativeDir: item.safRelativeDir, + fileName: item.safFileName, + ); } } diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 6747636b..de6a9e14 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -230,6 +230,11 @@ class PlatformBridge { return InstallationState.fromMap(result); } + static Future prepareRuntimeState(String dataDir) async { + if (!Platform.isAndroid) return; + await _invokeMap('prepareRuntimeState', {'data_dir': dataDir}); + } + static Future> _cachedInvoke( String cacheKey, Map cache, diff --git a/lib/utils/saf_display_path.dart b/lib/utils/saf_display_path.dart new file mode 100644 index 00000000..92136d96 --- /dev/null +++ b/lib/utils/saf_display_path.dart @@ -0,0 +1,98 @@ +String formatSafUriForDisplay(String pathOrUri) { + if (pathOrUri.isEmpty || !pathOrUri.startsWith('content://')) { + return pathOrUri; + } + + try { + final uri = Uri.parse(pathOrUri); + final documentId = _safDocumentId(uri); + if (documentId == null || documentId.isEmpty) return pathOrUri; + + final separatorIndex = documentId.indexOf(':'); + if (separatorIndex <= 0) return pathOrUri; + + final volumeId = documentId.substring(0, separatorIndex); + final relativePath = documentId + .substring(separatorIndex + 1) + .replaceAll('\\', '/'); + + if (volumeId.toLowerCase() == 'primary') { + return relativePath.isEmpty + ? '/storage/emulated/0' + : '/storage/emulated/0/$relativePath'; + } + + // Media/document providers use opaque IDs such as audio:12345 or + // msf:100001. Presenting those as an SD-card path is misleading. + if (!_looksLikeStorageVolumeId(volumeId)) return pathOrUri; + return relativePath.isEmpty ? 'SD Card' : 'SD Card/$relativePath'; + } catch (_) { + return pathOrUri; + } +} + +String buildSafFileDisplayPath({ + required String pathOrUri, + String? treeUri, + String? treeDisplayPath, + String? relativeDir, + String? fileName, +}) { + if (!pathOrUri.startsWith('content://')) return pathOrUri; + + final displayRoot = + _friendlyDisplayRoot(treeDisplayPath) ?? + _friendlyDisplayRoot(formatSafUriForDisplay(treeUri?.trim() ?? '')); + final cleanRelativeDir = _cleanDisplaySegment(relativeDir); + final cleanFileName = _cleanDisplaySegment(fileName); + + if (displayRoot != null) { + return [ + displayRoot.replaceAll(RegExp(r'/+$'), ''), + if (cleanRelativeDir != null) cleanRelativeDir, + if (cleanFileName != null) cleanFileName, + ].join('/'); + } + + return formatSafUriForDisplay(pathOrUri); +} + +String? _safDocumentId(Uri uri) { + final segments = uri.pathSegments; + for (final marker in const ['document', 'tree']) { + final index = segments.indexOf(marker); + if (index != -1 && index + 1 < segments.length) { + final raw = segments[index + 1]; + try { + return Uri.decodeComponent(raw); + } catch (_) { + return raw; + } + } + } + return null; +} + +bool _looksLikeStorageVolumeId(String value) { + return RegExp( + r'^[0-9a-f]{4}-[0-9a-f]{4}$', + caseSensitive: false, + ).hasMatch(value); +} + +String? _friendlyDisplayRoot(String? value) { + final normalized = value?.trim().replaceAll('\\', '/'); + if (normalized == null || + normalized.isEmpty || + normalized.startsWith('content://')) { + return null; + } + return normalized; +} + +String? _cleanDisplaySegment(String? value) { + final normalized = value?.trim().replaceAll('\\', '/'); + if (normalized == null || normalized.isEmpty) return null; + final withoutEdges = normalized.replaceAll(RegExp(r'^/+|/+$'), ''); + return withoutEdges.isEmpty ? null : withoutEdges; +} diff --git a/test/saf_display_path_test.dart b/test/saf_display_path_test.dart new file mode 100644 index 00000000..65b5c2d3 --- /dev/null +++ b/test/saf_display_path_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/utils/saf_display_path.dart'; + +void main() { + group('SAF display paths', () { + test('normalizes an external-storage document URI', () { + const uri = + 'content://com.android.externalstorage.documents/' + 'document/primary%3AMusic%2FSpotiFLAC%2FSong.flac'; + + expect( + formatSafUriForDisplay(uri), + '/storage/emulated/0/Music/SpotiFLAC/Song.flac', + ); + }); + + test('rebuilds a friendly path for an opaque SAF document ID', () { + expect( + buildSafFileDisplayPath( + pathOrUri: + 'content://com.android.providers.media.documents/' + 'document/audio%3A1000192991', + treeUri: + 'content://com.android.externalstorage.documents/' + 'tree/primary%3AMusic%2FSpotiFLAC', + treeDisplayPath: '/storage/emulated/0/Music/SpotiFLAC', + relativeDir: 'Falling In Reverse/Popular Monster', + fileName: '01 - Popular Monster.flac', + ), + '/storage/emulated/0/Music/SpotiFLAC/' + 'Falling In Reverse/Popular Monster/01 - Popular Monster.flac', + ); + }); + + test('does not present a MediaStore ID as an SD-card path', () { + const uri = + 'content://com.android.providers.media.documents/' + 'document/audio%3A12345'; + expect(formatSafUriForDisplay(uri), uri); + }); + + test('keeps ordinary filesystem paths unchanged', () { + const path = '/storage/emulated/0/Music/Song.flac'; + expect(formatSafUriForDisplay(path), path); + expect(buildSafFileDisplayPath(pathOrUri: path), path); + }); + }); +}