Merge branch 'main' into fix-359-sqlcipher-16kb

This commit is contained in:
Jason Ross
2026-07-06 16:56:27 -05:00
committed by GitHub
4 changed files with 113 additions and 9 deletions
+64 -3
View File
@@ -228,8 +228,25 @@ jobs:
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
# sdkmanager can exit 1 on a transient package-mirror/network hiccup with no useful trail —
# an `E2E (31)` leg died exactly this way (#387). Retry up to 3x with backoff so a transient
# failure self-heals, and on a hard failure print the installed-package list so the cause is
# visible in the step log instead of a bare exit 1.
- name: Install SDK platform and build-tools
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"
run: |
for attempt in 1 2 3; do
echo "::group::sdkmanager install (attempt $attempt)"
if sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"; then
echo "::endgroup::"
exit 0
fi
echo "::endgroup::"
echo "::warning::sdkmanager attempt $attempt failed to install $ANDROID_PLATFORM / $ANDROID_BUILD_TOOLS"
if [ "$attempt" -lt 3 ]; then sleep "$((attempt * 15))"; fi
done
echo "::error::sdkmanager failed to install the SDK packages after 3 attempts"
echo "--- sdkmanager --list_installed ---"; sdkmanager --list_installed 2>&1 || true
exit 1
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
@@ -281,7 +298,13 @@ jobs:
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: ./gradlew connectedDebugAndroidTest --stacktrace
# Stream logcat to a per-api-level file (the emulator is booted here) before the tests,
# so a test failure or emulator flake is diagnosable from the uploaded artifact.
# Backgrounded; gradle stays the last foreground command so the step's exit status is
# still the test result (a real failure still trips continue-on-error -> the retry).
script: |
adb logcat -v time > "$RUNNER_TEMP/logcat-api${{ matrix.api-level }}.txt" 2>&1 &
./gradlew connectedDebugAndroidTest --stacktrace
- name: Run E2E tests (retry after emulator boot race)
if: steps.e2e.outcome == 'failure'
@@ -293,7 +316,31 @@ jobs:
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: ./gradlew connectedDebugAndroidTest --stacktrace
# Retry runs a fresh emulator boot; stream its logcat the same way. `>` overwrites
# attempt 1's file so the artifact holds the FINAL attempt's logs, matching the
# failure-time dump below (which reflects this last attempt's state).
script: |
adb logcat -v time > "$RUNNER_TEMP/logcat-api${{ matrix.api-level }}.txt" 2>&1 &
./gradlew connectedDebugAndroidTest --stacktrace
# On any E2E failure (both boot attempts failed, a hung emulator, or an earlier setup/SDK
# step), snapshot device + runner state to the step log AND a file for the artifact upload —
# the `E2E (31)` sdkmanager death (#387) left no trail. Each probe is guarded (|| true) so a
# missing tool / offline device can't abort the step; accel-check, /dev/kvm, free -h and
# df -h characterise the runner even when the emulator never booted.
- name: Dump emulator + system diagnostics on failure
if: failure()
run: |
DIAG="${RUNNER_TEMP:-/tmp}/diagnostics-api${{ matrix.api-level }}.txt"
{
echo "===== E2E API ${{ matrix.api-level }} failure diagnostics ====="
echo "--- adb devices ---"; adb devices 2>&1 || true
echo "--- adb logcat -d (tail 200) ---"; adb logcat -d 2>&1 | tail -200 || true
echo "--- emulator -accel-check ---"; "$ANDROID_SDK_ROOT/emulator/emulator" -accel-check 2>&1 || true
echo "--- /dev/kvm ---"; ls -l /dev/kvm 2>&1 || true
echo "--- free memory ---"; free -h 2>&1 || true
echo "--- free disk ---"; df -h 2>&1 || true
} 2>&1 | tee "$DIAG"
- name: Upload E2E test report
if: ${{ !cancelled() }}
@@ -303,6 +350,20 @@ jobs:
path: app/build/reports/androidTests/connected/
if-no-files-found: warn
# Always upload the streamed logcat + (on failure) the system-state dump so an emulator flake
# or a red matrix leg is diagnosable from artifacts without a re-run — parity with the
# e2e-preview boot-diagnostics artifact. Per-api-level name (upload-artifact@v7 rejects
# duplicate artifact names).
- name: Upload E2E diagnostics
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-diagnostics-api${{ matrix.api-level }}
path: |
${{ runner.temp }}/logcat-api${{ matrix.api-level }}.txt
${{ runner.temp }}/diagnostics-api${{ matrix.api-level }}.txt
if-no-files-found: warn
# API 37 (Android 17, preview) E2E. Its only system image is the nonstandard
# android-37.0 / google_apis_ps16k (16 KB page size), which reactivecircus/android-emulator-runner
# can't provision (it builds android-37 / google_apis, neither of which exists), so this job
@@ -15,6 +15,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModelStore
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
@@ -60,10 +61,20 @@ class ComposeScreenTest {
private var db: AccountDatabase? = null
// Holds the real ComposeViewModel built by hand in setContent() below, so closeDb() can clear()
// it (triggering ViewModel.onCleared()) before closing the DB.
private val viewModelStore = ViewModelStore()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
@After
fun closeDb() {
// Clear the store (→ ViewModel.onCleared() → cancels viewModelScope) BEFORE closing the DB.
// ComposeViewModel's init block launches a viewModelScope coroutine that reads the real
// accountSettings/signature Room repositories (applySignature()); without this, that read can
// still be in flight when the DB closes, racing a SQLITE_MISUSE ("connection is closed") —
// the same class of teardown race fixed in SignaturesScreenTest/AccountSettingsScreenTest.
viewModelStore.clear()
db?.close()
}
@@ -92,6 +103,7 @@ class ComposeScreenTest {
signatureRepository = SignatureRepository(database.signatureDao()),
settingsRepository = SettingsRepository(context),
)
viewModelStore.put("compose", viewModel)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ComposeScreen(onBack = onBack, viewModel = viewModel)
@@ -7,11 +7,13 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModelStore
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.work.WorkManager
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -54,13 +56,27 @@ class AccountSettingsScreenTest {
private var manageSignaturesClicked = false
private lateinit var db: AccountDatabase
// Holds the real AccountSettingsViewModel built by hand in setContent() below, so tearDown() can
// clear() it (triggering ViewModel.onCleared()) before closing the DB.
private val viewModelStore = ViewModelStore()
@After
fun tearDown() {
// Clear the store (→ ViewModel.onCleared() → cancels viewModelScope) BEFORE closing the DB.
// The ViewModel's `settings`/`signatureCount`/`defaultSignatureName`/`account` Room
// InvalidationTracker Flows are kept alive by stateIn(WhileSubscribed(5_000)): without this,
// a collector can still be live up to 5s after the UI detaches, so a re-query lands on the
// just-closed in-memory DB and throws SQLITE_MISUSE ("connection is closed") — an intermittent
// teardown race, not a real bug. (Previously worked around by never closing the DB at all.)
viewModelStore.clear()
db.close()
}
private fun setContent(): AccountSettingsRepository {
val context = ApplicationProvider.getApplicationContext<Context>()
// Intentionally not closed in an @After: the ViewModel's `settings` Room Flow (kept alive by
// stateIn/WhileSubscribed) keeps querying after the test body, so closing the in-memory DB out
// from under it races and crashes ("connection pool has been closed"). The DB is reclaimed with
// the test process.
val db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
val repository = AccountSettingsRepository(db.accountSettingsDao())
runBlocking {
db.accountDao().upsert(account.toEntity()) // FK parent for the account_settings row
@@ -74,6 +90,7 @@ class AccountSettingsScreenTest {
syncScheduler = SyncScheduler(Provider { WorkManager.getInstance(context) }),
settingsRepository = SettingsRepository(context),
)
viewModelStore.put("account-settings", viewModel)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
AccountSettingsScreen(
@@ -14,6 +14,7 @@ import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModelStore
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
@@ -50,6 +51,10 @@ class SignaturesScreenTest {
private lateinit var db: AccountDatabase
private lateinit var repository: SignatureRepository
// Holds the real SignaturesViewModel built by hand in setContent() below, so tearDown() can
// clear() it (triggering ViewModel.onCleared()) before closing the DB.
private val viewModelStore = ViewModelStore()
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
@@ -70,7 +75,15 @@ class SignaturesScreenTest {
}
@After
fun tearDown() = db.close()
fun tearDown() {
// Clear the store (→ ViewModel.onCleared() → cancels viewModelScope) BEFORE closing the DB.
// SignaturesViewModel.signatures is a Room InvalidationTracker Flow kept alive by
// stateIn(WhileSubscribed(5_000)): without this, the collector can still be live up to 5s
// after the UI detaches, so a re-query lands on the just-closed in-memory DB and throws
// SQLITE_MISUSE ("connection is closed") — an intermittent teardown race, not a real bug.
viewModelStore.clear()
db.close()
}
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
@@ -81,6 +94,7 @@ class SignaturesScreenTest {
SavedStateHandle(mapOf(Routes.SIGNATURES_ARG_ACCOUNT to accountId)),
repository,
)
viewModelStore.put("signatures", viewModel)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
SignaturesScreen(onBack = {}, onEdit = {}, onAdd = {}, viewModel = viewModel)