Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit b10ac710 authored by Guillaume Jacquart's avatar Guillaume Jacquart
Browse files

4025:Feat: Install app from InstallAppService commands.

parent d5657522
Loading
Loading
Loading
Loading
+19 −1
Original line number Diff line number Diff line
/*
 * Copyright MURENA SAS 2026
 * Apps  Quickly and easily install Android apps onto your device!
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package foundation.e.apps;

import foundation.e.apps.IInstallAppCallback;

interface IInstallAppService {
    void installAppId(String appId, @nullable String source, IInstallAppCallback callback);
    void installAppId(String packageName, @nullable String source, IInstallAppCallback callback);
}
+4 −2
Original line number Diff line number Diff line
/*
 * Copyright MURENA SAS 2023
 * Copyright MURENA SAS 2023-2026
 * Apps  Quickly and easily install Android apps onto your device!
 *
 * This program is free software: you can redistribute it and/or modify
@@ -101,7 +101,9 @@ class CleanApkAppsRepository @Inject constructor(
            type = null
        )

        return response.body()?.app ?: return Application()
        return response.body()?.app?.let {
                it.copy(source = if (it.is_pwa) Source.PWA else Source.OPEN_SOURCE)
            } ?: Application()
    }

    override suspend fun getSearchResults(pattern: String): List<Application> {
+142 −0
Original line number Diff line number Diff line
/*
 * Copyright MURENA SAS 2026
 * Apps  Quickly and easily install Android apps onto your device!
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package foundation.e.apps.domain.appslookup

import foundation.e.apps.data.Stores
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.blockedApps.BlockedAppRepository
import foundation.e.apps.data.cleanapk.repositories.CleanApkAppsRepository
import foundation.e.apps.data.cleanapk.repositories.CleanApkPwaRepository
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.enums.User
import foundation.e.apps.data.login.state.LoginState
import foundation.e.apps.data.playstore.PlayStoreRepository
import foundation.e.apps.data.preference.AppLoungeDataStore
import timber.log.Timber
import javax.inject.Inject

class UnavailableApp(message: String?): Exception(message)

class StoreNotConfigured(message: String?): Exception(message)

class GetAppDetailsUseCase @Inject constructor (
    private val blockedAppRepository: BlockedAppRepository,
    private val cleanApkAppsRepository: CleanApkAppsRepository,
    private val cleanApkPwaRepository: CleanApkPwaRepository,
    private val playStoreRepository: PlayStoreRepository,
    private val stores: Stores,
    private val appLoungeDataStore: AppLoungeDataStore,
) {

    suspend operator fun invoke(packageName: String, source: Source? = null): Application {
        if (blockedAppRepository.isBlockedApp(packageName)) {
            throw UnavailableApp("Can't install $packageName, it is in NotWorkingApps list.").let {
                Timber.i(it)
                it
            }
        }

        if (source == Source.SYSTEM_APP)
            throw UnavailableApp("System_app can't be installed through this").let {
            Timber.i(it)
            it
        }

        val user = appLoungeDataStore.getUser()
        val loginState = appLoungeDataStore.getLoginState()

        val availableStores = stores.getEnabledSearchSources().filter { store ->
            when(store) {
                Source.PLAY_STORE ->
                    loginState == LoginState.AVAILABLE &&  user in listOf(User.ANONYMOUS, User.GOOGLE)
                Source.SYSTEM_APP -> false
                Source.PWA, Source.OPEN_SOURCE -> loginState == LoginState.AVAILABLE
            }
        }

        if (availableStores.isEmpty()) throw StoreNotConfigured("There isn't any store configured").let {
            Timber.i(it)
            it
        }

        if (source == null) {
            var application: Application? = null
            for (store in availableStores.sorted()) {
                application = try {
                    getAppDetailsUnchecked(packageName, store)
                } catch (e: Exception) {
                    Timber.i(e, "While getAppDetails on $packageName in $store")
                    continue
                }
                break
            }
            return application ?: throw UnavailableApp("")
        }

        if (source !in availableStores) {
            throw StoreNotConfigured("$source isn't configured, can't get app from it.").let {
                Timber.i(it)
                it
            }
        }

        return getAppDetailsUnchecked(packageName, source)
    }

    private suspend fun getAppDetailsUnchecked(packageName: String, source: Source) = when(source) {
        Source.OPEN_SOURCE -> {
        getOpenSourceAppDetails(packageName)
    }

        Source.PLAY_STORE -> {
        getPlayStoreAppDetails(packageName)
    }
        Source.PWA -> {
        getPWAAppDetails(packageName)
    }
        Source.SYSTEM_APP -> throw UnavailableApp("System_app can't be installed through this")
    }

    private suspend fun getPlayStoreAppDetails(packageName: String): Application {
        // purchased apps are excluded from update : possible or not to install silently ?
        // should we filter for age rating ?
        return playStoreRepository.getAppDetails(packageName)
        // Can throw
//            throw IllegalStateException("App version code cannot be 0")
//            throw InternalException.AppNotFound() // 404
//            any GplayHttpRequestException
//              or any other things
    }

    private suspend fun getOpenSourceAppDetails(packageName: String): Application {
        val application = cleanApkAppsRepository.getAppDetails(packageName)
        if (application._id.isNullOrBlank()) {
            throw UnavailableApp("$packageName wasn't found in CleanAPK")
        }
        return application
    }

    private suspend fun getPWAAppDetails(packageName: String): Application {
        val application = cleanApkPwaRepository.getAppDetails(packageName)
        if (application._id.isNullOrBlank()) {
            throw UnavailableApp("$packageName wasn't found in CleanAPK")
        }
        return application
    }
}
+74 −0
Original line number Diff line number Diff line
/*
 * Copyright MURENA SAS 2026
 * Apps  Quickly and easily install Android apps onto your device!
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package foundation.e.apps.domain.appslookup

import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.enums.Status
import foundation.e.apps.data.install.AppInstallRepository
import foundation.e.apps.install.pkg.AppLoungePackageManager
import foundation.e.apps.install.pkg.PwaManager
import foundation.e.apps.install.workmanager.AppInstallProcessor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import timber.log.Timber
import javax.inject.Inject

class InstallAppByIdUseCase @Inject constructor(
    private val getAppDetailsUseCase: GetAppDetailsUseCase,
    private val appLoungePackageManager: AppLoungePackageManager,
    private val appInstallProcessor: AppInstallProcessor,
    private val appInstallRepository: AppInstallRepository,
    private val pwaManager: PwaManager,
) {
    suspend operator fun invoke(packageName: String, source: Source? = null): Flow<Status> {
        if (appLoungePackageManager.isInstalled(packageName)) {  // PWA will always return false
            return flowOf(Status.INSTALLED)
        }

        val app: Application
        try {
            app = getAppDetailsUseCase(packageName, source)
        } catch(_: UnavailableApp) {
            return flowOf(Status.UNAVAILABLE)
        } catch(_: StoreNotConfigured) {
            return flowOf(Status.UNAVAILABLE)
        } catch(_: Exception) {
            return flowOf(Status.INSTALLATION_ISSUE)
        }
        Timber.d("DebugGJ: Found app detais: $app")

        // TODO should-we validate purchased and age limit here ?

        appInstallProcessor.initAppInstall(app, isAnUpdate = false)

        return appInstallRepository.getDownloadFlowById(app._id).map {
            it?.status ?: if (isInstalled(app)) Status.INSTALLED else Status.UNAVAILABLE
        }
    }

    private suspend fun isInstalled(app: Application): Boolean {
        return when(app.source) {
            Source.PWA -> pwaManager.getPwaStatus(app) == Status.INSTALLED
            else -> appLoungePackageManager.isInstalled(app.package_name)
        }
    }
}
+34 −11
Original line number Diff line number Diff line
@@ -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()