diff --git a/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsContract.kt b/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsContract.kt index 6f1b5cc0a1..8a9ff68474 100644 --- a/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsContract.kt +++ b/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsContract.kt @@ -277,6 +277,8 @@ object SettingsContract { const val BILLING = "vending_billing" const val ASSET_DELIVERY = "vending_asset_delivery" const val ASSET_DEVICE_SYNC = "vending_device_sync" + const val APPS_INSTALL = "vending_apps_install" + const val APPS_INSTALLER_LIST = "vending_apps_installer_list" val PROJECTION = arrayOf( LICENSING, @@ -285,6 +287,8 @@ object SettingsContract { BILLING, ASSET_DELIVERY, ASSET_DEVICE_SYNC, + APPS_INSTALL, + APPS_INSTALLER_LIST, ) } diff --git a/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsProvider.kt b/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsProvider.kt index 1ef875e74c..fd05f7b639 100644 --- a/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsProvider.kt +++ b/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsProvider.kt @@ -10,7 +10,6 @@ import android.content.ContentValues import android.content.Context import android.content.Context.MODE_PRIVATE import android.content.SharedPreferences -import android.content.pm.ApplicationInfo import android.database.Cursor import android.database.MatrixCursor import android.net.Uri @@ -368,6 +367,8 @@ class SettingsProvider : ContentProvider() { Vending.ASSET_DELIVERY -> getSettingsBoolean(key, false) Vending.ASSET_DEVICE_SYNC -> getSettingsBoolean(key, false) Vending.SPLIT_INSTALL -> getSettingsBoolean(key, false) + Vending.APPS_INSTALL -> getSettingsBoolean(key, false) + Vending.APPS_INSTALLER_LIST -> getSettingsString(key, "") else -> throw IllegalArgumentException("Unknown key: $key") } } @@ -383,6 +384,8 @@ class SettingsProvider : ContentProvider() { Vending.SPLIT_INSTALL -> editor.putBoolean(key, value as Boolean) Vending.ASSET_DELIVERY -> editor.putBoolean(key, value as Boolean) Vending.ASSET_DEVICE_SYNC -> editor.putBoolean(key, value as Boolean) + Vending.APPS_INSTALL -> editor.putBoolean(key, value as Boolean) + Vending.APPS_INSTALLER_LIST -> editor.putString(key, value as String) else -> throw IllegalArgumentException("Unknown key: $key") } } diff --git a/play-services-base/core/src/main/kotlin/org/microg/gms/vending/InstallerData.kt b/play-services-base/core/src/main/kotlin/org/microg/gms/vending/InstallerData.kt new file mode 100644 index 0000000000..c4ebd4bc68 --- /dev/null +++ b/play-services-base/core/src/main/kotlin/org/microg/gms/vending/InstallerData.kt @@ -0,0 +1,61 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.vending + +import org.json.JSONException +import org.json.JSONObject + +enum class AllowType(val value: Int) { + REJECT_ALWAYS(0), + REJECT_ONCE(1), + ALLOW_ONCE(2), + ALLOW_ALWAYS(3), +} + +data class InstallerData(val packageName: String, var allowType: Int, val pkgSignSha256: String) { + + override fun toString(): String { + return JSONObject() + .put(CHANNEL_PACKAGE_NAME, packageName) + .put(CHANNEL_ALLOW_TYPE, allowType) + .put(CHANNEL_SIGNATURE, pkgSignSha256) + .toString() + } + + companion object { + private const val CHANNEL_PACKAGE_NAME = "packageName" + private const val CHANNEL_ALLOW_TYPE = "allowType" + private const val CHANNEL_SIGNATURE = "signature" + + private fun parse(jsonString: String): InstallerData? { + try { + val json = JSONObject(jsonString) + return InstallerData( + json.getString(CHANNEL_PACKAGE_NAME), + json.getInt(CHANNEL_ALLOW_TYPE), + json.getString(CHANNEL_SIGNATURE) + ) + } catch (e: JSONException) { + return null + } + } + + fun loadDataSet(content: String): Set { + return content.split("|").mapNotNull { parse(it) }.toSet() + } + + fun updateDataSetString(channelList: Set, channel: InstallerData): String { + val channelData = channelList.find { it.packageName == channel.packageName && it.pkgSignSha256 == channel.pkgSignSha256 } + val newChannelList = if (channelData != null) { + channelData.allowType = channel.allowType + channelList + } else { + channelList + channel + } + return newChannelList.let { it -> it.joinToString(separator = "|") { it.toString() } } + } + } +} \ No newline at end of file diff --git a/play-services-core/src/huawei/AndroidManifest.xml b/play-services-core/src/huawei/AndroidManifest.xml index 7388cfec00..26aa9013ea 100644 --- a/play-services-core/src/huawei/AndroidManifest.xml +++ b/play-services-core/src/huawei/AndroidManifest.xml @@ -55,5 +55,8 @@ + \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/ui/VendingFragment.kt b/play-services-core/src/main/kotlin/org/microg/gms/ui/VendingFragment.kt index 46faa4b4f4..acd224c188 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/ui/VendingFragment.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/VendingFragment.kt @@ -7,7 +7,11 @@ package org.microg.gms.ui import android.annotation.SuppressLint import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.TwoStatePreference @@ -118,7 +122,27 @@ class VendingFragment : PreferenceFragmentCompat() { } } + init { + setHasOptionsMenu(true) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + menu.add(0, MENU_INSTALL_MANAGED, 0, R.string.pref_app_install_settings_title) + super.onCreateOptionsMenu(menu, inflater) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + MENU_INSTALL_MANAGED -> { + findNavController().navigate(requireContext(), R.id.openVendingInstallSettings) + true + } + + else -> super.onOptionsItemSelected(item) + } + } companion object { + private const val MENU_INSTALL_MANAGED = Menu.FIRST const val PREF_LICENSING_ENABLED = "vending_licensing" const val PREF_LICENSING_PURCHASE_FREE_APPS_ENABLED = "vending_licensing_purchase_free_apps" const val PREF_SPLIT_INSTALL_ENABLED = "vending_split_install" diff --git a/play-services-core/src/main/kotlin/org/microg/gms/ui/VendingInstallSettingsFragment.kt b/play-services-core/src/main/kotlin/org/microg/gms/ui/VendingInstallSettingsFragment.kt new file mode 100644 index 0000000000..814477b130 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/VendingInstallSettingsFragment.kt @@ -0,0 +1,85 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.ui + +import android.os.Bundle +import androidx.lifecycle.lifecycleScope +import androidx.preference.Preference +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreference +import com.google.android.gms.R +import org.microg.gms.utils.getApplicationLabel +import org.microg.gms.vending.AllowType +import org.microg.gms.vending.InstallerData +import org.microg.gms.vending.VendingPreferences + +class VendingInstallSettingsFragment : PreferenceFragmentCompat() { + private lateinit var switchBarPreference: SwitchBarPreference + private lateinit var installers: PreferenceCategory + private lateinit var none: Preference + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.preferences_vending_installer_settings) + + switchBarPreference = preferenceScreen.findPreference("pref_vending_allow_install_apps") ?: switchBarPreference + installers = preferenceScreen.findPreference("pref_permission_installer_settings") ?: installers + none = preferenceScreen.findPreference("pref_permission_installer_none") ?: none + + switchBarPreference.setOnPreferenceChangeListener { _, newValue -> + val appContext = requireContext().applicationContext + lifecycleScope.launchWhenResumed { + if (newValue is Boolean) { + VendingPreferences.setInstallEnabled(appContext, newValue) + } + updateContent() + } + true + } + } + + override fun onResume() { + super.onResume() + updateContent() + } + + fun updateContent() { + val appContext = requireContext().applicationContext + lifecycleScope.launchWhenResumed { + installers.isVisible = VendingPreferences.isInstallEnabled(appContext) + switchBarPreference.isChecked = VendingPreferences.isInstallEnabled(appContext) + val installerList = VendingPreferences.getInstallerList(appContext) + val installerDataSet = InstallerData.loadDataSet(installerList) + val installerViews = installerDataSet.mapNotNull { + runCatching { + SwitchPreference(appContext).apply { + key = "pref_permission_channels_${it.packageName}" + title = appContext.packageManager.getApplicationLabel(it.packageName) + icon = appContext.packageManager.getApplicationIcon(it.packageName) + isChecked = it.allowType == AllowType.ALLOW_ALWAYS.value + setOnPreferenceChangeListener { _, newValue -> + lifecycleScope.launchWhenResumed { + if (newValue is Boolean) { + val allowType = if (newValue) AllowType.ALLOW_ALWAYS.value else AllowType.REJECT_ALWAYS.value + val content = InstallerData.updateDataSetString(installerDataSet, it.apply { this.allowType = allowType }) + VendingPreferences.setInstallerList(appContext, content) + } + } + true + } + } + }.getOrNull() + } + installers.removeAll() + for (installerView in installerViews) { + installers.addPreference(installerView) + } + if (installerViews.isEmpty()) { + installers.addPreference(none) + } + } + } +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/vending/VendingPreferences.kt b/play-services-core/src/main/kotlin/org/microg/gms/vending/VendingPreferences.kt index 07f21c50e0..4f95a41b18 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/vending/VendingPreferences.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/vending/VendingPreferences.kt @@ -99,4 +99,34 @@ object VendingPreferences { put(SettingsContract.Vending.ASSET_DEVICE_SYNC, enabled) } } + + @JvmStatic + fun isInstallEnabled(context: Context): Boolean { + val projection = arrayOf(SettingsContract.Vending.APPS_INSTALL) + return SettingsContract.getSettings(context, SettingsContract.Vending.getContentUri(context), projection) { c -> + c.getInt(0) != 0 + } + } + + @JvmStatic + fun setInstallEnabled(context: Context, enabled: Boolean) { + SettingsContract.setSettings(context, SettingsContract.Vending.getContentUri(context)) { + put(SettingsContract.Vending.APPS_INSTALL, enabled) + } + } + + @JvmStatic + fun getInstallerList(context: Context): String { + val projection = arrayOf(SettingsContract.Vending.APPS_INSTALLER_LIST) + return SettingsContract.getSettings(context, SettingsContract.Vending.getContentUri(context), projection) { c -> + c.getString(0) + } + } + + @JvmStatic + fun setInstallerList(context: Context, content: String) { + SettingsContract.setSettings(context, SettingsContract.Vending.getContentUri(context)) { + put(SettingsContract.Vending.APPS_INSTALLER_LIST, content) + } + } } \ No newline at end of file diff --git a/play-services-core/src/main/res/navigation/nav_settings.xml b/play-services-core/src/main/res/navigation/nav_settings.xml index cdeec4fd38..4e202752d1 100644 --- a/play-services-core/src/main/res/navigation/nav_settings.xml +++ b/play-services-core/src/main/res/navigation/nav_settings.xml @@ -168,7 +168,18 @@ + android:label="@string/service_name_vending"> + + + + + + diff --git a/play-services-core/src/main/res/values-zh-rCN/strings.xml b/play-services-core/src/main/res/values-zh-rCN/strings.xml index 9eda431379..20711be71c 100644 --- a/play-services-core/src/main/res/values-zh-rCN/strings.xml +++ b/play-services-core/src/main/res/values-zh-rCN/strings.xml @@ -347,4 +347,10 @@ microG GmsCore 内置一套自由的 SafetyNet 实现,但是官方服务器要 当为您的工作场所或教育机构设置工作资料时,设置程序可能会尝试连接到 Google 以允许下载应用到工作资料。 工作资料 您应自行确保使用 microG 符合企业的规章政策。microG 是在尽最大努力基础上提供的,不能保证完全按预期运行。 + + 商店安装设置 + 允许安装渠道应用 + 授权允许安装从其他渠道下载的应用程序。 + 为确保您的应用程序正常运行,请授权安装从其他来源下载的应用程序。该应用程序的某些服务需要必要的权限才能运行,拒绝权限可能会限制或禁用该应用程序的功能。 + 授权渠道 diff --git a/play-services-core/src/main/res/values/strings.xml b/play-services-core/src/main/res/values/strings.xml index 3b27af36fa..4b955539f1 100644 --- a/play-services-core/src/main/res/values/strings.xml +++ b/play-services-core/src/main/res/values/strings.xml @@ -403,6 +403,12 @@ Please set up a password, PIN, or pattern lock screen." microG services needs to access your device\'s camera to scan a code for %1$s.\n\nTo enable, please grant camera permission to microG services in Settings. Camera permission required + App Installer Settings + Allow App Installation + Authorization allows installation of apps provided from other sources. + To ensure that your installed apps work properly, please authorize microG Companion to install apps downloaded from other sources. + Apps using App Installer + Google Location Sharing Manage your real-time Location sharing across Google apps and services from this device Learn more about "Location Sharing" diff --git a/play-services-core/src/main/res/xml/preferences_vending_installer_settings.xml b/play-services-core/src/main/res/xml/preferences_vending_installer_settings.xml new file mode 100644 index 0000000000..642f0dfff6 --- /dev/null +++ b/play-services-core/src/main/res/xml/preferences_vending_installer_settings.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vending-app/src/main/AndroidManifest.xml b/vending-app/src/main/AndroidManifest.xml index ff4aa4475a..6cb46bed4e 100644 --- a/vending-app/src/main/AndroidManifest.xml +++ b/vending-app/src/main/AndroidManifest.xml @@ -16,8 +16,10 @@ android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22" /> + + @@ -297,5 +299,25 @@ + + + + + + + + + + diff --git a/vending-app/src/main/java/com/android/vending/VendingPreferences.kt b/vending-app/src/main/java/com/android/vending/VendingPreferences.kt index 9000e05942..078e17d031 100644 --- a/vending-app/src/main/java/com/android/vending/VendingPreferences.kt +++ b/vending-app/src/main/java/com/android/vending/VendingPreferences.kt @@ -57,4 +57,27 @@ object VendingPreferences { c.getInt(0) != 0 } } + + @JvmStatic + fun isInstallEnabled(context: Context): Boolean { + val projection = arrayOf(SettingsContract.Vending.APPS_INSTALL) + return SettingsContract.getSettings(context, SettingsContract.Vending.getContentUri(context), projection) { c -> + c.getInt(0) != 0 + } + } + + @JvmStatic + fun getInstallerList(context: Context): String { + val projection = arrayOf(SettingsContract.Vending.APPS_INSTALLER_LIST) + return SettingsContract.getSettings(context, SettingsContract.Vending.getContentUri(context), projection) { c -> + c.getString(0) + } + } + + @JvmStatic + fun setInstallerList(context: Context, content: String) { + SettingsContract.setSettings(context, SettingsContract.Vending.getContentUri(context)) { + put(SettingsContract.Vending.APPS_INSTALLER_LIST, content) + } + } } \ No newline at end of file diff --git a/vending-app/src/main/kotlin/org/microg/vending/installer/AppInstallActivity.kt b/vending-app/src/main/kotlin/org/microg/vending/installer/AppInstallActivity.kt new file mode 100644 index 0000000000..6374fe3c2d --- /dev/null +++ b/vending-app/src/main/kotlin/org/microg/vending/installer/AppInstallActivity.kt @@ -0,0 +1,195 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.vending.installer + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.os.Messenger +import android.provider.Settings +import android.util.Base64 +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.core.net.toUri +import androidx.lifecycle.lifecycleScope +import com.android.vending.VendingPreferences +import com.android.vending.installer.installPackages +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.microg.gms.profile.Build.VERSION.SDK_INT +import org.microg.gms.profile.ProfileManager +import org.microg.gms.utils.getFirstSignatureDigest +import org.microg.gms.vending.AllowType +import org.microg.gms.vending.InstallerData +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +private const val REQUEST_INSTALL_PERMISSION = 1001 + +@RequiresApi(21) +class AppInstallActivity : AppCompatActivity() { + + private val callingPackageName: String? + get() = callingActivity?.packageName + + private val packUris: List + get() { + val list = if (SDK_INT >= 33) { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + } + if (list != null && !list.isEmpty()) return list + val streamUri = if (SDK_INT >= 33) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + if (streamUri != null) return listOf(streamUri) + return listOfNotNull(intent.data) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val installEnabled = VendingPreferences.isInstallEnabled(this) + if (!installEnabled) { + return onResult(RESULT_CANCELED, "Install is disabled") + } + ProfileManager.ensureInitialized(this) + + if (callingPackageName.isNullOrEmpty()) { + Log.d(TAG, "onCreate: No calling activity, use startActivityForResult()") + return onResult(RESULT_CANCELED, "No calling activity") + } + + if (packUris.isEmpty()) { + Log.d(TAG, "onCreate: Missing package URI: $intent") + return onResult(RESULT_CANCELED, "Missing package URI") + } + + val pkgSignSha256ByteArray = packageManager.getFirstSignatureDigest(callingPackageName!!, "SHA-256") + ?: return onResult(RESULT_CANCELED, "$callingPackageName request install permission denied: signature is null") + + val pkgSignSha256Base64 = Base64.encodeToString(pkgSignSha256ByteArray, Base64.NO_WRAP) + Log.d(TAG, "onCreate $callingPackageName pkgSignSha256Base64: $pkgSignSha256Base64") + + val installerList = VendingPreferences.getInstallerList(this) + val installerDataSet = InstallerData.loadDataSet(installerList) + + val callerInstallerData = callerToInstallerData(installerDataSet, callingPackageName!!, pkgSignSha256Base64) + if (callerInstallerData.allowType == AllowType.REJECT_ALWAYS.value) { + return onResult(RESULT_CANCELED, "$callingPackageName is not allowed to install") + } + + val appInfo = extractInstallAppInfo(packUris) ?: + return onResult(RESULT_CANCELED, "Can't extract app information from provided .apk") + + lifecycleScope.launchWhenStarted { + var callerAllow = callerInstallerData.allowType + if (callerAllow == AllowType.REJECT_ONCE.value || callerAllow == AllowType.ALLOW_ONCE.value) { + callerAllow = showRequestInstallReminder(appInfo) + } + Log.d(TAG, "onCreate: callerPackagePermissionType: $callerAllow") + + val newInstallerDataString = InstallerData.updateDataSetString(installerDataSet, callerInstallerData.apply { this.allowType = callerAllow }) + VendingPreferences.setInstallerList(this@AppInstallActivity, newInstallerDataString) + Log.d(TAG, "onCreate: newInstallerDataString: $newInstallerDataString") + + if (callerAllow == AllowType.ALLOW_ALWAYS.value || callerAllow == AllowType.ALLOW_ONCE.value) { + if (hasInstallPermission()) { + Log.d(TAG, "onCreate: hasInstallPermission") + handleInstallRequest(appInfo.packageName) + } else { + openInstallPermissionSettings() + } + return@launchWhenStarted + } + + onResult(RESULT_CANCELED, "$callingPackageName request install permission denied", appInfo.packageName) + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == REQUEST_INSTALL_PERMISSION) { + if (hasInstallPermission()) { + Log.d(TAG, "onCreate: requestInstallPermission granted") + val appInfo = extractInstallAppInfo(packUris!!) ?: return onResult(RESULT_CANCELED, "File changed while granting permission") + handleInstallRequest(appInfo.packageName) + } else { + onResult(RESULT_CANCELED, "Install Permission denied") + } + } + } + + private fun callerToInstallerData(installerDataSet: Set, callingPackage: String, pkgSignSha256: String): InstallerData { + if (installerDataSet.isEmpty() || installerDataSet.none { it.packageName == callingPackage && it.pkgSignSha256 == pkgSignSha256 }) { + return InstallerData(callingPackage, AllowType.REJECT_ONCE.value, pkgSignSha256) + } + return installerDataSet.first { it.packageName == callingPackage && it.pkgSignSha256 == pkgSignSha256 } + } + + private fun handleInstallRequest(installPackageName: String) { + lifecycleScope.launch { + val isSuccess = runCatching { + withContext(Dispatchers.IO) { + installPackages( + packageName = installPackageName, + componentFiles = uriToApkFiles(packUris!!), + isUpdate = true + ) + } + }.isSuccess + Log.d(TAG, "handleInstallRequest: installPackages<$installPackageName> isSuccess: $isSuccess") + if (isSuccess) { + onResult(RESULT_OK, installPackageName = installPackageName) + } else { + onResult(RESULT_CANCELED, "Install failed") + } + } + } + + private suspend fun showRequestInstallReminder(appInfo: InstallAppInfo) = suspendCoroutine { con -> + val intent = Intent(this, AskInstallReminderActivity::class.java) + intent.putExtra(EXTRA_MESSENGER, Messenger(object : Handler(Looper.getMainLooper()) { + override fun handleMessage(msg: Message) { + con.resume(msg.what) + } + })) + intent.putExtra(EXTRA_CALLER_PACKAGE, callingPackageName) + intent.putExtra(EXTRA_INSTALL_PACKAGE_NAME, appInfo.packageName) + intent.putExtra(EXTRA_INSTALL_PACKAGE_LABEL, appInfo.appLabel) + intent.putExtra(EXTRA_INSTALL_PACKAGE_ICON, appInfo.appIcon?.toByteArrayOrNull()) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(intent) + } + + private fun openInstallPermissionSettings() { + Log.d(TAG, "openInstallPermissionSettings: request ") + val intent = if (SDK_INT >= 26) { + Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = "package:$packageName".toUri() + } + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + } + } + startActivityForResult(intent, REQUEST_INSTALL_PERMISSION) + } + + private fun onResult(result: Int = RESULT_OK, error: String? = null, installPackageName: String? = null) { + Log.d(TAG, "onResult: error: $error ") + sendBroadcastReceiver(callingPackageName, installPackageName, result, error) + setResult(result, Intent().apply { putExtra("error", error) }) + finishAndRemoveTask() + } +} \ No newline at end of file diff --git a/vending-app/src/main/kotlin/org/microg/vending/installer/AppInstallExtensions.kt b/vending-app/src/main/kotlin/org/microg/vending/installer/AppInstallExtensions.kt new file mode 100644 index 0000000000..0914aa954a --- /dev/null +++ b/vending-app/src/main/kotlin/org/microg/vending/installer/AppInstallExtensions.kt @@ -0,0 +1,148 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.vending.installer + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.net.Uri +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.graphics.createBitmap +import androidx.core.graphics.drawable.toDrawable +import org.microg.gms.profile.Build.VERSION.SDK_INT +import java.io.ByteArrayOutputStream +import java.io.File + +const val TAG = "AppInstall" +const val EXTRA_MESSENGER = "messenger" +const val EXTRA_CALLER_PACKAGE = "calling_package" +const val EXTRA_INSTALL_PACKAGE = "installed_app_package" +const val EXTRA_INSTALL_PACKAGE_ICON = "installPackageIcon" +const val EXTRA_INSTALL_PACKAGE_NAME = "installPackageName" +const val EXTRA_INSTALL_PACKAGE_LABEL = "installPackageLabel" +const val INSTALL_RESULT_RECV_ACTION = "com.android.vending.install.PACAKGE" +const val SOURCE_PACKAGE = "source_package" + +fun Context.hasInstallPermission() = if (SDK_INT >= 26) { + packageManager.canRequestPackageInstalls() +} else { + true +} + +data class InstallAppInfo(val packageName: String, val appLabel: String, val appIcon: Drawable?) + +fun Context.extractInstallAppInfo(uris: List): InstallAppInfo? { + var packageName: String? = null + var appLabel: String? = null + var appIcon: Drawable? = null + for (item in uris) { + var tempFile: File? = null + try { + tempFile = File.createTempFile("temp_apk_", ".apk", cacheDir).apply { + contentResolver.openInputStream(item)?.use { input -> + outputStream().use { output -> input.copyTo(output) } + } + } + val packageInfo = if (SDK_INT >= 33) { + packageManager.getPackageArchiveInfo(tempFile.absolutePath, PackageManager.PackageInfoFlags.of(PackageManager.GET_META_DATA.toLong())) + } else { + @Suppress("DEPRECATION") + packageManager.getPackageArchiveInfo(tempFile.absolutePath, PackageManager.GET_META_DATA) + } ?: continue + Log.d(TAG, "Package: $packageInfo, App: ${packageInfo.applicationInfo}") + if (packageName != null && packageInfo.packageName != packageName) { + Log.w(TAG, "Inconsistent packages") + return null + } + packageName = packageInfo.packageName + val appInfo = packageInfo.applicationInfo.apply { + this?.sourceDir = tempFile.absolutePath + this?.publicSourceDir = tempFile.absolutePath + } ?: continue + val thisAppLabel = packageManager.getApplicationLabel(appInfo).toString() + Log.d(TAG, "Got app label: $thisAppLabel") + if (thisAppLabel != packageName && thisAppLabel.isNotBlank()) appLabel = thisAppLabel + appIcon = packageManager.getApplicationIcon(appInfo) + if (appLabel != null) break + } catch (e: Exception) { + Log.w(TAG, "Failed to extract app info: ${e.message}", e) + } finally { + tempFile?.delete() + } + } + if (packageName != null) { + return InstallAppInfo(packageName, appLabel ?: packageName, appIcon) + } + return null +} + +fun Context.uriToApkFiles(uriList: List): List { + return uriList.mapIndexedNotNull { uriIndex, uri -> + File.createTempFile("temp_apk_", ".$uriIndex.apk", cacheDir).apply { + contentResolver.openInputStream(uri)?.use { input -> + outputStream().use { output -> input.copyTo(output) } + } + } + } +} + +fun Drawable.toByteArrayOrNull(): ByteArray? = runCatching { + val bitmap = if (this is BitmapDrawable) { + this.bitmap + } else { + createBitmap(intrinsicWidth, intrinsicHeight).also { bmp -> + val canvas = Canvas(bmp) + setBounds(0, 0, canvas.width, canvas.height) + draw(canvas) + } + } + + ByteArrayOutputStream().use { outputStream -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) + if (this !is BitmapDrawable) { + bitmap.recycle() + } + outputStream.toByteArray() + } +}.onFailure { e -> + Log.w(TAG, "Failed to convert Drawable to ByteArray: ${e.message}", e) +}.getOrNull() + +fun ByteArray.toDrawableOrNull(context: Context): Drawable? = runCatching { + val bitmap = BitmapFactory.decodeByteArray(this, 0, size) + bitmap.toDrawable(context.resources) +}.onFailure { e -> + Log.w(TAG, "Failed to convert ByteArray to Drawable: ${e.message}", e) +}.getOrNull() + +@RequiresApi(21) +fun Context.sendBroadcastReceiver(callingPackage: String?, installingPackage: String?, status: Int = 0, statusMessage: String? = null, sessionId: Int = 0) { + try { + Log.d(TAG, "transform broadcast to caller app start : $callingPackage, status: $status, sessionId:${sessionId}") + if (callingPackage.isNullOrEmpty() || installingPackage.isNullOrEmpty()) { + return + } + val forwardIntent = Intent(INSTALL_RESULT_RECV_ACTION).apply { + putExtra(PackageInstaller.EXTRA_SESSION_ID, sessionId) + putExtra(PackageInstaller.EXTRA_STATUS, status) + putExtra(PackageInstaller.EXTRA_STATUS_MESSAGE, statusMessage) + putExtra(SOURCE_PACKAGE, packageName) + putExtra(EXTRA_INSTALL_PACKAGE, installingPackage) + setPackage(callingPackage) + } + sendBroadcast(forwardIntent) + Log.d(TAG, "transform broadcast to caller app end: $callingPackage, status: $status, sessionId:${sessionId}") + } catch (e: Exception) { + Log.d(TAG, "error:${e.message}") + } +} \ No newline at end of file diff --git a/vending-app/src/main/kotlin/org/microg/vending/installer/AskInstallReminderActivity.kt b/vending-app/src/main/kotlin/org/microg/vending/installer/AskInstallReminderActivity.kt new file mode 100644 index 0000000000..51bf385dfb --- /dev/null +++ b/vending-app/src/main/kotlin/org/microg/vending/installer/AskInstallReminderActivity.kt @@ -0,0 +1,88 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.vending.installer + +import android.os.Bundle +import android.os.Message +import android.os.Messenger +import android.widget.Button +import android.widget.CheckBox +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import com.android.vending.R +import org.microg.gms.utils.getApplicationLabel +import org.microg.gms.vending.AllowType + +@RequiresApi(21) +class AskInstallReminderActivity : AppCompatActivity() { + + private lateinit var permissionDesc: TextView + private lateinit var appIconView: ImageView + private lateinit var appNameView: TextView + private lateinit var checkBox: CheckBox + private lateinit var btnAllow: Button + private lateinit var btnClose: ImageView + private var isNotShowAgainChecked: Boolean = false + private var isBtnClick: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_install_reminder) + setupViews() + setupListeners() + } + + private fun setupViews() { + val callerPackage = intent.extras?.getString(EXTRA_CALLER_PACKAGE)?.takeIf { it.isNotEmpty() } + ?: return finishWithReply(AllowType.REJECT_ONCE.value) + val callerLabel = runCatching { packageManager.getApplicationLabel(callerPackage) }.getOrNull() + ?: return finishWithReply(AllowType.REJECT_ONCE.value) + val appIcon = intent?.getByteArrayExtra(EXTRA_INSTALL_PACKAGE_ICON)?.toDrawableOrNull(this) + val appLabel = intent?.getStringExtra(EXTRA_INSTALL_PACKAGE_LABEL)?.takeIf { it.isNotEmpty() } + ?: return finishWithReply(AllowType.REJECT_ONCE.value) + + permissionDesc = findViewById(R.id.tv_description) + permissionDesc.text = getString(R.string.app_install_allow_to_install_third_app, callerLabel) + appIconView = findViewById(R.id.iv_app_icon) + appIcon?.let { appIconView.setImageDrawable(it) } + appNameView = findViewById(R.id.tv_app_name) + appNameView.text = appLabel + checkBox = findViewById(R.id.cb_dont_show_again) + checkBox.setOnCheckedChangeListener { _, isChecked -> isNotShowAgainChecked = isChecked } + + btnAllow = findViewById(R.id.btn_allow) + btnClose = findViewById(R.id.btn_close) + } + + private fun setupListeners() { + btnClose.setOnClickListener { + isBtnClick = true + finishWithReply(if (isNotShowAgainChecked) AllowType.REJECT_ALWAYS.value else AllowType.REJECT_ONCE.value) + } + btnAllow.setOnClickListener { + isBtnClick = true + finishWithReply(if (isNotShowAgainChecked) AllowType.ALLOW_ALWAYS.value else AllowType.ALLOW_ONCE.value) + } + } + + override fun onStop() { + super.onStop() + if (!isBtnClick) { + finishWithReply() + } + } + + private fun finishWithReply(code: Int = AllowType.REJECT_ONCE.value) { + intent?.getParcelableExtra(EXTRA_MESSENGER)?.let { + runCatching { + it.send(Message.obtain().apply { what = code }) + } + } + finishAndRemoveTask() + } +} \ No newline at end of file diff --git a/vending-app/src/main/res/layout/activity_install_reminder.xml b/vending-app/src/main/res/layout/activity_install_reminder.xml new file mode 100644 index 0000000000..3c3050f55c --- /dev/null +++ b/vending-app/src/main/res/layout/activity_install_reminder.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +