Skip to content
Binary file modified composeApp/release/baselineProfiles/0/composeApp-release.dm
Binary file not shown.
Binary file modified composeApp/release/baselineProfiles/1/composeApp-release.dm
Binary file not shown.
28 changes: 11 additions & 17 deletions composeApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
<?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.DOWNLOAD_WITHOUT_NOTIFICATION" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />

<queries>
<package android:name="zed.rainxch.githubstore" />
<intent>
<action android:name="android.intent.action.MAIN" />
</intent>
</queries>

<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />

<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"
tools:ignore="RequestInstallPackagesPolicy" />
Comment on lines +10 to +11
Copy link
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.REQUEST_DELETE_PACKAGES" />

<application
android:name=".app.GithubStoreApp"
android:allowBackup="true"
Expand All @@ -24,7 +22,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 +32,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 +43,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 +67,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
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 @@ -50,7 +50,6 @@ class AndroidDownloader(
val request = DownloadManager.Request(url.toUri()).apply {
setTitle(safeName)
setDescription("Downloading asset")
setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)

setDestinationInExternalFilesDir(context, null, "ghs_downloads/$safeName")

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}" }
}

}

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
}
}

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 @@ -23,6 +23,9 @@ interface InstalledAppDao {
@Query("SELECT * FROM installed_apps WHERE repoId = :repoId")
suspend fun getAppByRepoId(repoId: Long): InstalledAppEntity?

@Query("SELECT * FROM installed_apps WHERE repoId = :repoId")
fun getAppByRepoIdAsFlow(repoId: Long): Flow<InstalledAppEntity?>

@Query("SELECT COUNT(*) FROM installed_apps WHERE isUpdateAvailable = 1")
fun getUpdateCount(): Flow<Int>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ class InstalledAppsRepositoryImpl(
override suspend fun getAppByRepoId(repoId: Long): InstalledApp? =
installedAppsDao.getAppByRepoId(repoId)?.toDomain()

override fun getAppByRepoIdAsFlow(repoId: Long): Flow<InstalledApp?> =
installedAppsDao.getAppByRepoIdAsFlow(repoId).map { it?.toDomain() }

override suspend fun isAppInstalled(repoId: Long): Boolean =
installedAppsDao.getAppByRepoId(repoId) != null

Expand Down
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 @@ -9,6 +9,7 @@ interface InstalledAppsRepository {
fun getUpdateCount(): Flow<Int>
suspend fun getAppByPackage(packageName: String): InstalledApp?
suspend fun getAppByRepoId(repoId: Long): InstalledApp?
fun getAppByRepoIdAsFlow(repoId: Long): Flow<InstalledApp?>
suspend fun isAppInstalled(repoId: Long): Boolean

suspend fun saveInstalledApp(app: InstalledApp)
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
}
Loading