Loading app/src/main/AndroidManifest.xml +9 −0 Original line number Diff line number Diff line Loading @@ -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" Loading app/src/main/java/foundation/e/apps/data/Constants.kt +7 −0 Original line number Diff line number Diff line Loading @@ -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" Loading app/src/main/java/foundation/e/apps/receivers/InstallRequestReceiver.kt 0 → 100644 +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" } } Loading
app/src/main/AndroidManifest.xml +9 −0 Original line number Diff line number Diff line Loading @@ -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" Loading
app/src/main/java/foundation/e/apps/data/Constants.kt +7 −0 Original line number Diff line number Diff line Loading @@ -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" Loading
app/src/main/java/foundation/e/apps/receivers/InstallRequestReceiver.kt 0 → 100644 +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" } }