Add CI/release workflows and first Compose UI tests

GitHub Actions (every action pinned to its commit SHA, version in a comment):
- ci.yml, on pull_request to main, runs three jobs: a debug build, the
  unit tests, and the instrumented suite on a headless emulator.
- release.yml, on workflow_dispatch, builds the release APK, archives the
  source as zip + tar.gz, and publishes a GitHub release.

Compose UI tests (app/src/androidTest), driving the real screens with
fake-backed view models so they need no network, database, or Hilt graph:
- ManualSetupScreen: submit-button validation, advanced-options toggle,
  and add-account success/failure.
- ComposeScreen: send-button enablement and the send -> close flow.
- LibreMailBottomBar: tab rendering and selection callback.

Mark gradlew executable (100755) so it runs on the Linux CI runners.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 21:36:41 -05:00
co-authored by Claude Opus 4.8
parent 96c9f71a26
commit 669946ea34
7 changed files with 604 additions and 0 deletions
+164
View File
@@ -0,0 +1,164 @@
# SPDX-License-Identifier: GPL-3.0-or-later
name: CI
on:
pull_request:
branches: [main]
# A new push to a PR cancels any in-flight run for that PR.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
# SDK packages this project builds against (compileSdk 37 / build-tools 37.0.0).
# Quote the package ids when passed to sdkmanager — the ';' is a shell separator.
ANDROID_PLATFORM: "platforms;android-37.0"
ANDROID_BUILD_TOOLS: "build-tools;37.0.0"
jobs:
debug-build:
name: Debug build
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: temurin
java-version: "21"
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install SDK platform and build-tools
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Assemble debug APK
run: ./gradlew assembleDebug --stacktrace
- name: Upload debug APK
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: debug-apk
path: app/build/outputs/apk/debug/*.apk
if-no-files-found: error
unit-tests:
name: Unit tests
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: temurin
java-version: "21"
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install SDK platform and build-tools
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Run unit tests
run: ./gradlew testDebugUnitTest --stacktrace
- name: Upload unit test report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: unit-test-report
path: app/build/reports/tests/testDebugUnitTest/
if-no-files-found: warn
instrumented-tests:
name: Instrumented / UI tests (emulator)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# minSdk is 33; the instrumented suite (SQLCipher, Keystore, Room, Compose UI,
# mail providers) needs no API-37-specific behavior, so it runs on a stable,
# widely-available emulator image rather than the bleeding-edge android-37.0.
api-level: [35]
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: temurin
java-version: "21"
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install SDK platform and build-tools
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
# The hardware-accelerated emulator needs KVM, which is gated behind a udev rule.
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Cache AVD snapshot
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-${{ matrix.api-level }}-google_apis-x86_64
# On a cache miss, cold-boot the emulator once so its snapshot can be cached,
# making subsequent runs start from a warm snapshot.
- name: Create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
with:
api-level: ${{ matrix.api-level }}
target: google_apis
arch: x86_64
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: false
script: echo "Generated AVD snapshot for caching."
- name: Run instrumented tests
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
with:
api-level: ${{ matrix.api-level }}
target: google_apis
arch: x86_64
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
- name: Upload instrumented test report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: instrumented-test-report-api${{ matrix.api-level }}
path: app/build/reports/androidTests/connected/
if-no-files-found: warn
+77
View File
@@ -0,0 +1,77 @@
# SPDX-License-Identifier: GPL-3.0-or-later
name: Release
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag to create (e.g. v0.1.0)"
required: true
type: string
prerelease:
description: "Mark this GitHub release as a pre-release"
required: false
type: boolean
default: true
# Needed to create the tag, the GitHub release, and upload its assets.
permissions:
contents: write
env:
ANDROID_PLATFORM: "platforms;android-37.0"
ANDROID_BUILD_TOOLS: "build-tools;37.0.0"
jobs:
release:
name: Build APK and publish release
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: temurin
java-version: "21"
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install SDK platform and build-tools
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
# No release keystore is configured in CI, so the build falls back to the debug
# signing key (installable for testing, not for store publication).
- name: Assemble release APK
run: ./gradlew assembleRelease --stacktrace
- name: Stage release artifacts (APK + source archives)
run: |
set -euo pipefail
tag='${{ inputs.tag }}'
mkdir -p dist
apk="$(find app/build/outputs/apk/release -name '*.apk' -print -quit)"
cp "$apk" "dist/LibreMail-${tag}.apk"
# Source archives contain only git-tracked files at the released commit.
git archive --format=zip --prefix="LibreMail-${tag}/" -o "dist/LibreMail-${tag}-src.zip" HEAD
git archive --format=tar --prefix="LibreMail-${tag}/" HEAD | gzip > "dist/LibreMail-${tag}-src.tar.gz"
ls -l dist
- name: Create GitHub release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
tag_name: ${{ inputs.tag }}
name: ${{ inputs.tag }}
target_commitish: ${{ github.sha }}
prerelease: ${{ inputs.prerelease }}
generate_release_notes: true
fail_on_unmatched_files: true
files: |
dist/LibreMail-${{ inputs.tag }}.apk
dist/LibreMail-${{ inputs.tag }}-src.zip
dist/LibreMail-${{ inputs.tag }}-src.tar.gz
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui
import java.io.File
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import org.libremail.domain.model.Account
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Draft
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
/**
* In-memory [AccountRepository] for Compose UI tests: serves a fixed account list, records the
* account/password handed to [addImapAccount], and returns a configurable connection-test result.
*/
class FakeAccountRepository(
accounts: List<Account> = emptyList(),
private var result: Result<List<String>> = Result.success(listOf("INBOX")),
) : AccountRepository {
private val accountsFlow = MutableStateFlow(accounts)
var addedAccount: Account? = null
private set
var addedPassword: String? = null
private set
override fun observeAccounts(): Flow<List<Account>> = accountsFlow
override suspend fun testConnection(params: ImapConnectionParams): Result<List<String>> = result
override suspend fun addImapAccount(account: Account, password: String): Result<List<String>> {
addedAccount = account
addedPassword = password
return result
}
override suspend fun addOutlookAccount(
email: String,
accessToken: String,
authStateJson: String,
): Result<List<String>> = result
override suspend fun deleteAccount(id: String) {
accountsFlow.value = accountsFlow.value.filterNot { it.id == id }
}
}
/**
* In-memory [MailRepository] for Compose UI tests: records sent messages / saved drafts and returns
* a configurable [sendResult]. Read paths emit empty so screens render their empty states.
*/
class FakeMailRepository(
var sendResult: Result<Unit> = Result.success(Unit),
) : MailRepository {
val sentMessages = mutableListOf<OutgoingMessage>()
val savedDrafts = mutableListOf<Draft>()
val deletedDraftIds = mutableListOf<String>()
override fun observeMessages(): Flow<List<Message>> = flowOf(emptyList())
override suspend fun getMessage(id: String): Message? = null
override suspend fun openMessage(id: String): Result<Message> =
Result.failure(UnsupportedOperationException("not used in UI tests"))
override fun observeAttachments(messageId: String): Flow<List<Attachment>> = flowOf(emptyList())
override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File> =
Result.failure(UnsupportedOperationException("not used in UI tests"))
override suspend fun setStarred(id: String, starred: Boolean): Result<Unit> = Result.success(Unit)
override suspend fun deleteMessage(id: String): Result<Unit> = Result.success(Unit)
override suspend fun sendMessage(outgoing: OutgoingMessage): Result<Unit> {
sentMessages += outgoing
return sendResult
}
override fun observeDrafts(): Flow<List<Draft>> = flowOf(emptyList())
override suspend fun getDraft(id: String): Draft? = null
override suspend fun saveDraft(draft: Draft) {
savedDrafts += draft
}
override suspend fun deleteDraft(id: String) {
deletedDraftIds += id
}
override fun observeOutbox(): Flow<List<OutboxMessage>> = flowOf(emptyList())
override suspend fun cancelOutboxMessage(id: String) {}
override suspend fun retryOutbox() {}
override suspend fun searchServer(query: String) {}
override suspend fun clearSearchResults() {}
}
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.ui.theme.LibreMailTheme
/** UI test for the shared bottom navigation bar: it renders both tabs and reports the tapped tab. */
@RunWith(AndroidJUnit4::class)
class LibreMailBottomBarTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
@Test
fun rendersBothTabs_andEmitsSelectionOnTap() {
var selected: TopDest? = null
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
LibreMailBottomBar(current = TopDest.MAILBOX, onSelect = { selected = it })
}
}
composeTestRule.onNodeWithText(string(R.string.nav_mailbox)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.nav_settings)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.nav_settings)).performClick()
assertEquals(TopDest.SETTINGS, selected)
}
}
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.accountsetup
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.ui.FakeAccountRepository
import org.libremail.ui.theme.LibreMailTheme
/**
* End-to-end UI test for manual IMAP/SMTP account setup. Drives the real [ManualSetupScreen] and a
* real [ManualSetupViewModel] backed by an in-memory [FakeAccountRepository].
*/
@RunWith(AndroidJUnit4::class)
class ManualSetupScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
// Build the view model once and capture it, so recomposition doesn't recreate it.
private fun setContent(
repository: FakeAccountRepository = FakeAccountRepository(),
onAccountAdded: () -> Unit = {},
) {
val viewModel = ManualSetupViewModel(repository)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ManualSetupScreen(onBack = {}, onAccountAdded = onAccountAdded, viewModel = viewModel)
}
}
}
private fun fillValidForm() {
composeTestRule.onNodeWithText(string(R.string.manual_email)).performTextInput("user@example.com")
composeTestRule.onNodeWithText(string(R.string.manual_password)).performTextInput("app-password")
composeTestRule.onNodeWithText(string(R.string.manual_imap_server)).performTextInput("imap.example.com")
composeTestRule.onNodeWithText(string(R.string.manual_smtp_server)).performTextInput("smtp.example.com")
}
@Test
fun testAndAddButton_isDisabledUntilRequiredFieldsAreFilled() {
setContent()
composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).assertIsNotEnabled()
fillValidForm()
composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).assertIsEnabled()
}
@Test
fun advancedToggle_revealsPortField() {
setContent()
composeTestRule.onNodeWithText(string(R.string.manual_imap_port)).assertDoesNotExist()
composeTestRule.onNodeWithText(string(R.string.settings_advanced)).performClick()
composeTestRule.waitUntil(timeoutMillis = 5_000) {
composeTestRule.onAllNodesWithText(string(R.string.manual_imap_port)).fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithText(string(R.string.manual_imap_port)).assertExists()
}
@Test
fun submit_onSuccess_invokesOnAccountAddedWithEnteredCredentials() {
val repository = FakeAccountRepository(result = Result.success(listOf("INBOX")))
var added = false
setContent(repository) { added = true }
fillValidForm()
composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).performClick()
composeTestRule.waitUntil(timeoutMillis = 5_000) { added }
assertTrue(added)
assertEquals("user@example.com", repository.addedAccount?.email)
assertEquals("app-password", repository.addedPassword)
}
@Test
fun submit_onFailure_showsErrorAndStaysOnScreen() {
val repository = FakeAccountRepository(result = Result.failure(RuntimeException("Login failed")))
var added = false
setContent(repository) { added = true }
fillValidForm()
composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).performClick()
composeTestRule.waitUntil(timeoutMillis = 5_000) {
composeTestRule.onAllNodesWithText("Login failed").fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithText("Login failed").assertExists()
assertFalse(added)
}
}
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose
import android.Manifest
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.SavedStateHandle
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.contacts.ContactsRepository
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.ServerConfig
import org.libremail.ui.FakeAccountRepository
import org.libremail.ui.FakeMailRepository
import org.libremail.ui.theme.LibreMailTheme
/**
* End-to-end UI test for composing and sending a message. Drives the real [ComposeScreen] and a
* real [ComposeViewModel] backed by in-memory fakes plus the real (empty) device contacts provider.
*/
@RunWith(AndroidJUnit4::class)
class ComposeScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private val account = Account(
id = "imap:me@example.com",
email = "me@example.com",
displayName = "Me",
authType = AuthType.PASSWORD_IMAP,
imap = ServerConfig("imap.example.com", 993, MailSecurity.SSL_TLS),
smtp = ServerConfig("smtp.example.com", 465, MailSecurity.SSL_TLS),
)
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
@Before
fun grantContactsPermission() {
// ComposeScreen requests READ_CONTACTS on first composition; pre-grant it (before the test
// calls setContent) so no system permission dialog appears to block the headless run.
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.uiAutomation.grantRuntimePermission(
instrumentation.targetContext.packageName,
Manifest.permission.READ_CONTACTS,
)
}
// Build the view model once and capture it, so recomposition doesn't recreate it.
private fun setContent(
mailRepository: FakeMailRepository = FakeMailRepository(),
onBack: () -> Unit = {},
) {
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
val viewModel = ComposeViewModel(
savedStateHandle = SavedStateHandle(),
mailRepository = mailRepository,
accountRepository = FakeAccountRepository(accounts = listOf(account)),
contactsRepository = ContactsRepository(context),
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ComposeScreen(onBack = onBack, viewModel = viewModel)
}
}
}
@Test
fun sendButton_isDisabledUntilRecipientEntered() {
setContent()
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).assertIsNotEnabled()
composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com")
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).assertIsEnabled()
}
@Test
fun send_withRecipient_sendsMessageAndClosesScreen() {
val mailRepository = FakeMailRepository()
var closed = false
setContent(mailRepository) { closed = true }
composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com")
composeTestRule.onNodeWithText(string(R.string.compose_subject)).performTextInput("Hello")
composeTestRule.onNodeWithText(string(R.string.compose_body)).performTextInput("Body text")
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick()
composeTestRule.waitUntil(timeoutMillis = 5_000) { mailRepository.sentMessages.isNotEmpty() }
val sent = mailRepository.sentMessages.single()
assertEquals("you@example.com", sent.to)
assertEquals("Hello", sent.subject)
assertEquals(account.id, sent.accountId)
composeTestRule.waitUntil(timeoutMillis = 5_000) { closed }
}
}
Vendored Regular → Executable
View File