Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Contributing to Thunderbird for Android

Welcome to the Thunderbird for Android project! We’re excited to have you here and welcome your contributions.

Getting Started

Before you start contributing, please take a moment to familiarize yourself with the following:

Bug Reports and Feature Requests

If you encounter a bug or have a feature request, please follow these steps:

  • Search the existing issues to see if your issue or feature has already been reported.
  • If you can’t find an existing issue, please open a new issue on GitHub.

Translations

If you’d like to help to translate K-9 Mail / Thunderbird for Android, please visit the Weblate - K-9 Mail/Thunderbird project.

Contributing Code

Thank you for your willingness to contribute code! Here’s how you can get started:

1. Find an issue:

2. Discuss your plan:

  • Leave a comment on the issue you want to work on, explaining what you plan to do. This helps avoid duplicate work and gets you feedback from the team.

3. Fork the repository:

  • Create your own fork of the Thunderbird for Android repository on GitHub.

4. Create a branch:

  • Start a new branch from the main branch to keep your changes separate.
  • Name your branch descriptively (e.g., fix-issue-123 or add-feature-xyz).

5. Make your changes:

6. Test your changes:

  • Run the project’s tests to make sure everything works and that your changes don’t introduce any regressions.
  • If applicable, write new tests to cover your changes.

7. Push your changes:

  • Upload your branch to your forked repository.

8. Open a pull request:

  • Create a pull request to merge your changes into the main project.
  • Provide a clear and concise description of your changes, including:
    • A reference to the issue you’re addressing.
    • A summary of the changes you made.
    • Any relevant screenshots or testing results.

Thank You!

Thank you for taking the time to contribute to Thunderbird for Android! We appreciate your help in making the project better and more useful for everyone.

Last change: 2025-06-26, commit: 3b4ef25

Git Commit Guide

Use Conventional Commits to write consistent and meaningful commit messages. This makes your work easier to review, track, and maintain for everyone involved in the project.

✍️ Commit Message Format

<type>(<scope>): <description>

<body>

<footer(s)>

Components:

  • <type>: The type of change being made (e.g., feat, fix, docs).
  • <scope> (optional): The scope indicates the area of the codebase affected by the change (e.g., auth, ui).
  • <description>: Short description of the change (50 characters or less)
  • <body> (optional): Explain what changed and why, include context if helpful.
  • <footer(s)> (optional): Include issue references, breaking changes, etc.

Examples

Basic:

feat: add QR code scanner

With scope:

feat(auth): add login functionality

With body and issue reference:

fix(api): handle null response from login endpoint

Checks for missing tokens to prevent app crash during login.

Fixes #123

🏷️ Commit Types

TypeUse for…Example
featNew featuresfeat(camera): add zoom support
fixBug fixesfix(auth): handle empty username crash
docsDocumentation onlydocs(readme): update setup instructions
styleCode style (no logic changes)style: reformat settings screen
refactorCode changes (no features/fixes)refactor(nav): simplify stack setup
testAdding/editing teststest(api): add unit test for login
choreTooling, CI, dependencieschore(ci): update GitHub Actions config
revertReverting previous commitsrevert: remove feature flag

📍Optional Scope

The scope is optional but recommended for clarity, especially for large changes or or when multiple areas of the codebase are involved.

ScopeUse for…Example
authAuthenticationfeat(auth): add login functionality
settingsUser settingsfeat(settings): add dark mode toggle
buildBuild systemfix(build): improve build performance
uiUI/themerefactor(ui): split theme into modules
depsDependencieschore(deps): bump Kotlin to 2.0.0

🧠 Best Practices

1. One Commit, One Purpose

  • ✅ Each commit should represent a single logical change or addition to the codebase.
  • ❌ Don’t mix unrelated changes together (e.g., fixing a bug and updating docs, or changing a style and ) adding a feature).

2. Keep It Manageable

  • ✅ Break up large changes into smaller, more manageable commits.
  • ✅ If a commit changes more than 200 lines of code, consider breaking it up.
  • ❌ Avoid massive, hard-to-review commits.

3. Keep It Working

  • ✅ Each commit should leave the codebase in a buildable and testable state.
  • ❌ Never commit broken code or failing tests.

4. Think About Reviewers (and Future You)

  • ✅ Write messages for your teammates and future self, assuming they have no context.
  • ✅ Explain non-obvious changes or decisions in the message body.
  • ✅ Consider the commit as a documentation tool.
  • ❌ Avoid jargon, acronyms, or vague messages like update stuff.

Summary

  • Use Conventional Commits for consistency.
  • Keep commit messages short, structured, and focused.
  • Make each commit purposeful and self-contained.
  • Write commits that make collaboration and future development easier for everyone—including you.
Last change: 2025-06-26, commit: 3b4ef25

🧪 Testing Guide

This document outlines the testing practices and guidelines for the Thunderbird for Android project.

Key Testing Principles:

  • Follow the Arrange-Act-Assert (AAA) pattern
  • Use descriptive test names
  • Prefer fake implementations over mocks
  • Name the object under test as testSubject
  • Use AssertK for assertions

📐 Test Structure

🔍 Arrange-Act-Assert Pattern

Tests in this project should follow the Arrange-Act-Assert (AAA) pattern:

  1. Arrange: Set up the test conditions and inputs
  2. Act: Perform the action being tested
  3. Assert: Verify the expected outcomes

Example:

@Test
fun `example test using AAA pattern`() {
    // Arrange
    val input = "test input"
    val expectedOutput = "expected result"
    val testSubject = SystemUnderTest()

    // Act
    val result = testSubject.processInput(input)

    // Assert
    assertThat(result).isEqualTo(expectedOutput)
}

Use comments to clearly separate these sections in your tests:

// Arrange
// Act
// Assert

📝 Test Naming

Use descriptive test names that clearly indicate what is being tested. For JVM tests, use backticks:

@Test
fun `method should return expected result when given valid input`() {
    // Test implementation
}

Note: Android instrumentation tests do not support backticks in test names. For these tests, use camelCase instead:

@Test
fun methodShouldReturnExpectedResultWhenGivenValidInput() {
    // Test implementation
}

💻 Test Implementation

🎭 Fakes over Mocks

In this project, we prefer using fake implementations over mocks:

  • Preferred: Create fake/test implementations of interfaces or classes
  • Avoid: Using mocking libraries to create mock objects

Fakes provide better test reliability and are more maintainable in the long run. They also make tests more readable and less prone to breaking when implementation details change.

Mocks can lead to brittle tests that are tightly coupled to the implementation details, making them harder to maintain. They also negatively impact test performance, particularly during test initialization. Which can quickly become overwhelming when an excessive number of tests includes mock implementations.

Example of a fake implementation:

// Interface
interface DataRepository {
    fun getData(): List<String>
}

// Fake implementation for testing
class FakeDataRepository(
    // Allow passing initial data during construction
    initialData: List<String> = emptyList()
) : DataRepository {
    // Mutable property to allow changing data between tests
    var dataToReturn = initialData

    override fun getData(): List<String> {
        return dataToReturn
    }
}

// In test
@Test
fun `processor should transform data correctly`() {
    // Arrange
    val fakeRepo = FakeDataRepository(listOf("item1", "item2"))
    val testSubject = DataProcessor(fakeRepo)

    // Act
    val result = testSubject.process()

    // Assert
    assertThat(result).containsExactly("ITEM1", "ITEM2")
}

📋 Naming Conventions

When writing tests, use the following naming conventions:

  • Name the object under test as testSubject (not “sut” or other abbreviations)
  • Name fake implementations with a “Fake” prefix (e.g., FakeDataRepository)
  • Use descriptive variable names that clearly indicate their purpose

✅ Assertions

Use AssertK for assertions in tests:

@Test
fun `example test`() {
    // Arrange
    val list = listOf("apple", "banana")

    // Act
    val result = list.contains("apple")

    // Assert
    assertThat(result).isTrue()
    assertThat(list).contains("banana")
}

Note: You’ll need to import the appropriate AssertK assertions:

  • assertk.assertThat for the base assertion function
  • Functions from the assertk.assertions namespace for specific assertion types (e.g., import assertk.assertions.isEqualTo, import assertk.assertions.contains, import assertk.assertions.isTrue, etc.)

🧮 Test Types

This section describes the different types of tests we use in the project. Each type serves a specific purpose in our testing strategy, and together they help ensure the quality and reliability of our codebase.

🔬 Unit Tests

Unit tests verify that individual components work correctly in isolation.

What to Test:

  • Single units of functionality
  • Individual methods or functions
  • Classes in isolation
  • Business logic
  • Edge cases and error handling

Key Characteristics:

  • Independent (no external dependencies)
  • No reliance on external resources
  • Uses fake implementations for dependencies

Frameworks:

Location:

  • Tests should be in the same module as the code being tested
  • Should be in the src/test directory or src/{platformTarget}Test for Kotlin Multiplatform
  • Tests should be in the same package as the code being tested

Contributor Expectations:

  • ✅ All new code should be covered by unit tests
  • ✅ Add tests that reproduce bugs when fixing issues
  • ✅ Follow the AAA pattern (Arrange-Act-Assert)
  • ✅ Use descriptive test names
  • ✅ Prefer fake implementations over mocks

🔌 Integration Tests

Integration tests verify that components work correctly together.

What to Test:

  • Interactions between components
  • Communication between layers
  • Data flow across multiple units
  • Component integration points

Key Characteristics:

  • Tests multiple components together
  • May use real implementations when appropriate
  • Focuses on component boundaries

Frameworks:

  • JUnit 4 (for tests in src/test)
  • AssertK for assertions
  • Robolectric (for Android framework classes in src/test)
  • Espresso (for UI testing in src/androidTest)

Location:

  • Preferably in the src/test or src/commonTest, src/{platformTarget}Test for Kotlin Multiplatform
  • Only use src/androidTest, when there’s a specific need for Android dependencies

Why prefer test over androidTest:

  • JUnit tests run faster (on JVM instead of emulator/device)
  • Easier to set up and maintain
  • Better integration with CI/CD pipelines
  • Lower resource requirements
  • Faster feedback during development

When to use androidTest:

  • When testing functionality that depends on Android-specific APIs that are not available with Robolectric
  • When tests need to interact with the Android framework directly

Contributor Expectations:

  • ✅ Add tests for features involving multiple components
  • ✅ Focus on critical paths and user flows
  • ✅ Be mindful of test execution time
  • ✅ Follow the AAA pattern
  • ✅ Use descriptive test names

📱 UI Tests

UI tests verify the application from a user’s perspective.

What to Test:

  • User interface behavior
  • UI component interactions
  • Complete user flows
  • Screen transitions
  • Input handling and validation

Key Characteristics:

  • Tests from user perspective
  • Verifies visual elements and interactions
  • Covers end-to-end scenarios

Frameworks:

  • Espresso for Android UI testing
  • Compose UI testing for Jetpack Compose
  • JUnit 4 as the test runner

Location:

  • In the src/test directory for Compose UI tests
  • In the src/androidTest directory for Espresso tests

Contributor Expectations:

  • ✅ Add tests for new UI components and screens
  • ✅ Focus on critical user flows
  • ✅ Consider different device configurations
  • ✅ Test both positive and negative scenarios
  • ✅ Follow the AAA pattern
  • ✅ Use descriptive test names

📸 Screenshot Tests

⚠️ Work in Progress ⚠️

Screenshot tests verify the visual appearance of UI components.

What to Test:

  • Visual appearance of UI components
  • Layout correctness
  • Visual regressions
  • Theme and style application

Key Characteristics:

  • Captures visual snapshots
  • Compares against reference images (golden images)
  • Detects unintended visual changes

Frameworks:

  • JUnit 4 as the test runner
  • Compose UI testing
  • Screenshot comparison tools (TBD)

Location:

  • Same module as the code being tested
  • In the src/test directory

Contributor Expectations:

  • ✅ Add tests for new Composable UI components
  • ✅ Verify correct rendering in different states
  • ✅ Update reference screenshots for intentional changes

🚫 Test Types We Don’t Currently Have

This section helps contributors understand our testing strategy and future plans.

End-to-End Tests

  • Full system tests verifying complete user journeys
  • Tests across multiple screens and features
  • Validates entire application workflows

Performance Tests

  • Measures startup time, memory usage, responsiveness
  • Validates app performance under various conditions
  • Identifies performance bottlenecks

Accessibility Tests

  • Verifies proper content descriptions
  • Checks contrast ratios and keyboard navigation
  • Ensures app is usable by people with disabilities

Localization Tests 🌐

  • Verifies correct translation display
  • Tests right-to-left language support
  • Validates date, time, and number formatting

Manual Test Scripts 📝

  • Manual testing by QA team for exploratory testing
  • Ensures repeatable test execution
  • Documents expected behavior for manual tests

🏃 Running Tests

Quick commands to run tests in the project.

Run all tests:

./gradlew test

Run tests for a specific module:

./gradlew :module-name:test

Run Android instrumentation tests:

./gradlew connectedAndroidTest

Run tests with coverage:

./gradlew testDebugUnitTestCoverage

📊 Code Coverage

⚠️ Work in Progress ⚠️

This section is currently under development and will be updated with specific code coverage rules and guidelines.

Code coverage helps us understand how much of our codebase is being tested. While we don’t currently have strict requirements, we aim for high coverage in critical components.

Current Approach:

  • Focus on critical business logic
  • Prioritize user-facing features
  • No strict percentage requirements
  • Quality of tests over quantity

Future Guidelines (Coming Soon):

  • Code coverage targets by component type
  • Coverage report generation instructions
  • Interpretation guidelines
  • Exemptions for generated/simple code
  • CI/CD integration details

Remember: High code coverage doesn’t guarantee high-quality tests. Focus on writing meaningful tests that verify correct behavior, not just increasing coverage numbers.

Last change: 2025-06-26, commit: 3b4ef25

Java to Kotlin Conversion Guide

This guide describes our process for converting Java code to Kotlin.

Why Convert to Kotlin?

Java and Kotlin are compatible languages, but we decided to convert our codebase to Kotlin for the following reasons:

  • Kotlin is more concise and expressive than Java.
  • Kotlin has better support for null safety.
  • Kotlin has a number of modern language features that make it easier to write maintainable code.

See our ADR-0001 for more information.

How to Convert Java Code to Kotlin

  1. Write tests for any code that is not adequately covered by tests.
  2. Use the “Convert Java File to Kotlin File” action in IntelliJ or Android Studio to convert the Java code.
  3. Fix any issues that prevent the code from compiling after the automatic conversion.
  4. Commit the changes as separate commits:
    1. The change of file extension (e.g. example.java -> example.kt).
    2. The conversion of the Java file to Kotlin.
    • This can be automated by IntelliJ/Android Studio if you use their VCS integration and enable the option to commit changes separately.
  5. Refactor the code to improve readability and maintainability. This includes:
    1. Removing unnecessary code.
    2. Using Kotlin’s standard library functions, language features, null safety and coding conventions.

Additional Tips

  • Use when expressions instead of if-else statements.
  • Use apply and also to perform side effects on objects.
  • Use @JvmField to expose a Kotlin property as a field in Java.

Resources

Last change: 2025-06-26, commit: 3b4ef25

🏗️ Architecture

The application follows a modular architecture with clear separation between different layers and components. The architecture is designed to support both the Thunderbird for Android and K-9 Mail applications while maximizing code reuse, maintainability and enable adoption of Kotlin Multiplatform in the future.

🔑 Key Architectural Principles

  • 🚀 Multi platform Compatibility: The architecture is designed to support future Kotlin Multiplatform adoption
  • 📱 Offline-First: The application is designed to work offline with local data storage and synchronization with remote servers
  • 🧩 Modularity: The application is divided into distinct modules with clear responsibilities
  • 🔀 Separation of Concerns: Each module focuses on a specific aspect of the application
  • ⬇️ Dependency Inversion: Higher-level modules do not depend on lower-level modules directly
  • 🎯 Single Responsibility: Each component has a single responsibility
  • 🔄 API/Implementation Separation: Clear separation between public APIs and implementation details
  • 🧹 Clean Architecture: Separation of UI, domain, and data layers
  • 🧪 Testability: The architecture facilitates comprehensive testing at all levels

📝 Architecture Decision Records

The Architecture Decision Records document the architectural decisions made during the development of the project, providing context and rationale for key technical choices. Reading through these decisions will improve your contributions and ensure long-term maintainability of the project.

📦 Module Structure

The application is organized into several module types:

  • 📱 App Modules: app-thunderbird and app-k9mail - Application entry points
  • 🔄 App Common: app-common - Shared code between applications
  • ✨ Feature Modules: feature:* - Independent feature modules
  • 🧰 Core Modules: core:* - Foundational components and utilities used across multiple features
  • 📚 Library Modules: library:* - Specific implementations for reuse
  • 🔙 Legacy Modules: Legacy code being gradually migrated

For more details on the module organization and structure, see the Module Organization and Module Structure documents.

🧩 Architectural Patterns

The architecture follows several key patterns to ensure maintainability, testability, and separation of concerns:

🔄 API/Implementation Separation

Each module should be split into two main parts: API and implementation. This separation provides clear boundaries between what a module exposes to other modules and how it implements its functionality internally:

  • 📝 API: Public interfaces, models, and contracts
  • ⚙️ Implementation: Concrete implementations of the interfaces

This separation provides clear boundaries, improves testability, and enables flexibility.

See API Module and Implementation Module for more details.

Clean Architecture

Thunderbird for Android uses Clean Architecture with three main layers (UI, domain, and data) to break down complex feature implementation into manageable components. Each layer has a specific responsibility:

graph TD
    subgraph UI[UI Layer]
        UI_COMPONENTS[UI Components]
        VIEW_MODEL[ViewModels]
    end

    subgraph DOMAIN["Domain Layer"]
        USE_CASE[Use Cases]
        REPO[Repositories]
    end

    subgraph DATA[Data Layer]
        DATA_SOURCE[Data Sources]
        API[API Clients]
        DB[Local Database]
    end

    UI_COMPONENTS --> VIEW_MODEL
    VIEW_MODEL --> USE_CASE
    USE_CASE --> REPO
    REPO --> DATA_SOURCE
    DATA_SOURCE --> API
    DATA_SOURCE --> DB

    classDef ui_layer fill:#d9e9ff,stroke:#000000,color:#000000
    classDef ui_class fill:#4d94ff,stroke:#000000,color:#000000
    classDef domain_layer fill:#d9ffd9,stroke:#000000,color:#000000
    classDef domain_class fill:#33cc33,stroke:#000000,color:#000000
    classDef data_layer fill:#ffe6cc,stroke:#000000,color:#000000
    classDef data_class fill:#ffaa33,stroke:#000000,color:#000000
    
    linkStyle default stroke:#999,stroke-width:2px

    class UI ui_layer
    class UI_COMPONENTS,VIEW_MODEL ui_class
    class DOMAIN domain_layer
    class USE_CASE,REPO domain_class
    class DATA data_layer
    class DATA_SOURCE,API,DB data_class

🖼️ UI Layer (Presentation)

The UI layer is responsible for displaying data to the user and handling user interactions.

Key Components:

  • 🎨 Compose UI: Screen components built with Jetpack Compose
  • 🧠 ViewModels: Manage UI state and handle UI events
  • 📊 UI State: Immutable data classes representing the UI state
  • 🎮 Events: User interactions or system events that trigger state changes
  • 🔔 Effects: One-time side effects like navigation or showing messages

Pattern: Model-View-Intent (MVI)

  • 📋 Model: UI state representing the current state of the screen
  • 👁️ View: Compose UI that renders the state
  • 🎮 Event: User interactions that trigger state changes (equivalent to “Intent” in standard MVI)
  • 🔔 Effect: One-time side effects like navigation or notifications

🧠 Domain Layer (Business Logic)

The domain layer contains the business logic and rules of the application. It is independent of the UI and data layers, allowing for easy testing and reuse.

Key Components:

  • ⚙️ Use Cases: Encapsulate business logic operations
  • 📋 Domain Models: Represent business entities
  • 📝 Repository Interfaces: Define data access contracts
graph TB
    subgraph DOMAIN[Domain Layer]
        USE_CASE[Use Cases]
        MODEL[Domain Models]
        REPO_API[Repository Interfaces]
    end

    subgraph DATA[Data Layer]
        REPO_IMPL[Repository Implementations]
    end

    USE_CASE --> |uses| REPO_API
    USE_CASE --> |uses| MODEL
    REPO_API --> |uses| MODEL
    REPO_IMPL --> |implements| REPO_API
    REPO_IMPL --> |uses| MODEL

    classDef domain_layer fill:#d9ffd9,stroke:#000000,color:#000000
    classDef domain_class fill:#33cc33,stroke:#000000,color:#000000
    classDef data_layer fill:#ffe6cc,stroke:#000000,color:#000000
    classDef data_class fill:#ffaa33,stroke:#000000,color:#000000

    linkStyle default stroke:#999,stroke-width:2px

    class DOMAIN domain_layer
    class USE_CASE,REPO_API,MODEL domain_class
    class DATA data_layer
    class REPO_IMPL data_class

💾 Data Layer

The data layer is responsible for data retrieval, storage, and synchronization.

Key Components:

  • 📦 Repository implementations: Implement repository interfaces from the domain layer
  • 🔌 Data Sources: Provide data from specific sources (API, database, preferences)
  • 📄 Data Transfer Objects: Represent data at the data layer

Pattern: Data Source Pattern

  • 🔍 Abstracts data sources behind a clean API
  • Maps data between domain models and data transfer objects
graph TD
    subgraph DOMAIN[Domain Layer]
        REPO_API[Repository]
    end

    subgraph DATA[Data Layer]
        REPO_IMPL[Repository implementations]
        RDS[Remote Data Sources]
        LDS[Local Data Sources]
        MAPPER[Data Mappers]
        DTO[Data Transfer Objects]
    end

    REPO_IMPL --> |implements| REPO_API
    REPO_IMPL --> RDS
    REPO_IMPL --> LDS
    REPO_IMPL --> MAPPER
    RDS --> MAPPER
    LDS --> MAPPER
    MAPPER --> DTO

    classDef domain_layer fill:#d9ffd9,stroke:#000000,color:#000000
    classDef domain_class fill:#33cc33,stroke:#000000,color:#000000
    classDef data_layer fill:#ffe6cc,stroke:#000000,color:#000000
    classDef data_class fill:#ffaa33,stroke:#000000,color:#000000

    linkStyle default stroke:#999,stroke-width:2px

    class DOMAIN domain_layer
    class REPO_API domain_class
    class DATA data_layer
    class REPO_IMPL,RDS,LDS,MAPPER,DTO data_class

🔄 Immutability

Immutability means that once an object is created, it cannot be changed. Instead of modifying existing objects, new objects are created with the desired changes. In the context of UI state, this means that each state object represents a complete snapshot of the UI at a specific point in time.

Why is Immutability Important?

Immutability provides several benefits:

  • Predictability: With immutable state, the UI can only change when a new state object is provided, making the flow of data more predictable and easier to reason about.
  • Debugging: Each state change creates a new state object, making it easier to track changes and debug issues by comparing state objects.
  • Concurrency: Immutable objects are thread-safe by nature, eliminating many concurrency issues.
  • Performance: While creating new objects might seem inefficient, modern frameworks optimize this process, and the benefits of immutability often outweigh the costs.
  • Time-travel debugging: Immutability enables storing previous states, allowing developers to “time travel” back to previous application states during debugging.

🎨 UI Architecture

The UI is built using Jetpack Compose with a component-based architecture following our modified Model-View-Intent (MVI) pattern. This architecture provides a unidirectional data flow, clear separation of concerns, and improved testability.

For detailed information about the UI architecture and theming, see the UI Architecture and Theme System documents.

📱 Offline-First Approach

The application implements an offline-first Approach to provide a reliable user experience regardless of network conditions:

  • 💾 Local database as the single source of truth
  • 🔄 Background synchronization with remote servers
  • 📋 Operation queueing for network operations
  • 🔀 Conflict resolution for data modified both locally and remotely

Implementation Approach

graph LR
    subgraph UI[UI Layer]
        VIEW_MODEL[ViewModel]
    end

    subgraph DOMAIN[Domain Layer]
        USE_CASE[Use Cases]
    end

    subgraph DATA[Data Layer]
        subgraph SYNC[Synchronization]
            SYNC_MANAGER[Sync Manager]
            SYNC_QUEUE[Sync Queue]
        end
        REPO[Repository]
        LOCAL[Local Data Source]
        REMOTE[Remote Data Source]
    end
    
    VIEW_MODEL --> USE_CASE
    USE_CASE --> REPO
    SYNC_MANAGER --> LOCAL
    SYNC_MANAGER --> REMOTE
    SYNC_MANAGER --> SYNC_QUEUE
    REPO --> LOCAL
    REPO --> REMOTE
    REPO --> SYNC_MANAGER
    REPO ~~~ SYNC
    
    classDef ui_layer fill:#d9e9ff,stroke:#000000,color:#000000
    classDef ui_class fill:#4d94ff,stroke:#000000,color:#000000
    classDef domain_layer fill:#d9ffd9,stroke:#000000,color:#000000
    classDef domain_class fill:#33cc33,stroke:#000000,color:#000000
    classDef data_layer fill:#ffe6cc,stroke:#000000,color:#000000
    classDef data_class fill:#ffaa33,stroke:#000000,color:#000000
    classDef sync_layer fill:#e6cce6,stroke:#000000,color:#000000
    classDef sync_class fill:#cc99cc,stroke:#000000,color:#000000

    linkStyle default stroke:#999,stroke-width:2px

    class UI ui_layer
    class VIEW_MODEL ui_class
    class DOMAIN domain_layer
    class USE_CASE domain_class
    class DATA data_layer
    class REPO,LOCAL,REMOTE data_class
    class SYNC sync_layer
    class SYNC_MANAGER,SYNC_API,SYNC_QUEUE sync_class

The offline-first approach is implemented across all layers of the application:

  1. 💾 Data Layer:
    • 📊 Local database as the primary data source
    • 🌐 Remote data source for server communication
    • 📦 Repository pattern to coordinate between data sources
    • 🔄 Synchronization manager to handle data syncing
  2. 🧠 Domain Layer:
    • ⚙️ Use cases handle both online and offline scenarios
    • 📝 Business logic accounts for potential network unavailability
    • 📋 Domain models represent data regardless of connectivity state
  3. 🖼️ UI Layer:
    • 🧠 ViewModels expose UI state that reflects connectivity status
    • 🚦 UI components display appropriate indicators for offline mode
    • 👆 User interactions are designed to work regardless of connectivity

