Skip to content
18 changes: 9 additions & 9 deletions composeApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<!-- Needed to prompt install of APKs from our app -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"
tools:ignore="RequestInstallPackagesPolicy" />
Comment on lines +10 to +11
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

REQUEST_INSTALL_PACKAGES also requires a Google Play policy declaration — tools:ignore is insufficient.

REQUEST_INSTALL_PACKAGES is a restricted permission on Google Play. Similar to QUERY_ALL_PACKAGES, suppressing the lint warning with tools:ignore="RequestInstallPackagesPolicy" does not satisfy the Play Store policy requirement. Apps requesting this permission must qualify under an approved use case (e.g., acting as an app store) and must submit a Permissions Declaration Form. While this app's purpose justifies the use case, the declaration must still be submitted to avoid rejection.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composeApp/src/androidMain/AndroidManifest.xml` around lines 10 - 11, The
manifest currently uses the restricted permission
"android.permission.REQUEST_INSTALL_PACKAGES" with only tools:ignore
suppression; that is insufficient for Play Store — remove the lint suppression
(remove tools:ignore="RequestInstallPackagesPolicy" from the <uses-permission>
entry), ensure the permission is only present if strictly required (consider
making it non-required via android:required="false" or gating it behind runtime
logic), and before publishing submit the Google Play Permissions Declaration
Form in the Play Console with the approved use-case justification for
REQUEST_INSTALL_PACKAGES so the app will pass review.

<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
Comment on lines +7 to +12
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check manifest for <queries> entries and package visibility approach
echo "=== Checking AndroidManifest.xml for queries and package visibility ==="
grep -n "queries\|QUERY_ALL\|intent-filter\|action\|category" composeApp/src/androidMain/AndroidManifest.xml || true

echo -e "\n=== Full manifest content to understand structure ==="
cat composeApp/src/androidMain/AndroidManifest.xml

Repository: rainxchzed/Github-Store

Length of output: 5714


🏁 Script executed:

#!/bin/bash
# Check how package-related APIs are used in Kotlin code
echo "=== Checking for getLaunchIntentForPackage usage ==="
rg -n "getLaunchIntentForPackage" --type kotlin -A 3 -B 1

echo -e "\n=== Checking for getInstalledPackages usage ==="
rg -n "getInstalledPackages" --type kotlin -A 3 -B 1

echo -e "\n=== Checking for queryIntentActivities usage ==="
rg -n "queryIntentActivities" --type kotlin -A 3 -B 1

echo -e "\n=== Broader search for 'openApp' function to understand scope ==="
rg -n "openApp\|launchPackage\|resolveActivity" --type kotlin -A 5 -B 1

Repository: rainxchzed/Github-Store

Length of output: 3428


QUERY_ALL_PACKAGES requires a Google Play Permissions Declaration Form — suppressing the lint warning with tools:ignore does not satisfy Play Store policy.

QUERY_ALL_PACKAGES gives visibility into the inventory of installed apps on a device and is restricted by Google Play. Apps that fail to meet the policy requirements or do not submit the Permissions Declaration Form may be removed from Google Play. This permission may not be used if the app can operate with more targeted scoped package visibility (e.g., specific <queries> entries or intent filters for ACTION_MAIN/CATEGORY_LAUNCHER).

The codebase uses getInstalledPackages() in AndroidPackageMonitor to enumerate all installed packages and getLaunchIntentForPackage() in multiple locations for the openApp flow. While the manifest already includes a <queries> element with an intent filter for ACTION_MAIN, this is insufficient — getInstalledPackages() genuinely requires QUERY_ALL_PACKAGES to retrieve the full package list.

You must submit a Google Play Permissions Declaration Form justifying this permission, or refactor the package enumeration logic to avoid getInstalledPackages() entirely (e.g., by listening to ACTION_PACKAGE_ADDED/ACTION_PACKAGE_REMOVED broadcasts instead of polling all packages).

