diff --git a/app/build.gradle b/app/build.gradle
index 7ad27fa1f0a6536e19d534d5e0fbe72f7fa9bfb5..a2357b4f2de754df66820496bc55b25729b12630 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}"
@@ -126,13 +126,14 @@ android {
buildConfig = true
viewBinding = true
compose = true
+ 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')
@@ -195,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 b8d83e7f692ff98b5b072005bb663c47678e3e24..78612a846aa75acf739752b2fac4dcd444666cf0 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,12 @@
android:foregroundServiceType="dataSync">
+
+
+
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 d9e03e71572f3a406ffceaccbfc2c3895ebe0114..34b4b869b293177215d2242b9ed377ff37cfdc9f 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 0000000000000000000000000000000000000000..12cdfcb0e8825a0e3e4d7e058e92d5272903d0d3
--- /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 0000000000000000000000000000000000000000..755b48f8e4c05e6488d12521b02cb303b85e896b
--- /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
new file mode 100644
index 0000000000000000000000000000000000000000..2d1b085fc3c0ceb7704d5aac91e01e24226d3d6e
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/services/InstallAppService.kt
@@ -0,0 +1,119 @@
+/*
+ * 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 dagger.hilt.android.AndroidEntryPoint
+import foundation.e.apps.R
+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
+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.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(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()
+// }
+
+ 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)
+// }
+// }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b04e563c53461c06eecf3afa7306a74704bdfbf2..d98b2ed6db962517f3041e1d301c81f1e4301458 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 0000000000000000000000000000000000000000..42afabfd2abebf31384ca7797186a27a4b7dbee8
--- /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 0000000000000000000000000000000000000000..c78ff6dace377da27d7446fda5c8f42cfa10cd7b
--- /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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/install-app-lib/installappdemo/.gitignore b/install-app-lib/installappdemo/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..42afabfd2abebf31384ca7797186a27a4b7dbee8
--- /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 0000000000000000000000000000000000000000..612bfcf890ac4090b6df207a24fad7995e7ed382
--- /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 0000000000000000000000000000000000000000..b5b925508d9b1e71358e6385c26e3946ed626d5b
--- /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 0000000000000000000000000000000000000000..632db57a63e6e64482ededfb7a7c241cc2e51793
--- /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 0000000000000000000000000000000000000000..481bb434814107eb79d7a30b676d344b0df2f8ce
--- /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 0000000000000000000000000000000000000000..a5918e68abcdde7f61ccae4f0ad4885b764573fd
--- /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 0000000000000000000000000000000000000000..193f17b9174f1b9ec6d3292953a83d963937d8c1
--- /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/install-app-lib/src/main/aidl/foundation/e/apps/installapp/IInstallAppService.aidl b/install-app-lib/src/main/aidl/foundation/e/apps/installapp/IInstallAppService.aidl
new file mode 100644
index 0000000000000000000000000000000000000000..dfb64d59b9719802df51e08a2488a6f386e5df58
--- /dev/null
+++ b/install-app-lib/src/main/aidl/foundation/e/apps/installapp/IInstallAppService.aidl
@@ -0,0 +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.installapp;
+
+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 0000000000000000000000000000000000000000..4da7b96906863ef791b78b8949c9dd37aea558ce
--- /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 0000000000000000000000000000000000000000..4e2f6fad38cdd7ede68394d99cbda23006487ecd
--- /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 0000000000000000000000000000000000000000..4671a835238f543421d51a46a1dc6c7b22451945
--- /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 0b35903798457ed980886edba821edacc925afc5..afa5364b27997672d608dcdaf88153a97f865cb0 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'