💉 Dependency Injection

The application uses Koin for dependency injection, with modules organized by feature:

  • 📱 App Modules: Configure application-wide dependencies
  • 🔄 App Common: Shared dependencies between applications
  • ✨ Feature Modules: Configure feature-specific dependencies
  • 🧰 Core Modules: Configure core dependencies
// Example Koin module for a feature
val featureModule = module {
    viewModel { FeatureViewModel(get()) }
    single<FeatureRepository> { FeatureRepositoryImpl(get(), get()) }
    single<FeatureUseCase> { FeatureUseCaseImpl(get()) }
    single<FeatureApiClient> { FeatureApiClientImpl() }
}

🔄 Cross-Cutting Concerns

Cross-cutting concerns are aspects of the application that affect multiple features and cannot be cleanly handled individually for every feature. These concerns require consistent implementation throughout the codebase to ensure maintainability an reliability.

In Thunderbird for Android, several cross-cutting concerns are implemented as dedicated core modules to provide standardized solutions that can be reused across the application:

  • ⚠️ Error Handling: Comprehensive error handling (core/outcome) transforms exceptions into domain-specific errors and provides user-friendly feedback.
  • 📋 Logging: Centralized logging system (core/logging) ensures consistent log formatting, levels, and storage.
  • 🔒 Security: Modules like core/security handle encryption, authentication, and secure data storage.

Work in progress:

  • 🔐 Encryption: The core/crypto module provides encryption and decryption utilities for secure data handling.
  • 📦 Feature Flags: The core/feature-flags module manages feature toggles and experimental features.
  • 🔄 Synchronization: The core/sync module manages background synchronization, conflict resolution, and offline-first behavior.
  • 🛠️ Configuration Management: Centralized handling of application settings and environment-specific configurations.

By implementing these concerns as core modules, the application achieves a clean and modular architecture that is easier to maintain and extend.

⚠️ Error Handling

The application implements a comprehensive error handling strategy across all layers. We favor using the Outcome pattern over exceptions for expected error conditions, while exceptions are reserved for truly exceptional situations that indicate programming errors or unrecoverable system failures.

  • 🧠 Domain Errors: Encapsulate business logic errors as sealed classes, ensuring clear representation of specific error cases.
  • 💾 Data Errors: Transform network or database exceptions into domain-specific errors using result patterns in repository implementations.
  • 🖼️ UI Error Handling: Provide user-friendly error feedback by:
    • Mapping domain errors to UI state in ViewModels.
    • Displaying actionable error states in Compose UI components.
    • Offering retry options for network connectivity issues.

note

Exceptions should be used sparingly. Favor the Outcome pattern and sealed classes for predictable error conditions to enhance maintainability and clarity.

🛠️ How to Implement Error Handling

When implementing error handling in your code:

  1. Define domain-specific errors as sealed classes in your feature’s domain layer:

    sealed class AccountError {
        data class AuthenticationFailed(val reason: String) : AccountError()
        data class NetworkError(val exception: Exception) : AccountError()
        data class ValidationError(val field: String, val message: String) : AccountError()
    }
    
  2. Use result patterns (Outcome) instead of exceptions for error handling:

    // Use the Outcome class for representing success or failure
    sealed class Outcome<out T, out E> {
        data class Success<T>(val value: T) : Outcome<T, Nothing>()
        data class Failure<E>(val error: E) : Outcome<Nothing, E>()
    }
    
  3. Transform external errors into domain errors in your repositories using result patterns:

    // Return Outcome instead of throwing exceptions
    fun authenticate(credentials: Credentials): Outcome<AuthResult, AccountError> {
        return try {
            val result = apiClient.authenticate(credentials)
            Outcome.Success(result)
        } catch (e: HttpException) {
            val error = when (e.code()) {
                401 -> AccountError.AuthenticationFailed("Invalid credentials")
                else -> AccountError.NetworkError(e)
            }
            logger.error(e) { "Authentication failed: ${error::class.simpleName}" }
            Outcome.Failure(error)
        } catch (e: Exception) {
            logger.error(e) { "Authentication failed with unexpected error" }
            Outcome.Failure(AccountError.NetworkError(e))
        }
    }
    
  4. Handle errors in Use Cases by propagating the Outcome:

    class LoginUseCase(
        private val accountRepository: AccountRepository,
        private val credentialValidator: CredentialValidator,
    ) {
        fun execute(credentials: Credentials): Outcome<AuthResult, AccountError> {
            // Validate input first
            val validationResult = credentialValidator.validate(credentials)
            if (validationResult is ValidationResult.Failure) {
                return Outcome.Failure(
                    AccountError.ValidationError(
                        field = validationResult.field,
                        message = validationResult.message
                    )
                )
            }
    
            // Proceed with authentication
            return accountRepository.authenticate(credentials)
        }
    }
    
  5. Handle outcomes in ViewModels and transform them into UI state:

    viewModelScope.launch {
        val outcome = loginUseCase.execute(credentials)
    
        when (outcome) {
            is Outcome.Success -> {
                _uiState.update { it.copy(isLoggedIn = true) }
            }
            is Outcome.Failure -> {
                val errorMessage = when (val error = outcome.error) {
                    is AccountError.AuthenticationFailed -> 
                        stringProvider.getString(R.string.error_authentication_failed, error.reason)
                    is AccountError.NetworkError -> 
                        stringProvider.getString(R.string.error_network, error.exception.message)
                    is AccountError.ValidationError -> 
                        stringProvider.getString(R.string.error_validation, error.field, error.message)
                }
                _uiState.update { it.copy(error = errorMessage) }
            }
        }
    }
    
  6. Always log errors for debugging purposes:

    // Logging is integrated into the Outcome pattern
    fun fetchMessages(): Outcome<List<Message>, MessageError> {
        return try {
            val messages = messageService.fetchMessages()
            logger.info { "Successfully fetched ${messages.size} messages" }
            Outcome.Success(messages)
        } catch (e: Exception) {
            logger.error(e) { "Failed to fetch messages" }
            Outcome.Failure(MessageError.FetchFailed(e))
        }
    }
    
  7. Compose multiple operations that return Outcomes:

    fun synchronizeAccount(): Outcome<SyncResult, SyncError> {
        // First operation
        val messagesOutcome = fetchMessages()
        if (messagesOutcome is Outcome.Failure) {
            return Outcome.Failure(SyncError.MessageSyncFailed(messagesOutcome.error))
        }
    
        // Second operation using the result of the first
        val messages = messagesOutcome.getOrNull()!!
        val folderOutcome = updateFolders(messages)
        if (folderOutcome is Outcome.Failure) {
            return Outcome.Failure(SyncError.FolderUpdateFailed(folderOutcome.error))
        }
    
        // Return success with combined results
        return Outcome.Success(
            SyncResult(
                messageCount = messages.size,
                folderCount = folderOutcome.getOrNull()!!.size
            )
        )
    }
    

📝 Logging

The application uses a structured logging system with a well-defined API:

  • 📊 Logging Architecture:
    • Core logging API (core/logging/api) defines interfaces like Logger and LogSink
    • Multiple implementations (composite, console) allow for flexible logging targets
    • Composite implementation enables logging to multiple sinks simultaneously
  • 🔄 Logger vs. Sink:
    • Logger: The front-facing interface that application code interacts with to create log entries
      • Provides methods for different log levels (verbose, debug, info, warn, error)
      • Handles the creation of log events with appropriate metadata (timestamp, tag, etc.)
      • Example: DefaultLogger implements the Logger interface and delegates to a LogSink
    • LogSink: The back-end component that receives log events and determines how to process them
      • Defines where and how log messages are actually stored or displayed
      • Filters log events based on configured log levels
      • Can be implemented in various ways (console output, file storage, remote logging service)
      • Multiple sinks can be used simultaneously via composite pattern
  • 📋 Log Levels:
    • VERBOSE: Most detailed log level for debugging
    • DEBUG: Detailed information for diagnosing problems
    • INFO: General information about application flow
    • WARN: Potential issues that don’t affect functionality
    • ERROR: Issues that affect functionality but don’t crash the application

🛠️ How to Implement Logging

When adding logging to your code:

  1. Inject a Logger into your class:

    class AccountRepository(
        private val apiClient: ApiClient,
        private val logger: Logger,
    ) {
        // Repository implementation
    }
    
  2. Choose the appropriate log level based on the importance of the information:

    • Use verbose for detailed debugging information (only visible in debug builds)
    • Use debug for general debugging information
    • Use info for important events that should be visible in production
    • Use warn for potential issues that don’t affect functionality
    • Use error for issues that affect functionality
  3. Use lambda syntax to avoid string concatenation when logging isn’t needed:

    // Good - string is only created if this log level is enabled
    logger.debug { "Processing message with ID: $messageId" }
    
    // Avoid - string is always created even if debug logging is disabled
    logger.debug("Processing message with ID: " + messageId)
    
  4. Include relevant context in log messages:

    logger.info { "Syncing account: ${account.email}, folders: ${folders.size}" }
    
  5. Log exceptions with the appropriate level and context:

    try {
        apiClient.fetchMessages()
    } catch (e: Exception) {
        logger.error(e) { "Failed to fetch messages for account: ${account.email}" }
        throw MessageSyncError.FetchFailed(e)
    }
    
  6. Use tags for better filtering when needed:

    private val logTag = LogTag("AccountSync")
    
    fun syncAccount() {
        logger.info(logTag) { "Starting account sync for: ${account.email}" }
    }
    

🔒 Security

Security is a critical aspect of an email client. The application implements:

  • 🔐 Data Encryption:
    • End-to-end encryption using OpenPGP (via the legacy/crypto-openpgp module)
    • Classes like EncryptionDetector and OpenPgpEncryptionExtractor handle encrypted emails
    • Local storage encryption for sensitive data like account credentials
  • 🔑 Authentication:
    • Support for various authentication types (OAuth, password, client certificate)
    • Secure token storage and management
    • Authentication error handling and recovery
  • 🛡️ Network Security:
    • TLS for all network connections with certificate validation
    • Certificate pinning for critical connections
    • Protection against MITM attacks

note

This section is a work in progress. The security architecture is being developed and will be documented in detail as it evolves.

🛠️ How to Implement Security

When implementing security features in your code:

  1. Never store sensitive data in plain text:

    // Bad - storing password in plain text
    sharedPreferences.putString("password", password)
    
    // Good - use the secure credential storage
    val credentialStorage = get<CredentialStorage>()
    credentialStorage.storeCredentials(accountUuid, credentials)
    
  2. Use encryption for sensitive data:

    // For data that needs to be stored encrypted
    val encryptionManager = get<EncryptionManager>()
    val encryptedData = encryptionManager.encrypt(sensitiveData)
    database.storeEncryptedData(encryptedData)
    
  3. Validate user input to prevent injection attacks:

    // Validate input before using it
    if (!InputValidator.isValidEmailAddress(userInput)) {
        throw ValidationError("Invalid email address")
    }
    
  4. Use secure network connections:

    // The networking modules enforce TLS by default
    // Make sure to use the provided clients rather than creating your own
    val networkClient = get<NetworkClient>()
    

🧪 Testing Strategy

The architecture supports comprehensive testing:

  • 🔬 Unit Tests: Test individual components in isolation
  • 🔌 Integration Tests: Test interactions between components
  • 📱 UI Tests: Test the UI behavior and user flows

See the Testing guide document for more details on how to write and run tests for the application.

🔙 Legacy Integration

The application includes legacy code that is gradually being migrated to the new architecture:

  • 📦 Legacy Modules: Contain code from the original K-9 Mail application
  • 🔄 Migration Strategy: Gradual migration to the new architecture
  • 🔌 Integration Points: Clear interfaces between legacy and new code

For more details on the legacy integration, see the Legacy Integration document.

🔄 User Flows

The User Flows provides visual representations of typical user flows through the application, helping to understand how different components interact.

📦 Module Organization

The Thunderbird for Android project is following a modularization approach, where the codebase is divided into multiple distinct modules. These modules encapsulate specific functionality and can be developed, tested, and maintained independently. This modular architecture promotes reusability, scalability, and maintainability of the codebase.

This document outlines the adopted module organization for the Thunderbird for Android project, serving as a guide for developers to understand the codebase structure and ensure consistent architectural patterns.

📂 Module Overview

The modules are organized into several types, each serving a specific purpose in the overall architecture:

graph TB
    subgraph APP[App Modules]
        direction TB
        APP_TB["`**:app-thunderbird**<br>Thunderbird for Android`"]
        APP_K9["`**:app-k9mail**<br>K-9 Mail`"]
    end

    subgraph COMMON[App Common Module]
        direction TB
        COMMON_APP["`**:app-common**<br>Integration Code`"]
    end

    subgraph FEATURE[Feature Modules]
        direction TB
        FEATURE_ACCOUNT["`**:feature:account**`"]
        FEATURE_SETTINGS["`**:feature:settings**`"]
        FEATURE_ONBOARDING["`**:feature:onboarding**`"]
        FEATURE_MAIL["`**:feature:mail**`"]
    end

    subgraph CORE[Core Modules]
        direction TB
        CORE_UI["`**:core:ui**`"]
        CORE_COMMON["`**:core:common**`"]
        CORE_ANDROID["`**:core:android**`"]
        CORE_NETWORK["`**:core:network**`"]
        CORE_DATABASE["`**:core:database**`"]
        CORE_TESTING["`**:core:testing**`"]
    end

    subgraph LIBRARY[Library Modules]
        direction TB
        LIB_AUTH["`**:library:auth**`"]
        LIB_CRYPTO["`**:library:crypto**`"]
        LIB_STORAGE["`**:library:storage**`"]
    end

    subgraph LEGACY[Legacy Modules]
        direction TB
        LEGACY_K9["`**:legacy**`"]
        LEGACY_MAIL["`**:mail**`"]
        LEGACY_BACKEND["`**:backend**`"]
    end

    APP ~~~ COMMON
    COMMON ~~~ FEATURE
    FEATURE ~~~ CORE
    CORE ~~~ LIBRARY
    LIBRARY ~~~ LEGACY

    APP --> |depends on| COMMON
    COMMON --> |depends on| FEATURE
    FEATURE --> |depends on| CORE
    CORE --> |depends on| LIBRARY
    COMMON --> |depends on<br>as legacy bridge| LEGACY

    classDef app fill:#d9e9ff,stroke:#000000,color:#000000
    classDef app_module fill:#4d94ff,stroke:#000000,color:#000000
    classDef common fill:#e6e6e6,stroke:#000000,color:#000000
    classDef common_module fill:#999999,stroke:#000000,color:#000000
    classDef feature fill:#d9ffd9,stroke:#000000,color:#000000
    classDef feature_module fill:#33cc33,stroke:#000000,color:#000000
    classDef core fill:#e6cce6,stroke:#000000,color:#000000
    classDef core_module fill:#cc99cc,stroke:#000000,color:#000000
    classDef library fill:#fff0d0,stroke:#000000,color:#000000
    classDef library_module fill:#ffaa33,stroke:#000000,color:#000000
    classDef legacy fill:#ffe6e6,stroke:#000000,color:#000000
    classDef legacy_module fill:#ff9999,stroke:#000000,color:#000000

    linkStyle default stroke:#999,stroke-width:2px
    linkStyle 0,1,2,3,4 stroke-width:0px

    class APP app
    class APP_TB,APP_K9 app_module
    class COMMON common
    class COMMON_APP common_module
    class FEATURE feature
    class FEATURE_ACCOUNT,FEATURE_SETTINGS,FEATURE_ONBOARDING,FEATURE_MAIL feature_module
    class CORE core
    class CORE_UI,CORE_COMMON,CORE_ANDROID,CORE_DATABASE,CORE_NETWORK,CORE_TESTING core_module
    class LIBRARY library
    class LIB_AUTH,LIB_CRYPTO,LIB_STORAGE library_module
    class LEGACY legacy
    class LEGACY_MAIL,LEGACY_BACKEND,LEGACY_K9 legacy_module

Module Types

📱 App Modules

The App Modules (app-thunderbird and app-k9mail) contain the application-specific code, including:

  • Application entry points and initialization logic
  • Final dependency injection setup
  • Navigation configuration
  • Integration with feature modules solely for that application
  • App-specific themes and resources (strings, themes, etc.)

🔄 App Common Module

The app-common module acts as the central hub for shared code between both applications. This module serves as the primary “glue” that binds various feature modules together, providing a seamless integration point. It also contains:

  • Shared application logic
  • Feature coordination
  • Common dependency injection setup
  • Legacy code bridges and adapters
What Should Go in App Common

The app-common module should contain:

  1. Shared Application Logic: Code that’s needed by both app modules but isn’t specific to any one feature.
    • Example: BaseApplication provides common application initialization, language management, and theme setup.
    • This avoids duplication between app-thunderbird and app-k9mail.
  2. Feature Integration Code: Code that connects different features together.
    • Example: Code that coordinates between account and mail features.
    • This maintains separation between features while allowing them to work together.
  3. Common Dependency Injection Setup: Koin modules that configure dependencies shared by both applications.
    • Example: AppCommonModule includes legacy modules and app-common specific modules.
    • This ensures consistent dependency configuration across both applications.
  4. Legacy Code Bridges/Adapters: Implementations of interfaces defined in feature modules that delegate to legacy code.
    • Example: DefaultAccountProfileLocalDataSource implements AccountProfileLocalDataSource from feature:account:core and delegates to legacy account code.
    • These bridges isolate legacy code and prevent direct dependencies on it from feature modules.
What Should NOT Go in App Common

The following should NOT be placed in app-common:

  1. Feature-Specific Business Logic: Business logic that belongs to a specific feature domain should be in that feature’s module.
    • Example: Mail composition logic should be in feature:mail, not in app-common.
    • This maintains clear separation of concerns and feature independence.
  2. UI Components: UI components should be in core:ui or in feature modules.
    • Example: A custom button component should be in core:ui, while a mail-specific UI component should be in feature:mail.
    • This ensures proper layering and reusability.
  3. Direct Legacy Code: Legacy code should remain in legacy modules, with app-common providing bridges.
    • Example: Don’t move legacy mail code into app-common; instead, create a bridge in app-common.
    • This maintains the separation between legacy and modern code.
  4. New Feature Implementations: New features should be implemented in feature modules, not in app-common.
    • Example: A new calendar feature should be in feature:calendar, not in app-common.
    • This ensures features can evolve independently.
Decision Criteria for New Contributors

When deciding whether code belongs in app-common or a feature module, consider:

  1. Is it shared between both applications? If yes, it might belong in app-common.
  2. Is it specific to a single feature domain? If yes, it belongs in that feature module.
  3. Does it bridge to legacy code? If yes, it belongs in app-common.
  4. Does it coordinate between multiple features? If yes, it might belong in app-common.
  5. Is it a new feature implementation? If yes, create a new feature module instead.

Remember that app-common should primarily contain integration code, shared application logic, and bridges to legacy code. Feature-specific logic should be in feature modules, even if used by both applications.

✨ Feature Modules

The feature:* modules are independent and encapsulate distinct user-facing feature domains. They are designed to be reusable and can be integrated into any application module as needed.

Feature implementation modules (e.g., :feature:account:impl) should ideally not depend directly on other feature implementation modules. Instead, they should depend on the public :api module of other features (e.g., :feature:someOtherFeature:api) to access their functionality through defined contracts, see module structure for more details.

When features are complex, they can be split into smaller sub feature modules, addressing specific aspects or functionality within a feature domain:

  • :feature:account:api: Public interfaces for account management
  • :feature:account:settings:api: Public interfaces for account settings
  • :feature:account:settings:impl: Concrete implementations of account settings

🧰 Core Modules

The core:* modules contain foundational functionality used across the application:

  • core:ui: UI components, themes, and utilities
  • core:common: Common utilities and extensions
  • core:network: Networking utilities and API client infrastructure
  • core:database: Database infrastructure and utilities
  • core:testing: Testing utilities

Core modules should only contain generic, reusable components that have no specific business logic. Business objects (e.g., account, mail, etc.) should live in their respective feature modules.

📚 Library Modules

The library:* modules are for specific implementations that might be used across various features or applications. They could be third-party integrations or complex utilities and eventually shared across multiple projects.

🔙 Legacy Modules

The legacy:* modules that are still required for the project to function, but don’t follow the new project structure. These modules should not be used for new development. The goal is to migrate the functionality of these modules to the new structure over time.

Similarly the mail:* and backend:* modules are legacy modules that contain the old mail and backend implementations. These modules are being gradually replaced by the new feature modules.

The legacy modules are integrated into the app-common module, allowing them to be used by other parts of the app. The glue code for bridging legacy code to the new modular architecture is also located in the app-common module. See module legacy integration for more details.

🔗 Module Dependencies

The module dependency diagram below illustrates how different modules interact with each other in the project, showing the dependencies and integration points between modules:

  • App Modules: Depend on the App Common module for shared functionality and selectively integrate feature modules
  • App Common: Integrates various feature modules to provide a cohesive application
  • Feature Modules: Use core modules and libraries for their implementation, may depend on other feature api modules
  • App-Specific Features: Some features are integrated directly by specific apps (K-9 Mail or Thunderbird)

Rules for module dependencies:

  • One-Way Dependencies: Modules should not depend on each other in a circular manner
  • API-Implementation Separation: Modules should depend on api modules, not implementation modules, see module structure
  • Feature Integration: Features should be integrated through the app-common module, which acts as the central integration hub
  • Dependency Direction: Dependencies should flow from app modules to common, then to features, and finally to core and libraries
graph TB
    subgraph APP[App Modules]
        direction TB
        APP_TB["`**:app-thunderbird**<br>Thunderbird for Android`"]
        APP_K9["`**:app-k9mail**<br>K-9 Mail`"]
    end

    subgraph COMMON[App Common Module]
        direction TB
        COMMON_APP["`**:app-common**<br>Integration Code`"]
    end

    subgraph FEATURE[Feature Modules]
        direction TB
        FEATURE_ACCOUNT_API["`**:feature:account:api**`"]
        FEATURE_ACCOUNT_IMPL["`**:feature:account:impl**`"]
        FEATURE_SETTINGS_API["`**:feature:settings:api**`"]
        FEATURE_K9["`**:feature:k9OnlyFeature:impl**`"]
        FEATURE_TB["`**:feature:tfaOnlyFeature:impl**`"]
    end

    subgraph CORE[Core Modules]
        direction TB
        CORE_UI_API["`**:core:ui:api**`"]
        CORE_COMMON_API["`**:core:common:api**`"]
    end

    subgraph LIBRARY[Library Modules]
        direction TB
        LIB_AUTH["`**:library:auth**`"]
        LIB_STORAGE["`**:library:storage**`"]
    end

    APP_K9 --> |depends on| COMMON_APP
    APP_TB --> |depends on| COMMON_APP
    COMMON_APP --> |uses| FEATURE_ACCOUNT_API
    COMMON_APP --> |injects/uses impl of| FEATURE_ACCOUNT_IMPL
    FEATURE_ACCOUNT_IMPL --> FEATURE_ACCOUNT_API
    COMMON_APP --> |uses| FEATURE_SETTINGS_API
    APP_K9 --> |injects/uses impl of| FEATURE_K9
    APP_TB --> |injects/uses impl of| FEATURE_TB
    FEATURE_ACCOUNT_API --> |uses| CORE_UI_API
    FEATURE_SETTINGS_API --> |uses| CORE_COMMON_API
    FEATURE_TB --> |uses| LIB_AUTH
    FEATURE_K9 --> |uses| LIB_STORAGE
    CORE_COMMON_API --> |uses| LIB_STORAGE

    classDef app fill:#d9e9ff,stroke:#000000,color:#000000
    classDef app_module fill:#4d94ff,stroke:#000000,color:#000000
    classDef common fill:#e6e6e6,stroke:#000000,color:#000000
    classDef common_module fill:#999999,stroke:#000000,color:#000000
    classDef feature fill:#d9ffd9,stroke:#000000,color:#000000
    classDef feature_module fill:#33cc33,stroke:#000000,color:#000000
    classDef core fill:#e6cce6,stroke:#000000,color:#000000
    classDef core_module fill:#cc99cc,stroke:#000000,color:#000000
    classDef library fill:#fff0d0,stroke:#000000,color:#000000
    classDef library_module fill:#ffaa33,stroke:#000000,color:#000000
    classDef legacy fill:#ffe6e6,stroke:#000000,color:#000000
    classDef legacy_module fill:#ff9999,stroke:#000000,color:#000000

    linkStyle default stroke:#999,stroke-width:2px

    class APP app
    class APP_TB,APP_K9 app_module
    class COMMON common
    class COMMON_APP common_module
    class FEATURE feature
    class FEATURE_ACCOUNT_API,FEATURE_ACCOUNT_IMPL,FEATURE_SETTINGS_API,FEATURE_MAIL feature_module
    class CORE core
    class CORE_UI_API,CORE_COMMON_API core_module
    class LIBRARY library
    class LIB_AUTH,LIB_STORAGE library_module

    classDef featureK9 fill:#ffcccc,stroke:#cc0000,color:#000000
    classDef featureTB fill:#ccccff,stroke:#0000cc,color:#000000
    class FEATURE_K9 featureK9
    class FEATURE_TB featureTB
Last change: 2025-06-26, commit: 3b4ef25

📦 Module Structure

The Thunderbird for Android project is following a modularization approach, where the codebase is divided into multiple distinct modules. These modules encapsulate specific functionality and can be developed, tested, and maintained independently. This modular architecture promotes reusability, scalability, and maintainability of the codebase.

Each module should be split into two main parts: API and implementation. This separation provides clear boundaries between what a module exposes to other modules and how it implements its functionality internally.

When a feature is complex, it can be further split into sub modules, allowing for better organization and smaller modules for distinct functionalities within a feature domain.

This approach promotes:

  • Loose coupling: Modules interact through well-defined interfaces
  • Interchangeable implementations: Different implementations can be swapped without affecting consumers
  • Improved build times: Reduces the scope of recompilation when changes are made
  • Better testability: Modules can be tested in isolation
  • Clear ownership: Teams can own specific modules

📝 API Module

The API module defines the public contract that other modules can depend on. It should be stable, well-documented, and change infrequently.

