Compare commits

..

2 Commits

Author SHA1 Message Date
null a7f252e656 review 2026-07-11 18:49:40 -05:00
null a63bf350b1 fix(crash): LocalDate.ofInstant crashes on API<34 + add Lint/R8 CI gates
Adding Android Lint to CI immediately caught two real crash bugs invisible to
all emulator QA (the fixture emulators run API 34+):

- SettingsViewModel + YourProgressViewModel called LocalDate.ofInstant, which
  was only added in API 34 — but minSdk is 26. On every device running Android
  8-13 (the bulk of the install base) these throw NoSuchMethodError and crash
  the Settings and Your Progress screens. Fixed with the API-26-safe equivalent
  Instant.atZone(zone).toLocalDate() (same result).

The other two Lint errors were false positives (ProduceStateDoesNotAssignValue
on two EncryptedChatImage composables that DO assign value inside the producer —
the check misfires on a suspend/?.let RHS) — explicitly @Suppress'd with a note,
so Lint reaches 0 errors legitimately rather than via a blanket baseline.

CI (android-ci.yml) gains two jobs:
- android-lint: ./gradlew :app:lintDebug (fails on error-severity; 113 existing
  warnings are non-fatal and left for a separate burndown).
- release-build: first-ever R8 gate — builds :app:bundleRelease with a throwaway
  keystore + dummy RC_API_KEY (satisfies the release guards; AAB not distributed),
  so the minify/shrink/sign toolchain can never silently rot. Verified locally:
  bundleRelease SUCCEEDS today (95MB AAB). A green build proves the toolchain,
  not runtime survival of reflectively-loaded classes (Tink) — that stays a
  release-APK QA item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:41:04 -05:00
6 changed files with 26831 additions and 16722 deletions

View File

@ -1,9 +1,15 @@
# Android CI — JVM unit tests + static repo scans + secret scan.
# Android CI — JVM unit tests + Android Lint + R8 release-build gate + static
# repo scans + secret scan.
#
# Secrets are intentionally ABSENT here: the debug build compiles with an empty
# APP_CHECK_DEBUG_TOKEN and a placeholder RC_API_KEY (see app/build.gradle.kts
# `secret()` resolution). CI-built APKs therefore cannot pass App Check once
# enforcement is on — expected; CI only runs JVM tests.
# Real Firebase/RC secrets are intentionally ABSENT: the debug build compiles with
# an empty APP_CHECK_DEBUG_TOKEN and a placeholder RC_API_KEY (see
# app/build.gradle.kts `secret()` resolution). CI-built APKs therefore cannot pass
# App Check once enforcement is on — expected.
#
# The `release-build` job DOES exercise the RELEASE variant (minify+shrink+sign)
# using a THROWAWAY keystore + a dummy RC_API_KEY, purely to satisfy the release
# guards; that AAB is never distributed. A green release-build proves the R8
# toolchain, not that reflectively-loaded classes survive shrinking at runtime.
#
# app/google-services.json is gitignored (live Firebase config), but the
# google-services Gradle plugin hard-fails without one, so CI copies the dummy
@ -45,6 +51,85 @@ jobs:
name: unit-test-reports
path: app/build/reports/tests/
android-lint:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- uses: gradle/actions/setup-gradle@v4
- name: Provide stub google-services.json (real one is gitignored)
run: |
if [ ! -f app/google-services.json ]; then
cp .github/ci/google-services.json app/google-services.json
fi
# Fails on error-severity issues (NewApi, correctness). This gate caught two
# LocalDate.ofInstant NewApi crashes (API 34 call, minSdk 26) on 2026-07-11
# that emulator QA missed because the fixture emulators run API 34+.
- name: Android Lint (debug)
run: ./gradlew :app:lintDebug
- name: Upload lint report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: lint-report
path: app/build/reports/lint-results-debug.html
release-build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- uses: gradle/actions/setup-gradle@v4
- name: Provide stub google-services.json (real one is gitignored)
run: |
if [ ! -f app/google-services.json ]; then
cp .github/ci/google-services.json app/google-services.json
fi
# Throwaway upload key — only exists to satisfy the release signing guard.
# This AAB is never distributed.
- name: Generate throwaway CI signing keystore
run: |
keytool -genkeypair -v \
-keystore "$RUNNER_TEMP/ci-release.jks" \
-alias ci -keyalg RSA -keysize 2048 -validity 30 \
-storepass ci_ci_ci -keypass ci_ci_ci \
-dname "CN=CI, O=CI, C=US"
- name: Build minified release bundle (R8 gate)
env:
RC_API_KEY: ci_dummy_rc_key_not_real
RELEASE_STORE_FILE: ${{ runner.temp }}/ci-release.jks
RELEASE_STORE_PASSWORD: ci_ci_ci
RELEASE_KEY_ALIAS: ci
RELEASE_KEY_PASSWORD: ci_ci_ci
# Skip the Crashlytics mapping upload — it needs real Firebase creds the
# stub google-services.json does not have.
run: ./gradlew :app:bundleRelease -x uploadCrashlyticsMappingFileRelease
- name: Upload R8 mapping on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: r8-mapping
path: app/build/outputs/mapping/release/
repo-scans:
runs-on: ubuntu-latest
timeout-minutes: 10

View File

@ -288,6 +288,9 @@ private fun rememberMediaImageLoader(): ImageLoader {
}
}
// False positive: the producer DOES assign `value` unconditionally below. The
// ProduceStateDoesNotAssignValue check misfires when the RHS is a suspend call.
@Suppress("ProduceStateDoesNotAssignValue")
@Composable
private fun EncryptedChatImage(
mediaUrl: String,

View File

@ -125,7 +125,8 @@ class YourProgressViewModel @Inject constructor(
}
private fun dueFollowUpDay(createdAt: Long, outcomes: List<Outcome>): OutcomeDay? {
val paired = LocalDate.ofInstant(Instant.ofEpochMilli(createdAt), ZoneId.systemDefault())
// LocalDate.ofInstant is API 34+; atZone().toLocalDate() is API 26 (our minSdk) — same result.
val paired = Instant.ofEpochMilli(createdAt).atZone(ZoneId.systemDefault()).toLocalDate()
val today = LocalDate.now()
val ageDays = java.time.temporal.ChronoUnit.DAYS.between(paired, today)

View File

@ -186,6 +186,9 @@ private fun DiscussionMessageBubble(
}
/** Downloads the encrypted image bytes, decrypts them on-device, and renders the photo. */
// False positive: the producer DOES assign `value` unconditionally below. The
// ProduceStateDoesNotAssignValue check misfires when the RHS is a suspend call.
@Suppress("ProduceStateDoesNotAssignValue")
@Composable
private fun EncryptedChatImage(
mediaUrl: String,

View File

@ -220,7 +220,8 @@ class SettingsViewModel @Inject constructor(
fun consumeOutcomeError() = _uiState.update { it.copy(outcomeError = null) }
private fun dueFollowUpDay(createdAt: Long, outcomes: List<Outcome>): OutcomeDay? {
val paired = LocalDate.ofInstant(Instant.ofEpochMilli(createdAt), ZoneId.systemDefault())
// LocalDate.ofInstant is API 34+; atZone().toLocalDate() is API 26 (our minSdk) — same result.
val paired = Instant.ofEpochMilli(createdAt).atZone(ZoneId.systemDefault()).toLocalDate()
val today = LocalDate.now()
val ageDays = java.time.temporal.ChronoUnit.DAYS.between(paired, today)
val due = listOf(

File diff suppressed because it is too large Load Diff