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

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

fix(update): add installer ownership eligibility checks

Introduce a single eligibility checker for App Lounge update ownership decisions.

Allow only self-owned updates and a temporary legacy FakeStore compatibility path.
parent 0a3eb252
Loading
Loading
Loading
Loading
+21 −18
Original line number Diff line number Diff line
@@ -267,40 +267,40 @@ Create one source of truth for "may App Lounge attempt this update?".
### Tasks

#### Task 2.1 - Add result and checker types
- [ ] Create `UpdateEligibilityResult`.
- [ ] Include:
- [x] Create `UpdateEligibilityResult`.
- [x] Include:
  - `isEligible`
  - `reasonCode`
  - install-source details used for the decision
  - boolean like `isLegacyMigrationCase`
- [ ] Create `UpdateEligibilityChecker`.
- [x] Create `UpdateEligibilityChecker`.

#### Task 2.2 - Implement permanent rules
- [ ] Allow self-update.
- [ ] Allow when `updateOwnerPackageName == context.packageName`.
- [ ] Allow when `updateOwnerPackageName == null` and `installingPackageName == context.packageName`.
- [ ] Block everything else.
- [x] Allow self-update.
- [x] Allow when `updateOwnerPackageName == context.packageName`.
- [x] Allow when `updateOwnerPackageName == null` and `installingPackageName == context.packageName`.
- [x] Block everything else.

#### Task 2.3 - Implement temporary legacy FakeStore shim
- [ ] Allow when:
- [x] Allow when:
  - `initiatingPackageName == context.packageName`
  - `installingPackageName == AppLoungePackageManager.FAKE_STORE_PACKAGE_NAME`
  - `updateOwnerPackageName == null`
- [ ] Make the result reason code explicit so it is easy to remove later.
- [ ] Add a comment that this path is temporary migration compatibility.
- [x] Make the result reason code explicit so it is easy to remove later.
- [x] Add a comment that this path is temporary migration compatibility.

#### Task 2.4 - Add fail-closed behavior
- [ ] If install-source details cannot be read reliably, block with `BLOCK_UNKNOWN_INSTALL_SOURCE`.
- [ ] Keep the behavior deterministic.
- [x] If install-source details cannot be read reliably, block with `BLOCK_UNKNOWN_INSTALL_SOURCE`.
- [x] Keep the behavior deterministic.

#### Task 2.5 - Make the null-installer bucket explicit
- [ ] Do not allow `initiatingPackageName == context.packageName` by itself.
- [ ] If `installingPackageName == null`, `updateOwnerPackageName == null`, and `initiatingPackageName == context.packageName`, block with a distinct reason code.
- [ ] Add a short comment explaining that this is a separate legacy-risk bucket and not the same as the FakeStore migration case.
- [x] Do not allow `initiatingPackageName == context.packageName` by itself.
- [x] If `installingPackageName == null`, `updateOwnerPackageName == null`, and `initiatingPackageName == context.packageName`, block with a distinct reason code.
- [x] Add a short comment explaining that this is a separate legacy-risk bucket and not the same as the FakeStore migration case.

#### Task 2.6 - Add focused unit tests
- [ ] Create `UpdateEligibilityCheckerTest.kt`.
- [ ] Cover:
- [x] Create `UpdateEligibilityCheckerTest.kt`.
- [x] Cover:
  - self-update allowed
  - update owner is App Lounge allowed
  - installer of record is App Lounge allowed
@@ -337,7 +337,10 @@ Body:

### Iteration Notes