The API module contains:

  • Public interfaces: Contracts that define the module’s capabilities
  • Data models: Entities that are part of the public API
  • Constants and enums: Shared constants and enumeration types
  • Extension functions: Utility functions that extend public types
  • Navigation definitions: Navigation routes and arguments

The API module should be minimal and focused on defining the contract that other modules can depend on. It should not contain any implementation details.

Naming Convention

API modules should follow the naming convention:

  • feature:<feature-name>:api for feature modules
  • core:<core-name>:api for core modules

Example structure for a feature API module:

feature:account:api
├── src/main/kotlin/net/thunderbird/feature/account/api
│   ├── AccountManager.kt (interface)
│   ├── Account.kt (entity)
│   ├── AccountNavigation.kt (interface)
│   ├── AccountType.kt (entity)
│   └── AccountExtensions.kt (extension functions)

API Design Guidelines

When designing APIs, follow these principles:

  • Minimal surface area: Expose only what is necessary
  • Immutable data: Use immutable data structures where possible
  • Clear contracts: Define clear method signatures with documented parameters and return values
  • Error handling: Define how errors are communicated (exceptions, result types, etc.)

⚙️ Implementation Module

The implementation module depends on the API module but should not be depended upon by other modules (except for dependency injection setup).

The implementation module contains:

  • Interface implementations: Concrete implementations of the interfaces defined in the API module
  • Internal components: Classes and functions used internally
  • Data sources: Repositories, database access, network clients
  • UI components: Screens, composables, and ViewModels

Naming Convention

Implementation modules should follow the naming convention:

  • feature:<feature-name>:impl for standard implementations
  • feature:<feature-name>:impl-<variant> for variant-specific implementations
  • core:<core-name>:impl for core module implementations

Multiple Implementations

When multiple implementations are needed, such as for different providers or platforms, they can be placed in separate modules and named accordingly:

  • feature:account:impl-gmail - Gmail-specific implementation
  • feature:account:impl-yahoo - Yahoo-specific implementation
  • feature:account:impl-noop - No-operation implementation for testing

Example structure for a variant implementation:

feature:account:impl-gmail
├── src/main/kotlin/app/thunderbird/feature/account/gmail
│   └── GmailAccountManager.kt

Clean Architecture in Implementation Modules

A complex feature implementation module should apply Clean Architecture principles, separating concerns into:

  • UI Layer: Compose UI components, ViewModels, and UI state management
  • Domain Layer: Use cases, domain models, and business logic
  • Data Layer: Repositories, data sources, and data mapping
feature:account:impl
├── src/main/kotlin/app/thunderbird/feature/account/impl
│   ├── data/
│   │   ├── repository/
│   │   ├── datasource/
│   │   └── mapper/
│   ├── domain/
│   │   ├── repository/
│   │   ├── entity/
│   │   └── usecase/
│   └── ui/
│       ├── AccountScreen.kt
│       └── AccountViewModel.kt

Implementation Best Practices

  • Internal visibility: Use the internal modifier for classes and functions that should not be part of the public API
  • Encapsulation: Keep implementation details hidden from consumers
  • Testability: Design implementations to be easily testable
  • Dependency injection: Use constructor injection for dependencies
  • Error handling: Implement robust error handling according to API contracts
  • Performance: Consider performance implications of implementations
  • Logging: Include appropriate logging for debugging and monitoring

🧪 Testing Module

Testing modules provide test implementations, utilities, and frameworks for testing other modules. They are essential for ensuring the quality and correctness of the codebase.

Contents

The testing module contains:

  • Test utilities: Helper functions and classes for testing
  • Test frameworks: Custom test frameworks and extensions
  • Test fixtures: Reusable test setups and teardowns
  • Test matchers: Custom matchers for assertions

Naming Convention

Testing modules should follow the naming convention:

  • feature:<feature-name>:testing for feature-specific test utilities
  • core:<core-name>:testing for core test utilities
  • <module-name>:test for module-specific tests

Example structure for a testing module:

feature:account:testing
├── src/main/kotlin/app/thunderbird/feature/account/testing
│   ├── AccountTestUtils.kt
│   └── AccountTestMatchers.kt

Testing Best Practices

  • Reusability: Create reusable test utilities and data factories
  • Isolation: Tests should be isolated and not depend on external systems
  • Readability: Tests should be easy to read and understand
  • Maintainability: Tests should be easy to maintain and update
  • Coverage: Tests should cover all critical paths and edge cases

🤖 Fake Module

Fake modules provide alternative implementations of interfaces for testing, development, or demonstration purposes. They are simpler than the real implementations and are designed to be used in controlled environments.

Contents

The fake module contains:

  • Fake implementations: Simplified implementations of interfaces
  • Generic test data: Basic, reusable sample data for testing and demonstration
  • In-memory data stores: In-memory alternatives to real data stores
  • Controlled behavior: Implementations with predictable, configurable behavior
  • Test doubles: Mocks, stubs, and spies for testing

important

Fake modules should be limited to the most generic data and implementations. Specific use cases or test setups should be part of the actual test, not the fake module.

Naming Convention

Fake modules should follow the naming convention:

  • feature:<feature-name>:fake for feature-specific fake implementations
  • core:<core-name>:fake for core fake implementations

Example structure for a fake module:

feature:account:fake
├── src/main/kotlin/app/thunderbird/feature/account/fake
│   ├── FakeAccountRepository.kt
│   ├── FakeAccountDataSource.kt
│   ├── InMemoryAccountStore.kt
│   ├── FakeAccountManager.kt
│   └── data/
│       ├── FakeAccountData.kt
│       └── FakeAccountProfileData.kt

Fake Implementation Best Practices

  • Simplicity: Fake implementations should be simpler than real implementations
  • Deterministic behavior: Behavior should be predictable and controllable
  • Configuration: Allow configuration of behavior for different test scenarios
  • Visibility: Make internal state visible for testing assertions
  • Performance: Fake implementations should be fast for testing efficiency
  • Generic test data: Include basic, reusable test data that can be used across different tests
  • Realistic but generic data: Test data should be realistic enough to be useful but generic enough to be reused
  • Separation of concerns: Keep specific test scenarios and edge cases in the actual tests, not in the fake module

🔄 Common Module

Common modules provide shared functionality that is used by multiple modules within a feature. They contain implementation details, utilities, and components that need to be shared between related modules but are not part of the public API.

Contents

The common module contains:

  • Shared utilities: Helper functions and classes used across related modules
  • Internal implementations: Implementation details shared between modules
  • Shared UI components: Reusable UI components specific to a feature domain
  • Data repositories: Shared data storage and access implementations
  • Constants and resources: Shared constants, strings, and other resources

Naming Convention

Common modules should follow the naming convention:

  • feature:<feature-name>:common for feature-specific common code
  • core:<core-name>:common for core common code

Example structure for a common module:

feature:account:common
├── src/main/kotlin/net/thunderbird/feature/account/common
│   ├── AccountCommonModule.kt
│   ├── data/
│   │   └── InMemoryAccountStateRepository.kt
│   ├── domain/
│   │   ├── AccountDomainContract.kt
│   │   ├── input/
│   │   │   └── NumberInputField.kt
│   │   └── entity/
│   │       ├── AccountState.kt
│   │       ├── AccountDisplayOptions.kt
│   │       └── AuthorizationState.kt
│   └── ui/
│       ├── WizardNavigationBar.kt
│       └── WizardNavigationBarState.kt

Common Module Best Practices

  • Internal visibility: Use the internal modifier for classes and functions that should not be part of the public API
  • Clear organization: Organize code into data, domain, and UI packages for better maintainability
  • Shared contracts: Define clear interfaces for functionality that will be implemented by multiple modules
  • Reusable components: Create UI components that can be reused across different screens within a feature
  • Stateless where possible: Design components to be stateless and receive state through parameters
  • Minimal dependencies: Keep dependencies to a minimum to avoid transitive dependency issues
  • Documentation: Document the purpose and usage of shared components
  • Avoid leaking implementation details: Don’t expose implementation details that could create tight coupling

🔗 Module Dependencies

The module dependency diagram below illustrates how different modules interact with each other in the project, showing the dependencies and integration points between modules.

graph TB
    subgraph APP[App Modules]
        direction TB
        APP_TB["`**:app-thunderbird**<br>Thunderbird for Android`"]
        APP_K9["`**:app-k9mail**<br>K-9 Mail`"]
    end

    subgraph COMMON[App Common Module]
        direction TB
        COMMON_APP["`**:app-common**<br>Integration Code`"]
    end

    subgraph FEATURE[Feature]
        direction TB
        FEATURE1[feature:account:api]
        FEATURE2[feature:account:impl]
        FEATURE3[Feature 2]
        FEATURE_K9[Feature K-9 Only]
        FEATURE_TB[Feature TfA Only]
    end

    subgraph CORE[Core]
        direction TB
        CORE1[Core 1]
        CORE2[Core 2]
    end

    subgraph LIBRARY[Library]
        direction TB
        LIB1[Library 1]
        LIB2[Library 2]
    end

    APP_K9 --> |depends on| COMMON_APP
    APP_TB --> |depends on| COMMON_APP
    COMMON_APP --> |integrates| FEATURE1
    COMMON_APP --> |injects| FEATURE2
    FEATURE2 --> FEATURE1
    COMMON_APP --> |integrates| FEATURE3
    APP_K9 --> |integrates| FEATURE_K9
    APP_TB --> |integrates| FEATURE_TB
    FEATURE1 --> |uses| CORE1
    FEATURE3 --> |uses| CORE2
    FEATURE_TB --> |uses| CORE1
    FEATURE_K9 --> |uses| LIB2
    CORE2 --> |uses| LIB1

    classDef app fill:#d9e9ff,stroke:#000000,color:#000000
    classDef app_module fill:#4d94ff,stroke:#000000,color:#000000
    classDef common fill:#e6e6e6,stroke:#000000,color:#000000
    classDef common_module fill:#999999,stroke:#000000,color:#000000
    classDef feature fill:#d9ffd9,stroke:#000000,color:#000000
    classDef feature_module fill:#33cc33,stroke:#000000,color:#000000
    classDef core fill:#e6cce6,stroke:#000000,color:#000000
    classDef core_module fill:#cc99cc,stroke:#000000,color:#000000
    classDef library fill:#fff0d0,stroke:#000000,color:#000000
    classDef library_module fill:#ffaa33,stroke:#000000,color:#000000
    classDef legacy fill:#ffe6e6,stroke:#000000,color:#000000
    classDef legacy_module fill:#ff9999,stroke:#000000,color:#000000

    linkStyle default stroke:#999,stroke-width:2px

    class APP app
    class APP_K9,APP_TB app_module
    class COMMON common
    class COMMON_APP common_module
    class FEATURE feature
    class FEATURE1,FEATURE2,FEATURE3 feature_module
    class FEATURE_K9 featureK9
    class FEATURE_TB featureTB
    class CORE core
    class CORE1,CORE2 core_module
    class LIBRARY library
    class LIB1,LIB2 library_module

Module Interaction Patterns

  • App Modules: Depend on the App Common module for shared functionality and selectively integrate feature modules
  • App Common: Integrates various feature modules to provide a cohesive application
  • Feature Modules: Use core modules and libraries for their implementation, may depend on other feature API modules
  • App-Specific Features: Some features are integrated directly by specific apps (K-9 Mail or Thunderbird)

Dependency Rules

These rules must be strictly followed:

  1. One-Way Dependencies:
    • Modules should not depend on each other in a circular manner
    • Dependencies should form a directed acyclic graph (DAG)
  2. API-Implementation Separation:
    • Modules should depend only on API modules, not implementation modules
    • Implementation modules should be referenced only in dependency injection setup
  3. Feature Integration:
    • Features should be integrated through the App Common module, which acts as a central hub
    • Direct dependencies between feature implementations should be avoided, or limited to API modules
  4. Dependency Direction:
    • Dependencies should flow from app modules to common, then to features, and finally to core and libraries
    • Higher-level modules should depend on lower-level modules, not vice versa
  5. Minimal Dependencies:
    • Each module should have the minimal set of dependencies required
    • Avoid unnecessary dependencies that could lead to bloat

Dependency Management

  • Explicit Dependencies: All dependencies should be explicitly declared in the module’s build file
  • Transitive Dependencies: Avoid relying on transitive dependencies
  • Version Management: Use centralized version management for dependencies
  • Dependency Visibility: Use appropriate visibility modifiers to limit access to implementation details

Dependency Injection

  • Use Koin for dependency injection
  • Configure module dependencies in dedicated Koin modules
  • Inject API interfaces, not implementation classes
  • Use lazy injection where appropriate to improve startup performance

📏 Module Granularity

Determining the right granularity for modules is crucial for maintainability and scalability. This section provides guidelines on when to create new modules and how to structure them.

When to Create a New Module

Create a new module when:

  1. Distinct Functionality: The code represents a distinct piece of functionality with clear boundaries
  2. Reusability: The functionality could be reused across multiple features or applications
  3. Build Performance: Breaking down large modules improves build performance
  4. Testing: Isolation improves testability

When to Split a Module

Split an existing module when:

  1. Size: The module has grown too large (>10,000 lines of code as a rough guideline)
  2. Complexity: The module has become too complex with many responsibilities
  3. Dependencies: The module has too many dependencies
  4. Build Time: The module takes too long to build

When to Keep Modules Together

Keep functionality in the same module when:

  1. Cohesion: The functionality is highly cohesive and tightly coupled
  2. Small Size: The functionality is small and simple
  3. Single Responsibility: The functionality represents a single responsibility
Last change: 2025-06-26, commit: 3b4ef25

📦 Feature Modules and Extensions

The Thunderbird for Android project is organized into multiple feature modules, each encapsulating a specific functionality of the application. This document provides an overview of the main feature modules, how they are split into subfeatures, and how the application can be extended with additional features.

📏 Feature Module Best Practices

When developing new feature modules or extending existing ones, follow these best practices:

  1. API-First Design: Define clear public interfaces before implementation
  2. Single Responsibility: Each feature module should have a single, well-defined responsibility
  3. Minimal Dependencies: Minimize dependencies between feature modules
  4. Proper Layering: Follow Clean Architecture principles within each feature
  5. Testability: Design features to be easily testable in isolation
  6. Documentation: Document the purpose and usage of each feature module
  7. Consistent Naming: Follow the established naming conventions
  8. Feature Flags: Use feature flags for gradual rollout and A/B testing
  9. Accessibility: Ensure all features are accessible to all users
  10. Internationalization: Design features with internationalization in mind

By following these guidelines, the Thunderbird for Android application can maintain a clean, modular architecture while expanding its functionality to meet user needs.

📋 Feature Module Overview

The application is composed of several core feature modules, each responsible for a specific aspect of the application’s functionality:

graph TB
    subgraph FEATURE[App Features]
        direction TB
        
        
        subgraph ROW_2[" "]
            direction LR
            SETTINGS["`**Settings**<br>App configuration`"]
            NOTIFICATION["`**Notification**<br>Push and alert handling`"]
            SEARCH["`**Search**<br>Content discovery`"]
            WIDGET["`**Widget**<br>Home screen components`"]
        end
        
        subgraph ROW_1[" "]
            direction LR
            ACCOUNT["`**Account**<br>User accounts management`"]
            MAIL["`**Mail**<br>Email handling and display`"]
            NAVIGATION["`**Navigation**<br>App navigation and UI components`"]
            ONBOARDING["`**Onboarding**<br>User setup and introduction`"]
        end
    end

    classDef row fill: #d9ffd9, stroke: #d9ffd9, color: #d9ffd9
    classDef feature fill: #d9ffd9,stroke: #000000, color: #000000
    classDef feature_module fill: #33cc33, stroke: #000000, color:#000000
    
    class ROW_1,ROW_2 row
    class FEATURE feature
    class ACCOUNT,MAIL,NAVIGATION,ONBOARDING,SETTINGS,NOTIFICATION,SEARCH,WIDGET feature_module

🧩 Feature Module Details

🔑 Account Module

The Account module manages all aspects of email accounts, including setup, configuration, and authentication.

feature:account
├── feature:account:api
├── feature:account:impl
├── feature:account:setup
│   ├── feature:account:setup:api
│   └── feature:account:setup:impl
├── feature:account:settings
│   ├── feature:account:settings:api
│   └── feature:account:settings:impl
├── feature:account:server
│   ├── feature:account:server:api
│   ├── feature:account:server:impl
│   ├── feature:account:server:certificate
│   │   ├── feature:account:server:certificate:api
│   │   └── feature:account:server:certificate:impl
│   ├── feature:account:server:settings
│   │   ├── feature:account:server:settings:api
│   │   └── feature:account:server:settings:impl
│   └── feature:account:server:validation
│       ├── feature:account:server:validation:api
│       └── feature:account:server:validation:impl
├── feature:account:auth
│   ├── feature:account:auth:api
│   ├── feature:account:auth:impl
│   └── feature:account:auth:oauth
│       ├── feature:account:auth:oauth:api
│       └── feature:account:auth:oauth:impl
└── feature:account:storage
    ├── feature:account:storage:api
    ├── feature:account:storage:impl
    └── feature:account:storage:legacy
        ├── feature:account:storage:legacy:api
        └── feature:account:storage:legacy:impl

Subfeatures:

  • API/Implementation: Core public interfaces and implementations for account management
  • Setup: New account setup wizard functionality
    • API: Public interfaces for account setup
    • Implementation: Concrete implementations of setup flows
  • Settings: Account-specific settings management
    • API: Public interfaces for account settings
    • Implementation: Concrete implementations of settings functionality
  • Server: Server configuration and management
    • API/Implementation: Core server management interfaces and implementations
    • Certificate: SSL certificate handling
    • Settings: Server settings configuration
    • Validation: Server connection validation
  • Auth: Authentication functionality
    • API/Implementation: Core authentication interfaces and implementations
    • OAuth: OAuth-specific authentication implementation
  • Storage: Account data persistence
    • API/Implementation: Core storage interfaces and implementations
    • Legacy: Legacy storage implementation

📧 Mail Module

The Mail module handles core email functionality, including message display, composition, and folder management.

feature:mail
├── feature:mail:api
├── feature:mail:impl
├── feature:mail:account
│   ├── feature:mail:account:api
│   └── feature:mail:account:impl
├── feature:mail:folder
│   ├── feature:mail:folder:api
│   └── feature:mail:folder:impl
├── feature:mail:compose
│   ├── feature:mail:compose:api
│   └── feature:mail:compose:impl
└── feature:mail:message
    ├── feature:mail:message:api
    ├── feature:mail:message:impl
    ├── feature:mail:message:view
    │   ├── feature:mail:message:view:api
    │   └── feature:mail:message:view:impl
    └── feature:mail:message:list
        ├── feature:mail:message:list:api
        └── feature:mail:message:list:impl

Subfeatures:

  • API/Implementation: Core public interfaces and implementations for mail functionality
  • Account: Mail-specific account interfaces and implementations
    • API: Public interfaces for mail account integration
    • Implementation: Concrete implementations of mail account functionality
  • Folder: Email folder management
    • API: Public interfaces for folder operations
    • Implementation: Concrete implementations of folder management
  • Compose: Email composition functionality
    • API: Public interfaces for message composition
    • Implementation: Concrete implementations of composition features
  • Message: Message handling and display
    • API/Implementation: Core message handling interfaces and implementations
    • View: Individual message viewing functionality
    • List: Message list display and interaction

🧭 Navigation Module

The Navigation module provides UI components for navigating through the application.

feature:navigation
├── feature:navigation:api
├── feature:navigation:impl
└── feature:navigation:drawer
    ├── feature:navigation:drawer:api
    ├── feature:navigation:drawer:impl
    ├── feature:navigation:drawer:dropdown
    │   ├── feature:navigation:drawer:dropdown:api
    │   └── feature:navigation:drawer:dropdown:impl
    └── feature:navigation:drawer:siderail
        ├── feature:navigation:drawer:siderail:api
        └── feature:navigation:drawer:siderail:impl

Subfeatures:

  • API/Implementation: Core public interfaces and implementations for navigation
  • Drawer: Navigation drawer functionality
    • API/Implementation: Core drawer interfaces and implementations
    • Dropdown: Dropdown-style navigation implementation
    • Siderail: Side rail navigation implementation

🚀 Onboarding Module

The Onboarding module guides new users through the initial setup process.

feature:onboarding
├── feature:onboarding:api
├── feature:onboarding:impl
├── feature:onboarding:main
│   ├── feature:onboarding:main:api
│   └── feature:onboarding:main:impl
├── feature:onboarding:welcome
│   ├── feature:onboarding:welcome:api
│   └── feature:onboarding:welcome:impl
├── feature:onboarding:permissions
│   ├── feature:onboarding:permissions:api
│   └── feature:onboarding:permissions:impl
└── feature:onboarding:migration
    ├── feature:onboarding:migration:api
    ├── feature:onboarding:migration:impl
    ├── feature:onboarding:migration:thunderbird
    │   ├── feature:onboarding:migration:thunderbird:api
    │   └── feature:onboarding:migration:thunderbird:impl
    └── feature:onboarding:migration:noop
        ├── feature:onboarding:migration:noop:api
        └── feature:onboarding:migration:noop:impl

Subfeatures:

  • API/Implementation: Core public interfaces and implementations for onboarding
  • Main: Main onboarding flow
    • API: Public interfaces for the main onboarding process
    • Implementation: Concrete implementations of the onboarding flow
  • Welcome: Welcome screens and initial user experience
    • API: Public interfaces for welcome screens
    • Implementation: Concrete implementations of welcome screens
  • Permissions: Permission request handling
    • API: Public interfaces for permission management
    • Implementation: Concrete implementations of permission requests
  • Migration: Data migration from other apps
    • API/Implementation: Core migration interfaces and implementations
    • Thunderbird: Thunderbird-specific migration implementation
    • Noop: No-operation implementation for testing

⚙️ Settings Module

The Settings module provides interfaces for configuring application behavior.

feature:settings
├── feature:settings:api
├── feature:settings:impl
├── feature:settings:import
│   ├── feature:settings:import:api
│   └── feature:settings:import:impl
└── feature:settings:ui
    ├── feature:settings:ui:api
    └── feature:settings:ui:impl

Subfeatures:

  • API/Implementation: Core public interfaces and implementations for settings
  • Import: Settings import functionality
    • API: Public interfaces for settings import
    • Implementation: Concrete implementations of import functionality
  • UI: Settings user interface components
    • API: Public interfaces for settings UI
    • Implementation: Concrete implementations of settings screens

🔔 Notification Module

The Notification module handles push notifications and alerts for new emails and events.

feature:notification
├── feature:notification:api
├── feature:notification:impl
├── feature:notification:email
│   ├── feature:notification:email:api
│   └── feature:notification:email:impl
└── feature:notification:push
    ├── feature:notification:push:api
    └── feature:notification:push:impl

Subfeatures:

  • API/Implementation: Core public interfaces and implementations for notifications
  • Email: Email-specific notification handling
    • API: Public interfaces for email notifications
    • Implementation: Concrete implementations of email alerts
  • Push: Push notification handling
    • API: Public interfaces for push notifications
    • Implementation: Concrete implementations of push notification processing

🔍 Search Module

The Search module provides functionality for searching through emails and contacts.

feature:search
├── feature:search:api
├── feature:search:impl
├── feature:search:email
│   ├── feature:search:email:api
│   └── feature:search:email:impl
├── feature:search:contact
│   ├── feature:search:contact:api
│   └── feature:search:contact:impl
└── feature:search:ui
    ├── feature:search:ui:api
    └── feature:search:ui:impl

Subfeatures:

  • API/Implementation: Core public interfaces and implementations for search functionality
  • Email: Email-specific search capabilities
    • API: Public interfaces for email search
    • Implementation: Concrete implementations of email search
  • Contact: Contact search functionality
    • API: Public interfaces for contact search
    • Implementation: Concrete implementations of contact search
  • UI: Search user interface components
    • API: Public interfaces for search UI
    • Implementation: Concrete implementations of search screens

📱 Widget Module

The Widget module provides home screen widgets for quick access to email functionality.

feature:widget
├── feature:widget:api
├── feature:widget:impl
├── feature:widget:message-list
│   ├── feature:widget:message-list:api
│   └── feature:widget:message-list:impl
├── feature:widget:message-list-glance
│   ├── feature:widget:message-list-glance:api
│   └── feature:widget:message-list-glance:impl
├── feature:widget:shortcut
│   ├── feature:widget:shortcut:api
│   └── feature:widget:shortcut:impl
└── feature:widget:unread
    ├── feature:widget:unread:api
    └── feature:widget:unread:impl

Subfeatures:

  • API/Implementation: Core public interfaces and implementations for widgets
  • Message List: Email list widget
    • API: Public interfaces for message list widget
    • Implementation: Concrete implementations of message list widget
  • Message List Glance: Glanceable message widget
    • API: Public interfaces for glance widget
    • Implementation: Concrete implementations of glance widget
  • Shortcut: App shortcut widgets
    • API: Public interfaces for shortcut widgets
    • Implementation: Concrete implementations of shortcut widgets
  • Unread: Unread message counter widget
    • API: Public interfaces for unread counter widget
    • Implementation: Concrete implementations of unread counter widget

🔄 Supporting Feature Modules

In addition to the core email functionality, the application includes several supporting feature modules:

🔎 Autodiscovery Module

The Autodiscovery module automatically detects email server settings.

Subfeatures:

  • API (feature:autodiscovery:api): Public interfaces
  • Autoconfig (feature:autodiscovery:autoconfig): Automatic configuration
  • Service (feature:autodiscovery:service): Service implementation
  • Demo (feature:autodiscovery:demo): Demonstration implementation

💰 Funding Module

The Funding module handles in-app financial contributions and funding options.

Subfeatures:

  • API (feature:funding:api): Public interfaces
  • Google Play (feature:funding:googleplay): Google Play billing integration
  • Link (feature:funding:link): External funding link handling
  • Noop (feature:funding:noop): No-operation implementation

🔄 Migration Module

The Migration module handles data migration between different email clients.

Subfeatures:

  • Provider (feature:migration:provider): Migration data providers
  • QR Code (feature:migration:qrcode): QR code-based migration
  • Launcher (feature:migration:launcher): Migration launcher
    • API (feature:migration:launcher:api): Launcher interfaces
    • Noop (feature:migration:launcher:noop): No-operation implementation
    • Thunderbird (feature:migration:launcher:thunderbird): Thunderbird-specific implementation

