mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-22 07:18:38 +02:00
refactor(android): move install state, extension intents, SAF leases and small channels out of MainActivity.kt
MainActivity.kt mixed the Activity lifecycle and the backend dispatcher with several self-contained helpers. Give each its own file as MainActivity extension functions, following the W2-N split: - MainActivityInstallState.kt: ensureInstallMarker, prepareRuntimeState - MainActivityExtensionIntents.kt: verification and OAuth intent handling, processPendingExtensionCallbacks, session-grant events - MainActivitySafLeases.kt: SAF playback descriptor leases - MainActivitySmallChannels.kt: the concert_calendar and audio_output MethodChannels, still registered at the same point and in the same order in configureFlutterEngine The state these helpers touch stays in MainActivity and is widened from private to internal. Bodies move verbatim except that companion members are qualified with MainActivity. outside the class.
This commit is contained in:
1 parent
ff7afa5b3c
commit
cf4c05cd93
5 files changed
+326
-285
No files matched your search
@@ -32,10 +32,7 @@ import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.json.JSONTokener
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.LinkedHashMap
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class MainActivity: FlutterFragmentActivity() {
|
||||
@@ -62,8 +59,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
|
||||
internal val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
internal var backendChannel: MethodChannel? = null
|
||||
private var audioOutputChannel: MethodChannel? = null
|
||||
private var concertCalendarChannel: MethodChannel? = null
|
||||
internal var audioOutputChannel: MethodChannel? = null
|
||||
internal var concertCalendarChannel: MethodChannel? = null
|
||||
internal val coreBackend: CoreBackend by lazy { createCoreBackend(applicationContext) }
|
||||
private val nativeBackendMethods = setOf(
|
||||
"setScreenAwake",
|
||||
@@ -138,8 +135,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"exitApp",
|
||||
)
|
||||
private var libraryStorageReceiver: BroadcastReceiver? = null
|
||||
private val pendingSessionGrantEvents = mutableListOf<Map<String, Any>>()
|
||||
private var pendingVerificationNotification: String? = null
|
||||
internal val pendingSessionGrantEvents = mutableListOf<Map<String, Any>>()
|
||||
internal var pendingVerificationNotification: String? = null
|
||||
internal val setupFolderPickerStore by lazy {
|
||||
SetupFolderPickerStore(getSharedPreferences("setup_folder_picker", Context.MODE_PRIVATE))
|
||||
}
|
||||
@@ -154,8 +151,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
internal var libraryScanProgressEventSink: EventChannel.EventSink? = null
|
||||
internal var lastLibraryScanProgressPayload: String? = null
|
||||
private var flutterBackCallback: OnBackPressedCallback? = null
|
||||
private val playbackLeaseLock = Any()
|
||||
private val playbackLeases = LinkedHashMap<String, ParcelFileDescriptor>()
|
||||
internal val playbackLeaseLock = Any()
|
||||
internal val playbackLeases = LinkedHashMap<String, ParcelFileDescriptor>()
|
||||
@Volatile internal var safScanCancel = false
|
||||
@Volatile internal var safScanPaused = false
|
||||
@Volatile internal var safScanActive = false
|
||||
@@ -175,7 +172,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val extensionAuthCallbacks = ExtensionAuthCallbacks()
|
||||
internal val extensionAuthCallbacks = ExtensionAuthCallbacks()
|
||||
// audio_service keeps the engine across Activity recreation. Retain
|
||||
// its outstanding picker reply for the same lifetime, not the Activity.
|
||||
internal var pendingSafTreeResult: MethodChannel.Result? = null
|
||||
@@ -361,112 +358,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an install-scoped marker in noBackupFilesDir. A platform restore
|
||||
* can bring SharedPreferences back after reinstall, but this marker is never
|
||||
* backed up. Package timestamps distinguish that case from the first app
|
||||
* update after this marker was introduced, so existing users are not sent
|
||||
* through onboarding again.
|
||||
*/
|
||||
private fun ensureInstallMarker(): Map<String, Any> {
|
||||
val marker = File(noBackupFilesDir, "installation_state_v1")
|
||||
val markerExisted = marker.isFile
|
||||
val packageInfo = @Suppress("DEPRECATION")
|
||||
packageManager.getPackageInfo(packageName, 0)
|
||||
val installTimestampDelta = kotlin.math.abs(
|
||||
packageInfo.lastUpdateTime - packageInfo.firstInstallTime
|
||||
)
|
||||
val looksLikeFreshPackageInstall = installTimestampDelta <= 10_000L
|
||||
|
||||
var markerCreated = markerExisted
|
||||
if (!markerExisted) {
|
||||
markerCreated = try {
|
||||
marker.parentFile?.mkdirs()
|
||||
marker.writeText(
|
||||
"created_at=${System.currentTimeMillis()}\n" +
|
||||
"version_code=${BuildConfig.VERSION_CODE}\n"
|
||||
)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"Failed to create installation marker: ${e.message}"
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
return mapOf(
|
||||
"marker_existed" to markerExisted,
|
||||
"marker_created" to markerCreated,
|
||||
"fresh_package_install" to looksLikeFreshPackageInstall,
|
||||
)
|
||||
}
|
||||
|
||||
private fun prepareRuntimeState(extensionDataDir: String): String {
|
||||
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 values = LinkedHashMap<String, String>()
|
||||
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 = values.toSortedMap().values.firstOrNull() ?: 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 fallbackFile = File(noBackupFilesDir, "rs_v1")
|
||||
val fallback = try {
|
||||
normalize(fallbackFile.takeIf(File::isFile)?.readText())
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: ByteArray(16).also(SecureRandom()::nextBytes).hex().also { value ->
|
||||
try {
|
||||
fallbackFile.parentFile?.mkdirs()
|
||||
fallbackFile.writeText(value)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
domain + fallback
|
||||
}
|
||||
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)
|
||||
return JSONObject()
|
||||
.put("v", 1)
|
||||
.put("d", defaultValue)
|
||||
.put("s", entries)
|
||||
.toString()
|
||||
}
|
||||
|
||||
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).
|
||||
@@ -485,67 +376,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
PlayerWidgetBridge.handleIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleVerificationNotificationIntent(intent: Intent?) {
|
||||
if (intent?.action != VerificationNotificationIntent.ACTION) return
|
||||
val payload = intent.getStringExtra(VerificationNotificationIntent.PAYLOAD)
|
||||
?.takeIf { it.isNotBlank() } ?: return
|
||||
pendingVerificationNotification = payload
|
||||
intent.removeExtra(VerificationNotificationIntent.PAYLOAD)
|
||||
// Keep the payload until Dart is initialized and explicitly consumes it.
|
||||
backendChannel?.invokeMethod("extensionVerificationNotificationTapped", null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver Spotify (or other) OAuth authorization code to the extension runtime
|
||||
* and run its token exchange (e.g. completeSpotifyLogin). State is a one-time
|
||||
* host nonce resolved to the owning extension by the backend.
|
||||
*/
|
||||
private fun handleExtensionOAuthIntent(intent: Intent?) {
|
||||
val uri = intent?.data ?: return
|
||||
if (!extensionAuthCallbacks.offer(uri)) return
|
||||
// Keep the Intent intact until the result is handled. A cold start has
|
||||
// no extension owner yet; Dart drains this inbox after initialization.
|
||||
backendChannel?.invokeMethod("extensionAuthCallbackReceived", null)
|
||||
}
|
||||
|
||||
private suspend fun processPendingExtensionCallbacks() {
|
||||
extensionAuthCallbacks.drain { callback ->
|
||||
var callbackExtensionId = ""
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
coreBackend.completeAuthCallback(callback.state, callback.code, callback.sessionGrant) {
|
||||
callbackExtensionId = it
|
||||
}
|
||||
}
|
||||
android.util.Log.i("SpotiFLAC", "Extension callback completed")
|
||||
if (callback.sessionGrant) {
|
||||
notifySessionGrantCompleted(callbackExtensionId, true)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||
android.util.Log.w("SpotiFLAC", "Extension callback failed (${e.javaClass.simpleName})")
|
||||
if (callback.sessionGrant && callbackExtensionId.isNotEmpty()) {
|
||||
notifySessionGrantCompleted(callbackExtensionId, false)
|
||||
}
|
||||
}
|
||||
val current = intent?.data
|
||||
if (current?.getQueryParameter("state") == callback.state) intent.data = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifySessionGrantCompleted(extensionId: String, success: Boolean) {
|
||||
val payload = mapOf(
|
||||
"extension_id" to extensionId,
|
||||
"success" to success,
|
||||
)
|
||||
val channel = backendChannel
|
||||
if (channel == null) {
|
||||
pendingSessionGrantEvents.add(payload)
|
||||
return
|
||||
}
|
||||
channel.invokeMethod("extensionSessionGrantCompleted", payload)
|
||||
}
|
||||
|
||||
internal fun reportLibraryScanError(path: String, operation: String, message: String) {
|
||||
android.util.Log.e("SpotiFLAC", "Library scan: $operation [$path]: $message")
|
||||
val payload = mapOf(
|
||||
@@ -558,66 +388,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a short-lived descriptor lease for zero-copy SAF playback. The
|
||||
* returned proc path identifies the lease. The Android audio plugin must
|
||||
* duplicate this descriptor, never reopen it (AppFuse rejects reopening).
|
||||
* A small hard cap protects against abandoned method calls.
|
||||
*/
|
||||
private fun openSafPlaybackLease(uriStr: String): Map<String, String>? {
|
||||
if (!uriStr.startsWith("content://")) return null
|
||||
val descriptor = try {
|
||||
contentResolver.openFileDescriptor(Uri.parse(uriStr), "r")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "Failed to open SAF playback descriptor: ${e.message}")
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
if (!isSeekableSafDescriptor(descriptor)) {
|
||||
descriptor.close()
|
||||
return null
|
||||
}
|
||||
val token = UUID.randomUUID().toString()
|
||||
synchronized(playbackLeaseLock) {
|
||||
while (playbackLeases.size >= 4) {
|
||||
val oldest = playbackLeases.entries.firstOrNull() ?: break
|
||||
playbackLeases.remove(oldest.key)
|
||||
try {
|
||||
oldest.value.close()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
playbackLeases[token] = descriptor
|
||||
}
|
||||
return mapOf(
|
||||
"token" to token,
|
||||
"path" to "/proc/self/fd/${descriptor.fd}",
|
||||
"display_name" to buildUriDisplayName(Uri.parse(uriStr)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun closeSafPlaybackLease(token: String) {
|
||||
if (token.isBlank()) return
|
||||
val descriptor = synchronized(playbackLeaseLock) {
|
||||
playbackLeases.remove(token)
|
||||
} ?: return
|
||||
try {
|
||||
descriptor.close()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun closeAllSafPlaybackLeases() {
|
||||
val descriptors = synchronized(playbackLeaseLock) {
|
||||
val openDescriptors = playbackLeases.values.toList()
|
||||
playbackLeases.clear()
|
||||
openDescriptors
|
||||
}
|
||||
descriptors.forEach { descriptor ->
|
||||
try {
|
||||
descriptor.close()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (isFinishing) {
|
||||
val pending = pendingSafTreeResult
|
||||
@@ -672,54 +442,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
if (!flutterEngine.plugins.has(MusicHapticsPlugin::class.java)) {
|
||||
flutterEngine.plugins.add(MusicHapticsPlugin())
|
||||
}
|
||||
concertCalendarChannel = MethodChannel(messenger, "com.zarz.spotiflac/concert_calendar").also { channel ->
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
if (call.method != "add") {
|
||||
result.notImplemented()
|
||||
} else {
|
||||
val start = call.argument<Number>("start")?.toLong()
|
||||
if (start == null) {
|
||||
result.success(false)
|
||||
} else {
|
||||
val suppliedEnd = call.argument<Number>("end")?.toLong()
|
||||
val end = suppliedEnd?.takeIf { it > start } ?: (start + 3600000)
|
||||
try {
|
||||
startActivity(Intent(Intent.ACTION_INSERT).apply {
|
||||
data = android.provider.CalendarContract.Events.CONTENT_URI
|
||||
putExtra(android.provider.CalendarContract.Events.TITLE, call.argument<String>("title"))
|
||||
putExtra(android.provider.CalendarContract.Events.EVENT_LOCATION, call.argument<String>("location"))
|
||||
putExtra(android.provider.CalendarContract.Events.DESCRIPTION, call.argument<String>("url"))
|
||||
putExtra(android.provider.CalendarContract.EXTRA_EVENT_BEGIN_TIME, start)
|
||||
putExtra(android.provider.CalendarContract.EXTRA_EVENT_END_TIME, end)
|
||||
})
|
||||
result.success(true)
|
||||
} catch (_: Exception) {
|
||||
result.success(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
audioOutputChannel = MethodChannel(messenger, "com.zarz.spotiflac/audio_output").also { channel ->
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
if (call.method != "show") {
|
||||
result.notImplemented()
|
||||
} else {
|
||||
try {
|
||||
val shown = if (Build.VERSION.SDK_INT >= 34) {
|
||||
android.media.MediaRouter2.getInstance(this).showSystemOutputSwitcher()
|
||||
} else {
|
||||
// Older Android releases do not expose a public output picker.
|
||||
startActivity(Intent(android.provider.Settings.ACTION_BLUETOOTH_SETTINGS))
|
||||
true
|
||||
}
|
||||
result.success(shown)
|
||||
} catch (_: Exception) {
|
||||
result.success(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
registerConcertCalendarChannel(messenger)
|
||||
registerAudioOutputChannel(messenger)
|
||||
|
||||
registerProgressStreams(messenger)
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.content.Intent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// Extension OAuth and session-grant callbacks and verification notification
|
||||
// taps that reach MainActivity by Intent, and their hand-off to Dart. The
|
||||
// pending state lives in MainActivity; these helpers fill and drain it.
|
||||
|
||||
internal fun MainActivity.handleVerificationNotificationIntent(intent: Intent?) {
|
||||
if (intent?.action != VerificationNotificationIntent.ACTION) return
|
||||
val payload = intent.getStringExtra(VerificationNotificationIntent.PAYLOAD)
|
||||
?.takeIf { it.isNotBlank() } ?: return
|
||||
pendingVerificationNotification = payload
|
||||
intent.removeExtra(VerificationNotificationIntent.PAYLOAD)
|
||||
// Keep the payload until Dart is initialized and explicitly consumes it.
|
||||
backendChannel?.invokeMethod("extensionVerificationNotificationTapped", null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver Spotify (or other) OAuth authorization code to the extension runtime
|
||||
* and run its token exchange (e.g. completeSpotifyLogin). State is a one-time
|
||||
* host nonce resolved to the owning extension by the backend.
|
||||
*/
|
||||
internal fun MainActivity.handleExtensionOAuthIntent(intent: Intent?) {
|
||||
val uri = intent?.data ?: return
|
||||
if (!MainActivity.extensionAuthCallbacks.offer(uri)) return
|
||||
// Keep the Intent intact until the result is handled. A cold start has
|
||||
// no extension owner yet; Dart drains this inbox after initialization.
|
||||
backendChannel?.invokeMethod("extensionAuthCallbackReceived", null)
|
||||
}
|
||||
|
||||
internal suspend fun MainActivity.processPendingExtensionCallbacks() {
|
||||
MainActivity.extensionAuthCallbacks.drain { callback ->
|
||||
var callbackExtensionId = ""
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
coreBackend.completeAuthCallback(callback.state, callback.code, callback.sessionGrant) {
|
||||
callbackExtensionId = it
|
||||
}
|
||||
}
|
||||
android.util.Log.i("SpotiFLAC", "Extension callback completed")
|
||||
if (callback.sessionGrant) {
|
||||
notifySessionGrantCompleted(callbackExtensionId, true)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||
android.util.Log.w("SpotiFLAC", "Extension callback failed (${e.javaClass.simpleName})")
|
||||
if (callback.sessionGrant && callbackExtensionId.isNotEmpty()) {
|
||||
notifySessionGrantCompleted(callbackExtensionId, false)
|
||||
}
|
||||
}
|
||||
val current = intent?.data
|
||||
if (current?.getQueryParameter("state") == callback.state) intent.data = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun MainActivity.notifySessionGrantCompleted(extensionId: String, success: Boolean) {
|
||||
val payload = mapOf(
|
||||
"extension_id" to extensionId,
|
||||
"success" to success,
|
||||
)
|
||||
val channel = backendChannel
|
||||
if (channel == null) {
|
||||
pendingSessionGrantEvents.add(payload)
|
||||
return
|
||||
}
|
||||
channel.invokeMethod("extensionSessionGrantCompleted", payload)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.LinkedHashMap
|
||||
|
||||
// Install-scoped marker and the runtime state handed to the selected backend,
|
||||
// behind the `ensureInstallMarker` and `prepareRuntimeState` backend calls.
|
||||
|
||||
/**
|
||||
* Creates an install-scoped marker in noBackupFilesDir. A platform restore
|
||||
* can bring SharedPreferences back after reinstall, but this marker is never
|
||||
* backed up. Package timestamps distinguish that case from the first app
|
||||
* update after this marker was introduced, so existing users are not sent
|
||||
* through onboarding again.
|
||||
*/
|
||||
internal fun MainActivity.ensureInstallMarker(): Map<String, Any> {
|
||||
val marker = File(noBackupFilesDir, "installation_state_v1")
|
||||
val markerExisted = marker.isFile
|
||||
val packageInfo = @Suppress("DEPRECATION")
|
||||
packageManager.getPackageInfo(packageName, 0)
|
||||
val installTimestampDelta = kotlin.math.abs(
|
||||
packageInfo.lastUpdateTime - packageInfo.firstInstallTime
|
||||
)
|
||||
val looksLikeFreshPackageInstall = installTimestampDelta <= 10_000L
|
||||
|
||||
var markerCreated = markerExisted
|
||||
if (!markerExisted) {
|
||||
markerCreated = try {
|
||||
marker.parentFile?.mkdirs()
|
||||
marker.writeText(
|
||||
"created_at=${System.currentTimeMillis()}\n" +
|
||||
"version_code=${BuildConfig.VERSION_CODE}\n"
|
||||
)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"Failed to create installation marker: ${e.message}"
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
return mapOf(
|
||||
"marker_existed" to markerExisted,
|
||||
"marker_created" to markerCreated,
|
||||
"fresh_package_install" to looksLikeFreshPackageInstall,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun MainActivity.prepareRuntimeState(extensionDataDir: String): String {
|
||||
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 values = LinkedHashMap<String, String>()
|
||||
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 = values.toSortedMap().values.firstOrNull() ?: 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 fallbackFile = File(noBackupFilesDir, "rs_v1")
|
||||
val fallback = try {
|
||||
normalize(fallbackFile.takeIf(File::isFile)?.readText())
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: ByteArray(16).also(SecureRandom()::nextBytes).hex().also { value ->
|
||||
try {
|
||||
fallbackFile.parentFile?.mkdirs()
|
||||
fallbackFile.writeText(value)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
domain + fallback
|
||||
}
|
||||
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)
|
||||
return JSONObject()
|
||||
.put("v", 1)
|
||||
.put("d", defaultValue)
|
||||
.put("s", entries)
|
||||
.toString()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.net.Uri
|
||||
import java.util.UUID
|
||||
|
||||
// Descriptor leases for zero-copy SAF playback (`safOpenPlaybackLease` and
|
||||
// `safClosePlaybackLease`). The lease table lives in MainActivity; these
|
||||
// helpers open, close and release the leased descriptors.
|
||||
|
||||
/**
|
||||
* Opens a short-lived descriptor lease for zero-copy SAF playback. The
|
||||
* returned proc path identifies the lease. The Android audio plugin must
|
||||
* duplicate this descriptor, never reopen it (AppFuse rejects reopening).
|
||||
* A small hard cap protects against abandoned method calls.
|
||||
*/
|
||||
internal fun MainActivity.openSafPlaybackLease(uriStr: String): Map<String, String>? {
|
||||
if (!uriStr.startsWith("content://")) return null
|
||||
val descriptor = try {
|
||||
contentResolver.openFileDescriptor(Uri.parse(uriStr), "r")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "Failed to open SAF playback descriptor: ${e.message}")
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
if (!isSeekableSafDescriptor(descriptor)) {
|
||||
descriptor.close()
|
||||
return null
|
||||
}
|
||||
val token = UUID.randomUUID().toString()
|
||||
synchronized(playbackLeaseLock) {
|
||||
while (playbackLeases.size >= 4) {
|
||||
val oldest = playbackLeases.entries.firstOrNull() ?: break
|
||||
playbackLeases.remove(oldest.key)
|
||||
try {
|
||||
oldest.value.close()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
playbackLeases[token] = descriptor
|
||||
}
|
||||
return mapOf(
|
||||
"token" to token,
|
||||
"path" to "/proc/self/fd/${descriptor.fd}",
|
||||
"display_name" to buildUriDisplayName(Uri.parse(uriStr)),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun MainActivity.closeSafPlaybackLease(token: String) {
|
||||
if (token.isBlank()) return
|
||||
val descriptor = synchronized(playbackLeaseLock) {
|
||||
playbackLeases.remove(token)
|
||||
} ?: return
|
||||
try {
|
||||
descriptor.close()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
internal fun MainActivity.closeAllSafPlaybackLeases() {
|
||||
val descriptors = synchronized(playbackLeaseLock) {
|
||||
val openDescriptors = playbackLeases.values.toList()
|
||||
playbackLeases.clear()
|
||||
openDescriptors
|
||||
}
|
||||
descriptors.forEach { descriptor ->
|
||||
try {
|
||||
descriptor.close()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
// The single-method `concert_calendar` and `audio_output` MethodChannels.
|
||||
// MainActivity holds the channel references and clears them in onDestroy.
|
||||
|
||||
internal fun MainActivity.registerConcertCalendarChannel(messenger: BinaryMessenger) {
|
||||
concertCalendarChannel = MethodChannel(messenger, "com.zarz.spotiflac/concert_calendar").also { channel ->
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
if (call.method != "add") {
|
||||
result.notImplemented()
|
||||
} else {
|
||||
val start = call.argument<Number>("start")?.toLong()
|
||||
if (start == null) {
|
||||
result.success(false)
|
||||
} else {
|
||||
val suppliedEnd = call.argument<Number>("end")?.toLong()
|
||||
val end = suppliedEnd?.takeIf { it > start } ?: (start + 3600000)
|
||||
try {
|
||||
startActivity(Intent(Intent.ACTION_INSERT).apply {
|
||||
data = android.provider.CalendarContract.Events.CONTENT_URI
|
||||
putExtra(android.provider.CalendarContract.Events.TITLE, call.argument<String>("title"))
|
||||
putExtra(android.provider.CalendarContract.Events.EVENT_LOCATION, call.argument<String>("location"))
|
||||
putExtra(android.provider.CalendarContract.Events.DESCRIPTION, call.argument<String>("url"))
|
||||
putExtra(android.provider.CalendarContract.EXTRA_EVENT_BEGIN_TIME, start)
|
||||
putExtra(android.provider.CalendarContract.EXTRA_EVENT_END_TIME, end)
|
||||
})
|
||||
result.success(true)
|
||||
} catch (_: Exception) {
|
||||
result.success(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun MainActivity.registerAudioOutputChannel(messenger: BinaryMessenger) {
|
||||
audioOutputChannel = MethodChannel(messenger, "com.zarz.spotiflac/audio_output").also { channel ->
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
if (call.method != "show") {
|
||||
result.notImplemented()
|
||||
} else {
|
||||
try {
|
||||
val shown = if (Build.VERSION.SDK_INT >= 34) {
|
||||
android.media.MediaRouter2.getInstance(this).showSystemOutputSwitcher()
|
||||
} else {
|
||||
// Older Android releases do not expose a public output picker.
|
||||
startActivity(Intent(android.provider.Settings.ACTION_BLUETOOTH_SETTINGS))
|
||||
true
|
||||
}
|
||||
result.success(shown)
|
||||
} catch (_: Exception) {
|
||||
result.success(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user