From 6309561c9f7c75a442b9f6d23b924cfb0b87caa0 Mon Sep 17 00:00:00 2001 From: TheScarastic Date: Mon, 30 Mar 2026 14:21:53 +0530 Subject: [PATCH 1/2] Apps: Add support for shared library --- .../apps/data/application/data/Application.kt | 2 + .../downloadInfo/DownloadInfoApiImpl.kt | 21 ++ .../application/utils/GplayApiExtensions.kt | 6 +- .../database/install/AppInstallConverter.kt | 8 + .../database/install/AppInstallDatabase.kt | 13 +- .../e/apps/data/install/AppManagerImpl.kt | 24 +- .../install/download/DownloadManagerUtils.kt | 18 +- .../e/apps/data/install/models/AppInstall.kt | 3 +- .../e/apps/data/install/models/SharedLib.kt | 30 +++ .../install/pkg/AppLoungePackageManager.kt | 240 ++++++++++++++++-- .../install/sharedlib/SharedLibraryManager.kt | 103 ++++++++ .../workmanager/AppInstallProcessor.kt | 19 ++ .../install/AppInstallConverterTest.kt | 17 ++ 13 files changed, 468 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/data/install/models/SharedLib.kt create mode 100644 app/src/main/java/foundation/e/apps/data/install/sharedlib/SharedLibraryManager.kt diff --git a/app/src/main/java/foundation/e/apps/data/application/data/Application.kt b/app/src/main/java/foundation/e/apps/data/application/data/Application.kt index d10703978..ff73b787c 100644 --- a/app/src/main/java/foundation/e/apps/data/application/data/Application.kt +++ b/app/src/main/java/foundation/e/apps/data/application/data/Application.kt @@ -30,6 +30,7 @@ import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Type import foundation.e.apps.data.enums.Type.NATIVE import foundation.e.apps.data.enums.Type.PWA +import foundation.e.apps.data.install.models.SharedLib import foundation.e.apps.domain.model.install.Status data class Application( @@ -101,6 +102,7 @@ data class Application( @SerializedName(value = "antifeatures") val antiFeatures: List> = emptyList(), var isSystemApp: Boolean = false, + val dependentLibraries: List = emptyList(), ) { val iconUrl: String? get() { diff --git a/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImpl.kt b/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImpl.kt index 47a1b720c..4a683339d 100644 --- a/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImpl.kt @@ -93,6 +93,27 @@ class DownloadInfoApiImpl @Inject constructor( ) appInstall.files = downloadList list.addAll(downloadList.map { it.url }) + + appInstall.sharedLibs.forEach { lib -> + if (lib.downloadUrls.isEmpty()) { + val libFiles = runCatching { + appSources.gplayRepo.getDownloadInfo(lib.packageName, lib.versionCode, lib.offerType) + }.getOrElse { e -> + throw IllegalStateException( + "Cannot install ${appInstall.packageName}: " + + "failed to fetch download info for required library ${lib.packageName}", + e + ) + } + if (libFiles.isEmpty()) { + error( + "Cannot install ${appInstall.packageName}: " + + "no download URLs returned for required library ${lib.packageName}" + ) + } + lib.downloadUrls = libFiles.map { it.url } + } + } } private suspend fun updateDownloadInfoFromCleanApk( diff --git a/app/src/main/java/foundation/e/apps/data/application/utils/GplayApiExtensions.kt b/app/src/main/java/foundation/e/apps/data/application/utils/GplayApiExtensions.kt index 1092662d8..50a216086 100644 --- a/app/src/main/java/foundation/e/apps/data/application/utils/GplayApiExtensions.kt +++ b/app/src/main/java/foundation/e/apps/data/application/utils/GplayApiExtensions.kt @@ -25,6 +25,7 @@ import com.aurora.gplayapi.data.models.Artwork import com.aurora.gplayapi.data.models.Category import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.application.data.Ratings +import foundation.e.apps.data.install.models.SharedLib import foundation.e.apps.data.application.data.Category as AppLoungeCategory fun App.toApplication(context: Context): Application { @@ -56,7 +57,10 @@ fun App.toApplication(context: Context): Application { isFree = this.isFree, price = this.price, restriction = this.restriction, - contentRating = this.contentRating + contentRating = this.contentRating, + dependentLibraries = this.dependencies.dependentLibraries.map { + SharedLib(packageName = it.packageName, versionCode = it.versionCode, offerType = it.offerType) + } ) return app } diff --git a/app/src/main/java/foundation/e/apps/data/database/install/AppInstallConverter.kt b/app/src/main/java/foundation/e/apps/data/database/install/AppInstallConverter.kt index 3c860e005..81e22e311 100644 --- a/app/src/main/java/foundation/e/apps/data/database/install/AppInstallConverter.kt +++ b/app/src/main/java/foundation/e/apps/data/database/install/AppInstallConverter.kt @@ -5,6 +5,7 @@ import com.aurora.gplayapi.data.models.ContentRating import com.aurora.gplayapi.data.models.PlayFile import com.google.gson.Gson import com.google.gson.reflect.TypeToken +import foundation.e.apps.data.install.models.SharedLib class AppInstallConverter { @@ -40,4 +41,11 @@ class AppInstallConverter { fun toContentRating(name: String): ContentRating { return gson.fromJson(name, ContentRating::class.java) } + + @TypeConverter + fun sharedLibsToJson(value: List): String = gson.toJson(value) + + @TypeConverter + fun jsonToSharedLibs(value: String): List = + gson.fromJson(value, object : TypeToken>() {}.type) ?: emptyList() } diff --git a/app/src/main/java/foundation/e/apps/data/database/install/AppInstallDatabase.kt b/app/src/main/java/foundation/e/apps/data/database/install/AppInstallDatabase.kt index 011d4af7c..0ab083369 100644 --- a/app/src/main/java/foundation/e/apps/data/database/install/AppInstallDatabase.kt +++ b/app/src/main/java/foundation/e/apps/data/database/install/AppInstallDatabase.kt @@ -5,11 +5,13 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import foundation.e.apps.data.database.AppDatabase import foundation.e.apps.data.install.AppInstallDAO import foundation.e.apps.data.install.models.AppInstall -@Database(entities = [AppInstall::class], version = 6, exportSchema = false) +@Database(entities = [AppInstall::class], version = 7, exportSchema = false) @TypeConverters(AppInstallConverter::class) abstract class AppInstallDatabase : RoomDatabase() { abstract fun fusedDownloadDao(): AppInstallDAO @@ -18,11 +20,20 @@ abstract class AppInstallDatabase : RoomDatabase() { private lateinit var INSTANCE: AppInstallDatabase private const val DATABASE_NAME = "fused_database" + val migration6To7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE FusedDownload ADD COLUMN sharedLibs TEXT NOT NULL DEFAULT '[]'" + ) + } + } + fun getInstance(context: Context): AppInstallDatabase { if (!Companion::INSTANCE.isInitialized) { synchronized(AppDatabase::class) { INSTANCE = Room.databaseBuilder(context, AppInstallDatabase::class.java, DATABASE_NAME) + .addMigrations(migration6To7) .fallbackToDestructiveMigration() .build() } diff --git a/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt b/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt index b7509d197..4d2340a53 100644 --- a/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt @@ -33,6 +33,7 @@ import foundation.e.apps.data.install.download.data.DownloadProgressLD import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.data.install.pkg.AppLoungePackageManager import foundation.e.apps.data.install.pkg.PwaManager +import foundation.e.apps.data.install.sharedlib.SharedLibraryManager import foundation.e.apps.data.parentalcontrol.ContentRatingDao import foundation.e.apps.data.parentalcontrol.ContentRatingEntity import foundation.e.apps.domain.model.install.Status @@ -55,6 +56,7 @@ class AppManagerImpl @Inject constructor( private val appInstallRepository: AppInstallRepository, private val pwaManager: PwaManager, private val appLoungePackageManager: AppLoungePackageManager, + private val sharedLibraryManager: SharedLibraryManager, @Named("download") private val downloadNotificationChannel: NotificationChannel, @Named("update") private val updateNotificationChannel: NotificationChannel, @ApplicationContext private val context: Context @@ -137,8 +139,10 @@ class AppManagerImpl @Inject constructor( val list = mutableListOf() when (appInstall.type) { Type.NATIVE -> { + // Collect main app APKs (exclude the libraries/ subdir) val parentPathFile = File("$cacheDir/${appInstall.packageName}") - parentPathFile.listFiles()?.let { list.addAll(it) } + parentPathFile.listFiles { f -> f.isFile && f.name.endsWith(".apk") } + ?.let { list.addAll(it) } list.sort() if (list.isEmpty()) { @@ -149,9 +153,15 @@ class AppManagerImpl @Inject constructor( throw IllegalStateException(errorMessage) } + // Collect shared lib files that are not already installed + val sharedLibsToInstall = sharedLibraryManager.getLibFilesForInstall(appInstall) + try { - Timber.d("installApp: STARTED ${appInstall.name} ${list.size}") - appLoungePackageManager.installApplication(list, appInstall.packageName) + Timber.d( + "installApp: STARTED ${appInstall.name} ${list.size} " + + "sharedLibs=${sharedLibsToInstall.size}" + ) + appLoungePackageManager.installApplication(list, appInstall.packageName, sharedLibsToInstall) Timber.d("installApp: ENDED ${appInstall.name} ${list.size}") } catch (e: Exception) { Timber.e(">>> installApp app failed ${e.localizedMessage}") @@ -225,6 +235,14 @@ class AppManagerImpl @Inject constructor( appInstall.status = Status.DOWNLOADING appInstallRepository.updateDownload(appInstall) DownloadProgressLD.setDownloadId(-1) + + // Download shared libraries into per-lib subdirectories + sharedLibraryManager.enqueueMissingLibraryDownloads( + downloadManager = downloadManager, + appInstall = appInstall, + ) + appInstallRepository.updateDownload(appInstall) + appInstall.downloadURLList.forEach { count += 1 val packagePath: File = if (appInstall.files.isNotEmpty()) { diff --git a/app/src/main/java/foundation/e/apps/data/install/download/DownloadManagerUtils.kt b/app/src/main/java/foundation/e/apps/data/install/download/DownloadManagerUtils.kt index 883f37b76..454d2bd31 100644 --- a/app/src/main/java/foundation/e/apps/data/install/download/DownloadManagerUtils.kt +++ b/app/src/main/java/foundation/e/apps/data/install/download/DownloadManagerUtils.kt @@ -137,7 +137,10 @@ class DownloadManagerUtils @Inject constructor( appInstall ) - if (isDownloadSuccessful.first && areAllFilesDownloaded && checkCleanApkSignatureOK(appInstall)) { + if (isDownloadSuccessful.first && areAllFilesDownloaded && checkCleanApkSignatureOK( + appInstall + ) + ) { handleDownloadSuccess(appInstall) return } @@ -158,9 +161,13 @@ class DownloadManagerUtils @Inject constructor( private fun areAllFilesDownloaded( numberOfDownloadedItems: Int, appInstall: AppInstall - ) = - numberOfDownloadedItems == appInstall.downloadIdMap.size && - numberOfDownloadedItems == appInstall.downloadURLList.size + ): Boolean { + val expectedCount = appInstall.downloadURLList.size + + appInstall.sharedLibs.sumOf { it.downloadIds.size } + return expectedCount > 0 && + numberOfDownloadedItems == appInstall.downloadIdMap.size && + numberOfDownloadedItems == expectedCount + } private suspend fun updateDownloadIdMap( appInstall: AppInstall, @@ -171,7 +178,8 @@ class DownloadManagerUtils @Inject constructor( } private suspend fun checkCleanApkSignatureOK(appInstall: AppInstall): Boolean { - val isNonOpenSource = appInstall.source != Source.PWA && appInstall.source != Source.OPEN_SOURCE + val isNonOpenSource = + appInstall.source != Source.PWA && appInstall.source != Source.OPEN_SOURCE val isSigned = appManagerWrapper.isFDroidApplicationSigned(context, appInstall) if (isNonOpenSource || isSigned) { Timber.d("Apk signature is OK") diff --git a/app/src/main/java/foundation/e/apps/data/install/models/AppInstall.kt b/app/src/main/java/foundation/e/apps/data/install/models/AppInstall.kt index ab9534435..2a2104b17 100644 --- a/app/src/main/java/foundation/e/apps/data/install/models/AppInstall.kt +++ b/app/src/main/java/foundation/e/apps/data/install/models/AppInstall.kt @@ -28,7 +28,8 @@ data class AppInstall( var appSize: Long = 0, var files: List = mutableListOf(), var signature: String = String(), - var contentRating: ContentRating = ContentRating() + var contentRating: ContentRating = ContentRating(), + var sharedLibs: List = emptyList() ) { @Ignore private val installingStatusList = listOf( diff --git a/app/src/main/java/foundation/e/apps/data/install/models/SharedLib.kt b/app/src/main/java/foundation/e/apps/data/install/models/SharedLib.kt new file mode 100644 index 000000000..dc4204295 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/models/SharedLib.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.data.install.models + +import com.google.gson.annotations.SerializedName + +data class SharedLib( + val packageName: String, + val versionCode: Long, + val offerType: Int = 0, + @SerializedName(value = "downloadUrls", alternate = ["downLoadUrls"]) + var downloadUrls: List = emptyList(), + @SerializedName(value = "downloadIds", alternate = ["downloadIdMap"]) + var downloadIds: Map = emptyMap() +) diff --git a/app/src/main/java/foundation/e/apps/data/install/pkg/AppLoungePackageManager.kt b/app/src/main/java/foundation/e/apps/data/install/pkg/AppLoungePackageManager.kt index b60a4ce15..10288b310 100644 --- a/app/src/main/java/foundation/e/apps/data/install/pkg/AppLoungePackageManager.kt +++ b/app/src/main/java/foundation/e/apps/data/install/pkg/AppLoungePackageManager.kt @@ -18,17 +18,21 @@ package foundation.e.apps.data.install.pkg +import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo +import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller.Session import android.content.pm.PackageInstaller.SessionParams import android.content.pm.PackageManager import android.content.pm.PackageManager.NameNotFoundException import android.os.Build +import android.os.Handler +import android.os.HandlerThread import androidx.core.content.pm.PackageInfoCompat import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.apps.OpenForTesting @@ -36,9 +40,10 @@ import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Type import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.domain.model.install.Status -import kotlinx.coroutines.DelicateCoroutinesApi import timber.log.Timber import java.io.File +import java.io.IOException +import java.util.Collections import javax.inject.Inject import javax.inject.Singleton @@ -166,35 +171,34 @@ class AppLoungePackageManager @Inject constructor( } /** - * Installs the given package using system API - * @param list List of [File] to be written to install session. + * Installs the given package using system API. + * When [sharedLibs] is non-empty, shared libraries are staged and committed sequentially + * before the main app via [PackageInstaller.SessionCallback]. + * + * @param apkList APK files for the main app. + * @param packageName Package name of the main app. + * @param sharedLibs List of (APK files, package name) pairs for each dependent library. */ - @OptIn(DelicateCoroutinesApi::class) - fun installApplication(list: List, packageName: String) { + fun installApplication( + apkList: List, + packageName: String, + sharedLibs: List, String>> = emptyList() + ) { + if (sharedLibs.isNotEmpty()) { + installWithSequentialSessions(apkList, packageName, sharedLibs) + } else { + installSingleSession(apkList, packageName) + } + } + + @SuppressLint("RequestInstallPackagesPolicy") + private fun installSingleSession(apkList: List, packageName: String) { val sessionId = createInstallSession(packageName, SessionParams.MODE_FULL_INSTALL) val session = packageManager.packageInstaller.openSession(sessionId) try { - // Install the package using the provided stream - list.forEach { - syncFile(session, it) - } - - val callBackIntent = Intent(context, InstallerService::class.java) - callBackIntent.putExtra(PACKAGE_NAME, packageName) - - val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_UPDATE_CURRENT - } - val servicePendingIntent = PendingIntent.getService( - context, - sessionId, - callBackIntent, - flags - ) - session.commit(servicePendingIntent.intentSender) + apkList.forEach { syncFile(session, it) } + session.commit(buildServicePendingIntent(packageName, sessionId).intentSender) } catch (e: Exception) { Timber.e(e, "Initiating Install Failed for $packageName exception: ${e.localizedMessage}") val pendingIntent = PendingIntent.getBroadcast( @@ -211,6 +215,192 @@ class AppLoungePackageManager @Inject constructor( } } + private fun installWithSequentialSessions( + mainFiles: List, + mainPackageName: String, + sharedLibs: List, String>> + ) { + val handlerThread = HandlerThread("sequential-install-$mainPackageName").also { it.start() } + val packageInstaller = packageManager.packageInstaller + + val sessions = try { + stageAllSessions(sharedLibs, mainFiles, mainPackageName) + } catch (e: Exception) { + handlerThread.quitSafely() + throw e + } + + val ownedSessionIds = Collections.synchronizedSet(sessions.map { it.first }.toMutableSet()) + val commitQueue = ArrayDeque(sessions.drop(1)) + + val callback = createSequentialCommitCallback( + ownedSessionIds = ownedSessionIds, + commitQueue = commitQueue, + mainPackageName = mainPackageName, + handlerThread = handlerThread, + packageInstaller = packageInstaller, + ) + + val (firstId, isMain) = sessions.first() + try { + packageInstaller.registerSessionCallback(callback, Handler(handlerThread.looper)) + commitSession(firstId, mainPackageName, isMain) + } catch (e: Exception) { + abandonSessions(sessions.map { it.first }) + packageInstaller.unregisterSessionCallback(callback) + handlerThread.quitSafely() + throw e + } + } + + private fun stageAllSessions( + sharedLibs: List, String>>, + mainFiles: List, + mainPackageName: String + ): List> { + val sessions = mutableListOf>() + try { + sharedLibs.forEach { (files, libPkg) -> + val id = stageSession(files, libPkg) + sessions.add(id to false) + Timber.d("installApp: staged shared lib $libPkg (session $id)") + } + val mainId = stageSession(mainFiles, mainPackageName) + sessions.add(mainId to true) + } catch (e: Exception) { + abandonSessions(sessions.map { it.first }) + throw e + } + return sessions + } + + private fun createSequentialCommitCallback( + ownedSessionIds: MutableSet, + commitQueue: ArrayDeque>, + mainPackageName: String, + handlerThread: HandlerThread, + packageInstaller: PackageInstaller + ): PackageInstaller.SessionCallback { + val appContext = context + + fun notifyInstallerServiceOfFailure(message: String) { + runCatching { + appContext.startService( + Intent(appContext, InstallerService::class.java) + .putExtra(PACKAGE_NAME, mainPackageName) + .putExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) + .putExtra(PackageInstaller.EXTRA_STATUS_MESSAGE, message) + ) + }.onFailure { error -> + Timber.e(error, "Failed to notify InstallerService of failure for $mainPackageName") + } + } + + fun handleCommitFailure( + sessionId: Int, + error: Exception, + cleanup: () -> Unit + ) { + Timber.e(error, "commitSession failed for session $sessionId ($mainPackageName)") + runCatching { packageInstaller.openSession(sessionId).abandon() } + cleanup() + notifyInstallerServiceOfFailure( + "Failed to commit session $sessionId: ${error.localizedMessage}" + ) + } + + return object : PackageInstaller.SessionCallback() { + override fun onCreated(sessionId: Int) = Unit + override fun onBadgingChanged(sessionId: Int) = Unit + override fun onActiveChanged(sessionId: Int, active: Boolean) = Unit + override fun onProgressChanged(sessionId: Int, progress: Float) = Unit + + private fun cleanup() { + commitQueue.forEach { (sessionId, _) -> + runCatching { packageInstaller.openSession(sessionId).abandon() } + } + packageInstaller.unregisterSessionCallback(this) + handlerThread.quitSafely() + } + + override fun onFinished(sessionId: Int, success: Boolean) { + if (!ownedSessionIds.remove(sessionId)) return + + if (!success || commitQueue.isEmpty()) { + cleanup() + if (!success) { + notifyInstallerServiceOfFailure("Shared library session $sessionId failed") + } + return + } + + val (nextId, isMain) = commitQueue.removeFirst() + try { + commitSession(nextId, mainPackageName, isMain) + } catch (e: IOException) { + handleCommitFailure(nextId, e, ::cleanup) + } catch (e: SecurityException) { + handleCommitFailure(nextId, e, ::cleanup) + } catch (e: IllegalStateException) { + handleCommitFailure(nextId, e, ::cleanup) + } + } + } + } + + private fun abandonSessions(sessionIds: Collection) { + sessionIds.forEach { id -> + runCatching { packageManager.packageInstaller.openSession(id).abandon() } + } + } + + // Writes [files] into a new session and closes it without committing. + private fun stageSession(files: List, packageName: String): Int { + val sessionId = createInstallSession(packageName, SessionParams.MODE_FULL_INSTALL) + return try { + packageManager.packageInstaller.openSession(sessionId).use { session -> + files.forEach { syncFile(session, it) } + } + sessionId + } catch (e: Exception) { + runCatching { packageManager.packageInstaller.openSession(sessionId).abandon() } + throw e + } + } + + // Re-opens an already-staged session and commits it. + // Lib sessions use a no-op PendingIntent; only the main app session triggers InstallerService. + @SuppressLint("RequestInstallPackagesPolicy") + internal fun commitSession(sessionId: Int, mainPackageName: String, isMainApp: Boolean = true) { + try { + packageManager.packageInstaller.openSession(sessionId).use { session -> + val pendingIntent = if (isMainApp) { + buildServicePendingIntent(mainPackageName, sessionId) + } else { + buildNoOpPendingIntent(sessionId) + } + session.commit(pendingIntent.intentSender) + } + } catch (e: Exception) { + Timber.e(e, "commitSession failed for session $sessionId ($mainPackageName)") + throw e + } + } + + private fun buildNoOpPendingIntent(sessionId: Int): PendingIntent = + PendingIntent.getBroadcast(context, sessionId, Intent(), PendingIntent.FLAG_IMMUTABLE) + + private fun buildServicePendingIntent(packageName: String, sessionId: Int): PendingIntent { + val callBackIntent = Intent(context, InstallerService::class.java) + .putExtra(PACKAGE_NAME, packageName) + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + return PendingIntent.getService(context, sessionId, callBackIntent, flags) + } + private fun createInstallSession(packageName: String, mode: Int): Int { val packageInstaller = packageManager.packageInstaller val params = SessionParams(mode).apply { diff --git a/app/src/main/java/foundation/e/apps/data/install/sharedlib/SharedLibraryManager.kt b/app/src/main/java/foundation/e/apps/data/install/sharedlib/SharedLibraryManager.kt new file mode 100644 index 000000000..067dfb58f --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/sharedlib/SharedLibraryManager.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2024-2026 MURENA SAS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.data.install.sharedlib + +import android.app.DownloadManager +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import androidx.core.content.pm.PackageInfoCompat +import androidx.core.net.toUri +import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.apps.R +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.install.models.SharedLib +import timber.log.Timber +import java.io.File +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class SharedLibraryManager @Inject constructor( + @Named("cacheDir") private val cacheDir: String, + @ApplicationContext private val applicationContext: Context +) { + + fun getLibFilesForInstall(appInstall: AppInstall): List, String>> { + return getLibsRequiringDownload(appInstall.sharedLibs).map { lib -> + getLibFiles(appInstall.packageName, lib) + ?: error("Missing APK files for required shared library ${lib.packageName}") + } + } + + fun enqueueMissingLibraryDownloads( + downloadManager: DownloadManager, + appInstall: AppInstall, + ) { + clearStaleDownloadIds(appInstall) + getLibsRequiringDownload(appInstall.sharedLibs).forEach { lib -> + val libDir = File("$cacheDir/${appInstall.packageName}/libraries/${lib.packageName}") + .also { it.mkdirs() } + lib.downloadUrls.forEachIndexed { index, url -> + val libFile = File(libDir, "${lib.packageName}_${index + 1}.apk") + val requestId = downloadManager.enqueue( + DownloadManager.Request(url.toUri()) + .setTitle( + applicationContext.getString(R.string.additional_file_for, appInstall.name) + ) + .setDestinationUri(Uri.fromFile(libFile)) + ) + lib.downloadIds = lib.downloadIds + (requestId to false) + appInstall.downloadIdMap[requestId] = false + } + } + } + + private fun getLibsRequiringDownload(sharedLibs: List): List { + return sharedLibs.filter { !isInstalled(it.packageName, it.versionCode) } + } + + private fun isInstalled(packageName: String, versionCode: Long): Boolean { + return try { + val info = applicationContext.packageManager.getPackageInfo(packageName, 0) + PackageInfoCompat.getLongVersionCode(info) >= versionCode + } catch (e: PackageManager.NameNotFoundException) { + Timber.d("Shared library $packageName not installed: ${e.message}") + false + } + } + + private fun getLibFiles( + mainPackageName: String, + lib: SharedLib + ): Pair, String>? { + val libDir = File("$cacheDir/$mainPackageName/libraries/${lib.packageName}") + val files = libDir.listFiles { file -> file.isFile && file.name.endsWith(".apk") } + ?.sorted() + ?: emptyList() + return if (files.isNotEmpty()) Pair(files, lib.packageName) else null + } + + private fun clearStaleDownloadIds(appInstall: AppInstall) { + appInstall.sharedLibs.forEach { lib -> + lib.downloadIds.keys.forEach { appInstall.downloadIdMap.remove(it) } + lib.downloadIds = emptyMap() + } + } +} diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt index e2d33d90c..4f9bd6070 100644 --- a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt @@ -113,6 +113,25 @@ class AppInstallProcessor @Inject constructor( appInstall.downloadURLList = mutableListOf(application.url) } + if (application.source == Source.PLAY_STORE) { + val libs = application.dependentLibraries.ifEmpty { + runCatching { + applicationRepository.getApplicationDetails( + application._id, + application.package_name, + Source.PLAY_STORE + ).first.dependentLibraries + }.getOrElse { + throw IllegalStateException( + "Cannot install ${application.package_name}: " + + "failed to fetch required shared library details", + it + ) + } + } + appInstall.sharedLibs = libs + } + val isUpdate = isAnUpdate || application.status == Status.UPDATABLE || appInstallComponents.appManagerWrapper.isFusedDownloadInstalled(appInstall) diff --git a/app/src/test/java/foundation/e/apps/data/database/install/AppInstallConverterTest.kt b/app/src/test/java/foundation/e/apps/data/database/install/AppInstallConverterTest.kt index acf4acaf0..e87f6f543 100644 --- a/app/src/test/java/foundation/e/apps/data/database/install/AppInstallConverterTest.kt +++ b/app/src/test/java/foundation/e/apps/data/database/install/AppInstallConverterTest.kt @@ -4,6 +4,7 @@ import com.aurora.gplayapi.data.models.ContentRating import com.aurora.gplayapi.data.models.PlayFile import com.google.common.truth.Truth.assertThat import foundation.e.apps.data.database.install.AppInstallConverter +import foundation.e.apps.data.install.models.SharedLib import org.junit.Test class AppInstallConverterTest { @@ -50,4 +51,20 @@ class AppInstallConverterTest { assertThat(restored.id).isEqualTo("E") assertThat(restored.title).isEqualTo("Everyone") } + + @Test + fun sharedLibsRoundTrip_isPreserved() { + val sharedLibs = listOf( + SharedLib( + packageName = "com.example.lib", + versionCode = 42L, + offerType = 7, + ) + ) + + val json = converter.sharedLibsToJson(sharedLibs) + val restored = converter.jsonToSharedLibs(json) + + assertThat(restored).isEqualTo(sharedLibs) + } } -- GitLab From aa685d8e66ac7485c73016e5389046a9bd39d41d Mon Sep 17 00:00:00 2001 From: TheScarastic Date: Tue, 7 Apr 2026 20:47:33 +0530 Subject: [PATCH 2/2] tests: Add tests for shared lib --- .../downloadInfo/DownloadInfoApiImpl.kt | 7 +- .../install/sharedlib/SharedLibraryManager.kt | 2 +- .../workmanager/AppInstallProcessor.kt | 3 +- .../downloadInfo/DownloadInfoApiImplTest.kt | 173 +++++++++++ .../utils/GplayApiExtensionsTest.kt | 83 ++++++ .../install/AppInstallConverterTest.kt | 24 +- .../install/AppInstallDatabaseTest.kt | 159 ++++++++++ .../e/apps/data/install/AppManagerImplTest.kt | 217 ++++++++++++++ .../download/DownloadManagerUtilsTest.kt | 100 +++++++ .../AppLoungePackageManagerSharedLibTest.kt | 279 ++++++++++++++++++ .../AppInstallProcessorSharedLibTest.kt | 192 ++++++++++++ .../compose/state/InstallStatusStreamTest.kt | 14 +- .../ui/search/v2/SearchViewModelV2Test.kt | 17 +- 13 files changed, 1249 insertions(+), 21 deletions(-) create mode 100644 app/src/test/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImplTest.kt create mode 100644 app/src/test/java/foundation/e/apps/data/application/utils/GplayApiExtensionsTest.kt create mode 100644 app/src/test/java/foundation/e/apps/data/database/install/AppInstallDatabaseTest.kt create mode 100644 app/src/test/java/foundation/e/apps/data/install/AppManagerImplTest.kt create mode 100644 app/src/test/java/foundation/e/apps/data/install/download/DownloadManagerUtilsTest.kt create mode 100644 app/src/test/java/foundation/e/apps/data/install/pkg/AppLoungePackageManagerSharedLibTest.kt create mode 100644 app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorSharedLibTest.kt diff --git a/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImpl.kt b/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImpl.kt index 4a683339d..67220391e 100644 --- a/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImpl.kt @@ -98,17 +98,16 @@ class DownloadInfoApiImpl @Inject constructor( if (lib.downloadUrls.isEmpty()) { val libFiles = runCatching { appSources.gplayRepo.getDownloadInfo(lib.packageName, lib.versionCode, lib.offerType) - }.getOrElse { e -> - throw IllegalStateException( + }.getOrElse { + error( "Cannot install ${appInstall.packageName}: " + "failed to fetch download info for required library ${lib.packageName}", - e ) } if (libFiles.isEmpty()) { error( "Cannot install ${appInstall.packageName}: " + - "no download URLs returned for required library ${lib.packageName}" + "no download URLs returned for required library ${lib.packageName}", ) } lib.downloadUrls = libFiles.map { it.url } diff --git a/app/src/main/java/foundation/e/apps/data/install/sharedlib/SharedLibraryManager.kt b/app/src/main/java/foundation/e/apps/data/install/sharedlib/SharedLibraryManager.kt index 067dfb58f..48dcb0d69 100644 --- a/app/src/main/java/foundation/e/apps/data/install/sharedlib/SharedLibraryManager.kt +++ b/app/src/main/java/foundation/e/apps/data/install/sharedlib/SharedLibraryManager.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024-2026 MURENA SAS + * Copyright (C) 2026 e Foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt index 4f9bd6070..07daa2329 100644 --- a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt @@ -122,10 +122,9 @@ class AppInstallProcessor @Inject constructor( Source.PLAY_STORE ).first.dependentLibraries }.getOrElse { - throw IllegalStateException( + error( "Cannot install ${application.package_name}: " + "failed to fetch required shared library details", - it ) } } diff --git a/app/src/test/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImplTest.kt b/app/src/test/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImplTest.kt new file mode 100644 index 000000000..73a34e5ce --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImplTest.kt @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.data.application.downloadInfo + +import com.aurora.gplayapi.data.models.PlayFile +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.AppSourcesContainer +import foundation.e.apps.data.cleanapk.repositories.CleanApkAppsRepository +import foundation.e.apps.data.cleanapk.repositories.CleanApkPwaRepository +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.install.models.SharedLib +import foundation.e.apps.data.playstore.PlayStoreRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlin.test.assertFailsWith +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DownloadInfoApiImplTest { + + private val gplayRepo = mockk() + private val cleanApkAppsRepo = mockk() + private val cleanApkPwaRepo = mockk() + + private lateinit var downloadInfoApi: DownloadInfoApiImpl + + @Before + fun setUp() { + downloadInfoApi = DownloadInfoApiImpl( + AppSourcesContainer(gplayRepo, cleanApkAppsRepo, cleanApkPwaRepo) + ) + } + + @Test + fun updateFusedDownloadWithDownloadingInfo_populatesMissingSharedLibraryUrls() = runTest { + val sharedLib = SharedLib( + packageName = "com.example.lib", + versionCode = 11L, + offerType = 3 + ) + val appInstall = AppInstall( + packageName = "com.example.app", + versionCode = 99L, + offerType = 1, + sharedLibs = listOf(sharedLib) + ) + val mainFiles = listOf(playFile("https://example.org/app-base.apk")) + val libFiles = listOf( + playFile("https://example.org/lib-base.apk"), + playFile("https://example.org/lib-config.apk") + ) + + coEvery { gplayRepo.getDownloadInfo("com.example.app", 99L, 1) } returns mainFiles + coEvery { gplayRepo.getDownloadInfo("com.example.lib", 11L, 3) } returns libFiles + + downloadInfoApi.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) + + assertThat(appInstall.downloadURLList).containsExactly("https://example.org/app-base.apk") + assertThat(appInstall.files.map { it.url }).containsExactly("https://example.org/app-base.apk") + assertThat(sharedLib.downloadUrls).containsExactly( + "https://example.org/lib-base.apk", + "https://example.org/lib-config.apk" + ) + } + + @Test + fun updateFusedDownloadWithDownloadingInfo_keepsExistingSharedLibraryUrls() = runTest { + val sharedLib = SharedLib( + packageName = "com.example.lib", + versionCode = 11L, + offerType = 3, + downloadUrls = listOf("https://example.org/already-present.apk") + ) + val appInstall = AppInstall( + packageName = "com.example.app", + versionCode = 99L, + offerType = 1, + sharedLibs = listOf(sharedLib) + ) + val mainFiles = listOf(playFile("https://example.org/app-base.apk")) + + coEvery { gplayRepo.getDownloadInfo("com.example.app", 99L, 1) } returns mainFiles + + downloadInfoApi.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) + + assertThat(sharedLib.downloadUrls).containsExactly("https://example.org/already-present.apk") + coVerify(exactly = 0) { gplayRepo.getDownloadInfo("com.example.lib", any(), any()) } + } + + @Test + fun updateFusedDownloadWithDownloadingInfo_throwsWhenSharedLibraryHasNoDownloads() = runTest { + val sharedLib = SharedLib( + packageName = "com.example.lib", + versionCode = 11L, + offerType = 3 + ) + val appInstall = AppInstall( + packageName = "com.example.app", + versionCode = 99L, + offerType = 1, + sharedLibs = listOf(sharedLib) + ) + + coEvery { gplayRepo.getDownloadInfo("com.example.app", 99L, 1) } returns listOf( + playFile("https://example.org/app-base.apk") + ) + coEvery { gplayRepo.getDownloadInfo("com.example.lib", 11L, 3) } returns emptyList() + + val error = assertFailsWith { + downloadInfoApi.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) + } + + assertThat(error).hasMessageThat().contains("no download URLs returned for required library") + } + + @Test + fun updateFusedDownloadWithDownloadingInfo_throwsWhenSharedLibraryFetchFails() = runTest { + val sharedLib = SharedLib( + packageName = "com.example.lib", + versionCode = 11L, + offerType = 3 + ) + val appInstall = AppInstall( + packageName = "com.example.app", + versionCode = 99L, + offerType = 1, + sharedLibs = listOf(sharedLib) + ) + + coEvery { gplayRepo.getDownloadInfo("com.example.app", 99L, 1) } returns listOf( + playFile("https://example.org/app-base.apk") + ) + coEvery { + gplayRepo.getDownloadInfo("com.example.lib", 11L, 3) + } throws IllegalStateException("boom") + + val error = assertFailsWith { + downloadInfoApi.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) + } + + assertThat(error).hasMessageThat().contains( + "failed to fetch download info for required library" + ) + assertThat(error.cause).isNull() + } + + private fun playFile(url: String): PlayFile { + val file = mockk() + every { file.url } returns url + return file + } +} diff --git a/app/src/test/java/foundation/e/apps/data/application/utils/GplayApiExtensionsTest.kt b/app/src/test/java/foundation/e/apps/data/application/utils/GplayApiExtensionsTest.kt new file mode 100644 index 000000000..587609077 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/application/utils/GplayApiExtensionsTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.data.application.utils + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.aurora.gplayapi.Constants.Restriction +import com.aurora.gplayapi.data.models.App +import com.aurora.gplayapi.data.models.Artwork +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.install.models.SharedLib +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class GplayApiExtensionsTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun toApplication_mapsDependentLibraries() { + val artwork = mockk() + every { artwork.url } returns "https://example.com/icon.png" + + val app = mockk(relaxed = true) + every { app.id } returns 1 + every { app.developerName } returns "Dev" + every { app.categoryName } returns "Category" + every { app.description } returns "Description" + every { app.iconArtwork } returns artwork + every { app.updatedOn } returns "2024-01-01" + every { app.versionCode } returns 1L + every { app.versionName } returns "1.0" + every { app.displayName } returns "App" + every { app.screenshots } returns mutableListOf(artwork) + every { app.packageName } returns "com.example.app" + every { app.labeledRating } returns "4.5" + every { app.offerType } returns 1 + every { app.shareUrl } returns "https://example.com" + every { app.size } returns 1000L + every { app.isFree } returns true + every { app.price } returns "0" + every { app.restriction } returns Restriction.NOT_RESTRICTED + every { app.contentRating } returns mockk(relaxed = true) + every { app.dependencies.dependentLibraries } returns mutableListOf( + mockk(relaxed = true) { + every { packageName } returns "com.example.lib.one" + every { versionCode } returns 10L + every { offerType } returns 2 + }, + mockk(relaxed = true) { + every { packageName } returns "com.example.lib.two" + every { versionCode } returns 20L + every { offerType } returns 4 + } + ) + + val mapped = app.toApplication(context) + + assertThat(mapped.dependentLibraries).containsExactly( + SharedLib(packageName = "com.example.lib.one", versionCode = 10L, offerType = 2), + SharedLib(packageName = "com.example.lib.two", versionCode = 20L, offerType = 4) + ).inOrder() + } +} diff --git a/app/src/test/java/foundation/e/apps/data/database/install/AppInstallConverterTest.kt b/app/src/test/java/foundation/e/apps/data/database/install/AppInstallConverterTest.kt index e87f6f543..61380454b 100644 --- a/app/src/test/java/foundation/e/apps/data/database/install/AppInstallConverterTest.kt +++ b/app/src/test/java/foundation/e/apps/data/database/install/AppInstallConverterTest.kt @@ -56,9 +56,16 @@ class AppInstallConverterTest { fun sharedLibsRoundTrip_isPreserved() { val sharedLibs = listOf( SharedLib( - packageName = "com.example.lib", + packageName = "com.example.lib.one", versionCode = 42L, - offerType = 7, + offerType = 1, + downloadUrls = listOf("https://example.org/lib-one.apk"), + downloadIds = mapOf(10L to true) + ), + SharedLib( + packageName = "com.example.lib.two", + versionCode = 7L, + downloadUrls = listOf("https://example.org/lib-two.apk") ) ) @@ -67,4 +74,17 @@ class AppInstallConverterTest { assertThat(restored).isEqualTo(sharedLibs) } + + @Test + fun sharedLibsRoundTrip_readsLegacyFieldNames() { + val legacyJson = + """[{"packageName":"com.example.lib.one","versionCode":42,"offerType":1,"downLoadUrls":["https://example.org/lib-one.apk"],"downloadIdMap":{"10":true}}]""" + + val restored = converter.jsonToSharedLibs(legacyJson) + + assertThat(restored).hasSize(1) + assertThat(restored.single().downloadUrls) + .containsExactly("https://example.org/lib-one.apk") + assertThat(restored.single().downloadIds).isEqualTo(mapOf(10L to true)) + } } diff --git a/app/src/test/java/foundation/e/apps/data/database/install/AppInstallDatabaseTest.kt b/app/src/test/java/foundation/e/apps/data/database/install/AppInstallDatabaseTest.kt new file mode 100644 index 000000000..c122df26a --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/database/install/AppInstallDatabaseTest.kt @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.data.database.install + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.os.Build +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.R]) +class AppInstallDatabaseTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private val databasesToDelete = mutableListOf() + + @After + fun tearDown() { + databasesToDelete.forEach(context::deleteDatabase) + } + + @Test + fun migration6To7_opensVersion6DatabaseAndAddsSharedLibsColumn() { + val legacyDatabaseName = trackDatabase("app_install_test_${System.nanoTime()}") + createVersion6Database(legacyDatabaseName) + + val migratedDatabase = Room.databaseBuilder( + context, + AppInstallDatabase::class.java, + legacyDatabaseName + ).addMigrations(AppInstallDatabase.migration6To7) + .allowMainThreadQueries() + .build() + + try { + migratedDatabase.openHelper.writableDatabase + .query("PRAGMA table_info(`FusedDownload`)") + .use { cursor -> + var foundSharedLibsColumn = false + while (cursor.moveToNext()) { + if (cursor.getString(cursor.getColumnIndexOrThrow("name")) != "sharedLibs") { + continue + } + + foundSharedLibsColumn = true + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("type"))) + .isEqualTo("TEXT") + assertThat(cursor.getInt(cursor.getColumnIndexOrThrow("notnull"))) + .isEqualTo(1) + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("dflt_value"))) + .isEqualTo("'[]'") + } + + assertThat(foundSharedLibsColumn).isTrue() + } + migratedDatabase.openHelper.writableDatabase + .query("SELECT `sharedLibs` FROM `FusedDownload` WHERE `id` = 'legacy-id'") + .use { cursor -> + assertThat(cursor.moveToFirst()).isTrue() + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("sharedLibs"))) + .isEqualTo("[]") + } + } finally { + migratedDatabase.close() + } + } + + private fun createVersion6Database(databaseName: String) { + context.deleteDatabase(databaseName) + val databaseFile = context.getDatabasePath(databaseName) + databaseFile.parentFile?.mkdirs() + + val database = SQLiteDatabase.openOrCreateDatabase(databaseFile, null) + try { + database.execSQL(VERSION_6_CREATE_TABLE_SQL) + database.execSQL( + """ + INSERT INTO `FusedDownload` ( + `id`, `source`, `status`, `name`, `packageName`, `downloadURLList`, + `downloadIdMap`, `orgStatus`, `type`, `iconImageUrl`, `versionCode`, + `offerType`, `isFree`, `appSize`, `files`, `signature`, `contentRating` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + arrayOf( + "legacy-id", + "PLAY_STORE", + "AWAITING", + "Legacy App", + "com.example.legacy", + "[]", + "{}", + "AWAITING", + "NATIVE", + "icon.png", + 123L, + 0, + 1, + 2048L, + "[]", + "legacy-signature", + "{}" + ) + ) + database.version = 6 + } finally { + database.close() + } + } + + private fun trackDatabase(name: String): String { + databasesToDelete += name + return name + } + + private companion object { + private const val VERSION_6_CREATE_TABLE_SQL = + "CREATE TABLE IF NOT EXISTS `FusedDownload` (" + + "`id` TEXT NOT NULL, " + + "`source` TEXT NOT NULL, " + + "`status` TEXT NOT NULL, " + + "`name` TEXT NOT NULL, " + + "`packageName` TEXT NOT NULL, " + + "`downloadURLList` TEXT NOT NULL, " + + "`downloadIdMap` TEXT NOT NULL, " + + "`orgStatus` TEXT NOT NULL, " + + "`type` TEXT NOT NULL, " + + "`iconImageUrl` TEXT NOT NULL, " + + "`versionCode` INTEGER NOT NULL, " + + "`offerType` INTEGER NOT NULL, " + + "`isFree` INTEGER NOT NULL, " + + "`appSize` INTEGER NOT NULL, " + + "`files` TEXT NOT NULL, " + + "`signature` TEXT NOT NULL, " + + "`contentRating` TEXT NOT NULL, " + + "PRIMARY KEY(`id`))" + } +} diff --git a/app/src/test/java/foundation/e/apps/data/install/AppManagerImplTest.kt b/app/src/test/java/foundation/e/apps/data/install/AppManagerImplTest.kt new file mode 100644 index 000000000..34f94cfe1 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/install/AppManagerImplTest.kt @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.data.install + +import android.os.Build +import android.app.DownloadManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.PackageInfo +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.install.download.data.DownloadProgressLD +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.install.models.SharedLib +import foundation.e.apps.data.install.pkg.AppLoungePackageManager +import foundation.e.apps.data.install.pkg.PwaManager +import foundation.e.apps.data.install.sharedlib.SharedLibraryManager +import foundation.e.apps.domain.preferences.AppPreferencesRepository +import foundation.e.apps.domain.model.install.Status +import foundation.e.apps.installProcessor.FakeAppInstallDAO +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import android.net.Uri +import java.io.File +import java.nio.file.Files +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowPackageManager + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.R]) +class AppManagerImplTest { + + private lateinit var tempDir: File + private val downloadManager = mockk(relaxed = true) + private val notificationManager = mockk(relaxed = true) + private val pwaManager = mockk(relaxed = true) + private val appLoungePackageManager = mockk(relaxed = true) + private val appPreferencesRepository = mockk(relaxed = true) + private val downloadChannel = mockk(relaxed = true) + private val updateChannel = mockk(relaxed = true) + private val context: Context = ApplicationProvider.getApplicationContext() + private val shadowPackageManager: ShadowPackageManager = Shadows.shadowOf(context.packageManager) + + private lateinit var appManager: AppManagerImpl + + @Before + fun setUp() { + tempDir = Files.createTempDirectory("appManagerImplTest").toFile() + val sharedLibraryManager = SharedLibraryManager(tempDir.absolutePath, context) + + appManager = AppManagerImpl( + tempDir.absolutePath, + downloadManager, + notificationManager, + AppInstallRepository(FakeAppInstallDAO()), + pwaManager, + appLoungePackageManager, + sharedLibraryManager, + downloadChannel, + updateChannel, + context + ) + appManager.appPreferencesRepository = appPreferencesRepository + } + + @After + fun tearDown() { + shadowPackageManager.deletePackage("com.example.lib.installed") + shadowPackageManager.deletePackage("com.example.lib.missing") + DownloadProgressLD.setDownloadId(-1) + tempDir.deleteRecursively() + } + + @Test + fun installApp_passesOnlyMissingSharedLibrariesToPackageInstaller() = runTest { + val mainFiles = slot>() + val sharedLibs = slot, String>>>() + val missingLib = SharedLib(packageName = "com.example.lib.missing", versionCode = 5L) + val installedLib = SharedLib(packageName = "com.example.lib.installed", versionCode = 10L) + val appInstall = AppInstall( + name = "Test App", + packageName = "com.example.app", + status = Status.AWAITING, + sharedLibs = listOf(missingLib, installedLib) + ) + + createApk("com.example.app", "com.example.app_1.apk") + createApk("com.example.app", "com.example.app_2.apk") + createApk("com.example.app/libraries/com.example.lib.missing", "lib-missing_1.apk") + createApk("com.example.app/libraries/com.example.lib.missing", "lib-missing_2.apk") + createApk("com.example.app/libraries/com.example.lib.installed", "lib-installed_1.apk") + + shadowPackageManager.installPackage( + PackageInfo().apply { + packageName = "com.example.lib.installed" + longVersionCode = 10L + } + ) + every { + appLoungePackageManager.installApplication( + capture(mainFiles), + "com.example.app", + capture(sharedLibs) + ) + } returns Unit + + appManager.installApp(appInstall) + + assertThat(mainFiles.captured.map { it.name }).containsExactly( + "com.example.app_1.apk", + "com.example.app_2.apk" + ).inOrder() + assertThat(sharedLibs.captured).hasSize(1) + assertThat(sharedLibs.captured.single().second).isEqualTo("com.example.lib.missing") + assertThat(sharedLibs.captured.single().first.map { it.name }).containsExactly( + "lib-missing_1.apk", + "lib-missing_2.apk" + ).inOrder() + } + + @Test + fun downloadNativeApp_downloadsOnlyMissingSharedLibrariesAndClearsStaleIds() = runTest { + val destinationUris = mutableListOf() + val missingLib = SharedLib( + packageName = "com.example.lib.missing", + versionCode = 5L, + downloadUrls = listOf( + "https://example.org/lib-missing-1.apk", + "https://example.org/lib-missing-2.apk" + ), + downloadIds = mapOf(11L to false) + ) + val installedLib = SharedLib( + packageName = "com.example.lib.installed", + versionCode = 10L, + downloadUrls = listOf("https://example.org/lib-installed.apk"), + downloadIds = mapOf(12L to false) + ) + val appInstall = AppInstall( + name = "Test App", + packageName = "com.example.app", + status = Status.AWAITING, + downloadURLList = mutableListOf("https://example.org/main.apk"), + downloadIdMap = mutableMapOf(11L to false, 12L to false), + sharedLibs = listOf(missingLib, installedLib) + ) + + shadowPackageManager.installPackage( + PackageInfo().apply { + packageName = "com.example.lib.installed" + longVersionCode = 10L + } + ) + val requestIds = ArrayDeque(listOf(101L, 102L, 201L)) + every { downloadManager.enqueue(any()) } answers { + val request = invocation.args.first() as DownloadManager.Request + destinationUris += extractDestinationUri(request) + requestIds.removeFirst() + } + + appManager.downloadNativeApp(appInstall, isUpdate = false) + + assertThat(installedLib.downloadIds).isEmpty() + assertThat(missingLib.downloadIds.keys).containsExactly(101L, 102L) + assertThat(appInstall.downloadIdMap.keys).containsExactly(101L, 102L, 201L) + assertThat(destinationUris.mapNotNull { it.path }).containsExactly( + File( + tempDir, + "com.example.app/libraries/com.example.lib.missing/com.example.lib.missing_1.apk" + ).absolutePath, + File( + tempDir, + "com.example.app/libraries/com.example.lib.missing/com.example.lib.missing_2.apk" + ).absolutePath, + File(tempDir, "com.example.app/com.example.app_1.apk").absolutePath + ).inOrder() + verify(exactly = 3) { downloadManager.enqueue(any()) } + } + + private fun createApk(relativeDir: String, name: String) { + val directory = File(tempDir, relativeDir).apply { mkdirs() } + File(directory, name).writeText("apk") + } + + private fun extractDestinationUri(request: DownloadManager.Request): Uri { + val field = DownloadManager.Request::class.java.getDeclaredField("mDestinationUri") + field.isAccessible = true + return field.get(request) as Uri + } +} diff --git a/app/src/test/java/foundation/e/apps/data/install/download/DownloadManagerUtilsTest.kt b/app/src/test/java/foundation/e/apps/data/install/download/DownloadManagerUtilsTest.kt new file mode 100644 index 000000000..b2c746cfc --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/install/download/DownloadManagerUtilsTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.data.install.download + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.DownloadManager +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.install.models.SharedLib +import foundation.e.apps.data.install.notification.StorageNotificationManager +import foundation.e.apps.domain.model.install.Status +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import android.app.DownloadManager as PlatformDownloadManager + +@OptIn(ExperimentalCoroutinesApi::class) +class DownloadManagerUtilsTest { + + @Test + fun updateDownloadStatus_waitsForSharedLibraryDownloadsBeforeCompleting() = runTest { + val context = mockk(relaxed = true) + val appManagerWrapper = mockk(relaxed = true) + val downloadManager = mockk() + val storageNotificationManager = mockk(relaxed = true) + val sharedLib = SharedLib( + packageName = "com.example.lib", + versionCode = 1L, + downloadIds = mapOf(2L to false) + ) + val appInstall = AppInstall( + id = "download-id", + name = "Test App", + packageName = "com.example.app", + source = Source.PLAY_STORE, + status = Status.DOWNLOADING, + downloadURLList = mutableListOf("https://example.org/main.apk"), + downloadIdMap = mutableMapOf(1L to false, 2L to false), + sharedLibs = listOf(sharedLib) + ) + val utils = DownloadManagerUtils( + context, + appManagerWrapper, + downloadManager, + storageNotificationManager, + backgroundScope + ) + + coEvery { appManagerWrapper.getFusedDownload(1L, any()) } returns appInstall + coEvery { appManagerWrapper.getFusedDownload(2L, any()) } returns appInstall + every { downloadManager.hasDownloadFailed(any()) } returns false + every { downloadManager.isDownloadSuccessful(any()) } returns + (true to PlatformDownloadManager.STATUS_SUCCESSFUL) + coEvery { appManagerWrapper.updateFusedDownload(appInstall) } returns Unit + coEvery { appManagerWrapper.isFDroidApplicationSigned(any(), any()) } returns true + every { appManagerWrapper.moveOBBFileToOBBDirectory(any()) } returns Unit + + utils.updateDownloadStatus(1L) + advanceTimeBy(1500) + runCurrent() + + assertThat(appInstall.status).isEqualTo(Status.DOWNLOADING) + assertThat(appInstall.downloadIdMap[1L]).isTrue() + assertThat(appInstall.downloadIdMap[2L]).isFalse() + verify(exactly = 0) { appManagerWrapper.moveOBBFileToOBBDirectory(any()) } + + utils.updateDownloadStatus(2L) + advanceTimeBy(1500) + runCurrent() + + assertThat(appInstall.status).isEqualTo(Status.DOWNLOADED) + assertThat(appInstall.downloadIdMap[2L]).isTrue() + verify(exactly = 1) { appManagerWrapper.moveOBBFileToOBBDirectory(appInstall) } + coVerify(atLeast = 3) { appManagerWrapper.updateFusedDownload(appInstall) } + } +} diff --git a/app/src/test/java/foundation/e/apps/data/install/pkg/AppLoungePackageManagerSharedLibTest.kt b/app/src/test/java/foundation/e/apps/data/install/pkg/AppLoungePackageManagerSharedLibTest.kt new file mode 100644 index 000000000..3981aedad --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/install/pkg/AppLoungePackageManagerSharedLibTest.kt @@ -0,0 +1,279 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.data.install.pkg + +import android.content.ComponentName +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.nio.file.Files +import kotlin.test.assertFailsWith +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doNothing +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.spy +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.R]) +class AppLoungePackageManagerSharedLibTest { + + private val baseContext: Context = ApplicationProvider.getApplicationContext() + private val packageInstaller = mock() + private val libSession = mock() + private val mainSession = mock() + private val filesToDelete = mutableListOf() + + private lateinit var packageManager: PackageManager + private lateinit var context: RecordingContext + private lateinit var appLoungePackageManager: AppLoungePackageManager + + @Before + fun setUp() { + packageManager = spy(baseContext.packageManager) + doReturn(packageInstaller).whenever(packageManager).packageInstaller + context = RecordingContext(baseContext, packageManager) + stubSession(libSession) + stubSession(mainSession) + appLoungePackageManager = AppLoungePackageManager(context) + } + + @After + fun tearDown() { + filesToDelete.forEach(File::delete) + } + + @Test + fun installApplication_withSharedLibraries_commitsSessionsSequentially() { + val libApk = createApk("lib-one.apk") + val mainApk = createApk("main.apk") + val callbackCaptor = argumentCaptor() + + whenever(packageInstaller.createSession(any())).thenReturn(1, 2) + whenever(packageInstaller.openSession(1)).thenReturn(libSession) + whenever(packageInstaller.openSession(2)).thenReturn(mainSession) + + appLoungePackageManager.installApplication( + apkList = listOf(mainApk), + packageName = "com.example.app", + sharedLibs = listOf(listOf(libApk) to "com.example.lib") + ) + + verify(packageInstaller).registerSessionCallback(callbackCaptor.capture(), any()) + verify(packageInstaller, times(2)).createSession(any()) + verify(libSession).commit(any()) + verify(mainSession, never()).commit(any()) + + val callback = callbackCaptor.firstValue + callback.onFinished(1, true) + + verify(mainSession).commit(any()) + + callback.onFinished(2, true) + + verify(packageInstaller).unregisterSessionCallback(callback) + assertThat(context.startedServices).isEmpty() + } + + @Test + fun installApplication_withoutSharedLibraries_commitsSingleSession() { + val mainApk = createApk("main-single.apk") + + whenever(packageInstaller.createSession(any())).thenReturn(9) + whenever(packageInstaller.openSession(9)).thenReturn(mainSession) + + appLoungePackageManager.installApplication( + apkList = listOf(mainApk), + packageName = "com.example.app" + ) + + verify(packageInstaller).createSession(any()) + verify(mainSession).commit(any()) + verify(packageInstaller, never()).registerSessionCallback(any(), any()) + } + + @Test + fun installApplication_whenSharedLibrarySessionFails_abandonsRemainingSessionsAndNotifiesInstallerService() { + val libApk = createApk("lib-two.apk") + val mainApk = createApk("main-two.apk") + val callbackCaptor = argumentCaptor() + + whenever(packageInstaller.createSession(any())).thenReturn(11, 12) + whenever(packageInstaller.openSession(11)).thenReturn(libSession) + whenever(packageInstaller.openSession(12)).thenReturn(mainSession) + + appLoungePackageManager.installApplication( + apkList = listOf(mainApk), + packageName = "com.example.app", + sharedLibs = listOf(listOf(libApk) to "com.example.lib") + ) + + verify(packageInstaller).registerSessionCallback(callbackCaptor.capture(), any()) + val callback = callbackCaptor.firstValue + + callback.onFinished(11, false) + + verify(mainSession).abandon() + verify(packageInstaller).unregisterSessionCallback(callback) + assertThat(context.startedServices).hasSize(1) + assertThat( + context.startedServices.single().getStringExtra(AppLoungePackageManager.PACKAGE_NAME) + ).isEqualTo("com.example.app") + assertThat( + context.startedServices.single().getIntExtra(PackageInstaller.EXTRA_STATUS, Int.MIN_VALUE) + ).isEqualTo(PackageInstaller.STATUS_FAILURE) + assertThat( + context.startedServices.single() + .getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + ).contains("Shared library session 11 failed") + } + + @Test + fun installApplication_whenNextSessionCommitFails_abandonsFailedSessionAndNotifiesInstallerService() { + val libApk = createApk("lib-three.apk") + val mainApk = createApk("main-three.apk") + val callbackCaptor = argumentCaptor() + + whenever(packageInstaller.createSession(any())).thenReturn(21, 22) + whenever(packageInstaller.openSession(21)).thenReturn(libSession) + whenever(packageInstaller.openSession(22)).thenReturn(mainSession) + doThrow(IllegalStateException("boom")).whenever(mainSession).commit(any()) + + appLoungePackageManager.installApplication( + apkList = listOf(mainApk), + packageName = "com.example.app", + sharedLibs = listOf(listOf(libApk) to "com.example.lib") + ) + + verify(packageInstaller).registerSessionCallback(callbackCaptor.capture(), any()) + val callback = callbackCaptor.firstValue + + callback.onFinished(21, true) + + verify(mainSession).abandon() + verify(packageInstaller).unregisterSessionCallback(callback) + assertThat(context.startedServices).hasSize(1) + assertThat( + context.startedServices.single() + .getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + ).contains("Failed to commit session 22: boom") + } + + @Test + fun installApplication_whenStagingFails_abandonsPreviouslyStagedSessionsAndRethrows() { + val libApk = createApk("lib-stage.apk") + val mainApk = createApk("main-stage.apk") + + whenever(packageInstaller.createSession(any())).thenReturn(31, 32) + whenever(packageInstaller.openSession(31)).thenReturn(libSession) + whenever(packageInstaller.openSession(32)).thenReturn(mainSession) + doThrow(IOException("stage boom")).whenever(mainSession).openWrite(any(), eq(0L), eq(-1L)) + + val error = assertFailsWith { + appLoungePackageManager.installApplication( + apkList = listOf(mainApk), + packageName = "com.example.app", + sharedLibs = listOf(listOf(libApk) to "com.example.lib") + ) + } + + assertThat(error).hasMessageThat().contains("stage boom") + verify(libSession).abandon() + verify(mainSession).abandon() + verify(packageInstaller, never()).registerSessionCallback(any(), any()) + assertThat(context.startedServices).isEmpty() + } + + @Test + fun installApplication_whenFirstSessionCommitFails_abandonsAllSessionsAndRethrows() { + val libApk = createApk("lib-first-commit.apk") + val mainApk = createApk("main-first-commit.apk") + val callbackCaptor = argumentCaptor() + + whenever(packageInstaller.createSession(any())).thenReturn(41, 42) + whenever(packageInstaller.openSession(41)).thenReturn(libSession) + whenever(packageInstaller.openSession(42)).thenReturn(mainSession) + doThrow(IllegalStateException("first commit boom")).whenever(libSession).commit(any()) + + val error = assertFailsWith { + appLoungePackageManager.installApplication( + apkList = listOf(mainApk), + packageName = "com.example.app", + sharedLibs = listOf(listOf(libApk) to "com.example.lib") + ) + } + + assertThat(error).hasMessageThat().contains("first commit boom") + verify(packageInstaller).registerSessionCallback(callbackCaptor.capture(), any()) + verify(packageInstaller).unregisterSessionCallback(callbackCaptor.firstValue) + verify(libSession).abandon() + verify(mainSession).abandon() + assertThat(context.startedServices).isEmpty() + } + + private fun stubSession(session: PackageInstaller.Session) { + whenever(session.openWrite(any(), eq(0L), eq(-1L))).thenReturn(ByteArrayOutputStream()) + doNothing().whenever(session).fsync(any()) + doNothing().whenever(session).commit(any()) + doNothing().whenever(session).abandon() + doNothing().whenever(session).close() + } + + private fun createApk(name: String): File { + val file = Files.createTempFile(name.removeSuffix(".apk"), ".apk").toFile() + file.writeText("apk") + filesToDelete += file + return file + } + + private class RecordingContext( + base: Context, + private val packageManager: PackageManager + ) : ContextWrapper(base) { + + val startedServices = mutableListOf() + + override fun getPackageManager(): PackageManager = packageManager + + override fun startService(service: Intent): ComponentName? { + startedServices += service + return ComponentName(packageName, service.component?.className ?: "service") + } + } +} diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorSharedLibTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorSharedLibTest.kt new file mode 100644 index 000000000..a839497f9 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorSharedLibTest.kt @@ -0,0 +1,192 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.installProcessor + +import android.content.ContentResolver +import android.content.Context +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.application.ApplicationRepository +import foundation.e.apps.data.application.apps.AppsApi +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.blockedApps.BlockedAppRepository +import foundation.e.apps.data.enums.ResultStatus +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.enums.Type +import foundation.e.apps.data.install.AppInstallComponents +import foundation.e.apps.data.install.AppInstallRepository +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.install.models.SharedLib +import foundation.e.apps.data.install.notification.StorageNotificationManager +import foundation.e.apps.data.install.workmanager.AppInstallProcessor +import foundation.e.apps.data.parentalcontrol.ContentRatingDao +import foundation.e.apps.data.parentalcontrol.ParentalControlRepository +import foundation.e.apps.data.parentalcontrol.fdroid.FDroidAntiFeatureRepository +import foundation.e.apps.data.parentalcontrol.googleplay.GPlayContentRatingRepository +import foundation.e.apps.data.preference.PlayStoreAuthStore +import foundation.e.apps.domain.ValidateAppAgeLimitUseCase +import foundation.e.apps.domain.model.install.Status +import foundation.e.apps.domain.preferences.SessionRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.spyk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlin.test.assertFailsWith +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AppInstallProcessorSharedLibTest { + + private val context = mockk() + private val contentResolver = mockk() + private val applicationRepository = mockk() + private val sessionRepository = mockk(relaxed = true) + private val playStoreAuthStore = mockk(relaxed = true) + private val storageNotificationManager = mockk(relaxed = true) + private val appManagerWrapper = mockk(relaxed = true) + private val gPlayContentRatingRepository = mockk(relaxed = true) + private val fDroidAntiFeatureRepository = mockk(relaxed = true) + private val blockedAppRepository = mockk(relaxed = true) + private val appsApi = mockk(relaxed = true) + private val contentRatingDao = mockk(relaxed = true) + + private lateinit var validateAppAgeLimitUseCase: ValidateAppAgeLimitUseCase + + @Before + fun setUp() { + every { context.contentResolver } returns contentResolver + every { contentResolver.query(any(), any(), any(), any(), any()) } returns null + validateAppAgeLimitUseCase = ValidateAppAgeLimitUseCase( + gPlayContentRatingRepository, + fDroidAntiFeatureRepository, + ParentalControlRepository(context), + blockedAppRepository, + appsApi, + contentRatingDao + ) + } + + @Test + fun initAppInstall_usesDependenciesAlreadyPresentOnApplication() = runTest { + val libraries = listOf( + SharedLib(packageName = "com.example.lib.one", versionCode = 10L), + SharedLib(packageName = "com.example.lib.two", versionCode = 20L, offerType = 2) + ) + val application = createPlayStoreApplication(dependentLibraries = libraries) + val capturedInstall = slot() + + every { appManagerWrapper.isFusedDownloadInstalled(any()) } returns false + val processor = spyk(createProcessor()) + coEvery { processor.enqueueFusedDownload(capture(capturedInstall), any(), any()) } returns true + val result = processor.initAppInstall(application) + + assertThat(result).isTrue() + assertThat(capturedInstall.captured.sharedLibs).isEqualTo(libraries) + coVerify(exactly = 0) { + applicationRepository.getApplicationDetails(any(), any(), Source.PLAY_STORE) + } + } + + @Test + fun initAppInstall_fetchesDependenciesWhenApplicationDoesNotContainThem() = runTest { + val fetchedLibraries = listOf( + SharedLib(packageName = "com.example.lib.one", versionCode = 10L), + SharedLib(packageName = "com.example.lib.two", versionCode = 20L) + ) + val application = createPlayStoreApplication() + val capturedInstall = slot() + + every { appManagerWrapper.isFusedDownloadInstalled(any()) } returns false + coEvery { + applicationRepository.getApplicationDetails( + application._id, + application.package_name, + Source.PLAY_STORE + ) + } returns (application.copy(dependentLibraries = fetchedLibraries) to ResultStatus.OK) + val processor = spyk(createProcessor()) + coEvery { processor.enqueueFusedDownload(capture(capturedInstall), any(), any()) } returns true + val result = processor.initAppInstall(application) + + assertThat(result).isTrue() + assertThat(capturedInstall.captured.sharedLibs).isEqualTo(fetchedLibraries) + coVerify(exactly = 1) { + applicationRepository.getApplicationDetails( + application._id, + application.package_name, + Source.PLAY_STORE + ) + } + } + + @Test + fun initAppInstall_throwsWhenFetchingDependenciesFails() = runTest { + val application = createPlayStoreApplication() + + every { appManagerWrapper.isFusedDownloadInstalled(any()) } returns false + coEvery { + applicationRepository.getApplicationDetails( + application._id, + application.package_name, + Source.PLAY_STORE + ) + } throws IllegalStateException("boom") + + val error = assertFailsWith { + createProcessor().initAppInstall(application) + } + + assertThat(error).hasMessageThat().contains("failed to fetch required shared library details") + assertThat(error.cause).isNull() + } + + private fun createProcessor(): AppInstallProcessor { + val appInstallRepository = AppInstallRepository(FakeAppInstallDAO()) + val appInstallComponents = AppInstallComponents(appInstallRepository, appManagerWrapper) + return AppInstallProcessor( + context, + appInstallComponents, + applicationRepository, + validateAppAgeLimitUseCase, + sessionRepository, + playStoreAuthStore, + storageNotificationManager + ) + } + + private fun createPlayStoreApplication( + dependentLibraries: List = emptyList() + ) = Application( + _id = "123", + name = "Test app", + package_name = "com.example.app", + status = Status.INSTALLED, + source = Source.PLAY_STORE, + type = Type.NATIVE, + latest_version_code = 42L, + offer_type = 1, + isFree = true, + isSystemApp = true, + dependentLibraries = dependentLibraries + ) +} diff --git a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt index 93b3debb4..b598ac167 100644 --- a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt +++ b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt @@ -27,12 +27,11 @@ import foundation.e.apps.data.install.pkg.PwaManager import foundation.e.apps.util.MainCoroutineRule import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -85,7 +84,7 @@ class InstallStatusStreamTest { every { pwaManager.getInstalledPwaUrls() } returns setOf("https://pwa.example") val stream = InstallStatusStream(appManagerWrapper, appLoungePackageManager, pwaManager) - val snapshots = backgroundScope.async { + val collectedSnapshots = backgroundScope.async { stream.stream(packagePollIntervalMs = 50, pwaPollIntervalMs = 50) .take(2) .toList() @@ -93,12 +92,11 @@ class InstallStatusStreamTest { runCurrent() advanceTimeBy(50) - advanceUntilIdle() - - val collectedSnapshots = snapshots.await() + runCurrent() + val snapshots = collectedSnapshots.await() - assertEquals(setOf("com.example.one"), collectedSnapshots[0].installedPackages) - assertEquals(setOf("com.example.two"), collectedSnapshots[1].installedPackages) + assertEquals(setOf("com.example.one"), snapshots[0].installedPackages) + assertEquals(setOf("com.example.two"), snapshots[1].installedPackages) } private fun appInfo(packageName: String) = ApplicationInfo().apply { diff --git a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt index 90347cc48..6a6f79aa8 100644 --- a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt +++ b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt @@ -54,6 +54,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -789,7 +791,9 @@ class SearchViewModelV2Test { mainCoroutineRule.testDispatcher.scheduler.runCurrent() } - private suspend fun collectApplications(pagingData: PagingData): List { + private suspend fun TestScope.collectApplications( + pagingData: PagingData + ): List { val differ = AsyncPagingDataDiffer( diffCallback = ApplicationDiffUtil(), updateCallback = NoopListCallback(), @@ -797,9 +801,14 @@ class SearchViewModelV2Test { workerDispatcher = mainCoroutineRule.testDispatcher ) - differ.submitData(pagingData) - mainCoroutineRule.testDispatcher.scheduler.advanceUntilIdle() - return differ.snapshot().items + val job = backgroundScope.launch { + differ.submitData(pagingData) + } + differ.onPagesUpdatedFlow.first() + val items = differ.snapshot().items + job.cancel() + job.join() + return items } private fun visibleTabs(): List = buildList { -- GitLab