Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ class GithubStoreApp : Application() {
isPendingInstall = false,
installedVersionName = versionName,
installedVersionCode = versionCode,
signingFingerprint = SELF_SHA256_FINGERPRINT,
)

repo.saveInstalledApp(selfApp)
Expand All @@ -156,6 +157,9 @@ class GithubStoreApp : Application() {

companion object {
private const val SELF_REPO_ID = 1101281251L
private const val SELF_SHA256_FINGERPRINT =
@Suppress("ktlint:standard:max-line-length")
"B7:F2:8E:19:8E:48:C1:93:B0:38:C6:5D:92:DD:F7:BC:07:7B:0D:B5:9E:BC:9B:25:0A:6D:AC:48:C1:18:03:CA"
private const val SELF_REPO_OWNER = "OpenHub-Store"
private const val SELF_REPO_NAME = "GitHub-Store"
private const val SELF_AVATAR_URL =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
import zed.rainxch.core.data.local.db.migrations.MIGRATION_1_2
import zed.rainxch.core.data.local.db.migrations.MIGRATION_2_3
import zed.rainxch.core.data.local.db.migrations.MIGRATION_3_4
import zed.rainxch.core.data.local.db.migrations.MIGRATION_4_5
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify MIGRATION_4_5 definition and SQL statement.
# Expect: Migration should add signingFingerprint as a nullable TEXT column.

fd -t f -e kt "MIGRATION" --exec rg -l "MIGRATION_4_5" {} \; | head -5

echo "---"

# Search for the migration definition
ast-grep --pattern $'val MIGRATION_4_5 = $_'

Repository: OpenHub-Store/GitHub-Store

Length of output: 73


🏁 Script executed:

#!/bin/bash
# Find the migrations directory
fd -t d "migrations" --max-depth 5

echo "---"

# Look for any migration files (MIGRATION_*.kt)
fd -t f -e kt "MIGRATION_" --path "*migrations*" | head -20

echo "---"

# Search for MIGRATION_4_5 in all kotlin files
rg "MIGRATION_4_5" -t kt --max-count 10

Repository: OpenHub-Store/GitHub-Store

Length of output: 373


🏁 Script executed:

#!/bin/bash
# Find migrations directory
find . -type d -name "migrations" 2>/dev/null

echo "---"

# Find all MIGRATION files
find . -type f -name "*MIGRATION*.kt" 2>/dev/null | head -20

echo "---"

# Search for MIGRATION_4_5 text in kotlin files
rg "MIGRATION_4_5" --type kotlin 2>/dev/null || rg "MIGRATION_4_5" 2>/dev/null | head -20

echo "---"

# Check what's in the migrations directory if it exists
ls -la core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/ 2>/dev/null || echo "Path not found"

Repository: OpenHub-Store/GitHub-Store

Length of output: 1000


MIGRATION_4_5 file is missing but referenced in the code.

The import at line 9 and the reference at line 23 use MIGRATION_4_5, but the file does not exist in core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/. Only MIGRATION_1_2.kt, MIGRATION_2_3.kt, and MIGRATION_3_4.kt are present. Create the MIGRATION_4_5.kt file with the database schema changes for adding the signingFingerprint column.

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

In
`@core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt`
at line 9, Create a new Kotlin file defining the Room Migration object
MIGRATION_4_5 in package zed.rainxch.core.data.local.db.migrations that performs
the schema change to add the signingFingerprint column (e.g., execute an ALTER
TABLE <your_table> ADD COLUMN signingFingerprint TEXT NULL/DEFAULT NULL to avoid
breaking existing rows) and return the Migration(4,5) instance named
MIGRATION_4_5; then ensure this MIGRATION_4_5 object is referenced by the
existing migrations list (the same one initDatabase.kt imports) so the database
builder includes the 4->5 migration.


fun initDatabase(context: Context): AppDatabase {
val appContext = context.applicationContext
Expand All @@ -19,5 +20,6 @@ fun initDatabase(context: Context): AppDatabase {
MIGRATION_1_2,
MIGRATION_2_3,
MIGRATION_3_4,
MIGRATION_4_5,
).build()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package zed.rainxch.core.data.local.db.migrations

import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase

val MIGRATION_4_5 =
object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE installed_apps ADD COLUMN signingFingerprint TEXT")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ package zed.rainxch.core.data.services

import android.content.Context
import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.os.Build
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import zed.rainxch.core.domain.model.ApkPackageInfo
import zed.rainxch.core.domain.system.InstallerInfoExtractor
import java.io.File
import java.security.MessageDigest

class AndroidInstallerInfoExtractor(
private val context: Context,
Expand All @@ -17,7 +19,11 @@ class AndroidInstallerInfoExtractor(
withContext(Dispatchers.IO) {
try {
val packageManager = context.packageManager
val flags = PackageManager.GET_META_DATA or PackageManager.GET_ACTIVITIES
val flags =
PackageManager.GET_META_DATA or
PackageManager.GET_ACTIVITIES or
GET_SIGNING_CERTIFICATES

val packageInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackageArchiveInfo(
Expand All @@ -31,9 +37,11 @@ class AndroidInstallerInfoExtractor(

if (packageInfo == null) {
Logger.e {
"Failed to parse APK at $filePath, file exists: ${File(
filePath,
).exists()}, size: ${File(filePath).length()}"
"Failed to parse APK at $filePath, file exists: ${
File(
filePath,
).exists()
}, size: ${File(filePath).length()}"
}
return@withContext null
}
Expand All @@ -50,12 +58,43 @@ class AndroidInstallerInfoExtractor(
@Suppress("DEPRECATION")
packageInfo.versionCode.toLong()
}
val fingerprint: String? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val sigInfo = packageInfo.signingInfo
val certs =
if (sigInfo?.hasMultipleSigners() == true) {
sigInfo.apkContentsSigners
} else {
sigInfo?.signingCertificateHistory
}
certs?.firstOrNull()?.toByteArray()?.let { certBytes ->
MessageDigest
.getInstance("SHA-256")
.digest(certBytes)
.joinToString(":") { "%02X".format(it) }
}
} else {
@Suppress("DEPRECATION")
val legacyInfo =
packageManager.getPackageArchiveInfo(
filePath,
PackageManager.GET_SIGNATURES,
)
@Suppress("DEPRECATION")
legacyInfo?.signatures?.firstOrNull()?.toByteArray()?.let { certBytes ->
MessageDigest
.getInstance("SHA-256")
.digest(certBytes)
.joinToString(":") { "%02X".format(it) }
}
}

ApkPackageInfo(
appName = appName,
packageName = packageInfo.packageName,
versionName = packageInfo.versionName ?: "unknown",
versionCode = versionCode,
appName = appName,
signingFingerprint = fingerprint,
)
} catch (e: Exception) {
Logger.e { "Failed to extract APK info: ${e.message}, file: $filePath" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package zed.rainxch.core.data.services
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.os.Build
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import zed.rainxch.core.domain.model.DeviceApp
import zed.rainxch.core.domain.model.SystemPackageInfo
import zed.rainxch.core.domain.system.PackageMonitor
import java.security.MessageDigest

class AndroidPackageMonitor(
context: Context,
Expand All @@ -20,12 +22,23 @@ class AndroidPackageMonitor(
override suspend fun getInstalledPackageInfo(packageName: String): SystemPackageInfo? =
withContext(Dispatchers.IO) {
runCatching {
val flags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
GET_SIGNING_CERTIFICATES.toLong()
} else {
@Suppress("DEPRECATION")
PackageManager.GET_SIGNATURES.toLong()
}

val packageInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0L))
packageManager.getPackageInfo(
packageName,
PackageManager.PackageInfoFlags.of(flags),
)
} else {
@Suppress("DEPRECATION")
packageManager.getPackageInfo(packageName, 0)
packageManager.getPackageInfo(packageName, flags.toInt())
}

val versionCode =
Expand All @@ -36,11 +49,37 @@ class AndroidPackageMonitor(
packageInfo.versionCode.toLong()
}

val signingFingerprint: String? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val sigInfo = packageInfo.signingInfo
val certs =
if (sigInfo?.hasMultipleSigners() == true) {
sigInfo.apkContentsSigners
} else {
sigInfo?.signingCertificateHistory
}
certs?.firstOrNull()?.toByteArray()?.let { certBytes ->
MessageDigest
.getInstance("SHA-256")
.digest(certBytes)
.joinToString(":") { "%02X".format(it) }
}
} else {
@Suppress("DEPRECATION")
packageInfo.signatures?.firstOrNull()?.toByteArray()?.let { certBytes ->
MessageDigest
.getInstance("SHA-256")
.digest(certBytes)
.joinToString(":") { "%02X".format(it) }
}
}
Comment on lines +52 to +75
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

🧩 Analysis chain

🌐 Web query:

Official Android documentation for SigningInfo: difference between apkContentsSigners and signingCertificateHistory, and whether signingCertificateHistory includes past signing certificates after key rotation

💡 Result:

  • apkContentsSigners / getApkContentsSigners(): returns the certs that actually signed the current APK contents, and explicitly excludes any past/rotated certs the app is authorized to use. The docs note it generally shouldn’t be used unless hasMultipleSigners() is true (because in that case history isn’t available). [1]

  • signingCertificateHistory / getSigningCertificateHistory(): returns the certs the package has proven it is authorized to use, which includes the current signer cert plus past certs provided as proof of signing-certificate rotation. The array is ordered from original (index 0) to current (last index). It returns null when the package is signed by multiple signers (rotation history isn’t used in that case). [1]

  • Does signingCertificateHistory include past signing certificates after key rotation?
    Yes—when the APK provides a verified proof-of-rotation record (APK Signature Scheme v3), Android links past signing certs to the new one, and those past certs are included in signingCertificateHistory (and reflected by hasPastSigningCertificates()). [1], [2]

Sources: Android SigningInfo API reference [1]; Android 9 “APK signature scheme with key rotation” documentation [2].


Use the current signer from signingCertificateHistory, not the first entry.

On API 28+, packageInfo.signingInfo.signingCertificateHistory is ordered from original (index 0) to current (last index). Using firstOrNull() selects the original signing certificate, not the current one. After a legitimate key rotation, this causes the stored fingerprint to pin to an ancestor cert and raises false mismatch warnings. Use the last entry (lastOrNull()) to capture the active signer, or rely on apkContentsSigners when hasMultipleSigners() is true.

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

In
`@core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPackageMonitor.kt`
around lines 52 - 75, The signing fingerprint logic currently picks the first
certificate from packageInfo.signingInfo.signingCertificateHistory (via
firstOrNull()), which is the original cert; change it to use the current signer
by selecting the last certificate (lastOrNull()) when reading
signingCertificateHistory, while still using apkContentsSigners when
hasMultipleSigners() is true, so signingFingerprint reflects the active signer.


SystemPackageInfo(
packageName = packageInfo.packageName,
versionName = packageInfo.versionName ?: "unknown",
versionCode = versionCode,
isInstalled = true,
signingFingerprint = signingFingerprint,
)
}.getOrNull()
}
Expand Down Expand Up @@ -70,12 +109,10 @@ class AndroidPackageMonitor(

packages
.filter { pkg ->
// Exclude system apps (keep user-installed + updated system apps)
val isSystemApp = (pkg.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_SYSTEM != 0
val isUpdatedSystem = (pkg.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0
!isSystemApp || isUpdatedSystem
}
.map { pkg ->
}.map { pkg ->
val versionCode =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
pkg.longVersionCode
Expand All @@ -89,8 +126,8 @@ class AndroidPackageMonitor(
appName = pkg.applicationInfo?.loadLabel(packageManager)?.toString() ?: pkg.packageName,
versionName = pkg.versionName,
versionCode = versionCode,
signingFingerprint = null,
)
}
.sortedBy { it.appName.lowercase() }
}.sortedBy { it.appName.lowercase() }
}
}
Loading