- Add notes here after the phase is done.
- Added a shared `UpdateEligibilityChecker` port and `UpdateEligibilityResult` model in the `data` module, then bound the concrete `AppLoungeUpdateEligibilityChecker` implementation from the `app` module through Hilt.
- The checker now centralizes the permanent allow rules, the temporary FakeStore migration shim, fail-closed unreadable-source handling, and the explicit null-installer legacy block.
- `UpdateEligibilityResult` carries the raw `InstallSourceDetails`, the string reason code used in logs/tests, and an `isLegacyMigrationCase` flag for later migration wiring.
- Focused unit coverage passed for self-update, update-owner allow, installer-of-record allow, FakeStore legacy allow, Aurora/F-Droid/plain adb blocks, App Lounge-stamped adb allow, null-installer legacy block, Play-without-App-Lounge-initiation block, and unreadable source block.

## Phase 3 - Apply Eligibility In Update Discovery

+8 −0
Original line number Diff line number Diff line
@@ -23,6 +23,7 @@ import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import foundation.e.apps.data.install.AppManagerWrapper
import foundation.e.apps.data.install.core.AppLoungeUpdateEligibilityChecker
import foundation.e.apps.data.install.core.helper.InstallationCompletionHandler
import foundation.e.apps.data.install.download.DownloadManagerUtils
import foundation.e.apps.data.install.wrapper.AppEventDispatcher
@@ -38,6 +39,7 @@ import foundation.e.apps.data.installation.port.InstallationDownloadStatusUpdate
import foundation.e.apps.data.installation.port.NetworkStatusChecker
import foundation.e.apps.data.installation.port.ParentalControlAuthGateway
import foundation.e.apps.data.installation.port.StorageSpaceChecker
import foundation.e.apps.data.installation.port.UpdateEligibilityChecker
import foundation.e.apps.data.installation.port.UpdatesNotificationSender
import foundation.e.apps.data.installation.port.UpdatesTracker
import javax.inject.Singleton
@@ -83,4 +85,10 @@ interface AppInstallationModule {
    @Binds
    @Singleton
    fun bindInstallationCompletionNotifier(handler: InstallationCompletionHandler): InstallationCompletionNotifier

    @Binds
    @Singleton
    fun bindUpdateEligibilityChecker(
        checker: AppLoungeUpdateEligibilityChecker
    ): UpdateEligibilityChecker
}
+93 −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 javax.inject.Inject
import javax.inject.Singleton

@Singleton
class AppLoungeUpdateEligibilityChecker @Inject constructor(
    @ApplicationContext private val context: Context,
    private val appLoungePackageManager: AppLoungePackageManager,
) : UpdateEligibilityChecker {

    @Suppress("LongMethod", "ReturnCount")
    override fun getUpdateEligibility(packageName: String): UpdateEligibilityResult {
        val installSourceDetails = appLoungePackageManager.getInstallSourceDetails(packageName)

        if (packageName == context.packageName) {
            return UpdateEligibilityResult(
                isEligible = true,
                reasonCode = UpdateEligibilityResult.ALLOW_SELF_UPDATE,
                installSourceDetails = installSourceDetails,
            )
        }

        if (!installSourceDetails.readSucceeded) {
            return UpdateEligibilityResult(
                isEligible = false,
                reasonCode = UpdateEligibilityResult.BLOCK_UNKNOWN_INSTALL_SOURCE,
                installSourceDetails = installSourceDetails,
            )
        }

        if (installSourceDetails.updateOwnerPackageName == context.packageName) {
            return UpdateEligibilityResult(
                isEligible = true,
                reasonCode = UpdateEligibilityResult.ALLOW_UPDATE_OWNER,
                installSourceDetails = installSourceDetails,
            )
        }

        if (installSourceDetails.updateOwnerPackageName == null &&
            installSourceDetails.installingPackageName == context.packageName
        ) {
            return UpdateEligibilityResult(
                isEligible = true,
                reasonCode = UpdateEligibilityResult.ALLOW_INSTALLER_OF_RECORD,
                installSourceDetails = installSourceDetails,
            )
        }

        // Temporary migration compatibility for legacy App Lounge Play installs that were
        // previously re-stamped to FakeStore without an update owner.
        if (installSourceDetails.initiatingPackageName == context.packageName &&
            installSourceDetails.installingPackageName == AppLoungePackageManager.FAKE_STORE_PACKAGE_NAME &&
            installSourceDetails.updateOwnerPackageName == null
        ) {
            return UpdateEligibilityResult(
                isEligible = true,
                reasonCode = UpdateEligibilityResult.ALLOW_LEGACY_FAKESTORE_MIGRATION,
                installSourceDetails = installSourceDetails,
                isLegacyMigrationCase = true,
            )
        }

        // This is a separate legacy-risk bucket and not the same as the FakeStore migration case.
        if (installSourceDetails.installingPackageName == null &&
            installSourceDetails.updateOwnerPackageName == null &&
            installSourceDetails.initiatingPackageName == context.packageName
        ) {
            return UpdateEligibilityResult(
                isEligible = false,
                reasonCode = UpdateEligibilityResult.BLOCK_APP_LOUNGE_INITIATED_NULL_INSTALLER,
                installSourceDetails = installSourceDetails,
            )
        }

        val reasonCode = if (installSourceDetails.updateOwnerPackageName != null) {
            UpdateEligibilityResult.BLOCK_OTHER_UPDATE_OWNER
        } else {
            UpdateEligibilityResult.BLOCK_OTHER_INSTALLER
        }

        return UpdateEligibilityResult(
            isEligible = false,
            reasonCode = reasonCode,
            installSourceDetails = installSourceDetails,
        )
    }
}
+242 −0
Original line number Diff line number Diff line
package foundation.e.apps.data.install.core

