From 19d1635086d2fc0339bf798592094af636a7ca0f Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 16 Mar 2026 10:59:37 +0500 Subject: [PATCH 1/8] feat: implement APK signing fingerprint verification and improve Shizuku integration - Add `signingFingerprint` field to `InstalledAppEntity`, `InstalledApp`, and `ApkPackageInfo` to track app authenticity - Implement fingerprint verification during the installation process to prevent updates with mismatched signing keys - Enhance `AndroidInstallerInfoExtractor` to extract SHA-256 signing certificates for both modern (API 28+) and legacy Android versions - Refactor `DetailsViewModel` to decouple download and installation logic into distinct, manageable methods - Update `InstalledAppsRepository` and `PackageEventReceiver` to persist and verify signing fingerprints during updates - Improve Shizuku service management by moving `ShizukuStatus` to a dedicated model and refining lifecycle handling - Clean up `AutoUpdateWorker` and `UpdateScheduler` logic, including better notification handling and permission checks - Apply consistent code formatting and remove redundant comments across modified files --- .../services/AndroidInstallerInfoExtractor.kt | 49 +- .../core/data/services/AutoUpdateWorker.kt | 86 ++-- .../data/services/PackageEventReceiver.kt | 11 +- .../core/data/services/UpdateScheduler.kt | 18 +- .../shizuku/AndroidInstallerStatusProvider.kt | 29 +- .../services/shizuku/ShizukuServiceManager.kt | 190 +++----- .../services/shizuku/model/ShizukuStatus.kt | 8 + .../local/db/entities/InstalledAppEntity.kt | 1 + .../core/data/mappers/InstalledAppsMappers.kt | 2 + .../repository/InstalledAppsRepositoryImpl.kt | 58 ++- .../core/domain/model/ApkPackageInfo.kt | 1 + .../rainxch/core/domain/model/InstalledApp.kt | 1 + .../repository/InstalledAppsRepository.kt | 1 + .../use_cases/SyncInstalledAppsUseCase.kt | 5 - .../apps/presentation/AppsViewModel.kt | 89 ++-- .../details/presentation/DetailsViewModel.kt | 430 +++++++++++------- 16 files changed, 553 insertions(+), 426 deletions(-) create mode 100644 core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/model/ShizukuStatus.kt diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstallerInfoExtractor.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstallerInfoExtractor.kt index 64f948c1..3639f493 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstallerInfoExtractor.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstallerInfoExtractor.kt @@ -2,6 +2,7 @@ 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 @@ -9,6 +10,7 @@ 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, @@ -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( @@ -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 } @@ -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" } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt index 9c95550d..83d22ac2 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt @@ -18,14 +18,14 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.first import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import zed.rainxch.core.data.services.shizuku.ShizukuServiceManager +import zed.rainxch.core.data.services.shizuku.model.ShizukuStatus import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.InstallerType import zed.rainxch.core.domain.network.Downloader import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.ThemesRepository import zed.rainxch.core.domain.system.Installer -import zed.rainxch.core.data.services.shizuku.ShizukuServiceManager -import zed.rainxch.core.data.services.shizuku.ShizukuStatus /** * Background worker that automatically downloads and silently installs @@ -46,21 +46,20 @@ class AutoUpdateWorker( private val themesRepository: ThemesRepository by inject() private val shizukuServiceManager: ShizukuServiceManager by inject() - override suspend fun doWork(): Result = - try { + override suspend fun doWork(): Result { + return try { Logger.i { "AutoUpdateWorker: Starting auto-update" } - // Double-check preferences (they may have changed since scheduling) val autoUpdateEnabled = themesRepository.getAutoUpdateEnabled().first() val installerType = themesRepository.getInstallerType().first() - // Refresh Shizuku status directly — don't rely on app-process cached state, - // since this worker may run in a cold process where listeners weren't initialized. shizukuServiceManager.refreshStatus() val shizukuReady = shizukuServiceManager.status.value == ShizukuStatus.READY if (!autoUpdateEnabled || installerType != InstallerType.SHIZUKU || !shizukuReady) { - Logger.i { "AutoUpdateWorker: Conditions not met (autoUpdate=$autoUpdateEnabled, installer=$installerType, shizuku=$shizukuReady), skipping" } + Logger.i { + "AutoUpdateWorker: Conditions not met (autoUpdate=$autoUpdateEnabled, installer=$installerType, shizuku=$shizukuReady), skipping" + } return Result.success() } @@ -91,7 +90,6 @@ class AutoUpdateWorker( } catch (e: Exception) { failedApps.add(app.appName) Logger.e { "AutoUpdateWorker: Failed to update ${app.appName}: ${e.message}" } - // Clear pending status on failure try { installedAppsRepository.updatePendingStatus(app.packageName, false) } catch (clearEx: Exception) { @@ -100,7 +98,6 @@ class AutoUpdateWorker( } } - // Show summary notification showSummaryNotification(successfulApps, failedApps) Logger.i { "AutoUpdateWorker: Completed. Success: ${successfulApps.size}, Failed: ${failedApps.size}" } @@ -113,24 +110,28 @@ class AutoUpdateWorker( Result.failure() } } + } private suspend fun updateApp(app: InstalledApp) { - val assetUrl = app.latestAssetUrl - ?: throw IllegalStateException("No asset URL for ${app.appName}") - val assetName = app.latestAssetName - ?: throw IllegalStateException("No asset name for ${app.appName}") - val latestVersion = app.latestVersion - ?: throw IllegalStateException("No latest version for ${app.appName}") + val assetUrl = + app.latestAssetUrl + ?: throw IllegalStateException("No asset URL for ${app.appName}") + val assetName = + app.latestAssetName + ?: throw IllegalStateException("No asset name for ${app.appName}") + val latestVersion = + app.latestVersion + ?: throw IllegalStateException("No latest version for ${app.appName}") val ext = assetName.substringAfterLast('.', "").lowercase() - // Check if we already have a valid downloaded file val existingPath = downloader.getDownloadedFilePath(assetName) if (existingPath != null) { val file = java.io.File(existingPath) try { val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(existingPath) - val normalizedExisting = apkInfo?.versionName?.removePrefix("v")?.removePrefix("V") ?: "" + val normalizedExisting = + apkInfo?.versionName?.removePrefix("v")?.removePrefix("V") ?: "" val normalizedLatest = latestVersion.removePrefix("v").removePrefix("V") if (normalizedExisting != normalizedLatest) { file.delete() @@ -142,17 +143,17 @@ class AutoUpdateWorker( } } - // Download the APK Logger.d { "AutoUpdateWorker: Downloading $assetName for ${app.appName}" } downloader.download(assetUrl, assetName).collect { /* consume flow to completion */ } - val filePath = downloader.getDownloadedFilePath(assetName) - ?: throw IllegalStateException("Downloaded file not found for ${app.appName}") + val filePath = + downloader.getDownloadedFilePath(assetName) + ?: throw IllegalStateException("Downloaded file not found for ${app.appName}") - val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) - ?: throw IllegalStateException("Failed to extract APK info for ${app.appName}") + val apkInfo = + installer.getApkInfoExtractor().extractPackageInfo(filePath) + ?: throw IllegalStateException("Failed to extract APK info for ${app.appName}") - // Mark as pending install val currentApp = installedAppsRepository.getAppByPackage(app.packageName) if (currentApp != null) { installedAppsRepository.updateApp( @@ -167,7 +168,6 @@ class AutoUpdateWorker( ) } - // Silent install via Shizuku (ShizukuInstallerWrapper handles the actual Shizuku call) Logger.d { "AutoUpdateWorker: Installing ${app.appName} via Shizuku" } try { installer.install(filePath, ext) @@ -216,7 +216,6 @@ class AutoUpdateWorker( successfulApps: List, failedApps: List, ) { - // Check notification permission for API 33+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val granted = ContextCompat.checkSelfPermission( @@ -228,17 +227,25 @@ class AutoUpdateWorker( if (successfulApps.isEmpty() && failedApps.isEmpty()) return - val title = when { - failedApps.isEmpty() -> "${successfulApps.size} app${if (successfulApps.size > 1) "s" else ""} updated" - successfulApps.isEmpty() -> "Failed to update ${failedApps.size} app${if (failedApps.size > 1) "s" else ""}" - else -> "${successfulApps.size} updated, ${failedApps.size} failed" - } + val title = + when { + failedApps.isEmpty() -> "${successfulApps.size} app${if (successfulApps.size > 1) "s" else ""} updated" + successfulApps.isEmpty() -> "Failed to update ${failedApps.size} app${if (failedApps.size > 1) "s" else ""}" + else -> "${successfulApps.size} updated, ${failedApps.size} failed" + } - val text = when { - failedApps.isEmpty() -> successfulApps.joinToString(", ") - successfulApps.isEmpty() -> failedApps.joinToString(", ") - else -> "Updated: ${successfulApps.joinToString(", ")}. Failed: ${failedApps.joinToString(", ")}" - } + val text = + when { + failedApps.isEmpty() -> successfulApps.joinToString(", ") + + successfulApps.isEmpty() -> failedApps.joinToString(", ") + + else -> "Updated: ${successfulApps.joinToString(", ")}. Failed: ${ + failedApps.joinToString( + ", ", + ) + }" + } val launchIntent = applicationContext.packageManager @@ -266,15 +273,16 @@ class AutoUpdateWorker( } else { android.R.drawable.stat_notify_error }, - ) - .setContentTitle(title) + ).setContentTitle(title) .setContentText(text) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setContentIntent(pendingIntent) .setAutoCancel(true) .build() - NotificationManagerCompat.from(applicationContext).notify(SUMMARY_NOTIFICATION_ID, notification) + NotificationManagerCompat + .from(applicationContext) + .notify(SUMMARY_NOTIFICATION_ID, notification) } companion object { diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt index c22061ad..aae972a6 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt @@ -23,7 +23,9 @@ import zed.rainxch.core.domain.system.PackageMonitor * Uses [KoinComponent] for the no-arg constructor path (manifest-registered). * The constructor with explicit dependencies is used for dynamic registration. */ -class PackageEventReceiver() : BroadcastReceiver(), KoinComponent { +class PackageEventReceiver() : + BroadcastReceiver(), + KoinComponent { private val installedAppsRepositoryKoin: InstalledAppsRepository by inject() private val packageMonitorKoin: PackageMonitor by inject() @@ -41,11 +43,9 @@ class PackageEventReceiver() : BroadcastReceiver(), KoinComponent { this.explicitMonitor = packageMonitor } - private fun getRepository(): InstalledAppsRepository = - explicitRepository ?: installedAppsRepositoryKoin + private fun getRepository(): InstalledAppsRepository = explicitRepository ?: installedAppsRepositoryKoin - private fun getMonitor(): PackageMonitor = - explicitMonitor ?: packageMonitorKoin + private fun getMonitor(): PackageMonitor = explicitMonitor ?: packageMonitorKoin override fun onReceive( context: Context?, @@ -94,6 +94,7 @@ class PackageEventReceiver() : BroadcastReceiver(), KoinComponent { newAssetUrl = app.latestAssetUrl ?: "", newVersionName = systemInfo.versionName, newVersionCode = systemInfo.versionCode, + signingFingerprint = app.signingFingerprint, ) repo.updatePendingStatus(packageName, false) Logger.i { "Update confirmed via broadcast: $packageName (v${systemInfo.versionName})" } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt index 8895e55e..e4f97dc6 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt @@ -37,8 +37,6 @@ object UpdateScheduler { TimeUnit.MINUTES, ).build() - // KEEP preserves the existing schedule so reopening the app doesn't reset the timer. - // The schedule is only created fresh on first install or after cancel(). WorkManager .getInstance(context) .enqueueUniquePeriodicWork( @@ -47,10 +45,6 @@ object UpdateScheduler { request = request, ) - // Run an immediate one-time check so users get notified sooner - // rather than waiting up to intervalHours for the first periodic run. - // Uses REPLACE so each app launch gets a fresh check (the previous one-time - // work may have already completed). val immediateRequest = OneTimeWorkRequestBuilder() .setConstraints(constraints) @@ -68,11 +62,6 @@ object UpdateScheduler { Logger.i { "UpdateScheduler: Scheduled periodic update check every ${intervalHours}h + immediate check" } } - /** - * Force-reschedules the periodic update check with a new interval. - * Uses UPDATE policy to replace the existing schedule immediately. - * Call this when the user changes the update check interval in settings. - */ fun reschedule( context: Context, intervalHours: Long, @@ -105,10 +94,6 @@ object UpdateScheduler { Logger.i { "UpdateScheduler: Rescheduled periodic update check to every ${intervalHours}h" } } - /** - * Enqueues a one-time [AutoUpdateWorker] to download and silently install - * all available updates via Shizuku. Uses KEEP policy to avoid duplicate runs. - */ fun scheduleAutoUpdate(context: Context) { val constraints = Constraints @@ -123,8 +108,7 @@ object UpdateScheduler { BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES, - ) - .build() + ).build() WorkManager .getInstance(context) diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/AndroidInstallerStatusProvider.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/AndroidInstallerStatusProvider.kt index 475f24b3..6409ccb7 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/AndroidInstallerStatusProvider.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/AndroidInstallerStatusProvider.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import zed.rainxch.core.data.services.shizuku.model.ShizukuStatus import zed.rainxch.core.domain.model.ShizukuAvailability import zed.rainxch.core.domain.system.InstallerStatusProvider @@ -14,22 +15,22 @@ import zed.rainxch.core.domain.system.InstallerStatusProvider */ class AndroidInstallerStatusProvider( private val shizukuServiceManager: ShizukuServiceManager, - scope: CoroutineScope + scope: CoroutineScope, ) : InstallerStatusProvider { - override val shizukuAvailability: StateFlow = - shizukuServiceManager.status.map { status -> - when (status) { - ShizukuStatus.NOT_INSTALLED -> ShizukuAvailability.UNAVAILABLE - ShizukuStatus.NOT_RUNNING -> ShizukuAvailability.NOT_RUNNING - ShizukuStatus.PERMISSION_NEEDED -> ShizukuAvailability.PERMISSION_NEEDED - ShizukuStatus.READY -> ShizukuAvailability.READY - } - }.stateIn( - scope = scope, - started = SharingStarted.Eagerly, - initialValue = ShizukuAvailability.UNAVAILABLE - ) + shizukuServiceManager.status + .map { status -> + when (status) { + ShizukuStatus.NOT_INSTALLED -> ShizukuAvailability.UNAVAILABLE + ShizukuStatus.NOT_RUNNING -> ShizukuAvailability.NOT_RUNNING + ShizukuStatus.PERMISSION_NEEDED -> ShizukuAvailability.PERMISSION_NEEDED + ShizukuStatus.READY -> ShizukuAvailability.READY + } + }.stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = ShizukuAvailability.UNAVAILABLE, + ) override fun requestShizukuPermission() { shizukuServiceManager.requestPermission() diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuServiceManager.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuServiceManager.kt index fcf24b86..4ee9746b 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuServiceManager.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuServiceManager.kt @@ -14,33 +14,12 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull import rikka.shizuku.Shizuku +import zed.rainxch.core.data.services.shizuku.model.ShizukuStatus import kotlin.coroutines.resume -/** - * Status of the Shizuku service connection. - */ -enum class ShizukuStatus { - NOT_INSTALLED, - NOT_RUNNING, - PERMISSION_NEEDED, - READY -} - -/** - * Manages the Shizuku lifecycle: availability detection, permission management, - * and UserService binding for the installer service. - */ class ShizukuServiceManager( - private val context: Context + private val context: Context, ) { - - companion object { - private const val TAG = "ShizukuServiceManager" - private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" - private const val BIND_TIMEOUT_MS = 15_000L - const val SHIZUKU_PERMISSION_REQUEST_CODE = 1001 - } - private val _status = MutableStateFlow(ShizukuStatus.NOT_INSTALLED) val status: StateFlow = _status.asStateFlow() @@ -52,26 +31,28 @@ class ShizukuServiceManager( var installerService: IShizukuInstallerService? = null private set - private val binderReceivedListener = Shizuku.OnBinderReceivedListener { - Logger.d(TAG) { "Shizuku binder received" } - refreshStatus() - } + private val binderReceivedListener = + Shizuku.OnBinderReceivedListener { + Logger.d(TAG) { "Shizuku binder received" } + refreshStatus() + } - private val binderDeadListener = Shizuku.OnBinderDeadListener { - Logger.d(TAG) { "Shizuku binder dead" } - installerService = null - refreshStatus() - } + private val binderDeadListener = + Shizuku.OnBinderDeadListener { + Logger.d(TAG) { "Shizuku binder dead" } + installerService = null + refreshStatus() + } private val permissionResultListener = Shizuku.OnRequestPermissionResultListener { requestCode, grantResult -> - Logger.d(TAG) { "Shizuku permission result: requestCode=$requestCode, granted=${grantResult == PackageManager.PERMISSION_GRANTED}" } + Logger.d(TAG) { + "Shizuku permission result: requestCode=$requestCode," + + " granted=${grantResult == PackageManager.PERMISSION_GRANTED}" + } refreshStatus() } - /** - * Initialize Shizuku listeners. Call once during app startup (from DI). - */ fun initialize() { try { Shizuku.addBinderReceivedListenerSticky(binderReceivedListener) @@ -83,21 +64,6 @@ class ShizukuServiceManager( refreshStatus() } - /** - * Cleanup Shizuku listeners. Call during app teardown. - */ - fun destroy() { - try { - Shizuku.removeBinderReceivedListener(binderReceivedListener) - Shizuku.removeBinderDeadListener(binderDeadListener) - Shizuku.removeRequestPermissionResultListener(permissionResultListener) - } catch (_: Exception) {} - unbindService() - } - - /** - * Refresh the current Shizuku status by checking all prerequisites. - */ fun refreshStatus() { _status.value = computeStatus() } @@ -125,8 +91,8 @@ class ShizukuServiceManager( } } - private fun isShizukuInstalled(): Boolean { - return try { + private fun isShizukuInstalled(): Boolean = + try { context.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0) true } catch (_: PackageManager.NameNotFoundException) { @@ -134,11 +100,7 @@ class ShizukuServiceManager( } catch (_: Exception) { false } - } - /** - * Request Shizuku permission. Shows the Shizuku permission dialog. - */ fun requestPermission() { try { if (Shizuku.pingBinder()) { @@ -149,10 +111,6 @@ class ShizukuServiceManager( } } - /** - * Get or bind the Shizuku installer service. - * Returns null if Shizuku is not ready. - */ suspend fun getService(): IShizukuInstallerService? { Logger.d(TAG) { "getService() — current status=${_status.value}" } if (_status.value != ShizukuStatus.READY) { @@ -161,7 +119,6 @@ class ShizukuServiceManager( } return bindMutex.withLock { - // Re-check cached service after acquiring lock (another coroutine may have bound it) installerService?.let { service -> try { val alive = service.asBinder().pingBinder() @@ -185,51 +142,61 @@ class ShizukuServiceManager( Logger.d(TAG) { "bindService() — attempting to bind Shizuku UserService..." } return try { withTimeoutOrNull(BIND_TIMEOUT_MS) { - suspendCancellableCoroutine { continuation -> - val componentName = ComponentName( - context.packageName, - ShizukuInstallerServiceImpl::class.java.name - ) - Logger.d(TAG) { "bindService() — component: $componentName" } - - val args = Shizuku.UserServiceArgs(componentName) - .daemon(false) - .processNameSuffix("installer") - .version(1) - - val connection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { - Logger.d(TAG) { "onServiceConnected() — name=$name, binder=${binder?.javaClass?.name}, binderAlive=${binder?.pingBinder()}" } - val service = IShizukuInstallerService.Stub.asInterface(binder) - installerService = service - Logger.d(TAG) { "Shizuku installer service connected and cached" } - if (continuation.isActive) { - continuation.resume(service) + suspendCancellableCoroutine { continuation -> + val componentName = + ComponentName( + context.packageName, + ShizukuInstallerServiceImpl::class.java.name, + ) + Logger.d(TAG) { "bindService() — component: $componentName" } + + val args = + Shizuku + .UserServiceArgs(componentName) + .daemon(false) + .processNameSuffix("installer") + .version(1) + + val connection = + object : ServiceConnection { + override fun onServiceConnected( + name: ComponentName?, + binder: IBinder?, + ) { + Logger.d( + TAG, + ) { + "onServiceConnected() — name=$name, binder=${binder?.javaClass?.name}, binderAlive=${binder?.pingBinder()}" + } + val service = IShizukuInstallerService.Stub.asInterface(binder) + installerService = service + Logger.d(TAG) { "Shizuku installer service connected and cached" } + if (continuation.isActive) { + continuation.resume(service) + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + installerService = null + Logger.d(TAG) { "Shizuku installer service disconnected: $name" } + } } - } - - override fun onServiceDisconnected(name: ComponentName?) { - installerService = null - Logger.d(TAG) { "Shizuku installer service disconnected: $name" } - } - } - // Store connection and args before binding so unbindService() can - // clean up even if onServiceConnected never fires (e.g. timeout). - serviceConnection = connection - boundUserServiceArgs = args + serviceConnection = connection + boundUserServiceArgs = args - Logger.d(TAG) { "Calling Shizuku.bindUserService()..." } - Shizuku.bindUserService(args, connection) - Logger.d(TAG) { "Shizuku.bindUserService() called, waiting for callback..." } + Logger.d(TAG) { "Calling Shizuku.bindUserService()..." } + Shizuku.bindUserService(args, connection) + Logger.d(TAG) { "Shizuku.bindUserService() called, waiting for callback..." } - continuation.invokeOnCancellation { - Logger.d(TAG) { "bindService() coroutine cancelled, unbinding..." } - try { - Shizuku.unbindUserService(args, connection, true) - } catch (_: Exception) {} + continuation.invokeOnCancellation { + Logger.d(TAG) { "bindService() coroutine cancelled, unbinding..." } + try { + Shizuku.unbindUserService(args, connection, true) + } catch (_: Exception) { + } + } } - } }.also { service -> if (service == null) { Logger.w(TAG) { "bindService() timed out after ${BIND_TIMEOUT_MS}ms" } @@ -242,19 +209,10 @@ class ShizukuServiceManager( } } - private fun unbindService() { - val args = boundUserServiceArgs - val conn = serviceConnection - if (args != null && conn != null) { - try { - Shizuku.unbindUserService(args, conn, true) - Logger.d(TAG) { "Shizuku.unbindUserService() called" } - } catch (e: Exception) { - Logger.w(TAG) { "Failed to unbind Shizuku service: ${e.message}" } - } - } - installerService = null - serviceConnection = null - boundUserServiceArgs = null + companion object { + private const val TAG = "ShizukuServiceManager" + private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" + private const val BIND_TIMEOUT_MS = 15_000L + const val SHIZUKU_PERMISSION_REQUEST_CODE = 1001 } } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/model/ShizukuStatus.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/model/ShizukuStatus.kt new file mode 100644 index 00000000..fcf8ea3b --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/model/ShizukuStatus.kt @@ -0,0 +1,8 @@ +package zed.rainxch.core.data.services.shizuku.model + +enum class ShizukuStatus { + NOT_INSTALLED, + NOT_RUNNING, + PERMISSION_NEEDED, + READY, +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt index 27534196..12dff89d 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt @@ -23,6 +23,7 @@ data class InstalledAppEntity( val latestAssetSize: Long?, val appName: String, val installSource: InstallSource, + val signingFingerprint: String?, val installedAt: Long, val lastCheckedAt: Long, val lastUpdatedAt: Long, diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/InstalledAppsMappers.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/InstalledAppsMappers.kt index 222593bc..7515f26c 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/InstalledAppsMappers.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/InstalledAppsMappers.kt @@ -35,6 +35,7 @@ fun InstalledApp.toEntity(): InstalledAppEntity = installedVersionCode = installedVersionCode, latestVersionName = latestVersionName, latestVersionCode = latestVersionCode, + signingFingerprint = signingFingerprint, ) fun InstalledAppEntity.toDomain(): InstalledApp = @@ -69,4 +70,5 @@ fun InstalledAppEntity.toDomain(): InstalledApp = installedVersionCode = installedVersionCode, latestVersionName = latestVersionName, latestVersionCode = latestVersionCode, + signingFingerprint = signingFingerprint, ) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt index c5ef9c5f..3628947c 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt @@ -126,11 +126,12 @@ class InstalledAppsRepositoryImpl( // Only flag as update if the latest version is actually newer // (not just different — avoids false "downgrade" notifications) - val isUpdateAvailable = if (normalizedInstalledTag == normalizedLatestTag) { - false - } else { - isVersionNewer(normalizedLatestTag, normalizedInstalledTag) - } + val isUpdateAvailable = + if (normalizedInstalledTag == normalizedLatestTag) { + false + } else { + isVersionNewer(normalizedLatestTag, normalizedInstalledTag) + } Logger.d { "Update check for ${app.appName}: " + @@ -184,6 +185,7 @@ class InstalledAppsRepositoryImpl( newAssetUrl: String, newVersionName: String, newVersionCode: Long, + signingFingerprint: String?, ) { val app = installedAppsDao.getAppByPackage(packageName) ?: return @@ -220,6 +222,7 @@ class InstalledAppsRepositoryImpl( isUpdateAvailable = false, lastUpdatedAt = System.currentTimeMillis(), lastCheckedAt = System.currentTimeMillis(), + signingFingerprint = signingFingerprint, ), ) } @@ -249,7 +252,10 @@ class InstalledAppsRepositoryImpl( * This prevents false "downgrade" notifications when a user has a pre-release * installed and the latest stable version has a lower or equal base version. */ - private fun isVersionNewer(candidate: String, current: String): Boolean { + private fun isVersionNewer( + candidate: String, + current: String, + ): Boolean { val candidateParsed = parseSemanticVersion(candidate) val currentParsed = parseSemanticVersion(current) @@ -264,11 +270,21 @@ class InstalledAppsRepositoryImpl( // Numbers are equal; compare pre-release suffixes // No pre-release > has pre-release (e.g., 1.0.0 > 1.0.0-beta) return when { - candidateParsed.preRelease == null && currentParsed.preRelease != null -> true - candidateParsed.preRelease != null && currentParsed.preRelease == null -> false - candidateParsed.preRelease != null && currentParsed.preRelease != null -> + candidateParsed.preRelease == null && currentParsed.preRelease != null -> { + true + } + + candidateParsed.preRelease != null && currentParsed.preRelease == null -> { + false + } + + candidateParsed.preRelease != null && currentParsed.preRelease != null -> { comparePreRelease(candidateParsed.preRelease, currentParsed.preRelease) > 0 - else -> false // both null, versions are equal + } + + else -> { + false + } // both null, versions are equal } } @@ -303,7 +319,10 @@ class InstalledAppsRepositoryImpl( * Numeric identifiers always have lower precedence than alphanumeric. * A larger set of pre-release fields has higher precedence if all preceding are equal. */ - private fun comparePreRelease(a: String, b: String): Int { + private fun comparePreRelease( + a: String, + b: String, + ): Int { val aParts = a.split(".") val bParts = b.split(".") @@ -311,12 +330,17 @@ class InstalledAppsRepositoryImpl( val aNum = aParts[i].toIntOrNull() val bNum = bParts[i].toIntOrNull() - val cmp = when { - aNum != null && bNum != null -> aNum.compareTo(bNum) - aNum != null -> -1 // numeric < alphanumeric - bNum != null -> 1 - else -> aParts[i].compareTo(bParts[i]) - } + val cmp = + when { + aNum != null && bNum != null -> aNum.compareTo(bNum) + + aNum != null -> -1 + + // numeric < alphanumeric + bNum != null -> 1 + + else -> aParts[i].compareTo(bParts[i]) + } if (cmp != 0) return cmp } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ApkPackageInfo.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ApkPackageInfo.kt index dcaa365e..a8202fe1 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ApkPackageInfo.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ApkPackageInfo.kt @@ -5,4 +5,5 @@ data class ApkPackageInfo( val versionName: String, val versionCode: Long, val appName: String, + val signingFingerprint: String?, ) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt index aeb5ae99..11a3d447 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt @@ -22,6 +22,7 @@ data class InstalledApp( val lastCheckedAt: Long, val lastUpdatedAt: Long, val isUpdateAvailable: Boolean, + val signingFingerprint: String?, val updateCheckEnabled: Boolean = true, val releaseNotes: String? = "", val systemArchitecture: String, diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt index 3adb8a22..0c192d61 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt @@ -33,6 +33,7 @@ interface InstalledAppsRepository { newAssetUrl: String, newVersionName: String, newVersionCode: Long, + signingFingerprint: String?, ) suspend fun updateApp(app: InstalledApp) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt index 1e0ee326..aee009ef 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt @@ -31,11 +31,6 @@ class SyncInstalledAppsUseCase( private const val PENDING_TIMEOUT_MS = 24 * 60 * 60 * 1000L // 24 hours } - /** - * Executes the sync operation. - * - * @return Result indicating success or failure with error details - */ suspend operator fun invoke(): Result = withContext(Dispatchers.IO) { try { diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt index 9d65fab7..afb19d41 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt @@ -40,7 +40,7 @@ class AppsViewModel( private val shareManager: ShareManager, ) : ViewModel() { companion object { - private const val UPDATE_CHECK_COOLDOWN_MS = 30 * 60 * 1000L // 30 minutes + private const val UPDATE_CHECK_COOLDOWN_MS = 30 * 60 * 1000L } private var hasLoadedInitialData = false @@ -209,12 +209,18 @@ class AppsViewModel( _state.update { it.copy(appPendingUninstall = action.app) } } - // Link app to repo - AppsAction.OnAddByLinkClick -> openLinkSheet() - AppsAction.OnDismissLinkSheet -> dismissLinkSheet() + AppsAction.OnAddByLinkClick -> { + openLinkSheet() + } + + AppsAction.OnDismissLinkSheet -> { + dismissLinkSheet() + } + is AppsAction.OnDeviceAppSearchChange -> { _state.update { it.copy(deviceAppSearchQuery = action.query) } } + is AppsAction.OnDeviceAppSelected -> { _state.update { it.copy( @@ -226,6 +232,7 @@ class AppsViewModel( ) } } + is AppsAction.OnRepoUrlChanged -> { _state.update { it.copy( @@ -234,7 +241,11 @@ class AppsViewModel( ) } } - AppsAction.OnValidateAndLinkRepo -> validateAndLinkRepo() + + AppsAction.OnValidateAndLinkRepo -> { + validateAndLinkRepo() + } + AppsAction.OnBackToAppPicker -> { _state.update { it.copy( @@ -247,15 +258,19 @@ class AppsViewModel( } } - // Export/Import - AppsAction.OnExportApps -> exportApps() - AppsAction.OnImportApps -> importAppsFromFile() + AppsAction.OnExportApps -> { + exportApps() + } + + AppsAction.OnImportApps -> { + importAppsFromFile() + } - // Uninstall confirmation is AppsAction.OnUninstallConfirmed -> { uninstallApp(action.app) _state.update { it.copy(appPendingUninstall = null) } } + AppsAction.OnDismissUninstallDialog -> { _state.update { it.copy(appPendingUninstall = null) } } @@ -379,7 +394,6 @@ class AppsViewModel( val latestAssetUrl = primaryAsset.downloadUrl val latestAssetName = primaryAsset.name val latestVersion = latestRelease.tagName - val latestAssetSize = primaryAsset.size val ext = latestAssetName.substringAfterLast('.', "").lowercase() installer.ensurePermissionsOrThrow(ext) @@ -419,8 +433,6 @@ class AppsViewModel( installer.getApkInfoExtractor().extractPackageInfo(filePath) ?: throw IllegalStateException("Failed to extract APK info") - // Save latest release metadata and mark as pending install - // so PackageEventReceiver can verify the actual installation val currentApp = installedAppsRepository.getAppByPackage(app.packageName) if (currentApp != null) { installedAppsRepository.updateApp( @@ -446,9 +458,6 @@ class AppsViewModel( throw e } - // Don't mark as updated here — installer.install() just launches the - // system install dialog and returns immediately. PackageEventReceiver - // will handle confirming the actual installation via broadcast. updateAppState(app.packageName, UpdateState.Idle) logger.debug("Launched installer for ${app.appName} $latestVersion, waiting for system confirmation") @@ -553,7 +562,7 @@ class AppsViewModel( logger.debug("Update all completed successfully") _events.send(AppsEvent.ShowSuccess(getString(Res.string.all_apps_updated_successfully))) - } catch (e: CancellationException) { + } catch (_: CancellationException) { logger.debug("Update all cancelled") } catch (e: Exception) { logger.error("Update all failed: ${e.message}") @@ -687,8 +696,6 @@ class AppsViewModel( } } - // ── Link app to repo ────────────────────────────────────────── - private fun openLinkSheet() { viewModelScope.launch { _state.update { @@ -706,8 +713,10 @@ class AppsViewModel( try { val trackedPackages = appsRepository.getTrackedPackageNames() - val deviceApps = appsRepository.getDeviceApps() - .filter { it.packageName !in trackedPackages } + val deviceApps = + appsRepository + .getDeviceApps() + .filter { it.packageName !in trackedPackages } _state.update { it.copy(deviceApps = deviceApps) } } catch (e: Exception) { @@ -737,11 +746,11 @@ class AppsViewModel( val selectedApp = _state.value.selectedDeviceApp ?: return val url = _state.value.repoUrl.trim() - // Parse owner/repo from URL - val (owner, repo) = parseGithubUrl(url) ?: run { - _state.update { it.copy(repoValidationError = "Invalid GitHub URL. Use format: github.com/owner/repo") } - return - } + val (owner, repo) = + parseGithubUrl(url) ?: run { + _state.update { it.copy(repoValidationError = "Invalid GitHub URL. Use format: github.com/owner/repo") } + return + } viewModelScope.launch { _state.update { it.copy(isValidatingRepo = true, repoValidationError = null) } @@ -769,7 +778,7 @@ class AppsViewModel( _events.send(AppsEvent.AppLinkedSuccessfully(selectedApp.appName)) _events.send(AppsEvent.ShowSuccess("${selectedApp.appName} linked to ${repoInfo.owner}/${repoInfo.name}")) - } catch (e: RateLimitException) { + } catch (_: RateLimitException) { _state.update { it.copy( isValidatingRepo = false, @@ -789,14 +798,16 @@ class AppsViewModel( } private fun parseGithubUrl(input: String): Pair? { - val cleaned = input.trim() - .removePrefix("https://") - .removePrefix("http://") - .removePrefix("www.") - .removePrefix("github.com/") - .removeSuffix("/") - .split("?")[0] // remove query params - .split("#")[0] // remove fragment + val cleaned = + input + .trim() + .removePrefix("https://") + .removePrefix("http://") + .removePrefix("www.") + .removePrefix("github.com/") + .removeSuffix("/") + .split("?")[0] + .split("#")[0] val parts = cleaned.split("/") if (parts.size < 2) return null @@ -810,8 +821,6 @@ class AppsViewModel( return owner to repo } - // ── Export/Import ───────────────────────────────────────────── - private fun exportApps() { viewModelScope.launch { _state.update { it.copy(isExporting = true) } @@ -847,8 +856,12 @@ class AppsViewModel( _events.send( AppsEvent.ShowSuccess( "Imported ${result.imported} apps" + - if (result.skipped > 0) ", ${result.skipped} skipped" else "" + - if (result.failed > 0) ", ${result.failed} failed" else "", + if (result.skipped > 0) { + ", ${result.skipped} skipped" + } else { + "" + + if (result.failed > 0) ", ${result.failed} failed" else "" + }, ), ) } catch (e: Exception) { diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index ebd021e4..e40faad1 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -22,6 +22,7 @@ import kotlinx.datetime.format.char import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.getString import zed.rainxch.core.domain.logging.GitHubStoreLogger +import zed.rainxch.core.domain.model.ApkPackageInfo import zed.rainxch.core.domain.model.FavoriteRepo import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.GithubRelease @@ -58,6 +59,7 @@ import zed.rainxch.githubstore.core.presentation.res.link_copied_to_clipboard import zed.rainxch.githubstore.core.presentation.res.rate_limit_exceeded import zed.rainxch.githubstore.core.presentation.res.removed_from_favourites import zed.rainxch.githubstore.core.presentation.res.translation_failed +import java.io.File import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock.System @@ -776,23 +778,25 @@ class DetailsViewModel( is DetailsAction.TranslateAbout -> { val readme = _state.value.readmeMarkdown ?: return aboutTranslationJob?.cancel() - aboutTranslationJob = translateContent( - text = readme, - targetLanguageCode = action.targetLanguageCode, - updateState = { ts -> _state.update { it.copy(aboutTranslation = ts) } }, - getCurrentState = { _state.value.aboutTranslation }, - ) + aboutTranslationJob = + translateContent( + text = readme, + targetLanguageCode = action.targetLanguageCode, + updateState = { ts -> _state.update { it.copy(aboutTranslation = ts) } }, + getCurrentState = { _state.value.aboutTranslation }, + ) } is DetailsAction.TranslateWhatsNew -> { val description = _state.value.selectedRelease?.description ?: return whatsNewTranslationJob?.cancel() - whatsNewTranslationJob = translateContent( - text = description, - targetLanguageCode = action.targetLanguageCode, - updateState = { ts -> _state.update { it.copy(whatsNewTranslation = ts) } }, - getCurrentState = { _state.value.whatsNewTranslation }, - ) + whatsNewTranslationJob = + translateContent( + text = description, + targetLanguageCode = action.targetLanguageCode, + updateState = { ts -> _state.update { it.copy(whatsNewTranslation = ts) } }, + getCurrentState = { _state.value.whatsNewTranslation }, + ) } DetailsAction.ToggleAboutTranslation -> { @@ -885,14 +889,16 @@ class DetailsViewModel( downloadStage = DownloadStage.DOWNLOADING, ) - downloader.download(primary.downloadUrl, primary.name).collect { p -> - _state.value = - _state.value.copy(downloadProgressPercent = p.percent) - if (p.percent == 100) { + downloader + .download(primary.downloadUrl, primary.name) + .collect { p -> _state.value = - _state.value.copy(downloadStage = DownloadStage.VERIFYING) + _state.value.copy(downloadProgressPercent = p.percent) + if (p.percent == 100) { + _state.value = + _state.value.copy(downloadStage = DownloadStage.VERIFYING) + } } - } val filePath = downloader.getDownloadedFilePath(primary.name) @@ -989,149 +995,236 @@ class DetailsViewModel( currentDownloadJob = viewModelScope.launch { try { - currentAssetName = assetName + val filePath: String = + downloadAsset( + assetName = assetName, + sizeBytes = sizeBytes, + releaseTag = releaseTag, + isUpdate = isUpdate, + downloadUrl = downloadUrl, + ) ?: return@launch - appendLog( + installAsset( + isUpdate = isUpdate, + filePath = filePath, assetName = assetName, - size = sizeBytes, - tag = releaseTag, - result = - if (isUpdate) { - LogResult.UpdateStarted - } else { - LogResult.DownloadStarted - }, + downloadUrl = downloadUrl, + sizeBytes = sizeBytes, + releaseTag = releaseTag, ) + } catch (t: Throwable) { + logger.error("Install failed: ${t.message}") + t.printStackTrace() _state.value = _state.value.copy( - downloadError = null, - installError = null, - downloadProgressPercent = null, + downloadStage = DownloadStage.IDLE, + installError = t.message, ) + currentAssetName = null + appendLog( + assetName = assetName, + size = sizeBytes, + tag = releaseTag, + result = Error(t.message), + ) + } + } + } - val existingPath = downloader.getDownloadedFilePath(assetName) - val filePath: String - - val existingFile = existingPath?.let { java.io.File(it) } - if (existingFile != null && existingFile.exists() && existingFile.length() == sizeBytes) { - logger.debug("Reusing already downloaded file: $assetName") - filePath = existingPath - _state.value = - _state.value.copy( - downloadProgressPercent = 100, - downloadedBytes = sizeBytes, - totalBytes = sizeBytes, - downloadStage = DownloadStage.VERIFYING, - ) - } else { - _state.value = - _state.value.copy( - downloadStage = DownloadStage.DOWNLOADING, - downloadedBytes = 0L, - totalBytes = sizeBytes, - ) - downloader.download(downloadUrl, assetName).collect { p -> - _state.value = - _state.value.copy( - downloadProgressPercent = p.percent, - downloadedBytes = p.bytesDownloaded, - totalBytes = p.totalBytes ?: sizeBytes, - ) - if (p.percent == 100) { - _state.value = - _state.value.copy(downloadStage = DownloadStage.VERIFYING) - } - } - - filePath = downloader.getDownloadedFilePath(assetName) - ?: throw IllegalStateException("Downloaded file not found") + private suspend fun installAsset( + isUpdate: Boolean, + filePath: String, + assetName: String, + downloadUrl: String, + sizeBytes: Long, + releaseTag: String, + ) { + _state.value = _state.value.copy(downloadStage = DownloadStage.INSTALLING) + val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) + if (apkInfo != null) { + ApkPackageInfo( + packageName = apkInfo.packageName, + appName = apkInfo.appName, + versionName = apkInfo.versionName, + versionCode = apkInfo.versionCode, + signingFingerprint = apkInfo.signingFingerprint, + ) + } else { + logger.error("Failed to extract APK info for $assetName") + } - cachedDownloadAssetName = assetName - } + apkInfo?.let { + val result = + checkFingerprints( + apkPackageInfo = apkInfo, + ) + result + .onFailure { appendLog( assetName = assetName, size = sizeBytes, tag = releaseTag, - result = LogResult.Downloaded, + result = Error("Fingerprints does not match!"), ) - val ext = assetName.substringAfterLast('.', "").lowercase() + return + } + } - if (!installer.isSupported(ext)) { - throw IllegalStateException("Asset type .$ext not supported") - } + val ext = assetName.substringAfterLast('.', "").lowercase() + installer.install(filePath, ext) - // Check install permission after download — if blocked, offer external installer - try { - installer.ensurePermissionsOrThrow(extOrMime = ext) - } catch (e: IllegalStateException) { - logger.warn("Install permission blocked: ${e.message}") - _state.value = - _state.value.copy( - downloadStage = DownloadStage.IDLE, - showExternalInstallerPrompt = true, - pendingInstallFilePath = filePath, - ) - currentAssetName = null - appendLog( - assetName = assetName, - size = sizeBytes, - tag = releaseTag, - result = LogResult.PermissionBlocked, - ) - return@launch - } + if (platform == Platform.ANDROID) { + saveInstalledAppToDatabase( + assetName = assetName, + assetUrl = downloadUrl, + assetSize = sizeBytes, + releaseTag = releaseTag, + isUpdate = isUpdate, + filePath = filePath, + ) + } else { + viewModelScope.launch { + _events.send(DetailsEvent.OnMessage(getString(Res.string.installer_saved_downloads))) + } + } - _state.value = _state.value.copy(downloadStage = DownloadStage.INSTALLING) + _state.value = _state.value.copy(downloadStage = DownloadStage.IDLE) + currentAssetName = null + appendLog( + assetName = assetName, + size = sizeBytes, + tag = releaseTag, + result = + if (isUpdate) { + LogResult.Updated + } else { + LogResult.Installed + }, + ) + } - if (platform == Platform.ANDROID) { - saveInstalledAppToDatabase( - assetName = assetName, - assetUrl = downloadUrl, - assetSize = sizeBytes, - releaseTag = releaseTag, - isUpdate = isUpdate, - filePath = filePath, - ) - } else { - viewModelScope.launch { - _events.send(DetailsEvent.OnMessage(getString(Res.string.installer_saved_downloads))) - } - } + private suspend fun checkFingerprints(apkPackageInfo: ApkPackageInfo): Result { + val existingApp = + installedAppsRepository.getAppByPackage(apkPackageInfo.packageName) + ?: return Result.success(Unit) - installer.install(filePath, ext) + if (existingApp.signingFingerprint == null) return Result.success(Unit) - _state.value = _state.value.copy(downloadStage = DownloadStage.IDLE) - currentAssetName = null - appendLog( - assetName = assetName, - size = sizeBytes, - tag = releaseTag, - result = - if (isUpdate) { - LogResult.Updated - } else { - LogResult.Installed - }, + if (apkPackageInfo.signingFingerprint == null) return Result.success(Unit) + + return if (existingApp.signingFingerprint == apkPackageInfo.signingFingerprint) { + Result.success(Unit) + } else { + Result.failure( + IllegalStateException( + "Signing key changed! Expected: ${existingApp.signingFingerprint}, got: ${apkPackageInfo.signingFingerprint}", + ), + ) + } + } + + private suspend fun downloadAsset( + assetName: String, + sizeBytes: Long, + releaseTag: String, + isUpdate: Boolean, + downloadUrl: String, + ): String? { + currentAssetName = assetName + + appendLog( + assetName = assetName, + size = sizeBytes, + tag = releaseTag, + result = + if (isUpdate) { + LogResult.UpdateStarted + } else { + LogResult.DownloadStarted + }, + ) + _state.value = + _state.value.copy( + downloadError = null, + installError = null, + downloadProgressPercent = null, + ) + + val existingPath = downloader.getDownloadedFilePath(assetName) + val filePath: String + + val existingFile = existingPath?.let { File(it) } + if (existingFile != null && existingFile.exists() && existingFile.length() == sizeBytes) { + logger.debug("Reusing already downloaded file: $assetName") + filePath = existingPath + _state.value = + _state.value.copy( + downloadProgressPercent = 100, + downloadedBytes = sizeBytes, + totalBytes = sizeBytes, + downloadStage = DownloadStage.VERIFYING, + ) + } else { + _state.value = + _state.value.copy( + downloadStage = DownloadStage.DOWNLOADING, + downloadedBytes = 0L, + totalBytes = sizeBytes, + ) + downloader.download(downloadUrl, assetName).collect { p -> + _state.value = + _state.value.copy( + downloadProgressPercent = p.percent, + downloadedBytes = p.bytesDownloaded, + totalBytes = p.totalBytes ?: sizeBytes, ) - } catch (t: Throwable) { - logger.error("Install failed: ${t.message}") - t.printStackTrace() + if (p.percent == 100) { _state.value = - _state.value.copy( - downloadStage = DownloadStage.IDLE, - installError = t.message, - ) - currentAssetName = null - appendLog( - assetName = assetName, - size = sizeBytes, - tag = releaseTag, - result = LogResult.Error(t.message), - ) + _state.value.copy(downloadStage = DownloadStage.VERIFYING) } } + + filePath = downloader.getDownloadedFilePath(assetName) + ?: throw IllegalStateException("Downloaded file not found") + + cachedDownloadAssetName = assetName + } + + appendLog( + assetName = assetName, + size = sizeBytes, + tag = releaseTag, + result = LogResult.Downloaded, + ) + val ext = assetName.substringAfterLast('.', "").lowercase() + + if (!installer.isSupported(ext)) { + throw IllegalStateException("Asset type .$ext not supported") + } + + try { + installer.ensurePermissionsOrThrow(extOrMime = ext) + } catch (e: IllegalStateException) { + logger.warn("Install permission blocked: ${e.message}") + _state.value = + _state.value.copy( + downloadStage = DownloadStage.IDLE, + showExternalInstallerPrompt = true, + pendingInstallFilePath = filePath, + ) + currentAssetName = null + appendLog( + assetName = assetName, + size = sizeBytes, + tag = releaseTag, + result = LogResult.PermissionBlocked, + ) + return null + } + + return filePath } @OptIn(ExperimentalTime::class) @@ -1146,42 +1239,39 @@ class DetailsViewModel( try { val repo = _state.value.repository ?: return - var packageName: String - var appName = repo.name - var versionName: String? = null - var versionCode = 0L - - if (platform == Platform.ANDROID && assetName.lowercase().endsWith(".apk")) { - val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) - if (apkInfo != null) { - packageName = apkInfo.packageName - appName = apkInfo.appName - versionName = apkInfo.versionName - versionCode = apkInfo.versionCode - logger.debug( - "Extracted APK info - package: $packageName, name: $appName, versionName: $versionName, versionCode: $versionCode", - ) + val apkInfo: ApkPackageInfo = + if (platform == Platform.ANDROID && assetName.lowercase().endsWith(".apk")) { + val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) + if (apkInfo != null) { + ApkPackageInfo( + packageName = apkInfo.packageName, + appName = apkInfo.appName, + versionName = apkInfo.versionName, + versionCode = apkInfo.versionCode, + signingFingerprint = apkInfo.signingFingerprint, + ) + } else { + logger.error("Failed to extract APK info for $assetName") + return + } } else { - logger.error("Failed to extract APK info for $assetName") return } - } else { - packageName = "app.github.${repo.owner.login}.${repo.name}".lowercase() - } if (isUpdate) { installedAppsRepository.updateAppVersion( - packageName = packageName, + packageName = apkInfo.packageName, newTag = releaseTag, newAssetName = assetName, newAssetUrl = assetUrl, - newVersionName = versionName ?: "unknown", - newVersionCode = versionCode, + newVersionName = apkInfo.versionName, + newVersionCode = apkInfo.versionCode, + signingFingerprint = apkInfo.signingFingerprint, ) } else { val installedApp = InstalledApp( - packageName = packageName, + packageName = apkInfo.packageName, repoId = repo.id, repoName = repo.name, repoOwner = repo.owner.login, @@ -1196,7 +1286,7 @@ class DetailsViewModel( latestAssetName = assetName, latestAssetUrl = assetUrl, latestAssetSize = assetSize, - appName = appName, + appName = apkInfo.appName, installSource = InstallSource.THIS_APP, installedAt = System.now().toEpochMilliseconds(), lastCheckedAt = System.now().toEpochMilliseconds(), @@ -1207,10 +1297,11 @@ class DetailsViewModel( systemArchitecture = installer.detectSystemArchitecture().name, fileExtension = assetName.substringAfterLast('.', ""), isPendingInstall = true, - installedVersionName = versionName, - installedVersionCode = versionCode, - latestVersionName = versionName, - latestVersionCode = versionCode, + installedVersionName = apkInfo.versionName, + installedVersionCode = apkInfo.versionCode, + latestVersionName = apkInfo.versionName, + latestVersionCode = apkInfo.versionCode, + signingFingerprint = apkInfo.signingFingerprint, ) installedAppsRepository.saveInstalledApp(installedApp) @@ -1220,12 +1311,12 @@ class DetailsViewModel( favouritesRepository.updateFavoriteInstallStatus( repoId = repo.id, installed = true, - packageName = packageName, + packageName = apkInfo.packageName, ) } - delay(500) - val updatedApp = installedAppsRepository.getAppByPackage(packageName) + delay(1000) + val updatedApp = installedAppsRepository.getAppByPackage(apkInfo.packageName) _state.value = _state.value.copy(installedApp = updatedApp) logger.debug("Successfully saved and reloaded app: ${updatedApp?.packageName}") @@ -1354,8 +1445,8 @@ class DetailsViewModel( targetLanguageCode: String, updateState: (TranslationState) -> Unit, getCurrentState: () -> TranslationState, - ): Job { - return viewModelScope.launch { + ): Job = + viewModelScope.launch { try { updateState( getCurrentState().copy( @@ -1402,7 +1493,6 @@ class DetailsViewModel( ) } } - } private fun normalizeVersion(version: String?): String = version?.removePrefix("v")?.removePrefix("V")?.trim() ?: "" From 95b7a94865c143278aa8dd5a6ae52e4871802fef Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 16 Mar 2026 11:21:47 +0500 Subject: [PATCH 2/8] feat: implement signing key verification and warning dialogs - Add `SigningKeyWarning` model and update `DetailsState` to handle certificate mismatch scenarios - Implement a warning dialog in `DetailsRoot` that displays expected vs. actual fingerprints when a signing key change is detected - Add `OnDismissSigningKeyWarning` and `OnOverrideSigningKeyWarning` actions to `DetailsViewModel` to allow users to bypass the warning - Update `AutoUpdateWorker` to block automatic updates if the APK signing key has changed - Enhance `DetailsViewModel` to extract and verify APK package info/fingerprints before installation - Include the app's own SHA-256 fingerprint when saving its installation state in `GithubStoreApp` - Bump database version to 5 and add migration `MIGRATION_4_5` - Add localized string resources for signing key change titles, messages, and override actions - Minor cleanup of string concatenation logic in `AppsViewModel` --- .../rainxch/githubstore/app/GithubStoreApp.kt | 4 + .../core/data/local/db/initDatabase.kt | 2 + .../core/data/services/AutoUpdateWorker.kt | 15 +++ .../rainxch/core/data/local/db/AppDatabase.kt | 2 +- .../composeResources/values/strings.xml | 3 + .../apps/presentation/AppsViewModel.kt | 8 +- .../details/presentation/DetailsAction.kt | 4 + .../details/presentation/DetailsRoot.kt | 50 ++++++++ .../details/presentation/DetailsState.kt | 2 + .../details/presentation/DetailsViewModel.kt | 108 +++++++++++++++--- .../presentation/model/SigningKeyWarning.kt | 13 +++ 11 files changed, 188 insertions(+), 23 deletions(-) create mode 100644 feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/SigningKeyWarning.kt diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt index a8b33d47..bdaa982d 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -144,6 +144,7 @@ class GithubStoreApp : Application() { isPendingInstall = false, installedVersionName = versionName, installedVersionCode = versionCode, + signingFingerprint = SELF_SHA256_FINGERPRINT, ) repo.saveInstalledApp(selfApp) @@ -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 = diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt index 0e1b464b..8396d543 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt @@ -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 fun initDatabase(context: Context): AppDatabase { val appContext = context.applicationContext @@ -19,5 +20,6 @@ fun initDatabase(context: Context): AppDatabase { MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, + MIGRATION_4_5, ).build() } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt index 83d22ac2..ec2dd410 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt @@ -155,6 +155,21 @@ class AutoUpdateWorker( ?: throw IllegalStateException("Failed to extract APK info for ${app.appName}") val currentApp = installedAppsRepository.getAppByPackage(app.packageName) + + // TOFU: Block auto-update if signing key changed + if (currentApp != null && + currentApp.signingFingerprint != null && + apkInfo.signingFingerprint != null && + currentApp.signingFingerprint != apkInfo.signingFingerprint + ) { + Logger.e { + "AutoUpdateWorker: Signing key mismatch for ${app.appName}! " + + "Expected: ${currentApp.signingFingerprint}, got: ${apkInfo.signingFingerprint}. " + + "Skipping auto-update." + } + throw IllegalStateException("Signing key changed for ${app.appName}, blocking auto-update") + } + if (currentApp != null) { installedAppsRepository.updateApp( currentApp.copy( diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/AppDatabase.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/AppDatabase.kt index e4054a44..5eb9ddf3 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/AppDatabase.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/AppDatabase.kt @@ -21,7 +21,7 @@ import zed.rainxch.core.data.local.db.entities.UpdateHistoryEntity StarredRepositoryEntity::class, CacheEntryEntity::class, ], - version = 4, + version = 5, exportSchema = true, ) abstract class AppDatabase : RoomDatabase() { diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 224e332d..194bfa91 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -204,6 +204,9 @@ Downgrade requires uninstall Installing version %1$s requires uninstalling the current version (%2$s) first. Your app data will be lost. Uninstall first + Signing key changed + The signing certificate for this app has changed since it was first installed.\n\nThis could mean the developer rotated their signing key, or the binary may have been tampered with.\n\nExpected: %1$s\nReceived: %2$s + Install anyway Install %1$s Failed to open %1$s Failed to uninstall %1$s diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt index afb19d41..41f173fb 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt @@ -856,12 +856,8 @@ class AppsViewModel( _events.send( AppsEvent.ShowSuccess( "Imported ${result.imported} apps" + - if (result.skipped > 0) { - ", ${result.skipped} skipped" - } else { - "" + - if (result.failed > 0) ", ${result.failed} failed" else "" - }, + (if (result.skipped > 0) ", ${result.skipped} skipped" else "") + + (if (result.failed > 0) ", ${result.failed} failed" else ""), ), ) } catch (e: Exception) { diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt index 9594dd12..adc297b3 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt @@ -13,6 +13,10 @@ sealed interface DetailsAction { data object OnDismissDowngradeWarning : DetailsAction + data object OnDismissSigningKeyWarning : DetailsAction + + data object OnOverrideSigningKeyWarning : DetailsAction + data object UninstallApp : DetailsAction data object OnRequestUninstall : DetailsAction data object OnDismissUninstallConfirmation : DetailsAction diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt index b96179f1..1a25abeb 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt @@ -76,6 +76,9 @@ import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.add_to_favourites import zed.rainxch.githubstore.core.presentation.res.cancel import zed.rainxch.githubstore.core.presentation.res.confirm_uninstall_message +import zed.rainxch.githubstore.core.presentation.res.install_anyway +import zed.rainxch.githubstore.core.presentation.res.signing_key_changed_message +import zed.rainxch.githubstore.core.presentation.res.signing_key_changed_title import zed.rainxch.githubstore.core.presentation.res.confirm_uninstall_title import zed.rainxch.githubstore.core.presentation.res.dismiss import zed.rainxch.githubstore.core.presentation.res.downgrade_requires_uninstall @@ -192,6 +195,53 @@ fun DetailsRoot( ) } + // Signing key changed warning dialog + state.signingKeyWarning?.let { warning -> + AlertDialog( + onDismissRequest = { + viewModel.onAction(DetailsAction.OnDismissSigningKeyWarning) + }, + title = { + Text( + text = stringResource(Res.string.signing_key_changed_title), + ) + }, + text = { + Text( + text = + stringResource( + Res.string.signing_key_changed_message, + warning.expectedFingerprint.take(19), + warning.actualFingerprint.take(19), + ), + ) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.onAction(DetailsAction.OnOverrideSigningKeyWarning) + }, + ) { + Text( + text = stringResource(Res.string.install_anyway), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton( + onClick = { + viewModel.onAction(DetailsAction.OnDismissSigningKeyWarning) + }, + ) { + Text( + text = stringResource(Res.string.cancel), + ) + } + }, + ) + } + // Uninstall confirmation dialog if (state.showUninstallConfirmation) { val appName = state.installedApp?.appName ?: "" diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt index e137302b..69912707 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt @@ -9,6 +9,7 @@ import zed.rainxch.core.domain.model.SystemArchitecture import zed.rainxch.details.domain.model.ReleaseCategory import zed.rainxch.details.domain.model.RepoStats import zed.rainxch.details.presentation.model.DowngradeWarning +import zed.rainxch.details.presentation.model.SigningKeyWarning import zed.rainxch.details.presentation.model.DownloadStage import zed.rainxch.details.presentation.model.InstallLogItem import zed.rainxch.details.presentation.model.TranslationState @@ -59,6 +60,7 @@ data class DetailsState( val deviceLanguageCode: String = "en", val isComingFromUpdate: Boolean = false, val downgradeWarning: DowngradeWarning? = null, + val signingKeyWarning: SigningKeyWarning? = null, val showExternalInstallerPrompt: Boolean = false, val pendingInstallFilePath: String? = null, val showUninstallConfirmation: Boolean = false, diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index e40faad1..c247b692 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -47,6 +47,7 @@ import zed.rainxch.details.presentation.model.DownloadStage import zed.rainxch.details.presentation.model.InstallLogItem import zed.rainxch.details.presentation.model.LogResult import zed.rainxch.details.presentation.model.LogResult.Error +import zed.rainxch.details.presentation.model.SigningKeyWarning import zed.rainxch.details.presentation.model.SupportedLanguages import zed.rainxch.details.presentation.model.TranslationState import zed.rainxch.githubstore.core.presentation.res.Res @@ -361,6 +362,55 @@ class DetailsViewModel( } } + DetailsAction.OnDismissSigningKeyWarning -> { + _state.update { + it.copy( + signingKeyWarning = null, + downloadStage = DownloadStage.IDLE, + ) + } + currentAssetName = null + } + + DetailsAction.OnOverrideSigningKeyWarning -> { + val warning = _state.value.signingKeyWarning ?: return + _state.update { it.copy(signingKeyWarning = null) } + viewModelScope.launch { + try { + val ext = warning.pendingAssetName.substringAfterLast('.', "").lowercase() + installer.install(warning.pendingFilePath, ext) + + if (platform == Platform.ANDROID) { + saveInstalledAppToDatabase( + assetName = warning.pendingAssetName, + assetUrl = warning.pendingDownloadUrl, + assetSize = warning.pendingSizeBytes, + releaseTag = warning.pendingReleaseTag, + isUpdate = warning.pendingIsUpdate, + filePath = warning.pendingFilePath, + ) + } + + _state.value = _state.value.copy(downloadStage = DownloadStage.IDLE) + currentAssetName = null + appendLog( + assetName = warning.pendingAssetName, + size = warning.pendingSizeBytes, + tag = warning.pendingReleaseTag, + result = if (warning.pendingIsUpdate) LogResult.Updated else LogResult.Installed, + ) + } catch (t: Throwable) { + logger.error("Install after override failed: ${t.message}") + _state.value = + _state.value.copy( + downloadStage = DownloadStage.IDLE, + installError = t.message, + ) + currentAssetName = null + } + } + } + DetailsAction.InstallPrimary -> { val primary = _state.value.primaryAsset val release = _state.value.selectedRelease @@ -1012,6 +1062,8 @@ class DetailsViewModel( sizeBytes = sizeBytes, releaseTag = releaseTag, ) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (t: Throwable) { logger.error("Install failed: ${t.message}") t.printStackTrace() @@ -1040,20 +1092,28 @@ class DetailsViewModel( releaseTag: String, ) { _state.value = _state.value.copy(downloadStage = DownloadStage.INSTALLING) - val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) - if (apkInfo != null) { - ApkPackageInfo( - packageName = apkInfo.packageName, - appName = apkInfo.appName, - versionName = apkInfo.versionName, - versionCode = apkInfo.versionCode, - signingFingerprint = apkInfo.signingFingerprint, - ) - } else { - logger.error("Failed to extract APK info for $assetName") - } - apkInfo?.let { + val ext = assetName.substringAfterLast('.', "").lowercase() + val isApk = ext == "apk" + + if (isApk) { + val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) + if (apkInfo == null) { + logger.error("Failed to extract APK info for $assetName") + _state.value = _state.value.copy( + downloadStage = DownloadStage.IDLE, + installError = "Failed to verify APK package info", + ) + currentAssetName = null + appendLog( + assetName = assetName, + size = sizeBytes, + tag = releaseTag, + result = Error("Failed to extract APK info"), + ) + return + } + val result = checkFingerprints( apkPackageInfo = apkInfo, @@ -1061,18 +1121,34 @@ class DetailsViewModel( result .onFailure { + val existingApp = + installedAppsRepository.getAppByPackage(apkInfo.packageName) + _state.update { state -> + state.copy( + signingKeyWarning = + SigningKeyWarning( + packageName = apkInfo.packageName, + expectedFingerprint = existingApp?.signingFingerprint ?: "", + actualFingerprint = apkInfo.signingFingerprint ?: "", + pendingDownloadUrl = downloadUrl, + pendingAssetName = assetName, + pendingSizeBytes = sizeBytes, + pendingReleaseTag = releaseTag, + pendingIsUpdate = isUpdate, + pendingFilePath = filePath, + ), + ) + } appendLog( assetName = assetName, size = sizeBytes, tag = releaseTag, - result = Error("Fingerprints does not match!"), + result = Error("Signing key changed"), ) - return } } - val ext = assetName.substringAfterLast('.', "").lowercase() installer.install(filePath, ext) if (platform == Platform.ANDROID) { diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/SigningKeyWarning.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/SigningKeyWarning.kt new file mode 100644 index 00000000..15e75ce1 --- /dev/null +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/SigningKeyWarning.kt @@ -0,0 +1,13 @@ +package zed.rainxch.details.presentation.model + +data class SigningKeyWarning( + val packageName: String, + val expectedFingerprint: String, + val actualFingerprint: String, + val pendingDownloadUrl: String, + val pendingAssetName: String, + val pendingSizeBytes: Long, + val pendingReleaseTag: String, + val pendingIsUpdate: Boolean, + val pendingFilePath: String, +) From e27f25ef669df091b869a5af423ab8c670474205 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 16 Mar 2026 11:33:04 +0500 Subject: [PATCH 3/8] feat: implement signing fingerprint verification for auto-updates - Add `signingFingerprint` column to the `installed_apps` table via Room migration 4 to 5 - Update `SystemPackageInfo` and `DeviceApp` domain models to include the signing fingerprint - Implement SHA-256 signing certificate extraction in `AndroidPackageMonitor` for both legacy and modern Android versions - Enhance `AutoUpdateWorker` to verify the APK's signing fingerprint against the installed version before proceeding with an update - Update `AppsRepositoryImpl` to store and propagate signing fingerprints when linking or importing apps - Refactor `ShizukuInstallerWrapper` and `AppsRepositoryImpl` for better code consistency and formatting --- .../data/local/db/migrations/MIGRATION_4_5.kt | 11 ++ .../data/services/AndroidPackageMonitor.kt | 51 ++++- .../core/data/services/AutoUpdateWorker.kt | 45 +++-- .../shizuku/ShizukuInstallerWrapper.kt | 84 ++++----- .../rainxch/core/domain/model/DeviceApp.kt | 1 + .../core/domain/model/SystemPackageInfo.kt | 1 + .../data/repository/AppsRepositoryImpl.kt | 178 ++++++++++-------- 7 files changed, 209 insertions(+), 162 deletions(-) create mode 100644 core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_4_5.kt diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_4_5.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_4_5.kt new file mode 100644 index 00000000..cac1d0f9 --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_4_5.kt @@ -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") + } + } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPackageMonitor.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPackageMonitor.kt index 7b215245..838d5623 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPackageMonitor.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPackageMonitor.kt @@ -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, @@ -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 = @@ -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) } + } + } + SystemPackageInfo( packageName = packageInfo.packageName, versionName = packageInfo.versionName ?: "unknown", versionCode = versionCode, isInstalled = true, + signingFingerprint = signingFingerprint, ) }.getOrNull() } @@ -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 @@ -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() } } } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt index ec2dd410..a04ddefe 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt @@ -137,7 +137,7 @@ class AutoUpdateWorker( file.delete() Logger.d { "AutoUpdateWorker: Deleted mismatched existing file for ${app.appName}" } } - } catch (e: Exception) { + } catch (_: Exception) { file.delete() Logger.d { "AutoUpdateWorker: Deleted unextractable existing file for ${app.appName}" } } @@ -156,21 +156,20 @@ class AutoUpdateWorker( val currentApp = installedAppsRepository.getAppByPackage(app.packageName) - // TOFU: Block auto-update if signing key changed - if (currentApp != null && - currentApp.signingFingerprint != null && - apkInfo.signingFingerprint != null && - currentApp.signingFingerprint != apkInfo.signingFingerprint - ) { - Logger.e { - "AutoUpdateWorker: Signing key mismatch for ${app.appName}! " + - "Expected: ${currentApp.signingFingerprint}, got: ${apkInfo.signingFingerprint}. " + - "Skipping auto-update." + if (currentApp?.signingFingerprint != null) { + val expected = currentApp.signingFingerprint!!.trim().uppercase() + val actual = apkInfo.signingFingerprint?.trim()?.uppercase() + if (actual == null || expected != actual) { + Logger.e { + "AutoUpdateWorker: Signing key mismatch for ${app.appName}! " + + "Expected: ${currentApp.signingFingerprint}, got: ${apkInfo.signingFingerprint}. " + + "Skipping auto-update." + } + throw IllegalStateException( + "Signing fingerprint verification failed for ${app.appName}, blocking auto-update", + ) } - throw IllegalStateException("Signing key changed for ${app.appName}, blocking auto-update") - } - if (currentApp != null) { installedAppsRepository.updateApp( currentApp.copy( isPendingInstall = true, @@ -181,17 +180,17 @@ class AutoUpdateWorker( latestVersionCode = apkInfo.versionCode, ), ) - } - Logger.d { "AutoUpdateWorker: Installing ${app.appName} via Shizuku" } - try { - installer.install(filePath, ext) - } catch (e: Exception) { - installedAppsRepository.updatePendingStatus(app.packageName, false) - throw e - } + Logger.d { "AutoUpdateWorker: Installing ${app.appName} via Shizuku" } + try { + installer.install(filePath, ext) + } catch (e: Exception) { + installedAppsRepository.updatePendingStatus(app.packageName, false) + throw e + } - Logger.d { "AutoUpdateWorker: Install command completed for ${app.appName}, waiting for system confirmation via broadcast" } + Logger.d { "AutoUpdateWorker: Install command completed for ${app.appName}, waiting for system confirmation via broadcast" } + } } private fun createForegroundInfo( diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerWrapper.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerWrapper.kt index b2c77cab..6ae4b068 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerWrapper.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerWrapper.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import zed.rainxch.core.data.services.shizuku.model.ShizukuStatus import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.InstallerType import zed.rainxch.core.domain.model.SystemArchitecture @@ -26,9 +27,8 @@ import zed.rainxch.core.domain.system.InstallerInfoExtractor class ShizukuInstallerWrapper( private val androidInstaller: Installer, private val shizukuServiceManager: ShizukuServiceManager, - private val themesRepository: ThemesRepository + private val themesRepository: ThemesRepository, ) : Installer { - companion object { private const val TAG = "ShizukuInstaller" } @@ -53,50 +53,39 @@ class ShizukuInstallerWrapper( } } - // ==================== Delegated methods (always go to AndroidInstaller) ==================== - - override suspend fun isSupported(extOrMime: String): Boolean = - androidInstaller.isSupported(extOrMime) + override suspend fun isSupported(extOrMime: String): Boolean = androidInstaller.isSupported(extOrMime) - override fun isAssetInstallable(assetName: String): Boolean = - androidInstaller.isAssetInstallable(assetName) + override fun isAssetInstallable(assetName: String): Boolean = androidInstaller.isAssetInstallable(assetName) - override fun choosePrimaryAsset(assets: List): GithubAsset? = - androidInstaller.choosePrimaryAsset(assets) + override fun choosePrimaryAsset(assets: List): GithubAsset? = androidInstaller.choosePrimaryAsset(assets) - override fun detectSystemArchitecture(): SystemArchitecture = - androidInstaller.detectSystemArchitecture() + override fun detectSystemArchitecture(): SystemArchitecture = androidInstaller.detectSystemArchitecture() - override fun isObtainiumInstalled(): Boolean = - androidInstaller.isObtainiumInstalled() + override fun isObtainiumInstalled(): Boolean = androidInstaller.isObtainiumInstalled() override fun openInObtainium( repoOwner: String, repoName: String, - onOpenInstaller: () -> Unit + onOpenInstaller: () -> Unit, ) = androidInstaller.openInObtainium(repoOwner, repoName, onOpenInstaller) - override fun isAppManagerInstalled(): Boolean = - androidInstaller.isAppManagerInstalled() + override fun isAppManagerInstalled(): Boolean = androidInstaller.isAppManagerInstalled() override fun openInAppManager( filePath: String, - onOpenInstaller: () -> Unit + onOpenInstaller: () -> Unit, ) = androidInstaller.openInAppManager(filePath, onOpenInstaller) - override fun getApkInfoExtractor(): InstallerInfoExtractor = - androidInstaller.getApkInfoExtractor() - - override fun openApp(packageName: String): Boolean = - androidInstaller.openApp(packageName) + override fun getApkInfoExtractor(): InstallerInfoExtractor = androidInstaller.getApkInfoExtractor() - override fun openWithExternalInstaller(filePath: String) = - androidInstaller.openWithExternalInstaller(filePath) + override fun openApp(packageName: String): Boolean = androidInstaller.openApp(packageName) - // ==================== Overridden methods (may use Shizuku) ==================== + override fun openWithExternalInstaller(filePath: String) = androidInstaller.openWithExternalInstaller(filePath) override suspend fun ensurePermissionsOrThrow(extOrMime: String) { - Logger.d(TAG) { "ensurePermissionsOrThrow() — extOrMime=$extOrMime, cachedType=$cachedInstallerType, status=${shizukuServiceManager.status.value}" } + Logger.d(TAG) { + "ensurePermissionsOrThrow() — extOrMime=$extOrMime, cachedType=$cachedInstallerType, status=${shizukuServiceManager.status.value}" + } if (shouldUseShizuku()) { Logger.d(TAG) { "Shizuku active — skipping unknown sources permission check" } return @@ -105,7 +94,10 @@ class ShizukuInstallerWrapper( androidInstaller.ensurePermissionsOrThrow(extOrMime) } - override suspend fun install(filePath: String, extOrMime: String) { + override suspend fun install( + filePath: String, + extOrMime: String, + ) { Logger.d(TAG) { "install() called — filePath=$filePath, extOrMime=$extOrMime" } Logger.d(TAG) { "cachedInstallerType=$cachedInstallerType, shizukuStatus=${shizukuServiceManager.status.value}" } @@ -114,18 +106,19 @@ class ShizukuInstallerWrapper( try { val service = shizukuServiceManager.getService() if (service != null) { - // Run the blocking AIDL call on IO dispatcher to avoid ANR - val result = withContext(Dispatchers.IO) { - val file = java.io.File(filePath) - val pfd = android.os.ParcelFileDescriptor.open( - file, - android.os.ParcelFileDescriptor.MODE_READ_ONLY - ) - pfd.use { - Logger.d(TAG) { "Got Shizuku service, calling installPackage($filePath, size=${file.length()})..." } - service.installPackage(it, file.length()) + val result = + withContext(Dispatchers.IO) { + val file = java.io.File(filePath) + val pfd = + android.os.ParcelFileDescriptor.open( + file, + android.os.ParcelFileDescriptor.MODE_READ_ONLY, + ) + pfd.use { + Logger.d(TAG) { "Got Shizuku service, calling installPackage($filePath, size=${file.length()})..." } + service.installPackage(it, file.length()) + } } - } Logger.d(TAG) { "Shizuku installPackage() returned: $result" } if (result == 0) { Logger.d(TAG) { "Shizuku install SUCCEEDED for: $filePath" } @@ -142,7 +135,6 @@ class ShizukuInstallerWrapper( Logger.d(TAG) { "Not using Shizuku (enabled=${isShizukuEnabled()}, status=${shizukuServiceManager.status.value})" } } - // Fallback: ensure permissions then use standard installer Logger.d(TAG) { "Using standard AndroidInstaller for: $filePath" } androidInstaller.ensurePermissionsOrThrow(extOrMime) androidInstaller.install(filePath, extOrMime) @@ -154,10 +146,8 @@ class ShizukuInstallerWrapper( if (isShizukuEnabled() && shizukuServiceManager.status.value == ShizukuStatus.READY) { Logger.d(TAG) { "Attempting Shizuku uninstall..." } - // Fire on background thread — callers don't await result for standard uninstall either Thread { try { - // Bind/get service on this background thread (getService is suspend) val service = runBlocking { shizukuServiceManager.getService() } if (service != null) { Logger.d(TAG) { "Got service, calling uninstallPackage($packageName)..." } @@ -185,13 +175,7 @@ class ShizukuInstallerWrapper( androidInstaller.uninstall(packageName) } - // ==================== Internal helpers ==================== - - private suspend fun shouldUseShizuku(): Boolean { - return isShizukuEnabled() && shizukuServiceManager.status.value == ShizukuStatus.READY - } + private suspend fun shouldUseShizuku(): Boolean = isShizukuEnabled() && shizukuServiceManager.status.value == ShizukuStatus.READY - private fun isShizukuEnabled(): Boolean { - return cachedInstallerType == InstallerType.SHIZUKU - } + private fun isShizukuEnabled(): Boolean = cachedInstallerType == InstallerType.SHIZUKU } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DeviceApp.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DeviceApp.kt index 645964e3..f193231e 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DeviceApp.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DeviceApp.kt @@ -5,4 +5,5 @@ data class DeviceApp( val appName: String, val versionName: String?, val versionCode: Long, + val signingFingerprint: String?, ) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/SystemPackageInfo.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/SystemPackageInfo.kt index 46b2d1d4..7cc42604 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/SystemPackageInfo.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/SystemPackageInfo.kt @@ -5,4 +5,5 @@ data class SystemPackageInfo( val versionName: String, val versionCode: Long, val isInstalled: Boolean, + val signingFingerprint: String?, ) diff --git a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt index d6be1a6c..c59efe61 100644 --- a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt +++ b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt @@ -37,7 +37,6 @@ class AppsRepositoryImpl( private val packageMonitor: PackageMonitor, private val themesRepository: ThemesRepository, ) : AppsRepository { - private val json = Json { ignoreUnknownKeys = true } override suspend fun getApps(): Flow> = appsRepository.getAllInstalledApps() @@ -89,13 +88,19 @@ class AppsRepositoryImpl( null } - override suspend fun getDeviceApps(): List = - packageMonitor.getAllInstalledApps() + override suspend fun getDeviceApps(): List = packageMonitor.getAllInstalledApps() override suspend fun getTrackedPackageNames(): Set = - appsRepository.getAllInstalledApps().first().map { it.packageName }.toSet() + appsRepository + .getAllInstalledApps() + .first() + .map { it.packageName } + .toSet() - override suspend fun fetchRepoInfo(owner: String, repo: String): GithubRepoInfo? = + override suspend fun fetchRepoInfo( + owner: String, + repo: String, + ): GithubRepoInfo? = try { val repoModel = httpClient @@ -105,27 +110,27 @@ class AppsRepositoryImpl( } }.getOrThrow() - // Also fetch latest release tag val includePreReleases = themesRepository.getIncludePreReleases().first() - val latestTag = try { - val releases = - httpClient - .executeRequest> { - get("/repos/$owner/$repo/releases") { - header(HttpHeaders.Accept, "application/vnd.github+json") - parameter("per_page", 5) - } - }.getOrThrow() - - releases - .asSequence() - .filter { it.draft != true } - .filter { includePreReleases || it.prerelease != true } - .maxByOrNull { it.publishedAt ?: it.createdAt ?: "" } - ?.tagName - } catch (_: Exception) { - null - } + val latestTag = + try { + val releases = + httpClient + .executeRequest> { + get("/repos/$owner/$repo/releases") { + header(HttpHeaders.Accept, "application/vnd.github+json") + parameter("per_page", 5) + } + }.getOrThrow() + + releases + .asSequence() + .filter { it.draft != true } + .filter { includePreReleases || it.prerelease != true } + .maxByOrNull { it.publishedAt ?: it.createdAt ?: "" } + ?.tagName + } catch (_: Exception) { + null + } GithubRepoInfo( id = repoModel.id, @@ -144,67 +149,75 @@ class AppsRepositoryImpl( null } - override suspend fun linkAppToRepo(deviceApp: DeviceApp, repoInfo: GithubRepoInfo) { + override suspend fun linkAppToRepo( + deviceApp: DeviceApp, + repoInfo: GithubRepoInfo, + ) { val now = Clock.System.now().toEpochMilliseconds() - val installedApp = InstalledApp( - packageName = deviceApp.packageName, - repoId = repoInfo.id, - repoName = repoInfo.name, - repoOwner = repoInfo.owner, - repoOwnerAvatarUrl = repoInfo.ownerAvatarUrl, - repoDescription = repoInfo.description, - primaryLanguage = repoInfo.language, - repoUrl = repoInfo.htmlUrl, - installedVersion = deviceApp.versionName ?: "unknown", - installedAssetName = null, - installedAssetUrl = null, - latestVersion = repoInfo.latestReleaseTag, - latestAssetName = null, - latestAssetUrl = null, - latestAssetSize = null, - appName = deviceApp.appName, - installSource = InstallSource.MANUAL, - installedAt = now, - lastCheckedAt = 0L, - lastUpdatedAt = now, - isUpdateAvailable = false, - updateCheckEnabled = true, - releaseNotes = null, - systemArchitecture = "", - fileExtension = "apk", - isPendingInstall = false, - installedVersionName = deviceApp.versionName, - installedVersionCode = deviceApp.versionCode, - ) + val installedApp = + InstalledApp( + packageName = deviceApp.packageName, + repoId = repoInfo.id, + repoName = repoInfo.name, + repoOwner = repoInfo.owner, + repoOwnerAvatarUrl = repoInfo.ownerAvatarUrl, + repoDescription = repoInfo.description, + primaryLanguage = repoInfo.language, + repoUrl = repoInfo.htmlUrl, + installedVersion = deviceApp.versionName ?: "unknown", + installedAssetName = null, + installedAssetUrl = null, + latestVersion = repoInfo.latestReleaseTag, + latestAssetName = null, + latestAssetUrl = null, + latestAssetSize = null, + appName = deviceApp.appName, + installSource = InstallSource.MANUAL, + installedAt = now, + lastCheckedAt = 0L, + lastUpdatedAt = now, + isUpdateAvailable = false, + updateCheckEnabled = true, + releaseNotes = null, + systemArchitecture = "", + fileExtension = "apk", + isPendingInstall = false, + installedVersionName = deviceApp.versionName, + installedVersionCode = deviceApp.versionCode, + signingFingerprint = deviceApp.signingFingerprint, + ) appsRepository.saveInstalledApp(installedApp) } override suspend fun exportApps(): String { val apps = appsRepository.getAllInstalledApps().first() - val exported = ExportedAppList( - version = 1, - exportedAt = Clock.System.now().toEpochMilliseconds(), - apps = apps.map { app -> - ExportedApp( - packageName = app.packageName, - repoOwner = app.repoOwner, - repoName = app.repoName, - repoUrl = app.repoUrl, - ) - }, - ) + val exported = + ExportedAppList( + version = 1, + exportedAt = Clock.System.now().toEpochMilliseconds(), + apps = + apps.map { app -> + ExportedApp( + packageName = app.packageName, + repoOwner = app.repoOwner, + repoName = app.repoName, + repoUrl = app.repoUrl, + ) + }, + ) return json.encodeToString(ExportedAppList.serializer(), exported) } - override suspend fun importApps(jsonString: String): ImportResult { - val exportedList = try { - json.decodeFromString(ExportedAppList.serializer(), jsonString) - } catch (e: Exception) { - logger.error("Failed to parse import JSON: ${e.message}") - return ImportResult(imported = 0, skipped = 0, failed = 1) - } + override suspend fun importApps(json: String): ImportResult { + val exportedList = + try { + this@AppsRepositoryImpl.json.decodeFromString(ExportedAppList.serializer(), json) + } catch (e: Exception) { + logger.error("Failed to parse import JSON: ${e.message}") + return ImportResult(imported = 0, skipped = 0, failed = 1) + } val trackedPackages = getTrackedPackageNames() var imported = 0 @@ -224,15 +237,16 @@ class AppsRepositoryImpl( continue } - // Try to get device app info if installed val systemInfo = packageMonitor.getInstalledPackageInfo(exportedApp.packageName) - val deviceApp = DeviceApp( - packageName = exportedApp.packageName, - appName = exportedApp.repoName, - versionName = systemInfo?.versionName, - versionCode = systemInfo?.versionCode ?: 0L, - ) + val deviceApp = + DeviceApp( + packageName = exportedApp.packageName, + appName = exportedApp.repoName, + versionName = systemInfo?.versionName, + versionCode = systemInfo?.versionCode ?: 0L, + signingFingerprint = systemInfo?.signingFingerprint, + ) linkAppToRepo(deviceApp, repoInfo) imported++ From ec79ce7bb01b42d2344ad1b7cbdf24fffe40193c Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 16 Mar 2026 12:21:49 +0500 Subject: [PATCH 4/8] feat: implement GitHub artifact attestation verification - Add `checkAttestations` to `DetailsRepository` to verify build integrity via GitHub's attestations API - Implement SHA-256 checksum computation for downloaded assets before installation - Introduce `AttestationStatus` to track verification states: unchecked, checking, verified, and unverified - Update `DetailsViewModel` to trigger asynchronous attestation checks post-installation - Enhance `SmartInstallButton` with a new `AttestationBadge` component to display verification status and progress - Add localized strings for "Verified build" and "Checking..." statuses - Update `DetailsState` to persist and reflect the current attestation status in the UI --- .../composeResources/values/strings.xml | 2 + .../details/data/dto/AttestationsResponse.kt | 9 +++ .../data/repository/DetailsRepositoryImpl.kt | 21 ++++++ .../domain/repository/DetailsRepository.kt | 6 ++ .../details/presentation/DetailsState.kt | 2 + .../details/presentation/DetailsViewModel.kt | 43 ++++++++++++ .../components/SmartInstallButton.kt | 69 +++++++++++++++++-- .../presentation/model/AttestationStatus.kt | 8 +++ 8 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/dto/AttestationsResponse.kt create mode 100644 feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/AttestationStatus.kt diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 194bfa91..bc22439f 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -207,6 +207,8 @@ Signing key changed The signing certificate for this app has changed since it was first installed.\n\nThis could mean the developer rotated their signing key, or the binary may have been tampered with.\n\nExpected: %1$s\nReceived: %2$s Install anyway + Verified build + Checking\u2026 Install %1$s Failed to open %1$s Failed to uninstall %1$s diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/dto/AttestationsResponse.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/dto/AttestationsResponse.kt new file mode 100644 index 00000000..08ca8449 --- /dev/null +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/dto/AttestationsResponse.kt @@ -0,0 +1,9 @@ +package zed.rainxch.details.data.dto + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class AttestationsResponse( + val attestations: List = emptyList(), +) diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt index 6f30b715..9f765c3d 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt @@ -20,6 +20,7 @@ import zed.rainxch.core.data.dto.ReleaseNetwork import zed.rainxch.core.data.dto.RepoByIdNetwork import zed.rainxch.core.data.dto.RepoInfoNetwork import zed.rainxch.core.data.dto.UserProfileNetwork +import zed.rainxch.details.data.dto.AttestationsResponse import zed.rainxch.core.data.mappers.toDomain import zed.rainxch.core.data.network.executeRequest import zed.rainxch.core.data.services.LocalizationManager @@ -489,4 +490,24 @@ class DetailsRepositoryImpl( throw e } } + + override suspend fun checkAttestations( + owner: String, + repo: String, + sha256Digest: String, + ): Boolean = + try { + val response = + httpClient + .executeRequest { + get("/repos/$owner/$repo/attestations/sha256:$sha256Digest") { + header(HttpHeaders.Accept, "application/vnd.github+json") + } + }.getOrNull() + response != null && response.attestations.isNotEmpty() + } catch (e: Exception) { + logger.debug("Attestation check failed for $owner/$repo: ${e.message}") + false + } + } diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/DetailsRepository.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/DetailsRepository.kt index 7d558626..4a6c7964 100644 --- a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/DetailsRepository.kt +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/DetailsRepository.kt @@ -41,4 +41,10 @@ interface DetailsRepository { ): RepoStats suspend fun getUserProfile(username: String): GithubUserProfile + + suspend fun checkAttestations( + owner: String, + repo: String, + sha256Digest: String, + ): Boolean } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt index 69912707..05b40e79 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt @@ -13,6 +13,7 @@ import zed.rainxch.details.presentation.model.SigningKeyWarning import zed.rainxch.details.presentation.model.DownloadStage import zed.rainxch.details.presentation.model.InstallLogItem import zed.rainxch.details.presentation.model.TranslationState +import zed.rainxch.details.presentation.model.AttestationStatus import zed.rainxch.details.presentation.model.TranslationTarget data class DetailsState( @@ -64,6 +65,7 @@ data class DetailsState( val showExternalInstallerPrompt: Boolean = false, val pendingInstallFilePath: String? = null, val showUninstallConfirmation: Boolean = false, + val attestationStatus: AttestationStatus = AttestationStatus.UNCHECKED, ) { val filteredReleases: List get() = diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index c247b692..b0d0a4c0 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -42,6 +42,7 @@ import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.details.domain.model.ReleaseCategory import zed.rainxch.details.domain.repository.DetailsRepository import zed.rainxch.details.domain.repository.TranslationRepository +import zed.rainxch.details.presentation.model.AttestationStatus import zed.rainxch.details.presentation.model.DowngradeWarning import zed.rainxch.details.presentation.model.DownloadStage import zed.rainxch.details.presentation.model.InstallLogItem @@ -61,6 +62,8 @@ import zed.rainxch.githubstore.core.presentation.res.rate_limit_exceeded import zed.rainxch.githubstore.core.presentation.res.removed_from_favourites import zed.rainxch.githubstore.core.presentation.res.translation_failed import java.io.File +import java.io.FileInputStream +import java.security.MessageDigest import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock.System @@ -1151,6 +1154,9 @@ class DetailsViewModel( installer.install(filePath, ext) + // Launch attestation check asynchronously (non-blocking) + launchAttestationCheck(filePath) + if (platform == Platform.ANDROID) { saveInstalledAppToDatabase( assetName = assetName, @@ -1201,6 +1207,42 @@ class DetailsViewModel( } } + private fun launchAttestationCheck(filePath: String) { + val repo = _state.value.repository ?: return + val owner = repo.owner.login + val repoName = repo.name + + _state.update { it.copy(attestationStatus = AttestationStatus.CHECKING) } + + viewModelScope.launch { + try { + val digest = computeSha256(filePath) + val verified = detailsRepository.checkAttestations(owner, repoName, digest) + _state.update { + it.copy( + attestationStatus = + if (verified) AttestationStatus.VERIFIED else AttestationStatus.UNVERIFIED, + ) + } + } catch (e: Exception) { + logger.debug("Attestation check error: ${e.message}") + _state.update { it.copy(attestationStatus = AttestationStatus.UNVERIFIED) } + } + } + } + + private fun computeSha256(filePath: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(8192) + FileInputStream(File(filePath)).use { fis -> + var bytesRead: Int + while (fis.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + private suspend fun downloadAsset( assetName: String, sizeBytes: Long, @@ -1226,6 +1268,7 @@ class DetailsViewModel( downloadError = null, installError = null, downloadProgressPercent = null, + attestationStatus = AttestationStatus.UNCHECKED, ) val existingPath = downloader.getDownloadedFilePath(assetName) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt index 766e7473..74a101c4 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt @@ -1,5 +1,8 @@ package zed.rainxch.details.presentation.components +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -8,6 +11,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -20,7 +24,9 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Update +import androidx.compose.material.icons.filled.VerifiedUser import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon @@ -43,6 +49,7 @@ import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.GithubUser import zed.rainxch.details.presentation.DetailsAction import zed.rainxch.details.presentation.DetailsState +import zed.rainxch.details.presentation.model.AttestationStatus import zed.rainxch.details.presentation.model.DownloadStage import zed.rainxch.details.presentation.utils.LocalTopbarLiquidState import zed.rainxch.details.presentation.utils.extractArchitectureFromName @@ -50,6 +57,8 @@ import zed.rainxch.details.presentation.utils.isExactArchitectureMatch import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.architecture_compatible import zed.rainxch.githubstore.core.presentation.res.cancel_download +import zed.rainxch.githubstore.core.presentation.res.checking_attestation +import zed.rainxch.githubstore.core.presentation.res.verified_build import zed.rainxch.githubstore.core.presentation.res.downloading import zed.rainxch.githubstore.core.presentation.res.install_latest import zed.rainxch.githubstore.core.presentation.res.install_version @@ -97,11 +106,11 @@ fun SmartInstallButton( // When same version is installed, show Open button if (isSameVersionInstalled && !isActiveDownload) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { + Column(modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { // Uninstall button ElevatedCard( onClick = { onAction(DetailsAction.OnRequestUninstall) }, @@ -191,6 +200,9 @@ fun SmartInstallButton( } } } + } + + AttestationBadge(attestationStatus = state.attestationStatus) } return } @@ -535,6 +547,53 @@ fun SmartInstallButton( } } +@Composable +private fun AttestationBadge(attestationStatus: AttestationStatus) { + AnimatedVisibility( + visible = attestationStatus == AttestationStatus.VERIFIED || attestationStatus == AttestationStatus.CHECKING, + enter = fadeIn(), + exit = fadeOut(), + ) { + Row( + modifier = Modifier.padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + when (attestationStatus) { + AttestationStatus.CHECKING -> { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(Res.string.checking_attestation), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + AttestationStatus.VERIFIED -> { + Icon( + imageVector = Icons.Filled.VerifiedUser, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.tertiary, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(Res.string.verified_build), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + fontWeight = FontWeight.SemiBold, + ) + } + else -> {} + } + } + } +} + private fun normalizeVersion(version: String): String = version.removePrefix("v").removePrefix("V").trim() private fun formatFileSize(bytes: Long): String = diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/AttestationStatus.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/AttestationStatus.kt new file mode 100644 index 00000000..462285df --- /dev/null +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/AttestationStatus.kt @@ -0,0 +1,8 @@ +package zed.rainxch.details.presentation.model + +enum class AttestationStatus { + UNCHECKED, + CHECKING, + VERIFIED, + UNVERIFIED, +} From 64dc2d541541b5046e9a07b6a10d5c47cf914dae Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 16 Mar 2026 12:43:34 +0500 Subject: [PATCH 5/8] feat: implement package and signing key verification for app linking - Add validation logic to verify that the linked GitHub repository's APK matches the installed app's package name and signing fingerprint - Implement background verification process: check latest release, download APK, and extract signing info before linking - Update `AppsViewModel` to handle the verification workflow and provide real-time status updates - Enhance `LinkAppBottomSheet` UI to display validation status messages (e.g., "Checking latest release...", "Verifying signing key...") - Add new localized strings for mismatch errors and validation status steps - Ensure temporary APK files used for verification are deleted after the process completes --- .../composeResources/values/strings.xml | 5 ++ .../rainxch/apps/presentation/AppsState.kt | 1 + .../apps/presentation/AppsViewModel.kt | 89 ++++++++++++++++++- .../components/LinkAppBottomSheet.kt | 12 +++ 4 files changed, 106 insertions(+), 1 deletion(-) diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index bc22439f..d07fb5bd 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -550,6 +550,11 @@ github.com/owner/repo Validating… Link & Track + Checking latest release… + Downloading APK for verification… + Verifying signing key… + Package name mismatch: the APK is %1$s, but the selected app is %2$s + Signing key mismatch: the APK in this repository was signed by a different developer than the installed app Export Import Import apps diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt index adcc80e1..010b3134 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt @@ -26,6 +26,7 @@ data class AppsState( val repoUrl: String = "", val isValidatingRepo: Boolean = false, val repoValidationError: String? = null, + val linkValidationStatus: String? = null, val fetchedRepoInfo: GithubRepoInfo? = null, // Export/Import val isExporting: Boolean = false, diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt index 41f173fb..f060a261 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt @@ -20,6 +20,7 @@ import zed.rainxch.apps.presentation.model.AppItem import zed.rainxch.apps.presentation.model.UpdateAllProgress import zed.rainxch.apps.presentation.model.UpdateState import zed.rainxch.core.domain.logging.GitHubStoreLogger +import zed.rainxch.core.domain.model.DeviceApp import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.network.Downloader @@ -736,6 +737,7 @@ class AppsViewModel( selectedDeviceApp = null, repoUrl = "", repoValidationError = null, + linkValidationStatus = null, fetchedRepoInfo = null, isValidatingRepo = false, ) @@ -753,25 +755,47 @@ class AppsViewModel( } viewModelScope.launch { - _state.update { it.copy(isValidatingRepo = true, repoValidationError = null) } + _state.update { + it.copy( + isValidatingRepo = true, + repoValidationError = null, + linkValidationStatus = null, + ) + } try { + _state.update { it.copy(linkValidationStatus = getString(Res.string.validating_repo)) } + val repoInfo = appsRepository.fetchRepoInfo(owner, repo) if (repoInfo == null) { _state.update { it.copy( isValidatingRepo = false, + linkValidationStatus = null, repoValidationError = "Repository not found: $owner/$repo", ) } return@launch } + val validationError = validateSigningFingerprint(selectedApp, owner, repo) + if (validationError != null) { + _state.update { + it.copy( + isValidatingRepo = false, + linkValidationStatus = null, + repoValidationError = validationError, + ) + } + return@launch + } + appsRepository.linkAppToRepo(selectedApp, repoInfo) _state.update { it.copy( isValidatingRepo = false, + linkValidationStatus = null, showLinkSheet = false, ) } @@ -782,6 +806,7 @@ class AppsViewModel( _state.update { it.copy( isValidatingRepo = false, + linkValidationStatus = null, repoValidationError = "GitHub API rate limit exceeded. Try again later.", ) } @@ -790,6 +815,7 @@ class AppsViewModel( _state.update { it.copy( isValidatingRepo = false, + linkValidationStatus = null, repoValidationError = "Failed to link: ${e.message}", ) } @@ -797,6 +823,67 @@ class AppsViewModel( } } + private suspend fun validateSigningFingerprint( + deviceApp: DeviceApp, + owner: String, + repo: String, + ): String? { + val latestRelease = try { + _state.update { it.copy(linkValidationStatus = getString(Res.string.checking_release)) } + appsRepository.getLatestRelease(owner, repo) + } catch (e: RateLimitException) { + throw e + } catch (e: Exception) { + logger.debug("Could not fetch release for validation: ${e.message}") + return null + } + + if (latestRelease == null) return null + + val installableAssets = latestRelease.assets.filter { installer.isAssetInstallable(it.name) } + if (installableAssets.isEmpty()) return null + + val asset = installer.choosePrimaryAsset(installableAssets) ?: return null + + _state.update { it.copy(linkValidationStatus = getString(Res.string.downloading_for_verification)) } + + var filePath: String? = null + try { + downloader.download(asset.downloadUrl, asset.name).collect { /* progress tracked by spinner */ } + + filePath = downloader.getDownloadedFilePath(asset.name) ?: return null + + _state.update { it.copy(linkValidationStatus = getString(Res.string.verifying_signing_key)) } + + val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) + if (apkInfo == null) { + logger.debug("Could not extract APK info for validation") + return null + } + + if (apkInfo.packageName != deviceApp.packageName) { + return getString( + Res.string.package_name_mismatch, + apkInfo.packageName, + deviceApp.packageName, + ) + } + + val deviceFingerprint = deviceApp.signingFingerprint + val apkFingerprint = apkInfo.signingFingerprint + + if (deviceFingerprint != null && apkFingerprint != null && deviceFingerprint != apkFingerprint) { + return getString(Res.string.signing_key_mismatch_link) + } + + return null + } finally { + try { + if (filePath != null) File(filePath).delete() + } catch (_: Exception) { } + } + } + private fun parseGithubUrl(input: String): Pair? { val cleaned = input diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt index 9b84ed75..4534bbb9 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt @@ -89,6 +89,7 @@ fun LinkAppBottomSheet( repoUrl = state.repoUrl, isValidating = state.isValidatingRepo, validationError = state.repoValidationError, + validationStatus = state.linkValidationStatus, onUrlChanged = { onAction(AppsAction.OnRepoUrlChanged(it)) }, onConfirm = { onAction(AppsAction.OnValidateAndLinkRepo) }, onBack = { onAction(AppsAction.OnBackToAppPicker) }, @@ -239,6 +240,7 @@ private fun EnterUrlStep( repoUrl: String, isValidating: Boolean, validationError: String?, + validationStatus: String?, onUrlChanged: (String) -> Unit, onConfirm: () -> Unit, onBack: () -> Unit, @@ -338,5 +340,15 @@ private fun EnterUrlStep( ) } } + + if (isValidating && validationStatus != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = validationStatus, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) + } } } From b25bb3430454af6450fda89ed9840e20776b9251 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 16 Mar 2026 12:47:59 +0500 Subject: [PATCH 6/8] refactor: simplify README fetching logic - Replace complex logic for fetching and prioritizing localized README variants with a single request for the root `README.md` file. - Remove the use of `coroutineScope`, `async`, and `awaitAll` for concurrent README path attempts. - Simplify the fetch process to target `https://raw.githubusercontent.com/{owner}/{repo}/{branch}/README.md` directly. - Maintain markdown preprocessing and language detection for the fetched `README.md`. - Remove unused imports related to coroutines and the legacy readme helper attempts. --- .../data/repository/DetailsRepositoryImpl.kt | 147 +++--------------- 1 file changed, 18 insertions(+), 129 deletions(-) diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt index 9f765c3d..0dc15b5b 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt @@ -5,10 +5,6 @@ import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter import io.ktor.http.HttpHeaders -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import kotlinx.serialization.Serializable import zed.rainxch.core.data.cache.CacheManager import zed.rainxch.core.data.cache.CacheManager.CacheTtl.README @@ -276,136 +272,29 @@ class DetailsRepositoryImpl( repo: String, defaultBranch: String, ): Triple? { - val attempts = readmeHelper.generateReadmeAttempts() val baseUrl = "https://raw.githubusercontent.com/$owner/$repo/$defaultBranch/" - val primaryLang = localizationManager.getPrimaryLanguageCode() + val path = "README.md" - logger.debug( - "Attempting to fetch README for language preference: ${localizationManager.getCurrentLanguageCode()}", - ) - - val foundReadmes = - coroutineScope { - attempts - .map { attempt -> - async(start = CoroutineStart.LAZY) { - try { - logger.debug("Trying ${attempt.path} (priority: ${attempt.priority})...") - - val rawMarkdown = - httpClient - .executeRequest { - get("$baseUrl${attempt.path}") - }.getOrNull() - - if (rawMarkdown != null) { - logger.debug("Successfully fetched ${attempt.path}") - - val processed = - preprocessMarkdown( - markdown = rawMarkdown, - baseUrl = baseUrl, - ) - - val detectedLang = readmeHelper.detectReadmeLanguage(processed) - logger.debug("Detected language: ${detectedLang ?: "unknown"} for ${attempt.path}") - - attempt to Pair(processed, detectedLang) - } else { - null - } - } catch (e: Throwable) { - logger.debug("Failed to fetch ${attempt.path}: ${e.message}") - null - } - } - }.also { asyncTasks -> - asyncTasks.take(6).forEach { it.start() } - }.awaitAll() - .filterNotNull() - .associateBy({ it.first }, { it.second }) - } - - if (foundReadmes.isEmpty()) { - logger.error("Failed to fetch any README variant.") - return null - } - - foundReadmes.entries - .firstOrNull { (attempt, content) -> - attempt.filename != "README.md" && content.second == primaryLang - }?.let { (attempt, content) -> - logger.debug("Found localized README matching user language: ${attempt.path}") - return Triple(content.first, content.second, attempt.path) - } - - foundReadmes.entries - .firstOrNull { (attempt, _) -> - attempt.filename.contains(".$primaryLang.", ignoreCase = true) || - attempt.filename.contains("-${primaryLang.uppercase()}.", ignoreCase = true) - }?.let { (attempt, content) -> - logger.debug("Found explicit language file for user: ${attempt.path}") - return Triple(content.first, content.second ?: primaryLang, attempt.path) - } - - foundReadmes.entries - .firstOrNull { (attempt, content) -> - attempt.filename == "README.md" && content.second == primaryLang - }?.let { (attempt, content) -> - logger.debug("Default README matches user language: ${attempt.path}") - return Triple(content.first, content.second, attempt.path) - } - - if (primaryLang == "en") { - foundReadmes.entries - .firstOrNull { (_, content) -> - content.second == "en" - }?.let { (attempt, content) -> - logger.debug("Found English README for English user: ${attempt.path}") - return Triple(content.first, content.second, attempt.path) - } - } - - foundReadmes.entries - .firstOrNull { (_, content) -> - content.second == primaryLang - }?.let { (attempt, content) -> - logger.debug("Fallback: Using README matching user language: ${attempt.path}") - return Triple(content.first, content.second, attempt.path) - } - - if (primaryLang == "en") { - foundReadmes.entries - .firstOrNull { (_, content) -> - content.second == "en" - }?.let { (attempt, content) -> - logger.debug("Fallback: Using English README: ${attempt.path}") - return Triple(content.first, content.second, attempt.path) - } - } - - foundReadmes.entries - .firstOrNull { (attempt, _) -> - attempt.path == "README.md" - }?.let { (attempt, content) -> - logger.debug("Fallback: Using root README.md (language: ${content.second}): ${attempt.path}") - return Triple(content.first, content.second, attempt.path) - } + return try { + val rawMarkdown = + httpClient + .executeRequest { + get("$baseUrl$path") + }.getOrNull() - foundReadmes.entries - .firstOrNull { (attempt, _) -> - attempt.path.startsWith(".github/") - }?.let { (attempt, content) -> - logger.debug("Fallback: Using .github README: ${attempt.path}") - return Triple(content.first, content.second, attempt.path) + if (rawMarkdown != null) { + val processed = preprocessMarkdown(markdown = rawMarkdown, baseUrl = baseUrl) + val detectedLang = readmeHelper.detectReadmeLanguage(processed) + logger.debug("Fetched README.md (detected language: ${detectedLang ?: "unknown"})") + Triple(processed, detectedLang, path) + } else { + logger.error("Failed to fetch README.md for $owner/$repo") + null } - - foundReadmes.entries.minByOrNull { it.key.priority }?.let { (attempt, content) -> - logger.debug("Fallback: Using highest priority README: ${attempt.path}") - return Triple(content.first, content.second, attempt.path) + } catch (e: Throwable) { + logger.error("Failed to fetch README.md: ${e.message}") + null } - - return null } override suspend fun getRepoStats( From a613a0f5b7d937fbd9150bc775f4db6cdd94a38c Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 17 Mar 2026 09:18:25 +0500 Subject: [PATCH 7/8] feat: enhance app linking with asset selection and validation - Implement a new "Pick Asset" step in the manual app linking flow to allow users to select a specific APK for verification. - Add support for downloading and verifying the selected asset's package name and signing fingerprint against the installed app. - Update `AppsViewModel` to handle asset selection, download progress tracking, and detailed validation logic. - Enhance GitHub URL parsing to be more robust and strictly validate the "github.com" domain. - Refactor string resources to move hardcoded UI text into `strings.xml` for better localization support. - Improve the "Apps" list sorting to prioritize apps with available updates. - Update `LinkAppBottomSheet` UI with `AnimatedContent` for smoother transitions between linking steps and a new `PickAssetStep` list. --- .../composeResources/values/strings.xml | 15 + .../rainxch/apps/presentation/AppsAction.kt | 3 + .../rainxch/apps/presentation/AppsState.kt | 20 +- .../apps/presentation/AppsViewModel.kt | 275 +++++++++++++----- .../components/LinkAppBottomSheet.kt | 153 +++++++++- 5 files changed, 379 insertions(+), 87 deletions(-) diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index d07fb5bd..22663c95 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -555,6 +555,9 @@ Verifying signing key… Package name mismatch: the APK is %1$s, but the selected app is %2$s Signing key mismatch: the APK in this repository was signed by a different developer than the installed app + Select installer + Pick the APK to verify against your installed app + Download failed Export Import Import apps @@ -565,4 +568,16 @@ Track pre-release versions when checking for updates. When disabled, only stable releases are considered. Uninstall app? Are you sure you want to uninstall %1$s? This action cannot be undone and app data may be lost. + + Invalid GitHub URL. Use format: github.com/owner/repo + Repository not found: %1$s/%2$s + GitHub API rate limit exceeded. Try again later. + Failed to link: %1$s + Failed to load installed apps + %1$s linked to %2$s/%3$s + Export failed: %1$s + Import failed: %1$s + Imported %1$d apps + , %1$d skipped + , %1$d failed \ No newline at end of file diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt index 8c10d2c2..22bc5821 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt @@ -1,6 +1,7 @@ package zed.rainxch.apps.presentation import zed.rainxch.core.domain.model.DeviceApp +import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.InstalledApp sealed interface AppsAction { @@ -50,6 +51,8 @@ sealed interface AppsAction { data class OnRepoUrlChanged(val url: String) : AppsAction data object OnValidateAndLinkRepo : AppsAction data object OnBackToAppPicker : AppsAction + data class OnLinkAssetSelected(val asset: GithubAsset) : AppsAction + data object OnBackToEnterUrl : AppsAction // Export/Import data object OnExportApps : AppsAction diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt index 010b3134..b4df42e7 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt @@ -4,6 +4,7 @@ import zed.rainxch.apps.domain.model.GithubRepoInfo import zed.rainxch.apps.presentation.model.AppItem import zed.rainxch.apps.presentation.model.UpdateAllProgress import zed.rainxch.core.domain.model.DeviceApp +import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.InstalledApp data class AppsState( @@ -27,6 +28,9 @@ data class AppsState( val isValidatingRepo: Boolean = false, val repoValidationError: String? = null, val linkValidationStatus: String? = null, + val linkInstallableAssets: List = emptyList(), + val linkSelectedAsset: GithubAsset? = null, + val linkDownloadProgress: Int? = null, val fetchedRepoInfo: GithubRepoInfo? = null, // Export/Import val isExporting: Boolean = false, @@ -35,17 +39,19 @@ data class AppsState( val appPendingUninstall: InstalledApp? = null, ) { val filteredDeviceApps: List - get() = if (deviceAppSearchQuery.isBlank()) { - deviceApps - } else { - deviceApps.filter { - it.appName.contains(deviceAppSearchQuery, ignoreCase = true) || - it.packageName.contains(deviceAppSearchQuery, ignoreCase = true) + get() = + if (deviceAppSearchQuery.isBlank()) { + deviceApps + } else { + deviceApps.filter { + it.appName.contains(deviceAppSearchQuery, ignoreCase = true) || + it.packageName.contains(deviceAppSearchQuery, ignoreCase = true) + } } - } } enum class LinkStep { PickApp, EnterUrl, + PickAsset, } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt index f060a261..4724c2a7 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt @@ -21,6 +21,7 @@ import zed.rainxch.apps.presentation.model.UpdateAllProgress import zed.rainxch.apps.presentation.model.UpdateState import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.DeviceApp +import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.network.Downloader @@ -90,7 +91,7 @@ class AppsViewModel( downloadProgress = existing?.downloadProgress, error = existing?.error, ) - }.sortedBy { it.installedApp.isUpdateAvailable } + }.sortedByDescending { it.installedApp.isUpdateAvailable } _state.update { it.copy( @@ -255,6 +256,26 @@ class AppsViewModel( repoUrl = "", repoValidationError = null, fetchedRepoInfo = null, + linkInstallableAssets = emptyList(), + linkSelectedAsset = null, + linkDownloadProgress = null, + ) + } + } + + is AppsAction.OnLinkAssetSelected -> { + validateWithAsset(action.asset) + } + + AppsAction.OnBackToEnterUrl -> { + _state.update { + it.copy( + linkStep = LinkStep.EnterUrl, + linkInstallableAssets = emptyList(), + linkSelectedAsset = null, + linkDownloadProgress = null, + linkValidationStatus = null, + repoValidationError = null, ) } } @@ -722,7 +743,7 @@ class AppsViewModel( _state.update { it.copy(deviceApps = deviceApps) } } catch (e: Exception) { logger.error("Failed to load device apps: ${e.message}") - _events.send(AppsEvent.ShowError("Failed to load installed apps")) + _events.send(AppsEvent.ShowError(getString(Res.string.failed_to_load_apps))) } } } @@ -738,6 +759,9 @@ class AppsViewModel( repoUrl = "", repoValidationError = null, linkValidationStatus = null, + linkInstallableAssets = emptyList(), + linkSelectedAsset = null, + linkDownloadProgress = null, fetchedRepoInfo = null, isValidatingRepo = false, ) @@ -748,13 +772,15 @@ class AppsViewModel( val selectedApp = _state.value.selectedDeviceApp ?: return val url = _state.value.repoUrl.trim() - val (owner, repo) = - parseGithubUrl(url) ?: run { - _state.update { it.copy(repoValidationError = "Invalid GitHub URL. Use format: github.com/owner/repo") } - return - } + val parsed = parseGithubUrl(url) viewModelScope.launch { + if (parsed == null) { + _state.update { it.copy(repoValidationError = getString(Res.string.invalid_github_url)) } + return@launch + } + + val (owner, repo) = parsed _state.update { it.copy( isValidatingRepo = true, @@ -772,42 +798,73 @@ class AppsViewModel( it.copy( isValidatingRepo = false, linkValidationStatus = null, - repoValidationError = "Repository not found: $owner/$repo", + repoValidationError = getString(Res.string.repo_not_found, owner, repo), ) } return@launch } - val validationError = validateSigningFingerprint(selectedApp, owner, repo) - if (validationError != null) { + _state.update { + it.copy( + fetchedRepoInfo = repoInfo, + linkValidationStatus = getString(Res.string.checking_release), + ) + } + + val latestRelease = + try { + appsRepository.getLatestRelease(owner, repo) + } catch (e: RateLimitException) { + throw e + } catch (e: Exception) { + logger.debug("Could not fetch release for validation: ${e.message}") + return@launch + } + + if (latestRelease == null) { + appsRepository.linkAppToRepo(selectedApp, repoInfo) _state.update { it.copy( isValidatingRepo = false, linkValidationStatus = null, - repoValidationError = validationError, + showLinkSheet = false, ) } + _events.send(AppsEvent.AppLinkedSuccessfully(selectedApp.appName)) + _events.send(AppsEvent.ShowSuccess(getString(Res.string.app_linked_success, selectedApp.appName, repoInfo.owner, repoInfo.name))) return@launch } - appsRepository.linkAppToRepo(selectedApp, repoInfo) + val installableAssets = + latestRelease.assets.filter { installer.isAssetInstallable(it.name) } + if (installableAssets.isEmpty()) { + appsRepository.linkAppToRepo(selectedApp, repoInfo) + _state.update { + it.copy( + isValidatingRepo = false, + linkValidationStatus = null, + showLinkSheet = false, + ) + } + _events.send(AppsEvent.AppLinkedSuccessfully(selectedApp.appName)) + _events.send(AppsEvent.ShowSuccess(getString(Res.string.app_linked_success, selectedApp.appName, repoInfo.owner, repoInfo.name))) + return@launch + } _state.update { it.copy( isValidatingRepo = false, linkValidationStatus = null, - showLinkSheet = false, + linkStep = LinkStep.PickAsset, + linkInstallableAssets = installableAssets, ) } - - _events.send(AppsEvent.AppLinkedSuccessfully(selectedApp.appName)) - _events.send(AppsEvent.ShowSuccess("${selectedApp.appName} linked to ${repoInfo.owner}/${repoInfo.name}")) } catch (_: RateLimitException) { _state.update { it.copy( isValidatingRepo = false, linkValidationStatus = null, - repoValidationError = "GitHub API rate limit exceeded. Try again later.", + repoValidationError = getString(Res.string.rate_limit_try_again), ) } } catch (e: Exception) { @@ -816,91 +873,151 @@ class AppsViewModel( it.copy( isValidatingRepo = false, linkValidationStatus = null, - repoValidationError = "Failed to link: ${e.message}", + repoValidationError = getString(Res.string.failed_to_link, e.message ?: ""), ) } } } } - private suspend fun validateSigningFingerprint( - deviceApp: DeviceApp, - owner: String, - repo: String, - ): String? { - val latestRelease = try { - _state.update { it.copy(linkValidationStatus = getString(Res.string.checking_release)) } - appsRepository.getLatestRelease(owner, repo) - } catch (e: RateLimitException) { - throw e - } catch (e: Exception) { - logger.debug("Could not fetch release for validation: ${e.message}") - return null - } - - if (latestRelease == null) return null - - val installableAssets = latestRelease.assets.filter { installer.isAssetInstallable(it.name) } - if (installableAssets.isEmpty()) return null + private fun validateWithAsset(asset: GithubAsset) { + val selectedApp = _state.value.selectedDeviceApp ?: return + val repoInfo = _state.value.fetchedRepoInfo ?: return - val asset = installer.choosePrimaryAsset(installableAssets) ?: return null + viewModelScope.launch { + _state.update { + it.copy( + linkSelectedAsset = asset, + linkDownloadProgress = 0, + linkValidationStatus = getString(Res.string.downloading_for_verification), + repoValidationError = null, + ) + } - _state.update { it.copy(linkValidationStatus = getString(Res.string.downloading_for_verification)) } + var filePath: String? = null + try { + downloader.download(asset.downloadUrl, asset.name).collect { progress -> + _state.update { it.copy(linkDownloadProgress = progress.percent) } + } - var filePath: String? = null - try { - downloader.download(asset.downloadUrl, asset.name).collect { /* progress tracked by spinner */ } + filePath = downloader.getDownloadedFilePath(asset.name) + if (filePath == null) { + _state.update { + it.copy( + linkDownloadProgress = null, + linkValidationStatus = null, + repoValidationError = getString(Res.string.download_failed), + ) + } + return@launch + } - filePath = downloader.getDownloadedFilePath(asset.name) ?: return null + _state.update { + it.copy( + linkDownloadProgress = 100, + linkValidationStatus = getString(Res.string.verifying_signing_key), + ) + } - _state.update { it.copy(linkValidationStatus = getString(Res.string.verifying_signing_key)) } + val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) + if (apkInfo == null) { + logger.debug("Could not extract APK info for validation, linking anyway") + appsRepository.linkAppToRepo(selectedApp, repoInfo) + _state.update { + it.copy( + linkDownloadProgress = null, + linkValidationStatus = null, + showLinkSheet = false, + ) + } + _events.send(AppsEvent.AppLinkedSuccessfully(selectedApp.appName)) + _events.send(AppsEvent.ShowSuccess(getString(Res.string.app_linked_success, selectedApp.appName, repoInfo.owner, repoInfo.name))) + return@launch + } - val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) - if (apkInfo == null) { - logger.debug("Could not extract APK info for validation") - return null - } + if (apkInfo.packageName != selectedApp.packageName) { + _state.update { + it.copy( + linkDownloadProgress = null, + linkValidationStatus = null, + repoValidationError = + getString( + Res.string.package_name_mismatch, + apkInfo.packageName, + selectedApp.packageName, + ), + ) + } + return@launch + } - if (apkInfo.packageName != deviceApp.packageName) { - return getString( - Res.string.package_name_mismatch, - apkInfo.packageName, - deviceApp.packageName, - ) - } + val deviceFingerprint = selectedApp.signingFingerprint + val apkFingerprint = apkInfo.signingFingerprint - val deviceFingerprint = deviceApp.signingFingerprint - val apkFingerprint = apkInfo.signingFingerprint + if (deviceFingerprint != null && apkFingerprint != null && deviceFingerprint != apkFingerprint) { + _state.update { + it.copy( + linkDownloadProgress = null, + linkValidationStatus = null, + repoValidationError = getString(Res.string.signing_key_mismatch_link), + ) + } + return@launch + } - if (deviceFingerprint != null && apkFingerprint != null && deviceFingerprint != apkFingerprint) { - return getString(Res.string.signing_key_mismatch_link) + appsRepository.linkAppToRepo(selectedApp, repoInfo) + _state.update { + it.copy( + linkDownloadProgress = null, + linkValidationStatus = null, + showLinkSheet = false, + ) + } + _events.send(AppsEvent.AppLinkedSuccessfully(selectedApp.appName)) + _events.send(AppsEvent.ShowSuccess(getString(Res.string.app_linked_success, selectedApp.appName, repoInfo.owner, repoInfo.name))) + } catch (_: RateLimitException) { + _state.update { + it.copy( + linkDownloadProgress = null, + linkValidationStatus = null, + repoValidationError = getString(Res.string.rate_limit_try_again), + ) + } + } catch (e: Exception) { + logger.error("Failed to validate and link app: ${e.message}") + _state.update { + it.copy( + linkDownloadProgress = null, + linkValidationStatus = null, + repoValidationError = getString(Res.string.failed_to_link, e.message ?: ""), + ) + } + } finally { + try { + if (filePath != null) File(filePath).delete() + } catch (_: Exception) { + } } - - return null - } finally { - try { - if (filePath != null) File(filePath).delete() - } catch (_: Exception) { } } } private fun parseGithubUrl(input: String): Pair? { - val cleaned = + val normalized = input .trim() .removePrefix("https://") .removePrefix("http://") .removePrefix("www.") - .removePrefix("github.com/") + .substringBefore("?") + .substringBefore("#") .removeSuffix("/") - .split("?")[0] - .split("#")[0] - val parts = cleaned.split("/") - if (parts.size < 2) return null + val parts = normalized.split("/") + if (parts.size < 3) return null + if (!parts[0].equals("github.com", ignoreCase = true)) return null - val owner = parts[0] - val repo = parts[1] + val owner = parts[1] + val repo = parts[2] if (owner.isBlank() || repo.isBlank()) return null if (owner.length > 39 || repo.length > 100) return null @@ -918,7 +1035,7 @@ class AppsViewModel( _events.send(AppsEvent.ExportReady(json)) } catch (e: Exception) { logger.error("Export failed: ${e.message}") - _events.send(AppsEvent.ShowError("Export failed: ${e.message}")) + _events.send(AppsEvent.ShowError(getString(Res.string.export_failed, e.message ?: ""))) } finally { _state.update { it.copy(isExporting = false) } } @@ -942,14 +1059,14 @@ class AppsViewModel( _events.send(AppsEvent.ImportComplete(result)) _events.send( AppsEvent.ShowSuccess( - "Imported ${result.imported} apps" + - (if (result.skipped > 0) ", ${result.skipped} skipped" else "") + - (if (result.failed > 0) ", ${result.failed} failed" else ""), + getString(Res.string.imported_apps_summary, result.imported) + + (if (result.skipped > 0) getString(Res.string.imported_skipped, result.skipped) else "") + + (if (result.failed > 0) getString(Res.string.imported_failed, result.failed) else ""), ), ) } catch (e: Exception) { logger.error("Import failed: ${e.message}") - _events.send(AppsEvent.ShowError("Import failed: ${e.message}")) + _events.send(AppsEvent.ShowError(getString(Res.string.import_failed, e.message ?: ""))) } finally { _state.update { it.copy(isImporting = false) } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt index 4534bbb9..60ded6da 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -49,6 +50,7 @@ import zed.rainxch.apps.presentation.AppsAction import zed.rainxch.apps.presentation.AppsState import zed.rainxch.apps.presentation.LinkStep import zed.rainxch.core.domain.model.DeviceApp +import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.githubstore.core.presentation.res.* @OptIn(ExperimentalMaterial3Api::class) @@ -66,7 +68,7 @@ fun LinkAppBottomSheet( AnimatedContent( targetState = state.linkStep, transitionSpec = { - if (targetState == LinkStep.EnterUrl) { + if (targetState.ordinal > initialState.ordinal) { (slideInHorizontally { it } + fadeIn()) togetherWith (slideOutHorizontally { -it } + fadeOut()) } else { @@ -94,6 +96,16 @@ fun LinkAppBottomSheet( onConfirm = { onAction(AppsAction.OnValidateAndLinkRepo) }, onBack = { onAction(AppsAction.OnBackToAppPicker) }, ) + + LinkStep.PickAsset -> PickAssetStep( + assets = state.linkInstallableAssets, + selectedAsset = state.linkSelectedAsset, + downloadProgress = state.linkDownloadProgress, + validationStatus = state.linkValidationStatus, + validationError = state.repoValidationError, + onAssetSelected = { onAction(AppsAction.OnLinkAssetSelected(it)) }, + onBack = { onAction(AppsAction.OnBackToEnterUrl) }, + ) } } } @@ -352,3 +364,142 @@ private fun EnterUrlStep( } } } + +@Composable +private fun PickAssetStep( + assets: List, + selectedAsset: GithubAsset?, + downloadProgress: Int?, + validationStatus: String?, + validationError: String?, + onAssetSelected: (GithubAsset) -> Unit, + onBack: () -> Unit, +) { + val isProcessing = selectedAsset != null + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack, enabled = !isProcessing) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + + Text( + text = stringResource(Res.string.select_asset_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + } + + Spacer(Modifier.height(4.dp)) + + Text( + text = stringResource(Res.string.select_asset_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp), + ) + + Spacer(Modifier.height(12.dp)) + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + ) { + items( + items = assets, + key = { it.id }, + ) { asset -> + val isSelected = selectedAsset?.id == asset.id + + Row( + modifier = Modifier + .fillMaxWidth() + .then( + if (isSelected) { + Modifier.background( + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f), + RoundedCornerShape(8.dp), + ) + } else { + Modifier + }, + ) + .clickable(enabled = !isProcessing) { onAssetSelected(asset) } + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = asset.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = formatFileSize(asset.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (isSelected && downloadProgress != null) { + Spacer(Modifier.width(8.dp)) + CircularProgressIndicator( + progress = { downloadProgress / 100f }, + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(4.dp)) + Text( + text = "$downloadProgress%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), + ) + } + } + + if (validationStatus != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = validationStatus, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (validationError != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = validationError, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } +} + +private fun formatFileSize(bytes: Long): String = + when { + bytes >= 1_073_741_824 -> "%.1f GB".format(bytes / 1_073_741_824.0) + bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576.0) + bytes >= 1_024 -> "%.1f KB".format(bytes / 1_024.0) + else -> "$bytes B" + } From f5e0de18e3f05a0357395abb24fb198a170f8a5f Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 17 Mar 2026 09:18:40 +0500 Subject: [PATCH 8/8] locale: add translations for new features across multiple languages - Add localized strings for "Link app to repo", "Export/Import", and "Pre-release support" features. - Provide translations for Italian, Japanese, Korean, Polish, Arabic, Russian, Turkish, Bengali, Chinese (Simplified), Spanish, French, and Hindi. - Include strings for repository validation, signing key verification, package name mismatch warnings, and uninstall confirmation dialogs. - Add UI labels for asset selection, download status, and GitHub API rate limiting notifications. --- .../composeResources/values-ar/strings-ar.xml | 52 +++++++++++++++++++ .../composeResources/values-bn/strings-bn.xml | 52 +++++++++++++++++++ .../composeResources/values-es/strings-es.xml | 52 +++++++++++++++++++ .../composeResources/values-fr/strings-fr.xml | 52 +++++++++++++++++++ .../composeResources/values-hi/strings-hi.xml | 52 +++++++++++++++++++ .../composeResources/values-it/strings-it.xml | 52 +++++++++++++++++++ .../composeResources/values-ja/strings-ja.xml | 52 +++++++++++++++++++ .../composeResources/values-ko/strings-ko.xml | 52 +++++++++++++++++++ .../composeResources/values-pl/strings-pl.xml | 52 +++++++++++++++++++ .../composeResources/values-ru/strings-ru.xml | 52 +++++++++++++++++++ .../composeResources/values-tr/strings-tr.xml | 52 +++++++++++++++++++ .../values-zh-rCN/strings-zh-rCN.xml | 52 +++++++++++++++++++ 12 files changed, 624 insertions(+) diff --git a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml index 0312f8b9..f4da4b9b 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -528,4 +528,56 @@ ٦ ساعات ١٢ ساعة ٢٤ ساعة + + إضافة عبر رابط + ربط التطبيق بالمستودع + اختر تطبيقاً مثبتاً لربطه بمستودع GitHub + البحث عن التطبيقات… + رابط مستودع GitHub + github.com/owner/repo + جارٍ التحقق… + ربط وتتبع + التحقق من آخر إصدار… + تنزيل APK للتحقق… + التحقق من مفتاح التوقيع… + عدم تطابق اسم الحزمة: ملف APK هو %1$s، لكن التطبيق المحدد هو %2$s + عدم تطابق مفتاح التوقيع: ملف APK في هذا المستودع موقّع من مطور مختلف + اختر المثبّت + اختر ملف APK للتحقق من مطابقته للتطبيق المثبت + فشل التنزيل + تصدير + استيراد + استيراد التطبيقات + الصق ملف JSON المُصدَّر لاستعادة التطبيقات المتتبعة + الصق JSON المُصدَّر هنا… + تضمين الإصدارات التجريبية + تتبع الإصدارات التجريبية عند التحقق من التحديثات. عند التعطيل، يتم اعتبار الإصدارات المستقرة فقط. + إلغاء تثبيت التطبيق؟ + هل أنت متأكد من إلغاء تثبيت %1$s؟ لا يمكن التراجع عن هذا الإجراء وقد تُفقد بيانات التطبيق. + رابط GitHub غير صالح. استخدم التنسيق: github.com/owner/repo + المستودع غير موجود: %1$s/%2$s + تم تجاوز حد طلبات GitHub API. حاول لاحقاً. + فشل الربط: %1$s + فشل تحميل التطبيقات المثبتة + تم ربط %1$s بـ %2$s/%3$s + فشل التصدير: %1$s + فشل الاستيراد: %1$s + تم استيراد %1$d تطبيقات + ، %1$d تم تخطيها + ، %1$d فشلت + تغيّر مفتاح التوقيع + تغيّرت شهادة توقيع هذا التطبيق منذ تثبيته لأول مرة.\n\nقد يعني هذا أن المطور غيّر مفتاح التوقيع، أو أن الملف قد تم التلاعب به.\n\nالمتوقع: %1$s\nالمستلم: %2$s + التثبيت على أي حال + بناء موثق + جارٍ التحقق\u2026 + الملفات + لا توجد ملفات + لا توجد ملفات مرتبطة بهذا الإصدار + اختر خيار الملف + ملفات متعددة متاحة + تتوفر عدة ملفات قابلة للتثبيت لهذا الإصدار. يرجى مراجعة القائمة واختيار الملف المناسب لجهازك. + معلومات + إعادة المحاولة + اكتشاف تلقائي: %1$s + اختر اللغة diff --git a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml index c0166d85..e63464c4 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -527,4 +527,56 @@ ৬ঘ ১২ঘ ২৪ঘ + + লিঙ্ক দিয়ে যোগ করুন + অ্যাপ রিপোজিটরিতে লিঙ্ক করুন + GitHub রিপোজিটরিতে লিঙ্ক করতে একটি ইনস্টল করা অ্যাপ বেছে নিন + অ্যাপ খুঁজুন… + GitHub রিপোজিটরি URL + github.com/owner/repo + যাচাই হচ্ছে… + লিঙ্ক এবং ট্র্যাক করুন + সর্বশেষ রিলিজ পরীক্ষা হচ্ছে… + যাচাইয়ের জন্য APK ডাউনলোড হচ্ছে… + সাইনিং কী যাচাই হচ্ছে… + প্যাকেজ নাম মেলেনি: APK হলো %1$s, কিন্তু নির্বাচিত অ্যাপ হলো %2$s + সাইনিং কী মেলেনি: এই রিপোজিটরির APK একজন ভিন্ন ডেভেলপার দ্বারা স্বাক্ষরিত + ইনস্টলার নির্বাচন করুন + আপনার ইনস্টল করা অ্যাপের সাথে যাচাই করতে APK নির্বাচন করুন + ডাউনলোড ব্যর্থ + রপ্তানি + আমদানি + অ্যাপ আমদানি করুন + ট্র্যাক করা অ্যাপ পুনরুদ্ধার করতে রপ্তানি করা JSON পেস্ট করুন + রপ্তানি করা JSON এখানে পেস্ট করুন… + প্রি-রিলিজ অন্তর্ভুক্ত করুন + আপডেট পরীক্ষার সময় প্রি-রিলিজ সংস্করণ ট্র্যাক করুন। নিষ্ক্রিয় থাকলে, শুধুমাত্র স্থিতিশীল রিলিজ বিবেচনা করা হয়। + অ্যাপ আনইনস্টল করবেন? + আপনি কি নিশ্চিত যে %1$s আনইনস্টল করতে চান? এই ক্রিয়া পূর্বাবস্থায় ফেরানো যাবে না এবং অ্যাপের ডেটা হারিয়ে যেতে পারে। + অবৈধ GitHub URL। ফর্ম্যাট ব্যবহার করুন: github.com/owner/repo + রিপোজিটরি পাওয়া যায়নি: %1$s/%2$s + GitHub API হার সীমা অতিক্রম করেছে। পরে আবার চেষ্টা করুন। + লিঙ্ক করতে ব্যর্থ: %1$s + ইনস্টল করা অ্যাপ লোড করতে ব্যর্থ + %1$s %2$s/%3$s এর সাথে লিঙ্ক করা হয়েছে + রপ্তানি ব্যর্থ: %1$s + আমদানি ব্যর্থ: %1$s + %1$d অ্যাপ আমদানি করা হয়েছে + , %1$d বাদ দেওয়া হয়েছে + , %1$d ব্যর্থ + সাইনিং কী পরিবর্তিত হয়েছে + এই অ্যাপের সাইনিং সার্টিফিকেট প্রথম ইনস্টলের পর থেকে পরিবর্তিত হয়েছে।\n\nএর অর্থ হতে পারে ডেভেলপার তাদের সাইনিং কী পরিবর্তন করেছে, অথবা বাইনারি পরিবর্তন করা হয়েছে।\n\nপ্রত্যাশিত: %1$s\nপ্রাপ্ত: %2$s + যাই হোক ইনস্টল করুন + যাচাইকৃত বিল্ড + পরীক্ষা হচ্ছে\u2026 + সম্পদ + কোনো সম্পদ নেই + এই রিলিজের সাথে কোনো সম্পদ যুক্ত নেই + সম্পদ বিকল্প নির্বাচন করুন + একাধিক সম্পদ উপলব্ধ + এই রিলিজের জন্য একাধিক ইনস্টলযোগ্য ফাইল উপলব্ধ। তালিকা পর্যালোচনা করুন এবং আপনার ডিভাইসের জন্য উপযুক্তটি নির্বাচন করুন। + তথ্য + পুনরায় চেষ্টা + স্বয়ংক্রিয়ভাবে শনাক্ত: %1$s + ভাষা নির্বাচন করুন diff --git a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml index 618067e8..751e2168 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -488,4 +488,56 @@ 6h 12h 24h + + Añadir por enlace + Vincular app al repositorio + Elige una app instalada para vincularla a un repositorio de GitHub + Buscar apps… + URL del repositorio GitHub + github.com/owner/repo + Validando… + Vincular y seguir + Comprobando último lanzamiento… + Descargando APK para verificación… + Verificando clave de firma… + Nombre de paquete no coincide: el APK es %1$s, pero la app seleccionada es %2$s + Clave de firma no coincide: el APK de este repositorio fue firmado por un desarrollador diferente + Seleccionar instalador + Elige el APK para verificar contra tu app instalada + Error en la descarga + Exportar + Importar + Importar apps + Pega el JSON exportado para restaurar tus apps rastreadas + Pega el JSON exportado aquí… + Incluir pre-lanzamientos + Rastrear versiones pre-lanzamiento al buscar actualizaciones. Si está desactivado, solo se consideran las versiones estables. + ¿Desinstalar app? + ¿Estás seguro de que quieres desinstalar %1$s? Esta acción no se puede deshacer y los datos de la app podrían perderse. + URL de GitHub no válida. Usa el formato: github.com/owner/repo + Repositorio no encontrado: %1$s/%2$s + Límite de la API de GitHub excedido. Inténtalo más tarde. + Error al vincular: %1$s + Error al cargar las apps instaladas + %1$s vinculada a %2$s/%3$s + Error en la exportación: %1$s + Error en la importación: %1$s + %1$d apps importadas + , %1$d omitidas + , %1$d fallidas + Clave de firma cambiada + El certificado de firma de esta app ha cambiado desde su primera instalación.\n\nEsto podría significar que el desarrollador rotó su clave de firma, o el binario pudo haber sido manipulado.\n\nEsperado: %1$s\nRecibido: %2$s + Instalar de todos modos + Compilación verificada + Comprobando\u2026 + Recursos + Sin recursos + No hay recursos asociados a este lanzamiento + Seleccionar opción de recurso + Múltiples recursos disponibles + Hay varios archivos instalables disponibles para este lanzamiento. Revisa la lista y selecciona el que se ajuste a tu dispositivo. + Información + Reintentar + Detectado automáticamente: %1$s + Seleccionar idioma \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml index 221c7236..b94fd0e9 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -489,4 +489,56 @@ 6h 12h 24h + + Ajouter par lien + Lier l\'app au dépôt + Choisissez une app installée à lier à un dépôt GitHub + Rechercher des apps… + URL du dépôt GitHub + github.com/owner/repo + Validation… + Lier et suivre + Vérification de la dernière version… + Téléchargement de l\'APK pour vérification… + Vérification de la clé de signature… + Nom de paquet différent : l\'APK est %1$s, mais l\'app sélectionnée est %2$s + Clé de signature différente : l\'APK de ce dépôt a été signé par un autre développeur + Sélectionner l\'installateur + Choisissez l\'APK à vérifier avec votre app installée + Échec du téléchargement + Exporter + Importer + Importer des apps + Collez le JSON exporté pour restaurer vos apps suivies + Collez le JSON exporté ici… + Inclure les pré-versions + Suivre les versions pré-release lors de la vérification des mises à jour. Désactivé, seules les versions stables sont prises en compte. + Désinstaller l\'app ? + Êtes-vous sûr de vouloir désinstaller %1$s ? Cette action est irréversible et les données de l\'app pourraient être perdues. + URL GitHub invalide. Utilisez le format : github.com/owner/repo + Dépôt introuvable : %1$s/%2$s + Limite de l\'API GitHub dépassée. Réessayez plus tard. + Échec de la liaison : %1$s + Échec du chargement des apps installées + %1$s liée à %2$s/%3$s + Échec de l\'exportation : %1$s + Échec de l\'importation : %1$s + %1$d apps importées + , %1$d ignorées + , %1$d échouées + Clé de signature modifiée + Le certificat de signature de cette app a changé depuis sa première installation.\n\nCela peut signifier que le développeur a changé sa clé de signature, ou que le binaire a été altéré.\n\nAttendu : %1$s\nReçu : %2$s + Installer quand même + Build vérifié + Vérification\u2026 + Ressources + Aucune ressource + Aucune ressource associée à cette version + Sélectionner une option de ressource + Plusieurs ressources disponibles + Plusieurs fichiers installables sont disponibles pour cette version. Veuillez examiner la liste et sélectionner celui qui correspond à votre appareil. + Informations + Réessayer + Détection automatique : %1$s + Sélectionner la langue \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml index 6a52cd3f..df55b2c0 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -527,4 +527,56 @@ 6घ 12घ 24घ + + लिंक से जोड़ें + ऐप को रिपॉजिटरी से लिंक करें + GitHub रिपॉजिटरी से लिंक करने के लिए एक इंस्टॉल किया हुआ ऐप चुनें + ऐप्स खोजें… + GitHub रिपॉजिटरी URL + github.com/owner/repo + सत्यापन हो रहा है… + लिंक करें और ट्रैक करें + नवीनतम रिलीज़ की जाँच हो रही है… + सत्यापन के लिए APK डाउनलोड हो रहा है… + साइनिंग कुंजी सत्यापित हो रही है… + पैकेज नाम मेल नहीं खाता: APK %1$s है, लेकिन चयनित ऐप %2$s है + साइनिंग कुंजी मेल नहीं खाती: इस रिपॉजिटरी का APK किसी अन्य डेवलपर द्वारा हस्ताक्षरित है + इंस्टॉलर चुनें + अपने इंस्टॉल किए गए ऐप से मिलान करने के लिए APK चुनें + डाउनलोड विफल + निर्यात + आयात + ऐप्स आयात करें + अपने ट्रैक किए गए ऐप्स को पुनर्स्थापित करने के लिए निर्यात किया गया JSON पेस्ट करें + निर्यात किया गया JSON यहाँ पेस्ट करें… + प्री-रिलीज़ शामिल करें + अपडेट की जाँच करते समय प्री-रिलीज़ संस्करणों को ट्रैक करें। अक्षम होने पर, केवल स्थिर रिलीज़ पर विचार किया जाता है। + ऐप अनइंस्टॉल करें? + क्या आप वाकई %1$s को अनइंस्टॉल करना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती और ऐप डेटा खो सकता है। + अमान्य GitHub URL। प्रारूप का उपयोग करें: github.com/owner/repo + रिपॉजिटरी नहीं मिली: %1$s/%2$s + GitHub API दर सीमा पार हो गई। बाद में पुनः प्रयास करें। + लिंक करने में विफल: %1$s + इंस्टॉल किए गए ऐप्स लोड करने में विफल + %1$s को %2$s/%3$s से लिंक किया गया + निर्यात विफल: %1$s + आयात विफल: %1$s + %1$d ऐप्स आयात किए गए + , %1$d छोड़े गए + , %1$d विफल + साइनिंग कुंजी बदल गई + इस ऐप का साइनिंग प्रमाणपत्र पहली बार इंस्टॉल होने के बाद बदल गया है।\n\nइसका मतलब हो सकता है कि डेवलपर ने अपनी साइनिंग कुंजी बदल दी, या बाइनरी के साथ छेड़छाड़ की गई हो।\n\nअपेक्षित: %1$s\nप्राप्त: %2$s + फिर भी इंस्टॉल करें + सत्यापित बिल्ड + जाँच हो रही है\u2026 + संसाधन + कोई संसाधन नहीं + इस रिलीज़ से संबंधित कोई संसाधन नहीं + संसाधन विकल्प चुनें + कई संसाधन उपलब्ध + इस रिलीज़ के लिए कई इंस्टॉल करने योग्य फ़ाइलें उपलब्ध हैं। कृपया सूची की समीक्षा करें और अपने डिवाइस के लिए उपयुक्त फ़ाइल चुनें। + जानकारी + पुनः प्रयास + स्वतः पहचाना गया: %1$s + भाषा चुनें diff --git a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml index 9bc5809d..6c1fc726 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -527,4 +527,56 @@ 6h 12h 24h + + Aggiungi tramite link + Collega app al repository + Scegli un\'app installata da collegare a un repository GitHub + Cerca app… + URL del repository GitHub + github.com/owner/repo + Validazione… + Collega e monitora + Controllo dell\'ultima versione… + Download APK per la verifica… + Verifica della chiave di firma… + Nome pacchetto diverso: l\'APK è %1$s, ma l\'app selezionata è %2$s + Chiave di firma diversa: l\'APK di questo repository è stato firmato da uno sviluppatore diverso + Seleziona installatore + Scegli l\'APK da verificare con la tua app installata + Download fallito + Esporta + Importa + Importa app + Incolla il JSON esportato per ripristinare le app monitorate + Incolla il JSON esportato qui… + Includi pre-release + Monitora le versioni pre-release durante il controllo aggiornamenti. Se disabilitato, vengono considerate solo le versioni stabili. + Disinstallare l\'app? + Sei sicuro di voler disinstallare %1$s? Questa azione non può essere annullata e i dati dell\'app potrebbero andare persi. + URL GitHub non valido. Usa il formato: github.com/owner/repo + Repository non trovato: %1$s/%2$s + Limite API GitHub superato. Riprova più tardi. + Collegamento fallito: %1$s + Impossibile caricare le app installate + %1$s collegata a %2$s/%3$s + Esportazione fallita: %1$s + Importazione fallita: %1$s + %1$d app importate + , %1$d saltate + , %1$d fallite + Chiave di firma cambiata + Il certificato di firma di questa app è cambiato dalla prima installazione.\n\nQuesto potrebbe significare che lo sviluppatore ha cambiato la chiave di firma, o il binario potrebbe essere stato alterato.\n\nPrevisto: %1$s\nRicevuto: %2$s + Installa comunque + Build verificata + Controllo\u2026 + Risorse + Nessuna risorsa + Nessuna risorsa associata a questa versione + Seleziona opzione risorsa + Risorse multiple disponibili + Ci sono più file installabili disponibili per questa versione. Controlla la lista e seleziona quello adatto al tuo dispositivo. + Informazioni + Riprova + Rilevato automaticamente: %1$s + Seleziona lingua \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml index ebfd58b7..89f812d2 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -489,4 +489,56 @@ 6時間 12時間 24時間 + + リンクで追加 + アプリをリポジトリにリンク + GitHubリポジトリにリンクするインストール済みアプリを選択 + アプリを検索… + GitHubリポジトリURL + github.com/owner/repo + 検証中… + リンクして追跡 + 最新リリースを確認中… + 検証用APKをダウンロード中… + 署名キーを検証中… + パッケージ名が一致しません:APKは%1$sですが、選択されたアプリは%2$sです + 署名キーが一致しません:このリポジトリのAPKは別の開発者によって署名されています + インストーラーを選択 + インストール済みアプリと照合するAPKを選択 + ダウンロード失敗 + エクスポート + インポート + アプリをインポート + エクスポートしたJSONを貼り付けて追跡中のアプリを復元 + エクスポートしたJSONをここに貼り付け… + プレリリースを含める + アップデート確認時にプレリリース版を追跡します。無効の場合、安定版リリースのみが対象となります。 + アプリをアンインストールしますか? + %1$sをアンインストールしてよろしいですか?この操作は元に戻せず、アプリのデータが失われる可能性があります。 + 無効なGitHub URL。形式を使用してください:github.com/owner/repo + リポジトリが見つかりません:%1$s/%2$s + GitHub APIのレート制限を超えました。後でもう一度お試しください。 + リンクに失敗:%1$s + インストール済みアプリの読み込みに失敗 + %1$sを%2$s/%3$sにリンクしました + エクスポート失敗:%1$s + インポート失敗:%1$s + %1$d個のアプリをインポート + 、%1$d個スキップ + 、%1$d個失敗 + 署名キーが変更されました + このアプリの署名証明書が初回インストール以降に変更されました。\n\nこれは開発者が署名キーを変更したか、バイナリが改ざんされた可能性があります。\n\n期待値:%1$s\n受信値:%2$s + それでもインストール + 検証済みビルド + 確認中\u2026 + アセット + アセットなし + このリリースに関連するアセットはありません + アセットオプションを選択 + 複数のアセットが利用可能 + このリリースには複数のインストール可能なファイルがあります。リストを確認し、お使いのデバイスに合ったものを選択してください。 + 情報 + 再試行 + 自動検出:%1$s + 言語を選択 \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml index a17f1b82..9086f601 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -524,4 +524,56 @@ 6시간 12시간 24시간 + + 링크로 추가 + 앱을 저장소에 연결 + GitHub 저장소에 연결할 설치된 앱을 선택하세요 + 앱 검색… + GitHub 저장소 URL + github.com/owner/repo + 확인 중… + 연결 및 추적 + 최신 릴리스 확인 중… + 확인용 APK 다운로드 중… + 서명 키 확인 중… + 패키지 이름 불일치: APK는 %1$s이지만 선택된 앱은 %2$s입니다 + 서명 키 불일치: 이 저장소의 APK는 다른 개발자가 서명했습니다 + 설치 파일 선택 + 설치된 앱과 대조할 APK를 선택하세요 + 다운로드 실패 + 내보내기 + 가져오기 + 앱 가져오기 + 내보낸 JSON을 붙여넣어 추적 중인 앱을 복원하세요 + 내보낸 JSON을 여기에 붙여넣기… + 사전 릴리스 포함 + 업데이트 확인 시 사전 릴리스 버전을 추적합니다. 비활성화하면 안정적인 릴리스만 고려됩니다. + 앱을 제거하시겠습니까? + %1$s을(를) 제거하시겠습니까? 이 작업은 취소할 수 없으며 앱 데이터가 손실될 수 있습니다. + 잘못된 GitHub URL입니다. 형식: github.com/owner/repo + 저장소를 찾을 수 없음: %1$s/%2$s + GitHub API 요청 한도를 초과했습니다. 나중에 다시 시도하세요. + 연결 실패: %1$s + 설치된 앱을 불러오지 못했습니다 + %1$s이(가) %2$s/%3$s에 연결됨 + 내보내기 실패: %1$s + 가져오기 실패: %1$s + %1$d개의 앱을 가져왔습니다 + , %1$d개 건너뜀 + , %1$d개 실패 + 서명 키가 변경됨 + 이 앱의 서명 인증서가 처음 설치된 이후 변경되었습니다.\n\n개발자가 서명 키를 교체했거나 바이너리가 변조되었을 수 있습니다.\n\n예상: %1$s\n수신: %2$s + 그래도 설치 + 검증된 빌드 + 확인 중\u2026 + 에셋 + 에셋 없음 + 이 릴리스에 연관된 에셋이 없습니다 + 에셋 옵션 선택 + 여러 에셋 사용 가능 + 이 릴리스에 여러 설치 가능한 파일이 있습니다. 목록을 검토하고 기기에 맞는 파일을 선택하세요. + 정보 + 재시도 + 자동 감지: %1$s + 언어 선택 \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml index b2dcb959..4bcb555f 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -491,4 +491,56 @@ 6g 12g 24g + + Dodaj przez link + Połącz aplikację z repozytorium + Wybierz zainstalowaną aplikację, aby połączyć ją z repozytorium GitHub + Szukaj aplikacji… + URL repozytorium GitHub + github.com/owner/repo + Weryfikacja… + Połącz i śledź + Sprawdzanie najnowszego wydania… + Pobieranie APK do weryfikacji… + Weryfikacja klucza podpisu… + Niezgodność nazwy pakietu: APK to %1$s, ale wybrana aplikacja to %2$s + Niezgodność klucza podpisu: APK z tego repozytorium został podpisany przez innego programistę + Wybierz instalator + Wybierz APK do weryfikacji z zainstalowaną aplikacją + Pobieranie nie powiodło się + Eksportuj + Importuj + Importuj aplikacje + Wklej wyeksportowany JSON, aby przywrócić śledzone aplikacje + Wklej wyeksportowany JSON tutaj… + Uwzględnij wersje wstępne + Śledź wersje wstępne podczas sprawdzania aktualizacji. Po wyłączeniu uwzględniane są tylko stabilne wydania. + Odinstalować aplikację? + Czy na pewno chcesz odinstalować %1$s? Tej czynności nie można cofnąć, a dane aplikacji mogą zostać utracone. + Nieprawidłowy URL GitHub. Użyj formatu: github.com/owner/repo + Repozytorium nie znaleziono: %1$s/%2$s + Przekroczono limit zapytań GitHub API. Spróbuj później. + Nie udało się połączyć: %1$s + Nie udało się załadować zainstalowanych aplikacji + %1$s połączono z %2$s/%3$s + Eksport nie powiódł się: %1$s + Import nie powiódł się: %1$s + Zaimportowano %1$d aplikacji + , %1$d pominięto + , %1$d nie powiodło się + Klucz podpisu zmieniony + Certyfikat podpisu tej aplikacji zmienił się od pierwszej instalacji.\n\nMoże to oznaczać, że programista zmienił klucz podpisu lub plik binarny mógł zostać zmodyfikowany.\n\nOczekiwano: %1$s\nOtrzymano: %2$s + Zainstaluj mimo to + Zweryfikowana kompilacja + Sprawdzanie\u2026 + Zasoby + Brak zasobów + Brak zasobów powiązanych z tym wydaniem + Wybierz opcję zasobu + Dostępnych wiele zasobów + Dla tego wydania dostępnych jest wiele plików do zainstalowania. Przejrzyj listę i wybierz odpowiedni dla swojego urządzenia. + Informacje + Ponów + Wykryto automatycznie: %1$s + Wybierz język \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml index 9894bfd2..e2a45f14 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -491,4 +491,56 @@ 12ч 24ч + + Добавить по ссылке + Привязать приложение к репозиторию + Выберите установленное приложение для привязки к репозиторию GitHub + Поиск приложений… + URL репозитория GitHub + github.com/owner/repo + Проверка… + Привязать и отслеживать + Проверка последнего релиза… + Загрузка APK для проверки… + Проверка ключа подписи… + Несоответствие имени пакета: APK — %1$s, а выбранное приложение — %2$s + Несоответствие ключа подписи: APK в этом репозитории подписан другим разработчиком + Выберите установщик + Выберите APK для проверки соответствия установленному приложению + Ошибка загрузки + Экспорт + Импорт + Импорт приложений + Вставьте экспортированный JSON для восстановления отслеживаемых приложений + Вставьте экспортированный JSON… + Включить пре-релизы + Отслеживать пре-релизные версии при проверке обновлений. При отключении учитываются только стабильные релизы. + Удалить приложение? + Вы уверены, что хотите удалить %1$s? Это действие нельзя отменить, данные приложения могут быть утеряны. + Неверный URL GitHub. Используйте формат: github.com/owner/repo + Репозиторий не найден: %1$s/%2$s + Превышен лимит запросов GitHub API. Попробуйте позже. + Не удалось привязать: %1$s + Не удалось загрузить установленные приложения + %1$s привязано к %2$s/%3$s + Ошибка экспорта: %1$s + Ошибка импорта: %1$s + Импортировано %1$d приложений + , %1$d пропущено + , %1$d с ошибкой + Ключ подписи изменён + Сертификат подписи этого приложения изменился с момента первой установки.\n\nЭто может означать, что разработчик сменил ключ подписи, или бинарный файл был изменён.\n\nОжидалось: %1$s\nПолучено: %2$s + Всё равно установить + Проверенная сборка + Проверка\u2026 + Ресурсы + Нет ресурсов + Нет ресурсов, связанных с этим релизом + Выбрать вариант ресурса + Доступно несколько ресурсов + Для этого релиза доступно несколько устанавливаемых файлов. Просмотрите список и выберите подходящий для вашего устройства. + Информация + Повторить + Автоопределение: %1$s + Выбрать язык \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml index aa44eea6..bf8806a4 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -525,4 +525,56 @@ 6s 12s 24s + + Bağlantıyla ekle + Uygulamayı depoya bağla + GitHub deposuna bağlamak için yüklü bir uygulama seçin + Uygulama ara… + GitHub depo URL\'si + github.com/owner/repo + Doğrulanıyor… + Bağla ve takip et + Son sürüm kontrol ediliyor… + Doğrulama için APK indiriliyor… + İmza anahtarı doğrulanıyor… + Paket adı uyuşmuyor: APK %1$s, ancak seçilen uygulama %2$s + İmza anahtarı uyuşmuyor: bu depodaki APK farklı bir geliştirici tarafından imzalanmış + Yükleyici seçin + Yüklü uygulamanızla doğrulamak için APK seçin + İndirme başarısız + Dışa aktar + İçe aktar + Uygulamaları içe aktar + Takip edilen uygulamaları geri yüklemek için dışa aktarılan JSON\'u yapıştırın + Dışa aktarılan JSON\'u buraya yapıştırın… + Ön sürümleri dahil et + Güncelleme kontrolünde ön sürümleri takip edin. Devre dışı bırakıldığında, yalnızca kararlı sürümler dikkate alınır. + Uygulama kaldırılsın mı? + %1$s uygulamasını kaldırmak istediğinizden emin misiniz? Bu işlem geri alınamaz ve uygulama verileri kaybolabilir. + Geçersiz GitHub URL\'si. Biçim: github.com/owner/repo + Depo bulunamadı: %1$s/%2$s + GitHub API istek sınırı aşıldı. Daha sonra tekrar deneyin. + Bağlama başarısız: %1$s + Yüklü uygulamalar yüklenemedi + %1$s, %2$s/%3$s ile bağlandı + Dışa aktarma başarısız: %1$s + İçe aktarma başarısız: %1$s + %1$d uygulama içe aktarıldı + , %1$d atlandı + , %1$d başarısız + İmza anahtarı değişti + Bu uygulamanın imza sertifikası ilk kurulumdan bu yana değişti.\n\nBu, geliştiricinin imza anahtarını değiştirdiği veya dosyanın değiştirilmiş olabileceği anlamına gelebilir.\n\nBeklenen: %1$s\nAlınan: %2$s + Yine de yükle + Doğrulanmış yapı + Kontrol ediliyor\u2026 + Dosyalar + Dosya yok + Bu sürümle ilişkili dosya yok + Dosya seçeneği belirleyin + Birden fazla dosya mevcut + Bu sürüm için birden fazla kurulabilir dosya mevcut. Listeyi inceleyin ve cihazınıza uygun olanı seçin. + Bilgi + Tekrar dene + Otomatik algılanan: %1$s + Dil seçin diff --git a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml index 4a8476ab..889080b0 100644 --- a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml +++ b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml @@ -490,4 +490,56 @@ 6小时 12小时 24小时 + + 通过链接添加 + 将应用链接到仓库 + 选择一个已安装的应用以链接到GitHub仓库 + 搜索应用… + GitHub仓库URL + github.com/owner/repo + 验证中… + 链接并追踪 + 检查最新版本… + 正在下载APK进行验证… + 正在验证签名密钥… + 包名不匹配:APK为%1$s,但所选应用为%2$s + 签名密钥不匹配:此仓库中的APK由不同的开发者签名 + 选择安装包 + 选择APK以与已安装的应用进行验证 + 下载失败 + 导出 + 导入 + 导入应用 + 粘贴导出的JSON以恢复跟踪的应用 + 在此粘贴导出的JSON… + 包含预发布版本 + 检查更新时跟踪预发布版本。禁用后,仅考虑稳定版本。 + 卸载应用? + 确定要卸载%1$s吗?此操作无法撤销,应用数据可能会丢失。 + 无效的GitHub URL。请使用格式:github.com/owner/repo + 未找到仓库:%1$s/%2$s + GitHub API请求限制已超出。请稍后重试。 + 链接失败:%1$s + 无法加载已安装的应用 + %1$s已链接到%2$s/%3$s + 导出失败:%1$s + 导入失败:%1$s + 已导入%1$d个应用 + ,%1$d个已跳过 + ,%1$d个失败 + 签名密钥已更改 + 此应用的签名证书自首次安装以来已更改。\n\n这可能意味着开发者更换了签名密钥,或者二进制文件可能已被篡改。\n\n预期:%1$s\n收到:%2$s + 仍然安装 + 已验证的构建 + 检查中\u2026 + 资源 + 无资源 + 此版本没有关联的资源 + 选择资源选项 + 多个资源可用 + 此版本有多个可安装文件。请查看列表并选择适合您设备的文件。 + 信息 + 重试 + 自动检测:%1$s + 选择语言 \ No newline at end of file