📊 Telemetry Module

The Telemetry module handles usage analytics and reporting.

Subfeatures:

  • API (feature:telemetry:api): Public interfaces
  • Noop (feature:telemetry:noop): No-operation implementation
  • Glean (feature:telemetry:glean): Mozilla Glean integration

🔌 Extending with Additional Features

The modular architecture of Thunderbird for Android allows for easy extension with additional features. To give you an idea how the app could be extended when building a new feature, here are some theoretical examples along with their structure:

📅 Calendar Feature

A Calendar feature could be added to integrate calendar functionality with email.

feature:calendar
├── feature:calendar:api
├── feature:calendar:impl
├── feature:calendar:event
│   ├── feature:calendar:event:api
│   └── feature:calendar:event:impl
└── feature:calendar:sync
    ├── feature:calendar:sync:api
    └── feature:calendar:sync:impl

🗓️ Appointments Feature

An Appointments feature could manage meetings and appointments.

feature:appointment
├── feature:appointment:api
├── feature:appointment:impl
├── feature:appointment:scheduler
│   ├── feature:appointment:scheduler:api
│   └── feature:appointment:scheduler:impl
└── feature:appointment:notification
    ├── feature:appointment:notification:api
    └── feature:appointment:notification:impl

🔗 Feature Relationships

Features in the application interact with each other through well-defined APIs. The diagram below illustrates the relationships between different features:

graph TB
    subgraph CORE[Core Features]
        ACCOUNT[Account]
        MAIL[Mail]
    end

    subgraph EXTENSIONS[Potential Extensions]
        CALENDAR[Calendar]
        APPOINTMENT[Appointments]
    end

    MAIL --> |uses| ACCOUNT

    CALENDAR --> |integrates with| MAIL
    CALENDAR --> |uses| ACCOUNT
    APPOINTMENT --> |uses| ACCOUNT
    APPOINTMENT --> |integrates with| CALENDAR
    APPOINTMENT --> |uses| MAIL

    linkStyle default stroke:#999,stroke-width:2px

    classDef core fill:#e8c8ff,stroke:#000000,color:#000000
    classDef core_module fill:#c090ff,stroke:#000000,color:#000000
    classDef extension fill:#d0e0ff,stroke:#000000,color:#000000
    classDef extension_module fill:#8090ff,stroke:#000000,color:#000000
    class CORE core
    class ACCOUNT,MAIL,NAVIGATION,SETTINGS core_module
    class EXTENSIONS extension
    class CALENDAR,TODO,SYNC,NOTES,APPOINTMENT extension_module
Last change: 2025-06-26, commit: 3b4ef25

🎨 UI Architecture

The UI is built using Jetpack Compose with a component-based architecture following a modified Model-View-Intent (MVI) pattern. While we refer to it as MVI, our implementation uses “Events” instead of “Intents” for user interactions and “Actions” for use case calls. This architecture provides a unidirectional data flow, clear separation of concerns, and improved testability.

📱 Component Hierarchy

The UI components are organized in a hierarchical structure:

graph TD
    subgraph UI_ARCHITECTURE["UI Architecture"]
        SCREENS[Screens]
        COMPONENTS[Components]
        DESIGN[Design System Components]
        THEME[Theme]
    end

    SCREENS --> COMPONENTS
    COMPONENTS --> DESIGN
    DESIGN --> THEME

    classDef ui_layer fill:#d9e9ff,stroke:#000000,color:#000000
    classDef screen fill:#99ccff,stroke:#000000,color:#000000
    classDef component fill:#99ff99,stroke:#000000,color:#000000
    classDef design fill:#ffcc99,stroke:#000000,color:#000000
    classDef theme fill:#ffff99,stroke:#000000,color:#000000

    linkStyle default stroke:#999,stroke-width:2px

    class UI_ARCHITECTURE ui_layer
    class SCREENS screen
    class COMPONENTS component
    class DESIGN design
    class THEME theme

🖥️ Screens

  • Top-level composables that represent a full screen in the application
  • Typically associated with a specific route in the navigation graph
  • Responsible for orchestrating components and managing screen-level state
  • Connected to ViewModels that handle interaction logic and state management

Example:

@Composable
fun AccountSettingsScreen(
    viewModel: AccountSettingsViewModel = koinViewModel(),
    onNavigateNext: () -> Unit,
    onNavigateBack: () -> Unit,
) {
    val (state, dispatch) = viewModel.observe { effect ->
        when (effect) {
            AccountSettingsEffect.NavigateNext -> onNavigateNext()
            AccountSettingsEffect.NavigateBack -> onNavigateBack()
        }
    }

    AccountSettingsContent(
        state = state.value,
        onEvent = dispatch,
    )
}

🧩 Components

  • Reusable UI elements that encapsulate specific functionality
  • Can be composed of multiple smaller components
  • Follow a clear input-output model with immutable state passed in and events emitted out
  • Designed to be reusable across different screens

Example:

@Composable
fun AccountSettingsContent(
    state: AccountSettingsState,
    onEvent: (AccountSettingsEvent) -> Unit,
) {
    Scaffold(
        topBar = {
            TopAppBar(
                title = stringResource(R.string.account_settings_title),
                onNavigateBack = { onEvent(AccountSettingsEvent.BackClicked) },
            )
        },
    ) {
        when {
            state.isLoading -> LoadingIndicator()
            state.error != null -> ErrorView(
                message = state.error,
                onRetryClicked = { onEvent(AccountSettingsEvent.RetryClicked) }
            )
            state.settings != null -> AccountSettingsForm(
                settings = state.settings,
                onSettingChanged = { setting, value -> 
                    onEvent(AccountSettingsEvent.SettingChanged(setting, value))
                },
                onSaveClicked = { onEvent(AccountSettingsEvent.SaveClicked) }
            )
        }
    }
}

🎨 Design System Components

  • Foundational UI elements that implement the design system
  • Consistent visual language across the application
  • Encapsulate styling, theming, and behavior from Material Design 3
  • Located in the core:ui:compose:designsystem module for reuse across features
  • Built using the Atomic Design Methodology

Example:

@Composable
fun PrimaryButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    buttonStyle: ButtonStyle = ButtonStyle.Primary,
) {
    Button(
        onClick = onClick,
        modifier = modifier,
        enabled = enabled,
        colors = buttonStyle.colors(),
        shape = MaterialTheme.shapes.medium,
    ) {
        Text(text = text)
    }
}

🎭 Theme

  • Defines colors, typography, shapes, and other design tokens
  • Supports light and dark modes
  • Provides consistent visual appearance across the application
  • Implemented using Material Design 3 theming system
  • Located in the core:ui:compose:theme2 module for reuse across features
  • Provides a ThunderbirdTheme2 and a K9MailTheme2 composable that wraps the MaterialTheme with custom color schemes, typography, and shapes
  • Uses Jetpack Compose’s CompositionLocalProvider as a theme provider to make theme components available throughout the app

For a more detailed explanation of the theming system, including the theme provider implementation, see Theme System.

📊 Unidirectional Data Flow

The UI architecture follows a unidirectional data flow pattern, which is a fundamental concept that ensures data moves in a single, well-defined direction throughout the application. This architectural approach creates a predictable and maintainable system by enforcing a strict flow of information.

🔄 What is Unidirectional Data Flow?

Unidirectional data flow is a design pattern where:

  1. Data travels in one direction only
  2. State changes are predictable and traceable
  3. Components have clear, single responsibilities
  4. The UI is a pure function of the application state

In our implementation, the flow follows this cycle:

  1. User Interaction: The user interacts with the UI (e.g., clicks a button)
  2. Event Dispatch: The UI captures this interaction as an Event and dispatches it to the ViewModel
  3. Event Processing: The ViewModel processes the Event and determines what Action to take
  4. Action Execution: The ViewModel executes an Action, typically by calling a Use Case
  5. Domain Logic: The Use Case performs business logic, often involving repositories
  6. Result Return: The Use Case returns a Result to the ViewModel
  7. State Update: The ViewModel updates the State based on the Result
  8. UI Rendering: The UI observes the State change and re-renders accordingly
  9. Effect Handling: For one-time actions like navigation, the ViewModel emits an Effect that the UI handles

This cycle ensures that data flows in a single direction: UI → ViewModel → Domain → ViewModel → UI.

flowchart LR
    User([User]) --> |Interaction| UI
    UI --> |Event| ViewModel
    ViewModel --> |Action| Domain
    Domain --> |Result| ViewModel
    ViewModel --> |State| UI
    ViewModel --> |Effect| UI
    UI --> |Render| User

🌟 Benefits of Unidirectional Data Flow

Unidirectional data flow provides numerous advantages over bidirectional or unstructured data flow patterns:

  1. Predictability: Since data flows in only one direction, the system behavior becomes more predictable and easier to reason about.

  2. Debugging: Tracing issues becomes simpler because you can follow the data flow from source to destination without worrying about circular dependencies.

  3. State Management: With a single source of truth (the ViewModel’s state), there’s no risk of inconsistent state across different parts of the application.

  4. Testability: Each component in the flow can be tested in isolation with clear inputs and expected outputs.

  5. Separation of Concerns: Each component has a well-defined responsibility:

    • UI: Render state and capture user interactions
    • ViewModel: Process events, update state, and emit effects
    • Domain: Execute business logic
  6. Scalability: The pattern scales well as the application grows because new features can follow the same consistent pattern.

  7. Maintainability: Code is easier to maintain because changes in one part of the flow don’t unexpectedly affect other parts.

  8. Concurrency: Reduces race conditions and timing issues since state updates happen in a controlled, sequential manner.

We leverage unidirectional data flow in our MVI implementation to ensure that the UI remains responsive, predictable, and easy to test.

🔄 Model-View-Intent (MVI)

The UI layer follows the Model-View-Intent (MVI) pattern (with our Events/Effects/Actions adaptation as noted above), which provides a unidirectional data flow and clear separation between UI state and UI logic.

graph LR
    subgraph UI[UI Layer]
        VIEW[View]
        VIEW_MODEL[ViewModel]
    end

    subgraph DOMAIN[Domain Layer]
        USE_CASE[Use Cases]
    end

    VIEW --> |Events| VIEW_MODEL
    VIEW_MODEL --> |State| VIEW
    VIEW_MODEL --> |Effects| VIEW
    VIEW_MODEL --> |Actions| USE_CASE
    USE_CASE --> |Results| VIEW_MODEL

    classDef ui_layer fill:#d9e9ff,stroke:#000000,color:#000000
    classDef view fill:#7fd3e0,stroke:#000000,color:#000000
    classDef view_model fill:#cc99ff,stroke:#000000,color:#000000
    classDef domain_layer fill:#d9ffd9,stroke:#000000,color:#000000
    classDef use_case fill:#99ffcc,stroke:#000000,color:#000000

    linkStyle default stroke:#999,stroke-width:2px

    class UI ui_layer
    class VIEW view
    class VIEW_MODEL view_model
    class DOMAIN domain_layer
    class USE_CASE use_case

Key components:

  • 👁️ View: Renders the UI based on the current state and sends user events to the ViewModel
  • 🧠 ViewModel: Processes user events, converting them into actions and sending them to the Domain Layer. It also maps the results to a state and sends state updates to the UI.
  • 🧪 Use Cases: Encapsulate business logic and interact with repositories to perform data operations. They return results to the ViewModel, which updates the state.

Unidirectional Data flow:

  • 📊 State: Immutable representation of the UI state. States are the single source of truth for the UI and represent everything that can be displayed on the screen.
  • 🎮 Events: User interactions or system events that are passed to the ViewModel to be processed. Events trigger state changes or side effects.
  • 🔔 Effects: One-time side effects that don’t belong in the state, such as navigation actions, showing toasts, etc.
  • Actions: Operations triggered by the ViewModel to interact with the domain layer.
  • 📊 Results: Responses from the domain layer that are processed by the ViewModel to update the state.

🧩 Components

The MVI architecture is implemented using the following components:

👁️ View

  • Represents the UI layer in the MVI pattern
  • Composed of Jetpack Compose components (Screens, Components, etc.)
  • Responsible for rendering the UI state and capturing user interactions
  • Sends events to the ViewModel and receives state updates
  • Purely presentational with no business logic

In our architecture, the View is implemented using Jetpack Compose and consists of:

  1. Screen Composables: Top-level composables that represent a full screen
  2. Content Composables: Composables that render the UI based on the state
  3. Component Composables: Reusable UI elements

Example of a View implementation:

// Screen Composable (part of the View)
@Composable
internal fun AccountSettingsScreen(
    onNavigateNext: () -> Unit,
    onNavigateBack: () -> Unit,
    viewModel: AccountSettingsViewModel = koinViewModel(),
) {
    // Observe state and handle effects
    val (state, dispatch) = viewModel.observe { effect ->
        when (effect) {
            AccountSettingsEffect.NavigateNext -> onNavigateNext()
            AccountSettingsEffect.NavigateBack -> onNavigateBack()
        }
    }

    // Content Composable (also part of the View)
    AccountSettingsContent(
        state = state.value,
        onEvent = dispatch,
    )
}

// Content Composable (part of the View)
@Composable
private fun AccountSettingsContent(
    state: AccountSettingsState,
    onEvent: (AccountSettingsEvent) -> Unit,
) {
    // Render UI based on state
    when {
        state.isLoading -> LoadingIndicator()
        state.error != null -> ErrorView(
            message = state.error,
            onRetryClicked = { onEvent(AccountSettingsEvent.RetryClicked) }
        )
        state.settings != null -> AccountSettingsForm(
            settings = state.settings,
            onSettingChanged = { setting, value -> 
                onEvent(AccountSettingsEvent.SettingChanged(setting, value))
            },
            onSaveClicked = { onEvent(AccountSettingsEvent.SaveClicked) }
        )
    }
}

The View is responsible for:

  • Rendering the UI based on the current state
  • Capturing user interactions and converting them to events
  • Sending events to the ViewModel
  • Handling side effects (like navigation)
  • Maintaining a clear separation from business logic

🧠 ViewModel

  • Acts as the mediator between the View and the Domain layer
  • Processes events from the View and updates state
  • Coordinates with use cases for business logic
  • Exposes state as a StateFlow for the View to observe
  • Emits side effects for one-time actions like navigation

The ViewModel is implemented using the BaseViewModel class, which provides the core functionality for the MVI pattern:

abstract class BaseViewModel<STATE, EVENT, EFFECT>(
    initialState: STATE,
) : ViewModel(),
    UnidirectionalViewModel<STATE, EVENT, EFFECT> {

    private val _state = MutableStateFlow(initialState)
    override val state: StateFlow<STATE> = _state.asStateFlow()

    private val _effect = MutableSharedFlow<EFFECT>()
    override val effect: SharedFlow<EFFECT> = _effect.asSharedFlow()

    /**
     * Updates the [STATE] of the ViewModel.
     */
    protected fun updateState(update: (STATE) -> STATE) {
        _state.update(update)
    }

    /**
     * Emits a side effect.
     */
    protected fun emitEffect(effect: EFFECT) {
        viewModelScope.launch {
            _effect.emit(effect)
        }
    }
}

Example of a ViewModel implementation:

class AccountViewModel(
    private val getAccount: GetAccount,
    private val updateAccount: UpdateAccount,
) : BaseViewModel<AccountState, AccountEvent, AccountEffect>(
    initialState = AccountState()
) {
    // Handle events from the UI
    override fun event(event: AccountEvent) {
        when (event) {
            is AccountEvent.LoadAccount -> loadAccount(event.accountId)
            is AccountEvent.UpdateAccount -> saveAccount(event.account)
            is AccountEvent.BackClicked -> emitEffect(AccountEffect.NavigateBack)
        }
    }

    // Load account data
    private fun loadAccount(accountId: String) {
        viewModelScope.launch {
            // Update state to show loading
            updateState { it.copy(isLoading = true) }

            // Call use case to get account
            val account = getAccount(accountId)

            // Update state with account data
            updateState { 
                it.copy(
                    isLoading = false,
                    account = account
                )
            }
        }
    }

    // Save account changes
    private fun saveAccount(account: Account) {
        viewModelScope.launch {
            // Update state to show loading
            updateState { it.copy(isLoading = true) }

            // Call use case to update account
            val result = updateAccount(account)

            // Handle result
            if (result.isSuccess) {
                updateState { it.copy(isLoading = false) }
                emitEffect(AccountEffect.NavigateBack)
            } else {
                updateState { 
                    it.copy(
                        isLoading = false,
                        error = "Failed to save account"
                    )
                }
            }
        }
    }
}

🧪 Use Cases

  • Encapsulate business logic in the domain layer
  • Follow the single responsibility principle
  • Independent of UI and framework concerns
  • Can be easily tested in isolation
  • Invoked by ViewModels through Actions
  • Implemented using the operator fun invoke pattern for cleaner, more concise code

Use Cases represent the business logic of the application and are part of the domain layer. They encapsulate specific operations that the application can perform, such as creating an account, fetching data, or updating settings. Use cases should be implemented using the operator fun invoke pattern, which allows them to be called like functions.

note

Use Cases are only required when there needs to be business logic (such as validation, transformation, or complex operations). For simple CRUD operations or direct data access with no additional logic, ViewModels can use repositories directly. This approach reduces unnecessary abstraction layers while still maintaining clean architecture principles.

Example of a Use Case:

// Use Case interface using operator fun invoke pattern
fun interface CreateAccount {
    suspend operator fun invoke(accountState: AccountState): AccountCreatorResult
}

// Use Case implementation
class CreateAccountImpl(
    private val accountCreator: AccountCreator,
    private val accountValidator: AccountValidator,
) : CreateAccount {

    override suspend operator fun invoke(accountState: AccountState): AccountCreatorResult {
        // Validate account data
        val validationResult = accountValidator.validate(accountState)
        if (validationResult is ValidationResult.Failure) {
            return AccountCreatorResult.Error.Validation(validationResult.errors)
        }

        // Create account
        return try {
            val accountUuid = accountCreator.createAccount(accountState)
            AccountCreatorResult.Success(accountUuid)
        } catch (e: Exception) {
            AccountCreatorResult.Error.Creation(e.message ?: "Unknown error")
        }
    }
}

Use Cases are typically:

  • Injected into ViewModels
  • Invoked in response to user events
  • Responsible for orchestrating repositories and other domain services
  • Returning results that the ViewModel can use to update the UI state

The separation of Use Cases from ViewModels allows for:

  • Better testability of business logic
  • Reuse of business logic across different features
  • Clear separation of concerns
  • Easier maintenance and evolution of the codebase

Data Flow Components

📊 State

  • Immutable data classes representing the UI state
  • Single source of truth for the UI
  • Exposed as a StateFlow from the ViewModel
  • Rendered by Compose UI components

Example: State in Action

Here’s a complete example showing how state is defined, updated, and consumed:

// 1. Define the state
data class AccountSettingsState(
    val isLoading: Boolean = false,
    val settings: AccountSettings? = null,
    val error: String? = null,
)

// 2. Update state in ViewModel
class AccountSettingsViewModel(
    private val getSettings: GetAccountSettings,
) : BaseViewModel<AccountSettingsState, AccountSettingsEvent, AccountSettingsEffect>(
    initialState = AccountSettingsState(isLoading = true)
) {
    init {
        loadSettings()
    }

    private fun loadSettings() {
        viewModelScope.launch {
            try {
                val settings = getSettings()
                // Update state with loaded settings
                updateState { it.copy(isLoading = false, settings = settings, error = null) }
            } catch (e: Exception) {
                // Update state with error
                updateState { it.copy(isLoading = false, settings = null, error = e.message) }
            }
        }
    }

    override fun event(event: AccountSettingsEvent) {
        when (event) {
            is AccountSettingsEvent.RetryClicked -> {
                // Update state to show loading and retry
                updateState { it.copy(isLoading = true, error = null) }
                loadSettings()
            }
            // Handle other events...
        }
    }
}

// 3. Consume state in UI
@Composable
fun AccountSettingsContent(
    state: AccountSettingsState,
    onEvent: (AccountSettingsEvent) -> Unit,
) {
    when {
        state.isLoading -> {
            // Show loading UI
            CircularProgressIndicator(
                modifier = Modifier.align(Alignment.Center)
            )
        }
        state.error != null -> {
            // Show error UI
            ErrorView(
                message = state.error,
                onRetryClicked = { onEvent(AccountSettingsEvent.RetryClicked) }
            )
        }
        state.settings != null -> {
            // Show settings form
            AccountSettingsForm(
                settings = state.settings,
                onSettingChanged = { setting, value -> 
                    onEvent(AccountSettingsEvent.SettingChanged(setting, value))
                }
            )
        }
    }
}

🎮 Events

  • Represent user interactions or system events
  • Passed from the UI to the ViewModel
  • Trigger state updates or side effects

Example: Events in Action

Here’s a complete example showing how events are defined, dispatched, and handled:

// 1. Define events
sealed interface AccountSettingsEvent {
    data class SettingChanged(val setting: Setting, val value: Any) : AccountSettingsEvent
    data object SaveClicked : AccountSettingsEvent
    data object RetryClicked : AccountSettingsEvent
    data object BackClicked : AccountSettingsEvent
}

// 2. Handle events in ViewModel
class AccountSettingsViewModel(
    private val saveSettings: SaveAccountSettings,
) : BaseViewModel<AccountSettingsState, AccountSettingsEvent, AccountSettingsEffect>(
    initialState = AccountSettingsState()
) {
    override fun event(event: AccountSettingsEvent) {
        when (event) {
            is AccountSettingsEvent.SettingChanged -> {
                // Update state with new setting value
                updateState { state ->
                    val updatedSettings = state.settings?.copy() ?: return@updateState state
                    updatedSettings.updateSetting(event.setting, event.value)
                    state.copy(settings = updatedSettings)
                }
            }
            is AccountSettingsEvent.SaveClicked -> saveAccountSettings()
            is AccountSettingsEvent.RetryClicked -> loadSettings()
            is AccountSettingsEvent.BackClicked -> 
                emitEffect(AccountSettingsEffect.NavigateBack)
        }
    }

    private fun saveAccountSettings() {
        viewModelScope.launch {
            updateState { it.copy(isLoading = true) }

            val result = saveSettings(state.value.settings!!)

            if (result.isSuccess) {
                emitEffect(AccountSettingsEffect.ShowMessage("Settings saved"))
                emitEffect(AccountSettingsEffect.NavigateBack)
            } else {
                updateState { it.copy(
                    isLoading = false,
                    error = "Failed to save settings"
                )}
            }
        }
    }

    // Other methods...
}

// 3. Dispatch events from UI
@Composable
fun AccountSettingsContent(
    state: AccountSettingsState,
    onEvent: (AccountSettingsEvent) -> Unit,
) {
    Column(modifier = Modifier.padding(16.dp)) {
        if (state.settings != null) {
            // Setting fields
            for (setting in state.settings.items) {
                SettingItem(
                    setting = setting,
                    onValueChanged = { newValue ->
                        // Dispatch SettingChanged event
                        onEvent(AccountSettingsEvent.SettingChanged(setting, newValue))
                    }
                )
            }

            // Save button
            Button(
                onClick = { 
                    // Dispatch SaveClicked event
                    onEvent(AccountSettingsEvent.SaveClicked) 
                },
                modifier = Modifier.align(Alignment.End)
            ) {
                Text("Save")
            }
        }

        // Back button
        TextButton(
            onClick = { 
                // Dispatch BackClicked event
                onEvent(AccountSettingsEvent.BackClicked) 
            }
        ) {
            Text("Back")
        }
    }
}

🔔 Effects

  • Represent one-time side effects that don’t belong in the state
  • Emitted by the ViewModel to trigger navigation, show messages, or perform other one-time actions
  • Handled by the UI layer (Screen composables) to execute the appropriate action
  • Implemented using Kotlin’s SharedFlow for asynchronous, non-blocking delivery

Effects are essential for handling actions that should happen only once and shouldn’t be part of the UI state. Common use cases for effects include:

  • Navigation (e.g., navigating to another screen)
  • Showing transient UI elements (e.g., snackbars, toasts)
  • Playing sounds or haptic feedback
  • Triggering system actions (e.g., sharing content, opening URLs)

Example: Effects in Action

Here’s a simplified example showing how effects are defined, emitted, and handled:

// 1. Define effects
sealed interface AccountSettingsEffect {
    data object NavigateBack : AccountSettingsEffect
    data class ShowMessage(val message: String) : AccountSettingsEffect
}

// 2. Emit effects from ViewModel
class AccountSettingsViewModel : BaseViewModel<AccountSettingsState, AccountSettingsEvent, AccountSettingsEffect>(
    initialState = AccountSettingsState()
) {
    override fun event(event: AccountSettingsEvent) {
        when (event) {
            is AccountSettingsEvent.SaveClicked -> {
                // Save settings and show success message
                emitEffect(AccountSettingsEffect.ShowMessage("Settings saved"))
                emitEffect(AccountSettingsEffect.NavigateBack)
            }
            is AccountSettingsEvent.BackClicked -> 
                emitEffect(AccountSettingsEffect.NavigateBack)
        }
    }
}

// 3. Handle effects in UI
@Composable
fun AccountSettingsScreen(
    onNavigateBack: () -> Unit,
    viewModel: AccountSettingsViewModel = koinViewModel(),
) {
    val snackbarHostState = remember { SnackbarHostState() }

    val (state, dispatch) = viewModel.observe { effect ->
        when (effect) {
            AccountSettingsEffect.NavigateBack -> onNavigateBack()
            is AccountSettingsEffect.ShowMessage -> {
                CoroutineScope(Dispatchers.Main).launch {
                    snackbarHostState.showSnackbar(effect.message)
                }
            }
        }
    }

    // Screen content with snackbar host...
}

⚡ Actions

  • Represent calls to domain layer use cases
  • Triggered by the ViewModel in response to events
  • Bridge between UI and domain layers
  • Execute business logic and return results to the ViewModel

Example:

// In a domain layer repository interface
interface AccountRepository {
    suspend fun getAccount(accountId: String): Account
    suspend fun updateAccount(account: Account): Result<Unit>
    suspend fun deleteAccount(accountId: String): Result<Unit>
}

// Use case with operator fun invoke pattern (recommended approach)
// In a domain layer use case interface
fun interface UpdateAccount {
    suspend operator fun invoke(account: Account): Result<Unit>
}