import android.content.Context
import foundation.e.apps.data.install.pkg.AppLoungePackageManager
import foundation.e.apps.data.installation.model.InstallSourceDetails
import foundation.e.apps.data.installation.model.UpdateEligibilityResult
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.MockitoAnnotations

class UpdateEligibilityCheckerTest {

    @Mock
    private lateinit var context: Context

    @Mock
    private lateinit var appLoungePackageManager: AppLoungePackageManager

    private lateinit var checker: AppLoungeUpdateEligibilityChecker

    @Before
    fun setup() {
        MockitoAnnotations.openMocks(this)
        Mockito.`when`(context.packageName).thenReturn(APP_LOUNGE_PACKAGE_NAME)
        checker = AppLoungeUpdateEligibilityChecker(context, appLoungePackageManager)
    }

    @Test
    fun getUpdateEligibility_allowsSelfUpdate() {
        mockInstallSourceDetails(
            packageName = APP_LOUNGE_PACKAGE_NAME,
            installSourceDetails = readableInstallSource(
                initiatingPackageName = APP_LOUNGE_PACKAGE_NAME,
                installingPackageName = APP_LOUNGE_PACKAGE_NAME,
                updateOwnerPackageName = APP_LOUNGE_PACKAGE_NAME,
            )
        )

        val result = checker.getUpdateEligibility(APP_LOUNGE_PACKAGE_NAME)

        assertAllowed(result, UpdateEligibilityResult.ALLOW_SELF_UPDATE)
    }

    @Test
    fun getUpdateEligibility_allowsWhenAppLoungeIsUpdateOwner() {
        mockInstallSourceDetails(
            packageName = TARGET_PACKAGE_NAME,
            installSourceDetails = readableInstallSource(
                installingPackageName = "com.aurora.store",
                updateOwnerPackageName = APP_LOUNGE_PACKAGE_NAME,
            )
        )

        val result = checker.getUpdateEligibility(TARGET_PACKAGE_NAME)

        assertAllowed(result, UpdateEligibilityResult.ALLOW_UPDATE_OWNER)
    }

