android: the real client — backend, notifications, Live Update, Glyph
The app module was a design gallery: tokens, type and a dial, with no backend, no notification and nothing driving the rear matrix. This is the rest of it. **One engine, three views.** `TimerEngine` lives on the Application, not in a ViewModel, because the Compose screen, the foreground service's notification and the Glyph must show the *same* second. Two engines polling the same backend drift apart within seconds and the user sees a notification disagreeing with the dial. It polls, ticks from a monotonic anchor, and puts every reply through `:core`'s `reconcile`. `pomodoro.state()` does not finish an interval whose time is up — it reports `remaining_seconds = 0` and waits for a client to call `complete`. The tick loop does, behind the same re-entry guard `desktop/controller.py` uses, since without one a 1 Hz tick at zero fires `complete` every second until the reply lands. **Notifications.** Two channels, because the running interval must be silent and its *end* must not; one channel cannot be both, and a user who silences a buzzing countdown has silenced the only part that mattered. The ongoing one is an Android 16 Live Update: promotion needs `setOngoing` + `setRequestPromotedOngoing` + a `ProgressStyle`, and missing any one renders it as an ordinary notification with no error and no log. Its segments come from `:core`'s `dialTickCount`, so the status bar, the dial and the Glyph ring agree on what one tick is worth. **Glyph.** `GlyphMatrixManager.init()` binds `com.nothing.thirdparty/.GlyphService` and *discards* the boolean `bindService` returns — so on Android 11+ a missing `<queries>` entry makes the package invisible, the bind silently fail, and the matrix stay dark with no exception, no log and `onServiceConnected` never arriving. That was the whole of "the glyph does not work". The manifest now declares it and `MatrixGlyphDisplay` checks visibility itself rather than leaving the next person with the same dead end. The SDK's licence forbids redistribution (clause 2.1(c)), so the .aar stays git-ignored and `fetch-glyph-sdk.sh` downloads it. Without it a second source set compiles a no-op display: the app builds, installs and runs with an inert rear panel, which is also the correct behaviour on any phone that is not a Nothing. Verified on the device (Phone (3), Android 16), debug and release: onboarding against kadence.timka.su, the Live Update with `shortCriticalText` and a 600s progress track, the matrix accepting 625-dot frames, tasks opening in the Jira app, and R8 leaving the DTOs decodable. 225 tests, none needing a device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
32 files changed
+4118
-162
No files matched your search
@@ -6,6 +6,19 @@ plugins {
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
// ---- the Glyph Matrix SDK, which may or may not be here --------------------
|
||||
// Nothing's .aar is not on Maven and its licence forbids redistribution, so it
|
||||
// cannot be committed. `./fetch-glyph-sdk.sh` downloads it.
|
||||
//
|
||||
// Rather than failing the build without it, the rear display is chosen at
|
||||
// configuration time: exactly one of two source sets is compiled, each providing
|
||||
// `newGlyphDisplay()`. With the .aar the real one binds `GlyphMatrixManager`;
|
||||
// without it the no-op returns a display that accepts frames and drops them. The
|
||||
// rest of the app does not know which it got, and `:core` — where every frame is
|
||||
// actually computed — never depends on either.
|
||||
val glyphAar = layout.projectDirectory.file("libs/glyph-matrix-sdk-2.0.aar").asFile
|
||||
val hasGlyphSdk = glyphAar.isFile
|
||||
|
||||
android {
|
||||
namespace = "tech.kadence.android"
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
@@ -15,8 +28,43 @@ android {
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
versionName = "1.0.0"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
buildConfigField("boolean", "HAS_GLYPH_SDK", hasGlyphSdk.toString())
|
||||
|
||||
// What onboarding pre-fills. Only a starting guess — the field is editable,
|
||||
// and `KadencePrefs.normalizeBaseUrl` copes with a LAN address typed instead.
|
||||
// Both build types get the real deployment rather than an emulator loopback,
|
||||
// because this app's backend is a real host and `10.0.2.2` is only ever right
|
||||
// inside an emulator.
|
||||
buildConfigField("String", "DEFAULT_BACKEND", "\"https://kadence.timka.su\"")
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
kotlin.srcDir(if (hasGlyphSdk) "src/glyph/kotlin" else "src/noglyph/kotlin")
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
// Release signing comes from ~/.gradle/gradle.properties or the environment,
|
||||
// never from the repo. Absent them, `assembleRelease` falls back to the debug
|
||||
// key: it still produces an installable APK for one's own phone, which is the
|
||||
// only distribution this app has.
|
||||
create("release") {
|
||||
val storePath = (project.findProperty("KADENCE_KEYSTORE") as String?)
|
||||
?: System.getenv("KADENCE_KEYSTORE")
|
||||
if (storePath != null && file(storePath).isFile) {
|
||||
storeFile = file(storePath)
|
||||
storePassword = (project.findProperty("KADENCE_KEYSTORE_PASSWORD") as String?)
|
||||
?: System.getenv("KADENCE_KEYSTORE_PASSWORD")
|
||||
keyAlias = (project.findProperty("KADENCE_KEY_ALIAS") as String?)
|
||||
?: System.getenv("KADENCE_KEY_ALIAS") ?: "kadence"
|
||||
keyPassword = (project.findProperty("KADENCE_KEY_PASSWORD") as String?)
|
||||
?: System.getenv("KADENCE_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -25,7 +73,17 @@ android {
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
// R8 on, because an always-running foreground service wants the smaller,
|
||||
// faster build. The keep rules it needs are in proguard-rules.pro.
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
signingConfig = signingConfigs.getByName("release").takeIf {
|
||||
it.storeFile != null
|
||||
} ?: signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +99,13 @@ android {
|
||||
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
|
||||
// `android.util.Log` is one of the stubbed-out android.jar methods that throws
|
||||
// "not mocked" on the JVM. Most of these tests are deliberately *not* under
|
||||
// Robolectric — `KadenceApiTest` is pure okhttp + MockWebServer and should stay
|
||||
// that way — so a single debug log line inside the HTTP client would otherwise
|
||||
// fail six unrelated tests. Returning defaults makes Log a no-op there.
|
||||
unitTests.isReturnDefaultValues = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
@@ -54,6 +119,8 @@ dependencies {
|
||||
|
||||
implementation(libs.serialization.json)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.coroutines.android)
|
||||
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.ui)
|
||||
@@ -67,6 +134,11 @@ dependencies {
|
||||
implementation(libs.navigation.compose)
|
||||
implementation(libs.lifecycle.viewmodel.compose)
|
||||
implementation(libs.lifecycle.runtime.compose)
|
||||
implementation(libs.lifecycle.service)
|
||||
|
||||
if (hasGlyphSdk) {
|
||||
implementation(files(glyphAar))
|
||||
}
|
||||
|
||||
// Robolectric runs these on the JVM, so the UI layer is verifiable without a
|
||||
// device or an emulator — the same reason the logic lives in :core.
|
||||
@@ -83,3 +155,14 @@ dependencies {
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
}
|
||||
|
||||
// Make it obvious which rear display got compiled in — otherwise "the Glyph does
|
||||
// nothing" is indistinguishable from "the .aar was never downloaded".
|
||||
gradle.projectsEvaluated {
|
||||
if (!hasGlyphSdk) {
|
||||
logger.lifecycle(
|
||||
"Glyph Matrix SDK not found at app/libs/ — the rear display is a no-op. " +
|
||||
"Run ./fetch-glyph-sdk.sh to enable it.",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user