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

Verified Commit 51232e02 authored by Saalim Quadri's avatar Saalim Quadri
Browse files

feat: Add support to queue intent based installation

parent 9eba595a
Loading
Loading
Loading
Loading
Loading
+9 −0
Original line number Diff line number Diff line
@@ -165,6 +165,15 @@
            </intent-filter>
        </receiver>

        <receiver android:name=".receivers.InstallRequestReceiver"
            android:enabled="true"
            android:exported="true"
            tools:ignore="ExportedReceiver">
            <intent-filter>
                <action android:name="foundation.e.apps.action.REQUEST_APP_INSTALL"/>
            </intent-filter>
        </receiver>

        <!-- TODO: ExportedReceiver, suppressing because changes are needed in other apps -->
        <receiver android:name=".install.receiver.PwaPlayerStatusReceiver"
            tools:ignore="ExportedReceiver"
+7 −0
Original line number Diff line number Diff line
@@ -33,6 +33,13 @@ object Constants {
    const val ACTION_DUMP_APP_INSTALL_STATE = "foundation.e.apps.action.APP_INSTALL_STATE"
    const val TAG_APP_INSTALL_STATE = "APP_INSTALL_STATE"

    const val ACTION_REQUEST_APP_INSTALL = "foundation.e.apps.action.REQUEST_APP_INSTALL"
    const val ACTION_INSTALL_REQUEST_RESULT = "foundation.e.apps.action.INSTALL_REQUEST_RESULT"
    const val EXTRA_PACKAGE_NAME = "package_name"
    const val EXTRA_VERSION_CODE = "version_code"
    const val EXTRA_STATUS = "status"
    const val EXTRA_MESSAGE = "message"

    const val ACTION_PARENTAL_CONTROL_APP_LOUNGE_LOGIN =
        "${BuildConfig.PACKAGE_NAME_PARENTAL_CONTROL}.action.APP_LOUNGE_LOGIN"

+258 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 e Foundation
 *
 * 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.receivers

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import com.aurora.gplayapi.exceptions.InternalException
import dagger.hilt.android.AndroidEntryPoint
import foundation.e.apps.data.Constants.ACTION_INSTALL_REQUEST_RESULT
import foundation.e.apps.data.Constants.ACTION_REQUEST_APP_INSTALL
import foundation.e.apps.data.Constants.EXTRA_MESSAGE
import foundation.e.apps.data.Constants.EXTRA_PACKAGE_NAME
import foundation.e.apps.data.Constants.EXTRA_STATUS
import foundation.e.apps.data.Constants.EXTRA_VERSION_CODE
import foundation.e.apps.data.application.ApplicationDataManager
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.cleanapk.CleanApkRetrofit
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.playstore.PlayStoreRepository
import foundation.e.apps.data.playstore.utils.GplayHttpRequestException
import foundation.e.apps.install.workmanager.AppInstallProcessor
import foundation.e.apps.utils.SystemInfoProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import retrofit2.HttpException
import timber.log.Timber
import java.io.IOException
import javax.inject.Inject

/**
 * Broadcast receiver that handles external app installation requests.
 * This allows other system apps (like Seedvault) to request app installations
 * by sending a broadcast with the package name.
 *
 * Usage:
 * ```
 * val intent = Intent("foundation.e.apps.action.REQUEST_APP_INSTALL").apply {
 *     putExtra("package_name", "com.example.app")
 *     putExtra("version_code", 123L)  // optional
 *     setPackage("foundation.e.apps")
 * }
 * context.sendBroadcast(intent)
 * ```
 *
 * Response broadcast: foundation.e.apps.action.INSTALL_REQUEST_RESULT
 * - package_name: The requested package name
 * - status: QUEUED, NOT_FOUND, or ERROR
 * - message: Error message (if status is NOT_FOUND or ERROR)
 */
@AndroidEntryPoint
class InstallRequestReceiver : BroadcastReceiver() {

    @Inject
    lateinit var appInstallProcessor: AppInstallProcessor

    @Inject
    lateinit var playStoreRepository: PlayStoreRepository

    @Inject
    lateinit var cleanApkRetrofit: CleanApkRetrofit

    @Inject
    lateinit var applicationDataManager: ApplicationDataManager

    override fun onReceive(context: Context?, intent: Intent?) {
        if (intent?.action != ACTION_REQUEST_APP_INSTALL) return

        val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME)
        if (packageName.isNullOrBlank()) {
            Timber.w("InstallRequestReceiver: Missing package_name extra")
            return
        }

        val versionCode = intent.getLongExtra(EXTRA_VERSION_CODE, -1L)

        Timber.i("InstallRequestReceiver: Received install request for $packageName (version: $versionCode)")

        MainScope().launch {
            try {
                // Check if app is already installed
                if (context != null && isAppInstalled(context, packageName)) {
                    sendResultBroadcast(context, packageName, STATUS_ALREADY_INSTALLED, null)
                    Timber.i("InstallRequestReceiver: $packageName is already installed, skipping")
                    return@launch
                }

                val app = findApp(packageName)

                if (app != null && app.package_name.isNotEmpty()) {
                    appInstallProcessor.initAppInstall(app, isAnUpdate = false)
                    sendResultBroadcast(context, packageName, STATUS_QUEUED, null)
                    Timber.i("InstallRequestReceiver: Successfully queued $packageName for installation")
                } else {
                    sendResultBroadcast(
                        context,
                        packageName,
                        STATUS_NOT_FOUND,
                        "App not available in store"
                    )
                    Timber.w("InstallRequestReceiver: App $packageName not found in any store")
                }
            } catch (e: CancellationException) {
                throw e
            } catch (e: IOException) {
                sendResultBroadcast(context, packageName, STATUS_ERROR, "Network error: ${e.message}")
                Timber.e(e, "InstallRequestReceiver: Network error for $packageName")
            } catch (e: SecurityException) {
                sendResultBroadcast(context, packageName, STATUS_ERROR, "Security error: ${e.message}")
                Timber.e(e, "InstallRequestReceiver: Security error for $packageName")
            }
        }
    }

    private fun isAppInstalled(context: Context, packageName: String): Boolean {
        return try {
            context.packageManager.getPackageInfo(packageName, 0)
            true
        } catch (_: PackageManager.NameNotFoundException) {
            false
        }
    }

    /**
     * Tries to find an app by package name from multiple sources.
     * 1. Play Store Web API
     * 2. Play Store authenticated API
     * 3. Open Source (CleanAPK/F-Droid)
     */
    private suspend fun findApp(packageName: String): Application? {
        return findFromPlayStoreWeb(packageName)
            ?: findFromPlayStoreApi(packageName)
            ?: findFromCleanApk(packageName)
    }

    private suspend fun findFromPlayStoreWeb(packageName: String): Application? {
        return try {
            val webApp = playStoreRepository.getAppDetailsWeb(packageName)
            if (webApp != null && webApp.package_name.isNotEmpty()) {
                Timber.d("InstallRequestReceiver: Found $packageName via Play Store Web")
                webApp.source = Source.PLAY_STORE
                applicationDataManager.updateStatus(webApp)
                webApp
            } else {
                null
            }
        } catch (e: CancellationException) {
            throw e
        } catch (e: IOException) {
            Timber.d(e, "InstallRequestReceiver: Web lookup network error for $packageName")
            null
        } catch (e: GplayHttpRequestException) {
            Timber.d(e, "InstallRequestReceiver: Web lookup HTTP error for $packageName")
            null
        }
    }

    @Suppress("TooGenericExceptionCaught")
    private suspend fun findFromPlayStoreApi(packageName: String): Application? {
        return try {
            val playApp = playStoreRepository.getAppDetails(packageName)
            if (playApp.package_name.isNotEmpty()) {
                Timber.d("InstallRequestReceiver: Found $packageName via Play Store API")
                playApp.source = Source.PLAY_STORE
                applicationDataManager.updateStatus(playApp)
                playApp
            } else {
                null
            }
        } catch (e: CancellationException) {
            throw e
        } catch (_: InternalException.AppNotFound) {
            Timber.d("InstallRequestReceiver: $packageName not found in Play Store")
            null
        } catch (e: GplayHttpRequestException) {
            Timber.d(e, "InstallRequestReceiver: Play Store HTTP error for $packageName")
            null
        } catch (e: IOException) {
            Timber.d(e, "InstallRequestReceiver: Play Store network error for $packageName")
            null
        } catch (e: IllegalStateException) {
            Timber.d(e, "InstallRequestReceiver: Play Store auth/state error for $packageName")
            null
        }
    }

    private suspend fun findFromCleanApk(packageName: String): Application? {
        return try {
            val response = cleanApkRetrofit.checkAvailablePackages(
                packages = listOf(packageName),
                source = CleanApkRetrofit.APP_SOURCE_FOSS,
                architectures = SystemInfoProvider.getSupportedArchitectureList()
            )
            val searchResult = response.body()
            if (searchResult != null && searchResult.success) {
                val app = searchResult.apps.firstOrNull { it.package_name == packageName }
                if (app != null && app.package_name.isNotBlank()) {
                    Timber.d("InstallRequestReceiver: Found $packageName via CleanAPK")
                    app.source = Source.OPEN_SOURCE
                    applicationDataManager.updateStatus(app)
                    app
                } else {
                    null
                }
            } else {
                null
            }
        } catch (e: CancellationException) {
            throw e
        } catch (e: IOException) {
            Timber.d(e, "InstallRequestReceiver: CleanAPK network error for $packageName")
            null
        } catch (e: HttpException) {
            Timber.d(e, "InstallRequestReceiver: CleanAPK HTTP error for $packageName")
            null
        }
    }

    private fun sendResultBroadcast(
        context: Context?,
        packageName: String,
        status: String,
        message: String?
    ) {
        context?.sendBroadcast(
            Intent(ACTION_INSTALL_REQUEST_RESULT).apply {
                putExtra(EXTRA_PACKAGE_NAME, packageName)
                putExtra(EXTRA_STATUS, status)
                message?.let { putExtra(EXTRA_MESSAGE, it) }
            }
        )
    }

    companion object {
        const val STATUS_QUEUED = "QUEUED"
        const val STATUS_ALREADY_INSTALLED = "ALREADY_INSTALLED"
        const val STATUS_NOT_FOUND = "NOT_FOUND"
        const val STATUS_ERROR = "ERROR"
    }
}