Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,9 @@ jobs:

- name: Run Android unit tests
run: >-
./gradlew testDebugUnitTest :library:torrent:testAndroidHostTest
:library:core:testAndroidHostTest :library:hls:testAndroidHostTest
:library:dash:testAndroidHostTest
./gradlew :app:android:testDirectDebugUnitTest :app:android:testPlayDebugUnitTest
:library:torrent:testAndroidHostTest :library:core:testAndroidHostTest
:library:hls:testAndroidHostTest :library:dash:testAndroidHostTest

- name: Upload test results
if: always()
Expand Down
16 changes: 12 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,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/
Expand Down Expand Up @@ -118,7 +118,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()`
Expand Down Expand Up @@ -512,6 +512,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`
Expand Down Expand Up @@ -597,8 +604,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
Expand Down
12 changes: 11 additions & 1 deletion app/android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions app/android/src/direct/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application>
<activity
android:name="com.linroid.ketch.app.android.UpdateInstallActivity"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<provider
android:name="com.linroid.ketch.app.android.UpdateFileProvider"
android:authorities="${applicationId}.updates"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/update_paths" />
</provider>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -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>(AppUpdateState.Idle)
override val state: StateFlow<AppUpdateState> = 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"
Loading
Loading