    @Test
    fun getUpdateEligibility_allowsWhenAppLoungeIsInstallerOfRecord() {
        mockInstallSourceDetails(
            packageName = TARGET_PACKAGE_NAME,
            installSourceDetails = readableInstallSource(
                installingPackageName = APP_LOUNGE_PACKAGE_NAME,
                updateOwnerPackageName = null,
            )
        )

        val result = checker.getUpdateEligibility(TARGET_PACKAGE_NAME)

        assertAllowed(result, UpdateEligibilityResult.ALLOW_INSTALLER_OF_RECORD)
    }

    @Test
    fun getUpdateEligibility_allowsLegacyFakeStoreMigrationCase() {
        mockInstallSourceDetails(
            packageName = TARGET_PACKAGE_NAME,
            installSourceDetails = readableInstallSource(
                initiatingPackageName = APP_LOUNGE_PACKAGE_NAME,
                installingPackageName = AppLoungePackageManager.FAKE_STORE_PACKAGE_NAME,
                updateOwnerPackageName = null,
            )
        )

        val result = checker.getUpdateEligibility(TARGET_PACKAGE_NAME)

        assertAllowed(result, UpdateEligibilityResult.ALLOW_LEGACY_FAKESTORE_MIGRATION)
        assertTrue(result.isLegacyMigrationCase)
    }

    @Test
    fun getUpdateEligibility_blocksAuroraInstaller() {
        mockInstallSourceDetails(
            packageName = TARGET_PACKAGE_NAME,
            installSourceDetails = readableInstallSource(
                installingPackageName = "com.aurora.store",
                updateOwnerPackageName = null,
            )
        )

        val result = checker.getUpdateEligibility(TARGET_PACKAGE_NAME)

        assertBlocked(result, UpdateEligibilityResult.BLOCK_OTHER_INSTALLER)
    }

    @Test
    fun getUpdateEligibility_blocksFDroidInstaller() {
        mockInstallSourceDetails(
            packageName = TARGET_PACKAGE_NAME,
            installSourceDetails = readableInstallSource(
                installingPackageName = "org.fdroid.fdroid",
                updateOwnerPackageName = null,
            )
        )

        val result = checker.getUpdateEligibility(TARGET_PACKAGE_NAME)

        assertBlocked(result, UpdateEligibilityResult.BLOCK_OTHER_INSTALLER)
    }

    @Test
    fun getUpdateEligibility_blocksPlainAdbInstall() {
        mockInstallSourceDetails(
            packageName = TARGET_PACKAGE_NAME,
            installSourceDetails = readableInstallSource(
                initiatingPackageName = null,
                installingPackageName = null,
                updateOwnerPackageName = null,
            )
        )

        val result = checker.getUpdateEligibility(TARGET_PACKAGE_NAME)

        assertBlocked(result, UpdateEligibilityResult.BLOCK_OTHER_INSTALLER)
    }

    @Test
    fun getUpdateEligibility_allowsAdbInstallStampedWithAppLoungeInstaller() {
        mockInstallSourceDetails(
            packageName = TARGET_PACKAGE_NAME,
            installSourceDetails = readableInstallSource(
                initiatingPackageName = null,
                installingPackageName = APP_LOUNGE_PACKAGE_NAME,
                updateOwnerPackageName = null,
            )
        )

        val result = checker.getUpdateEligibility(TARGET_PACKAGE_NAME)

        assertAllowed(result, UpdateEligibilityResult.ALLOW_INSTALLER_OF_RECORD)
    }

    @Test
    fun getUpdateEligibility_blocksAppLoungeInitiatedNullInstallerBucket() {
        mockInstallSourceDetails(
            packageName = TARGET_PACKAGE_NAME,
            installSourceDetails = readableInstallSource(
                initiatingPackageName = APP_LOUNGE_PACKAGE_NAME,
                installingPackageName = null,
                updateOwnerPackageName = null,
            )
        )

        val result = checker.getUpdateEligibility(TARGET_PACKAGE_NAME)

        assertBlocked(
            result,
            UpdateEligibilityResult.BLOCK_APP_LOUNGE_INITIATED_NULL_INSTALLER,
        )
    }

