fix(android): re-sign release APK with v1 (JAR) signature for MDM signage (#81)

minSdk 26 makes AGP default the v1 (JAR) signature off, so the release APK is
v2-only. Some MDM-managed commercial signage (MAXHUB via the Pivot MDM) silently
removes a v2-only app on the next reboot because its boot integrity check expects
a v1 signature — screens that power-cycle nightly lose the app and fall back to
the setup screen.

`enableV1Signing = true` has no effect at minSdk >= 24 (verified: still v2-only).
Instead, finalize assembleRelease with a `resignReleaseV1` task that re-signs via
apksigner with --v1-signing-enabled true and a low --min-sdk-version, emitting v1
alongside v2/v3. Verified: v1+v2+v3 at min-sdk 19, verifies at API 36, and the
re-signed APK installs and runs on a live API 36 emulator.

Closes #81

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-11 21:02:18 -05:00 committed by screentinker
parent 3ddc209d19
commit 22376710ee
3 changed files with 54 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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<Test> {
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<Exec>("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") }