// Use case implementation
class UpdateAccountImpl(
    private val accountRepository: AccountRepository
) : UpdateAccount {
    override suspend operator fun invoke(account: Account): Result<Unit> {
        return accountRepository.updateAccount(account)
    }
}

// In the ViewModel
class AccountSettingsViewModel(
    private val updateAccount: UpdateAccount,
) : BaseViewModel<AccountSettingsState, AccountSettingsEvent, AccountSettingsEffect>(
    initialState = AccountSettingsState()
) {
    // Event handler
    override fun event(event: AccountSettingsEvent) {
        when (event) {
            is AccountSettingsEvent.SaveClicked -> saveAccount() // Triggers an action
        }
    }

    // Action
    private fun saveAccount() {
        viewModelScope.launch {
            updateState { it.copy(isLoading = true) }

            // Call to domain layer use case (the action) using invoke operator
            val result = updateAccount(currentAccount)

            when (result) {
                is Result.Success -> {
                    updateState { it.copy(isLoading = false) }
                    emitEffect(AccountSettingsEffect.NavigateBack)
                }
                is Result.Error -> {
                    updateState { 
                        it.copy(
                            isLoading = false,
                            error = result.message
                        )
                    }
                }
            }
        }
    }
}

📊 Results (Outcomes)

  • Represent the outcome of actions executed by use cases
  • Can be success or error
  • Used by the ViewModel to update the state or emit effects

Example:

// Result types for account creation
sealed interface AccountCreatorResult {
    data class Success(val accountUuid: String) : AccountCreatorResult

    sealed interface Error : AccountCreatorResult {
        data class Validation(val errors: List<ValidationError>) : Error
        data class Creation(val message: String) : Error
        data class Network(val exception: NetworkException) : Error
    }
}

// In ViewModel
private fun handleResult(result: AccountCreatorResult) {
    when (result) {
        is AccountCreatorResult.Success -> {
            // Update state with success
            updateState { it.copy(isLoading = false, error = null) }
            // Emit navigation effect
            emitEffect(Effect.NavigateNext(AccountUuid(result.accountUuid)))
        }
        is AccountCreatorResult.Error -> {
            // Update state with error
            updateState { it.copy(isLoading = false, error = result) }
            // Optionally emit effect for error handling
            when (result) {
                is AccountCreatorResult.Error.Network -> 
                    emitEffect(Effect.ShowNetworkError(result.exception))
                else -> { /* Handle other errors */ }
            }
        }
    }
}

🧭 Navigation

The application uses the Jetpack Navigation Compose library for navigation between screens:

  • 📱 Navigation Graph: Defines the screens and their relationships
  • 🔗 Navigation Arguments: Type-safe arguments passed between destinations
  • 🔙 Back Stack Management: Handles the navigation back stack
  • ↩️ Deep Linking: Supports deep linking to specific screens

To set up navigation in the app, you need to:

  1. Define route constants
  2. Create a NavHost with composable destinations
  3. Handle navigation callbacks in screens
  4. Use ViewModels to emit navigation effects

Example:

// Define route constants
private const val ROUTE_HOME = "home"
private const val ROUTE_SETTINGS = "settings"
private const val ROUTE_DETAILS = "details/{itemId}"

@Composable
fun AppNavHost(
    onFinish: () -> Unit,
) {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = ROUTE_HOME,
    ) {
        composable(route = ROUTE_HOME) {
            HomeScreen(
                onNavigateToSettings = { navController.navigate(ROUTE_SETTINGS) },
                onNavigateToDetails = { itemId -> 
                    navController.navigate("details/$itemId") 
                },
                viewModel = koinViewModel(),
            )
        }

        composable(route = ROUTE_SETTINGS) {
            SettingsScreen(
                onBack = { navController.popBackStack() },
                onFinish = onFinish,
                viewModel = koinViewModel(),
            )
        }

        composable(
            route = ROUTE_DETAILS,
            arguments = listOf(
                navArgument("itemId") { type = NavType.StringType }
            )
        ) { backStackEntry ->
            val itemId = backStackEntry.arguments?.getString("itemId") ?: ""
            DetailsScreen(
                itemId = itemId,
                onBack = { navController.popBackStack() },
                viewModel = koinViewModel(),
            )
        }
    }
}

In your screen composables, you handle navigation by observing effects from the ViewModel:

@Composable
fun HomeScreen(
    onNavigateToSettings: () -> Unit,
    onNavigateToDetails: (String) -> Unit,
    viewModel: HomeViewModel,
) {
    val (state, dispatch) = viewModel.observe { effect ->
        when (effect) {
            is HomeEffect.NavigateToSettings -> onNavigateToSettings()
            is HomeEffect.NavigateToDetails -> onNavigateToDetails(effect.itemId)
        }
    }

    // Screen content
}

In your ViewModels, you emit navigation effects:

class HomeViewModel : BaseViewModel<HomeState, HomeEvent, HomeEffect>(
    initialState = HomeState()
) {
    override fun event(event: HomeEvent) {
        when (event) {
            is HomeEvent.SettingsClicked -> emitEffect(HomeEffect.NavigateToSettings)
            is HomeEvent.ItemClicked -> emitEffect(HomeEffect.NavigateToDetails(event.itemId))
        }
    }
}

🔄 Complete End-to-End Example

Here’s a complete example of how all the components work together in a real-world scenario, using the CreateAccount feature:

1. Define the Contract

First, define the contract that specifies the State, Events, and Effects:

interface CreateAccountContract {

    interface ViewModel : UnidirectionalViewModel<State, Event, Effect>

    data class State(
        override val isLoading: Boolean = true,
        override val error: Error? = null,
    ) : LoadingErrorState<Error>

    sealed interface Event {
        data object CreateAccount : Event
        data object OnBackClicked : Event
    }

    sealed interface Effect {
        data class NavigateNext(val accountUuid: AccountUuid) : Effect
        data object NavigateBack : Effect
    }
}

2. Implement the ViewModel

Next, implement the ViewModel that handles events, updates state, and emits effects:

class CreateAccountViewModel(
    private val createAccount: CreateAccount,
    private val accountStateRepository: AccountStateRepository,
    initialState: State = State(),
) : BaseViewModel<State, Event, Effect>(initialState),
    CreateAccountContract.ViewModel {

    override fun event(event: Event) {
        when (event) {
            Event.CreateAccount -> createAccount()
            Event.OnBackClicked -> maybeNavigateBack()
        }
    }

    private fun createAccount() {
        val accountState = accountStateRepository.getState()

        viewModelScope.launch {
            updateState { it.copy(isLoading = true, error = null) }

            when (val result = createAccount(accountState)) {
                is AccountCreatorResult.Success -> showSuccess(AccountUuid(result.accountUuid))
                is AccountCreatorResult.Error -> showError(result)
            }
        }
    }

    private fun showSuccess(accountUuid: AccountUuid) {
        updateState {
            it.copy(
                isLoading = false,
                error = null,
            )
        }

        viewModelScope.launch {
            delay(WizardConstants.CONTINUE_NEXT_DELAY)
            navigateNext(accountUuid)
        }
    }

    private fun showError(error: AccountCreatorResult.Error) {
        updateState {
            it.copy(
                isLoading = false,
                error = error,
            )
        }
    }

    private fun maybeNavigateBack() {
        if (!state.value.isLoading) {
            navigateBack()
        }
    }

    private fun navigateBack() {
        viewModelScope.coroutineContext.cancelChildren()
        emitEffect(Effect.NavigateBack)
    }

    private fun navigateNext(accountUuid: AccountUuid) {
        viewModelScope.coroutineContext.cancelChildren()
        emitEffect(Effect.NavigateNext(accountUuid))
    }
}

3. Create the Screen Composable

Then, create the screen composable that observes the ViewModel and handles effects:

@Composable
internal fun CreateAccountScreen(
    onNext: (AccountUuid) -> Unit,
    onBack: () -> Unit,
    viewModel: ViewModel,
    brandNameProvider: BrandNameProvider,
    modifier: Modifier = Modifier,
) {
    val (state, dispatch) = viewModel.observe { effect ->
        when (effect) {
            Effect.NavigateBack -> onBack()
            is Effect.NavigateNext -> onNext(effect.accountUuid)
        }
    }

    LaunchedEffect(key1 = Unit) {
        dispatch(Event.CreateAccount)
    }

    BackHandler {
        dispatch(Event.OnBackClicked)
    }

    Scaffold(
        topBar = {
            AppTitleTopHeader(
                title = brandNameProvider.brandName,
            )
        },
        bottomBar = {
            WizardNavigationBar(
                onNextClick = {},
                onBackClick = {
                    dispatch(Event.OnBackClicked)
                },
                state = WizardNavigationBarState(
                    showNext = false,
                    isBackEnabled = state.value.error != null,
                ),
            )
        },
        modifier = modifier,
    ) { innerPadding ->
        CreateAccountContent(
            state = state.value,
            contentPadding = innerPadding,
        )
    }
}

4. Create the Content Composable

Finally, create the content composable that renders the UI based on the state:

@Composable
private fun CreateAccountContent(
    state: State,
    contentPadding: PaddingValues,
    modifier: Modifier = Modifier,
) {
    Box(
        modifier = modifier
            .fillMaxSize()
            .padding(contentPadding),
    ) {
        when {
            state.isLoading -> {
                CircularProgressIndicator(
                    modifier = Modifier.align(Alignment.Center),
                )
            }
            state.error != null -> {
                ErrorView(
                    error = state.error,
                    modifier = Modifier.align(Alignment.Center),
                )
            }
        }
    }
}

5. Add to Navigation

Add the screen to the navigation graph:

NavHost(
    navController = navController,
    startDestination = ROUTE_HOME,
) {
    // Other composables...

    composable(route = NESTED_NAVIGATION_CREATE_ACCOUNT) {
        CreateAccountScreen(
            onNext = { accountUuid -> onFinish(AccountSetupRoute.AccountSetup(accountUuid.value)) },
            onBack = { navController.popBackStack() },
            viewModel = koinViewModel<CreateAccountViewModel>(),
            brandNameProvider = koinInject(),
        )
    }
}

This example demonstrates the complete flow from UI to ViewModel to Domain and back, showing how all the components work together in a real-world scenario.

🔄 Component Interactions and State Changes

Understanding how components interact and how state changes flow through the system is crucial for working with our MVI architecture. Here’s a detailed explanation of the interaction flow:

sequenceDiagram
    participant User
    participant View
    participant ViewModel
    participant UseCase
    participant Repository

    User->>View: User Interaction
    View->>ViewModel: Event
    ViewModel->>ViewModel: Process Event
    ViewModel->>UseCase: Action (Execute Use Case)
    UseCase->>Repository: Data Operation
    Repository-->>UseCase: Result
    UseCase-->>ViewModel: Result
    ViewModel->>ViewModel: Update State
    ViewModel-->>View: New State
    View-->>User: UI Update

    Note over ViewModel,View: Side Effect (if needed)
    ViewModel->>View: Effect
    View->>User: One-time Action (e.g., Navigation)

Interaction Flow

  1. User Interaction: The user interacts with the UI (e.g., clicks a button, enters text)
  2. Event Dispatch: The View captures this interaction and dispatches an Event to the ViewModel
  3. Event Processing: The ViewModel processes the Event and determines what action to take
  4. Action Execution: The ViewModel executes an Action, typically by calling a Use Case
  5. Domain Logic: The Use Case executes business logic, often involving repositories or other domain services
  6. Result Handling: The Use Case returns a result to the ViewModel
  7. State Update: The ViewModel updates its State based on the result
  8. UI Update: The View observes the State change and updates the UI accordingly
  9. Side Effects (if needed): For one-time actions like navigation, the ViewModel emits an Effect that the View handles

State Changes

State changes follow a unidirectional flow:

  1. State Immutability: The State is an immutable data class that represents the entire UI state
  2. Single Source of Truth: The ViewModel is the single source of truth for the State
  3. State Updates: Only the ViewModel can update the State, using the updateState method
  4. State Observation: The View observes the State using collectAsStateWithLifecycle() and recomposes when it changes
  5. State Rendering: The View renders the UI based on the current State

Example of state changes in the ViewModel:

// Initial state
val initialState = AccountSettingsState(isLoading = false, settings = null, error = null)

// Update state to show loading
updateState { it.copy(isLoading = true, error = null) }

// Update state with loaded settings
updateState { it.copy(isLoading = false, settings = loadedSettings, error = null) }

// Update state to show error
updateState { it.copy(isLoading = false, error = "Failed to load settings") }

Component Responsibilities

Each component has specific responsibilities in the interaction flow:

  1. View:
    • Render UI based on State
    • Capture user interactions
    • Dispatch Events to ViewModel
    • Handle Effects (e.g., navigation)
  2. ViewModel:
    • Process Events
    • Execute Actions (Use Cases)
    • Update State
    • Emit Effects
  3. Use Cases:
    • Execute business logic
    • Coordinate with repositories and domain services
    • Return results to ViewModel
  4. Repositories:
    • Provide data access
    • Handle data operations
    • Return data to Use Cases

This clear separation of responsibilities ensures that each component focuses on its specific role, making the codebase more maintainable, testable, and scalable.

♿ Accessibility

The UI is designed with accessibility in mind:

  • 🔍 Content Scaling: Support for font scaling and dynamic text sizes
  • 🎙️ Screen Readers: Semantic properties for screen reader support
  • 🎯 Touch Targets: Appropriately sized touch targets
  • 🎨 Color Contrast: Sufficient color contrast for readability
  • ⌨️ Keyboard Navigation: Support for keyboard navigation
Last change: 2025-06-26, commit: 3b4ef25

🎭 Theming

This document provides a detailed explanation of the theming system used in our applications. It covers the theme architecture, components, customization, and usage.

  • ✨ Material Design 3: Based on Material Design 3 principles
  • 🎨 Colors: Custom color schemes with light and dark modes
    • 🌓 Dark Mode: Full support for light and dark themes
    • 🌈 Dynamic Color: Support for dynamic color based on system settings
  • 🪜 Elevations: Consistent elevation system for shadows
  • 🖼️ Images: Images and icons consistent with the theme
  • 🔶 Shapes: Customizable shape system for components
  • 📐 Sizes: Standardized sizes for components
  • 📏 Spacings: Consistent spacing system for layout
  • 🅰️ Typography: Consistent typography system

📱 Theme Architecture

Our theme architecture is designed with several key principles in mind:

  1. Consistency: Provide a unified look and feel across all applications while allowing for brand-specific customization
  2. Flexibility: Support different visual identities for different applications (Thunderbird, K-9 Mail) using the same underlying system
  3. Extensibility: Enable easy addition of new theme components or modification of existing ones
  4. Maintainability: Centralize theme definitions to simplify updates and changes
  5. Material Design Compatibility: Build on top of Material Design 3 while extending it with our specific needs

The theming system follows a hierarchical structure:

graph TD
    subgraph APP_THEMES["App-Specific Themes"]
        TB_THEME[ThunderbirdTheme2]
        K9_THEME[K9MailTheme2]
    end

    subgraph MAIN["Main Theme"]
        MAIN_THEME[MainTheme]
        THEME_CONFIG[ThemeConfig]
    end

    subgraph MATERIAL["Material Design 3"]
        MAT_THEME[MaterialTheme]
    end

    TB_THEME --> |uses| MAIN_THEME
    TB_THEME --> |defines| THEME_CONFIG
    K9_THEME --> |uses| MAIN_THEME
    K9_THEME --> |defines| THEME_CONFIG
    THEME_CONFIG --> |configures| MAIN_THEME
    MAIN_THEME --> |wraps| MAT_THEME

    classDef app_theme fill:#d9ffd9,stroke:#000000,color:#000000
    classDef main_theme fill:#d9e9ff,stroke:#000000,color:#000000
    classDef material fill:#ffe6cc,stroke:#000000,color:#000000

    linkStyle default stroke:#999,stroke-width:2px

    class TB_THEME,K9_THEME app_theme
    class MAIN_THEME,THEME_CONFIG main_theme
    class MAT_THEME material

🏗️ Architecture Layers

The theme system consists of three main layers:

  1. App-Specific Themes Layer: The top layer contains theme implementations for specific applications (ThunderbirdTheme2, K9MailTheme2). Each app theme:
    • Defines its own brand colors, logos, and other app-specific visual elements
    • Creates a ThemeConfig with these customizations
    • Uses the MainTheme as its foundation
  2. Main Theme Layer: The middle layer provides our extended theming system:
    • MainTheme: A composable function that sets up the theme environment
    • ThemeConfig: A data class that holds all theme components
    • This layer extends Material Design with additional components like custom spacings, elevations, and app-specific colors
  3. Material Design Layer: The foundation layer is Material Design 3:
    • Provides the base theming system (colors, typography, shapes)
    • Ensures compatibility with standard Material components
    • Our MainTheme wraps MaterialTheme and converts our theme components to Material 3 format when needed

🔄 Data Flow

The theme data flows through the system as follows:

  1. App-specific themes (ThunderbirdTheme2, K9MailTheme2) define their visual identity through a ThemeConfig
  2. ThemeConfig is passed to MainTheme, which:
    • Selects the appropriate color scheme based on dark/light mode
    • Configures system bars (status bar, navigation bar)
    • Provides all theme components through CompositionLocal providers
    • Converts our theme components to Material 3 format and configures MaterialTheme
  3. Composables access theme properties through the MainTheme object
  4. Material components automatically use the Material 3 theme derived from our theme

🌟 Benefits

This architecture provides several benefits:

  • Separation of Concerns: Each layer has a specific responsibility
  • Code Reuse: Common theme logic is shared between applications
  • Customization: Each application can have its own visual identity
  • Consistency: All applications share the same theming structure and components
  • Extensibility: New theme components can be added without changing the overall architecture
  • Compatibility: Works with both our custom components and standard Material components

🧩 Theme Components

The theming system consists of several components that work together to provide a comprehensive and consistent visual experience across the application. Each component is responsible for a specific aspect of the UI design.

🔧 ThemeConfig

The ThemeConfig is the central configuration class that holds all theme components. It serves as a container for all theme-related settings and is passed to the MainTheme composable.

data class ThemeConfig(
    val colors: ThemeColorSchemeVariants,
    val elevations: ThemeElevations,
    val images: ThemeImageVariants,
    val shapes: ThemeShapes,
    val sizes: ThemeSizes,
    val spacings: ThemeSpacings,
    val typography: ThemeTypography,
)

The ThemeConfig allows for:

  • Centralized management of all theme components
  • Easy switching between light and dark themes
  • Simplified theme customization for different applications
  • Consistent theme application throughout the app

🎨 ThemeColorScheme

The ThemeColorScheme defines all colors used in the application. It extends Material Design 3’s color system with additional colors specific to our applications.

data class ThemeColorScheme(
    // Material 3 colors
    val primary: Color,
    val onPrimary: Color,
    val primaryContainer: Color,
    val onPrimaryContainer: Color,
    val secondary: Color,
    val onSecondary: Color,
    val secondaryContainer: Color,
    val onSecondaryContainer: Color,
    val tertiary: Color,
    val onTertiary: Color,
    val tertiaryContainer: Color,
    val onTertiaryContainer: Color,
    val error: Color,
    val onError: Color,
    val errorContainer: Color,
    val onErrorContainer: Color,
    val surfaceDim: Color,
    val surface: Color,
    val surfaceBright: Color,
    val onSurface: Color,
    val onSurfaceVariant: Color,
    val surfaceContainerLowest: Color,
    val surfaceContainerLow: Color,
    val surfaceContainer: Color,
    val surfaceContainerHigh: Color,
    val surfaceContainerHighest: Color,
    val inverseSurface: Color,
    val inverseOnSurface: Color,
    val inversePrimary: Color,
    val outline: Color,
    val outlineVariant: Color,
    val scrim: Color,

    // Extra colors
    val info: Color,
    val onInfo: Color,
    val infoContainer: Color,
    val onInfoContainer: Color,
    val success: Color,
    val onSuccess: Color,
    val successContainer: Color,
    val onSuccessContainer: Color,
    val warning: Color,
    val onWarning: Color,
    val warningContainer: Color,
    val onWarningContainer: Color,
)

The color scheme is organized into:

  • Base colors: Primary, secondary, and tertiary colors that define the app’s brand identity
  • Surface colors: Colors for backgrounds, cards, and other surfaces
  • Content colors: Colors for text and icons that appear on various backgrounds (prefixed with “on”)
  • Container colors: Colors for containers like buttons, chips, and other interactive elements
  • Utility colors: Colors for specific purposes like errors, outlines, and scrims

Colors are provided in variants for both light and dark themes through the ThemeColorSchemeVariants class:

data class ThemeColorSchemeVariants(
    val light: ThemeColorScheme,
    val dark: ThemeColorScheme,
)

🪜 ThemeElevations

The ThemeElevations component defines standard elevation values used throughout the application to create a consistent sense of depth and hierarchy.

data class ThemeElevations(
    val level0: Dp,
    val level1: Dp,
    val level2: Dp,
    val level3: Dp,
    val level4: Dp,
    val level5: Dp,
)

Typical usage includes:

  • level0: For elements that are flush with their background (0dp)
  • level1: For subtle elevation like dividers (1dp)
  • level2: For cards, buttons in their resting state (3dp)
  • level3: For floating action buttons, navigation drawers (6dp)
  • level4: For dialogs, bottom sheets (8dp)
  • level5: For modal surfaces that should appear prominently (12dp)

🖼️ ThemeImages

The ThemeImages component stores references to app-specific images like logos, icons, and illustrations.

data class ThemeImages(
    val logo: Int, // Resource ID
    // ... other image resources
)

These images can have light and dark variants through the ThemeImageVariants class:

data class ThemeImageVariants(
    val light: ThemeImages,
    val dark: ThemeImages,
)

🔶 ThemeShapes

The ThemeShapes component defines the corner shapes used for UI elements throughout the application.

data class ThemeShapes(
    val extraSmall: CornerBasedShape,
    val small: CornerBasedShape,
    val medium: CornerBasedShape,
    val large: CornerBasedShape,
    val extraLarge: CornerBasedShape,
)

These shapes are used for:

  • extraSmall: Subtle rounding for elements like text fields (4dp)
  • small: Light rounding for cards, buttons (8dp)
  • medium: Moderate rounding for floating elements (12dp)
  • large: Significant rounding for prominent elements (16dp)
  • extraLarge: Very rounded corners for special elements (28dp)

Note: For no rounding (0% corner radius), use RectangleShape. For completely rounded corners (50% corner radius) for circular elements, use CircleShape.

The ThemeShapes can be converted to Material 3 shapes using the toMaterial3Shapes() method for compatibility with Material components.

📐 ThemeSizes

The ThemeSizes component defines standard size values for UI elements to ensure consistent sizing throughout the application.

data class ThemeSizes(
    val smaller: Dp,
    val small: Dp,
    val medium: Dp,
    val large: Dp,
    val larger: Dp,
    val huge: Dp,
    val huger: Dp,

    val iconSmall: Dp,
    val icon: Dp,
    val iconLarge: Dp,
    val iconAvatar: Dp,

    val topBarHeight: Dp,
    val bottomBarHeight: Dp,
    val bottomBarHeightWithFab: Dp,
)

These sizes are used for:

  • General sizes: smaller, small, medium, large, larger, huge, huger for component dimensions (width, height), button heights, and other UI element dimensions that need standardization
  • Icon sizes: iconSmall, icon, iconLarge for different icon sizes throughout the app
  • Avatar size: iconAvatar for user avatars and profile pictures
  • Layout sizes: topBarHeight, bottomBarHeight, bottomBarHeightWithFab for consistent app bar and navigation bar heights

📏 ThemeSpacings

The ThemeSpacings component defines standard spacing values used for margins, padding, and gaps between elements.

data class ThemeSpacings(
    val zero: Dp,
    val quarter: Dp,
    val half: Dp,
    val default: Dp,
    val oneHalf: Dp,
    val double: Dp,
    val triple: Dp,
    val quadruple: Dp,
)

Consistent spacing helps create a rhythmic and harmonious layout:

  • zero: No spacing (0dp)
  • quarter: Quarter of the default spacing, for very tight layouts (4dp)
  • half: Half of the default spacing, for tight layouts (8dp)
  • default: The standard spacing unit for general use (16dp)
  • oneHalf: One and a half times the default spacing (24dp)
  • double: Twice the default spacing, for separating sections (32dp)
  • triple: Three times the default spacing, for major layout divisions (48dp)
  • quadruple: Four times the default spacing, for maximum separation (64dp)

🅰️ ThemeTypography

The ThemeTypography component defines text styles for different types of content throughout the application.

data class ThemeTypography(
    // Display styles for large headlines
    val displayLarge: TextStyle,
    val displayMedium: TextStyle,
    val displaySmall: TextStyle,

    // Headline styles for section headers
    val headlineLarge: TextStyle,
    val headlineMedium: TextStyle,
    val headlineSmall: TextStyle,

    // Title styles for content titles
    val titleLarge: TextStyle,
    val titleMedium: TextStyle,
    val titleSmall: TextStyle,

    // Body styles for main content
    val bodyLarge: TextStyle,
    val bodyMedium: TextStyle,
    val bodySmall: TextStyle,

    // Label styles for buttons and small text
    val labelLarge: TextStyle,
    val labelMedium: TextStyle,
    val labelSmall: TextStyle,
)

Each TextStyle includes:

  • Font family
  • Font weight
  • Font size
  • Line height
  • Letter spacing
  • Other typographic attributes

The ThemeTypography can be converted to Material 3 typography using the toMaterial3Typography() method for compatibility with Material components.

↔️ Component Interaction

These theme components work together to create a cohesive design system:

  1. ThemeConfig aggregates all components and provides them to the MainTheme
  2. MainTheme makes components available through CompositionLocal providers
  3. Composables access theme components through the MainTheme object
  4. Components like ThemeColorScheme and ThemeShapes are converted to Material 3 equivalents for use with Material components

This structured approach ensures consistent design application throughout the app while providing flexibility for customization.

🌟 MainTheme

The MainTheme is the foundation of our theming system:

  • Acts as a wrapper around Material Design 3’s MaterialTheme
  • Provides additional theme components beyond what Material Design offers
  • Configurable through a ThemeConfig parameter
  • Supports dark mode and dynamic color
  • Exposes theme components through the MainTheme object

🔌 Theme Provider Implementation and Usage

🛠️ How the Theme Provider Works

