From 46ce066fc2a182fdf34defacd5246d170bbf37c0 Mon Sep 17 00:00:00 2001 From: Lin Zhang Date: Tue, 6 Oct 2026 18:52:31 +0800 Subject: [PATCH] Support app updates for direct Android distribution --- .github/workflows/release.yml | 14 +- .github/workflows/tests.yml | 4 +- AGENTS.md | 16 +- app/android/build.gradle.kts | 12 +- app/android/src/direct/AndroidManifest.xml | 19 ++ .../ketch/app/android/AndroidUpdater.kt | 166 ++++++++++++ .../linroid/ketch/app/android/AppUpdates.kt | 91 +++++++ .../app/android/UpdateInstallActivity.kt | 66 +++++ app/android/src/direct/res/values/strings.xml | 4 + .../src/direct/res/xml/update_paths.xml | 4 + .../linroid/ketch/app/android/MainActivity.kt | 24 +- .../linroid/ketch/app/android/AppUpdates.kt | 13 + .../ketch/app/android/AndroidUpdaterTest.kt | 255 ++++++++++++++++++ .../ketch/app/android/UpdateApkTest.kt | 45 ++++ .../ketch/app/platform/AppUpdateNotices.kt | 35 +++ .../linroid/ketch/app/platform/AppUpdates.kt | 4 +- .../linroid/ketch/config/DesktopSettings.kt | 2 +- docs/app-store-listing.md | 3 +- docs/development/testing.md | 17 ++ docs/updates.md | 48 +++- .../com/linroid/ketch/updater/Release.kt | 5 + .../com/linroid/ketch/updater/ReleaseTest.kt | 11 + 22 files changed, 828 insertions(+), 30 deletions(-) create mode 100644 app/android/src/direct/AndroidManifest.xml create mode 100644 app/android/src/direct/kotlin/com/linroid/ketch/app/android/AndroidUpdater.kt create mode 100644 app/android/src/direct/kotlin/com/linroid/ketch/app/android/AppUpdates.kt create mode 100644 app/android/src/direct/kotlin/com/linroid/ketch/app/android/UpdateInstallActivity.kt create mode 100644 app/android/src/direct/res/values/strings.xml create mode 100644 app/android/src/direct/res/xml/update_paths.xml create mode 100644 app/android/src/play/kotlin/com/linroid/ketch/app/android/AppUpdates.kt create mode 100644 app/android/src/testDirect/kotlin/com/linroid/ketch/app/android/AndroidUpdaterTest.kt create mode 100644 app/android/src/testDirect/kotlin/com/linroid/ketch/app/android/UpdateApkTest.kt create mode 100644 app/shared/src/commonMain/kotlin/com/linroid/ketch/app/platform/AppUpdateNotices.kt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3866013d..03100d214 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,28 +143,28 @@ jobs: ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | - ./gradlew :app:android:assembleRelease -PVERSION_NAME=${KETCH_VERSION#v} \ + ./gradlew :app:android:assembleDirectRelease -PVERSION_NAME=${KETCH_VERSION#v} \ -PversionCode=${{ github.run_number }} - name: Rename APK run: | - APK=$(find app/android/build/outputs/apk/release -name '*.apk' | head -1) - cp "$APK" app/android/build/outputs/apk/release/ketch-android-${KETCH_VERSION#v}.apk + APK=$(find app/android/build/outputs/apk/direct/release -name '*.apk' | head -1) + cp "$APK" app/android/build/outputs/apk/direct/release/ketch-android-${KETCH_VERSION#v}.apk # R8 renames classes and methods, so stack traces from this APK can only be read with the # mapping of this exact build. The text file is >100 MB; zipped it is a few MB. - name: Package R8 mapping run: > zip -j -9 - app/android/build/outputs/apk/release/ketch-android-${KETCH_VERSION#v}-mapping.zip - app/android/build/outputs/mapping/release/mapping.txt + app/android/build/outputs/apk/direct/release/ketch-android-${KETCH_VERSION#v}-mapping.zip + app/android/build/outputs/mapping/directRelease/mapping.txt - uses: actions/upload-artifact@v7 with: name: ketch-android path: | - app/android/build/outputs/apk/release/ketch-android-*.apk - app/android/build/outputs/apk/release/ketch-android-*-mapping.zip + app/android/build/outputs/apk/direct/release/ketch-android-*.apk + app/android/build/outputs/apk/direct/release/ketch-android-*-mapping.zip if-no-files-found: error build-desktop: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7092a3ab9..e3851b4c7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -120,7 +120,9 @@ jobs: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Run Android unit tests - run: ./gradlew testDebugUnitTest :library:torrent:testAndroidHostTest :library:core:testAndroidHostTest + run: >- + ./gradlew :app:android:testDirectDebugUnitTest :app:android:testPlayDebugUnitTest + :library:torrent:testAndroidHostTest :library:core:testAndroidHostTest - name: Upload test results if: always() diff --git a/AGENTS.md b/AGENTS.md index ae0f8e7f2..da2f8aaa8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ library/ server/ # Ktor-based daemon server with REST API, SSE events and mDNS (JVM only) mcp/ # MCP server exposing KetchApi as tools for AI agents (JVM only) config/ # Multiplatform TOML-based configuration (server, download, remotes, AI, ...) -updater/ # Self-update from GitHub releases for the desktop app and the CLI (JVM only) +updater/ # GitHub release updates for desktop, direct Android and CLI (JVM module) ai/ discover/ # LLM agent-driven resource discovery (JVM only, Koog framework) app/ @@ -110,7 +110,7 @@ cli/ # CLI: downloads plus `server`, `mcp` and `ai-discover` (JVM; Graa `PriorityRequest`, `ConnectionsRequest`, `PairingRequest`, `PairingTicket`, `PairingStatus`, `PairingState` -### `updater` (JVM only) +### `updater` (JVM module, also consumed by direct Android) - `com.linroid.ketch.updater` -- `ReleaseVersion`, `Release`, `ReleaseAsset`, `ReleaseProduct`, `ReleasePlatform`, `ReleaseFeed`, `GitHubReleases`, `ReleaseDownloader`, `UpdateException`, `extractArchive()` @@ -500,6 +500,13 @@ cli/ # CLI: downloads plus `server`, `mcp` and `ai-discover` (JVM; Graa is no tray). The tray lists every device with its own actions, and the macOS menu bar, the tray and the Dock menu are generated from `KetchCommands` (`DesktopMenuBar`, `DesktopTray`, `TaskbarFeedback` for the Dock and taskbar badge and progress) +- Self-update (Android): `direct` and `play` distribution flavors; only `direct` supplies + `AndroidUpdater` through `LocalAppUpdates` in Settings → About. It reuses `GitHubReleases` + and `ReleaseDownloader`, checks the APK package, release version and increasing version code, + and hands installation to Android through a private activity and FileProvider. Only this + flavor declares `REQUEST_INSTALL_PACKAGES`. Release builds check daily while the main screen's + model lives, using the existing `[desktop] checkForUpdates`; debug builds only check manually. + GitHub ships `assembleDirectRelease`; Play uses `bundlePlayRelease`. See [updates](docs/updates.md). - Self-update (desktop): `DesktopUpdater` implements the shared `AppUpdates` (`LocalAppUpdates`, shown in Settings → About and the macOS Help menu); it checks GitHub daily while `[desktop] checkForUpdates` is on, downloads this system's installer with the `updater` @@ -585,8 +592,9 @@ cli/ # CLI: downloads plus `server`, `mcp` and `ai-discover` (JVM; Graa provider to verify changes ### Self-update (`updater`) -- Shared by the desktop app and the CLI: `GitHubReleases` reads the latest (or a tagged) release - from the GitHub API, `Release.asset` picks the file by the release workflow's names, and +- Shared by desktop, direct Android and the CLI: `GitHubReleases` reads the latest (or a tagged) + release from the GitHub API, `Release.asset` / `androidAsset` pick the file by the workflow's + names, and `ReleaseDownloader` downloads it with a private Ketch engine and checks the SHA-256 digest GitHub publishes per asset (files without one are refused). Pass it the process's logger: every `Ketch` installs its logger globally diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts index ae480a58d..92146d7aa 100644 --- a/app/android/build.gradle.kts +++ b/app/android/build.gradle.kts @@ -26,6 +26,12 @@ android { versionCode = providers.gradleProperty("versionCode").orElse("1").get().toInt() } + flavorDimensions += "distribution" + productFlavors { + create("direct") { dimension = "distribution" } + create("play") { dimension = "distribution" } + } + signingConfigs { val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE") if (keystoreFile != null) { @@ -59,6 +65,7 @@ android { buildFeatures { compose = true + buildConfig = true } // Android 13+ lists the languages of res/values-* in Ketch's language setting. Each translation @@ -92,11 +99,14 @@ android { // Koog, kotlinx-schema and Ktor's server depend on kotlin-reflect for features the app does not // use (reflective tool sets and schemas, loading server modules by name). Its R8 rules keep most // of it, so release builds leave it out. -configurations.matching { it.name == "releaseRuntimeClasspath" }.configureEach { +configurations.matching { it.name.endsWith("ReleaseRuntimeClasspath") }.configureEach { exclude(group = "org.jetbrains.kotlin", module = "kotlin-reflect") } dependencies { + "directImplementation"(projects.updater) + testImplementation(libs.kotlin.testJunit) + testImplementation(libs.kotlinx.coroutines.test) implementation(projects.config) implementation(projects.app.shared) implementation(projects.ai.discover) diff --git a/app/android/src/direct/AndroidManifest.xml b/app/android/src/direct/AndroidManifest.xml new file mode 100644 index 000000000..ecca98b0f --- /dev/null +++ b/app/android/src/direct/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/app/android/src/direct/kotlin/com/linroid/ketch/app/android/AndroidUpdater.kt b/app/android/src/direct/kotlin/com/linroid/ketch/app/android/AndroidUpdater.kt new file mode 100644 index 000000000..756c58c3b --- /dev/null +++ b/app/android/src/direct/kotlin/com/linroid/ketch/app/android/AndroidUpdater.kt @@ -0,0 +1,166 @@ +package com.linroid.ketch.app.android + +import com.linroid.ketch.api.DownloadProgress +import com.linroid.ketch.api.log.KetchLogger +import com.linroid.ketch.api.log.describeCauses +import com.linroid.ketch.app.platform.AppUpdateState +import com.linroid.ketch.app.platform.AppUpdateStep +import com.linroid.ketch.app.platform.AppUpdates +import com.linroid.ketch.updater.Release +import com.linroid.ketch.updater.ReleaseAsset +import com.linroid.ketch.updater.ReleaseFeed +import com.linroid.ketch.updater.ReleaseVersion +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds + +/** + * Updates the direct Android build. Steps run on the main thread in the activity model's + * scope, so rotation keeps the download. The downloader verifies the digest before [validate] + * checks the APK; only an explicit [install] opens Android's installer. + */ +internal class AndroidUpdater( + private val scope: CoroutineScope, + private val feed: ReleaseFeed, + private val current: ReleaseVersion?, + private val workDir: File, + private val download: suspend (ReleaseAsset, File, (DownloadProgress) -> Unit) -> Unit, + private val validate: suspend (File, ReleaseVersion) -> Unit, + private val openInstaller: (File) -> Unit, + private val automaticChecks: Boolean = true, + private val onNotice: (AppUpdateState) -> Unit = {}, +) : AppUpdates { + private val log = KetchLogger("AndroidUpdater") + private val mutableState = MutableStateFlow(AppUpdateState.Idle) + override val state: StateFlow = mutableState.asStateFlow() + private var release: Release? = null + private var prepared: File? = null + private var step: Job? = null + private var automatic: Job? = null + + override fun check() = check(automatic = false) + + private fun check(automatic: Boolean) { + if (step?.isActive == true) return + val before = state.value + if (before != AppUpdateState.Idle && before != AppUpdateState.UpToDate && + !(before is AppUpdateState.Failed && before.step == AppUpdateStep.Check) + ) return + step = scope.launch { + mutableState.value = AppUpdateState.Checking + mutableState.value = try { + val latest = feed.latest() + when { + current == null || latest.version <= current -> AppUpdateState.UpToDate + // Releases appear before all their assets have finished uploading. + latest.androidAsset() == null -> AppUpdateState.UpToDate + else -> { + release = latest + AppUpdateState.Available(latest.version.toString(), latest.pageUrl, installable = true) + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.w { "Couldn't check for updates: ${e.describeCauses()}" } + if (automatic) before else failure(AppUpdateStep.Check, e) + } + if (automatic && state.value is AppUpdateState.Available) onNotice(state.value) + } + } + + override fun download() { + if (step?.isActive == true) return + val before = state.value + if (before !is AppUpdateState.Available && + !(before is AppUpdateState.Failed && before.step == AppUpdateStep.Download) + ) return + val release = release ?: return + val asset = release.androidAsset() ?: return + val version = release.version.toString() + prepared = null + step = scope.launch { + mutableState.value = AppUpdateState.Downloading(version, release.pageUrl, 0, asset.size) + mutableState.value = try { + // A fixed private path also limits what the installer and FileProvider can expose. + val file = File(workDir, UPDATE_APK) + download(asset, file) { progress -> + mutableState.value = AppUpdateState.Downloading( + version = version, + notesUrl = release.pageUrl, + downloadedBytes = progress.downloadedBytes, + totalBytes = progress.totalBytes.takeIf { it > 0 } ?: asset.size, + ) + } + validate(file, release.version) + prepared = file + AppUpdateState.Ready(version, release.pageUrl, restarts = false) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.w { "Couldn't download the update: ${e.describeCauses()}" } + failure(AppUpdateStep.Download, e) + } + if (state.value is AppUpdateState.Ready) onNotice(state.value) + } + } + + override fun install() { + if (step?.isActive == true) return + val file = prepared ?: return + val release = release ?: return + step = scope.launch { + try { + // Cache eviction or a changed file must not leave a permanently broken Install button. + validate(file, release.version) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + prepared = null + mutableState.value = failure(AppUpdateStep.Download, e) + return@launch + } + try { + openInstaller(file) + // Handing off is not installation success. Canceling Android's dialog allows retry. + mutableState.value = AppUpdateState.Ready( + release.version.toString(), release.pageUrl, restarts = false, + ) + } catch (e: Exception) { + log.w { "Couldn't open the installer: ${e.describeCauses()}" } + mutableState.value = failure(AppUpdateStep.Install, e) + } + } + } + + override fun setCheckAutomatically(enabled: Boolean) { + automatic?.cancel() + automatic = null + if (!enabled || !automaticChecks) return + automatic = scope.launch { + delay(30.seconds) + while (true) { + check(automatic = true) + delay(24.hours) + } + } + } + + private fun failure(step: AppUpdateStep, error: Exception) = AppUpdateState.Failed( + step = step, + reason = error.describeCauses(), + version = release?.version?.toString(), + notesUrl = release?.pageUrl, + ) +} + +internal const val UPDATE_APK = "ketch-update.apk" +internal const val UPDATES_DIR = "updates" diff --git a/app/android/src/direct/kotlin/com/linroid/ketch/app/android/AppUpdates.kt b/app/android/src/direct/kotlin/com/linroid/ketch/app/android/AppUpdates.kt new file mode 100644 index 000000000..0339fe147 --- /dev/null +++ b/app/android/src/direct/kotlin/com/linroid/ketch/app/android/AppUpdates.kt @@ -0,0 +1,91 @@ +package com.linroid.ketch.app.android + +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.content.pm.PackageInfoCompat +import com.linroid.ketch.api.log.LogLevel +import com.linroid.ketch.api.log.Logger +import com.linroid.ketch.app.feedback.MessageCenter +import com.linroid.ketch.app.platform.AppUpdates +import com.linroid.ketch.app.platform.postAppUpdateNotice +import com.linroid.ketch.engine.KtorHttpEngine +import com.linroid.ketch.updater.GitHubReleases +import com.linroid.ketch.updater.ReleaseDownloader +import com.linroid.ketch.updater.ReleaseVersion +import com.linroid.ketch.updater.UpdateException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +/** The Play source set supplies a null provider instead, with no updater dependency. */ +internal fun createAppUpdates( + scope: CoroutineScope, + app: KetchApplication, + messages: MessageCenter, +): AppUpdates { + val downloader = ReleaseDownloader( + httpEngine = { KtorHttpEngine() }, + logger = Logger.combine(Logger.console(LogLevel.DEBUG), app.fileLogger), + ) + lateinit var updater: AndroidUpdater + updater = AndroidUpdater( + scope = scope, + feed = GitHubReleases(httpEngine = { KtorHttpEngine() }), + current = ReleaseVersion.parse(BuildConfig.VERSION_NAME), + workDir = File(app.cacheDir, UPDATES_DIR), + download = downloader::download, + validate = { file, version -> + withContext(Dispatchers.IO) { + validateUpdateApk(app.packageManager, app.packageName, file, version) + } + }, + openInstaller = { + app.startActivity( + Intent(app, UpdateInstallActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + }, + automaticChecks = !BuildConfig.DEBUG, + onNotice = { state -> postAppUpdateNotice(state, messages, updater) }, + ) + updater.setCheckAutomatically(app.configStore.load().desktop.checkForUpdates) + return updater +} + +/** Android itself verifies the signing certificate when replacing the installed package. */ +@Suppress("DEPRECATION") +internal fun validateUpdateApk( + manager: PackageManager, + packageName: String, + file: File, + version: ReleaseVersion, +) { + val archive = manager.getPackageArchiveInfo(file.path, 0) + ?: throw UpdateException("The downloaded APK could not be read") + val installed = manager.getPackageInfo(packageName, 0) + requireUpdateApk( + packageName = packageName, + installedCode = PackageInfoCompat.getLongVersionCode(installed), + archivePackage = archive.packageName, + archiveCode = PackageInfoCompat.getLongVersionCode(archive), + archiveVersion = archive.versionName, + version = version, + ) +} + +/** Requires the requested release of this package and an Android upgrade, never a downgrade. */ +internal fun requireUpdateApk( + packageName: String, + installedCode: Long, + archivePackage: String, + archiveCode: Long, + archiveVersion: String?, + version: ReleaseVersion, +) { + if (archivePackage != packageName || archiveVersion?.let(ReleaseVersion::parse) != version) { + throw UpdateException("The downloaded APK does not match this app and release") + } + if (archiveCode <= installedCode) { + throw UpdateException("The downloaded APK is not newer than the installed build") + } +} diff --git a/app/android/src/direct/kotlin/com/linroid/ketch/app/android/UpdateInstallActivity.kt b/app/android/src/direct/kotlin/com/linroid/ketch/app/android/UpdateInstallActivity.kt new file mode 100644 index 000000000..1f76e2093 --- /dev/null +++ b/app/android/src/direct/kotlin/com/linroid/ketch/app/android/UpdateInstallActivity.kt @@ -0,0 +1,66 @@ +package com.linroid.ketch.app.android + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.FileProvider +import com.linroid.ketch.api.log.KetchLogger +import com.linroid.ketch.api.log.describeCauses +import java.io.File + +/** + * Private handoff to Android's installer. Activity result registration survives rotation and + * process recreation while the user grants permission; denial never opens the installer. + */ +internal class UpdateInstallActivity : ComponentActivity() { + private val permission = registerForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { + if (packageManager.canRequestPackageInstalls()) openInstaller() else finish() + } + private val installer = registerForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { finish() } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (savedInstanceState != null) return + attempt { + if (packageManager.canRequestPackageInstalls()) { + openInstaller() + } else { + permission.launch( + Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName")), + ) + } + } + } + + private fun openInstaller() = attempt { + val file = File(cacheDir, "$UPDATES_DIR/$UPDATE_APK") + check(file.isFile) { "The downloaded APK is no longer in the cache" } + val uri = FileProvider.getUriForFile(this, "$packageName.updates", file) + installer.launch( + Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "application/vnd.android.package-archive") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION), + ) + } + + private fun attempt(action: () -> Unit) { + try { + action() + } catch (e: Exception) { + KetchLogger("UpdateInstaller").w { "Couldn't open the installer: ${e.describeCauses()}" } + Toast.makeText(this, R.string.update_install_failed, Toast.LENGTH_LONG).show() + finish() + } + } +} + +/** Only the direct build's private update cache is exposed, with temporary read grants. */ +internal class UpdateFileProvider : FileProvider() diff --git a/app/android/src/direct/res/values/strings.xml b/app/android/src/direct/res/values/strings.xml new file mode 100644 index 000000000..290bcd46b --- /dev/null +++ b/app/android/src/direct/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Couldn\'t open the installer. Return to About and try again. + diff --git a/app/android/src/direct/res/xml/update_paths.xml b/app/android/src/direct/res/xml/update_paths.xml new file mode 100644 index 000000000..716a07715 --- /dev/null +++ b/app/android/src/direct/res/xml/update_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/android/src/main/kotlin/com/linroid/ketch/app/android/MainActivity.kt b/app/android/src/main/kotlin/com/linroid/ketch/app/android/MainActivity.kt index 11db0bad6..c1222c2df 100644 --- a/app/android/src/main/kotlin/com/linroid/ketch/app/android/MainActivity.kt +++ b/app/android/src/main/kotlin/com/linroid/ketch/app/android/MainActivity.kt @@ -20,6 +20,7 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.annotation.RequiresApi +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -29,6 +30,7 @@ import androidx.core.content.edit import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewModelScope import androidx.lifecycle.withStarted import com.linroid.ketch.api.log.KetchLogger import com.linroid.ketch.api.log.describeCauses @@ -39,6 +41,7 @@ import com.linroid.ketch.app.feedback.MessageTap import com.linroid.ketch.app.feedback.NotificationLink import com.linroid.ketch.app.i18n.appLanguageContext import com.linroid.ketch.app.instance.InstanceManager +import com.linroid.ketch.app.platform.LocalAppUpdates import com.linroid.ketch.app.state.AiDiscoverController import com.linroid.ketch.app.state.AppController import com.linroid.ketch.app.state.AppState @@ -121,11 +124,13 @@ class MainActivity : ComponentActivity() { MessageNotifications.open(controller.messages, tap.id, tap.postedAt) messageTap = null } - App( - controller, - activityEvents = svc.activityEvents, - fileLogger = ketchApplication.fileLogger, - ) + CompositionLocalProvider(LocalAppUpdates provides model.updates) { + App( + controller, + activityEvents = svc.activityEvents, + fileLogger = ketchApplication.fileLogger, + ) + } } } @@ -287,6 +292,15 @@ internal class MainModel(application: Application) : AndroidViewModel(applicatio } } + /** Only the direct distribution supplies updates; retained across activity recreation. */ + val updates by lazy { + createAppUpdates( + scope = viewModelScope, + app = getApplication(), + messages = checkNotNull(controller).messages, + ) + } + private var bound = false private val connection = object : ServiceConnection { diff --git a/app/android/src/play/kotlin/com/linroid/ketch/app/android/AppUpdates.kt b/app/android/src/play/kotlin/com/linroid/ketch/app/android/AppUpdates.kt new file mode 100644 index 000000000..db210442f --- /dev/null +++ b/app/android/src/play/kotlin/com/linroid/ketch/app/android/AppUpdates.kt @@ -0,0 +1,13 @@ +package com.linroid.ketch.app.android + +import com.linroid.ketch.app.feedback.MessageCenter +import com.linroid.ketch.app.platform.AppUpdates +import kotlinx.coroutines.CoroutineScope + +/** Google Play owns updates for this distribution, even when its APK is sideloaded. */ +@Suppress("UNUSED_PARAMETER") +internal fun createAppUpdates( + scope: CoroutineScope, + app: KetchApplication, + messages: MessageCenter, +): AppUpdates? = null diff --git a/app/android/src/testDirect/kotlin/com/linroid/ketch/app/android/AndroidUpdaterTest.kt b/app/android/src/testDirect/kotlin/com/linroid/ketch/app/android/AndroidUpdaterTest.kt new file mode 100644 index 000000000..df46dcef0 --- /dev/null +++ b/app/android/src/testDirect/kotlin/com/linroid/ketch/app/android/AndroidUpdaterTest.kt @@ -0,0 +1,255 @@ +package com.linroid.ketch.app.android + +import com.linroid.ketch.api.DownloadProgress +import com.linroid.ketch.app.platform.AppUpdateState +import com.linroid.ketch.app.platform.AppUpdateStep +import com.linroid.ketch.updater.Release +import com.linroid.ketch.updater.ReleaseAsset +import com.linroid.ketch.updater.ReleaseFeed +import com.linroid.ketch.updater.ReleaseVersion +import com.linroid.ketch.updater.UpdateException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class AndroidUpdaterTest { + private val version = ReleaseVersion(0, 0, 2) + private val release = Release( + version = version, + pageUrl = "https://github.com/linroid/Ketch/releases/tag/v$version", + assets = listOf( + ReleaseAsset("ketch-android-$version.apk", "https://example.com/app.apk", 100, "00"), + ), + ) + private var latest = release + private var checkFailure: Exception? = null + private var downloadFailure: Exception? = null + private var validationFailure: Exception? = null + private var installFailure: Exception? = null + private var gate: CompletableDeferred? = null + private var checks = 0 + private var downloads = 0 + private var validations = 0 + private val installs = mutableListOf() + private val notices = mutableListOf() + + @Test + fun check_newerRelease_offersTheApkWithoutInstalling() = runTest { + val updater = available() + assertEquals(AppUpdateState.Available("0.0.2", release.pageUrl, true), updater.state.value) + assertEquals(0, downloads) + assertTrue(installs.isEmpty()) + assertTrue(notices.isEmpty()) + } + + @Test + fun check_sameOrOlderRelease_doesNotOfferDowngrade() = runTest { + for (version in listOf(ReleaseVersion(0, 0, 1), ReleaseVersion(0, 0, 0))) { + latest = release.copy(version = version) + val updater = updater() + updater.check() + runCurrent() + assertEquals(AppUpdateState.UpToDate, updater.state.value) + } + } + + @Test + fun check_apkNotUploadedYet_canCheckAgain() = runTest { + latest = release.copy(assets = emptyList()) + val updater = updater() + updater.check() + runCurrent() + assertEquals(AppUpdateState.UpToDate, updater.state.value) + latest = release + updater.check() + runCurrent() + assertIs(updater.state.value) + } + + @Test + fun check_offline_manualFailureCanRetry() = runTest { + checkFailure = UpdateException("offline") + val updater = updater() + updater.check() + runCurrent() + assertEquals(AppUpdateStep.Check, assertIs(updater.state.value).step) + checkFailure = null + updater.check() + runCurrent() + assertIs(updater.state.value) + } + + @Test + fun automaticCheck_offlineIsQuiet_andCanBeDisabled() = runTest { + checkFailure = UpdateException("offline") + val updater = updater() + updater.setCheckAutomatically(true) + advanceTimeBy(30.seconds) + runCurrent() + assertEquals(1, checks) + assertEquals(AppUpdateState.Idle, updater.state.value) + assertTrue(notices.isEmpty()) + updater.setCheckAutomatically(false) + advanceTimeBy(25.hours) + runCurrent() + assertEquals(1, checks) + } + + @Test + fun automaticCheck_releaseFound_notifiesWithoutDownloading() = runTest { + val updater = updater() + updater.setCheckAutomatically(true) + advanceTimeBy(30.seconds) + runCurrent() + assertIs(notices.single()) + assertEquals(0, downloads) + } + + @Test + fun automaticCheck_debugBuild_doesNotCheck() = runTest { + updater(automaticChecks = false).setCheckAutomatically(true) + advanceTimeBy(25.hours) + runCurrent() + assertEquals(0, checks) + } + + @Test + fun download_repeatedCommands_areSerializedAndReportProgress() = runTest { + val updater = available() + gate = CompletableDeferred() + updater.download() + updater.download() + runCurrent() + updater.check() + updater.install() + assertEquals(1, downloads) + assertEquals(1, checks) + assertTrue(installs.isEmpty()) + assertEquals(50L, assertIs(updater.state.value).downloadedBytes) + gate!!.complete(Unit) + runCurrent() + assertEquals(AppUpdateState.Ready("0.0.2", release.pageUrl, false), updater.state.value) + assertEquals(1, validations) + assertIs(notices.single()) + } + + @Test + fun download_failedChecksum_neverValidatesOrInstalls_andCanRetry() = runTest { + val updater = available() + downloadFailure = UpdateException("checksum mismatch") + updater.download() + runCurrent() + updater.install() + assertEquals(AppUpdateStep.Download, assertIs(updater.state.value).step) + assertEquals(0, validations) + assertTrue(installs.isEmpty()) + downloadFailure = null + updater.download() + runCurrent() + assertIs(updater.state.value) + } + + @Test + fun download_invalidApk_neverOpensInstaller() = runTest { + val updater = available() + validationFailure = UpdateException("wrong package") + updater.download() + runCurrent() + updater.install() + runCurrent() + assertEquals(AppUpdateStep.Download, assertIs(updater.state.value).step) + assertTrue(installs.isEmpty()) + } + + @Test + fun install_canceledSystemDialog_canBeOpenedAgain() = runTest { + val updater = ready() + updater.install() + runCurrent() + assertIs(updater.state.value) + updater.install() + runCurrent() + assertEquals(2, installs.size) + assertEquals(UPDATE_APK, installs.first().name) + assertEquals(1, downloads) + } + + @Test + fun install_cacheEvicted_offersDownloadAgain() = runTest { + val updater = ready() + validationFailure = UpdateException("missing APK") + updater.install() + runCurrent() + assertEquals(AppUpdateStep.Download, assertIs(updater.state.value).step) + assertTrue(installs.isEmpty()) + validationFailure = null + updater.download() + runCurrent() + assertIs(updater.state.value) + assertEquals(2, downloads) + } + + @Test + fun install_noHandler_reportsFailureAndRetries() = runTest { + val updater = ready() + installFailure = IllegalStateException("no installer") + updater.install() + runCurrent() + assertEquals(AppUpdateStep.Install, assertIs(updater.state.value).step) + installFailure = null + updater.install() + runCurrent() + assertEquals(1, installs.size) + } + + private fun TestScope.available(): AndroidUpdater = updater().also { + it.check() + runCurrent() + } + + private fun TestScope.ready(): AndroidUpdater = available().also { + it.download() + runCurrent() + } + + private fun TestScope.updater(automaticChecks: Boolean = true) = AndroidUpdater( + scope = backgroundScope, + feed = object : ReleaseFeed { + override suspend fun latest(): Release { + checks++ + checkFailure?.let { throw it } + return latest + } + override suspend fun release(version: ReleaseVersion): Release = error("unused") + }, + current = ReleaseVersion(0, 0, 1), + workDir = File("unused-update-cache"), + download = { asset, _, progress -> + downloads++ + downloadFailure?.let { throw it } + progress(DownloadProgress(asset.size / 2, asset.size)) + gate?.await() + }, + validate = { _, _ -> + validations++ + validationFailure?.let { throw it } + }, + openInstaller = { + installFailure?.let { throw it } + installs += it + }, + automaticChecks = automaticChecks, + onNotice = { notices += it }, + ) +} diff --git a/app/android/src/testDirect/kotlin/com/linroid/ketch/app/android/UpdateApkTest.kt b/app/android/src/testDirect/kotlin/com/linroid/ketch/app/android/UpdateApkTest.kt new file mode 100644 index 000000000..52413dc0e --- /dev/null +++ b/app/android/src/testDirect/kotlin/com/linroid/ketch/app/android/UpdateApkTest.kt @@ -0,0 +1,45 @@ +package com.linroid.ketch.app.android + +import com.linroid.ketch.updater.ReleaseVersion +import com.linroid.ketch.updater.UpdateException +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class UpdateApkTest { + @Test + fun validate_matchingReleaseWithHigherCode_accepts() { + validate() + } + + @Test + fun validate_wrongPackage_rejects() { + assertFailsWith { validate(archivePackage = "another.app") } + } + + @Test + fun validate_wrongOrMissingVersion_rejects() { + for (name in listOf("0.0.1", "invalid", null)) { + assertFailsWith { validate(archiveVersion = name) } + } + } + + @Test + fun validate_sameOrOlderAndroidCode_rejectsEvenWithNewerVersionName() { + for (code in listOf(10L, 9L)) { + assertFailsWith { validate(archiveCode = code) } + } + } + + private fun validate( + archivePackage: String = "com.linroid.ketch.app", + archiveVersion: String? = "0.0.2", + archiveCode: Long = 11, + ) = requireUpdateApk( + packageName = "com.linroid.ketch.app", + installedCode = 10, + archivePackage = archivePackage, + archiveCode = archiveCode, + archiveVersion = archiveVersion, + version = ReleaseVersion(0, 0, 2), + ) +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/platform/AppUpdateNotices.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/platform/AppUpdateNotices.kt new file mode 100644 index 000000000..7f36e33b9 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/platform/AppUpdateNotices.kt @@ -0,0 +1,35 @@ +package com.linroid.ketch.app.platform + +import com.linroid.ketch.app.feedback.MessageAction +import com.linroid.ketch.app.feedback.MessageCenter +import com.linroid.ketch.app.feedback.MessageLevel +import com.linroid.ketch.app.feedback.ToastMode +import com.linroid.ketch.app.i18n.text +import ketch.app.shared.generated.resources.Res +import ketch.app.shared.generated.resources.settings_about_update_available +import ketch.app.shared.generated.resources.settings_about_update_install +import ketch.app.shared.generated.resources.settings_about_update_open_installer +import ketch.app.shared.generated.resources.settings_about_update_ready + +/** Automatic discoveries and finished downloads stay visible until the user acts or dismisses. */ +fun postAppUpdateNotice(state: AppUpdateState, messages: MessageCenter, updates: AppUpdates): Unit { + when (state) { + is AppUpdateState.Available -> messages.post( + level = MessageLevel.Info, + title = Res.string.settings_about_update_available.text(state.version), + actions = listOf( + MessageAction(Res.string.settings_about_update_install.text(), updates::download), + ), + toast = ToastMode.Sticky, + ) + is AppUpdateState.Ready -> messages.post( + level = MessageLevel.Success, + title = Res.string.settings_about_update_ready.text(state.version), + actions = listOf( + MessageAction(Res.string.settings_about_update_open_installer.text(), updates::install), + ), + toast = ToastMode.Sticky, + ) + else -> Unit + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/platform/AppUpdates.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/platform/AppUpdates.kt index 7dbc944ea..551379272 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/platform/AppUpdates.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/platform/AppUpdates.kt @@ -67,8 +67,8 @@ sealed interface AppUpdateState { } /** - * The app updating itself to the latest release. Only the desktop app provides it, through - * [LocalAppUpdates]; app stores update the other apps. + * The app updating itself to the latest release. The desktop and direct Android builds provide it + * through [LocalAppUpdates]; app stores update the other builds. */ interface AppUpdates { /** Where the update stands. */ diff --git a/config/src/commonMain/kotlin/com/linroid/ketch/config/DesktopSettings.kt b/config/src/commonMain/kotlin/com/linroid/ketch/config/DesktopSettings.kt index a7e396cfd..69407e764 100644 --- a/config/src/commonMain/kotlin/com/linroid/ketch/config/DesktopSettings.kt +++ b/config/src/commonMain/kotlin/com/linroid/ketch/config/DesktopSettings.kt @@ -38,7 +38,7 @@ enum class DockBadgeMode { /** * Desktop app behavior, persisted under `[desktop]`. * - * Only the desktop app reads this section. + * The direct Android build also uses [checkForUpdates], keeping the existing update preference. * * @property closeAction what closing the main window does. * @property openAtLogin whether the user asked for Ketch to open at login; the diff --git a/docs/app-store-listing.md b/docs/app-store-listing.md index ebc1f796a..6f9d431a4 100644 --- a/docs/app-store-listing.md +++ b/docs/app-store-listing.md @@ -55,7 +55,8 @@ the iOS app does not offer, and torrents (see [BitTorrent](#bittorrent-on-the-ap ### Before the first release - [ ] **Upload an app bundle.** Play takes `.aab` files only, and the release workflow builds - an APK (`assembleRelease`). Add `:app:android:bundleRelease` with the same version code. + the direct APK (`assembleDirectRelease`). Build `:app:android:bundlePlayRelease` with the same + version code for Play; this flavor omits the self-updater and install-packages permission. - [ ] **Keep one signing key across channels.** Play App Signing re-signs what Play delivers. If Play generates its own key, the Play build and the GitHub APK can never update each other. Upload the existing release key as the app signing key (Play Console's "Use existing app diff --git a/docs/development/testing.md b/docs/development/testing.md index 5670140ce..dbd6e2b9d 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -66,6 +66,23 @@ runs on Windows; CI runs it in a Windows job of its own: ./gradlew :app:desktop:test --tests '*WindowsPortableScriptTest*' ``` +## Android distributions + +```shell +./gradlew :app:android:testDirectDebugUnitTest :app:android:testPlayDebugUnitTest \ + :app:android:assembleDirectDebug :app:android:assemblePlayDebug +``` + +The direct flavor's tests cover update selection, progress, retries, automatic checks, and APK +package/version validation. Shared checksum and release-feed tests run with `:updater:test`. +Check the merged manifests when changing distribution wiring: only `direct` may contain +`REQUEST_INSTALL_PACKAGES`, `UpdateInstallActivity` and `UpdateFileProvider`. + +For an installation smoke test, use two direct release APKs signed with the same key and a higher +`versionCode` in the update. Exercise permission denial and grant, rotation on the permission +screen, canceling and retrying the installer, and a successful update retaining settings/tasks. +Debug signing cannot replace a release-signed installation. See [updates](../updates.md). + ## Desktop Startup Memory The desktop launcher uses `-Xms32m -Xmx512m`. The limit covers the Java heap, not native diff --git a/docs/updates.md b/docs/updates.md index b9f09ebcd..cffb7f71c 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -1,9 +1,9 @@ # Updates -The desktop app and the native `ketch` command update themselves from the project's -[GitHub releases](https://github.com/linroid/Ketch/releases). Android, iOS, the web app and the -browser extension do not: they update through their stores, the hosted page or a new release -download. +The desktop app, direct Android build and native `ketch` command update themselves from the +project's [GitHub releases](https://github.com/linroid/Ketch/releases). The Google Play and iOS +builds, web app and browser extension update through their stores, the hosted page or a new +release download. The shared code lives in the `updater` module (`com.linroid.ketch.updater`), including `extractArchive` (`Archives.kt`), which unpacks the command's archives and the portable Windows @@ -19,8 +19,8 @@ scripts in `src/main/resources/update/`, `install-update-windows-portable.ps1` a - Versions come from the tag (`v0.0.1-rc15`) and compare as releases do: a pre-release before its release, and `rc9` before `rc15` (`ReleaseVersion`). - A release can be published before the workflow uploads its files. Until this system's file is - there, the desktop app treats the release as not out yet and the command says to try again - later. + there, the desktop and direct Android apps treat the release as not out yet; the command says + to try again later. - `Release.asset` picks the file of this system by the names the release workflow gives them: `ketch-cli---.tar.gz` (`.zip` on Windows), `ketch-desktop---.dmg`, `.msi` or `.deb`, and @@ -29,7 +29,8 @@ scripts in `src/main/resources/update/`, `install-update-windows-portable.ps1` a `.github/workflows/release.yml` breaks updates of every installed copy. - `ReleaseDownloader` downloads the file with a Ketch engine of its own and checks it against the SHA-256 digest GitHub computes for each uploaded asset. A file without a digest is refused. - Nothing is signed beyond that: the trust comes from GitHub's TLS, as for the install script. + The release metadata is trusted through GitHub's TLS. Android also enforces APK signing when + replacing the installed app. ## The command @@ -62,6 +63,37 @@ See the [CLI README](../cli/README.md#update) for the options. | Windows (portable) | The `-portable.zip` is unpacked while downloading; the script renames the old files aside and the new ones into the app's folder | Needs a writable folder, otherwise the `.zip` opens in Explorer. See [the portable Windows app](#the-portable-windows-app) | | Linux | `pkexec dpkg -i` | Asks for the user's password. Without `pkexec` or `dpkg` the `.deb` opens in the system's installer | +## The Android app + +Android has two `distribution` flavors with the same application ID: + +- **`direct`** is the APK distributed on GitHub. Settings → About → Updates checks for newer + releases, downloads the universal `ketch-android-.apk` and verifies GitHub's SHA-256 + digest. The APK must name this package and release and have a higher Android `versionCode`. + Only after validation does Open installer become available. Android checks the signing + certificate and asks the user to confirm the update. +- **`play`** relies on Google Play. It has no updater dependency, update UI, installation + activity, update file provider or `REQUEST_INSTALL_PACKAGES` permission, even if sideloaded. + +The direct release build checks 30 seconds after the main screen opens and then daily while +that screen's model lives. It shows a toast for a newer release and another when its download +is ready. Debug builds check only when asked. The About-page automatic-check switch reuses +`[desktop] checkForUpdates` for compatibility with the existing shared UI and config schema. +Checks never download or install without the user choosing to do so. + +The APK is saved as `cache/updates/ketch-update.apk`, outside backups, and shared with Android's +installer through a private FileProvider with a temporary read grant. If Ketch cannot request +installs yet, it opens Android's **Allow from this source** setting; returning after granting +permission continues to the installer. Denying permission or canceling installation leaves the +update ready to retry. If Android clears the cached file, About offers to download it again. +Rotation keeps an active download; process death requires checking and downloading again. + +Build the GitHub APK with `:app:android:assembleDirectRelease`, and the Play bundle with +`:app:android:bundlePlayRelease`. The release workflow ships the direct APK under its existing +asset name. Keep the existing application ID, signing key and increasing `-PversionCode` so +already-installed APKs can upgrade. A debug-signed build cannot install the release-signed APK +as an update. See [Google Play signing](app-store-listing.md#google-play) for channel changes. + ## The portable Windows app Each Windows release also ships `ketch-desktop--windows--portable.zip`: the app's @@ -123,7 +155,7 @@ product with the same UpgradeCode, so changing it would leave installed copies b ## Limitations -- The builds are not signed or notarized. macOS may ask again for permissions it ties to the +- The desktop builds are not signed or notarized. macOS may ask again for permissions it ties to the app's signature, such as notifications, after an update, and may refuse to let the app replace itself (System Settings → Privacy & Security → App Management); the install then leaves the old app in place and `update.log` says why. diff --git a/updater/src/main/kotlin/com/linroid/ketch/updater/Release.kt b/updater/src/main/kotlin/com/linroid/ketch/updater/Release.kt index 8ef34fe16..2c2c7ea79 100644 --- a/updater/src/main/kotlin/com/linroid/ketch/updater/Release.kt +++ b/updater/src/main/kotlin/com/linroid/ketch/updater/Release.kt @@ -15,6 +15,11 @@ data class Release( val pageUrl: String, val assets: List, ) { + /** The universal Android APK published by the release workflow, or `null` before upload. */ + fun androidAsset(): ReleaseAsset? = assets.firstOrNull { + it.name == "ketch-android-$version.apk" + } + /** The file of [product] for [platform], or `null` when this release has none. */ fun asset(product: ReleaseProduct, platform: ReleasePlatform): ReleaseAsset? = product.fileNames(version, platform).firstNotNullOfOrNull { name -> diff --git a/updater/src/test/kotlin/com/linroid/ketch/updater/ReleaseTest.kt b/updater/src/test/kotlin/com/linroid/ketch/updater/ReleaseTest.kt index 9d86fe2e3..9dc2b455d 100644 --- a/updater/src/test/kotlin/com/linroid/ketch/updater/ReleaseTest.kt +++ b/updater/src/test/kotlin/com/linroid/ketch/updater/ReleaseTest.kt @@ -7,6 +7,17 @@ import kotlin.test.assertNull class ReleaseTest { private val version = ReleaseVersion(0, 0, 2, "rc1") + @Test + fun androidAsset_selectsOnlyThisVersionsUniversalApk() { + val release = release( + "ketch-android-0.0.1.apk", + "ketch-android-0.0.2-rc1-mapping.zip", + "ketch-android-0.0.2-rc1.apk", + ) + assertEquals("ketch-android-0.0.2-rc1.apk", release.androidAsset()?.name) + assertNull(release("ketch-android-0.0.1.apk").androidAsset()) + } + @Test fun asset_desktop_usesTheInstallerOfEachSystem() { val release = release(