    @Test
    fun getUpdateEligibility_blocksPlayInstallerWhenAppLoungeDidNotInitiateInstall() {
        mockInstallSourceDetails(
            packageName = TARGET_PACKAGE_NAME,
            installSourceDetails = readableInstallSource(
                initiatingPackageName = "com.android.vending",
                installingPackageName = AppLoungePackageManager.FAKE_STORE_PACKAGE_NAME,
                updateOwnerPackageName = null,
            )
        )

        val result = checker.getUpdateEligibility(TARGET_PACKAGE_NAME)

        assertBlocked(result, UpdateEligibilityResult.BLOCK_OTHER_INSTALLER)
    }

    @Test
    fun getUpdateEligibility_blocksUnreadableInstallSource() {
        mockInstallSourceDetails(
            packageName = TARGET_PACKAGE_NAME,
            installSourceDetails = InstallSourceDetails(
                readSucceeded = false,
                failureReason = "NameNotFoundException",
            )
        )

        val result = checker.getUpdateEligibility(TARGET_PACKAGE_NAME)

        assertBlocked(result, UpdateEligibilityResult.BLOCK_UNKNOWN_INSTALL_SOURCE)
    }

    private fun mockInstallSourceDetails(
        packageName: String,
        installSourceDetails: InstallSourceDetails,
    ) {
        Mockito.`when`(appLoungePackageManager.getInstallSourceDetails(packageName))
            .thenReturn(installSourceDetails)
    }

    private fun readableInstallSource(
        initiatingPackageName: String? = null,
        installingPackageName: String? = null,
        updateOwnerPackageName: String? = null,
    ) = InstallSourceDetails(
        initiatingPackageName = initiatingPackageName,
        installingPackageName = installingPackageName,
        updateOwnerPackageName = updateOwnerPackageName,
        readSucceeded = true,
    )

    private fun assertAllowed(result: UpdateEligibilityResult, reasonCode: String) {
        assertTrue(result.isEligible)
        assertEquals(reasonCode, result.reasonCode)
    }

    private fun assertBlocked(result: UpdateEligibilityResult, reasonCode: String) {
        assertFalse(result.isEligible)
        assertEquals(reasonCode, result.reasonCode)
        assertFalse(result.isLegacyMigrationCase)
    }

    companion object {
        private const val APP_LOUNGE_PACKAGE_NAME = "foundation.e.apps"
        private const val TARGET_PACKAGE_NAME = "com.example.target"
    }
}
+20 −0
Original line number Diff line number Diff line
package foundation.e.apps.data.installation.model

data class UpdateEligibilityResult(
    val isEligible: Boolean,
    val reasonCode: String,
    val installSourceDetails: InstallSourceDetails,
    val isLegacyMigrationCase: Boolean = false,
) {
    companion object {
        const val ALLOW_SELF_UPDATE = "ALLOW_SELF_UPDATE"
        const val ALLOW_UPDATE_OWNER = "ALLOW_UPDATE_OWNER"
        const val ALLOW_INSTALLER_OF_RECORD = "ALLOW_INSTALLER_OF_RECORD"
        const val ALLOW_LEGACY_FAKESTORE_MIGRATION = "ALLOW_LEGACY_FAKESTORE_MIGRATION"
        const val BLOCK_OTHER_UPDATE_OWNER = "BLOCK_OTHER_UPDATE_OWNER"
        const val BLOCK_OTHER_INSTALLER = "BLOCK_OTHER_INSTALLER"
        const val BLOCK_APP_LOUNGE_INITIATED_NULL_INSTALLER =
            "BLOCK_APP_LOUNGE_INITIATED_NULL_INSTALLER"
        const val BLOCK_UNKNOWN_INSTALL_SOURCE = "BLOCK_UNKNOWN_INSTALL_SOURCE"
    }
}
Loading