mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-26 12:52:40 +02:00
fix(metadata): show readable SAF file locations
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user