Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Verified Commit a12a354c authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

fix(update): migrate legacy fakestore installs

Add a startup migration for legacy App Lounge Play installs that were previously stamped as com.android.vending.

Keep migration asynchronous and retryable so legacy apps are not stranded if installer rewriting fails.

Document the separate null-installer legacy bucket so it is either blocked deliberately or handled by a later dedicated migration track.
parent 838a0db4
Loading
Loading
Loading
Loading
+30 −26
Original line number Diff line number Diff line
@@ -94,8 +94,7 @@ Likely production files to touch:
- `app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt`
- `data/src/main/java/foundation/e/apps/data/installation/core/InstallationProcessor.kt`
- `app/src/main/java/foundation/e/apps/AppLoungeApplication.kt`
- `domain/src/main/kotlin/foundation/e/apps/domain/preferences/SessionRepository.kt`
- `data/src/main/java/foundation/e/apps/data/preference/SessionDataStore.kt`
- `data/src/main/java/foundation/e/apps/data/preference/LegacyInstallerMigrationStore.kt`

Likely new production files:

@@ -480,42 +479,42 @@ Migrate old App Lounge-installed Play apps that currently look owned by `com.and
### Tasks

#### Task 5.1 - Add migration persistence
- [ ] Add a migration version or flag store.
- [ ] Prefer a migration version plus per-package outcomes over one global boolean.
- [ ] Store enough information to retry failed packages later.
- [x] Add a migration version or flag store.
- [x] Prefer a migration version plus per-package outcomes over one global boolean.
- [x] Store enough information to retry failed packages later.

#### Task 5.2 - Add FakeStore migration runner
- [ ] Create `LegacyInstallerMigrationRunner`.
- [ ] Scan installed apps asynchronously.
- [ ] Only consider packages that match the legacy compatibility shape.
- [ ] For each matching package, attempt:
- [x] Create `LegacyInstallerMigrationRunner`.
- [x] Scan installed apps asynchronously.
- [x] Only consider packages that match the legacy compatibility shape.
- [x] For each matching package, attempt:
  - `packageManager.setInstallerPackageName(targetPackage, context.packageName)`

#### Task 5.3 - Handle FakeStore migration failures gracefully
- [ ] Catch and classify `SecurityException` separately if possible.
- [ ] Catch generic runtime failure.
- [ ] If migration fails, keep the legacy shim valid for that package.
- [ ] Do not mark the whole migration complete if some packages still need retry.
- [x] Catch and classify `SecurityException` separately if possible.
- [x] Catch generic runtime failure.
- [x] If migration fails, keep the legacy shim valid for that package.
- [x] Do not mark the whole migration complete if some packages still need retry.

#### Task 5.4 - Decide null-installer legacy strategy explicitly
- [ ] Decide whether the `installingPackageName == null` plus `initiatingPackageName == foundation.e.apps` bucket is blocked in the first rollout or gets a second migration track.
- [ ] Do not auto-allow this bucket based only on `initiatingPackageName`.
- [ ] If no safe migration is defined, keep this bucket blocked and logged with a distinct reason code.
- [ ] If a migration is proposed later, document the evidence requirements needed before installer repair is attempted.
- [x] Decide whether the `installingPackageName == null` plus `initiatingPackageName == foundation.e.apps` bucket is blocked in the first rollout or gets a second migration track.
- [x] Do not auto-allow this bucket based only on `initiatingPackageName`.
- [x] If no safe migration is defined, keep this bucket blocked and logged with a distinct reason code.
- [x] If a migration is proposed later, document the evidence requirements needed before installer repair is attempted.

#### Task 5.5 - Wire migration into app startup
- [ ] Start the migration asynchronously from `AppLoungeApplication.onCreate()`.
- [ ] Do not block app startup.
- [ ] Add clear comments explaining why this runs on startup and why it must stay asynchronous.
- [x] Start the migration asynchronously from `AppLoungeApplication.onCreate()`.
- [x] Do not block app startup.
- [x] Add clear comments explaining why this runs on startup and why it must stay asynchronous.

#### Task 5.6 - Add detailed logs
- [ ] Log each package evaluated.
- [ ] Log migration success, failure, or skip with reason code.
- [ ] Keep logs developer-focused and compact.
- [x] Log each package evaluated.
- [x] Log migration success, failure, or skip with reason code.
- [x] Keep logs developer-focused and compact.

#### Task 5.7 - Add focused tests
- [ ] Create `LegacyInstallerMigrationRunnerTest.kt`.
- [ ] Cover:
- [x] Create `LegacyInstallerMigrationRunnerTest.kt`.
- [x] Cover:
  - matching legacy package migrates successfully
  - non-legacy package is skipped
  - null-installer App Lounge-initiated package is not accidentally treated as FakeStore legacy
@@ -549,7 +548,12 @@ Body:

### Iteration Notes

- Add notes here after the phase is done.
- Added a dedicated `LegacyInstallerMigrationStore` instead of extending `SessionRepository`, so migration persistence stays narrow and does not force unrelated session/auth test fakes to change.
- The store records a migration version plus per-package failure reason codes. The runner still rescans current legacy-shaped installs on each startup, so failed packages stay retryable and no single global completion flag can strand them.
- `LegacyInstallerMigrationRunner` now identifies the FakeStore-shaped legacy bucket through the centralized eligibility result, rewrites installer metadata back to `foundation.e.apps`, and records compact success/skip/failure logs per evaluated package.
- The separate null-installer bucket remains blocked and is logged as `MIGRATION_SKIPPED_NULL_INSTALLER_BUCKET`; this rollout does not attempt installer repair for it because the audited bucket is mixed-source and lacks safe evidence for automatic migration.
- Startup wiring now launches migration asynchronously from `AppLoungeApplication.onCreate()` inside the existing non-Robolectric startup branch.
- Focused migration tests now cover success, non-legacy skip, null-installer skip, `SecurityException`, generic runtime failure, and package-state changes during migration.

## Phase 6 - Cleanup, Remove Old Heuristics From Ownership Decisions, And Finalize Diagnostics

+10 −0
Original line number Diff line number Diff line
@@ -29,6 +29,7 @@ import dagger.hilt.android.HiltAndroidApp
import foundation.e.apps.data.Constants.TAG_APP_INSTALL_STATE
import foundation.e.apps.data.Constants.TAG_AUTHDATA_DUMP
import foundation.e.apps.data.di.qualifiers.IoCoroutineScope
import foundation.e.apps.data.install.core.LegacyInstallerMigrationRunner
import foundation.e.apps.data.install.pkg.AppLoungePackageManager
import foundation.e.apps.data.install.pkg.PkgManagerBR
import foundation.e.apps.data.install.updates.UpdatesWorkManager
@@ -83,6 +84,9 @@ class AppLoungeApplication : Application(), Configuration.Provider {
    @Inject
    lateinit var installOrchestrator: InstallOrchestrator

    @Inject
    lateinit var legacyInstallerMigrationRunner: LegacyInstallerMigrationRunner

    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
    override fun onCreate() {
        super.onCreate()
@@ -131,6 +135,12 @@ class AppLoungeApplication : Application(), Configuration.Provider {
                )
            }

            // Keep this asynchronous on startup so legacy installer repair does not delay app
            // launch, while still giving old FakeStore-stamped installs a chance to recover.
            coroutineScope.launch {
                legacyInstallerMigrationRunner.run()
            }

            removeStalledInstallationFromDb()
            installOrchestrator.init()
        }
+7 −0
Original line number Diff line number Diff line
package foundation.e.apps.data.install.core

data class LegacyInstallerMigrationResult(
    val packageName: String,
    val reasonCode: String,
    val migrated: Boolean,
)
+94 −0
Original line number Diff line number Diff line
package foundation.e.apps.data.install.core

import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import foundation.e.apps.data.install.pkg.AppLoungePackageManager
import foundation.e.apps.data.installation.model.UpdateEligibilityResult
import foundation.e.apps.data.installation.port.UpdateEligibilityChecker
import foundation.e.apps.data.preference.LegacyInstallerMigrationStore
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class LegacyInstallerMigrationRunner @Inject constructor(
    @ApplicationContext private val context: Context,
    private val appLoungePackageManager: AppLoungePackageManager,
    private val updateEligibilityChecker: UpdateEligibilityChecker,
    private val legacyInstallerMigrationStore: LegacyInstallerMigrationStore,
) {
    companion object {
        private const val MIGRATION_VERSION = 1
        const val MIGRATION_SUCCESS = "MIGRATION_SUCCESS"
        const val MIGRATION_FAILED_SECURITY = "MIGRATION_FAILED_SECURITY"
        const val MIGRATION_FAILED_RUNTIME = "MIGRATION_FAILED_RUNTIME"
        const val MIGRATION_SKIPPED_NOT_LEGACY = "MIGRATION_SKIPPED_NOT_LEGACY"
        const val MIGRATION_SKIPPED_NULL_INSTALLER_BUCKET = "MIGRATION_SKIPPED_NULL_INSTALLER_BUCKET"
    }

    suspend fun run(): List<LegacyInstallerMigrationResult> {
        legacyInstallerMigrationStore.saveMigrationVersion(MIGRATION_VERSION)

        return appLoungePackageManager.getAllUserApps().map { applicationInfo ->
            migratePackageIfNeeded(applicationInfo.packageName)
        }
    }

    private suspend fun migratePackageIfNeeded(packageName: String): LegacyInstallerMigrationResult {
        val eligibilityResult = updateEligibilityChecker.getUpdateEligibility(packageName)
        return when {
            eligibilityResult.isLegacyMigrationCase -> migrateLegacyPackage(packageName)
            eligibilityResult.reasonCode == UpdateEligibilityResult.BLOCK_APP_LOUNGE_INITIATED_NULL_INSTALLER -> {
                LegacyInstallerMigrationResult(
                    packageName = packageName,
                    reasonCode = MIGRATION_SKIPPED_NULL_INSTALLER_BUCKET,
                    migrated = false,
                ).also(::logResult)
            }
            else -> LegacyInstallerMigrationResult(
                packageName = packageName,
                reasonCode = MIGRATION_SKIPPED_NOT_LEGACY,
                migrated = false,
            ).also(::logResult)
        }
    }

    private suspend fun migrateLegacyPackage(packageName: String): LegacyInstallerMigrationResult {
        return try {
            appLoungePackageManager.setInstallerPackageName(packageName, context.packageName)
            legacyInstallerMigrationStore.clearMigrationFailure(packageName)
            LegacyInstallerMigrationResult(
                packageName = packageName,
                reasonCode = MIGRATION_SUCCESS,
                migrated = true,
            ).also(::logResult)
        } catch (_: SecurityException) {
            legacyInstallerMigrationStore.saveMigrationFailure(packageName, MIGRATION_FAILED_SECURITY)
            LegacyInstallerMigrationResult(
                packageName = packageName,
                reasonCode = MIGRATION_FAILED_SECURITY,
                migrated = false,
            ).also(::logResult)
        } catch (_: RuntimeException) {
            legacyInstallerMigrationStore.saveMigrationFailure(packageName, MIGRATION_FAILED_RUNTIME)
            LegacyInstallerMigrationResult(
                packageName = packageName,
                reasonCode = MIGRATION_FAILED_RUNTIME,
                migrated = false,
            ).also(::logResult)
        }
    }

    private fun logResult(result: LegacyInstallerMigrationResult) {
        val message = "Legacy installer migration: packageName=%s reasonCode=%s migrated=%s"
        if (result.migrated) {
            Timber.i(message, result.packageName, result.reasonCode, result.migrated)
        } else if (result.reasonCode == MIGRATION_FAILED_SECURITY ||
            result.reasonCode == MIGRATION_FAILED_RUNTIME
        ) {
            Timber.w(message, result.packageName, result.reasonCode, result.migrated)
        } else {
            Timber.i(message, result.packageName, result.reasonCode, result.migrated)
        }
    }
}
+4 −0
Original line number Diff line number Diff line
@@ -135,6 +135,10 @@ class AppLoungePackageManager @Inject constructor(
        }
    }

    fun setInstallerPackageName(packageName: String, installerPackageName: String) {
        packageManager.setInstallerPackageName(packageName, installerPackageName)
    }

    @Suppress("TooGenericExceptionCaught")
    fun getInstallSourceDetails(packageName: String): InstallSourceDetails {
        return try {
Loading