ci(android): never ship a debug-signed APK; check the signer (#5116)
release / govulncheck (push) Successful in 26s
release / go (push) Successful in 2m5s
release / web (push) Successful in 1m24s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m57s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m26s
release / Verify release artifacts (tag releases only) (push) Skipped

Adopts the rest of family idea #5103 (distributing your own APK):

- Practice 2: build.gradle.kts no longer falls back to the debug key
  when ANDROID_KEYSTORE_PATH is unset; the release build is signed with
  the release key or left unsigned. Main no longer builds and uploads a
  debug-signed app-debug.apk, which no install could ever update.
- Practice 3: android-release runs apksigner on the built APK, prints
  the signer's DN and SHA-256 digest, and fails on a debug signer.
  An unsigned build fails the same step, since there is no
  app-release.apk to verify.
- Practice 9: debug builds offer no server update. The banner does not
  poll and the About card says updates come from Android Studio, since
  the release-signed APK cannot install over a debug-signed app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 21:33:22 -04:00
co-authored by Claude Opus 5.5
parent 13a7a3629a
commit a1e9de2c84
5 changed files with 50 additions and 18 deletions
+5 -1
View File
@@ -72,9 +72,13 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// Signed with the release key or not at all. Falling back to the
// debug key made a missing secret into a published APK that no
// install could ever update (family idea #5103, practice 2). An
// unsigned build installs nowhere, so the gap shows at once.
signingConfig =
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
signingConfigs.getByName("debug")
null
} else {
signingConfigs.getByName("release")
}
@@ -41,6 +41,10 @@ data class AboutUiState(
// check must decide on. Held in state rather than read inline so a test
// can drive the comparison without a BuildConfig.
val installedCode: Long = BuildConfig.VERSION_CODE.toLong(),
// A debug build is signed with this machine's debug key, so the server's
// release-signed APK can never install over it (family idea #5103,
// practice 9). It updates from Android Studio instead.
val selfUpdates: Boolean = !BuildConfig.DEBUG,
val isChecking: Boolean = false,
val installStage: InstallStage = InstallStage.IDLE,
val installMessage: String? = null,
@@ -67,7 +71,7 @@ class AboutCardViewModel @Inject constructor(
val state: StateFlow<AboutUiState> = internal.asStateFlow()
fun checkForUpdates() {
if (internal.value.isChecking) return
if (internal.value.isChecking || !internal.value.selfUpdates) return
viewModelScope.launch {
internal.update { it.copy(isChecking = true, installMessage = null) }
val installed = internal.value.installedVersion
@@ -383,6 +383,14 @@ private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) {
@Composable
private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
if (!state.selfUpdates) {
Text(
text = "Debug build: updates install from Android Studio.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
UpdateCheckLine(result = state.result)
Button(
onClick = viewModel::checkForUpdates,
@@ -29,6 +29,9 @@ private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L
* restart re-shows it, which is acceptable nudging for v1 (matches
* Flutter). Server 404 / network errors stay silent. Constructed at
* launch via the construct-the-singleton trick in `MinstrelApplication`.
*
* A debug build never polls: it is signed with a local debug key, so the
* server's release-signed APK could never install over it (#5103).
*/
@Singleton
class UpdateBannerController @Inject constructor(
@@ -45,6 +48,10 @@ class UpdateBannerController @Inject constructor(
}.stateIn(scope, SharingStarted.Eagerly, null)
init {
if (!BuildConfig.DEBUG) startPolling()
}
private fun startPolling() {
scope.launch {
while (true) {
runOnce()