diff --git a/app/build.gradle b/app/build.gradle index c025bde97..759d87d49 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -226,8 +226,10 @@ dependencies { testImplementation libs.androidx.test.core testImplementation libs.junit testImplementation libs.robolectric + testImplementation libs.mockk testImplementation libs.mockito.core testImplementation libs.hamcrest + testImplementation libs.slf4j.simple androidTestImplementation libs.androidx.test.core androidTestImplementation libs.androidx.core.testing diff --git a/app/src/androidTest/proguard-rules.pro b/app/src/androidTest/proguard-rules.pro index 7279fe2f7..ce7d3875f 100644 --- a/app/src/androidTest/proguard-rules.pro +++ b/app/src/androidTest/proguard-rules.pro @@ -19,7 +19,11 @@ -keep class junit.** { *; } -dontwarn junit.** +-keep class kotlin.reflect.** { *; } -keep class io.mockk.** { *; } +-keep class kotlin.io.** { *; } +-keep class kotlin.collections.** { *; } +-keep class java.util.concurrent.Executor { *; } -keep class androidx.arch.core.executor.ArchTaskExecutor {*;} @@ -28,4 +32,4 @@ } # This is necessary so that RemoteWorkManager can be initialized (also marked with @Keep) --keep class androidx.work.WorkManager { *; } +-keep class androidx.work.** { *; } diff --git a/app/src/main/java/org/fdroid/fdroid/AppUpdateManager.kt b/app/src/main/java/org/fdroid/fdroid/AppUpdateManager.kt index 8fa3133dd..59dcfdf62 100644 --- a/app/src/main/java/org/fdroid/fdroid/AppUpdateManager.kt +++ b/app/src/main/java/org/fdroid/fdroid/AppUpdateManager.kt @@ -2,29 +2,31 @@ package org.fdroid.fdroid import android.content.Context import android.net.Uri +import androidx.core.net.toUri import mu.KotlinLogging import org.acra.util.versionCodeLong import org.fdroid.database.DbUpdateChecker import org.fdroid.database.Repository import org.fdroid.database.UpdatableApp +import org.fdroid.download.DownloaderFactory import org.fdroid.fdroid.AppUpdateStatusManager.Status.Downloading import org.fdroid.fdroid.data.Apk import org.fdroid.fdroid.data.App import org.fdroid.fdroid.installer.ApkCache import org.fdroid.fdroid.installer.InstallManagerService import org.fdroid.fdroid.installer.InstallerFactory -import org.fdroid.fdroid.net.DownloaderFactory import org.fdroid.index.RepoManager -class AppUpdateManager( +class AppUpdateManager @JvmOverloads constructor( private val context: Context, private val repoManager: RepoManager, private val updateChecker: DbUpdateChecker, + private val downloaderFactory: DownloaderFactory = + org.fdroid.fdroid.net.DownloaderFactory.INSTANCE, + private val statusManager: AppUpdateStatusManager = AppUpdateStatusManager.getInstance(context), ) { private val log = KotlinLogging.logger { } - private val downloaderFactory = DownloaderFactory.INSTANCE - private val statusManager = AppUpdateStatusManager.getInstance(context) fun updateApps() { // get apps with updates pending @@ -33,6 +35,7 @@ class AppUpdateManager( .sortedWith { app1, app2 -> // our own app will be last to update if (app1.packageName == context.packageName) return@sortedWith 1 + if (app2.packageName == context.packageName) return@sortedWith -1 // other apps are sorted by name (app1.name ?: "").compareTo(app2.name ?: "", ignoreCase = true) } @@ -46,7 +49,7 @@ class AppUpdateManager( private val installManagerService = InstallManagerService.getInstance(context) private val legacyApp = App(app) private val legacyApk = Apk(app.update, repo) - private val uri = Uri.parse(legacyApk.canonicalUrl) + private val uri = legacyApk.canonicalUrl.toUri() private var lastProgress = 0L override fun onInstallProcessStarted() { @@ -80,7 +83,7 @@ class AppUpdateManager( // legacy cruft val legacyApp = App(app) val legacyApk = Apk(app.update, repo) - val uri = Uri.parse(legacyApk.canonicalUrl) + val uri = legacyApk.canonicalUrl.toUri() // check if app was already installed in the meantime try { val packageInfo = context.packageManager.getPackageInfo(app.packageName, 0) diff --git a/app/src/test/java/org/fdroid/fdroid/AppUpdateManagerTest.kt b/app/src/test/java/org/fdroid/fdroid/AppUpdateManagerTest.kt new file mode 100644 index 000000000..69b24214c --- /dev/null +++ b/app/src/test/java/org/fdroid/fdroid/AppUpdateManagerTest.kt @@ -0,0 +1,288 @@ +package org.fdroid.fdroid + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.net.Uri +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import io.mockk.verifyOrder +import org.fdroid.database.AppManifest +import org.fdroid.database.AppVersion +import org.fdroid.database.DbUpdateChecker +import org.fdroid.database.Repository +import org.fdroid.database.UpdatableApp +import org.fdroid.download.Downloader +import org.fdroid.fdroid.data.App +import org.fdroid.fdroid.installer.InstallManagerService +import org.fdroid.fdroid.installer.Installer +import org.fdroid.fdroid.installer.InstallerFactory +import org.fdroid.index.RepoManager +import org.fdroid.index.v2.FileV1 +import org.junit.Test +import java.io.File +import java.io.IOException + +class AppUpdateManagerTest { + + private val context: Context = mockk() + private val repoManager: RepoManager = mockk() + private val updateChecker: DbUpdateChecker = mockk() + private val preferences: Preferences = mockk() + private val downloaderFactory: org.fdroid.download.DownloaderFactory = mockk() + private val statusManager: AppUpdateStatusManager = mockk() + + private val appUpdateManager = + AppUpdateManager(context, repoManager, updateChecker, downloaderFactory, statusManager) + + private val downloader: Downloader = mockk() + private val installer: Installer = mockk() + private val packageManager: PackageManager = mockk() + private val installManagerService: InstallManagerService = mockk(relaxed = true) + private val repoUri: Uri = mockk(relaxed = true) + + private val app1 = App().apply { + packageName = "org.example.1" + name = "One" + repoId = 42 + } + private val app2 = App().apply { + packageName = "org.example.2" + name = "Two" + repoId = 42 + } + private val repo: Repository = mockk(relaxed = true) { + every { repoId } returns app1.repoId + every { address } returns "https://example.org/repo" + } + private val file1 = FileV1("one", "one") + private val file2 = FileV1("two", "one") + private val version1: AppVersion = mockk(relaxed = true) { + every { repoId } returns app1.repoId + every { packageName } returns app1.packageName + every { manifest } returns AppManifest("1", 1) + every { file } returns file1 + } + private val version2: AppVersion = mockk(relaxed = true) { + every { repoId } returns app2.repoId + every { packageName } returns app2.packageName + every { manifest } returns AppManifest("2", 2) + every { file } returns file2 + } + + // need to mock apps as their constructor is internal + private val updatableApp1: UpdatableApp = mockk(relaxed = true) { + every { packageName } returns app1.packageName + every { name } returns app1.name + every { summary } returns app1.summary + every { repoId } returns app1.repoId + every { update } returns version1 + every { update.file } returns file1 + every { update.manifest } returns AppManifest("1", 1) + every { update.repoId } returns app1.repoId + every { update.packageName } returns app1.packageName + every { installedVersionCode } returns app1.installedVersionCode + } + private val updatableApp2: UpdatableApp = mockk(relaxed = true) { + every { packageName } returns app2.packageName + every { name } returns app2.name + every { summary } returns app2.summary + every { repoId } returns app2.repoId + every { update } returns version2 + every { update.repoId } returns app2.repoId + every { update.packageName } returns app2.packageName + every { update.file } returns file2 + every { update.manifest } returns AppManifest("2", 2) + every { installedVersionCode } returns app2.installedVersionCode + } + + init { + mockkStatic(Preferences::get) + every { Preferences.get() } returns preferences + + mockkStatic(InstallManagerService::getInstance) + every { InstallManagerService.getInstance(any()) } returns installManagerService + + mockkStatic(Uri::parse) + every { Uri.parse(any()) } returns repoUri + + mockkStatic(InstallerFactory::create) + every { InstallerFactory.create(any(), any(), any()) } returns installer + + every { context.packageManager } returns packageManager + } + + @Test + fun testNoUpdates() { + val updates = emptyList() + + every { preferences.backendReleaseChannels } returns null + every { + updateChecker.getUpdatableApps( + releaseChannels = null, + onlyFromPreferredRepo = true, + includeKnownVulnerabilities = false, + ) + } returns updates + every { statusManager.addUpdatableApps(updates, false) } just Runs + + appUpdateManager.updateApps() + } + + @Test + fun testSomeUpdates() { + val updates = listOf(updatableApp1, updatableApp2) + + every { preferences.backendReleaseChannels } returns null + every { + updateChecker.getUpdatableApps( + releaseChannels = null, + onlyFromPreferredRepo = true, + includeKnownVulnerabilities = false, + ) + } returns updates + every { context.packageName } returns null + every { repoManager.getRepository(app1.repoId) } returns repo + every { repoManager.getRepository(app2.repoId) } returns repo + every { statusManager.addUpdatableApps(updates, false) } just Runs + every { statusManager.addApk(any(), any(), any(), any()) } just Runs + every { + packageManager.getPackageInfo(any(), any()) + } returns getPackageInfo(0) + every { context.cacheDir } returns File("/tmp/fdroid-app-update-test") + every { downloaderFactory.create(repo, any(), file1, any()) } returns downloader + every { downloaderFactory.create(repo, any(), file2, any()) } returns downloader + every { downloader.setListener(any()) } just Runs + every { downloader.download() } just Runs + every { installer.installPackage(any(), any()) } just Runs + + appUpdateManager.updateApps() + + verify(exactly = 2) { + installManagerService.onDownloadComplete(any()) + installer.installPackage(any(), any()) + } + } + + @Test + fun testVersionAlreadyInstalled() { + val updates = listOf(updatableApp1, updatableApp2) + + every { preferences.backendReleaseChannels } returns null + every { + updateChecker.getUpdatableApps( + releaseChannels = null, + onlyFromPreferredRepo = true, + includeKnownVulnerabilities = false, + ) + } returns updates + every { context.packageName } returns null + every { repoManager.getRepository(app1.repoId) } returns repo + every { repoManager.getRepository(app2.repoId) } returns repo + every { statusManager.addUpdatableApps(updates, false) } just Runs + every { statusManager.addApk(any(), any(), any(), any()) } just Runs + every { + packageManager.getPackageInfo(app1.packageName, any()) + } returns getPackageInfo(1) + every { + packageManager.getPackageInfo(app2.packageName, any()) + } returns getPackageInfo(2) + + appUpdateManager.updateApps() + + verify(exactly = 0) { + installManagerService.onDownloadComplete(any()) + installer.installPackage(any(), any()) + } + } + + @Test + fun testOurOwnAppIsUpdatedLast() { + val installer1: Installer = mockk() + val installer2: Installer = mockk() + val updates = listOf(updatableApp1, updatableApp2) + val updatesSorted = listOf(updatableApp2, updatableApp1) + + every { preferences.backendReleaseChannels } returns null + every { + updateChecker.getUpdatableApps( + releaseChannels = null, + onlyFromPreferredRepo = true, + includeKnownVulnerabilities = false, + ) + } returns updates + every { context.packageName } returns app1.packageName // we are app1 + every { repoManager.getRepository(app1.repoId) } returns repo + every { repoManager.getRepository(app2.repoId) } returns repo + every { statusManager.addUpdatableApps(updatesSorted, false) } just Runs + every { statusManager.addApk(any(), any(), any(), any()) } just Runs + every { + packageManager.getPackageInfo(any(), any()) + } returns getPackageInfo(0) + every { context.cacheDir } returns File("/tmp/fdroid-app-update-test") + every { downloaderFactory.create(repo, any(), file1, any()) } returns downloader + every { downloaderFactory.create(repo, any(), file2, any()) } returns downloader + every { downloader.setListener(any()) } just Runs + every { downloader.download() } just Runs + every { + InstallerFactory.create(any(), match { it.packageName == app1.packageName }, any()) + } returns installer1 + every { + InstallerFactory.create(any(), match { it.packageName == app2.packageName }, any()) + } returns installer2 + every { installer1.installPackage(any(), any()) } just Runs + every { installer2.installPackage(any(), any()) } just Runs + + appUpdateManager.updateApps() + + verifyOrder { + // app1 gets installed last, because it is us and updating us kills us + installer2.installPackage(any(), any()) + installer1.installPackage(any(), any()) + } + } + + @Test + fun testFailedDownloadSkipsUpdate() { + val updates = listOf(updatableApp1) + + every { preferences.backendReleaseChannels } returns null + every { + updateChecker.getUpdatableApps( + releaseChannels = null, + onlyFromPreferredRepo = true, + includeKnownVulnerabilities = false, + ) + } returns updates + every { context.packageName } returns null + every { repoManager.getRepository(app1.repoId) } returns repo + every { statusManager.addUpdatableApps(updates, false) } just Runs + every { statusManager.addApk(any(), any(), any(), any()) } just Runs + every { + packageManager.getPackageInfo(any(), any()) + } returns getPackageInfo(0) + every { context.cacheDir } returns File("/tmp/fdroid-app-update-test") + every { downloaderFactory.create(repo, any(), file1, any()) } returns downloader + every { downloader.setListener(any()) } just Runs + every { downloader.download() } throws IOException("foo bar") + + appUpdateManager.updateApps() + + verify(exactly = 0) { + installer.installPackage(any(), any()) + } + verify { + installManagerService.onDownloadFailed(any(), any()) + } + } + + private fun getPackageInfo(versionCode: Int) = PackageInfo().apply { + @Suppress("DEPRECATION") // longVersionCode doesn't work + this.versionCode = versionCode + } + +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c8456d48..a137a3eb0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -151,6 +151,7 @@ androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidxT androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" } androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "androidxWork" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4jApi" } +slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4jApi" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } hamcrest = { module = "org.hamcrest:hamcrest", version.ref = "hamcrest" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCore" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 929d26723..08f865c85 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -15272,6 +15272,11 @@ + + + + +