implement ota updates
lint / ruff (push) Successful in 14s
tests / desktop (push) Successful in 22s
docker / image (push) Successful in 43s
tests / web (push) Successful in 39s
tests / android-core (push) Successful in 1m1s
tests / backend (push) Successful in 2m12s
android / apk (push) Successful in 6m50s
lint / ruff (push) Successful in 14s
tests / desktop (push) Successful in 22s
docker / image (push) Successful in 43s
tests / web (push) Successful in 39s
tests / android-core (push) Successful in 1m1s
tests / backend (push) Successful in 2m12s
android / apk (push) Successful in 6m50s
This commit is contained in:
25 files changed
+2353
-4
No files matched your search
@@ -27,6 +27,16 @@ SCHEDULER_ENABLED=true
|
|||||||
TELEGRAM_BOT_ENABLED=true
|
TELEGRAM_BOT_ENABLED=true
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# ---- Android OTA updates ---------------------------------------------------
|
||||||
|
# The repository is private, so the backend reads Actions artifacts on the
|
||||||
|
# phone's behalf. Create a read-only token at
|
||||||
|
# https://git.timka.su/user/settings/applications and paste it here; without it
|
||||||
|
# /api/updates/android answers 503 and the app simply never offers an update.
|
||||||
|
GITEA_URL=https://git.timka.su
|
||||||
|
GITEA_REPO=timka123/kadence
|
||||||
|
GITEA_TOKEN=
|
||||||
|
GITEA_UPDATE_BRANCH=master
|
||||||
|
|
||||||
# ---- web login (closes the browser UI to everyone but you) -----------------
|
# ---- web login (closes the browser UI to everyone but you) -----------------
|
||||||
# Unset: the browser UI is as open as the API ("off by default" above). Set
|
# Unset: the browser UI is as open as the API ("off by default" above). Set
|
||||||
# either of these and the login screen appears — the desktop app is untouched,
|
# either of these and the login screen appears — the desktop app is untouched,
|
||||||
|
|||||||
@@ -74,6 +74,16 @@ jobs:
|
|||||||
printf '%s' "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/kadence.keystore"
|
printf '%s' "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/kadence.keystore"
|
||||||
echo "signed=true" >> "$GITHUB_OUTPUT"
|
echo "signed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Resolve the version name
|
||||||
|
run: |
|
||||||
|
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||||
|
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||||
|
else
|
||||||
|
VERSION="0.1.0-master.${{ gitea.run_number }}"
|
||||||
|
fi
|
||||||
|
echo "KADENCE_VERSION_NAME=$VERSION" >> "$GITHUB_ENV"
|
||||||
|
echo "Resolved version $VERSION"
|
||||||
|
|
||||||
- name: Build the release APK
|
- name: Build the release APK
|
||||||
working-directory: android
|
working-directory: android
|
||||||
env:
|
env:
|
||||||
@@ -103,12 +113,42 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# A small, separately uploaded description of the APK. The backend's OTA
|
||||||
|
# endpoint reads this artifact on every check; keeping it out of the APK
|
||||||
|
# artifact avoids downloading tens of megabytes just to ask "is there a
|
||||||
|
# newer build?".
|
||||||
|
- name: Write the update metadata
|
||||||
|
run: |
|
||||||
|
APK=android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
META_DIR=android/app/build/outputs/update
|
||||||
|
mkdir -p "$META_DIR"
|
||||||
|
|
||||||
|
APKSIGNER="$(find "$ANDROID_HOME/build-tools" -name apksigner | sort | tail -1)"
|
||||||
|
SIGNER_SHA256="$("$APKSIGNER" verify --print-certs "$APK" \
|
||||||
|
| sed -n 's/^Signer #1 certificate SHA-256 digest: //p' | head -1)"
|
||||||
|
|
||||||
|
printf '{\n "version_code": %s,\n "version_name": "%s",\n "commit": "%s",\n "built_at": "%s",\n "size_bytes": %s,\n "sha256": "%s",\n "signer_sha256": "%s"\n}\n' \
|
||||||
|
"${{ gitea.run_number }}" \
|
||||||
|
"$KADENCE_VERSION_NAME" \
|
||||||
|
"$GITHUB_SHA" \
|
||||||
|
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
"$(stat -c%s "$APK")" \
|
||||||
|
"$(sha256sum "$APK" | cut -d' ' -f1)" \
|
||||||
|
"$SIGNER_SHA256" > "$META_DIR/update.json"
|
||||||
|
cat "$META_DIR/update.json"
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: kadence-release-apk
|
name: kadence-release-apk
|
||||||
path: android/app/build/outputs/apk/release/app-release.apk
|
path: android/app/build/outputs/apk/release/app-release.apk
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: kadence-update-metadata
|
||||||
|
path: android/app/build/outputs/update/update.json
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Attach it to the release, when this is a tag
|
- name: Attach it to the release, when this is a tag
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ and you can fill everything in from **Settings**.
|
|||||||
| `SCHEDULER_ENABLED`, `TELEGRAM_BOT_ENABLED` | switch the background jobs / bot off |
|
| `SCHEDULER_ENABLED`, `TELEGRAM_BOT_ENABLED` | switch the background jobs / bot off |
|
||||||
| `AUTH_USERNAME`, `AUTH_PASSWORD` | closes the **browser UI** behind a login; see [Going public](#going-public) |
|
| `AUTH_USERNAME`, `AUTH_PASSWORD` | closes the **browser UI** behind a login; see [Going public](#going-public) |
|
||||||
| `TELEGRAM_OWNER_ID`, `TELEGRAM_BOT_USERNAME` | lets that same browser login happen via the Telegram Login Widget |
|
| `TELEGRAM_OWNER_ID`, `TELEGRAM_BOT_USERNAME` | lets that same browser login happen via the Telegram Login Widget |
|
||||||
|
| `GITEA_TOKEN`, `GITEA_REPO`, `GITEA_URL`, `GITEA_UPDATE_BRANCH` | lets the backend read the private repo's Actions artifacts for Android OTA updates; empty token disables the update endpoints |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -213,7 +214,7 @@ and you can fill everything in from **Settings**.
|
|||||||
|
|
||||||
* `backend/` — FastAPI app. `app/services/` holds the thinking (timer, tasks,
|
* `backend/` — FastAPI app. `app/services/` holds the thinking (timer, tasks,
|
||||||
stats, report, blocker policy, scheduler, bot); `app/sources/` holds one module
|
stats, report, blocker policy, scheduler, bot); `app/sources/` holds one module
|
||||||
per integration; `app/api/` is a thin HTTP layer. 73 tests under `backend/tests`.
|
per integration; `app/api/` is a thin HTTP layer. 78 tests under `backend/tests`.
|
||||||
* `frontend/` — React + TypeScript + MUI, built by Vite into the backend image and
|
* `frontend/` — React + TypeScript + MUI, built by Vite into the backend image and
|
||||||
served from the same origin (so there is no CORS to configure).
|
served from the same origin (so there is no CORS to configure).
|
||||||
* **The look** — warm paper, ink type, hairline rules instead of boxes and
|
* **The look** — warm paper, ink type, hairline rules instead of boxes and
|
||||||
@@ -270,14 +271,17 @@ Gitea Actions, in `.gitea/workflows/`:
|
|||||||
|
|
||||||
| Workflow | When | What |
|
| Workflow | When | What |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `test.yml` | push to master, every PR | backend (73), desktop (47), `:core` (193), and the web app's typecheck + build — four parallel jobs, none of which may need a running backend |
|
| `test.yml` | push to master, every PR | backend (78), desktop (47), `:core` (193), and the web app's typecheck + build — four parallel jobs, none of which may need a running backend |
|
||||||
| `lint.yml` | push to master, every PR | `ruff check` over `backend/` and `desktop/`, with the rule set named in `backend/pyproject.toml` |
|
| `lint.yml` | push to master, every PR | `ruff check` over `backend/` and `desktop/`, with the rule set named in `backend/pyproject.toml` |
|
||||||
| `docker.yml` | push to master, `v*` tags | builds the image, starts it against SQLite and waits for `/api/health`, then pushes `git.timka.su/timka123/kadence:latest` and `:<short-sha>` |
|
| `docker.yml` | push to master, `v*` tags | builds the image, starts it against SQLite and waits for `/api/health`, then pushes `git.timka.su/timka123/kadence:latest` and `:<short-sha>` |
|
||||||
| `android-release.yml` | push to master, `v*` tags | `:app`'s suite (81, on the JVM), then the release APK — signed from secrets, with `apksigner verify` printing the certificate it actually used |
|
| `android-release.yml` | push to master, `v*` tags | `:app`'s suite (89, on the JVM), then the release APK and the small OTA metadata artifact — signed from secrets, with `apksigner verify` printing the certificate it actually used |
|
||||||
|
|
||||||
**Nothing runs until a docker-mode runner is registered** —
|
**Nothing runs until a docker-mode runner is registered** —
|
||||||
[`docs/gitea-runner.md`](docs/gitea-runner.md) is the guide, and
|
[`docs/gitea-runner.md`](docs/gitea-runner.md) is the guide, and
|
||||||
`docker-compose.ci.yml` + `deploy/act-runner.yaml` are the two files it installs.
|
`docker-compose.ci.yml` + `deploy/act-runner.yaml` are the two files it installs.
|
||||||
|
Once it is online, [`docs/production-builds.md`](docs/production-builds.md) covers
|
||||||
|
the release path: tagging, secrets, verifying the image and APK, deploying, and
|
||||||
|
rolling back.
|
||||||
Short version, on the runner host:
|
Short version, on the runner host:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!-- OTA: the app downloads its own release APK from the backend's Gitea
|
||||||
|
proxy and hands it to Android's installer. The user still grants
|
||||||
|
"install unknown apps" and confirms the install; this permission only
|
||||||
|
makes the request possible. -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<!-- The countdown notification, and the alarm when an interval ends. Runtime
|
<!-- The countdown notification, and the alarm when an interval ends. Runtime
|
||||||
permission since Android 13; the timer screen asks for it. -->
|
permission since Android 13; the timer screen asks for it. -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
@@ -81,6 +87,19 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Kadence">
|
android:theme="@style/Theme.Kadence">
|
||||||
|
|
||||||
|
<!-- The downloaded APK lives in the cache directory. FileProvider is
|
||||||
|
what gives Android's package installer a readable URI without
|
||||||
|
exposing the whole filesystem or using a file:// URI. -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.updates"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/update_file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import tech.kadence.android.glyph.newGlyphDisplay
|
|||||||
import tech.kadence.android.notify.Notifications
|
import tech.kadence.android.notify.Notifications
|
||||||
import tech.kadence.android.service.TimerService
|
import tech.kadence.android.service.TimerService
|
||||||
import tech.kadence.android.timer.TimerEngine
|
import tech.kadence.android.timer.TimerEngine
|
||||||
|
import tech.kadence.android.update.UpdateManager
|
||||||
import tech.kadence.core.IntervalFacts
|
import tech.kadence.core.IntervalFacts
|
||||||
|
|
||||||
private const val TAG = "Kadence"
|
private const val TAG = "Kadence"
|
||||||
@@ -72,6 +73,10 @@ class KadenceApplication : Application() {
|
|||||||
lateinit var blocker: BlockerEngine
|
lateinit var blocker: BlockerEngine
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** OTA checks and downloads. Deliberately not started in the background. */
|
||||||
|
lateinit var updates: UpdateManager
|
||||||
|
private set
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One bound matrix for app-driven frames.
|
* One bound matrix for app-driven frames.
|
||||||
*
|
*
|
||||||
@@ -93,6 +98,12 @@ class KadenceApplication : Application() {
|
|||||||
// in Settings takes effect on the next call instead of needing a restart.
|
// in Settings takes effect on the next call instead of needing a restart.
|
||||||
api = KadenceApi(config = { prefs.current })
|
api = KadenceApi(config = { prefs.current })
|
||||||
repo = KadenceRepository(api)
|
repo = KadenceRepository(api)
|
||||||
|
updates = UpdateManager(
|
||||||
|
context = this,
|
||||||
|
repo = repo,
|
||||||
|
prefs = prefs,
|
||||||
|
scope = scope,
|
||||||
|
)
|
||||||
engine = TimerEngine(
|
engine = TimerEngine(
|
||||||
repo = repo,
|
repo = repo,
|
||||||
scope = scope,
|
scope = scope,
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||||
app.engine.refreshNow()
|
app.engine.refreshNow()
|
||||||
app.blocker.refreshNow()
|
app.blocker.refreshNow()
|
||||||
|
// Throttled inside the manager: resuming the app repeatedly does
|
||||||
|
// not mean repeatedly asking Gitea through the backend.
|
||||||
|
app.updates.check()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,10 @@
|
|||||||
*/
|
*/
|
||||||
package tech.kadence.android.data
|
package tech.kadence.android.data
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import tech.kadence.android.data.dto.AndroidUpdateDto
|
||||||
import tech.kadence.android.data.dto.AuthMeDto
|
import tech.kadence.android.data.dto.AuthMeDto
|
||||||
import tech.kadence.android.data.dto.BlockDefaultsDto
|
import tech.kadence.android.data.dto.BlockDefaultsDto
|
||||||
import tech.kadence.android.data.dto.BlockEventsDto
|
import tech.kadence.android.data.dto.BlockEventsDto
|
||||||
@@ -42,6 +44,17 @@ class KadenceRepository(val api: KadenceApi) {
|
|||||||
|
|
||||||
suspend fun authMe(): ApiResult<AuthMeDto> = api.get("/api/auth/me")
|
suspend fun authMe(): ApiResult<AuthMeDto> = api.get("/api/auth/me")
|
||||||
|
|
||||||
|
// ---- updates ------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The newest successful master build, as re-served by this backend. */
|
||||||
|
suspend fun androidUpdate(): ApiResult<AndroidUpdateDto> = api.get("/api/updates/android")
|
||||||
|
|
||||||
|
/** Download the APK behind [androidUpdate]. */
|
||||||
|
suspend fun downloadAndroidUpdate(
|
||||||
|
target: File,
|
||||||
|
onProgress: (Long, Long?) -> Unit = { _, _ -> },
|
||||||
|
): ApiResult<Unit> = api.download("/api/updates/android/apk", target, onProgress)
|
||||||
|
|
||||||
// ---- timer --------------------------------------------------------------
|
// ---- timer --------------------------------------------------------------
|
||||||
|
|
||||||
suspend fun timerState(): ApiResult<TimerStateDto> = api.get("/api/timer/state")
|
suspend fun timerState(): ApiResult<TimerStateDto> = api.get("/api/timer/state")
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* The Android OTA metadata the backend re-serves from Gitea Actions.
|
||||||
|
*
|
||||||
|
* Tolerant like every other DTO here: a backend that grows or drops a field must
|
||||||
|
* not turn an update check into a crash. The only field that decides anything is
|
||||||
|
* [AndroidUpdateDto.versionCode]; the rest is shown to the user or used as a
|
||||||
|
* pre-flight check before the APK is downloaded.
|
||||||
|
*/
|
||||||
|
package tech.kadence.android.data.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AndroidUpdateDto(
|
||||||
|
@SerialName("version_code") val versionCode: Long = 0,
|
||||||
|
@SerialName("version_name") val versionName: String = "",
|
||||||
|
val commit: String = "",
|
||||||
|
@SerialName("built_at") val builtAt: String? = null,
|
||||||
|
@SerialName("size_bytes") val sizeBytes: Long = 0,
|
||||||
|
val sha256: String = "",
|
||||||
|
@SerialName("signer_sha256") val signerSha256: String = "",
|
||||||
|
@SerialName("run_number") val runNumber: Long = 0,
|
||||||
|
) {
|
||||||
|
val shortCommit: String get() = commit.take(7)
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import okhttp3.MediaType.Companion.toMediaType
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -158,6 +159,80 @@ class KadenceApi(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a binary response to [target], reporting progress as it goes.
|
||||||
|
*
|
||||||
|
* This exists for the OTA APK: `request()` returns the body as a String, and
|
||||||
|
* an APK is both binary and large. The write is staged in a `.part` file so a
|
||||||
|
* dropped connection can never leave a truncated APK looking installable.
|
||||||
|
*/
|
||||||
|
suspend fun download(
|
||||||
|
path: String,
|
||||||
|
target: File,
|
||||||
|
onProgress: (downloaded: Long, total: Long?) -> Unit = { _, _ -> },
|
||||||
|
): ApiResult<Unit> = withContext(Dispatchers.IO) {
|
||||||
|
val base = config().baseUrl.trimEnd('/')
|
||||||
|
val url = "$base$path".toHttpUrlOrNull()
|
||||||
|
?: return@withContext ApiResult.NetworkError(
|
||||||
|
IOException("not a usable backend URL: $base"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val request = Request.Builder().url(url).build()
|
||||||
|
val partial = File(target.path + ".part")
|
||||||
|
try {
|
||||||
|
http.newCall(request).execute().use { response ->
|
||||||
|
if (response.code == 401 && !path.startsWith("/api/auth/")) {
|
||||||
|
unauthorized.publish()
|
||||||
|
return@withContext ApiResult.Unauthorized(
|
||||||
|
parseErrorDetail(response.body.string(), "not authorised"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
return@withContext ApiResult.HttpError(
|
||||||
|
response.code,
|
||||||
|
parseErrorDetail(
|
||||||
|
response.body.string(),
|
||||||
|
"${response.code} ${response.message}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = response.body
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
val total = body.contentLength().takeIf { it > 0 }
|
||||||
|
var downloaded = 0L
|
||||||
|
|
||||||
|
try {
|
||||||
|
body.byteStream().use { input ->
|
||||||
|
partial.outputStream().use { output ->
|
||||||
|
val buffer = ByteArray(64 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
downloaded += read
|
||||||
|
onProgress(downloaded, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!partial.renameTo(target)) {
|
||||||
|
partial.copyTo(target, overwrite = true)
|
||||||
|
partial.delete()
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
partial.delete()
|
||||||
|
target.delete()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
ApiResult.Ok(Unit)
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
partial.delete()
|
||||||
|
target.delete()
|
||||||
|
ApiResult.NetworkError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend inline fun <reified T> get(path: String): ApiResult<T> = decode(request("GET", path))
|
suspend inline fun <reified T> get(path: String): ApiResult<T> = decode(request("GET", path))
|
||||||
|
|
||||||
suspend inline fun <reified T> post(path: String, body: String? = null): ApiResult<T> =
|
suspend inline fun <reified T> post(path: String, body: String? = null): ApiResult<T> =
|
||||||
|
|||||||
@@ -60,6 +60,21 @@ class KadencePrefs(context: Context) {
|
|||||||
onboarded = prefs.getBoolean(KEY_ONBOARDED, false),
|
onboarded = prefs.getBoolean(KEY_ONBOARDED, false),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update-check bookkeeping, deliberately outside [KadenceConfig].
|
||||||
|
*
|
||||||
|
* These are not settings the user edits and they must not cause the whole
|
||||||
|
* config StateFlow — and with it every screen — to recompose. A build the
|
||||||
|
* user skipped stays skipped until they ask again from Settings.
|
||||||
|
*/
|
||||||
|
var skippedUpdateVersionCode: Long
|
||||||
|
get() = prefs.getLong(KEY_UPDATE_SKIPPED, 0L)
|
||||||
|
set(value) = prefs.edit().putLong(KEY_UPDATE_SKIPPED, value).apply()
|
||||||
|
|
||||||
|
var lastUpdateCheckMillis: Long
|
||||||
|
get() = prefs.getLong(KEY_UPDATE_CHECKED_AT, 0L)
|
||||||
|
set(value) = prefs.edit().putLong(KEY_UPDATE_CHECKED_AT, value).apply()
|
||||||
|
|
||||||
fun update(transform: (KadenceConfig) -> KadenceConfig) {
|
fun update(transform: (KadenceConfig) -> KadenceConfig) {
|
||||||
val next = transform(_config.value)
|
val next = transform(_config.value)
|
||||||
prefs.edit().apply {
|
prefs.edit().apply {
|
||||||
@@ -88,6 +103,8 @@ class KadencePrefs(context: Context) {
|
|||||||
private const val KEY_DYNAMIC_ACCENT = "dynamic_accent"
|
private const val KEY_DYNAMIC_ACCENT = "dynamic_accent"
|
||||||
private const val KEY_ALARM = "alarm_on_complete"
|
private const val KEY_ALARM = "alarm_on_complete"
|
||||||
private const val KEY_ONBOARDED = "onboarded"
|
private const val KEY_ONBOARDED = "onboarded"
|
||||||
|
private const val KEY_UPDATE_SKIPPED = "update_skipped_version"
|
||||||
|
private const val KEY_UPDATE_CHECKED_AT = "update_checked_at"
|
||||||
|
|
||||||
fun normalizeBaseUrl(raw: String): String {
|
fun normalizeBaseUrl(raw: String): String {
|
||||||
val trimmed = raw.trim().trimEnd('/')
|
val trimmed = raw.trim().trimEnd('/')
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ import tech.kadence.android.ui.settings.SettingsScreen
|
|||||||
import tech.kadence.android.ui.tasks.TasksScreen
|
import tech.kadence.android.ui.tasks.TasksScreen
|
||||||
import tech.kadence.android.ui.theme.KadenceTheme
|
import tech.kadence.android.ui.theme.KadenceTheme
|
||||||
import tech.kadence.android.ui.timer.TimerScreen
|
import tech.kadence.android.ui.timer.TimerScreen
|
||||||
|
import tech.kadence.android.ui.update.UpdateBanner
|
||||||
|
import tech.kadence.android.ui.update.UpdateDialog
|
||||||
|
import tech.kadence.android.ui.update.hasUpdateSuggestion
|
||||||
|
|
||||||
private enum class Destination(val label: String) {
|
private enum class Destination(val label: String) {
|
||||||
Timer("Timer"),
|
Timer("Timer"),
|
||||||
@@ -63,8 +66,10 @@ private enum class Destination(val label: String) {
|
|||||||
fun KadenceApp(app: KadenceApplication) {
|
fun KadenceApp(app: KadenceApplication) {
|
||||||
val config by app.prefs.config.collectAsStateWithLifecycle()
|
val config by app.prefs.config.collectAsStateWithLifecycle()
|
||||||
val ui by app.engine.state.collectAsStateWithLifecycle()
|
val ui by app.engine.state.collectAsStateWithLifecycle()
|
||||||
|
val updateState by app.updates.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
var showOnboarding by remember { mutableStateOf(!config.onboarded) }
|
var showOnboarding by remember { mutableStateOf(!config.onboarded) }
|
||||||
|
var showUpdateDialog by remember { mutableStateOf(false) }
|
||||||
var destination by remember { mutableStateOf(Destination.Timer) }
|
var destination by remember { mutableStateOf(Destination.Timer) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -104,6 +109,7 @@ fun KadenceApp(app: KadenceApplication) {
|
|||||||
// it is, ask straight away rather than waiting out the interval.
|
// it is, ask straight away rather than waiting out the interval.
|
||||||
app.engine.refreshNow()
|
app.engine.refreshNow()
|
||||||
app.blocker.refreshNow()
|
app.blocker.refreshNow()
|
||||||
|
app.updates.check()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -111,6 +117,16 @@ fun KadenceApp(app: KadenceApplication) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().statusBarsPadding()) {
|
Column(Modifier.fillMaxSize().statusBarsPadding()) {
|
||||||
|
if (hasUpdateSuggestion(updateState)) {
|
||||||
|
Box(Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) {
|
||||||
|
UpdateBanner(
|
||||||
|
state = updateState,
|
||||||
|
onOpen = { showUpdateDialog = true },
|
||||||
|
onLater = app.updates::later,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(Modifier.weight(1f)) {
|
Box(Modifier.weight(1f)) {
|
||||||
when (destination) {
|
when (destination) {
|
||||||
Destination.Timer -> TimerScreen(
|
Destination.Timer -> TimerScreen(
|
||||||
@@ -177,6 +193,9 @@ fun KadenceApp(app: KadenceApplication) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onReconnect = { showOnboarding = true },
|
onReconnect = { showOnboarding = true },
|
||||||
|
updateManager = app.updates,
|
||||||
|
updateState = updateState,
|
||||||
|
onOpenUpdate = { showUpdateDialog = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,6 +207,14 @@ fun KadenceApp(app: KadenceApplication) {
|
|||||||
modifier = Modifier.navigationBarsPadding(),
|
modifier = Modifier.navigationBarsPadding(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showUpdateDialog) {
|
||||||
|
UpdateDialog(
|
||||||
|
manager = app.updates,
|
||||||
|
state = updateState,
|
||||||
|
onDismiss = { showUpdateDialog = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ import tech.kadence.android.ui.components.Panel
|
|||||||
import tech.kadence.android.ui.components.SectionTitle
|
import tech.kadence.android.ui.components.SectionTitle
|
||||||
import tech.kadence.android.ui.components.ToggleRow
|
import tech.kadence.android.ui.components.ToggleRow
|
||||||
import tech.kadence.android.ui.theme.KadenceTheme
|
import tech.kadence.android.ui.theme.KadenceTheme
|
||||||
|
import tech.kadence.android.ui.update.hasUpdateSuggestion
|
||||||
|
import tech.kadence.android.ui.update.updateHeadline
|
||||||
|
import tech.kadence.android.update.UpdateManager
|
||||||
|
import tech.kadence.android.update.UpdateState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
@@ -54,6 +58,9 @@ fun SettingsScreen(
|
|||||||
onUpdate: ((KadenceConfig) -> KadenceConfig) -> Unit,
|
onUpdate: ((KadenceConfig) -> KadenceConfig) -> Unit,
|
||||||
onSetTimerEnabled: (Boolean) -> Unit,
|
onSetTimerEnabled: (Boolean) -> Unit,
|
||||||
onReconnect: () -> Unit,
|
onReconnect: () -> Unit,
|
||||||
|
updateManager: UpdateManager,
|
||||||
|
updateState: UpdateState,
|
||||||
|
onOpenUpdate: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colors = KadenceTheme.colors
|
val colors = KadenceTheme.colors
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -223,6 +230,48 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SectionTitle("Updates")
|
||||||
|
Panel(padding = 0.dp) {
|
||||||
|
Column(Modifier.padding(14.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Kadence ${BuildConfig.VERSION_NAME} · build " +
|
||||||
|
"${BuildConfig.VERSION_CODE}",
|
||||||
|
style = KadenceTheme.type.body,
|
||||||
|
color = colors.ink,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = updateStatusText(updateManager, updateState),
|
||||||
|
style = KadenceTheme.type.caption,
|
||||||
|
color = when (updateState) {
|
||||||
|
is UpdateState.Failed -> colors.clay
|
||||||
|
is UpdateState.Ready,
|
||||||
|
is UpdateState.NeedsInstallPermission,
|
||||||
|
-> colors.pine
|
||||||
|
is UpdateState.UpToDate -> colors.pine
|
||||||
|
else -> colors.inkMuted
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalRule()
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
GhostButton(
|
||||||
|
text = "Check now",
|
||||||
|
onClick = { updateManager.check(force = true) },
|
||||||
|
enabled = updateManager.updatesSupported &&
|
||||||
|
updateState !is UpdateState.Checking &&
|
||||||
|
updateState !is UpdateState.Downloading &&
|
||||||
|
updateState !is UpdateState.Ready &&
|
||||||
|
updateState !is UpdateState.NeedsInstallPermission,
|
||||||
|
)
|
||||||
|
if (hasUpdateSuggestion(updateState)) {
|
||||||
|
GhostButton(text = "View update", onClick = onOpenUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SectionTitle("Appearance")
|
SectionTitle("Appearance")
|
||||||
Panel(padding = 0.dp) {
|
Panel(padding = 0.dp) {
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
@@ -245,6 +294,21 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateStatusText(manager: UpdateManager, state: UpdateState): String {
|
||||||
|
if (!manager.updatesSupported) return "OTA updates are disabled in debug builds."
|
||||||
|
return when (state) {
|
||||||
|
UpdateState.Idle -> "Checks run at most every six hours when you open the app."
|
||||||
|
UpdateState.Checking -> "Checking the backend for a newer build…"
|
||||||
|
UpdateState.UpToDate -> "This install is current."
|
||||||
|
is UpdateState.Available -> updateHeadline(state)
|
||||||
|
is UpdateState.Downloading -> "Downloading build ${state.update.versionCode}…"
|
||||||
|
is UpdateState.Ready -> "Downloaded and verified. Ready to install."
|
||||||
|
is UpdateState.NeedsInstallPermission ->
|
||||||
|
"Allow installs from Kadence, then tap Install."
|
||||||
|
is UpdateState.Failed -> state.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Deep-link straight to Kadence's notification page, not the global one. */
|
/** Deep-link straight to Kadence's notification page, not the global one. */
|
||||||
private fun openAppNotificationSettings(context: Context) {
|
private fun openAppNotificationSettings(context: Context) {
|
||||||
val intent = Intent(AndroidSettings.ACTION_APP_NOTIFICATION_SETTINGS)
|
val intent = Intent(AndroidSettings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
/**
|
||||||
|
* The two places OTA is visible: a quiet banner over the shell, and a dialog
|
||||||
|
* that carries the download/install states.
|
||||||
|
*
|
||||||
|
* The banner is deliberately not modal. An update is a suggestion, not an
|
||||||
|
* interruption — the timer screen must remain usable while the user decides.
|
||||||
|
*/
|
||||||
|
package tech.kadence.android.ui.update
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import java.util.Locale
|
||||||
|
import tech.kadence.android.data.dto.AndroidUpdateDto
|
||||||
|
import tech.kadence.android.ui.components.GhostButton
|
||||||
|
import tech.kadence.android.ui.components.HorizontalRule
|
||||||
|
import tech.kadence.android.ui.components.PrimaryButton
|
||||||
|
import tech.kadence.android.ui.theme.KadenceTheme
|
||||||
|
import tech.kadence.android.update.UpdateManager
|
||||||
|
import tech.kadence.android.update.UpdateState
|
||||||
|
|
||||||
|
private fun updateOf(state: UpdateState): AndroidUpdateDto? = when (state) {
|
||||||
|
is UpdateState.Available -> state.update
|
||||||
|
is UpdateState.Downloading -> state.update
|
||||||
|
is UpdateState.Ready -> state.update
|
||||||
|
is UpdateState.NeedsInstallPermission -> state.update
|
||||||
|
is UpdateState.Failed -> state.update.takeUnless { state.quiet }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasUpdateSuggestion(state: UpdateState): Boolean = updateOf(state) != null
|
||||||
|
|
||||||
|
fun updateHeadline(state: UpdateState): String {
|
||||||
|
val update = updateOf(state) ?: return ""
|
||||||
|
return when (state) {
|
||||||
|
is UpdateState.Available -> "Build ${update.versionCode} is available."
|
||||||
|
is UpdateState.Downloading -> "Downloading build ${update.versionCode}…"
|
||||||
|
is UpdateState.Ready -> "Build ${update.versionCode} is ready to install."
|
||||||
|
is UpdateState.NeedsInstallPermission ->
|
||||||
|
"Allow installs from Kadence to finish the update."
|
||||||
|
is UpdateState.Failed -> state.message
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdateBanner(
|
||||||
|
state: UpdateState,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
onLater: () -> Unit,
|
||||||
|
) {
|
||||||
|
val update = updateOf(state) ?: return
|
||||||
|
val colors = KadenceTheme.colors
|
||||||
|
val label = when (state) {
|
||||||
|
is UpdateState.Ready, is UpdateState.NeedsInstallPermission -> "Install"
|
||||||
|
is UpdateState.Failed -> "Retry"
|
||||||
|
else -> "Update"
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = colors.accentSoft,
|
||||||
|
border = androidx.compose.foundation.BorderStroke(1.dp, colors.accent.copy(alpha = 0.45f)),
|
||||||
|
tonalElevation = 0.dp,
|
||||||
|
shadowElevation = 0.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "Update · ${displayName(update)}",
|
||||||
|
style = KadenceTheme.type.bodySmall,
|
||||||
|
color = colors.ink,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = when (state) {
|
||||||
|
is UpdateState.Downloading -> progressText(state)
|
||||||
|
is UpdateState.Ready -> "Verified. Android will ask you to confirm."
|
||||||
|
is UpdateState.NeedsInstallPermission ->
|
||||||
|
"Android needs permission to install unknown apps."
|
||||||
|
is UpdateState.Failed -> "The last attempt did not finish."
|
||||||
|
else -> "A newer build is ready from Gitea Actions."
|
||||||
|
},
|
||||||
|
style = KadenceTheme.type.caption,
|
||||||
|
color = colors.inkMuted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state is UpdateState.Available) {
|
||||||
|
TextButton(onClick = onLater) {
|
||||||
|
Text("Later", style = KadenceTheme.type.button, color = colors.inkMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextButton(onClick = onOpen) {
|
||||||
|
Text(label, style = KadenceTheme.type.button, color = colors.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdateDialog(
|
||||||
|
manager: UpdateManager,
|
||||||
|
state: UpdateState,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val update = updateOf(state)
|
||||||
|
val colors = KadenceTheme.colors
|
||||||
|
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = colors.paper,
|
||||||
|
border = androidx.compose.foundation.BorderStroke(1.dp, colors.ruleStrong),
|
||||||
|
tonalElevation = 0.dp,
|
||||||
|
shadowElevation = 0.dp,
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = when (state) {
|
||||||
|
is UpdateState.Ready,
|
||||||
|
is UpdateState.NeedsInstallPermission,
|
||||||
|
-> "Install update"
|
||||||
|
is UpdateState.Failed -> "Update not finished"
|
||||||
|
else -> "Update available"
|
||||||
|
},
|
||||||
|
style = KadenceTheme.type.pageTitle,
|
||||||
|
color = colors.ink,
|
||||||
|
)
|
||||||
|
HorizontalRule()
|
||||||
|
|
||||||
|
if (update != null) {
|
||||||
|
Text(
|
||||||
|
text = displayName(update),
|
||||||
|
style = KadenceTheme.type.body,
|
||||||
|
color = colors.ink,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = buildString {
|
||||||
|
if (update.shortCommit.isNotBlank()) {
|
||||||
|
append("Commit ${update.shortCommit}")
|
||||||
|
}
|
||||||
|
if (update.builtAt != null) {
|
||||||
|
if (isNotEmpty()) append(" · ")
|
||||||
|
append(update.builtAt.take(10))
|
||||||
|
}
|
||||||
|
if (update.sizeBytes > 0) {
|
||||||
|
if (isNotEmpty()) append(" · ")
|
||||||
|
append(readableSize(update.sizeBytes))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style = KadenceTheme.type.caption,
|
||||||
|
color = colors.inkMuted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = bodyText(state),
|
||||||
|
style = KadenceTheme.type.bodySmall,
|
||||||
|
color = if (state is UpdateState.Failed) colors.clay else colors.inkMuted,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state is UpdateState.Downloading) {
|
||||||
|
DownloadProgress(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
is UpdateState.Available -> {
|
||||||
|
PrimaryButton("Download", onClick = manager::download)
|
||||||
|
GhostButton("Skip this build", onClick = {
|
||||||
|
manager.skip()
|
||||||
|
onDismiss()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
is UpdateState.Downloading -> {
|
||||||
|
GhostButton("Continue in background", onClick = onDismiss)
|
||||||
|
}
|
||||||
|
is UpdateState.Ready -> {
|
||||||
|
PrimaryButton("Install", onClick = manager::install)
|
||||||
|
GhostButton("Later", onClick = onDismiss)
|
||||||
|
}
|
||||||
|
is UpdateState.NeedsInstallPermission -> {
|
||||||
|
PrimaryButton("Install", onClick = manager::install)
|
||||||
|
GhostButton(
|
||||||
|
"Permission settings",
|
||||||
|
onClick = manager::openInstallPermissionSettings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is UpdateState.Failed -> {
|
||||||
|
if (update != null) {
|
||||||
|
PrimaryButton("Retry", onClick = manager::download)
|
||||||
|
} else {
|
||||||
|
PrimaryButton(
|
||||||
|
"Check again",
|
||||||
|
onClick = { manager.check(force = true) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GhostButton("Close", onClick = {
|
||||||
|
manager.dismissError()
|
||||||
|
onDismiss()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else -> GhostButton("Close", onClick = onDismiss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DownloadProgress(state: UpdateState.Downloading) {
|
||||||
|
val colors = KadenceTheme.colors
|
||||||
|
val fraction = state.total?.takeIf { it > 0 }
|
||||||
|
?.let { (state.downloaded.toFloat() / it.toFloat()).coerceIn(0f, 1f) }
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
if (fraction != null) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(4.dp)
|
||||||
|
.background(colors.rule, RoundedCornerShape(2.dp)),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(fraction)
|
||||||
|
.height(4.dp)
|
||||||
|
.background(colors.accent, RoundedCornerShape(2.dp)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = progressText(state),
|
||||||
|
style = KadenceTheme.type.caption,
|
||||||
|
color = colors.inkFaint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun progressText(state: UpdateState.Downloading): String {
|
||||||
|
val total = state.total
|
||||||
|
return if (total != null && total > 0) {
|
||||||
|
val percent = (state.downloaded * 100 / total).coerceIn(0, 100)
|
||||||
|
"${readableSize(state.downloaded)} of ${readableSize(total)} · $percent%"
|
||||||
|
} else {
|
||||||
|
readableSize(state.downloaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bodyText(state: UpdateState): String = when (state) {
|
||||||
|
is UpdateState.Available -> "This build comes from the newest successful master run. " +
|
||||||
|
"It is downloaded only when you ask, then Android confirms the install."
|
||||||
|
is UpdateState.Downloading -> "Keep the app open or leave it in the background. " +
|
||||||
|
"If the transfer fails, Retry starts again from the beginning."
|
||||||
|
is UpdateState.Ready -> "The APK is signed with this app's key. Android will install " +
|
||||||
|
"it over the current build after you confirm."
|
||||||
|
is UpdateState.NeedsInstallPermission -> "Android blocks installs from apps that have " +
|
||||||
|
"not been granted this permission. Open the settings page, allow Kadence, then " +
|
||||||
|
"come back and tap Install."
|
||||||
|
is UpdateState.Failed -> state.message
|
||||||
|
is UpdateState.Checking -> "Asking the backend which build is current…"
|
||||||
|
is UpdateState.UpToDate -> "This install is on the newest successful master build."
|
||||||
|
UpdateState.Idle -> "No update is being offered right now."
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun displayName(update: AndroidUpdateDto): String =
|
||||||
|
update.versionName.ifBlank { "Build ${update.versionCode}" }
|
||||||
|
|
||||||
|
private fun readableSize(bytes: Long): String = when {
|
||||||
|
bytes >= 1024L * 1024L ->
|
||||||
|
String.format(Locale.US, "%.1f MB", bytes / (1024.0 * 1024.0))
|
||||||
|
bytes >= 1024L -> String.format(Locale.US, "%.0f KB", bytes / 1024.0)
|
||||||
|
else -> "$bytes B"
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* What makes it safe to hand a downloaded file to Android's installer.
|
||||||
|
*
|
||||||
|
* The backend is the user's own, and the APK comes over the same authenticated
|
||||||
|
* connection as everything else, but neither of those says the file is *this
|
||||||
|
* app's* next build. The signing certificate does. A build signed with another
|
||||||
|
* key cannot upgrade an installed app anyway; checking first turns an opaque
|
||||||
|
* "App not installed" into an honest refusal.
|
||||||
|
*/
|
||||||
|
package tech.kadence.android.update
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.content.pm.SigningInfo
|
||||||
|
import java.io.File
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import tech.kadence.android.data.dto.AndroidUpdateDto
|
||||||
|
|
||||||
|
object ApkVerifier {
|
||||||
|
|
||||||
|
fun currentSignerDigests(context: Context): Set<String> = runCatching {
|
||||||
|
val info = context.packageManager.getPackageInfo(
|
||||||
|
context.packageName,
|
||||||
|
PackageManager.GET_SIGNING_CERTIFICATES,
|
||||||
|
)
|
||||||
|
digests(info)
|
||||||
|
}.getOrDefault(emptySet())
|
||||||
|
|
||||||
|
/** True when the server's advertised signer is one of ours, or was not provided. */
|
||||||
|
fun matchesExpectedSigner(update: AndroidUpdateDto, current: Set<String>): Boolean {
|
||||||
|
val expected = normalize(update.signerSha256)
|
||||||
|
if (expected.isBlank()) return true
|
||||||
|
return current.any { normalize(it) == expected }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return null when the APK is installable over this app; otherwise a message
|
||||||
|
* for the user, with the file already known to be unusable.
|
||||||
|
*/
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
fun verify(
|
||||||
|
context: Context,
|
||||||
|
apk: File,
|
||||||
|
installedVersionCode: Long,
|
||||||
|
): String? {
|
||||||
|
if (!apk.isFile || apk.length() == 0L) return "The downloaded APK is empty."
|
||||||
|
|
||||||
|
val manager = context.packageManager
|
||||||
|
val archive = runCatching {
|
||||||
|
manager.getPackageArchiveInfo(apk.path, PackageManager.GET_SIGNING_CERTIFICATES)
|
||||||
|
}.getOrNull() ?: return "The download is not a valid APK."
|
||||||
|
|
||||||
|
if (archive.packageName != context.packageName) {
|
||||||
|
return "The downloaded APK belongs to a different app."
|
||||||
|
}
|
||||||
|
if (archive.longVersionCode <= installedVersionCode) {
|
||||||
|
return "The downloaded APK is not newer than the installed build."
|
||||||
|
}
|
||||||
|
|
||||||
|
val archiveSigners = digests(archive)
|
||||||
|
if (archiveSigners.isEmpty()) return "The downloaded APK is not signed."
|
||||||
|
|
||||||
|
val currentSigners = currentSignerDigests(context)
|
||||||
|
if (currentSigners.isEmpty()) {
|
||||||
|
return "Could not read this app's signing certificate."
|
||||||
|
}
|
||||||
|
if (archiveSigners != currentSigners) {
|
||||||
|
return "The downloaded build is signed with a different key, so Android " +
|
||||||
|
"would not install it over this app."
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun digests(info: PackageInfo): Set<String> {
|
||||||
|
val signing: SigningInfo? = info.signingInfo
|
||||||
|
val signatures = signing?.apkContentsSigners.orEmpty()
|
||||||
|
return signatures.mapTo(mutableSetOf()) { signature ->
|
||||||
|
MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(signature.toByteArray())
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalize(digest: String): String =
|
||||||
|
digest.replace(":", "").trim().lowercase()
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
/**
|
||||||
|
* The OTA state machine.
|
||||||
|
*
|
||||||
|
* It checks a feed the backend builds from Gitea Actions, offers the build when
|
||||||
|
* it is genuinely newer, downloads only after the user says so, verifies the
|
||||||
|
* signing key, and then lets Android's own installer ask the final question.
|
||||||
|
*
|
||||||
|
* The manager never installs anything silently and never keeps a partial file:
|
||||||
|
* a failed download reverts to a retryable state, and an APK that fails the
|
||||||
|
* signature check is deleted before the user is told why.
|
||||||
|
*/
|
||||||
|
package tech.kadence.android.update
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings as AndroidSettings
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import tech.kadence.android.BuildConfig
|
||||||
|
import tech.kadence.android.data.KadenceRepository
|
||||||
|
import tech.kadence.android.data.dto.AndroidUpdateDto
|
||||||
|
import tech.kadence.android.data.net.ApiResult
|
||||||
|
import tech.kadence.android.data.store.KadencePrefs
|
||||||
|
|
||||||
|
sealed interface UpdateState {
|
||||||
|
data object Idle : UpdateState
|
||||||
|
data object Checking : UpdateState
|
||||||
|
data object UpToDate : UpdateState
|
||||||
|
|
||||||
|
data class Available(val update: AndroidUpdateDto) : UpdateState
|
||||||
|
|
||||||
|
data class Downloading(
|
||||||
|
val update: AndroidUpdateDto,
|
||||||
|
val downloaded: Long,
|
||||||
|
val total: Long?,
|
||||||
|
) : UpdateState
|
||||||
|
|
||||||
|
data class Ready(val update: AndroidUpdateDto, val apk: File) : UpdateState
|
||||||
|
|
||||||
|
data class NeedsInstallPermission(
|
||||||
|
val update: AndroidUpdateDto,
|
||||||
|
val apk: File,
|
||||||
|
) : UpdateState
|
||||||
|
|
||||||
|
data class Failed(
|
||||||
|
val message: String,
|
||||||
|
/** Set when a download failed, so the banner can offer a retry. */
|
||||||
|
val update: AndroidUpdateDto? = null,
|
||||||
|
/** True for background checks: Settings can show it, the banner stays quiet. */
|
||||||
|
val quiet: Boolean = false,
|
||||||
|
) : UpdateState
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateManager(
|
||||||
|
private val context: Context,
|
||||||
|
private val repo: KadenceRepository,
|
||||||
|
private val prefs: KadencePrefs,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val installedVersionCode: Long = BuildConfig.VERSION_CODE.toLong(),
|
||||||
|
private val debugBuild: Boolean = BuildConfig.DEBUG,
|
||||||
|
private val now: () -> Long = System::currentTimeMillis,
|
||||||
|
private val checkIntervalMillis: Long = 6 * 60 * 60 * 1000L,
|
||||||
|
) {
|
||||||
|
private val _state = MutableStateFlow<UpdateState>(UpdateState.Idle)
|
||||||
|
val state: StateFlow<UpdateState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val apkDir = File(context.cacheDir, "updates")
|
||||||
|
private var work: Job? = null
|
||||||
|
|
||||||
|
val updatesSupported: Boolean get() = !debugBuild
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look for a newer build at most once per [checkIntervalMillis].
|
||||||
|
*
|
||||||
|
* @param force bypass the interval and the "skipped this build" choice. This
|
||||||
|
* is the Settings button; automatic resume checks use the quiet path.
|
||||||
|
*/
|
||||||
|
fun check(force: Boolean = false) {
|
||||||
|
if (!updatesSupported) {
|
||||||
|
if (force) {
|
||||||
|
_state.value = UpdateState.Failed(
|
||||||
|
"OTA updates are only available in release builds.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (work?.isActive == true) return
|
||||||
|
if (!force && recentlyChecked()) return
|
||||||
|
|
||||||
|
val config = prefs.current
|
||||||
|
if (!config.isConfigured || !config.onboarded) return
|
||||||
|
|
||||||
|
// A ready APK must not be replaced by a fresh check while the user is
|
||||||
|
// deciding whether to install it.
|
||||||
|
if (_state.value is UpdateState.Downloading ||
|
||||||
|
_state.value is UpdateState.Ready ||
|
||||||
|
_state.value is UpdateState.NeedsInstallPermission
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
work = scope.launch {
|
||||||
|
_state.value = UpdateState.Checking
|
||||||
|
val result = repo.androidUpdate()
|
||||||
|
prefs.lastUpdateCheckMillis = now()
|
||||||
|
|
||||||
|
when (result) {
|
||||||
|
is ApiResult.Ok -> offer(result.value, force)
|
||||||
|
is ApiResult.HttpError -> failedCheck(result.detail, force)
|
||||||
|
is ApiResult.Unauthorized -> failedCheck(result.detail, force)
|
||||||
|
is ApiResult.NetworkError -> failedCheck(
|
||||||
|
"Could not reach the backend to check for updates.",
|
||||||
|
force,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun offer(update: AndroidUpdateDto, force: Boolean) {
|
||||||
|
if (update.versionCode <= installedVersionCode) {
|
||||||
|
_state.value = UpdateState.UpToDate
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentSigners = ApkVerifier.currentSignerDigests(context)
|
||||||
|
if (!ApkVerifier.matchesExpectedSigner(update, currentSigners)) {
|
||||||
|
_state.value = UpdateState.Failed(
|
||||||
|
"The newest build is signed with a different key, so it cannot " +
|
||||||
|
"upgrade this install.",
|
||||||
|
quiet = !force,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && update.versionCode == prefs.skippedUpdateVersionCode) {
|
||||||
|
_state.value = UpdateState.Idle
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.value = UpdateState.Available(update)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failedCheck(message: String, force: Boolean) {
|
||||||
|
_state.value = UpdateState.Failed(message, quiet = !force)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recentlyChecked(): Boolean {
|
||||||
|
val last = prefs.lastUpdateCheckMillis
|
||||||
|
return last > 0 && now() - last in 0 until checkIntervalMillis
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download the offered build, verify it, and stop at "ready to install". */
|
||||||
|
fun download() {
|
||||||
|
if (work?.isActive == true) return
|
||||||
|
val update = when (val current = _state.value) {
|
||||||
|
is UpdateState.Available -> current.update
|
||||||
|
is UpdateState.Failed -> current.update
|
||||||
|
else -> null
|
||||||
|
} ?: return
|
||||||
|
|
||||||
|
val target = File(apkDir, "kadence-${update.versionCode}.apk")
|
||||||
|
work = scope.launch {
|
||||||
|
cleanupOldApks(keep = target)
|
||||||
|
_state.value = UpdateState.Downloading(
|
||||||
|
update,
|
||||||
|
downloaded = 0,
|
||||||
|
total = update.sizeBytes.takeIf { it > 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = repo.downloadAndroidUpdate(target) { done, total ->
|
||||||
|
_state.value = UpdateState.Downloading(
|
||||||
|
update,
|
||||||
|
downloaded = done,
|
||||||
|
total = total ?: update.sizeBytes.takeIf { it > 0 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (result) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
val problem = ApkVerifier.verify(context, target, installedVersionCode)
|
||||||
|
if (problem != null) {
|
||||||
|
target.delete()
|
||||||
|
_state.value = UpdateState.Failed(problem, update = update)
|
||||||
|
} else {
|
||||||
|
_state.value = UpdateState.Ready(update, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ApiResult.HttpError -> downloadFailed(update, result.detail)
|
||||||
|
is ApiResult.Unauthorized -> downloadFailed(update, result.detail)
|
||||||
|
is ApiResult.NetworkError -> downloadFailed(
|
||||||
|
update,
|
||||||
|
"The download was interrupted. Try again.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadFailed(update: AndroidUpdateDto, message: String) {
|
||||||
|
_state.value = UpdateState.Failed(message, update = update)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand the verified APK to Android's package installer.
|
||||||
|
*
|
||||||
|
* If the user has not allowed Kadence to install unknown apps, the state
|
||||||
|
* moves to [UpdateState.NeedsInstallPermission] and the UI offers the
|
||||||
|
* matching system settings page. It does not silently drop the download.
|
||||||
|
*/
|
||||||
|
fun install() {
|
||||||
|
val ready: UpdateState.Ready = when (val current = _state.value) {
|
||||||
|
is UpdateState.Ready -> current
|
||||||
|
is UpdateState.NeedsInstallPermission ->
|
||||||
|
UpdateState.Ready(current.update, current.apk)
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.packageManager.canRequestPackageInstalls()) {
|
||||||
|
_state.value = UpdateState.NeedsInstallPermission(ready.update, ready.apk)
|
||||||
|
// Take the user straight to the grant. The same "Install" button then
|
||||||
|
// works when they come back, without needing a second tap on a
|
||||||
|
// different label.
|
||||||
|
openInstallPermissionSettings()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = runCatching {
|
||||||
|
FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.updates",
|
||||||
|
ready.apk,
|
||||||
|
)
|
||||||
|
}.getOrElse {
|
||||||
|
_state.value = UpdateState.Failed(
|
||||||
|
"Could not open the downloaded APK.",
|
||||||
|
update = ready.update,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
|
.setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
runCatching { context.startActivity(intent) }.onFailure {
|
||||||
|
_state.value = UpdateState.Failed(
|
||||||
|
"No package installer is available on this device.",
|
||||||
|
update = ready.update,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openInstallPermissionSettings() {
|
||||||
|
val intent = Intent(
|
||||||
|
AndroidSettings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||||
|
Uri.parse("package:${context.packageName}"),
|
||||||
|
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
runCatching { context.startActivity(intent) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hide the suggestion until the next launch, without skipping the build. */
|
||||||
|
fun later() {
|
||||||
|
if (_state.value is UpdateState.Available) {
|
||||||
|
_state.value = UpdateState.Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Never suggest this exact build again, until a manual check. */
|
||||||
|
fun skip() {
|
||||||
|
val update = when (val current = _state.value) {
|
||||||
|
is UpdateState.Available -> current.update
|
||||||
|
is UpdateState.Failed -> current.update
|
||||||
|
else -> null
|
||||||
|
} ?: return
|
||||||
|
prefs.skippedUpdateVersionCode = update.versionCode
|
||||||
|
_state.value = UpdateState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissError() {
|
||||||
|
if (_state.value is UpdateState.Failed) _state.value = UpdateState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanupOldApks(keep: File) {
|
||||||
|
apkDir.listFiles()?.forEach { file ->
|
||||||
|
if (file != keep && (file.name.endsWith(".apk") || file.name.endsWith(".part"))) {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Only the OTA directory is shared, and only through FileProvider. The APK
|
||||||
|
is downloaded into cacheDir/updates by UpdateManager. -->
|
||||||
|
<paths>
|
||||||
|
<cache-path name="updates" path="updates/" />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* The update offer, without downloading anything real.
|
||||||
|
*
|
||||||
|
* MockWebServer stands in for the backend, so these tests pin the decisions that are
|
||||||
|
* easy to get wrong: a build that is not newer is ignored, a skipped build stays
|
||||||
|
* skipped until asked for, the six-hour throttle really throttles, and a signing-key
|
||||||
|
* mismatch never becomes an "install" button.
|
||||||
|
*/
|
||||||
|
package tech.kadence.android
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import mockwebserver3.MockResponse
|
||||||
|
import mockwebserver3.MockWebServer
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import tech.kadence.android.data.KadenceRepository
|
||||||
|
import tech.kadence.android.data.net.AuthConfig
|
||||||
|
import tech.kadence.android.data.net.KadenceApi
|
||||||
|
import tech.kadence.android.data.store.KadencePrefs
|
||||||
|
import tech.kadence.android.update.UpdateManager
|
||||||
|
import tech.kadence.android.update.UpdateState
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34])
|
||||||
|
class UpdateManagerTest {
|
||||||
|
|
||||||
|
private lateinit var server: MockWebServer
|
||||||
|
private lateinit var context: Context
|
||||||
|
private lateinit var prefs: KadencePrefs
|
||||||
|
private var clock = 1_000_000L
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
private fun fixtureJson(
|
||||||
|
versionCode: Long,
|
||||||
|
signer: String = "",
|
||||||
|
): String = """
|
||||||
|
{
|
||||||
|
"version_code": $versionCode,
|
||||||
|
"version_name": "0.1.0-master.$versionCode",
|
||||||
|
"commit": "abcdef1234567890",
|
||||||
|
"built_at": "2026-10-22T10:00:00Z",
|
||||||
|
"size_bytes": 1234567,
|
||||||
|
"sha256": "deadbeef",
|
||||||
|
"signer_sha256": "$signer",
|
||||||
|
"run_number": $versionCode
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
private fun jsonResponse(code: Int, body: String): MockResponse =
|
||||||
|
MockResponse.Builder()
|
||||||
|
.code(code)
|
||||||
|
.addHeader("Content-Type", "application/json")
|
||||||
|
.body(body)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
context = ApplicationProvider.getApplicationContext()
|
||||||
|
context.getSharedPreferences("kadence", Context.MODE_PRIVATE)
|
||||||
|
.edit().clear().commit()
|
||||||
|
prefs = KadencePrefs(context)
|
||||||
|
prefs.update {
|
||||||
|
it.copy(
|
||||||
|
baseUrl = "http://placeholder",
|
||||||
|
apiToken = "token",
|
||||||
|
onboarded = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
server = MockWebServer()
|
||||||
|
server.start()
|
||||||
|
prefs.update { it.copy(baseUrl = server.url("/").toString().trimEnd('/')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
server.close()
|
||||||
|
File(context.cacheDir, "updates").deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun manager(
|
||||||
|
installedVersionCode: Long = 4L,
|
||||||
|
debugBuild: Boolean = false,
|
||||||
|
): UpdateManager {
|
||||||
|
val api = KadenceApi(config = {
|
||||||
|
object : AuthConfig {
|
||||||
|
override val baseUrl = prefs.current.baseUrl
|
||||||
|
override val apiToken = prefs.current.apiToken
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return UpdateManager(
|
||||||
|
context = context,
|
||||||
|
repo = KadenceRepository(api),
|
||||||
|
prefs = prefs,
|
||||||
|
scope = scope,
|
||||||
|
installedVersionCode = installedVersionCode,
|
||||||
|
debugBuild = debugBuild,
|
||||||
|
now = { clock },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun await(
|
||||||
|
manager: UpdateManager,
|
||||||
|
predicate: (UpdateState) -> Boolean,
|
||||||
|
): UpdateState = runBlocking {
|
||||||
|
withTimeout(5_000) { manager.state.first(predicate) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a newer build is offered and remembered as checked`() {
|
||||||
|
server.enqueue(jsonResponse(200, fixtureJson(versionCode = 9)))
|
||||||
|
val manager = manager()
|
||||||
|
|
||||||
|
manager.check()
|
||||||
|
val state = await(manager) { it !is UpdateState.Checking }
|
||||||
|
|
||||||
|
val available = assertIs<UpdateState.Available>(state)
|
||||||
|
assertEquals(9L, available.update.versionCode)
|
||||||
|
assertEquals(1, server.requestCount)
|
||||||
|
assertEquals(clock, prefs.lastUpdateCheckMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a build that is not newer does not become an offer`() {
|
||||||
|
server.enqueue(jsonResponse(200, fixtureJson(versionCode = 4)))
|
||||||
|
val manager = manager(installedVersionCode = 4L)
|
||||||
|
|
||||||
|
manager.check()
|
||||||
|
val state = await(manager) { it !is UpdateState.Checking }
|
||||||
|
|
||||||
|
assertIs<UpdateState.UpToDate>(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resume checks inside six hours do not ask again`() {
|
||||||
|
server.enqueue(jsonResponse(200, fixtureJson(versionCode = 9)))
|
||||||
|
val manager = manager()
|
||||||
|
|
||||||
|
manager.check()
|
||||||
|
await(manager) { it !is UpdateState.Checking }
|
||||||
|
clock += 60_000
|
||||||
|
|
||||||
|
manager.check()
|
||||||
|
|
||||||
|
assertEquals(1, server.requestCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a skipped build stays skipped until the user asks again`() {
|
||||||
|
server.enqueue(jsonResponse(200, fixtureJson(versionCode = 9)))
|
||||||
|
server.enqueue(jsonResponse(200, fixtureJson(versionCode = 9)))
|
||||||
|
server.enqueue(jsonResponse(200, fixtureJson(versionCode = 9)))
|
||||||
|
val manager = manager()
|
||||||
|
|
||||||
|
manager.check()
|
||||||
|
val offered = assertIs<UpdateState.Available>(
|
||||||
|
await(manager) { it !is UpdateState.Checking },
|
||||||
|
)
|
||||||
|
manager.skip()
|
||||||
|
assertIs<UpdateState.Idle>(manager.state.value)
|
||||||
|
assertEquals(9L, prefs.skippedUpdateVersionCode)
|
||||||
|
|
||||||
|
clock += 7 * 60 * 60 * 1000L
|
||||||
|
manager.check()
|
||||||
|
assertIs<UpdateState.Idle>(await(manager) { it is UpdateState.Idle })
|
||||||
|
assertEquals(2, server.requestCount)
|
||||||
|
|
||||||
|
manager.check(force = true)
|
||||||
|
val forced = assertIs<UpdateState.Available>(
|
||||||
|
await(manager) { it is UpdateState.Available },
|
||||||
|
)
|
||||||
|
assertEquals(offered.update.versionCode, forced.update.versionCode)
|
||||||
|
assertEquals(3, server.requestCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a signing key mismatch is refused before any download`() {
|
||||||
|
val wrongKey = "00".repeat(32)
|
||||||
|
server.enqueue(jsonResponse(200, fixtureJson(versionCode = 9, signer = wrongKey)))
|
||||||
|
val manager = manager()
|
||||||
|
|
||||||
|
manager.check()
|
||||||
|
val state = await(manager) { it !is UpdateState.Checking }
|
||||||
|
|
||||||
|
val failed = assertIs<UpdateState.Failed>(state)
|
||||||
|
assertTrue(failed.message.contains("signed with a different key"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a failed download keeps the update around for retry`() {
|
||||||
|
server.enqueue(jsonResponse(200, fixtureJson(versionCode = 9)))
|
||||||
|
server.enqueue(MockResponse.Builder().code(500).body("nope").build())
|
||||||
|
val manager = manager()
|
||||||
|
|
||||||
|
manager.check()
|
||||||
|
await(manager) { it is UpdateState.Available }
|
||||||
|
manager.download()
|
||||||
|
val state = await(manager) { it is UpdateState.Failed }
|
||||||
|
|
||||||
|
val failed = assertIs<UpdateState.Failed>(state)
|
||||||
|
assertEquals(9L, failed.update?.versionCode)
|
||||||
|
assertTrue(!File(context.cacheDir, "updates/kadence-9.apk").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `debug builds do not check at all`() {
|
||||||
|
val manager = manager(debugBuild = true)
|
||||||
|
|
||||||
|
manager.check(force = true)
|
||||||
|
|
||||||
|
val failed = assertIs<UpdateState.Failed>(manager.state.value)
|
||||||
|
assertTrue(failed.message.contains("release builds"))
|
||||||
|
assertEquals(0, server.requestCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unconfigured install stays quiet`() {
|
||||||
|
prefs.update { it.copy(onboarded = false) }
|
||||||
|
val manager = manager()
|
||||||
|
|
||||||
|
manager.check()
|
||||||
|
|
||||||
|
assertIs<UpdateState.Idle>(manager.state.value)
|
||||||
|
assertEquals(0, server.requestCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ from app.api import (
|
|||||||
routes_settings,
|
routes_settings,
|
||||||
routes_tasks,
|
routes_tasks,
|
||||||
routes_timer,
|
routes_timer,
|
||||||
|
routes_updates,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -16,5 +17,6 @@ api_router.include_router(routes_tasks.router)
|
|||||||
api_router.include_router(routes_blocker.router)
|
api_router.include_router(routes_blocker.router)
|
||||||
api_router.include_router(routes_reports.router)
|
api_router.include_router(routes_reports.router)
|
||||||
api_router.include_router(routes_settings.router)
|
api_router.include_router(routes_settings.router)
|
||||||
|
api_router.include_router(routes_updates.router)
|
||||||
|
|
||||||
__all__ = ["api_router"]
|
__all__ = ["api_router"]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""The Android OTA feed.
|
||||||
|
|
||||||
|
The APK never comes from the phone's own Gitea credentials — the repository is
|
||||||
|
private. The backend reads the newest successful Actions artifact and re-serves
|
||||||
|
it here, behind the same auth as the rest of `/api`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from app.services import android_updates
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/updates", tags=["updates"])
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_http(exc: android_updates.UpdateUnavailable) -> None:
|
||||||
|
if isinstance(exc, android_updates.UpdateNotConfigured):
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
if isinstance(exc, android_updates.UpdateNotFound):
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/android")
|
||||||
|
def android_update() -> dict:
|
||||||
|
"""Metadata for the newest successful master build.
|
||||||
|
|
||||||
|
The client compares `version_code` with its own; the backend deliberately
|
||||||
|
does not try to know which build the phone is running.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return android_updates.latest_android()
|
||||||
|
except android_updates.UpdateUnavailable as exc:
|
||||||
|
_raise_http(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/android/apk")
|
||||||
|
def android_apk() -> FileResponse:
|
||||||
|
"""The APK for the newest successful master build."""
|
||||||
|
try:
|
||||||
|
path, metadata = android_updates.android_apk()
|
||||||
|
except android_updates.UpdateUnavailable as exc:
|
||||||
|
_raise_http(exc)
|
||||||
|
|
||||||
|
version = metadata.get("version_name") or metadata.get("version_code") or "latest"
|
||||||
|
return FileResponse(
|
||||||
|
path,
|
||||||
|
media_type="application/vnd.android.package-archive",
|
||||||
|
filename=f"kadence-{version}.apk",
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
@@ -55,6 +55,30 @@ class EnvSettings(BaseSettings):
|
|||||||
default=True,
|
default=True,
|
||||||
description="Run the Telegram command bot when a token is configured.",
|
description="Run the Telegram command bot when a token is configured.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---- Android OTA updates -----------------------------------------------
|
||||||
|
# The Gitea repository is private, so these cannot be read anonymously.
|
||||||
|
# The token is a read-only personal access token; it never leaves the
|
||||||
|
# backend, and the Android client only sees /api/updates/android.
|
||||||
|
gitea_url: str = Field(
|
||||||
|
default="https://git.timka.su",
|
||||||
|
description="Base URL of the Gitea instance that runs the Android workflow.",
|
||||||
|
)
|
||||||
|
gitea_repo: str = Field(
|
||||||
|
default="timka123/kadence",
|
||||||
|
description="owner/repository whose Actions artifacts carry the APK.",
|
||||||
|
)
|
||||||
|
gitea_token: str = Field(
|
||||||
|
default="",
|
||||||
|
description=(
|
||||||
|
"Read-only Gitea token used to read Actions artifacts. Empty disables "
|
||||||
|
"the Android update endpoint."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
gitea_update_branch: str = Field(
|
||||||
|
default="master",
|
||||||
|
description="Only successful builds from this branch are offered as updates.",
|
||||||
|
)
|
||||||
api_token: str = Field(
|
api_token: str = Field(
|
||||||
default="",
|
default="",
|
||||||
description=(
|
description=(
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
"""The Android OTA feed, read from this repository's Gitea Actions artifacts.
|
||||||
|
|
||||||
|
The Gitea repository is private, so the phone cannot read Actions artifacts
|
||||||
|
itself. This module is the only place that touches the Gitea token: it finds the
|
||||||
|
newest successful build from the configured branch, reads the small metadata
|
||||||
|
artifact, and pulls the APK out of the matching artifact zip on demand.
|
||||||
|
|
||||||
|
Nothing here trusts the metadata blindly. Every field falls back to what the
|
||||||
|
Gitea run itself reports, so an older artifact that predates `update.json` still
|
||||||
|
produces a usable answer instead of a 500.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import EnvSettings, env_settings
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
METADATA_ARTIFACT = "kadence-update-metadata"
|
||||||
|
APK_ARTIFACT = "kadence-release-apk"
|
||||||
|
|
||||||
|
_METADATA_TTL_SECONDS = 60.0
|
||||||
|
_HTTP_TIMEOUT = 30.0
|
||||||
|
_DOWNLOAD_TIMEOUT = 120.0
|
||||||
|
_MAX_METADATA_BYTES = 1_000_000
|
||||||
|
_CACHE_DIR = Path(tempfile.gettempdir()) / "kadence-updates"
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateUnavailable(RuntimeError):
|
||||||
|
"""The feed cannot be built right now."""
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateNotConfigured(UpdateUnavailable):
|
||||||
|
"""No Gitea token was supplied, so there is nothing to read."""
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateNotFound(UpdateUnavailable):
|
||||||
|
"""There is no successful artifact for the configured branch."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ResolvedUpdate:
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
apk_artifact_id: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Artifact:
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
created_at: str
|
||||||
|
size_bytes: int
|
||||||
|
expired: bool
|
||||||
|
run_id: int
|
||||||
|
run_number: int
|
||||||
|
branch: str
|
||||||
|
commit: str
|
||||||
|
conclusion: str
|
||||||
|
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_resolved_cache: tuple[float, ResolvedUpdate] | None = None
|
||||||
|
_apk_cache: dict[int, Path] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def reset_cache() -> None:
|
||||||
|
"""Drop the in-process caches. Tests use this between Gitea responses."""
|
||||||
|
global _resolved_cache
|
||||||
|
with _lock:
|
||||||
|
_resolved_cache = None
|
||||||
|
_apk_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Gitea reads -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _settings() -> EnvSettings:
|
||||||
|
settings = env_settings()
|
||||||
|
if not settings.gitea_token:
|
||||||
|
raise UpdateNotConfigured(
|
||||||
|
"Android updates are not configured: set GITEA_TOKEN on the backend."
|
||||||
|
)
|
||||||
|
if not settings.gitea_repo or "/" not in settings.gitea_repo:
|
||||||
|
raise UpdateNotConfigured("Android updates need GITEA_REPO as owner/repository.")
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def _client(settings: EnvSettings, *, timeout: float = _HTTP_TIMEOUT) -> httpx.Client:
|
||||||
|
return httpx.Client(
|
||||||
|
base_url=settings.gitea_url.rstrip("/"),
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {settings.gitea_token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
timeout=timeout,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _api(settings: EnvSettings, path: str) -> str:
|
||||||
|
return f"/api/v1/repos/{settings.gitea_repo}{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact(raw: Any) -> Artifact | None:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
run = raw.get("workflow_run")
|
||||||
|
run = run if isinstance(run, dict) else {}
|
||||||
|
try:
|
||||||
|
artifact_id = int(raw.get("id") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if artifact_id <= 0:
|
||||||
|
return None
|
||||||
|
return Artifact(
|
||||||
|
id=artifact_id,
|
||||||
|
name=str(raw.get("name") or ""),
|
||||||
|
created_at=str(raw.get("created_at") or ""),
|
||||||
|
size_bytes=int(raw.get("size_in_bytes") or 0),
|
||||||
|
expired=bool(raw.get("expired")),
|
||||||
|
run_id=int(run.get("id") or 0),
|
||||||
|
run_number=int(run.get("run_number") or 0),
|
||||||
|
branch=str(run.get("head_branch") or ""),
|
||||||
|
commit=str(run.get("head_sha") or ""),
|
||||||
|
conclusion=str(run.get("conclusion") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_artifacts(
|
||||||
|
client: httpx.Client, settings: EnvSettings, name: str
|
||||||
|
) -> list[Artifact]:
|
||||||
|
try:
|
||||||
|
response = client.get(_api(settings, "/actions/artifacts"), params={"name": name})
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
raise UpdateUnavailable(f"Could not read Gitea artifacts: {exc}") from exc
|
||||||
|
|
||||||
|
entries = payload.get("artifacts") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return []
|
||||||
|
return [artifact for raw in entries if (artifact := _artifact(raw)) is not None]
|
||||||
|
|
||||||
|
|
||||||
|
def _successful(artifacts: list[Artifact], branch: str) -> list[Artifact]:
|
||||||
|
"""Newest first, and only successful builds from the requested branch."""
|
||||||
|
usable = [
|
||||||
|
artifact
|
||||||
|
for artifact in artifacts
|
||||||
|
if not artifact.expired
|
||||||
|
and artifact.conclusion == "success"
|
||||||
|
and artifact.branch == branch
|
||||||
|
]
|
||||||
|
# `run_number` is the monotonic number Android uses as `versionCode`. The
|
||||||
|
# created_at string is the tie-breaker for older Gitea runs that report 0.
|
||||||
|
return sorted(usable, key=lambda item: (item.run_number, item.created_at), reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _download_artifact(client: httpx.Client, settings: EnvSettings, artifact: Artifact) -> Path:
|
||||||
|
"""Download one artifact zip to a temporary file and return its path."""
|
||||||
|
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = _CACHE_DIR / f"artifact-{artifact.id}.zip"
|
||||||
|
if target.is_file() and target.stat().st_size > 0:
|
||||||
|
return target
|
||||||
|
|
||||||
|
temporary = target.with_suffix(".zip.part")
|
||||||
|
try:
|
||||||
|
with client.stream(
|
||||||
|
"GET",
|
||||||
|
_api(settings, f"/actions/artifacts/{artifact.id}/zip"),
|
||||||
|
timeout=_DOWNLOAD_TIMEOUT,
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
with temporary.open("wb") as handle:
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
handle.write(chunk)
|
||||||
|
if temporary.stat().st_size == 0:
|
||||||
|
raise UpdateUnavailable("Gitea returned an empty artifact archive.")
|
||||||
|
temporary.replace(target)
|
||||||
|
except (httpx.HTTPError, OSError) as exc:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
raise UpdateUnavailable(f"Could not download the Gitea artifact: {exc}") from exc
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _member(zf: zipfile.ZipFile, basename: str) -> zipfile.ZipInfo | None:
|
||||||
|
for info in zf.infolist():
|
||||||
|
if not info.is_dir() and Path(info.filename).name == basename:
|
||||||
|
return info
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_metadata(client: httpx.Client, settings: EnvSettings, artifact: Artifact) -> dict[str, Any]:
|
||||||
|
archive = _download_artifact(client, settings, artifact)
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(archive) as zf:
|
||||||
|
info = _member(zf, "update.json")
|
||||||
|
if info is None:
|
||||||
|
raise UpdateUnavailable("The update metadata artifact has no update.json.")
|
||||||
|
if info.file_size > _MAX_METADATA_BYTES:
|
||||||
|
raise UpdateUnavailable("The update metadata artifact is unexpectedly large.")
|
||||||
|
raw = json.loads(zf.read(info).decode("utf-8"))
|
||||||
|
except (zipfile.BadZipFile, OSError, UnicodeDecodeError, ValueError) as exc:
|
||||||
|
raise UpdateUnavailable(f"Could not read the update metadata: {exc}") from exc
|
||||||
|
return raw if isinstance(raw, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _int(value: Any, fallback: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_from_artifact(raw: dict[str, Any], artifact: Artifact) -> dict[str, Any]:
|
||||||
|
run_number = artifact.run_number
|
||||||
|
return {
|
||||||
|
"version_code": _int(raw.get("version_code"), run_number),
|
||||||
|
"version_name": str(
|
||||||
|
raw.get("version_name") or f"0.1.0-master.{run_number or artifact.run_id}"
|
||||||
|
),
|
||||||
|
"commit": str(raw.get("commit") or artifact.commit),
|
||||||
|
"built_at": str(raw.get("built_at") or artifact.created_at),
|
||||||
|
"size_bytes": _int(raw.get("size_bytes"), artifact.size_bytes),
|
||||||
|
"sha256": str(raw.get("sha256") or ""),
|
||||||
|
"signer_sha256": str(raw.get("signer_sha256") or ""),
|
||||||
|
"run_number": run_number,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_metadata(artifact: Artifact) -> dict[str, Any]:
|
||||||
|
"""For artifacts built before the metadata artifact existed."""
|
||||||
|
return _metadata_from_artifact({}, artifact)
|
||||||
|
|
||||||
|
|
||||||
|
def _apk_for_run(artifacts: list[Artifact], update: Artifact) -> Artifact | None:
|
||||||
|
for artifact in artifacts:
|
||||||
|
if artifact.run_id and artifact.run_id == update.run_id and not artifact.expired:
|
||||||
|
return artifact
|
||||||
|
# Defensive fallback for an instance that omits workflow_run.id on an
|
||||||
|
# artifact but keeps the run number.
|
||||||
|
if update.run_number:
|
||||||
|
for artifact in artifacts:
|
||||||
|
if artifact.run_number == update.run_number and not artifact.expired:
|
||||||
|
return artifact
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(settings: EnvSettings) -> ResolvedUpdate:
|
||||||
|
global _resolved_cache
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
with _lock:
|
||||||
|
cached = _resolved_cache
|
||||||
|
if cached is not None and now - cached[0] < _METADATA_TTL_SECONDS:
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
|
with _client(settings) as client:
|
||||||
|
metadata_artifacts = _successful(
|
||||||
|
_list_artifacts(client, settings, METADATA_ARTIFACT),
|
||||||
|
settings.gitea_update_branch,
|
||||||
|
)
|
||||||
|
apk_artifacts = _list_artifacts(client, settings, APK_ARTIFACT)
|
||||||
|
usable_apk = _successful(apk_artifacts, settings.gitea_update_branch)
|
||||||
|
|
||||||
|
resolved: ResolvedUpdate | None = None
|
||||||
|
for artifact in metadata_artifacts:
|
||||||
|
apk = _apk_for_run(usable_apk, artifact)
|
||||||
|
if apk is None:
|
||||||
|
continue
|
||||||
|
resolved = ResolvedUpdate(
|
||||||
|
metadata=_metadata_from_artifact(
|
||||||
|
_read_metadata(client, settings, artifact), artifact
|
||||||
|
),
|
||||||
|
apk_artifact_id=apk.id,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Before the first build after this feature landed there is no metadata
|
||||||
|
# artifact. The run number is the versionCode, so the newest APK
|
||||||
|
# artifact is still enough to offer an update.
|
||||||
|
if resolved is None and usable_apk:
|
||||||
|
newest = usable_apk[0]
|
||||||
|
resolved = ResolvedUpdate(
|
||||||
|
metadata=_derive_metadata(newest),
|
||||||
|
apk_artifact_id=newest.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resolved is None:
|
||||||
|
raise UpdateNotFound(
|
||||||
|
f"No successful {settings.gitea_update_branch} build with an APK was found."
|
||||||
|
)
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
_resolved_cache = (time.monotonic(), resolved)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _apk_file(client: httpx.Client, settings: EnvSettings, artifact_id: int) -> Path:
|
||||||
|
with _lock:
|
||||||
|
cached = _apk_cache.get(artifact_id)
|
||||||
|
if cached is not None and cached.is_file() and cached.stat().st_size > 0:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# The artifact list does not carry the APK's own record here, so fetch it by
|
||||||
|
# id. The id is enough for both the zip URL and the cache key.
|
||||||
|
artifact = Artifact(
|
||||||
|
id=artifact_id,
|
||||||
|
name=APK_ARTIFACT,
|
||||||
|
created_at="",
|
||||||
|
size_bytes=0,
|
||||||
|
expired=False,
|
||||||
|
run_id=0,
|
||||||
|
run_number=0,
|
||||||
|
branch="",
|
||||||
|
commit="",
|
||||||
|
conclusion="success",
|
||||||
|
)
|
||||||
|
archive = _download_artifact(client, settings, artifact)
|
||||||
|
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = _CACHE_DIR / f"kadence-{artifact_id}.apk"
|
||||||
|
temporary = target.with_suffix(".apk.part")
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(archive) as zf:
|
||||||
|
info = _member(zf, "app-release.apk")
|
||||||
|
if info is None:
|
||||||
|
raise UpdateUnavailable("The APK artifact has no app-release.apk.")
|
||||||
|
with zf.open(info) as source, temporary.open("wb") as destination:
|
||||||
|
shutil.copyfileobj(source, destination)
|
||||||
|
if temporary.stat().st_size == 0:
|
||||||
|
raise UpdateUnavailable("The APK inside the artifact is empty.")
|
||||||
|
temporary.replace(target)
|
||||||
|
except (zipfile.BadZipFile, OSError) as exc:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
raise UpdateUnavailable(f"Could not extract the APK: {exc}") from exc
|
||||||
|
except UpdateUnavailable:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
_apk_cache[artifact_id] = target
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the public feed -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def latest_android() -> dict[str, Any]:
|
||||||
|
"""The newest successful build, as JSON-ready metadata."""
|
||||||
|
settings = _settings()
|
||||||
|
return _resolve(settings).metadata
|
||||||
|
|
||||||
|
|
||||||
|
def android_apk() -> tuple[Path, dict[str, Any]]:
|
||||||
|
"""The APK for the newest build, along with its metadata."""
|
||||||
|
settings = _settings()
|
||||||
|
resolved = _resolve(settings)
|
||||||
|
with _client(settings) as client:
|
||||||
|
path = _apk_file(client, settings, resolved.apk_artifact_id)
|
||||||
|
return path, resolved.metadata
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""The OTA feed: which artifact wins, and what the phone receives."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from app.config import env_settings
|
||||||
|
from app.services import android_updates
|
||||||
|
|
||||||
|
|
||||||
|
def _configure(monkeypatch, *, token: str = "read-token") -> None:
|
||||||
|
monkeypatch.setenv("GITEA_URL", "https://git.example.test")
|
||||||
|
monkeypatch.setenv("GITEA_REPO", "timka123/kadence")
|
||||||
|
monkeypatch.setenv("GITEA_UPDATE_BRANCH", "master")
|
||||||
|
monkeypatch.setenv("GITEA_TOKEN", token)
|
||||||
|
env_settings.cache_clear()
|
||||||
|
android_updates.reset_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact(
|
||||||
|
artifact_id: int,
|
||||||
|
*,
|
||||||
|
run_id: int,
|
||||||
|
run_number: int,
|
||||||
|
name: str = android_updates.METADATA_ARTIFACT,
|
||||||
|
branch: str = "master",
|
||||||
|
conclusion: str = "success",
|
||||||
|
expired: bool = False,
|
||||||
|
created_at: str = "2026-10-20T10:00:00Z",
|
||||||
|
size: int = 100,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"id": artifact_id,
|
||||||
|
"name": name,
|
||||||
|
"created_at": created_at,
|
||||||
|
"size_in_bytes": size,
|
||||||
|
"expired": expired,
|
||||||
|
"workflow_run": {
|
||||||
|
"id": run_id,
|
||||||
|
"run_number": run_number,
|
||||||
|
"head_branch": branch,
|
||||||
|
"head_sha": f"sha-{run_number}",
|
||||||
|
"conclusion": conclusion,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _zip(files: dict[str, bytes]) -> bytes:
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, "w") as archive:
|
||||||
|
for name, body in files.items():
|
||||||
|
archive.writestr(name, body)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _install_transport(monkeypatch, handler) -> None:
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
|
||||||
|
def client(_settings, *, timeout: float = 30.0) -> httpx.Client:
|
||||||
|
return httpx.Client(
|
||||||
|
base_url="https://git.example.test",
|
||||||
|
transport=transport,
|
||||||
|
timeout=timeout,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(android_updates, "_client", client)
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_body(version_code: int, version_name: str) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"version_code": version_code,
|
||||||
|
"version_name": version_name,
|
||||||
|
"commit": f"sha-{version_code}",
|
||||||
|
"built_at": "2026-10-20T10:00:00Z",
|
||||||
|
"size_bytes": 1234,
|
||||||
|
"sha256": "abc",
|
||||||
|
"signer_sha256": "def",
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_newest_successful_master_build_wins(monkeypatch):
|
||||||
|
_configure(monkeypatch)
|
||||||
|
metadata_artifacts = [
|
||||||
|
_artifact(11, run_id=101, run_number=10),
|
||||||
|
_artifact(12, run_id=102, run_number=20),
|
||||||
|
# A newer tag build must not become the master channel.
|
||||||
|
_artifact(13, run_id=103, run_number=30, branch="v0.2.0"),
|
||||||
|
# A failed build uploaded an artifact but must never be offered.
|
||||||
|
_artifact(14, run_id=104, run_number=40, conclusion="failure"),
|
||||||
|
# An expired artifact is not downloadable any more.
|
||||||
|
_artifact(15, run_id=105, run_number=50, expired=True),
|
||||||
|
]
|
||||||
|
apk_artifacts = [
|
||||||
|
_artifact(
|
||||||
|
artifact_id + 100,
|
||||||
|
run_id=run_id,
|
||||||
|
run_number=run_number,
|
||||||
|
name=android_updates.APK_ARTIFACT,
|
||||||
|
)
|
||||||
|
for artifact_id, run_id, run_number in (
|
||||||
|
(11, 101, 10),
|
||||||
|
(12, 102, 20),
|
||||||
|
(13, 103, 30),
|
||||||
|
(14, 104, 40),
|
||||||
|
(15, 105, 50),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
zips = {
|
||||||
|
12: _zip({"update.json": _metadata_body(20, "0.1.0-master.20")}),
|
||||||
|
112: _zip({"app-release.apk": b"new apk"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.path.endswith("/actions/artifacts"):
|
||||||
|
name = request.url.params.get("name")
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"total_count": 5,
|
||||||
|
"artifacts": (
|
||||||
|
metadata_artifacts
|
||||||
|
if name == android_updates.METADATA_ARTIFACT
|
||||||
|
else apk_artifacts
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
artifact_id = int(request.url.path.rstrip("/").split("/")[-2])
|
||||||
|
return httpx.Response(200, content=zips[artifact_id])
|
||||||
|
|
||||||
|
_install_transport(monkeypatch, handler)
|
||||||
|
|
||||||
|
metadata = android_updates.latest_android()
|
||||||
|
assert metadata["version_code"] == 20
|
||||||
|
assert metadata["version_name"] == "0.1.0-master.20"
|
||||||
|
assert metadata["commit"] == "sha-20"
|
||||||
|
assert metadata["size_bytes"] == 1234
|
||||||
|
assert metadata["signer_sha256"] == "def"
|
||||||
|
|
||||||
|
path, same = android_updates.android_apk()
|
||||||
|
assert path.read_bytes() == b"new apk"
|
||||||
|
assert same == metadata
|
||||||
|
|
||||||
|
|
||||||
|
def test_falls_back_to_an_apk_artifact_without_metadata(monkeypatch):
|
||||||
|
_configure(monkeypatch)
|
||||||
|
apk = _artifact(
|
||||||
|
201,
|
||||||
|
run_id=301,
|
||||||
|
run_number=77,
|
||||||
|
name=android_updates.APK_ARTIFACT,
|
||||||
|
created_at="2026-10-21T09:30:00Z",
|
||||||
|
size=900,
|
||||||
|
)
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.path.endswith("/actions/artifacts"):
|
||||||
|
name = request.url.params.get("name")
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"total_count": 0 if name == android_updates.METADATA_ARTIFACT else 1,
|
||||||
|
"artifacts": [] if name == android_updates.METADATA_ARTIFACT else [apk]},
|
||||||
|
)
|
||||||
|
raise AssertionError("the metadata endpoint should not download anything")
|
||||||
|
|
||||||
|
_install_transport(monkeypatch, handler)
|
||||||
|
|
||||||
|
metadata = android_updates.latest_android()
|
||||||
|
assert metadata["version_code"] == 77
|
||||||
|
assert metadata["version_name"] == "0.1.0-master.77"
|
||||||
|
assert metadata["commit"] == "sha-77"
|
||||||
|
assert metadata["built_at"] == "2026-10-21T09:30:00Z"
|
||||||
|
assert metadata["size_bytes"] == 900
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_token_is_not_configured(monkeypatch):
|
||||||
|
_configure(monkeypatch, token="")
|
||||||
|
with pytest.raises(android_updates.UpdateNotConfigured):
|
||||||
|
android_updates.latest_android()
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitea_failure_is_reported_as_unavailable(monkeypatch):
|
||||||
|
_configure(monkeypatch)
|
||||||
|
_install_transport(monkeypatch, lambda _request: httpx.Response(500, text="boom"))
|
||||||
|
|
||||||
|
with pytest.raises(android_updates.UpdateUnavailable):
|
||||||
|
android_updates.latest_android()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_route_reports_503_when_updates_are_not_configured(client, monkeypatch):
|
||||||
|
_configure(monkeypatch, token="")
|
||||||
|
|
||||||
|
response = client.get("/api/updates/android")
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert "GITEA_TOKEN" in response.json()["detail"]
|
||||||
@@ -46,6 +46,13 @@ services:
|
|||||||
TELEGRAM_BOT_ENABLED: ${TELEGRAM_BOT_ENABLED:-true}
|
TELEGRAM_BOT_ENABLED: ${TELEGRAM_BOT_ENABLED:-true}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
|
||||||
|
# Android OTA: the token never reaches the phone. Empty disables the
|
||||||
|
# update endpoints (they answer 503) without affecting anything else.
|
||||||
|
GITEA_URL: ${GITEA_URL:-https://git.timka.su}
|
||||||
|
GITEA_REPO: ${GITEA_REPO:-timka123/kadence}
|
||||||
|
GITEA_TOKEN: ${GITEA_TOKEN:-}
|
||||||
|
GITEA_UPDATE_BRANCH: ${GITEA_UPDATE_BRANCH:-master}
|
||||||
|
|
||||||
# Web login — see .env.example for what each does.
|
# Web login — see .env.example for what each does.
|
||||||
AUTH_USERNAME: ${AUTH_USERNAME:-timka}
|
AUTH_USERNAME: ${AUTH_USERNAME:-timka}
|
||||||
AUTH_PASSWORD: ${AUTH_PASSWORD:-}
|
AUTH_PASSWORD: ${AUTH_PASSWORD:-}
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ docker compose logs -f # expect: "runner registered successfully"
|
|||||||
```
|
```
|
||||||
|
|
||||||
That is it. The runner appears in Gitea under the scope you took the token from,
|
That is it. The runner appears in Gitea under the scope you took the token from,
|
||||||
with its labels listed beside it.
|
with its labels listed beside it. For the release process that uses this runner —
|
||||||
|
tags, secrets, artifacts, deployment and rollback — see
|
||||||
|
[`production-builds.md`](production-builds.md).
|
||||||
|
|
||||||
### What the pieces do
|
### What the pieces do
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
# Production builds in Gitea Actions
|
||||||
|
|
||||||
|
This is the practical guide to turning a commit into something that can be
|
||||||
|
deployed or installed: the backend + web image, the signed Android APK, and what
|
||||||
|
to do about the desktop agent. It assumes the runner from
|
||||||
|
[`gitea-runner.md`](gitea-runner.md) is already registered and online.
|
||||||
|
|
||||||
|
The short version:
|
||||||
|
|
||||||
|
* a **push to `master`** produces a snapshot — `latest` plus the short commit SHA;
|
||||||
|
* a **`v*` tag** produces a release — the same image tagged with the version, and
|
||||||
|
an APK attached to the Gitea release.
|
||||||
|
|
||||||
|
A tag is the only thing that means "production". `latest` moves with every push
|
||||||
|
and should never be pinned in a production compose file.
|
||||||
|
|
||||||
|
* [What the workflows produce](#what-the-workflows-produce)
|
||||||
|
* [One-time setup](#one-time-setup)
|
||||||
|
* [Cutting a release](#cutting-a-release)
|
||||||
|
* [The backend + web image](#the-backend--web-image)
|
||||||
|
* [The Android APK](#the-android-apk)
|
||||||
|
* [Over-the-air updates](#over-the-air-updates)
|
||||||
|
* [The desktop agent](#the-desktop-agent)
|
||||||
|
* [Versioning and rollback](#versioning-and-rollback)
|
||||||
|
* [Downloading artifacts without the web UI](#downloading-artifacts-without-the-web-ui)
|
||||||
|
* [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the workflows produce
|
||||||
|
|
||||||
|
| Workflow | Trigger | Artifact |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `test.yml` | push to `master`, every pull request | No release artifact. It is the gate: backend, desktop, `:core`, and the web typecheck + build |
|
||||||
|
| `lint.yml` | push to `master`, every pull request | No release artifact. `ruff check` over `backend/` and `desktop/` |
|
||||||
|
| `docker.yml` | push to `master`, `v*` tags | Image `git.timka.su/timka123/kadence:<short-sha>` and `:latest`; on a tag also `:<version>` |
|
||||||
|
| `android-release.yml` | push to `master`, `v*` tags | APK artifact `kadence-release-apk` and metadata `kadence-update-metadata`; on a tag also the release asset `kadence-<version>.apk` |
|
||||||
|
|
||||||
|
There is no build job for the desktop agent. See
|
||||||
|
[The desktop agent](#the-desktop-agent) for what that means in practice.
|
||||||
|
|
||||||
|
## One-time setup
|
||||||
|
|
||||||
|
### The runner
|
||||||
|
|
||||||
|
Use the docker-mode runner from [`gitea-runner.md`](gitea-runner.md). The
|
||||||
|
Android job needs a real Android SDK and Gradle cache, and the Docker job needs
|
||||||
|
access to the host's Docker daemon through the mounted socket. A host-mode runner
|
||||||
|
answering `ubuntu-latest` is the most common reason a production build behaves
|
||||||
|
differently from the one in the docs.
|
||||||
|
|
||||||
|
### Secrets
|
||||||
|
|
||||||
|
All secrets live under **Repository → Settings → Actions → Secrets**.
|
||||||
|
|
||||||
|
| Secret | Used by | What it must be |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `REGISTRY_TOKEN` | `docker.yml` | A personal access token with `package:write`. Gitea's built-in job token cannot publish packages |
|
||||||
|
| `REGISTRY_USER` | `docker.yml` | Optional. The registry username paired with the token; defaults to the repository owner |
|
||||||
|
| `KADENCE_KEYSTORE_BASE64` | `android-release.yml` | `base64 -w0 your-release.keystore` |
|
||||||
|
| `KADENCE_KEYSTORE_PASSWORD` | `android-release.yml` | The store password |
|
||||||
|
| `KADENCE_KEY_ALIAS` | `android-release.yml` | The key alias, normally `kadence` |
|
||||||
|
| `KADENCE_KEY_PASSWORD` | `android-release.yml` | The key password |
|
||||||
|
|
||||||
|
The workflow uses Gitea's built-in `GITEA_TOKEN` to attach the APK to a tagged
|
||||||
|
release. That token is supplied by Actions; it is not one of the secrets above.
|
||||||
|
|
||||||
|
Without `REGISTRY_TOKEN` the Docker workflow still builds and smoke-tests the
|
||||||
|
image, then skips the push with a warning. That is useful for a test repository,
|
||||||
|
not for production.
|
||||||
|
|
||||||
|
Without the keystore secrets the Android job still builds an APK, but it is
|
||||||
|
**debug-signed**. It installs on your own phone and cannot be upgraded by a
|
||||||
|
properly signed release later. For production, set all four keystore values:
|
||||||
|
`KADENCE_REQUIRE_SIGNING` then makes a missing or misnamed secret fail the build
|
||||||
|
instead of silently falling back to the debug key.
|
||||||
|
|
||||||
|
### The keystore
|
||||||
|
|
||||||
|
If you do not already have one, create it once and keep it somewhere safe — in a
|
||||||
|
password manager or another encrypted backup, never in the repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
keytool -genkeypair -v \
|
||||||
|
-keystore kadence-release.keystore \
|
||||||
|
-alias kadence \
|
||||||
|
-keyalg RSA -keysize 4096 -validity 10000 \
|
||||||
|
-dname "CN=Kadence, O=Kadence"
|
||||||
|
|
||||||
|
base64 -w0 kadence-release.keystore > kadence-release.keystore.b64
|
||||||
|
```
|
||||||
|
|
||||||
|
Put the contents of `kadence-release.keystore.b64` into
|
||||||
|
`KADENCE_KEYSTORE_BASE64`. Losing this file means future APKs cannot upgrade the
|
||||||
|
installed app; Android treats them as a different application.
|
||||||
|
|
||||||
|
### The tag convention
|
||||||
|
|
||||||
|
Use annotated `v` tags with a semantic version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag -a v0.2.0 -m "Kadence 0.2.0"
|
||||||
|
git push origin v0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
`v0.2.0`, not `0.2.0`. Both publishing workflows filter on `v*`, and the Docker
|
||||||
|
workflow uses the tag as the image tag verbatim.
|
||||||
|
|
||||||
|
## Cutting a release
|
||||||
|
|
||||||
|
1. **Start from a green `master`.** The release workflows do not wait for the
|
||||||
|
test workflow, so check that the latest `master` run is green before tagging.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout master
|
||||||
|
git pull --ff-only
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Tag the commit you mean to ship.** The tag is what the workflows check out;
|
||||||
|
an unpushed commit is not part of the build.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag -a v0.2.0 -m "Kadence 0.2.0"
|
||||||
|
git push origin v0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Watch both workflows.** In Gitea: **Actions → `docker`** and
|
||||||
|
**Actions → `android`**. The Android job is the slower one on a cold cache
|
||||||
|
because it installs the SDK and fetches the Glyph Matrix AAR.
|
||||||
|
|
||||||
|
4. **Verify the image.** It should exist under both the version and the commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull git.timka.su/timka123/kadence:v0.2.0
|
||||||
|
docker image inspect git.timka.su/timka123/kadence:v0.2.0 \
|
||||||
|
--format '{{index .RepoDigests 0}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Verify the APK.** Download it from the release page
|
||||||
|
(`https://git.timka.su/timka123/kadence/releases/tag/v0.2.0`) and check the
|
||||||
|
certificate before putting it on a phone:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apksigner verify --print-certs kadence-v0.2.0.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
The certificate must be the release key, not `CN=Android Debug`.
|
||||||
|
|
||||||
|
6. **Deploy or install.** The next two sections cover each artifact.
|
||||||
|
|
||||||
|
## The backend + web image
|
||||||
|
|
||||||
|
### What the job does
|
||||||
|
|
||||||
|
`docker.yml` builds the single image from the repository `Dockerfile`: Vite
|
||||||
|
builds the web app, then the Python 3.12 runtime serves both the API and the
|
||||||
|
built static files. The job then starts the image against SQLite with the
|
||||||
|
scheduler and Telegram bot disabled and waits for `/api/health` before it pushes
|
||||||
|
anything. A tag that cannot boot is not published.
|
||||||
|
|
||||||
|
The pushed names are:
|
||||||
|
|
||||||
|
| Tag | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `:<short-sha>` | The exact commit, for every build |
|
||||||
|
| `:latest` | The newest successful build from `master` or a tag; moves on every push |
|
||||||
|
| `:vX.Y.Z` | A tagged release |
|
||||||
|
|
||||||
|
### Deploying the image
|
||||||
|
|
||||||
|
The repository's `deploy/deploy.sh` currently builds on the server from the
|
||||||
|
checked-out branch. That is the simplest path, and it means the deployment does
|
||||||
|
not need registry credentials.
|
||||||
|
|
||||||
|
To deploy the image that CI actually built instead, pin it in the environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh sw_pl_1
|
||||||
|
cd /opt/kadence
|
||||||
|
|
||||||
|
# Only needed if the registry package is private.
|
||||||
|
docker login git.timka.su
|
||||||
|
|
||||||
|
export KADENCE_IMAGE=git.timka.su/timka123/kadence:v0.2.0
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-build
|
||||||
|
|
||||||
|
curl -fsS https://kadence.timka.su/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
`--no-build` is deliberate: without it, `up` may build the local checkout and
|
||||||
|
quietly ignore the image you just pulled. The production overlay keeps the app on
|
||||||
|
`127.0.0.1` and leaves TLS to nginx; see `docker-compose.prod.yml` and
|
||||||
|
`deploy/nginx/kadence.timka.su.conf`.
|
||||||
|
|
||||||
|
For a fully pinned deployment, use the digest from step 4 instead of the version
|
||||||
|
tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export KADENCE_IMAGE=git.timka.su/timka123/kadence@sha256:<digest>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Before you roll a release
|
||||||
|
|
||||||
|
Take a database backup. The backend uses `create_all` rather than a migration
|
||||||
|
tool, so it adds tables and columns but never removes or rewrites them. Rolling an
|
||||||
|
old application back against a newer schema is only safe if the change was
|
||||||
|
additive:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/kadence
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec -T db \
|
||||||
|
sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' \
|
||||||
|
> "backup-$(date +%F-%H%M).sql"
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Android APK
|
||||||
|
|
||||||
|
### What the job does
|
||||||
|
|
||||||
|
`android-release.yml`:
|
||||||
|
|
||||||
|
1. checks out the tag or commit;
|
||||||
|
2. installs JDK 21 and downloads the Glyph Matrix AAR from Nothing;
|
||||||
|
3. installs the Android SDK (`platforms;android-37.0`, build tools, platform
|
||||||
|
tools) and restores the Gradle caches;
|
||||||
|
4. runs `:app`'s JVM/Robolectric tests;
|
||||||
|
5. builds `assembleRelease` with the version code from the workflow run number;
|
||||||
|
6. verifies the signing certificate with `apksigner` and rejects a debug key;
|
||||||
|
7. writes `update.json` (version, commit, size, SHA-256 and signer digest);
|
||||||
|
8. uploads `kadence-release-apk` and `kadence-update-metadata`, and on a `v*`
|
||||||
|
tag attaches the APK to the Gitea release as `kadence-<version>.apk`.
|
||||||
|
|
||||||
|
The build is signed from the keystore secrets. `KADENCE_REQUIRE_SIGNING` is set
|
||||||
|
when a keystore was found, so a missing password or wrong alias fails the job
|
||||||
|
rather than producing a debug-signed "release".
|
||||||
|
|
||||||
|
### Version code and version name
|
||||||
|
|
||||||
|
`versionCode` comes from `${{ gitea.run_number }}`, so successive CI builds can
|
||||||
|
install over each other. That is the number Android compares when updating.
|
||||||
|
|
||||||
|
`versionName` is resolved before the build:
|
||||||
|
|
||||||
|
| Trigger | `versionName` |
|
||||||
|
| --- | --- |
|
||||||
|
| Tag `v0.2.0` | `0.2.0` (the leading `v` is dropped) |
|
||||||
|
| Push to `master` | `0.1.0-master.<run_number>` |
|
||||||
|
|
||||||
|
So a tagged release reports its tag in Settings, while a master build is
|
||||||
|
identifiable as a CI build and still has a unique, monotonic `versionCode`.
|
||||||
|
|
||||||
|
### Installing the artifact
|
||||||
|
|
||||||
|
From the Actions run page, download `kadence-release-apk.zip`, unpack it, and
|
||||||
|
install the `app-release.apk` inside:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb install -r app-release.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the release asset on a tagged build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fL -o kadence-v0.2.0.apk \
|
||||||
|
https://git.timka.su/timka123/kadence/releases/download/v0.2.0/kadence-v0.2.0.apk
|
||||||
|
adb install -r kadence-v0.2.0.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
The phone must already have the app installed with the same signing key. The
|
||||||
|
debug build uses a different application id (`.debug`) and can sit beside the
|
||||||
|
release build, but it cannot be upgraded into it.
|
||||||
|
|
||||||
|
### Over-the-air updates
|
||||||
|
|
||||||
|
The Android app can update itself from these artifacts, without the phone ever
|
||||||
|
holding a Gitea credential. The repository is private, so the backend reads the
|
||||||
|
newest successful build from `GITEA_UPDATE_BRANCH` and re-serves it behind the
|
||||||
|
same `/api` auth as everything else.
|
||||||
|
|
||||||
|
Configure the backend (compose passes these through from `.env`):
|
||||||
|
|
||||||
|
| Variable | What for |
|
||||||
|
| --- | --- |
|
||||||
|
| `GITEA_TOKEN` | read-only token that can see this repository's Actions artifacts. Empty disables OTA: the endpoints answer `503` and the app never offers an update |
|
||||||
|
| `GITEA_REPO` | `owner/repository`, default `timka123/kadence` |
|
||||||
|
| `GITEA_URL` | Gitea base URL, default `https://git.timka.su` |
|
||||||
|
| `GITEA_UPDATE_BRANCH` | branch whose successful builds are offered, default `master` |
|
||||||
|
|
||||||
|
The token only needs repository read access; it is never sent to the phone. The
|
||||||
|
two endpoints are:
|
||||||
|
|
||||||
|
* `GET /api/updates/android` — `update.json` for the newest successful build,
|
||||||
|
with `run_number` filled in from Gitea even for older artifacts.
|
||||||
|
* `GET /api/updates/android/apk` — the APK itself, streamed with
|
||||||
|
`Cache-Control: no-store`.
|
||||||
|
|
||||||
|
A missing token is `503`, no usable artifact is `404`, and a Gitea failure is
|
||||||
|
`502`. The app treats background failures quietly; Settings → Updates shows the
|
||||||
|
reason.
|
||||||
|
|
||||||
|
On the phone:
|
||||||
|
|
||||||
|
* checks run on resume, at most once every six hours, in release builds only,
|
||||||
|
and only after onboarding;
|
||||||
|
* only a strictly higher `version_code` is offered, and a skipped build stays
|
||||||
|
skipped until **Check now** in Settings;
|
||||||
|
* the APK is downloaded only after the user asks, into the app cache, with a
|
||||||
|
`.part` file so an interrupted transfer cannot look installable;
|
||||||
|
* before handing it to Android, the app checks the package name, the version
|
||||||
|
code and that the APK's signing certificate matches the installed app. A
|
||||||
|
mismatch is deleted and reported instead of producing "App not installed";
|
||||||
|
* Android asks for "install unknown apps" permission the first time, then its
|
||||||
|
own installer confirms the upgrade.
|
||||||
|
|
||||||
|
This is why the release keystore matters: an APK signed with a different key
|
||||||
|
cannot upgrade an install, and the app refuses it before downloading. Debug
|
||||||
|
builds have OTA disabled entirely.
|
||||||
|
|
||||||
|
### Artifact retention
|
||||||
|
|
||||||
|
Gitea's artifact retention is finite (the instance's configured default, often
|
||||||
|
90 days). The release asset attached to a tag is not subject to the same
|
||||||
|
retention as a workflow artifact; that is why tagged builds are the production
|
||||||
|
path and plain `master` artifacts are only a convenience.
|
||||||
|
|
||||||
|
## The desktop agent
|
||||||
|
|
||||||
|
There is no desktop build in `.gitea/workflows` today. A production desktop
|
||||||
|
"build" is still the checkout plus the installer script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout v0.2.0
|
||||||
|
./desktop/packaging/install-desktop.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The app is plain Python with PyQt6, run by the system interpreter. The launcher
|
||||||
|
installs a `.pth` file pointing at the checkout, so upgrading means checking out
|
||||||
|
the new tag and re-running the installer. The privileged helper is separate and
|
||||||
|
needs its own install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./desktop/helper/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need a downloadable desktop artifact, build a wheel from the same tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd desktop
|
||||||
|
python3 -m venv /tmp/kadence-build
|
||||||
|
/tmp/kadence-build/bin/pip install build
|
||||||
|
/tmp/kadence-build/bin/python -m build
|
||||||
|
# dist/kadence_desktop-0.2.0-py3-none-any.whl
|
||||||
|
```
|
||||||
|
|
||||||
|
That wheel contains the Python package and QML files, not the privileged helper
|
||||||
|
or the Qt system dependency. It is not a replacement for
|
||||||
|
`install-desktop.sh` on a machine that needs the helper and menu entry.
|
||||||
|
|
||||||
|
## Versioning and rollback
|
||||||
|
|
||||||
|
| Thing | Value | Where it comes from |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Image release tag | `v0.2.0` | Git tag, verbatim |
|
||||||
|
| Image commit tag | first 7 characters of `HEAD` | `git rev-parse --short HEAD` |
|
||||||
|
| Image `latest` | newest successful `master` build | `docker.yml` |
|
||||||
|
| APK `versionCode` | workflow run number | `gitea.run_number` |
|
||||||
|
| APK `versionName` | tag without `v`, or `0.1.0-master.<run_number>` | `Resolve the version name` step |
|
||||||
|
| APK release asset | `kadence-v0.2.0.apk` | Git tag |
|
||||||
|
|
||||||
|
To roll back the backend, redeploy the previous version tag or digest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export KADENCE_IMAGE=git.timka.su/timka123/kadence:v0.1.3
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-build
|
||||||
|
```
|
||||||
|
|
||||||
|
Check `/api/health` and the app log afterwards. If the newer release added a
|
||||||
|
column the old code does not understand, restore the database backup taken before
|
||||||
|
the upgrade; `create_all` does not undo schema changes.
|
||||||
|
|
||||||
|
To roll back the phone, install the previous release APK over the current one.
|
||||||
|
Android refuses a lower `versionCode` during a normal install; `adb install -r -d`
|
||||||
|
allows it on a device where downgrades are permitted, but that is not a reliable
|
||||||
|
production path. Keep the previous APK and the matching keystore, or bump the
|
||||||
|
version code and rebuild.
|
||||||
|
|
||||||
|
## Downloading artifacts without the web UI
|
||||||
|
|
||||||
|
The run page in Gitea is the simplest route: **Actions → run → Artifacts**.
|
||||||
|
For a tagged release, the attached asset is under **Releases**.
|
||||||
|
|
||||||
|
With a personal access token, the API works too. Listing the artifacts of the
|
||||||
|
latest successful `android` run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=... # a Gitea token with repository read access
|
||||||
|
REPO=https://git.timka.su/api/v1/repos/timka123/kadence
|
||||||
|
|
||||||
|
curl -fsSL -H "Authorization: token $TOKEN" \
|
||||||
|
"$REPO/actions/artifacts?name=kadence-release-apk" \
|
||||||
|
| jq -r '.artifacts[] | select(.expired == false) | "\(.id) \(.created_at)"' \
|
||||||
|
| sort -k2 | tail -1
|
||||||
|
```
|
||||||
|
|
||||||
|
Then download and unpack the chosen artifact id:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ARTIFACT_ID=...
|
||||||
|
curl -fsSL -H "Authorization: token $TOKEN" \
|
||||||
|
"$REPO/actions/artifacts/$ARTIFACT_ID/zip" -o kadence-release-apk.zip
|
||||||
|
unzip -o kadence-release-apk.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
For a tagged build, the release asset can be fetched by tag instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL -H "Authorization: token $TOKEN" \
|
||||||
|
"$REPO/releases/tags/v0.2.0" \
|
||||||
|
| jq -r '.assets[] | select(.name | startswith("kadence-")) | .browser_download_url'
|
||||||
|
```
|
||||||
|
|
||||||
|
Artifacts from an older run can be expired; the release asset is the durable
|
||||||
|
copy.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Likely cause |
|
||||||
|
| --- | --- |
|
||||||
|
| The run sits queued forever | No online runner claims `ubuntu-latest`, or the runner is registered at a scope that does not include this repository |
|
||||||
|
| `docker login` step warns that there is no `REGISTRY_TOKEN` | The secret is missing, empty, or lacks `package:write`; the image is built but not pushed |
|
||||||
|
| `Cannot connect to the Docker daemon` in `docker.yml` | The job container does not have the host socket; check `docker_host` and `valid_volumes` in `deploy/act-runner.yaml` |
|
||||||
|
| The Docker push returns 401 | `REGISTRY_TOKEN` is a job token or a PAT without `package:write`, or the username does not match the token |
|
||||||
|
| Android build fails before Gradle starts | The SDK install failed; check `android/ci/install-sdk.sh` and the cache key. A cold cache can take several minutes |
|
||||||
|
| The APK is debug-signed despite the keystore secrets | At least one of `KADENCE_KEYSTORE_BASE64`, `KADENCE_KEYSTORE_PASSWORD`, `KADENCE_KEY_ALIAS`, `KADENCE_KEY_PASSWORD` is missing or wrong. With `KADENCE_REQUIRE_SIGNING` the job should have failed; fix the secret rather than shipping it |
|
||||||
|
| `kadence-release-apk` is missing from the run | The build failed before the upload step, or the artifact has expired |
|
||||||
|
| The tag build cannot attach the APK to a release | The tag was pushed but no release exists yet, or the built-in `GITEA_TOKEN` lacks permission. Create the release, or check the repository's Actions token permissions |
|
||||||
|
| A tag push did not start a workflow | The tag does not match `v*`, or it was created locally and never pushed: `git push origin v0.2.0` |
|
||||||
|
| The deployment still runs old code after a pull | `docker compose up` rebuilt the local checkout. Use `--no-build` and pin `KADENCE_IMAGE` |
|
||||||
|
| Android installs reject the new APK | It is signed with a different key, or has a lower version code than the installed one. Keep the release keystore; do not switch it between releases |
|
||||||
|
|
||||||
|
### Secrets are not in the build logs
|
||||||
|
|
||||||
|
The workflows never echo a token or a keystore password. The APK job does print
|
||||||
|
the certificate with `apksigner --print-certs`, which is the public certificate,
|
||||||
|
not the private key. Do not add a debug step that prints the environment, the
|
||||||
|
decoded keystore, or the registry token.
|
||||||
Reference in new issue
Block a user