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'