REQUEST_DELETE_PACKAGES on line 12 is correctly added — it is required for Intent.ACTION_DELETE_PACKAGE and is a normal-protection-level permission (automatically granted).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composeApp/src/androidMain/AndroidManifest.xml` around lines 10 - 12, The
manifest currently requests QUERY_ALL_PACKAGES which triggers Google Play’s
Permissions Declaration requirement; either remove QUERY_ALL_PACKAGES and
refactor code to avoid PackageManager.getInstalledPackages() (update
AndroidPackageMonitor to track installs via
ACTION_PACKAGE_ADDED/ACTION_PACKAGE_REMOVED broadcasts and rely on
PackageManager.getLaunchIntentForPackage() only when opening a known package)
and add targeted <queries> entries for any specific packages or intent filters
needed, or keep QUERY_ALL_PACKAGES but ensure you complete and submit the Google
Play Permissions Declaration Form justifying its use; leave
REQUEST_DELETE_PACKAGES as-is since it is required for
Intent.ACTION_DELETE_PACKAGE.


<queries>
<package android:name="zed.rainxch.githubstore" />
Expand All @@ -24,7 +28,6 @@
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:usesCleartextTraffic="false">

<!-- Expose cache files via FileProvider for APK install -->
<activity
android:name=".MainActivity"
android:exported="true"
Expand All @@ -35,7 +38,6 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<!-- Auth callback (existing) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />

Expand All @@ -47,7 +49,6 @@
android:scheme="githubstore" />
</intent-filter>

<!-- Custom scheme: githubstore://repo/{owner}/{repo} -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />

Expand All @@ -72,8 +73,7 @@
android:scheme="https" />
</intent-filter>

<!-- App website links: https://github-store.org/app/?repo={owner}/{repo} -->
<intent-filter>
<intent-filter android:autoVerify="true">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Android App Links android:autoVerify assetlinks.json verification fallback behavior

💡 Result:

What android:autoVerify="true" does

  • When your app is installed/updated, Android attempts to verify each declared HTTPS host in any VIEW/BROWSABLE/DEFAULT intent-filter that has android:autoVerify="true" by fetching https://<host>/.well-known/assetlinks.json and checking that it authorizes your app (package + signing cert SHA-256). If verification succeeds, the link becomes a verified Android App Link (so the system can open the app directly without prompting). [1]

If assetlinks.json is missing/invalid/unreachable (verification fails)

Then App Links verification does not apply for that host, so Android falls back to normal web-link intent resolution (i.e., your intent-filter may still match, but it’s treated as unverified). The user experience depends on Android version:

  • Android 12+: unverified web links generally open in the default browser instead of showing the chooser/disambiguation dialog. [2][3]
  • Android 11 and lower: unverified links commonly lead to a disambiguation (“Open with”) dialog (or whichever app the user previously chose as default). [2][3]

(Independently of App Links: if multiple apps can handle an implicit intent, Android may show a chooser; if only one can, it can open directly—standard intent behavior.) [4]

Practical implications (“fallback behavior”)

  • Your app can still open those URLs only if the user explicitly selects it (Android 11-) or manually enables link handling in settings (Android 12+ “Open supported links”), but it won’t be the guaranteed/default handler without successful verification. [2][3]
  • Common causes of verification failure include redirects, wrong SHA-256 fingerprint (e.g., Play App Signing vs debug), wrong host, invalid JSON, or serving the file incorrectly. [1]

Sources:
[1] Android Developers — Troubleshoot App Links https://developer.android.com/training/app-links/troubleshoot
[2] Android Developers Codelab — Android App Links intro (notes Android 12+ browser handling) https://developer.android.com/codelabs/android-app-links-introduction
[3] Android Developers (Medium) — Deep Links Crash Course, Part 3 (Android 12 behavior change) https://medium.com/androiddevelopers/deep-links-crash-course-part-3-troubleshooting-your-deep-links-61329fecb93
[4] Android Developers — Intents and intent filters (chooser behavior) https://developer.android.com/guide/components/intents-filters


android:autoVerify="true" requires a validated Digital Asset Links file to work correctly.

Adding autoVerify="true" to the intent-filter enables Android App Links verification. Without a valid assetlinks.json hosted at https://github-store.org/.well-known/assetlinks.json containing the correct app SHA-256 certificate fingerprint and package name, verification fails and Android falls back to treating the link as an unverified web link. On Android 12+, this typically means the link opens in the default browser without prompting the app to handle it; on Android 11 and lower, it may show a disambiguation dialog. Either way, this degrades the intended UX of seamlessly opening the link in your app.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composeApp/src/androidMain/AndroidManifest.xml` at line 70, The manifest sets
android:autoVerify="true" on the intent-filter which requires a valid Digital
Asset Links file; either remove or set autoVerify to false on the intent-filter
to avoid broken verification, or host a correct assetlinks.json at
https://github-store.org/.well-known/assetlinks.json containing the app's
package name and SHA-256 signing-certificate fingerprint so Android App Links
can be validated; update the intent-filter (android:autoVerify) and ensure the
assetlinks.json is generated with the signing key used for the app (match the
package name and fingerprint referenced in the manifest).

