From e7dca5ee90ce12c6f93da082a53d0c3e03ca32cc Mon Sep 17 00:00:00 2001 From: jacquargia Date: Fri, 6 Feb 2026 11:10:34 +0100 Subject: [PATCH 1/3] 4025: Add AIDL InstallService - dummy implementation --- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 11 +++ .../e/apps/IInstallAppCallback.aidl | 7 ++ .../foundation/e/apps/IInstallAppService.aidl | 7 ++ .../e/apps/services/InstallAppService.kt | 96 +++++++++++++++++++ 5 files changed, 122 insertions(+) create mode 100644 app/src/main/aidl/foundation/e/apps/IInstallAppCallback.aidl create mode 100644 app/src/main/aidl/foundation/e/apps/IInstallAppService.aidl create mode 100644 app/src/main/java/foundation/e/apps/services/InstallAppService.kt diff --git a/app/build.gradle b/app/build.gradle index 7ad27fa1f..bca09a7b3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -126,6 +126,7 @@ android { buildConfig = true viewBinding = true compose = true + aidl = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b8d83e7f6..b4f100190 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -24,6 +24,10 @@ android:name="${applicationId}.permission.AUTH_DATA_PROVIDER" android:protectionLevel="signature" /> + + @@ -128,6 +132,13 @@ android:foregroundServiceType="dataSync"> + + + diff --git a/app/src/main/aidl/foundation/e/apps/IInstallAppCallback.aidl b/app/src/main/aidl/foundation/e/apps/IInstallAppCallback.aidl new file mode 100644 index 000000000..fd047cda8 --- /dev/null +++ b/app/src/main/aidl/foundation/e/apps/IInstallAppCallback.aidl @@ -0,0 +1,7 @@ +package foundation.e.apps; + +interface IInstallAppCallback { + boolean onStatusUpdate(String status); + void onError(String code, String message); + void onProgress(int percent); +} diff --git a/app/src/main/aidl/foundation/e/apps/IInstallAppService.aidl b/app/src/main/aidl/foundation/e/apps/IInstallAppService.aidl new file mode 100644 index 000000000..631d1bdcf --- /dev/null +++ b/app/src/main/aidl/foundation/e/apps/IInstallAppService.aidl @@ -0,0 +1,7 @@ +package foundation.e.apps; + +import foundation.e.apps.IInstallAppCallback; + +interface IInstallAppService { + void installAppId(String appId, @nullable String source, IInstallAppCallback callback); +} diff --git a/app/src/main/java/foundation/e/apps/services/InstallAppService.kt b/app/src/main/java/foundation/e/apps/services/InstallAppService.kt new file mode 100644 index 000000000..b17823ab8 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/services/InstallAppService.kt @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2026 MURENA SAS + * 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 . + */ + +package foundation.e.apps.services + +import android.app.Service +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import foundation.e.apps.R +import foundation.e.apps.IInstallAppCallback +import foundation.e.apps.IInstallAppService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + + +// Should we filter blacklisted app ? <-- same behavior as manual install <- check it +// Should we filter faulty apps ? <-- same behavior as manual install <- check it. + + +class InstallAppService : Service() { + + 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 onCreate() { + super.onCreate() + startAsForeground() + } + + override fun onBind(intent: Intent): IBinder { + return binder + } + + override fun onDestroy() { + stopForeground(STOP_FOREGROUND_REMOVE) + super.onDestroy() + } + + private fun startAsForeground() { + val channelId = getString(R.string.basic_notification_channel_id) + val notification = NotificationCompat.Builder(this, channelId) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.installing)) + .setSmallIcon(R.drawable.app_lounge_notification_icon) + .setOngoing(true) + .build() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } +} -- GitLab From 8ca2ffead7b40b4ae4ac77167ae5ba73be3c2b71 Mon Sep 17 00:00:00 2001 From: jacquargia Date: Fri, 6 Feb 2026 18:35:14 +0100 Subject: [PATCH 2/3] 4025:Feat: Install app from InstallAppService commands. --- .../foundation/e/apps/IInstallAppService.aidl | 20 ++- .../repositories/CleanApkAppsRepository.kt | 6 +- .../domain/appslookup/GetAppDetailsUseCase.kt | 142 ++++++++++++++++++ .../appslookup/InstallAppByIdUseCase.kt | 74 +++++++++ .../e/apps/services/InstallAppService.kt | 45 ++++-- 5 files changed, 273 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/domain/appslookup/GetAppDetailsUseCase.kt create mode 100644 app/src/main/java/foundation/e/apps/domain/appslookup/InstallAppByIdUseCase.kt diff --git a/app/src/main/aidl/foundation/e/apps/IInstallAppService.aidl b/app/src/main/aidl/foundation/e/apps/IInstallAppService.aidl index 631d1bdcf..0a8f67c88 100644 --- a/app/src/main/aidl/foundation/e/apps/IInstallAppService.aidl +++ b/app/src/main/aidl/foundation/e/apps/IInstallAppService.aidl @@ -1,7 +1,25 @@ +/* + * 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 . + */ + 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); } diff --git a/app/src/main/java/foundation/e/apps/data/cleanapk/repositories/CleanApkAppsRepository.kt b/app/src/main/java/foundation/e/apps/data/cleanapk/repositories/CleanApkAppsRepository.kt index d9e03e715..34b4b869b 100644 --- a/app/src/main/java/foundation/e/apps/data/cleanapk/repositories/CleanApkAppsRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/cleanapk/repositories/CleanApkAppsRepository.kt @@ -1,5 +1,5 @@ /* - * 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 @@ -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 { diff --git a/app/src/main/java/foundation/e/apps/domain/appslookup/GetAppDetailsUseCase.kt b/app/src/main/java/foundation/e/apps/domain/appslookup/GetAppDetailsUseCase.kt new file mode 100644 index 000000000..12cdfcb0e --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/appslookup/GetAppDetailsUseCase.kt @@ -0,0 +1,142 @@ +/* + * 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 . + */ + +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 + } +} diff --git a/app/src/main/java/foundation/e/apps/domain/appslookup/InstallAppByIdUseCase.kt b/app/src/main/java/foundation/e/apps/domain/appslookup/InstallAppByIdUseCase.kt new file mode 100644 index 000000000..755b48f8e --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/appslookup/InstallAppByIdUseCase.kt @@ -0,0 +1,74 @@ +/* + * 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 . + */ + +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 { + 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) + } + } +} diff --git a/app/src/main/java/foundation/e/apps/services/InstallAppService.kt b/app/src/main/java/foundation/e/apps/services/InstallAppService.kt index b17823ab8..b97cfb635 100644 --- a/app/src/main/java/foundation/e/apps/services/InstallAppService.kt +++ b/app/src/main/java/foundation/e/apps/services/InstallAppService.kt @@ -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() -- GitLab From 617cbadaf0cb1d2d755e89501036671fd46118a6 Mon Sep 17 00:00:00 2001 From: jacquarg Date: Mon, 9 Feb 2026 09:31:51 +0100 Subject: [PATCH 3/3] 4025:Feat: add install-app-lib, with demo app --- app/build.gradle | 13 +- app/src/main/AndroidManifest.xml | 1 - .../e/apps/IInstallAppCallback.aidl | 7 - .../e/apps/services/InstallAppService.kt | 68 ++++----- gradle/libs.versions.toml | 10 ++ install-app-lib/.gitignore | 1 + install-app-lib/build.gradle.kts | 84 +++++++++++ install-app-lib/consumer-rules.pro | 0 install-app-lib/installappdemo/.gitignore | 1 + .../installappdemo/build.gradle.kts | 68 +++++++++ .../src/main/AndroidManifest.xml | 18 +++ .../e/apps/installapp/demo/MainActivity.kt | 134 ++++++++++++++++++ install-app-lib/proguard-rules.pro | 21 +++ install-app-lib/src/main/AndroidManifest.xml | 4 + .../apps/installapp/IInstallAppCallback.aidl | 24 ++++ .../apps/installapp}/IInstallAppService.aidl | 4 +- .../apps/installapp/AppInstallationStatus.kt | 18 +++ .../e/apps/installapp/AppInstaller.kt | 100 +++++++++++++ .../e/apps/installapp/ExampleUnitTest.kt | 17 +++ settings.gradle | 2 + 20 files changed, 545 insertions(+), 50 deletions(-) delete mode 100644 app/src/main/aidl/foundation/e/apps/IInstallAppCallback.aidl create mode 100644 install-app-lib/.gitignore create mode 100644 install-app-lib/build.gradle.kts create mode 100644 install-app-lib/consumer-rules.pro create mode 100644 install-app-lib/installappdemo/.gitignore create mode 100644 install-app-lib/installappdemo/build.gradle.kts create mode 100644 install-app-lib/installappdemo/src/main/AndroidManifest.xml create mode 100644 install-app-lib/installappdemo/src/main/java/foundation/e/apps/installapp/demo/MainActivity.kt create mode 100644 install-app-lib/proguard-rules.pro create mode 100644 install-app-lib/src/main/AndroidManifest.xml create mode 100644 install-app-lib/src/main/aidl/foundation/e/apps/installapp/IInstallAppCallback.aidl rename {app/src/main/aidl/foundation/e/apps => install-app-lib/src/main/aidl/foundation/e/apps/installapp}/IInstallAppService.aidl (90%) create mode 100644 install-app-lib/src/main/kotlin/foundation/e/apps/installapp/AppInstallationStatus.kt create mode 100644 install-app-lib/src/main/kotlin/foundation/e/apps/installapp/AppInstaller.kt create mode 100644 install-app-lib/src/test/java/foundation/e/apps/installapp/ExampleUnitTest.kt diff --git a/app/build.gradle b/app/build.gradle index bca09a7b3..a2357b4f2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -74,13 +74,13 @@ tasks.withType(Test).configureEach { } android { - compileSdk = 36 + compileSdk = libs.versions.compileSdk.get().toInteger() defaultConfig { applicationId = "foundation.e.apps" - minSdk = 30 + minSdk = libs.versions.minSdk.get().toInteger() //noinspection OldTargetApi - targetSdk = 34 + targetSdk = libs.versions.targetSdk.get().toInteger() versionCode = versionMajor * 1000000 + versionMinor * 1000 + versionPatch versionName = "${versionMajor}.${versionMinor}.${versionPatch}" @@ -129,11 +129,11 @@ android { aidl = true } compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 + sourceCompatibility = JavaVersion.toVersion(libs.versions.jvmTarget.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.jvmTarget.get()) } kotlinOptions { - jvmTarget = '21' + jvmTarget = libs.versions.jvmTarget.get() } lint { lintConfig = file('lint.xml') @@ -196,6 +196,7 @@ dependencies { // Project dependencies implementation(project(":auth-data-lib")) implementation(project(":parental-control-data")) + implementation(project(":install-app-lib")) // eFoundation libraries implementation(libs.telemetry) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b4f100190..78612a846 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -135,7 +135,6 @@ diff --git a/app/src/main/aidl/foundation/e/apps/IInstallAppCallback.aidl b/app/src/main/aidl/foundation/e/apps/IInstallAppCallback.aidl deleted file mode 100644 index fd047cda8..000000000 --- a/app/src/main/aidl/foundation/e/apps/IInstallAppCallback.aidl +++ /dev/null @@ -1,7 +0,0 @@ -package foundation.e.apps; - -interface IInstallAppCallback { - boolean onStatusUpdate(String status); - void onError(String code, String message); - void onProgress(int percent); -} diff --git a/app/src/main/java/foundation/e/apps/services/InstallAppService.kt b/app/src/main/java/foundation/e/apps/services/InstallAppService.kt index b97cfb635..2d1b085fc 100644 --- a/app/src/main/java/foundation/e/apps/services/InstallAppService.kt +++ b/app/src/main/java/foundation/e/apps/services/InstallAppService.kt @@ -27,8 +27,8 @@ 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.installapp.IInstallAppCallback +import foundation.e.apps.installapp.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 @@ -52,9 +52,9 @@ class InstallAppService : Service() { lateinit var installAppByIdUseCase: InstallAppByIdUseCase - companion object { - private const val NOTIFICATION_ID = 2201 - } +// companion object { +// private const val NOTIFICATION_ID = 2201 +// } private val binder = object : IInstallAppService.Stub() { override fun installAppId(packageName: String, installerName: String?, callback: IInstallAppCallback) { @@ -82,38 +82,38 @@ class InstallAppService : Service() { } } - // TODO: generate code, to auto-review first. - override fun onCreate() { - super.onCreate() - startAsForeground() - } +// // TODO: generate code, to auto-review first. +// override fun onCreate() { +// super.onCreate() +// startAsForeground() +// } override fun onBind(intent: Intent): IBinder { return binder } - override fun onDestroy() { - stopForeground(STOP_FOREGROUND_REMOVE) - super.onDestroy() - } - - private fun startAsForeground() { - val channelId = getString(R.string.basic_notification_channel_id) - val notification = NotificationCompat.Builder(this, channelId) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.installing)) - .setSmallIcon(R.drawable.app_lounge_notification_icon) - .setOngoing(true) - .build() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startForeground( - NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC - ) - } else { - startForeground(NOTIFICATION_ID, notification) - } - } +// override fun onDestroy() { +// stopForeground(STOP_FOREGROUND_REMOVE) +// super.onDestroy() +// } +// +// private fun startAsForeground() { +// val channelId = getString(R.string.basic_notification_channel_id) +// val notification = NotificationCompat.Builder(this, channelId) +// .setContentTitle(getString(R.string.app_name)) +// .setContentText(getString(R.string.installing)) +// .setSmallIcon(R.drawable.app_lounge_notification_icon) +// .setOngoing(true) +// .build() +// +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { +// startForeground( +// NOTIFICATION_ID, +// notification, +// ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +// ) +// } else { +// startForeground(NOTIFICATION_ID, notification) +// } +// } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b04e563c5..d98b2ed6d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,11 @@ [versions] + +compileSdk = "36" +minSdk = "30" +targetSdk = "34" + +jvmTarget = "21" + activityCompose = "1.12.2" activityKtx = "1.10.0" androidGradlePlugin = "8.9.3" @@ -136,6 +143,8 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } truth = { module = "com.google.truth:truth", version.ref = "truth" } viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" } work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } +ui = { group = "androidx.compose.ui", name = "ui" } +ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } @@ -150,3 +159,4 @@ ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } navigation-safeargs = { id = "androidx.navigation.safeargs", version.ref = "navigation" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } +maven-publish = { id = "maven-publish" } diff --git a/install-app-lib/.gitignore b/install-app-lib/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/install-app-lib/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/install-app-lib/build.gradle.kts b/install-app-lib/build.gradle.kts new file mode 100644 index 000000000..c78ff6dac --- /dev/null +++ b/install-app-lib/build.gradle.kts @@ -0,0 +1,84 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.maven.publish) +} + +android { + namespace = "foundation.e.apps.installapp" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.jvmTarget.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.jvmTarget.get()) + } + kotlinOptions { + jvmTarget = libs.versions.jvmTarget.get() + } + buildFeatures { + aidl = true + } + + afterEvaluate { + publishing { + publications { + create("aar") { + groupId = "foundation.e.apps" + artifactId = "install-app-lib" + version = "0.9.5" + + artifact("$buildDir/outputs/aar/${project.name}-release.aar") + + pom { + name = "InstallAppLib" + description = "Library providing a way to install an app through AppLounge" + + licenses { + license { + name = "The Apache Software License, Version 2.0" + url = "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + } + } + } + } + + repositories { + maven { + name = "GitLab" + url = uri("https://gitlab.e.foundation/api/v4/projects/355/packages/maven") + credentials(HttpHeaderCredentials::class) { + name = "Job-Token" + value = System.getenv("CI_JOB_TOKEN") + } + authentication { + create("headerAuthentication") + } + } + } + } + } +} + +dependencies { + implementation(libs.core.ktx) + implementation(libs.appcompat) + implementation(libs.material) + testImplementation(libs.junit) +} diff --git a/install-app-lib/consumer-rules.pro b/install-app-lib/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/install-app-lib/installappdemo/.gitignore b/install-app-lib/installappdemo/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/install-app-lib/installappdemo/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/install-app-lib/installappdemo/build.gradle.kts b/install-app-lib/installappdemo/build.gradle.kts new file mode 100644 index 000000000..612bfcf89 --- /dev/null +++ b/install-app-lib/installappdemo/build.gradle.kts @@ -0,0 +1,68 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "foundation.e.apps.installapp.demo" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + applicationId = "foundation.e.apps.installapp.demo" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + } + + signingConfigs { + create("platformConfig") { + storeFile = file("../../app/keystore/platform.jks") + storePassword = "platform" + keyAlias = "platform" + keyPassword = "platform" + } + } + + buildTypes { + debug { + signingConfig = signingConfigs.get("platformConfig") + } + + release { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.jvmTarget.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.jvmTarget.get()) + } + kotlinOptions { + jvmTarget = libs.versions.jvmTarget.get() + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(project(":install-app-lib")) +// implementation("foundation.e.apps:install-app-lib:1.0.0") + + implementation(libs.core.ktx) + implementation(libs.lifecycle.runtime.ktx) + implementation(libs.activity.compose) + implementation(platform(libs.compose.bom)) + implementation(libs.ui) + implementation(libs.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.ext.junit) + androidTestImplementation(libs.espresso.core) + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.compose.ui.test.junit4) + debugImplementation(libs.compose.ui.tooling) + debugImplementation(libs.compose.ui.test.manifest) +} diff --git a/install-app-lib/installappdemo/src/main/AndroidManifest.xml b/install-app-lib/installappdemo/src/main/AndroidManifest.xml new file mode 100644 index 000000000..b5b925508 --- /dev/null +++ b/install-app-lib/installappdemo/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/install-app-lib/installappdemo/src/main/java/foundation/e/apps/installapp/demo/MainActivity.kt b/install-app-lib/installappdemo/src/main/java/foundation/e/apps/installapp/demo/MainActivity.kt new file mode 100644 index 000000000..632db57a6 --- /dev/null +++ b/install-app-lib/installappdemo/src/main/java/foundation/e/apps/installapp/demo/MainActivity.kt @@ -0,0 +1,134 @@ +package foundation.e.apps.installapp.demo + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.lifecycleScope +import foundation.e.apps.installapp.AppInstaller +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +class MainActivity : ComponentActivity() { + private val status = MutableStateFlow("Not-connected") + private var installJob: Job? = null + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + //AppLoungeTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + InstallAppScreen( + modifier = Modifier.padding(innerPadding), + status, + ::installApp, + ::stopInstallApp + ) + } + //} + } + } + + + private fun installApp(packageName: String, installSource: String?) { + val appInstaller = AppInstaller(this) +// installJob = appInstaller.installByPackageName(packageName, installSource) +// .map { status.value = it.name } +// .launchIn(lifecycleScope) + + installJob = lifecycleScope.launch { + appInstaller.installByPackageName(packageName, installSource).collect { + Log.d("DebugGJ", "install ${packageName} status: $it ") + //lastStatus = it + status.value = it.name + } + + } + } + + + private fun stopInstallApp() { + installJob?.cancel() + } + +} + +@Composable() +private fun InstallAppScreen( + modifier: Modifier, + statusFlow: Flow, + installApp: (String, String)-> Unit, + stopInstallApp: () -> Unit) { + + var packageName by remember { mutableStateOf("") } + var installSource by remember { mutableStateOf("") } + val status by statusFlow.collectAsState("not connected") + + Column( + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + ) { + TextField( + value = packageName, + onValueChange = { packageName = it }, + label = { Text(text = "Package name") }, + modifier = Modifier.fillMaxWidth() + ) + TextField( + value = installSource, + onValueChange = { installSource = it }, + label = { Text(text = "Install source") }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) + + Button(onClick = { + installApp(packageName, installSource) + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp)) { + Text("Install") + } + + Button(onClick = { + stopInstallApp() + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp)) { + Text("STOP") + } + + Row(modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp)) { + Text("Status: ") + Text(status) + } + } +} diff --git a/install-app-lib/proguard-rules.pro b/install-app-lib/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/install-app-lib/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/install-app-lib/src/main/AndroidManifest.xml b/install-app-lib/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a5918e68a --- /dev/null +++ b/install-app-lib/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/install-app-lib/src/main/aidl/foundation/e/apps/installapp/IInstallAppCallback.aidl b/install-app-lib/src/main/aidl/foundation/e/apps/installapp/IInstallAppCallback.aidl new file mode 100644 index 000000000..193f17b91 --- /dev/null +++ b/install-app-lib/src/main/aidl/foundation/e/apps/installapp/IInstallAppCallback.aidl @@ -0,0 +1,24 @@ +/* + * 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 . + */ +package foundation.e.apps.installapp; + +interface IInstallAppCallback { + boolean onStatusUpdate(String status); + void onError(String code, String message); + void onProgress(int percent); +} diff --git a/app/src/main/aidl/foundation/e/apps/IInstallAppService.aidl b/install-app-lib/src/main/aidl/foundation/e/apps/installapp/IInstallAppService.aidl similarity index 90% rename from app/src/main/aidl/foundation/e/apps/IInstallAppService.aidl rename to install-app-lib/src/main/aidl/foundation/e/apps/installapp/IInstallAppService.aidl index 0a8f67c88..dfb64d59b 100644 --- a/app/src/main/aidl/foundation/e/apps/IInstallAppService.aidl +++ b/install-app-lib/src/main/aidl/foundation/e/apps/installapp/IInstallAppService.aidl @@ -16,9 +16,9 @@ * along with this program. If not, see . */ -package foundation.e.apps; +package foundation.e.apps.installapp; -import foundation.e.apps.IInstallAppCallback; +import foundation.e.apps.installapp.IInstallAppCallback; interface IInstallAppService { void installAppId(String packageName, @nullable String source, IInstallAppCallback callback); diff --git a/install-app-lib/src/main/kotlin/foundation/e/apps/installapp/AppInstallationStatus.kt b/install-app-lib/src/main/kotlin/foundation/e/apps/installapp/AppInstallationStatus.kt new file mode 100644 index 000000000..4da7b9690 --- /dev/null +++ b/install-app-lib/src/main/kotlin/foundation/e/apps/installapp/AppInstallationStatus.kt @@ -0,0 +1,18 @@ +package foundation.e.apps.installapp + +/** Duplicates: foundation.e.apps.data.enums.Status + * + */ +enum class AppInstallationStatus { + INSTALLED, + UPDATABLE, + INSTALLING, + DOWNLOADING, + DOWNLOADED, + UNAVAILABLE, + QUEUED, + BLOCKED, + INSTALLATION_ISSUE, + AWAITING, + PURCHASE_NEEDED; +} \ No newline at end of file diff --git a/install-app-lib/src/main/kotlin/foundation/e/apps/installapp/AppInstaller.kt b/install-app-lib/src/main/kotlin/foundation/e/apps/installapp/AppInstaller.kt new file mode 100644 index 000000000..4e2f6fad3 --- /dev/null +++ b/install-app-lib/src/main/kotlin/foundation/e/apps/installapp/AppInstaller.kt @@ -0,0 +1,100 @@ +package foundation.e.apps.installapp + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.util.Log +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.onFailure +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.resumeWithException + +class AppInstaller(private val context: Context) { + fun installByPackageName(packageName: String, installSource: String?): Flow { + return callbackFlow { + val installCallback = object : IInstallAppCallback.Stub() { + override fun onStatusUpdate(status: String): Boolean { + val installStatus = runCatching { AppInstallationStatus.valueOf(status) }.getOrDefault( + AppInstallationStatus.UNAVAILABLE) + trySendBlocking(installStatus) + .onFailure { throwable -> + // Downstream has been cancelled or failed, can log here + Log.d("DebugGJ", "Failed to send a status .") + } + + when (installStatus) { + AppInstallationStatus.INSTALLED -> { + Log.d("DebugGJ", "Received installed status, close channel") + channel.close() + } + + AppInstallationStatus.INSTALLATION_ISSUE, + AppInstallationStatus.PURCHASE_NEEDED + -> { + Log.d("DebugGJ", "Received $installStatus, cancel flow") + cancel(CancellationException("Error while installing: $status")) + } + + // Which are the other terminal states ? + // "UNAVAILABLE" <- in case of not found. but is it also the initial state ? + // "BLOCKED" ? + + else -> {} + + } + + + return true + + } + + override fun onError(code: String, message: String) { + // error.value = "$code:$message" + } + + override fun onProgress(percent: Int) { + //progress.value = percent + } + } + + val serviceConnection = object : ServiceConnection { + override fun onServiceConnected( + componentName: ComponentName, + binder: IBinder + ) { + Log.d("DebugGJ", "ServiceConncetion::onServiceConnceted $this.") + val service = IInstallAppService.Stub.asInterface(binder) + service.installAppId(packageName, installSource, installCallback) + } + + override fun onServiceDisconnected(componentName: ComponentName) { + Log.d("DebugGJ", "ServiceConncetion::onServiceDisconnceted $this.") + cancel(CancellationException("Service disconnected")) + } + } + + val intent = Intent().apply { + component = ComponentName.unflattenFromString("foundation.e.apps/.services.InstallAppService") + } + context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + + /* + * Suspends until either 'onCompleted'/'onApiError' from the callback is invoked + * or flow collector is cancelled (e.g. by 'take(1)' or because a collector's coroutine was cancelled). + * In both cases, callback will be properly unregistered. + */ + awaitClose { + Log.d("DebugGJ", "Will close, unbind the service") + context.unbindService(serviceConnection) + } + } + } +} diff --git a/install-app-lib/src/test/java/foundation/e/apps/installapp/ExampleUnitTest.kt b/install-app-lib/src/test/java/foundation/e/apps/installapp/ExampleUnitTest.kt new file mode 100644 index 000000000..4671a8352 --- /dev/null +++ b/install-app-lib/src/test/java/foundation/e/apps/installapp/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package foundation.e.apps.installapp + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 0b3590379..afa5364b2 100644 --- a/settings.gradle +++ b/settings.gradle @@ -64,3 +64,5 @@ rootProject.name = "App Lounge" include ':app' include ':parental-control-data' include ':auth-data-lib' +include ':install-app-lib' +include ':install-app-lib:installappdemo' -- GitLab