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) {