fix(metadata): show readable SAF file locations

This commit is contained in:
zarzet
2026-08-21 01:02:02 +07:00
parent cee3d75655
commit 89d39c4074
9 changed files with 348 additions and 40 deletions
+1
View File
@@ -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.
@@ -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<String, String>()
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<String>("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) {
+58 -1
View File
@@ -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) {
+7
View File
@@ -93,6 +93,13 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
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();
+1
View File
@@ -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;
+15 -39
View File
@@ -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,
);
}
}
+5
View File
@@ -230,6 +230,11 @@ class PlatformBridge {
return InstallationState.fromMap(result);
}
static Future<void> prepareRuntimeState(String dataDir) async {
if (!Platform.isAndroid) return;
await _invokeMap('prepareRuntimeState', {'data_dir': dataDir});
}
static Future<Map<String, dynamic>> _cachedInvoke(
String cacheKey,
Map<String, _BridgeCacheEntry> cache,
+98
View File
@@ -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;
}
+48
View File
@@ -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);
});
});
}