The MainTheme function uses Jetpack Compose’s CompositionLocalProvider to make theme components available throughout the composition tree:

@Composable
fun MainTheme(
    themeConfig: ThemeConfig,
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit,
) {
    val themeColorScheme = selectThemeColorScheme(
        themeConfig = themeConfig,
        darkTheme = darkTheme,
        dynamicColor = dynamicColor,
    )
    val themeImages = selectThemeImages(
        themeConfig = themeConfig,
        darkTheme = darkTheme,
    )

    SystemBar(
        darkTheme = darkTheme,
        colorScheme = themeColorScheme,
    )

    CompositionLocalProvider(
        LocalThemeColorScheme provides themeColorScheme,
        LocalThemeElevations provides themeConfig.elevations,
        LocalThemeImages provides themeImages,
        LocalThemeShapes provides themeConfig.shapes,
        LocalThemeSizes provides themeConfig.sizes,
        LocalThemeSpacings provides themeConfig.spacings,
        LocalThemeTypography provides themeConfig.typography,
    ) {
        MaterialTheme(
            colorScheme = themeColorScheme.toMaterial3ColorScheme(),
            shapes = themeConfig.shapes.toMaterial3Shapes(),
            typography = themeConfig.typography.toMaterial3Typography(),
            content = content,
        )
    }
}

Each theme component is provided through a CompositionLocal that makes it available to all composables in the composition tree. These CompositionLocal values are defined using staticCompositionLocalOf in their respective files:

internal val LocalThemeColorScheme = staticCompositionLocalOf<ThemeColorScheme> {
    error("No ThemeColorScheme provided")
}

internal val LocalThemeElevations = staticCompositionLocalOf<ThemeElevations> {
    error("No ThemeElevations provided")
}

// ... other LocalTheme* definitions

The MainTheme object provides properties to access these values from anywhere in the composition tree:

object MainTheme {
    val colors: ThemeColorScheme
        @Composable
        @ReadOnlyComposable
        get() = LocalThemeColorScheme.current

    val elevations: ThemeElevations
        @Composable
        @ReadOnlyComposable
        get() = LocalThemeElevations.current

    // ... other properties
}

This theme provider mechanism ensures that theme components are available throughout the app without having to pass them as parameters to every composable.

🎭 App-Specific Themes

The app-specific themes (ThunderbirdTheme2 and K9MailTheme2) customize the MainTheme for each application:

  • Provide app-specific color schemes
  • Include app-specific assets (like logos)
  • Configure theme components through ThemeConfig
  • Use default values for common components (elevations, sizes, spacings, shapes, typography)

ThunderbirdTheme2

@Composable
fun ThunderbirdTheme2(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = false,
    content: @Composable () -> Unit,
) {
    val images = ThemeImages(
        logo = R.drawable.core_ui_theme2_thunderbird_logo,
    )

    val themeConfig = ThemeConfig(
        colors = ThemeColorSchemeVariants(
            dark = darkThemeColorScheme,
            light = lightThemeColorScheme,
        ),
        elevations = defaultThemeElevations,
        images = ThemeImageVariants(
            light = images,
            dark = images,
        ),
        sizes = defaultThemeSizes,
        spacings = defaultThemeSpacings,
        shapes = defaultThemeShapes,
        typography = defaultTypography,
    )

    MainTheme(
        themeConfig = themeConfig,
        darkTheme = darkTheme,
        dynamicColor = dynamicColor,
        content = content,
    )
}

K9MailTheme2

@Composable
fun K9MailTheme2(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = false,
    content: @Composable () -> Unit,
) {
    val images = ThemeImages(
        logo = R.drawable.core_ui_theme2_k9mail_logo,
    )

    val themeConfig = ThemeConfig(
        colors = ThemeColorSchemeVariants(
            dark = darkThemeColorScheme,
            light = lightThemeColorScheme,
        ),
        elevations = defaultThemeElevations,
        images = ThemeImageVariants(
            light = images,
            dark = images,
        ),
        sizes = defaultThemeSizes,
        spacings = defaultThemeSpacings,
        shapes = defaultThemeShapes,
        typography = defaultTypography,
    )

    MainTheme(
        themeConfig = themeConfig,
        darkTheme = darkTheme,
        dynamicColor = dynamicColor,
        content = content,
    )
}

🎨 Using Themes in the App

🧩 Applying a Theme

To apply a theme to your UI, wrap your composables with the appropriate theme composable:

// For Thunderbird app
@Composable
fun ThunderbirdApp() {
    ThunderbirdTheme2 {
        // App content
    }
}

// For K9Mail app
@Composable
fun K9MailApp() {
    K9MailTheme2 {
        // App content
    }
}

🔑 Accessing Theme Components

Inside themed content, you can access theme properties through the MainTheme object:

@Composable
fun ThemedButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Button(
        onClick = onClick,
        modifier = modifier,
        colors = ButtonDefaults.buttonColors(
            containerColor = MainTheme.colors.primary,
            contentColor = MainTheme.colors.onPrimary,
        ),
        shape = MainTheme.shapes.medium,
    ) {
        Text(
            text = text,
            style = MainTheme.typography.labelLarge,
        )
    }
}

🌓 Dark Mode and Dynamic Color

The theming system supports both dark mode and dynamic color:

  • Dark Mode: Automatically applies the appropriate color scheme based on the system’s dark mode setting
  • Dynamic Color: Optionally uses the device’s wallpaper colors for the theme (Android 12+)
@Composable
fun ThunderbirdTheme2(
    darkTheme: Boolean = isSystemInDarkTheme(), // Default to system setting
    dynamicColor: Boolean = false, // Disabled by default
    content: @Composable () -> Unit,
) {
    // ...
}

🔧 Customizing Themes

To customize a theme, you can create a new theme composable that wraps MainTheme with your custom ThemeConfig:

@Composable
fun CustomTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = false,
    content: @Composable () -> Unit,
) {
    val images = ThemeImages(
        logo = R.drawable.custom_logo,
    )

    val themeConfig = ThemeConfig(
        colors = ThemeColorSchemeVariants(
            dark = customDarkThemeColorScheme,
            light = customLightThemeColorScheme,
        ),
        elevations = customThemeElevations,
        images = ThemeImageVariants(
            light = images,
            dark = images,
        ),
        sizes = customThemeSizes,
        spacings = customThemeSpacings,
        shapes = customThemeShapes,
        typography = customTypography,
    )

    MainTheme(
        themeConfig = themeConfig,
        darkTheme = darkTheme,
        dynamicColor = dynamicColor,
        content = content,
    )
}

🧪 Testing with Themes

When writing tests for composables that use theme components, you need to wrap them in a theme:

@Test
fun testThemedButton() {
    composeTestRule.setContent {
        ThunderbirdTheme2 {
            ThemedButton(
                text = "Click Me",
                onClick = {},
            )
        }
    }

    composeTestRule.onNodeWithText("Click Me").assertExists()
}
Last change: 2025-06-26, commit: 3b4ef25

🎨 Design System

The design system is a collection of guidelines, principles, and tools that help teams create consistent and cohesive visual designs and user experiences. It is built using the Atomic Design Methodology.

📚 Background

Jetpack Compose is a declarative UI toolkit for Android that provides a modern and efficient way to build UIs for Android apps. In this context, design systems and atomic design can help designers and developers create more scalable, maintainable, and reusable UIs.

🧩 Design System

A design system is a collection of guidelines, principles, and tools that help teams create consistent and cohesive visual designs and user experiences. It typically includes a set of reusable components, such as icons, typography, color palettes, and layouts, that can be combined and customized to create new designs.

The design system also provides documentation and resources for designers and developers to ensure that the designs are implemented consistently and efficiently across all platforms and devices. The goal of a design system is to streamline the design process, improve design quality, and maintain brand consistency.

An example is Google’s Material Design that is used to develop cohesive apps.

🧪 Atomic Design

Atomic design

Atomic design is a methodology for creating user interfaces (UI) in a design system by breaking them down into smaller, reusable components. These components are classified into five categories based on their level of abstraction: atoms, molecules, organisms, templates, and pages.

  • Atoms are the smallest building blocks, such as buttons, labels, and input fields and could be combined to create more complex components.
  • Molecules are groups of atoms that work together, like search bars, forms or menus
  • Organisms are more complex components that combine molecules and atoms, such as headers or cards.
  • Templates are pages with placeholders for components
  • Pages are the final UI

By using atomic design, designers and developers can create more consistent and reusable UIs. This can save time and improve the overall quality, as well as facilitate collaboration between team members.

📝 Acknowledgement

Last change: 2025-06-26, commit: 3b4ef25

User Flows

The user flows diagrams below illustrate typical paths users take through the application, helping developers understand how different components interact from a user perspective.

For information about the repository structure and module organization, see the Project Structure document.

Mail

Reading email

read email sequence

read email classes

Sending email

send email sequence

Verifying Flows

We plan to test these user flows using maestro, a tool for automating UI tests. Maestro allows us to write tests in a simple YAML format, making it easy to define user interactions and verify application behavior.

The current flows could be found in the *ui-flows directory in the repository.

Last change: 2025-06-26, commit: 3b4ef25

🔙 Legacy Module Integration

This document outlines how existing legacy code is integrated into the new modular architecture of the application and the strategy for its migration. The core principle is to isolate legacy code and provide a controlled way for newer modules to interact with legacy functionality without becoming directly dependent on it.

note

This document should be read in conjunction with Module Structure and Module Organization to get a complete understanding of the modular architecture.

Overview

The Thunderbird for Android project is transitioning from a monolithic architecture to a modular one. During this transition, we need to maintain compatibility with existing legacy code while gradually migrating to the new architecture. The legacy:*, mail:*, and backend:* modules contain functionality that is still essential for the project but does not yet adhere to the new modular architecture. These modules are integrated into the new architecture through the :app-common module, which acts as a bridge or adapter to provide access to legacy functionality without directly depending on it.

The key components in this integration strategy are:

  1. Legacy Modules: legacy:*, mail:*, and backend:* modules containing existing functionality
  2. Interfaces: Well-defined interfaces in feature:*:api and core:* modules
  3. App Common Bridge: The :app-common module that implements these interfaces and delegates to legacy code
  4. Dependency Injection: Configuration that provides the appropriate implementations to modules

Integration Approach “The App Common Bridge

Newer application modules (such as features or core components) depend on well-defined Interfaces (e.g., those found in feature:*:api modules). Typically, a feature will provide its own modern Implementation (e.g., :feature:mail:impl) for its API.

However, to manage dependencies on code still within legacy:*, mail:*, and backend:* modules and prevent it from spreading, we use app-common as bridge or adapter to provide an alternative implementation for these. In this role, app-common is responsible for:

  1. Implementing interfaces: app-common provides concrete implementations for interfaces that newer modules define.
  2. Delegating to legacy code: Internally, these app-common implementations will delegate calls, adapt data, and manage interactions with the underlying legacy:*, mail:*, and backend:* modules.
  3. Containing glue code: All logic required to connect the modern interfaces with the legacy systems is encapsulated within app-common.

This approach ensures that:

  • Newer modules are decoupled from legacy implementations: They only interact with the defined interfaces, regardless of whether the implementation is the modern feature impl or the app-common bridge.
  • Legacy code is isolated.
  • A clear path for refactoring is maintained: Initially, the application might be configured to use the app-common bridge. As new, native implementations in feature modules (e.g., :feature:mail:impl) mature, the dependency injection can be switched to use them, often without changes to the modules consuming the interface.

Bridge Pattern Flow

The typical flow is:

  1. Interfaces: Interfaces are defined, usually within the api module of a feature (e.g., :feature:mail:api) or a core module. These interfaces represent the contract for a piece of functionality.
  2. New Module Dependency: Newer modules (e.g., :feature:somefeature:impl or other parts of :app-common) depend on these defined interfaces, to avoid dependency on concrete legacy classes.
  3. Implementation: The :app-common module provides concrete implementations for these interfaces.
  4. Delegation to Legacy: Internally, these implementations within :app-common delegate the actual work to the code residing in the legacy modules (e.g., legacy:*, mail:*, backend:*).
  5. Dependency Injection: The application’s dependency injection framework is configured to provide instances of these :app-common bridge implementations when a newer module requests an implementation of the interface.

This pattern ensures that newer modules remain decoupled from the specifics of legacy code.

The following diagram illustrates this pattern, showing how both a feature’s own implementation and app-common can relate to the interfaces, with app-common specifically bridging to legacy systems:

graph TB
    subgraph FEATURE[Feature Modules]
        direction TB
        INTERFACES["`**Interfaces**<br> (e.g., :feature:mail:api)`"]
        IMPLEMENTATIONS["`**Implementations**<br> (e.g., :feature:mail:impl)`"]
        OTHER_MODULES["`**Other Modules**<br>(depend on Interfaces)`"]
    end

    subgraph COMMON[App Common Module]
        direction TB
        COMMON_APP["`**:app-common**<br>Integration Code`"]
    end

    subgraph LEGACY[Legacy Modules]
        direction TB
        LEGACY_K9["`**:legacy**`"]
        LEGACY_MAIL["`**:mail**`"]
        LEGACY_BACKEND["`**:backend**`"]
    end

    OTHER_MODULES --> |uses| INTERFACES
    IMPLEMENTATIONS --> |depends on| INTERFACES
    COMMON_APP --> |implements| INTERFACES
    COMMON_APP --> |delegates to / wraps| LEGACY_K9
    COMMON_APP --> |delegates to / wraps| LEGACY_MAIL
    COMMON_APP --> |delegates to / wraps| LEGACY_BACKEND

    classDef common fill:#e6e6e6,stroke:#000000,color:#000000
    classDef common_module fill:#999999,stroke:#000000,color:#000000
    classDef feature fill:#d9ffd9,stroke:#000000,color:#000000
    classDef feature_module fill:#33cc33,stroke:#000000,color:#000000
    classDef legacy fill:#ffe6e6,stroke:#000000,color:#000000
    classDef legacy_module fill:#ff9999,stroke:#000000,color:#000000

    linkStyle default stroke:#999,stroke-width:2px

    class COMMON common
    class COMMON_APP common_module
    class FEATURE feature
    class INTERFACES,IMPLEMENTATIONS,OTHER_MODULES feature_module
    class LEGACY legacy
    class LEGACY_MAIL,LEGACY_BACKEND,LEGACY_K9 legacy_module

Implementation Techniques

Several techniques are used to implement the bridge pattern effectively:

  1. Wrapper Classes: Creating immutable data classes that wrap legacy data structures, implementing interfaces from the new architecture. These wrappers should not contain conversion methods but should delegate this responsibility to specific mapper classes.

  2. Adapter Implementations: Classes in :app-common that implement interfaces from the new architecture but delegate to legacy code internally.

  3. Data Conversion: Dedicated mapper classes that handle mapping between legacy and new data structures, ensuring clean separation of concerns.

Example: Account Profile Bridge

A concrete example of this pattern is the account profile bridge, which demonstrates a complete implementation of the bridge pattern across multiple layers:

  1. Modern Interfaces:
    • AccountProfileRepository in feature:account:api defines the high-level contract for account profile management
    • AccountProfileLocalDataSource in feature:account:core defines the data access contract
  2. Modern Data Structure: AccountProfile in feature:account:api is a clean, immutable data class that represents account profile information in the new architecture.
  3. Repository Implementation: DefaultAccountProfileRepository in feature:account:core implements the AccountProfileRepository interface and depends on AccountProfileLocalDataSource.
  4. Bridge Implementation: DefaultAccountProfileLocalDataSource in app-common implements the AccountProfileLocalDataSource interface and serves as the bridge to legacy code.
  5. Legacy Access: The bridge uses DefaultLegacyAccountWrapperManager to access legacy account data:
    • LegacyAccountWrapperManager in core:android:account defines the contract for legacy account access
    • LegacyAccountWrapper in core:android:account is an immutable wrapper around the legacy LegacyAccount class
  6. Data Conversion: The bridge uses a dedicated mapper class to convert between modern AccountProfile objects and legacy account data.
  7. Dependency Injection: The appCommonAccountModule in app-common registers DefaultAccountProfileLocalDataSource as implementations of the respective interface.

This multi-layered approach allows newer modules to interact with legacy account functionality through clean, modern interfaces without directly depending on legacy code. It also demonstrates how bridges can be composed, with higher-level bridges (AccountProfile) building on lower-level bridges (LegacyAccountWrapper).

Testing Considerations

Testing bridge implementations requires special attention to ensure both the bridge itself and its integration with legacy code work correctly:

  1. Unit Testing Bridge Classes:
    • Test the bridge implementation in isolation by faking/stubbing the legacy dependencies
    • Verify that the bridge correctly translates between the new interfaces and legacy code
    • Focus on testing the conversion logic and error handling
  2. Integration Testing:
    • Test the bridge with actual legacy code to ensure proper integration
    • Verify that the bridge correctly handles all edge cases from legacy code
  3. Test Doubles:
    • Create fake implementations of bridge classes for testing other components
    • Example: FakeLegacyAccountWrapperManager can be used to test components that depend on LegacyAccountWrapperManager
  4. Migration Testing:
    • When migrating from a legacy bridge to a new implementation, test both implementations with the same test suite
    • Ensure behavior consistency during the transition

Testing Examples

Below are examples of tests for legacy module integration, demonstrating different testing approaches and best practices.

Example 1: Unit Testing a Bridge Implementation

This example shows how to test a bridge implementation (DefaultAccountProfileLocalDataSource) in isolation by using a fake implementation of the legacy dependency (FakeLegacyAccountWrapperManager):

class DefaultAccountProfileLocalDataSourceTest {

    @Test
    fun `getById should return account profile`() = runTest {
        // arrange
        val accountId = AccountIdFactory.create()
        val legacyAccount = createLegacyAccount(accountId)
        val accountProfile = createAccountProfile(accountId)
        val testSubject = createTestSubject(legacyAccount)

        // act & assert
        testSubject.getById(accountId).test {
            assertThat(awaitItem()).isEqualTo(accountProfile)
        }
    }

    @Test
    fun `getById should return null when account is not found`() = runTest {
        // arrange
        val accountId = AccountIdFactory.create()
        val testSubject = createTestSubject(null)

        // act & assert
        testSubject.getById(accountId).test {
            assertThat(awaitItem()).isEqualTo(null)
        }
    }

    @Test
    fun `update should save account profile`() = runTest {
        // arrange
        val accountId = AccountIdFactory.create()
        val legacyAccount = createLegacyAccount(accountId)
        val accountProfile = createAccountProfile(accountId)

        val updatedName = "updatedName"
        val updatedAccountProfile = accountProfile.copy(name = updatedName)

        val testSubject = createTestSubject(legacyAccount)

        // act & assert
        testSubject.getById(accountId).test {
            assertThat(awaitItem()).isEqualTo(accountProfile)

            testSubject.update(updatedAccountProfile)

            assertThat(awaitItem()).isEqualTo(updatedAccountProfile)
        }
    }

    private fun createTestSubject(
        legacyAccount: LegacyAccountWrapper?,
    ): DefaultAccountProfileLocalDataSource {
        return DefaultAccountProfileLocalDataSource(
            accountManager = FakeLegacyAccountWrapperManager(
                initialAccounts = if (legacyAccount != null) {
                    listOf(legacyAccount)
                } else {
                    emptyList()
                },
            ),
            dataMapper = DefaultAccountProfileDataMapper(
                avatarMapper = DefaultAccountAvatarDataMapper(),
            ),
        )
    }
}

Key points:

  • The test creates a controlled test environment using a fake implementation of the legacy dependency
  • It tests both success cases and error handling (account not found)
  • It verifies that the bridge correctly translates between legacy data structures and domain models
  • The test is structured with clear arrange, act, and assert sections

Example 2: Creating Test Doubles for Legacy Dependencies

This example shows how to create a fake implementation of a legacy dependency (FakeLegacyAccountWrapperManager) for testing:

internal class FakeLegacyAccountWrapperManager(
    initialAccounts: List<LegacyAccountWrapper> = emptyList(),
) : LegacyAccountWrapperManager {

    private val accountsState = MutableStateFlow(
        initialAccounts,
    )
    private val accounts: StateFlow<List<LegacyAccountWrapper>> = accountsState

    override fun getAll(): Flow<List<LegacyAccountWrapper>> = accounts

    override fun getById(id: AccountId): Flow<LegacyAccountWrapper?> = accounts
        .map { list ->
            list.find { it.id == id }
        }

    override suspend fun update(account: LegacyAccountWrapper) {
        accountsState.update { currentList ->
            currentList.toMutableList().apply {
                removeIf { it.uuid == account.uuid }
                add(account)
            }
        }
    }
}

Key points:

  • The fake implementation implements the same interface as the real implementation
  • It provides a simple in-memory implementation for testing
  • It uses Kotlin Flows to simulate the reactive behavior of the real implementation
  • It allows for easy setup of test data through the constructor parameter

Example 3: Testing Data Conversion Logic

This example shows how to test data conversion logic in bridge implementations:

class DefaultAccountProfileDataMapperTest {

    @Test
    fun `toDomain should convert ProfileDto to AccountProfile`() {
        // Arrange
        val dto = createProfileDto()
        val expected = createAccountProfile()

        val testSubject = DefaultAccountProfileDataMapper(
            avatarMapper = FakeAccountAvatarDataMapper(
                dto = dto.avatar,
                domain = expected.avatar,
            ),
        )

        // Act
        val result = testSubject.toDomain(dto)

        // Assert
        assertThat(result.id).isEqualTo(expected.id)
        assertThat(result.name).isEqualTo(expected.name)
        assertThat(result.color).isEqualTo(expected.color)
        assertThat(result.avatar).isEqualTo(expected.avatar)
    }

    @Test
    fun `toDto should convert AccountProfile to ProfileDto`() {
        // Arrange
        val domain = createAccountProfile()
        val expected = createProfileDto()

        val testSubject = DefaultAccountProfileDataMapper(
            avatarMapper = FakeAccountAvatarDataMapper(
                dto = expected.avatar,
                domain = domain.avatar,
            ),
        )

        // Act
        val result = testSubject.toDto(domain)

        // Assert
        assertThat(result.id).isEqualTo(expected.id)
        assertThat(result.name).isEqualTo(expected.name)
        assertThat(result.color).isEqualTo(expected.color)
        assertThat(result.avatar).isEqualTo(expected.avatar)
    }
}

Key points:

  • The test verifies that the mapper correctly converts between legacy data structures (DTOs) and domain models
  • It tests both directions of the conversion (toDomain and toDto)
  • It uses a fake implementation of a dependency (FakeAccountAvatarDataMapper) to isolate the test
  • It verifies that all properties are correctly mapped

Best Practices for Testing Legacy Module Integration

  1. Isolate the Bridge: Test the bridge implementation in isolation by using fake or mock implementations of legacy dependencies.
  2. Test Both Directions: For data conversion, test both directions (legacy to domain and domain to legacy).
  3. Cover Edge Cases: Test edge cases such as null values, empty collections, and error conditions.
  4. Use Clear Test Structure: Structure tests with clear arrange, act, and assert sections.
  5. Create Reusable Test Fixtures: Create helper methods for creating test data to make tests more readable and maintainable.
  6. Test Reactive Behavior: For reactive code (using Flows, LiveData, etc.), use appropriate testing utilities (e.g., Turbine for Flow testing).
  7. Verify Integration: In addition to unit tests, create integration tests that verify the bridge works correctly with actual legacy code.
  8. Test Migration Path: When migrating from a legacy bridge to a new implementation, test both implementations with the same test suite to ensure behavior consistency.

Migration Strategy

The long-term strategy involves gradually migrating functionality out of the legacy modules:

  1. Identify Functionality: Pinpoint specific functionalities within legacy modules that need to be modernized.
  2. Define Interfaces: Ensure clear interfaces are defined (typically in feature api modules) for this functionality.
  3. Entity Modeling: Create proper domain entity models that represent the business objects as immutable data classes.
  4. Implement in New Modules: Re-implement the functionality within new, dedicated feature impl modules or core modules.
  5. Update Bridge (Optional): If :app-common was bridging to this specific legacy code, its bridge implementation can be updated or removed.
  6. Switch DI Configuration: Update the dependency injection to provide the new modern implementation instead of the legacy bridge.
  7. Retire Legacy Code: Once no longer referenced, the corresponding legacy code can be safely removed.

Migration Example

Using the account profile example, the migration process would look like:

  1. Identify: Account profile functionality in legacy modules needs modernization.
  2. Define Interfaces:
    • AccountProfileRepository interface is defined in feature:account:api
    • AccountProfileLocalDataSource interface is defined in feature:account:core
  3. Entity Modeling: Create AccountProfile as an immutable data class in feature:account:api.
  4. Implement: Create a new implementation of AccountProfileLocalDataSource in a modern module, e.g., feature:account:impl.
  5. Update Bridge: Update or remove DefaultAccountProfileLocalDataSource in app-common.
  6. Switch DI: Update appCommonAccountModule to provide the new implementation instead of DefaultAccountProfileLocalDataSource.
  7. Retire: Once all references to legacy account code are removed, the legacy code and lower-level bridges (LegacyAccountWrapperManager, DefaultLegacyAccountWrapperManager) can be safely deleted.

This approach ensures a smooth transition with minimal disruption to the application’s functionality.

Dependency Direction

A strict dependency rule is enforced: New modules (features, core) must not directly depend on legacy modules. The dependency flow is always from newer modules to interfaces, with :app-common providing the implementation. If :app-common bridges to legacy code, that is an internal detail of :app-common.

The legacy module integration diagram below explains how legacy code is integrated into the new modular architecture:

