diff --git a/CHANGELOG.md b/CHANGELOG.md index 8497c34..605717b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,15 @@ unaffected. A device with no timezone set and not reporting one falls back to the server clock (unchanged from before). +### Fixed +- **#81 — release APK is now v1 + v2 + v3 signed.** With `minSdk 26`, the Android Gradle + Plugin defaulted the v1 (JAR) signature *off*, producing a v2-only APK that some + MDM-managed commercial signage (e.g. MAXHUB via the Pivot MDM) silently removes on the + next reboot — so screens that power-cycle nightly lost the app and fell back to the + setup screen. Setting `enableV1Signing = true` had no effect at minSdk ≥ 24; the release + build now re-signs with `apksigner` and a low `--min-sdk-version` to emit the JAR + signature alongside v2/v3. Verified to install and run on Android 14+/API 36 as well. + ### Notes - **Scheduling fails open.** If the on-device evaluator ever errors (bad timezone id, malformed block), the item **plays** rather than being hidden. A blank screen is worse diff --git a/README.md b/README.md index 4102a0a..2b11264 100644 --- a/README.md +++ b/README.md @@ -403,6 +403,13 @@ The APK will be at `android/app/build/outputs/apk/debug/app-debug.apk`. Copy it cp android/app/build/outputs/apk/debug/app-debug.apk ScreenTinker.apk ``` +> **Release builds & MDM signage (#81):** `./gradlew assembleRelease` is automatically +> re-signed to carry a **v1 (JAR) signature alongside v2/v3** (the `resignReleaseV1` task in +> `app/build.gradle.kts`). At `minSdk 26` the Gradle plugin omits v1, and some MDM-managed +> commercial displays (e.g. MAXHUB/Pivot) **strip a v2-only APK on reboot** — screens that +> power-cycle nightly then lose the app. v1+v2+v3 installs everywhere from API 19 to the +> latest Android. (`enableV1Signing = true` alone does not work at minSdk ≥ 24.) + To generate a new signing keystore: ```bash diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8554df6..dfcafa4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -21,6 +21,9 @@ android { storePassword = System.getenv("KEYSTORE_PASSWORD") ?: findProperty("KEYSTORE_PASSWORD") as String? ?: "" keyAlias = System.getenv("KEY_ALIAS") ?: findProperty("KEY_ALIAS") as String? ?: "remotedisplay" keyPassword = System.getenv("KEY_PASSWORD") ?: findProperty("KEY_PASSWORD") as String? ?: "" + // #81: AGP ignores enableV1Signing at minSdk>=24, so assembleRelease emits a + // v2-only APK. The v1 (JAR) signature that some MDM-managed signage (MAXHUB) + // requires is added by the `resignReleaseV1` task below (apksigner re-sign). } } @@ -87,3 +90,38 @@ dependencies { tasks.withType { systemProperty("scheduleVectors", File(rootProject.projectDir.parentFile, "shared/schedule-vectors.json").absolutePath) } + +// #81: AGP ignores enableV1Signing at minSdk>=24, so `assembleRelease` produces a +// v2-only APK - and some MDM-managed signage (MAXHUB/Pivot) silently removes a v2-only +// app on the next reboot because its boot integrity check expects a v1 (JAR) signature. +// Re-sign the assembled release APK with apksigner, forcing a low --min-sdk-version so +// the v1 signature is emitted alongside v2/v3. v1+v2+v3 verifies on every Android +// version (legacy MDM hardware via v1, modern Android via v2/v3). +tasks.register("resignReleaseV1") { + val apk = layout.buildDirectory.file("outputs/apk/release/app-release.apk").get().asFile + onlyIf { apk.exists() } + doFirst { + val sdkDir = System.getenv("ANDROID_HOME") + ?: System.getenv("ANDROID_SDK_ROOT") + ?: rootProject.file("local.properties").takeIf { it.exists() } + ?.readLines()?.firstOrNull { it.startsWith("sdk.dir=") }?.substringAfter("=")?.trim() + ?: throw GradleException("#81 resign: set ANDROID_HOME or sdk.dir in local.properties") + val buildTools = File(sdkDir, "build-tools").listFiles() + ?.filter { it.isDirectory }?.maxByOrNull { it.name } + ?: throw GradleException("#81 resign: no build-tools found under $sdkDir") + commandLine( + File(buildTools, "apksigner").absolutePath, "sign", + "--ks", file("../release-key.jks").absolutePath, + "--ks-key-alias", (System.getenv("KEY_ALIAS") ?: "remotedisplay"), + "--ks-pass", "pass:" + (System.getenv("KEYSTORE_PASSWORD") ?: ""), + "--key-pass", "pass:" + (System.getenv("KEY_PASSWORD") ?: ""), + "--v1-signing-enabled", "true", + "--v2-signing-enabled", "true", + "--v3-signing-enabled", "true", + "--min-sdk-version", "19", + apk.absolutePath + ) + } +} +// AGP registers assembleRelease lazily, so match it when/after it's created. +tasks.matching { it.name == "assembleRelease" }.configureEach { finalizedBy("resignReleaseV1") }