<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class AndroidInstaller(
override fun getApkInfoExtractor(): InstallerInfoExtractor {
return installerInfoExtractor
}

override fun detectSystemArchitecture(): SystemArchitecture {
val arch = Build.SUPPORTED_ABIS.firstOrNull() ?: return SystemArchitecture.UNKNOWN
return when {
Expand All @@ -42,7 +43,10 @@ class AndroidInstaller(
return isArchitectureCompatible(name, systemArch)
}

private fun isArchitectureCompatible(assetName: String, systemArch: SystemArchitecture): Boolean {
private fun isArchitectureCompatible(
assetName: String,
systemArch: SystemArchitecture
): Boolean {
return AssetArchitectureMatcher.isCompatible(assetName, systemArch)
}

Expand All @@ -57,17 +61,37 @@ class AndroidInstaller(
val name = asset.name.lowercase()
val archBoost = when (systemArch) {
SystemArchitecture.X86_64 -> {
if (AssetArchitectureMatcher.isExactMatch(name, SystemArchitecture.X86_64)) 10000 else 0
if (AssetArchitectureMatcher.isExactMatch(
name,
SystemArchitecture.X86_64
)
) 10000 else 0
}

SystemArchitecture.AARCH64 -> {
if (AssetArchitectureMatcher.isExactMatch(name, SystemArchitecture.AARCH64)) 10000 else 0
if (AssetArchitectureMatcher.isExactMatch(
name,
SystemArchitecture.AARCH64
)
) 10000 else 0
}

SystemArchitecture.X86 -> {
if (AssetArchitectureMatcher.isExactMatch(name, SystemArchitecture.X86)) 10000 else 0
if (AssetArchitectureMatcher.isExactMatch(
name,
SystemArchitecture.X86
)
) 10000 else 0
}

SystemArchitecture.ARM -> {
if (AssetArchitectureMatcher.isExactMatch(name, SystemArchitecture.ARM)) 10000 else 0
if (AssetArchitectureMatcher.isExactMatch(
name,
SystemArchitecture.ARM
)
) 10000 else 0
}

SystemArchitecture.UNKNOWN -> 0
}
archBoost + asset.size
Expand All @@ -80,16 +104,14 @@ class AndroidInstaller(
}

override suspend fun ensurePermissionsOrThrow(extOrMime: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val pm = context.packageManager
if (!pm.canRequestPackageInstalls()) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${context.packageName}".toUri()
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
throw IllegalStateException("Please enable 'Install unknown apps' for this app in Settings and try again.")
val pm = context.packageManager
if (!pm.canRequestPackageInstalls()) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${context.packageName}".toUri()
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
throw IllegalStateException("Please enable 'Install unknown apps' for this app in Settings and try again.")
}
}

Expand Down Expand Up @@ -158,6 +180,38 @@ class AndroidInstaller(
}
}

override fun uninstall(packageName: String) {
Logger.d { "Requesting uninstall for: $packageName" }
val intent = Intent(Intent.ACTION_DELETE).apply {
data = "package:$packageName".toUri()
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(intent)
} catch (e: Exception) {
Logger.w { "Failed to start uninstall for $packageName: ${e.message}" }
}

}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

override fun openApp(packageName: String): Boolean {
val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName)
return if (launchIntent != null) {
try {
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(launchIntent)
true
} catch (e: ActivityNotFoundException) {
Logger.w { "Failed to launch $packageName: ${e.message}" }
false
}

} else {
Logger.w { "No launch intent found for $packageName" }
false
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

override fun openInAppManager(
filePath: String,
onOpenInstaller: () -> Unit
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package zed.rainxch.core.data.services

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import zed.rainxch.core.domain.repository.InstalledAppsRepository
import zed.rainxch.core.domain.system.PackageMonitor

/**
* Listens to system package install/uninstall/replace broadcasts.
* When a tracked package is installed or updated, it resolves the pending
* install flag and updates version info from the system PackageManager.
* When a tracked package is removed, it deletes the record from the database.
*/
class PackageEventReceiver(
private val installedAppsRepository: InstalledAppsRepository,
private val packageMonitor: PackageMonitor
) : BroadcastReceiver() {

private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

override fun onReceive(context: Context?, intent: Intent?) {
val packageName = intent?.data?.schemeSpecificPart ?: return

Logger.d { "PackageEventReceiver: ${intent.action} for $packageName" }

when (intent.action) {
Intent.ACTION_PACKAGE_ADDED,
Intent.ACTION_PACKAGE_REPLACED -> {
scope.launch { onPackageInstalled(packageName) }
}

Intent.ACTION_PACKAGE_FULLY_REMOVED -> {
scope.launch { onPackageRemoved(packageName) }
}
}
}

private suspend fun onPackageInstalled(packageName: String) {
try {
val app = installedAppsRepository.getAppByPackage(packageName) ?: return

if (app.isPendingInstall) {
val systemInfo = packageMonitor.getInstalledPackageInfo(packageName)
if (systemInfo != null) {
installedAppsRepository.updateApp(
app.copy(
isPendingInstall = false,
isUpdateAvailable = false,
installedVersionName = systemInfo.versionName,
installedVersionCode = systemInfo.versionCode,
latestVersionName = systemInfo.versionName,
latestVersionCode = systemInfo.versionCode
)
)
Logger.i { "Resolved pending install via broadcast: $packageName (v${systemInfo.versionName})" }
} else {
installedAppsRepository.updatePendingStatus(packageName, false)
Logger.i { "Resolved pending install via broadcast (no system info): $packageName" }
}
} else {
val systemInfo = packageMonitor.getInstalledPackageInfo(packageName)
if (systemInfo != null) {
installedAppsRepository.updateApp(
app.copy(
installedVersionName = systemInfo.versionName,
installedVersionCode = systemInfo.versionCode
)
)
Logger.d { "Updated version info via broadcast: $packageName (v${systemInfo.versionName})" }
}
}
} catch (e: Exception) {
Logger.e { "PackageEventReceiver error for $packageName: ${e.message}" }
}
}

private suspend fun onPackageRemoved(packageName: String) {
try {
val app = installedAppsRepository.getAppByPackage(packageName) ?: return
installedAppsRepository.deleteInstalledApp(packageName)
Logger.i { "Removed uninstalled app via broadcast: $packageName" }
} catch (e: Exception) {
Logger.e { "PackageEventReceiver remove error for $packageName: ${e.message}" }
}
}

companion object {
fun createIntentFilter(): IntentFilter {
return IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
addDataScheme("package")
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ class DesktopInstaller(

}

override fun uninstall(packageName: String) {
// Desktop doesn't have a unified uninstall mechanism
Logger.d { "Uninstall not supported on desktop for: $packageName" }
}

override fun openApp(packageName: String): Boolean {
// Desktop apps are launched differently per platform
Logger.d { "Open app not supported on desktop for: $packageName" }
return false
}

override fun isAssetInstallable(assetName: String): Boolean {
val name = assetName.lowercase()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,8 @@ interface Installer {
)

fun getApkInfoExtractor(): InstallerInfoExtractor

fun uninstall(packageName: String)

fun openApp(packageName: String): Boolean
}
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,16 @@
<string name="installing">Installing</string>
<string name="pending_install">Pending install</string>

<!-- Uninstall / Open -->
<string name="uninstall">Uninstall</string>
<string name="open_app">Open</string>
<string name="downgrade_requires_uninstall">Downgrade requires uninstall</string>
<string name="downgrade_warning_message">Installing version %1$s requires uninstalling the current version (%2$s) first. Your app data will be lost.</string>
<string name="uninstall_first">Uninstall first</string>
<string name="install_version">Install %1$s</string>
<string name="failed_to_open_app">Failed to open %1$s</string>
<string name="failed_to_uninstall">Failed to uninstall %1$s</string>

<!-- Install helpers -->
<string name="open_in_obtainium">Open in Obtainium</string>
<string name="obtainium_description">Manage updates automatically</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ sealed interface AppsAction {
data object OnCancelUpdateAll : AppsAction
data object OnCheckAllForUpdates : AppsAction
data object OnRefresh : AppsAction
data class OnUninstallApp(val app: InstalledApp) : AppsAction
data class OnNavigateToRepo(val repoId: Long) : AppsAction
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Update
import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
Expand Down Expand Up @@ -300,6 +301,7 @@ fun AppsScreen(
onOpenClick = { onAction(AppsAction.OnOpenApp(appItem.installedApp)) },
onUpdateClick = { onAction(AppsAction.OnUpdateApp(appItem.installedApp)) },
onCancelClick = { onAction(AppsAction.OnCancelUpdate(appItem.installedApp.packageName)) },
onUninstallClick = { onAction(AppsAction.OnUninstallApp(appItem.installedApp)) },
onRepoClick = { onAction(AppsAction.OnNavigateToRepo(appItem.installedApp.repoId)) },
modifier = Modifier.liquefiable(liquidState)
)
Expand Down Expand Up @@ -373,6 +375,7 @@ fun AppItemCard(
onOpenClick: () -> Unit,
onUpdateClick: () -> Unit,
onCancelClick: () -> Unit,
onUninstallClick: () -> Unit,
onRepoClick: () -> Unit,
modifier: Modifier = Modifier
) {
Expand Down Expand Up @@ -548,8 +551,26 @@ fun AppItemCard(

Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Uninstall icon button (shown when not pending and not actively updating)
if (!app.isPendingInstall &&
appItem.updateState !is UpdateState.Downloading &&
appItem.updateState !is UpdateState.Installing &&
appItem.updateState !is UpdateState.CheckingUpdate
) {
IconButton(
onClick = onUninstallClick
) {
Icon(
imageVector = Icons.Outlined.DeleteOutline,
contentDescription = stringResource(Res.string.uninstall),
tint = MaterialTheme.colorScheme.error
)
}
}

Button(
onClick = onOpenClick,
modifier = Modifier.weight(1f),
Expand Down
Loading