fix(android): sanitize restored installation state

This commit is contained in:
zarzet
2026-07-23 10:39:31 +07:00
parent 480a96bd0e
commit c9114fef01
12 changed files with 673 additions and 27 deletions
+3
View File
@@ -21,6 +21,9 @@
android:label="SpotiFLAC Mobile"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:fullBackupContent="@xml/backup_rules_legacy"
android:dataExtractionRules="@xml/backup_rules"
android:usesCleartextTraffic="false"
android:networkSecurityConfig="@xml/network_security_config"
android:enableOnBackInvokedCallback="true"
@@ -1980,6 +1980,48 @@ 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,
)
}
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).
@@ -2189,6 +2231,12 @@ class MainActivity: FlutterFragmentActivity() {
scope.launch {
try {
when (call.method) {
"ensureInstallMarker" -> {
val installState = withContext(Dispatchers.IO) {
ensureInstallMarker()
}
result.success(installState)
}
"exitApp" -> {
flutterBackCallback?.isEnabled = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<!--
SpotiFLAC Mobile provides an explicit, schema-aware Backup & Restore
feature. Platform backup must not copy device-bound SAF grants, paths,
queues, extension credentials, or signed sessions to another install.
-->
<cloud-backup>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<!-- Android 11 and lower use this legacy rule format. -->
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</full-backup-content>