graph TB
    subgraph APP[App Modules]
        direction TB
        APP_TB["`**:app-thunderbird**<br>Thunderbird for Android`"]
        APP_K9["`**:app-k9mail**<br>K-9 Mail`"]
    end

    subgraph COMMON[App Common Module]
        direction TB
        COMMON_APP["`**:app-common**<br>Integration Code`"]
    end

    subgraph FEATURE[Feature Modules]
        direction TB
        FEATURE1[Feature 1]
        FEATURE2[Feature 2]
        FEATURE3[Feature from Legacy]
    end

    subgraph CORE[Core Modules]
        direction TB
        CORE1[Core 1]
        CORE2[Core 2]
        CORE3[Core from Legacy]
    end

    subgraph LIBRARY[Library Modules]
        direction TB
        LIB1[Library 1]
        LIB2[Library 2]
    end

    subgraph LEGACY[Legacy Modules]
        direction TB
        LEGACY_CODE[Legacy Code]
    end

    APP_K9 --> |depends on| COMMON_APP
    APP_TB --> |depends on| COMMON_APP
    COMMON_APP --> |integrates| FEATURE1
    COMMON_APP --> |integrates| FEATURE2
    COMMON_APP --> |integrates| FEATURE3
    FEATURE1 --> |uses| CORE1
    FEATURE1 --> |uses| LIB2
    FEATURE2 --> |uses| CORE2
    FEATURE2 --> |uses| CORE3
    COMMON_APP --> |integrates| LEGACY_CODE
    LEGACY_CODE -.-> |migrate to| FEATURE3
    LEGACY_CODE -.-> |migrate to| CORE3

    classDef app fill:#d9e9ff,stroke:#000000,color:#000000
    classDef app_module fill:#4d94ff,stroke:#000000,color:#000000
    classDef common fill:#e6e6e6,stroke:#000000,color:#000000
    classDef common_module fill:#999999,stroke:#000000,color:#000000
    classDef feature fill:#d9ffd9,stroke:#000000,color:#000000
    classDef feature_module fill:#33cc33,stroke:#000000,color:#000000
    classDef core fill:#e6cce6,stroke:#000000,color:#000000
    classDef core_module fill:#cc99cc,stroke:#000000,color:#000000
    classDef library fill:#fff0d0,stroke:#000000,color:#000000
    classDef library_module fill:#ffaa33,stroke:#000000,color:#000000
    classDef legacy fill:#ffe6e6,stroke:#000000,color:#000000
    classDef legacy_module fill:#ff9999,stroke:#000000,color:#000000

    linkStyle default stroke:#999,stroke-width:2px

    class APP app
    class APP_K9,APP_TB app_module
    class COMMON common
    class COMMON_APP common_module
    class FEATURE feature
    class FEATURE1,FEATURE2,FEATURE3 feature_module
    class CORE core
    class CORE1,CORE2,CORE3 core_module
    class LIBRARY library
    class LIB1,LIB2 library_module
    class LEGACY legacy
    class LEGACY_CODE legacy_module
Last change: 2025-06-26, commit: 3b4ef25

Architecture Decision Records

The docs/architecture/adr folder contains the architecture decision records (ADRs) for our project.

ADRs are short text documents that serve as a historical context for the architecture decisions we make over the course of the project.

What is an ADR?

An Architecture Decision Record (ADR) is a document that captures an important architectural decision made along with its context and consequences. ADRs record the decision making process and allow others to understand the rationale behind decisions, providing insight and facilitating future decision-making processes.

Format of an ADR

We adhere to Michael Nygard’s ADR format proposal, where each ADR document should contain:

  1. Title: A short descriptive name for the decision.
    1. Link to Issue: A link to the issue that prompted the decision.
    2. Link to Pull Request: A link to the pull request that implements the ADR.
    3. Link to Tracking Issue: A link to the tracking issue, if applicable.
  2. Status: The current status of the decision (proposed, accepted, rejected, deprecated, superseded)
  3. Context: The context that motivates this decision.
  4. Decision: The change that we’re proposing and/or doing.
  5. Consequences: What becomes easier or more difficult to do and any risks introduced as a result of the decision.

Creating a new ADR

When creating a new ADR, please follow the provided ADR template file and ensure that your document is clear and concise.

Once you are ready to propose your ADR, you should:

  1. Create an issue in the repository, get consensus from at least one other project contributor.
  2. Make a post on the mobile-planning list to announce your ADR. You can use the below template as needed.
  3. Create a pull request in the repository linking the issue.
  4. Make a decision together with mobile module owners, the PR will be merged when accepted.

Directory Structure

The ADRs will be stored in a directory named docs/adr, and each ADR will be a file named NNNN-title-with-dashes.md where NNNN is a four-digit number that is increased by 1 for every new adr.

ADR Life Cycle

The life cycle of an ADR is as follows:

  1. Proposed: The ADR is under consideration.
  2. Accepted: The decision described in the ADR has been accepted and should be adhered to, unless it is superseded by another ADR.
  3. Rejected: The decision described in the ADR has been rejected.
  4. Deprecated: The decision described in the ADR is no longer relevant due to changes in system context.
  5. Superseded: The decision described in the ADR has been replaced by another decision.

Each ADR will have a status indicating its current life-cycle stage. An ADR can be updated over time, either to change the status or to add more information.

Contributions

We welcome contributions in the form of new ADRs or updates to existing ones. Please ensure all contributions follow the standard format and provide clear and concise information.

Appendix: Intent to Adopt Template

You may use this template in your Intent to Adopt email as noted above. Tweak it as you feel is useful.

Hello everyone,

I’m writing to share an intent to adopt a new architecture decision: [ADR-[Number]] [Title of ADR]

This change addresses [brief summary of the problem] and proposes [brief description of the approach].

This decision is based on [briefly mention motivating factors, constraints, or technical context].

You can read the full proposal here: [link to ADR]

If you have feedback or concerns, please respond in the linked issue. We plan to finalize the decision after [proposed date], factoring in discussion at that time.

Thanks, [Your Name]

Switch from Java to Kotlin

Status

  • Accepted

Context

We’ve been using Java as our primary language for Android development. While Java has served us well, it has certain limitations in terms of null safety, verbosity, functional programming, and more. Kotlin, officially supported by Google for Android development, offers solutions to many of these issues and provides more modern language features that can improve productivity, maintainability, and overall code quality.

Decision

Switch our primary programming language for Android development from Java to Kotlin. This will involve rewriting our existing Java codebase in Kotlin and writing all new code in Kotlin. To facilitate the transition, we will gradually refactor our existing Java codebase to Kotlin.

Consequences

  • Positive Consequences
    • Improved null safety, reducing potential for null pointer exceptions.
    • Increased code readability and maintainability due to less verbose syntax.
    • Availability of modern language features such as coroutines for asynchronous programming, and extension functions.
    • Officially supported by Google for Android development, ensuring future-proof development.
  • Negative Consequences
    • The process of refactoring existing Java code to Kotlin can be time-consuming.
    • Potential for introduction of new bugs during refactoring.
Last change: 2025-06-26, commit: 3b4ef25

UI - Wrap Material Components in Atomic Design System

Status

  • Accepted

Context

As we continued developing our Jetpack Compose application, we found a need to increase the consistency, reusability, and maintainability of our user interface (UI) components. We have been using Material components directly throughout our application. This lead to a lack of uniformity and increases the complexity of changes as the same modifications had to be implemented multiple times across different screens.

Decision

To address these challenges, we’ve decided to adopt an Atomic Design System as a foundation for our application UI. This system encapsulates Material components within our own components, organized into categories of atoms, molecules, and organisms. We also defined templates as layout structures that can be flexibly combined to construct pages. These components collectively form the building blocks that we are using to construct our application’s UI.

Consequences

  • Positive Consequences
    • Increased reusability of components across the application, reducing code duplication.
    • More consistent UI and uniform styling across the entire application.
    • Improved maintainability, as changes to a component only need to be made in one place.
  • Negative Consequences
    • Initial effort and time investment needed to implement the atomic design system.
    • Developers need to adapt to the new system and learn how to use it effectively.
    • Potential for over-complication if simple components are excessively broken down into atomic parts.
Last change: 2025-06-26, commit: 3b4ef25

Switch Test Assertions from Truth to assertk

Status

  • Accepted

Context

Our project has been using the Truth testing library for writing tests. While Truth has served us well, it is primarily designed for Java and lacks some features that make our Kotlin tests more idiomatic and expressive. As our codebase is primarily Kotlin, we have been looking for a testing library that is more aligned with Kotlin’s features and idioms.

Decision

We have decided to use assertk as the default assertions framework for writing tests in our project. assertk provides a fluent API that is very similar to Truth, making the transition easier. Moreover, it is designed to work well with Kotlin, enabling us to leverage Kotlin-specific features in our tests.

We’ve further committed to converting all pre-existing tests from Truth to assertk.

Consequences

Note: The migration of all Truth tests to assertk has already been completed.

  • Positive Consequences
    • Ease of Transition: The syntax of assertk is very similar to Truth, which makes the migration process smoother.
    • Kotlin-Friendly: assertk is designed specifically for Kotlin, allowing us to write more idiomatic and expressive Kotlin tests.
  • Negative Consequences
    • Dependency: While we are replacing one library with another, introducing a new library always carries the risk of bugs or future deprecation.
    • Migration Effort: Existing tests written using Truth will need to be migrated to use assertk, requiring some effort, although mitigated by the similar syntax.
Last change: 2025-06-26, commit: 3b4ef25

Naming Conventions for Interfaces and Their Implementations

Status

  • Accepted

Context

When there’s an interface that has multiple implementations it’s often easy enough to give meaningful names to both the interface and the implementations (e.g. the interface Backend with the implementations ImapBackend and Pop3Backend). Naming becomes harder when the interface mainly exists to allow having isolated unit tests and the production code contains exactly one implementation of the interface. Prior to this ADR we didn’t have any naming guidelines and the names varied widely. Often when there was only one (production) implementation, the class name used one of the prefixes Default, Real, or K9. None of these had any special meaning and it wasn’t clear which one to pick when creating a new interface/class pair.

Decision

We’ll be using the following guidelines for naming interfaces and their implementation classes:

  1. Interface Naming: Name interfaces as if they were classes, using a clear and descriptive name. Avoid using the “IInterface” pattern.
  2. Implementation Naming: Use a prefix that clearly indicates the relationship between the interface and implementation, such as DatabaseMessageStore or InMemoryMessageStore for the MessageStore interface.
  3. Descriptive Names: Use descriptive names for interfaces and implementing classes that accurately reflect their purpose and functionality.
  4. Platform-specific Implementations: Use the platform name as a prefix for interface implementations specific to that platform, e.g. AndroidPowerManager.
  5. App-specific Implementations: Use the prefix K9 for K-9 Mail and Tb for Thunderbird when app-specific implementations are needed, e.g. K9AppNameProvider and TbAppNameProvider.
  6. Flexibility: If no brief descriptive name fits and there is only one production implementation, use the prefix Default, like DefaultImapFolder.

Consequences

  • Positive Consequences
    • Improved code readability and maintainability through consistent naming.
    • Reduced confusion and misunderstandings by using clear and descriptive names.
  • Negative Consequences
    • Initial effort is required to rename existing classes that do not follow these naming conventions.
Last change: 2025-06-26, commit: 3b4ef25

Central Management of Android Project Dependencies and Gradle Configurations via Build-Plugin Module

Status

  • Accepted

Context

In our Android project, managing dependencies and configurations directly within each module’s build.gradle.kts file has historically led to inconsistencies, duplication, and difficulty in updates. This challenge was particularly noticeable when maintaining the project configuration. By centralizing this setup in a build-plugin module, we can encapsulate and reuse Gradle logic, streamline the build process, and ensure consistency across all project modules and ease maintainability of our codebase.

Decision

To address these challenges, we have decided to establish a build-plugin module within our project. This module will serve as the foundation for all common Gradle configurations, dependency management, and custom plugins, allowing for simplified configuration across various project modules and plugins. Key components of this module include:

  • Custom Plugins: A suite of custom plugins that configure Gradle for different project aspects, ensuring each project type has tailored and streamlined build processes. These plugins should cover Android application, Android library, Jetpack Compose and Java modules.
  • Dependency Management: Utilizing the Gradle Version Catalog to centrally manage and update all dependencies and plugins, ensuring that every module uses the same versions and reduces the risk of conflicts.
  • Common Configuration Settings: Establishing common configurations for Java, Kotlin, and Android to reduce the complexity and variability in setup across different modules.

Consequences

Positive Consequences

  1. Consistency Across Modules: All project modules will use the same versions of dependencies and plugins, reducing the risk of conflicts and enhancing uniformity. They will also share common configurations, ensuring consistency in the build process.
  2. Ease of Maintenance: Centralizing dependency versions in the Gradle Version Catalog allows for simple and quick updates to libraries and tools across all project modules from a single source.
  3. Simplified Configuration Process: The custom plugins within the build-plugin module provides a streamlined way to apply settings and dependencies uniformly, enhancing productivity and reducing setup complexity.

Negative Consequences

  1. Initial Overhead: The setup of the build-plugin module with a Gradle Version Catalog and the migration of existing configurations required an initial investment of time and resources, but this has been completed.
  2. Complexity for New Developers: The centralized build architecture, particularly with the use of a Gradle Version Catalog, may initially seem daunting to new team members who are unfamiliar with this level of abstraction.
  3. Dependency on the Build-Plugin Module: The entire project becomes reliant on the stability and accuracy of the build-plugin module. Errors within this module or the catalog could impact the build process across all modules.
Last change: 2025-06-26, commit: 3b4ef25

White Label Architecture

Status

  • Accepted

Context

Our project hosts two separate applications, K-9 Mail and Thunderbird for Android, which share a significant amount of functionality. Despite their common features, each app requires distinct branding elements such as app names, themes, and specific strings.

Decision

We have decided to adopt a modular white-label architecture, where each application is developed as a separate module that relies on a shared codebase. This structure allows us to streamline configuration details specific to each brand either during build or at runtime. This is how we structure the modules:

Application Modules

There will be 2 separate modules for each of the two applications: Thunderbird for Android will be located in app-thunderbird and K-9 Mail in app-k9mail. These modules will contain app-specific implementations, configurations, resources, and startup logic. They should solely depend on the app-common module for shared functionalities and may selectively integrate other modules when needed to configure app-specific functionality.

App Common Module

A central module named app-common acts as the central integration point for shared code among the applications. This module contains the core functionality, shared resources, and configurations that are common to both apps. It should be kept as lean as possible to avoid unnecessary dependencies and ensure that it remains focused on shared functionality.

Consequences

Positive Consequences

  • Enhanced maintainability due to a shared codebase for common functionalities, reducing code duplication.
  • Increased agility in developing and deploying new features across both applications, as common enhancements need to be implemented only once.

Negative Consequences

  • Potential for configuration complexities as differentiations increase between the two applications.
  • Higher initial setup time and learning curve for new developers due to the modular and decoupled architecture.
Last change: 2025-06-26, commit: 3b4ef25

Project Structure

Status

  • Accepted

Context

The project consists of two distinct applications. To improve maintainability and streamline development, we propose a modular structure using Gradle. This structure is designed to enable clear separation of concerns, facilitate scalable growth, and ensure efficient dependency management. It consists of various module types such as app, app-common, feature, core, and library modules, promoting enhanced modular reusability.

Decision

To achieve the goals outlined in the context, we have decided to adopt the following modular structure:

  1. App Modules:
    • app-thunderbird and app-k9mail are the modules for the two applications, Thunderbird for Android and K-9 Mail respectively. These modules will contain app-specific implementations, configurations, resources, and startup logic. They should solely depend on the app-common module for shared functionalities and may selectively integrate feature and core to setup app-specific needs.
  2. App Common Module:
    • app-common: Acts as the central hub for shared code between both applications. This module serves as the primary “glue” that binds various feature modules together, providing a seamless integration point. While it can depend on library modules for additional functionalities, its main purpose is to orchestrate the interactions among the feature and core modules, ensuring similar functionality across both applications. This module should be kept lean to avoid unnecessary dependencies and ensure it remains focused on shared functionality.
  3. Feature Modules:
    • feature:*: These are independent feature modules, that encapsulate distinct user-facing features. They are designed to be reusable and can be integrated into any application module as needed. They maintain dependencies on core modules and may interact with other feature or library modules.
  4. Core Module:
    • core:*: The core modules contain essential utilities and base classes used across the entire project. These modules are grouped by their functionality (e.g., networking, database management, theming, common utilities). This segmentation allows for cleaner dependency management and specialization within foundational aspects.
  5. Library Modules:
    • library:* These modules are for specific implementations that might be used across various features or applications. They could be third-party integrations or complex utilities and eventually shared across multiple projects.
