Loading app/src/main/aidl/foundation/e/apps/IInstallAppService.aidl +19 −1 Original line number Diff line number Diff line /* * Copyright MURENA SAS 2026 * Apps Quickly and easily install Android apps onto your device! * * 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 <https://www.gnu.org/licenses/>. */ package foundation.e.apps; import foundation.e.apps.IInstallAppCallback; interface IInstallAppService { void installAppId(String appId, @nullable String source, IInstallAppCallback callback); void installAppId(String packageName, @nullable String source, IInstallAppCallback callback); } app/src/main/java/foundation/e/apps/data/cleanapk/repositories/CleanApkAppsRepository.kt +4 −2 Original line number Diff line number Diff line /* * Copyright MURENA SAS 2023 * Copyright MURENA SAS 2023-2026 * Apps Quickly and easily install Android apps onto your device! * * This program is free software: you can redistribute it and/or modify Loading Loading @@ -101,7 +101,9 @@ class CleanApkAppsRepository @Inject constructor( type = null ) return response.body()?.app ?: return Application() return response.body()?.app?.let { it.copy(source = if (it.is_pwa) Source.PWA else Source.OPEN_SOURCE) } ?: Application() } override suspend fun getSearchResults(pattern: String): List<Application> { Loading app/src/main/java/foundation/e/apps/domain/appslookup/GetAppDetailsUseCase.kt 0 → 100644 +142 −0 Original line number Diff line number Diff line /* * Copyright MURENA SAS 2026 * Apps Quickly and easily install Android apps onto your device! * * 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 <https://www.gnu.org/licenses/>. */ package foundation.e.apps.domain.appslookup import foundation.e.apps.data.Stores import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.blockedApps.BlockedAppRepository 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.enums.User import foundation.e.apps.data.login.state.LoginState import foundation.e.apps.data.playstore.PlayStoreRepository import foundation.e.apps.data.preference.AppLoungeDataStore import timber.log.Timber import javax.inject.Inject class UnavailableApp(message: String?): Exception(message) class StoreNotConfigured(message: String?): Exception(message) class GetAppDetailsUseCase @Inject constructor ( private val blockedAppRepository: BlockedAppRepository, private val cleanApkAppsRepository: CleanApkAppsRepository, private val cleanApkPwaRepository: CleanApkPwaRepository, private val playStoreRepository: PlayStoreRepository, private val stores: Stores, private val appLoungeDataStore: AppLoungeDataStore, ) { suspend operator fun invoke(packageName: String, source: Source? = null): Application { if (blockedAppRepository.isBlockedApp(packageName)) { throw UnavailableApp("Can't install $packageName, it is in NotWorkingApps list.").let { Timber.i(it) it } } if (source == Source.SYSTEM_APP) throw UnavailableApp("System_app can't be installed through this").let { Timber.i(it) it } val user = appLoungeDataStore.getUser() val loginState = appLoungeDataStore.getLoginState() val availableStores = stores.getEnabledSearchSources().filter { store -> when(store) { Source.PLAY_STORE -> loginState == LoginState.AVAILABLE && user in listOf(User.ANONYMOUS, User.GOOGLE) Source.SYSTEM_APP -> false Source.PWA, Source.OPEN_SOURCE -> loginState == LoginState.AVAILABLE } } if (availableStores.isEmpty()) throw StoreNotConfigured("There isn't any store configured").let { Timber.i(it) it } if (source == null) { var application: Application? = null for (store in availableStores.sorted()) { application = try { getAppDetailsUnchecked(packageName, store) } catch (e: Exception) { Timber.i(e, "While getAppDetails on $packageName in $store") continue } break } return application ?: throw UnavailableApp("") } if (source !in availableStores) { throw StoreNotConfigured("$source isn't configured, can't get app from it.").let { Timber.i(it) it } } return getAppDetailsUnchecked(packageName, source) } private suspend fun getAppDetailsUnchecked(packageName: String, source: Source) = when(source) { Source.OPEN_SOURCE -> { getOpenSourceAppDetails(packageName) } Source.PLAY_STORE -> { getPlayStoreAppDetails(packageName) } Source.PWA -> { getPWAAppDetails(packageName) } Source.SYSTEM_APP -> throw UnavailableApp("System_app can't be installed through this") } private suspend fun getPlayStoreAppDetails(packageName: String): Application { // purchased apps are excluded from update : possible or not to install silently ? // should we filter for age rating ? return playStoreRepository.getAppDetails(packageName) // Can throw // throw IllegalStateException("App version code cannot be 0") // throw InternalException.AppNotFound() // 404 // any GplayHttpRequestException // or any other things } private suspend fun getOpenSourceAppDetails(packageName: String): Application { val application = cleanApkAppsRepository.getAppDetails(packageName) if (application._id.isNullOrBlank()) { throw UnavailableApp("$packageName wasn't found in CleanAPK") } return application } private suspend fun getPWAAppDetails(packageName: String): Application { val application = cleanApkPwaRepository.getAppDetails(packageName) if (application._id.isNullOrBlank()) { throw UnavailableApp("$packageName wasn't found in CleanAPK") } return application } } app/src/main/java/foundation/e/apps/domain/appslookup/InstallAppByIdUseCase.kt 0 → 100644 +74 −0 Original line number Diff line number Diff line /* * Copyright MURENA SAS 2026 * Apps Quickly and easily install Android apps onto your device! * * 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 <https://www.gnu.org/licenses/>. */ package foundation.e.apps.domain.appslookup import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Status import foundation.e.apps.data.install.AppInstallRepository import foundation.e.apps.install.pkg.AppLoungePackageManager import foundation.e.apps.install.pkg.PwaManager import foundation.e.apps.install.workmanager.AppInstallProcessor import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import timber.log.Timber import javax.inject.Inject class InstallAppByIdUseCase @Inject constructor( private val getAppDetailsUseCase: GetAppDetailsUseCase, private val appLoungePackageManager: AppLoungePackageManager, private val appInstallProcessor: AppInstallProcessor, private val appInstallRepository: AppInstallRepository, private val pwaManager: PwaManager, ) { suspend operator fun invoke(packageName: String, source: Source? = null): Flow<Status> { if (appLoungePackageManager.isInstalled(packageName)) { // PWA will always return false return flowOf(Status.INSTALLED) } val app: Application try { app = getAppDetailsUseCase(packageName, source) } catch(_: UnavailableApp) { return flowOf(Status.UNAVAILABLE) } catch(_: StoreNotConfigured) { return flowOf(Status.UNAVAILABLE) } catch(_: Exception) { return flowOf(Status.INSTALLATION_ISSUE) } Timber.d("DebugGJ: Found app detais: $app") // TODO should-we validate purchased and age limit here ? appInstallProcessor.initAppInstall(app, isAnUpdate = false) return appInstallRepository.getDownloadFlowById(app._id).map { it?.status ?: if (isInstalled(app)) Status.INSTALLED else Status.UNAVAILABLE } } private suspend fun isInstalled(app: Application): Boolean { return when(app.source) { Source.PWA -> pwaManager.getPwaStatus(app) == Status.INSTALLED else -> appLoungePackageManager.isInstalled(app.package_name) } } } app/src/main/java/foundation/e/apps/services/InstallAppService.kt +34 −11 Original line number Diff line number Diff line Loading @@ -25,41 +25,64 @@ import android.os.Build import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.R import foundation.e.apps.IInstallAppCallback import foundation.e.apps.IInstallAppService import foundation.e.apps.data.enums.Source import foundation.e.apps.data.updates.UpdatesManagerImpl.Companion.PACKAGE_NAME_ANDROID_VENDING import foundation.e.apps.data.updates.UpdatesManagerImpl.Companion.PACKAGE_NAME_F_DROID import foundation.e.apps.data.updates.UpdatesManagerImpl.Companion.PACKAGE_NAME_F_DROID_PRIVILEGED import foundation.e.apps.domain.appslookup.InstallAppByIdUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.Job import kotlinx.coroutines.launch import javax.inject.Inject // Should we filter blacklisted app ? <-- same behavior as manual install <- check it // Should we filter faulty apps ? <-- same behavior as manual install <- check it. @AndroidEntryPoint class InstallAppService : Service() { @Inject lateinit var installAppByIdUseCase: InstallAppByIdUseCase companion object { private const val NOTIFICATION_ID = 2201 } private val binder = object : IInstallAppService.Stub() { override fun installAppId(appId: String, source: String?, callback: IInstallAppCallback) { Log.d("DebugGJ", "InstallAppService::installAppId") GlobalScope.launch(Dispatchers.IO) { callback.onStatusUpdate("START_INSTALL") var i = 10 while (i > 0) { Log.d("DebugGJ", "InstallAppService::installAppId will send progress") callback.onProgress(i) delay(2000) i -= 1 override fun installAppId(packageName: String, installerName: String?, callback: IInstallAppCallback) { Log.d("DebugGJ", "InstallAppService::installAppId $packageName, $installerName") var installAppJob: Job? = null installAppJob = GlobalScope.launch(Dispatchers.IO) { val source: Source? = when(installerName?.lowercase()) { "pwa" -> Source.PWA this@InstallAppService.packageName, PACKAGE_NAME_F_DROID, PACKAGE_NAME_F_DROID_PRIVILEGED -> Source.OPEN_SOURCE PACKAGE_NAME_ANDROID_VENDING -> Source.PLAY_STORE else -> null } Log.d("DebugGJ", "will startinstallation $packageName, $source") val statusFlow = installAppByIdUseCase(packageName, source) statusFlow.collect { status -> val continueListening = callback.onStatusUpdate(status.name) if (!continueListening) { installAppJob?.cancel() } } } } } // TODO: generate code, to auto-review first. override fun onCreate() { super.onCreate() startAsForeground() Loading Loading
app/src/main/aidl/foundation/e/apps/IInstallAppService.aidl +19 −1 Original line number Diff line number Diff line /* * Copyright MURENA SAS 2026 * Apps Quickly and easily install Android apps onto your device! * * 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 <https://www.gnu.org/licenses/>. */ package foundation.e.apps; import foundation.e.apps.IInstallAppCallback; interface IInstallAppService { void installAppId(String appId, @nullable String source, IInstallAppCallback callback); void installAppId(String packageName, @nullable String source, IInstallAppCallback callback); }
app/src/main/java/foundation/e/apps/data/cleanapk/repositories/CleanApkAppsRepository.kt +4 −2 Original line number Diff line number Diff line /* * Copyright MURENA SAS 2023 * Copyright MURENA SAS 2023-2026 * Apps Quickly and easily install Android apps onto your device! * * This program is free software: you can redistribute it and/or modify Loading Loading @@ -101,7 +101,9 @@ class CleanApkAppsRepository @Inject constructor( type = null ) return response.body()?.app ?: return Application() return response.body()?.app?.let { it.copy(source = if (it.is_pwa) Source.PWA else Source.OPEN_SOURCE) } ?: Application() } override suspend fun getSearchResults(pattern: String): List<Application> { Loading
app/src/main/java/foundation/e/apps/domain/appslookup/GetAppDetailsUseCase.kt 0 → 100644 +142 −0 Original line number Diff line number Diff line /* * Copyright MURENA SAS 2026 * Apps Quickly and easily install Android apps onto your device! * * 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 <https://www.gnu.org/licenses/>. */ package foundation.e.apps.domain.appslookup import foundation.e.apps.data.Stores import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.blockedApps.BlockedAppRepository 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.enums.User import foundation.e.apps.data.login.state.LoginState import foundation.e.apps.data.playstore.PlayStoreRepository import foundation.e.apps.data.preference.AppLoungeDataStore import timber.log.Timber import javax.inject.Inject class UnavailableApp(message: String?): Exception(message) class StoreNotConfigured(message: String?): Exception(message) class GetAppDetailsUseCase @Inject constructor ( private val blockedAppRepository: BlockedAppRepository, private val cleanApkAppsRepository: CleanApkAppsRepository, private val cleanApkPwaRepository: CleanApkPwaRepository, private val playStoreRepository: PlayStoreRepository, private val stores: Stores, private val appLoungeDataStore: AppLoungeDataStore, ) { suspend operator fun invoke(packageName: String, source: Source? = null): Application { if (blockedAppRepository.isBlockedApp(packageName)) { throw UnavailableApp("Can't install $packageName, it is in NotWorkingApps list.").let { Timber.i(it) it } } if (source == Source.SYSTEM_APP) throw UnavailableApp("System_app can't be installed through this").let { Timber.i(it) it } val user = appLoungeDataStore.getUser() val loginState = appLoungeDataStore.getLoginState() val availableStores = stores.getEnabledSearchSources().filter { store -> when(store) { Source.PLAY_STORE -> loginState == LoginState.AVAILABLE && user in listOf(User.ANONYMOUS, User.GOOGLE) Source.SYSTEM_APP -> false Source.PWA, Source.OPEN_SOURCE -> loginState == LoginState.AVAILABLE } } if (availableStores.isEmpty()) throw StoreNotConfigured("There isn't any store configured").let { Timber.i(it) it } if (source == null) { var application: Application? = null for (store in availableStores.sorted()) { application = try { getAppDetailsUnchecked(packageName, store) } catch (e: Exception) { Timber.i(e, "While getAppDetails on $packageName in $store") continue } break } return application ?: throw UnavailableApp("") } if (source !in availableStores) { throw StoreNotConfigured("$source isn't configured, can't get app from it.").let { Timber.i(it) it } } return getAppDetailsUnchecked(packageName, source) } private suspend fun getAppDetailsUnchecked(packageName: String, source: Source) = when(source) { Source.OPEN_SOURCE -> { getOpenSourceAppDetails(packageName) } Source.PLAY_STORE -> { getPlayStoreAppDetails(packageName) } Source.PWA -> { getPWAAppDetails(packageName) } Source.SYSTEM_APP -> throw UnavailableApp("System_app can't be installed through this") } private suspend fun getPlayStoreAppDetails(packageName: String): Application { // purchased apps are excluded from update : possible or not to install silently ? // should we filter for age rating ? return playStoreRepository.getAppDetails(packageName) // Can throw // throw IllegalStateException("App version code cannot be 0") // throw InternalException.AppNotFound() // 404 // any GplayHttpRequestException // or any other things } private suspend fun getOpenSourceAppDetails(packageName: String): Application { val application = cleanApkAppsRepository.getAppDetails(packageName) if (application._id.isNullOrBlank()) { throw UnavailableApp("$packageName wasn't found in CleanAPK") } return application } private suspend fun getPWAAppDetails(packageName: String): Application { val application = cleanApkPwaRepository.getAppDetails(packageName) if (application._id.isNullOrBlank()) { throw UnavailableApp("$packageName wasn't found in CleanAPK") } return application } }
app/src/main/java/foundation/e/apps/domain/appslookup/InstallAppByIdUseCase.kt 0 → 100644 +74 −0 Original line number Diff line number Diff line /* * Copyright MURENA SAS 2026 * Apps Quickly and easily install Android apps onto your device! * * 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 <https://www.gnu.org/licenses/>. */ package foundation.e.apps.domain.appslookup import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Status import foundation.e.apps.data.install.AppInstallRepository import foundation.e.apps.install.pkg.AppLoungePackageManager import foundation.e.apps.install.pkg.PwaManager import foundation.e.apps.install.workmanager.AppInstallProcessor import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import timber.log.Timber import javax.inject.Inject class InstallAppByIdUseCase @Inject constructor( private val getAppDetailsUseCase: GetAppDetailsUseCase, private val appLoungePackageManager: AppLoungePackageManager, private val appInstallProcessor: AppInstallProcessor, private val appInstallRepository: AppInstallRepository, private val pwaManager: PwaManager, ) { suspend operator fun invoke(packageName: String, source: Source? = null): Flow<Status> { if (appLoungePackageManager.isInstalled(packageName)) { // PWA will always return false return flowOf(Status.INSTALLED) } val app: Application try { app = getAppDetailsUseCase(packageName, source) } catch(_: UnavailableApp) { return flowOf(Status.UNAVAILABLE) } catch(_: StoreNotConfigured) { return flowOf(Status.UNAVAILABLE) } catch(_: Exception) { return flowOf(Status.INSTALLATION_ISSUE) } Timber.d("DebugGJ: Found app detais: $app") // TODO should-we validate purchased and age limit here ? appInstallProcessor.initAppInstall(app, isAnUpdate = false) return appInstallRepository.getDownloadFlowById(app._id).map { it?.status ?: if (isInstalled(app)) Status.INSTALLED else Status.UNAVAILABLE } } private suspend fun isInstalled(app: Application): Boolean { return when(app.source) { Source.PWA -> pwaManager.getPwaStatus(app) == Status.INSTALLED else -> appLoungePackageManager.isInstalled(app.package_name) } } }
app/src/main/java/foundation/e/apps/services/InstallAppService.kt +34 −11 Original line number Diff line number Diff line Loading @@ -25,41 +25,64 @@ import android.os.Build import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.R import foundation.e.apps.IInstallAppCallback import foundation.e.apps.IInstallAppService import foundation.e.apps.data.enums.Source import foundation.e.apps.data.updates.UpdatesManagerImpl.Companion.PACKAGE_NAME_ANDROID_VENDING import foundation.e.apps.data.updates.UpdatesManagerImpl.Companion.PACKAGE_NAME_F_DROID import foundation.e.apps.data.updates.UpdatesManagerImpl.Companion.PACKAGE_NAME_F_DROID_PRIVILEGED import foundation.e.apps.domain.appslookup.InstallAppByIdUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.Job import kotlinx.coroutines.launch import javax.inject.Inject // Should we filter blacklisted app ? <-- same behavior as manual install <- check it // Should we filter faulty apps ? <-- same behavior as manual install <- check it. @AndroidEntryPoint class InstallAppService : Service() { @Inject lateinit var installAppByIdUseCase: InstallAppByIdUseCase companion object { private const val NOTIFICATION_ID = 2201 } private val binder = object : IInstallAppService.Stub() { override fun installAppId(appId: String, source: String?, callback: IInstallAppCallback) { Log.d("DebugGJ", "InstallAppService::installAppId") GlobalScope.launch(Dispatchers.IO) { callback.onStatusUpdate("START_INSTALL") var i = 10 while (i > 0) { Log.d("DebugGJ", "InstallAppService::installAppId will send progress") callback.onProgress(i) delay(2000) i -= 1 override fun installAppId(packageName: String, installerName: String?, callback: IInstallAppCallback) { Log.d("DebugGJ", "InstallAppService::installAppId $packageName, $installerName") var installAppJob: Job? = null installAppJob = GlobalScope.launch(Dispatchers.IO) { val source: Source? = when(installerName?.lowercase()) { "pwa" -> Source.PWA this@InstallAppService.packageName, PACKAGE_NAME_F_DROID, PACKAGE_NAME_F_DROID_PRIVILEGED -> Source.OPEN_SOURCE PACKAGE_NAME_ANDROID_VENDING -> Source.PLAY_STORE else -> null } Log.d("DebugGJ", "will startinstallation $packageName, $source") val statusFlow = installAppByIdUseCase(packageName, source) statusFlow.collect { status -> val continueListening = callback.onStatusUpdate(status.name) if (!continueListening) { installAppJob?.cancel() } } } } } // TODO: generate code, to auto-review first. override fun onCreate() { super.onCreate() startAsForeground() Loading