graph TD
    subgraph APP[App]
        APP_K9["`
            **:app-k9mail**
            K-9 Mail
        `"]
        APP_TB["`
            **:app-thunderbird**
            Thunderbird for Android
        `"]
    end

    subgraph COMMON[App Common]
        APP_COMMON["`
            **:app-common**
            Integration Code
        `"]
    end

    subgraph FEATURE[Feature]
        FEATURE1[Feature 1]
        FEATURE2[Feature 2]
    end

    subgraph CORE[Core]
        CORE1[Core 1]
        CORE2[Core 2]
    end

    subgraph LIBRARY[Library]
        LIB1[Library 1]
        LIB2[Library 2]
    end

    APP --> |depends on| COMMON
    COMMON --> |integrates| FEATURE
    FEATURE --> |uses| CORE
    FEATURE --> |uses| LIBRARY

    classDef module fill:yellow
    classDef app fill:azure
    classDef app_common fill:#ddd
    class APP_K9 app
    class APP_TB app
    class APP_COMMON app_common

Legacy Modules

Modules that are still required for the project to function, but don’t follow the new project structure.

These modules should not be used for new development.

The goal is to migrate the functionality of these modules to the new structure over time. By placing them under the legacy module, we can easily identify and manage them.

graph TD
    subgraph APP[App]
        APP_K9["`
            **:app-k9mail**
            K-9 Mail
        `"]
        APP_TB["`
            **:app-thunderbird**
            Thunderbird for Android
        `"]
    end

    subgraph COMMON[App Common]
        APP_COMMON["`
            **:app-common**
            Integration Code
        `"]
    end

    subgraph FEATURE[Feature]
        FEATURE1[Feature 1]
        FEATURE2[Feature 2]
        FEATURE3[Feature from Legacy]
    end

    subgraph CORE[Core]
        CORE1[Core 1]
        CORE2[Core 2]
        CORE3[Core from Legacy]
    end

    subgraph LIBRARY[Library]
        LIB1[Library 1]
        LIB2[Library 2]
    end

    APP --> |depends on| COMMON
    COMMON --> |integrates| FEATURE
    FEATURE --> |uses| CORE
    FEATURE --> |uses| LIBRARY

    subgraph LEGACY[Legacy]
        LEG[Legacy Code]
    end

    COMMON -.-> |integrates| LEGACY
    LEG -.-> |migrate to| FEATURE3
    LEG -.-> |migrate to| CORE3

    classDef module fill:yellow
    classDef app fill:azure
    classDef app_common fill:#ddd
    classDef legacy fill:#F99
    class APP_K9 app
    class APP_TB app
    class APP_COMMON app_common
    class LEGACY legacy

Consequences

Positive Consequences

  • Improved modularity facilitates easier code maintenance and scaling.
  • Clear separation of concerns reduces dependencies and potential conflicts between modules.
  • Enhanced reusability of the feature, core and library modules across different parts of the application or even in different projects.

Negative Consequences

  • Initial complexity in setting up and managing multiple modules may increase the learning curve and setup time for new developers.
  • Over-modularization can lead to excessive abstraction, potentially impacting runtime performance and complicating the debugging process.
  • Legacy modules may require additional effort to migrate to the new structure, potentially causing delays in the adoption of the new architecture.
Last change: 2025-06-26, commit: 3b4ef25

Change Shared Modules package to net.thunderbird

Status

  • Accepted

Context

The Thunderbird Android project is a white-label version of K-9 Mail, and both apps — app-thunderbird and app-kmail — coexist in the same repository. They have distinct application IDs and branding, but share a significant portion of the code through common modules.

These shared modules currently use the app.k9mail or com.fsck package name, which are legacy artifacts from K-9 Mail. While K-9 will remain available for some time, the project’s primary focus has shifted toward Thunderbird.

To reflect this shift, establish clearer ownership, and prepare for future development (including cross-platform code integration), we will rename the packages in shared modules from app.k9mail and com.fsck to net.thunderbird. The actual application IDs and package names of app-thunderbird and app-k9mail must remain unchanged.

Decision

We decided to rename the base package in all shared modules from app.k9mail and com.fsck to net.thunderbird.

Specifically:

  • All Kotlin/Java packages in shared modules will be refactored to use net.thunderbird as the base
  • This must not affect the application IDs or packages of app-thunderbird or app-kmail, which will remain as-is
  • All references, imports, and configuration references will be updated accordingly
  • Tests, resources, and Gradle module settings will be adjusted to match the new package structure

This change will establish a clearer identity for the shared code, align with Thunderbird’s branding, and prepare the project for cross-platform development.

Consequences

Positive Consequences

  • Shared code reflects Thunderbird branding and identity
  • Reduces confusion when navigating codebase shared by both apps
  • Sets the foundation for cross-platform compatibility and future modularization
  • Helps reinforce long-term direction of the project toward Thunderbird

Negative Consequences

  • Large-scale refactoring required across multiple modules
  • Risk of introducing regressions during package renaming
  • Potential for disruption in local development setups (e.g., IDE caching, broken imports)
  • Contributors familiar with the old structure may need time to adjust
Last change: 2025-06-26, commit: 3b4ef25

Thunderbird for Android Release Documentation

Please see the sub-pages for release documentation

Releases

Thunderbird for Android follows a release train model to ensure timely and predictable releases. This model allows for regular feature rollouts, stability improvements, and bug fixes.

Branches in the Release Train Model

Daily

Daily builds are used for initial testing of new features and changes. Feature flags are additionally used to work on features that are not yet ready for consumption.

  • Branch: main
  • Purpose: Active development of new features and improvements
  • Release Cadence: Daily
  • Audience: Developers and highly technical users who want to test the bleeding edge of Thunderbird. Daily builds are unstable and not recommended for production use.
  • Availability: Daily builds are available on the Play Store internal channel. APKs are available on ftp.mozilla.org.

Beta

After features are stabilized in Daily, they are merged into the Beta branch for broader testing. Uplifts are limited to bug/security fixes only. The Beta branch serves as a preview of what will be included in the next stable release, allowing for user feedback and final adjustments before general availability.

  • Branch: beta
  • Purpose: Pre-release testing
  • Release Cadence: Weekly with the option to skip if not needed
  • Merge Cadence: Every 2 Months
  • Audience: Early adopters and testers. Testers are encouraged to provide error logs and help reproduce issues filed.
  • Availability: Beta builds are available from the Play Store and F-Droid.

Release

This branch represents the stable version of Thunderbird, which is released to the public. It is tested and suitable for general use. Bug fixes and minor updates are periodically applied between major releases. Uplifts to Release are limited to stability/security fixes only.

  • Branch: release
  • Purpose: Stable releases
  • Release Cadence: Major releases every 2 months. Minor releases every 2 weeks with the option to skip if not needed.
  • Merge Cadence: Every 2 months
  • Audience: General users. Users may be filing bug reports or leaving reviews to express their level of satisfaction.
  • Availability: Release builds are available from the Play Store and F-Droid.

Example Feature Release Flow

  1. A new feature is developed and merged via pull requests into the main branch.
  2. Every 2 months, main is merged into the beta branch for external testing and feedback.
  3. Every 2 months, beta is merged into the release branch, and a release is made available to all users.

Example Bug Release Flow

  1. A high-impact bug is fixed and merged via pull request into the main branch.
  2. After it has received adequate testing on daily, the fix is cherry-picked (uplifted) to the beta branch and released in the next scheduled beta.
  3. After it has received adequate testing on beta, the fix is cherry-picked (uplifted) to the release branch and released in the next stable minor release.

Sample Release Timeline

MilestoneDetailsDate
TfA 11.0a1 startsFeb 28
TfA merge 11.0a1 main->betaMay 2
TfA 11.0b1May 5
TfA 11.0bXIf neededMay 12
TfA 11.0bXIf neededMay 19
TfA 11.0bXIf neededMay 26
TfA 11.0bXIf neededJun 2
TfA 11.0bXIf neededJun 9
TfA 11.0bXIf neededJun 16
TfA 11.0bXIf neededJun 23
TfA 11.0bXIf neededJun 30
TfA merge 11.0bX beta->releaseJun 30
TfA 11.0Jul 7
TfA 11.XIf neededJul 21
TfA 11.XIf neededAug 4
TfA 11.XIf neededAug 18
TfA 11.XIf neededSep 1

Feature Flags

Thunderbird for Android uses Feature Flags to disable features not yet ready for consumption.

  • On main, feature flags are enabled as soon as developers have completed all pull requests related to the feature.
  • On beta, feature flags remain enabled unless the feature has not been fully completed and the developers would like to pause the feature.
  • On release, feature flags are disabled until an explicit decision has been made to enable the feature for all users.

Versioning System

Version Names

Thunderbird for Android stable release versions follow the X.Y format, where:

  • X (Major version): Incremented for each new release cycle.
  • Y (Patch version): Incremented when changes are added to an existing major version.

For beta builds, the suffix b1 is appended, where the number increments for each beta. For daily builds, the suffix a1 is appended, which remains constant.

Version Codes

The version code is an internal version number for Android that helps determine whether one version is more recent than another.

The version code for beta and release is an integer value that increments for each new release.

The version code for daily is calculated based on the date and has the format yDDDHHmm:

  • y: The number of years since a base year, with 2023 as the starting point (e.g., 2024 is 1)
  • DDD: The day of the year in 3 digits, zero-padded
  • HH: The hour of the day in 2 digits (00–23)
  • mm: The minute of the hour in 2 digits

For example:

  • 2024-02-09 16:451 | 040 | 16 | 4510401645
  • 2025-10-12 09:232 | 285 | 09 | 2322850923
  • 2122-02-09 16:4599 | 040 | 16 | 45990401645

Merge Days

Active development occurs on the main branch and becomes part of daily. Every 2 months:

  1. main is merged into beta, for testing.
  2. beta is merged into release, making it publicly available.

On the former, main carries over to beta, where the community can test the changes as part of “Thunderbird Beta for Testers” (net.thunderbird.android.beta) until the next merge day. On the latter, code that was in beta goes to release, where the general population receives product updates (net.thunderbird.android).

When a merge occurs, the version name is carried forward to the next branch. However, the alpha and beta suffixes are removed/reset accordingly. For example, let’s say we are shortly before the Thunderbird 9.0 release. The latest releases were Thunderbird 8.2, Thunderbird Beta 9.0b4, and Thunderbird Daily 10.0a1. Here is what happens:

  • The beta branch is merged to release. The resulting version on release changes from 8.2 to 9.0.
  • The main branch is merged to beta. The resulting version on beta changes from 9.0b4 to 10.0b1
  • The main branch version number is changed from 10.0a1 to 11.0a1

While the version name changes, it must be ensured that the version code stays the same for each branch. Our application IDs are specific to the branch they are on. For example:

  • Beta always uses net.thunderbird.android.beta as the app ID. Let’s say the version code is 20 at 9.0b4, it will be 21 at 10.0b1.
  • Likewise, when 9.0b4 becomes 9.0, if the version code on beta is 20 and on release it is 12, then 9.0 becomes 13 and not 21.

Milestones

We’re using GitHub Milestones to track work for each major release. There is only one milestone for the whole major release, so work going into 9.0 and 9.1 would both be in the “Thunderbird 9” milestone. Each milestone has the due date set to the anticipated release date.

There are exactly three open milestones at any given time, some of our automation depends on this being the case. The milestone with the date furthest into the future is the target for the main branch, the one closest is the target for the release branch. When an uplift occurs, the milestone is changed to the respective next target.

Learn more on the milestones page

Merge Process

The merge process enables various benefits, including:

  • Carrying forward main branch history to beta, and beta branch history to release.
  • No branch history is lost.
  • Git tags are retained in the git log.
  • Files/code that is unique per branch can remain that way (e.g. notes files such as changelog_master.xml, version codes).

The following steps are taken when merging main into beta:

  1. Lock the main branch with the ‘CLOSED TREE (main)’ ruleset
  2. Send a message to the #tb-mobile-dev:mozilla.org matrix channel to let them know:
  • You will be performing the merge from main into beta
  • The main branch is locked and cannot be changed during the merge
  • You will let them know when the merge is complete and main is re-opened
  1. Review merge results and ensure correctness
  2. Ensure feature flags are following the rules
  3. Push the merge
  4. Submit a pull request that increments the version in main
  5. Open a new milestone for the new version on github
  6. Once the version increment is merged into main, unlock the branch
  7. Send a message to the #tb-mobile-dev:mozilla.org channel to notify of merge completion and that main is re-opened

The following steps are taken when merging beta into release:

  1. Send a message to the #tb-mobile-dev:mozilla.org matrix channel to let them know:
  • You will be performing the merge from beta into release
  • You will let them know when the merge is complete
  1. Review merge results and ensure correctness
  2. Ensure feature flags are following the rules
  3. Push the merge
  4. Close the milestone for the version that was previously in release
  5. Send a message to the #tb-mobile-dev:mozilla.org channel to notify of merge completion

Merges are performed with the do_merge.sh script.

The following will merge main into beta: scripts/ci/merges/do_merge.sh beta

And the following will merge beta into release: scripts/ci/merges/do_merge.sh release

Be sure to review merge results and ensure correctness before pushing to the repository.

Files of particular importance are:

  • app-k9mail/build.gradle.kts
  • app-thunderbird/build.gradle.kts
  • app-k9mail/src/main/res/raw/changelog_master.xml

These build.gradle.kts files must be handled as described in “Merge Days” section above. This is part of the do_merge.sh automation. The app-k9mail/src/main/res/raw/changelog_master.xml should not include any beta notes in the release branch.

Branch Uplifts

If the urgency of a fix requires it to be included in the Beta or Release channel before the next merge, the uplift process is followed. If possible, uplifts should be avoided and patches should “ride the train” instead, following the merge day cycle.

Uplift Criteria

Beta uplifts should:

  • Be limited to bug/security fixes only (features ride the train).
  • Not change any localizable strings
  • Have tests, or a strong statement of what can be done in the absence of tests.
  • Have landed in main and stabilized on the daily channel.
  • Have a comment in the GitHub issue assessing performance impact, risk, and reasons the patch is needed on beta.

Release uplifts should additionally:

  • Be limited to stability/security fixes only (features ride the train).
  • Have landed in beta and stabilized on the beta channel.

Uplift Process

  1. The requestor adds the “task: uplift to beta” or “task: uplift to release” label to a merged pull request.
  2. The requestor makes a comment in the associated issue with the Approval Request Comment template filled out.
  3. The release driver reviews all uplift requests and, retaining the label for approved uplifts and removing the label for rejected uplifts.
  4. The release driver runs the Uplift Merges action for the specified target branch, which will remove the label, adjust the milestone, cherry-pick the commits, and push to the target branch.

Template for uplift requests:

[Approval Request Comment]
Original Issue/Pull request:
Regression caused by (issue #):
User impact if declined:
Testing completed (on daily, etc.):
Risk to taking this patch (and alternatives if risky):

Releases

Releases for both K-9 and Thunderbird for Android are automated with github actions. Daily builds are scheduled with the Daily Builds action and all builds are performed by the Shippable Build & Signing action.

For the historical manual release process, see Releasing.

Release Process

These are the general steps for a release:

  1. Perform merge or uplifts. Each release is the result of either a merge or uplift.
  2. Draft release notes at thunderbird-notes.
  3. Trigger build via the Shippable Build & Signing action.
  4. Review the build results by reviewing the action summary and the git commits resulting from the build.
    • Make sure the version code is incremented properly and not wildly off
    • Ensure the commits are correct
    • Ensure the symlink app-metadata points to the right product at this commit
  5. Test the build in the internal testing track
    • Release versions should be thoroughly tested with the test plan in Testrail
    • Beta versions only require a basic smoke test to ensure it installs
  6. Promote TfA and K-9 releases to production track in Play Store.
    • Set rollout to a low rate (generally 10-30%).
    • Betas are only released for TfA. K-9 beta users are advised to use Thunderbird.
  7. Wait for Play Store review to complete.
    • Release versions of TfA and K-9 have managed publishing enabled. Once the review has completed you need to publish the release
    • Beta versions of TfA do not have managed publishing enabled. It will be available once Google has reviewed, even on a weekend.
  8. Update F-Droid to new TfA and K-9 releases by sending a pull request to fdroiddata
  9. Send community updates to Matrix channels, and beta or planning mailing lists as needed.
  10. Approximately 24 hours after initial release to production, assess the following before updating rollout to a higher rate:
    • Crash rates, GitHub issues, install base, and reviews.
Last change: 2025-06-26, commit: 3b4ef25

Release Automation Setup

Release automation is triggered by the workflow_dispatch event on the “Shippable Build & Signing” workflow. GitHub environments are used to set configuration variables and secrets for each application and release type.

Automatic setup

There is a script available for automatic setup, which is helpful if you want to replicate this on your own repository for devlopment. Please see /scripts/ci/setup_release_automation.

You can run it using:

python -m venv venv
source venv/bin/activate
pip install requests pynacl
cd .signing
python ../scripts/ci/setup_release_automation -r yourfork/thunderbird-android

You will need the following files:

  • The signing keys with their default filenames
  • A matrix-account.json with the following keys:
{
  "homeserver": "matrix-client.matrix.org",
  "room": "room id here",
  "token": "matrix token here",
  "userMap": {
    "github_username": "@matrix_id:mozilla.org"
  }
}
  • play-store-account.json with the service account json that will do the uploads
  • thunderbird-mobile-gh-releaser-bot.clientid.txt as a simple file with the client ID of the releaser bot (you can skip this to use GitHub Actions as the user)
  • thunderbird-mobile-gh-releaser-bot.pem with the private key of the releaser bot

Build Environments

Build environments determine the configuration for the respective release channel. The following are available:

  • thunderbird_beta
  • thunderbird_daily
  • thunderbird_release

The following (non-sensitive) variables have been set:

  • RELEASE_TYPE: daily | beta | release
  • MATRIX_INCLUDES: A JSON string to determine the packages to be built

The following MATRIX_INCLUDES would build an apk and aab for Thunderbird, and an apk for K-9 Mail.

[
  { "appName": "thunderbird", "packageFormat": "apk", "packageFlavor": "foss" },
  {
    "appName": "thunderbird",
    "packageFormat": "bundle",
    "packageFlavor": "full"
  },
  { "appName": "k9mail", "packageFormat": "apk", "packageFlavor": "foss" }
]

The environments are locked to the respective branch they belong to.

Signing Environments

These environments contain the secrets for signing. Their names follow this pattern:

<appName>_<releaseType>_<packageFlavor>
thunderbird_beta_full
thunderbird_beta_foss
k9mail_beta_foss

The following secrets are needed:

  • SIGNING_KEY: The base64 encoded signing key, see https://github.com/noriban/sign-android-release for details
  • KEY_ALIAS: The alias of your signing key
  • KEY_PASSWORD: The private key password for your signing keystore
  • KEY_STORE_PASSWORD: The password to your signing keystore

The environments are locked to the respective branch they belong to.

Publishing Hold Environment

The “publish_hold” is shared by all application variants and is used by the “pre_publish” job. It has no secrets or variables, but “Required Reviewers” is set to trusted team members who oversee releases. The effect is that after package signing completes, the publishing jobs that depend on it will not run until released manually.

publish hold

Github Releases Environment

This environment will create the github release. It uses actions/create-github-app-token to upload the release with limited permissions.

  • RELEASER_APP_CLIENT_ID: Environment variable with the OAuth Client ID of the GitHub app
  • RELEASER_APP_PRIVATE_KEY: Secret with the private key of the app

The releases environment is locked to the release, beta and main branches.

If you leave out the environment, the Github Actions user will be used.

Matrix Notify Environment

This environment will notify about build updates. It requires the following keys:

  • MATRIX_NOTIFY_TOKEN: The Matrix token of the user
  • MATRIX_NOTIFY_HOMESERVER: The homeserver for the account
  • MATRIX_NOTIFY_ROOM: The room id to notify in
  • MATRIX_NOTIFY_USER_MAP: A json object that maps github usernames to matrix ids

If you leave out this environment, no notifications will be sent.

Last change: 2025-06-26, commit: 3b4ef25

Create K-9 Mail releases

This document contains the historical manual release process for K-9 Mail. Please use the automated process instead. We're keeping this around in case we need to do a manual release.

One-time setup

  1. Create a .signing folder in the root of the Git repository, if it doesn’t exist yet.
  2. Download the k9-release-signing.jks and k9.release.signing.properties files from 1Password and place them in the .signing folder.

Example <app>.<releaseType>.signing.properties file:

<app>.<releaseType>.storeFile=<path to keystore "../.signing/k9mail.jks">
<app>.<releaseType>.storePassword=<storePassword>
<app>.<releaseType>.keyAlias=<keyAlias>
<app>.<releaseType>.keyPassword=<keyPassword>
  • <app> is the short name of the app, e.g. k9
  • <releaseType> is the type of release, e.g. release

One-time setup for F-Droid builds

  1. Install fdroidserver by following the installation instructions.

    1. On MacOS, it’s best to install the latest version from source, because the version in Homebrew has some issues.
      1. Install the android command line tools if not available already.

        brew install --cask android-commandlinetools
        
      2. Install latest fdroidserver from source:

        python -m venv fdroidserver-env
        source fdroidserver-env/bin/activate
        pip install git+https://gitlab.com/fdroid/fdroidserver.git
        
      3. To use fdroidserver from the command line, you need to activate the virtual environment before each use:

        source fdroidserver-env/bin/activate
        
      4. To deactivate the virtual environment:

        deactivate
        
  2. Sign up for a Gitlab account and fork the fdroiddata repository.

  3. Clone your fork of the fdroiddata repository.

Release a beta version

  1. Update versionCode and versionName in app-k9mail/build.gradle.kts

  2. Create change log entries in

    • app-k9mail/src/main/res/raw/changelog_master.xml
    • app-metadata/com.fsck.k9/en-US/changelogs/${versionCode}.txt Use past tense. Try to keep them high level. Focus on the user (experience).
  3. Update the metadata link to point to K-9 Mail’s data: ln --symbolic --no-dereference --force app-metadata/com.fsck.k9 metadata

  4. Commit the changes. Message: “Version $versionName”

  5. Run ./gradlew clean :app-k9mail:assembleRelease --no-build-cache --no-configuration-cache

  6. Update an existing installation to make sure the app is signed with the proper key and runs on a real device.

    adb install -r app-k9mail/build/outputs/apk/release/app-k9mail-release.apk
    
  7. Tag as $versionName, e.g. 6.508

  8. Copy app-k9mail/build/outputs/apk/release/app-k9mail-release.apk as k9-${versionName}.apk to Google Drive (MZLA Team > K9 > APKs)

  9. Change versionName in app-k9mail/build.gradle.kts to next version name followed by -SNAPSHOT

  10. Commit the changes. Message: “Prepare for version $newVersionName”

  11. Update gh-pages branch with the new change log

  12. Push main branch

  13. Push tags

  14. Push gh-pages branch

Create release on GitHub

  1. Go to https://github.com/thunderbird/thunderbird-android/tags and select the appropriate tag
  2. Click “Create release from tag”
  3. Fill out the form
    • Click “Generate release notes”
    • Replace contents under “What’s changed” with change log entries
    • Add GitHub handles in parentheses to change log entries
    • If necessary, add another entry “Internal changes” (or similar) so people who contributed changes outside of the entries mentioned in the change log can be mentioned via GitHub handle.
    • Attach the APK
    • Select “Set as a pre-release”
    • Click “Publish release”

Create release on F-Droid

  1. Fetch the latest changes from the fdroiddata repository.

  2. Switch to a new branch in your copy of the fdroiddata repository.

  3. Edit metadata/com.fsck.k9.yml to create a new entry for the version you want to release. Usually it’s copy & paste of the previous entry and adjusting versionName, versionCode, and commit (use the tag name). Leave CurrentVersion and CurrentVersionCode unchanged. Those specify which version is the stable/recommended build.

    Example:

    - versionName: "${versionName}"
      versionCode: ${versionCode}
      commit: "${tagName}"
      subdir: app-k9mail
      gradle:
        - yes
      scandelete:
        - build-plugin/build
    
  4. Commit the changes. Message: “Update K-9 Mail to $newVersionName (beta)”

  5. Run fdroid build --latest com.fsck.k9 to build the project using F-Droid’s toolchain.

  6. Push the changes to your fork of the fdroiddata repository.

  7. Open a merge request on Gitlab. (The message from the server after the push in the previous step should contain a URL)

  8. Select the App update template and fill it out.

  9. Create merge request and the F-Droid team will do the rest.

Create release on Google Play

  1. Go to the Google Play Console
  2. Select the K-9 Mail app
  3. Click on Open testing in the left sidebar
  4. Click on Create new release
  5. Upload the APK to App bundles
  6. Fill out Release name (e.g. “$versionCode ($versionName)”)
  7. Fill out Release notes (copy from app-metadata/com.fsck.k9/en-US/changelogs/${versionCode}.txt)
  8. Click Next
  9. Review the release
  10. Configure a full rollout for beta versions
  11. On the Publishing overview page, click Send change for review
  12. Wait for the review to complete
  13. In case of a rejection, fix the issues and repeat the process

Release a stable version

When the team decides the main branch is stable enough and it’s time to release a new stable version, create a new maintenance branch (off main) using the desired version number with the last two digits dropped followed by -MAINT. Example: 6.8-MAINT when the first stable release is K-9 Mail 6.800.

Ideally the first stable release contains no code changes when compared to the last beta version built from main. That way the new release won’t contain any changes that weren’t exposed to user testing in a beta version before.

  1. Switch to the appropriate maintenance branch, e.g. 6.8-MAINT

  2. Update versionCode and versionName in app-k9mail/build.gradle.kts (stable releases use an even digit after the dot, e.g. 5.400, 6.603)

  3. Create change log entries in

    • app-k9mail/src/main/res/raw/changelog_master.xml
    • app-k9mail/fastlane/metadata/android/en-US/changelogs/${versionCode}.txt Use past tense. Try to keep them high level. Focus on the user (experience).
  4. Update the metadata link to point to K-9 Mail’s data: ln --symbolic --no-dereference --force app-metadata/com.fsck.k9 metadata

  5. Commit the changes. Message: “Version $versionName”

  6. Run ./gradlew clean :app-k9mail:assembleRelease --no-build-cache --no-configuration-cache

  7. Update an existing installation to make sure the app is signed with the proper key and runs on a real device.

    adb install -r app-k9mail/build/outputs/apk/release/app-k9mail-release.apk
    
  8. Tag as $versionName, e.g. 6.800

  9. Copy app-k9mail/build/outputs/apk/release/app-k9mail-release.apk as k9-${versionName}.apk to Google Drive (MZLA Team > K9 > APKs)

  10. Update gh-pages branch with the new change log. Create a new file if it’s the first stable release in a series.

  11. Push maintenance branch

  12. Push tags

  13. Push gh-pages branch

Create release on GitHub

  1. Go to https://github.com/thunderbird/thunderbird-android/tags and select the appropriate tag
  2. Click “Create release from tag”
  3. Fill out the form
    • Click “Generate release notes”
    • Replace contents under “What’s changed” with change log entries
    • Add GitHub handles in parentheses to change log entries
    • If necessary, add another entry “Internal changes” (or similar) so people who contributed changes outside of the entries mentioned in the change log can be mentioned via GitHub handle.
    • Attach the APK
    • Select “Set as the latest release”
    • Click “Publish release”

Create release on F-Droid

  1. Fetch the latest changes from the fdroiddata repository.

  2. Switch to a new branch in your copy of the fdroiddata repository.

  3. Edit metadata/com.fsck.k9.yml to create a new entry for the version you want to release. Usually it’s copy & paste of the previous entry and adjusting versionName, versionCode, and commit (use the tag name). Change CurrentVersion and CurrentVersionCode to the new values, making this the new stable/recommended build.

    Example:

    - versionName: "${versionName}"
      versionCode: ${versionCode}
      commit: "${tagName}"
      subdir: app-k9mail
      gradle:
        - yes
      scandelete:
        - build-plugin/build
    
  4. Commit the changes. Message: “Update K-9 Mail to $newVersionName”

  5. Run fdroid build --latest com.fsck.k9 to build the project using F-Droid’s toolchain.

  6. Push the changes to your fork of the fdroiddata repository.

  7. Open a merge request on Gitlab. (The message from the server after the push in the previous step should contain a URL)

  8. Select the App update template and fill it out.

  9. Create merge request and the F-Droid team will do the rest.

Create release on Google Play

  1. Go to the Google Play Console
  2. Select the K-9 Mail app
  3. Click on Production in the left sidebar
  4. Click on Create new release
  5. Upload the APK to App bundles
  6. Fill out Release name (e.g. “$versionCode ($versionName)”)
  7. Fill out Release notes (copy from app-k9mail/fastlane/metadata/android/en-US/changelogs/${versionCode}.txt)
  8. Click Next
  9. Review the release
  10. Start with a staged rollout (usually 20%)
  11. On the Publishing overview page, click Send change for review
  12. Wait for the review to complete
  13. In case of a rejection, fix the issues and repeat the process
  14. Once the review is complete, monitor the staged rollout for issues and increase the rollout percentage as necessary

Troubleshooting

F-Droid

If the app doesn’t show up in the F-Droid client:

  • Check the build cycle, maybe you just missed it and it will be available in the next cycle. (The cycle is usually every 5 days.)
  • Check F-Droid Status for any issues.
  • Check F-Droid Monitor for any errors mentioning com.fsck.k9.
Last change: 2025-06-26, commit: 3b4ef25

Managing strings

We use Android’s resource system to localize user-visible strings in our apps.

Our source language is English (American English to be more precise, but simply “English” (en) on Weblate).

Translations of source strings happen exclusively in our Weblate project. This means the source language is only modified by changes to this repository, i.e. via pull requests. Translations are only updated on Weblate and then merged into this repository by the Thunderbird team. This is to avoid overlapping changes in both repositories that will lead to merge conflicts.

Adding a string

Add a new string to the appropriate res/values/strings.xml file.

Please don’t add any translations for this new string to this repository. If you can also provide a translation for the new string, wait until the change is merged into this repository and propagated to Weblate. Then translate the new string on Weblate.

Changing a string

Changing a string should be avoided. Weblate doesn’t automatically invalidate translations when a source string is changed. This can be worked around by removing the old string and adding a new one. Make sure to only modify the source language. It’s fine for the translations to then contain unused strings. The next merge with Weblate will remove those.

Removing a string

Remove the source string from res/values/strings.xml. Don’t modify translations under res/values-<lang>/strings.xml. The next merge from Weblate will automatically get rid of the translated strings.

Changing translations in this repository

This should be avoided whenever possible, as it can create merge conflicts between Weblate and this repository. If you need to change individual strings, please translate them on Weblate instead. If a mechanical change is necessary across all languages, this should be discussed with the core team who will use this procedure:

  1. Lock all components on Weblate by clicking the “Lock” button in the repository maintenance screen.
  2. Commit all outstanding changes by clicking the “Commit” button in the same screen.
  3. Trigger creating a pull request containing translation updates from Weblate by clicking the “Push” button in the repository maintenance screen.
  4. Merge that pull request containing updates from Weblate into this repository.
  5. Create a pull request to change the translated files, following the established procedures to get it merged. Make sure you’ve rebased against the latest changes.
  6. Wait for the changes in this repository to be automatically propagated to and processed by Weblate.
  7. Unlock components on Weblate by clicking the “Unlock” button in the repository maintenance screen.

Managing translations

Right now we’re using the androidResources.localeFilters mechanism provided by the Android Gradle Plugin to limit which languages are included in builds of the app, See localFilters.

This list needs to be kept in sync with the string array supported_languages, so the in-app language picker offers exactly the languages that are included in the app.

Removing a language

  1. Remove the language code from the androidResources.localeFilters list in app-thunderbird/build.gradle.kts and app-k9mail/build.gradle.kts.
  2. Remove the entry from supported_languages in app/core/src/main/res/values/arrays_general_settings_values.xml.

Adding a language

  1. Add the language code to the androidResources.localeFilters list in app-thunderbird/build.gradle.kts and app-k9mail/build.gradle.kts.
  2. Add an entry to supported_languages in app/core/src/main/res/values/arrays_general_settings_values.xml.
  3. Make sure that language_values in app/core/src/main/res/values/arrays_general_settings_values.xml contains an entry for the language code you just added. If not:
    1. Add the language name (in its native script) to language_entries in app/ui/legacy/src/main/res/values/arrays_general_settings_strings.xml. Please note that this list should be ordered using the Unicode default collation order.
    2. Add the language code to language_values in app/core/src/main/res/values/arrays_general_settings_values.xml so that the index in the list matches that of the newly added entry in language_entries.

Adding a component on Weblate

When adding a new code module that is including translatable strings, a new components needs to be added to Weblate.

  1. Go the the Weblate page to add a component.
  2. Switch to the “From existing component” tab.
  3. Enter a name for the component.
  4. For “Component”, select “K-9 Mail/Thunderbird/ui-legacy”.
  5. Press the “Continue” button.
  6. Under “Choose translation files to import”, select “Specify configuration manually”.
  7. Press the “Continue” button.
  8. For “File format”, select “Android String Resource”.
  9. Under “File mask”, enter the path to the string resource files with a wildcard, e.g. feature/account/common/src/main/res/values-*/strings.xml.
  10. Under “Monolingual base language file”, enter the path to the string source file, e.g. feature/account/common/src/main/res/values/strings.xml.
  11. Uncheck “Edit base file”.
  12. For “Translation license”, select “Apache License 2.0”.
  13. Press the “Save” button.

Things to note

For some languages Android uses different language codes than typical translation tools, e.g. Hebrew’s code is he on Weblate, but iw on Android. When writing automation tools, there needs to be a mapping step involved.

See translation-cli for an example.

Last change: 2025-06-26, commit: 3b4ef25

How to Document

This guide provides detailed instructions for contributing to and maintaining the documentation for the Thunderbird for Android project. It explains the tools used, the structure of the documentation, and guidelines for creating and editing content.

We use mdbook to generate the documentation. The source files for the documentation are located in the docs/ directory.

Contributing

If you’d like to contribute to this project, please familiarize yourself with our Contribution Guide.

To add or modify the documentation, please edit the markdown files located in the docs/ directory using standard Markdown syntax, including GitHub flavored Markdown. You can use headers, lists, links, code blocks, and other Markdown features to structure your content.

For creating diagrams, we use the mermaid syntax. To include mermaid diagrams in your Markdown files, use the following syntax:

```mermaid
graph TD;
    A-->B;
    A-->C;
    B-->D;
    C-->D;
```

Result:

graph TD;
    A-->B;
    A-->C;
    B-->D;
    C-->D;

Adding a New Page

To add a new page, create a markdown file in the docs/ directory or within a suitable subfolder. For example:

  • To create a new top-level page: docs/NEW_PAGE.md.
  • To create a page within a subfolder: docs/subfolder/new-subpage.md.

To include the new page in the table of contents, add a link to the SUMMARY.md file pointing to newly created page.

For consistency with GitHub conventions and other mandatory files, markdown files in the top level docs/ directory shall be written in uppercase, as well the README.md file within subfolders. Further markdown files in subdirectories shall use a lowercase filename.

Organizing with Subfolders

Subfolders in the docs/ folder can be used to organize related documentation. This can be useful if related topics should be grouped together. For example, we have a subfolder named architecture/ for all documentation related to our application’s architecture.

Linking New Pages in the Summary

The SUMMARY.md file serves as the table of contents (TOC) for the documentation. To include the new page in the TOC, a link needs to be added in the SUMMARY.md file, like so:

- [Page Title](relative/path/to/file.md)

Indentation is used to create hierarchy in the TOC:

- [Page Title](relative/path/to/file.md)
  - [Subpage Title](relative/path/to/subfolder/file.md)

Assets

If you need to embed images, put them in the assets folder closest to the file they are being used in. This can either be the top-level assets folder, or a (potentially new) assets subfolder in the respective section.

Documentation Toolchain

The documentation is built using mdbook and several extensions. Follow these steps to set up the required tools.

Install mdbook and extensions

Ensure you have Cargo installed, then run:

./docs/install.sh

This script installs mdbook and the required extensions and other dependencies.

Use –force to update the dependencies, recommended when mdbook was updated:

./docs/install.sh --force

Extensions

We use the following mdbook extensions:

Building the Documentation

Once you have mdbook and its extensions installed, you can build the documentation by running this command:

mdbook build docs

The generated documentation will be available in the book/docs/latest/ folder.

To preview the documentation, run the following command:

mdbook serve docs --open

The mdbook serve docs command will serve the book at http://localhost:3000 and rebuild the documentation on changes. The --open option will open the book in your web browser and is optional.

Last change: 2025-06-26, commit: 3b4ef25