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

About Thunderbird for Android

Thunderbird for Android (TfA) is a powerful, privacy‑focused email app for managing multiple accounts with ease — including a Unified Inbox for maximum productivity. It’s open source, built with the community, and never treats your private data as a product.

This page is for both users and contributors:

  • If you want to use Thunderbird for Android, see Download.
  • If you want to contribute, jump to Contributing.

📥 Download

You can get Thunderbird for Android from multiple sources:

Check the Release Notes to see what’s new in each version.

✨ Highlights

  • Unified Inbox and multiple account support
  • Open source, privacy‑respecting by design
  • Modern, fast UI with helpful features like powerful search and rich composer
  • Actively developed with regular updates

📨 K‑9 Mail heritage

Thunderbird for Android is based on K‑9 Mail, a long‑standing open source email app. The projects share much of the same code and therefore may look and feel similar.

Some features are selectively enabled in Thunderbird when they are a better fit for Thunderbird users (e.g., import from K‑9).

If you prefer to keep using K‑9 Mail, you can find it here:

❓ Need help or have feedback?

Community chat (Matrix):

🤝 Contributing

We welcome contributions of all kinds:

Last change: , commit: 335ed15

🤝 Contributing to Thunderbird for Android

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

🌱 New Contributor Essentials

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

Note: Some support resources currently point to the K-9 Mail forum due to the project’s history and ongoing migration. Where appropriate, we’ll update links to Thunderbird-specific channels.

Helpful background (not strictly required):

🐛 Bug Reports and Feature Ideas

When you encounter a bug or have a feature request or idea, please do the following:

Bugs:

Feature Requests / Ideas:

We don’t track new ideas or feature requests in GitHub Issues.

  • Start a discussion in Mozilla Connect – Ideas
  • Once a feature is accepted and work is planned, maintainers will create the corresponding GitHub issue(s).

🌐 Translations

If you’d like to help to translate Thunderbird for Android, please visit:

🤝 Contributing Code

This should give you a detailed overview on how to contribute code to the project. Use it as a reference for setup, development, testing, and review.

🚀 Getting Started

🏗️ Development Practices

🔍 Reviews & Collaboration

🌐 Translations

  • Translations – How to help localize Thunderbird for Android via Weblate.
  • Managing Strings – Developer guide for adding/changing/removing strings, languages, and handling Weblate sync.

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: , commit: 335ed15

🚀 Development Environment

This guide will help you set up your development environment and get started with contributing to the Thunderbird for Android project.

📋 Prerequisites

Before you begin, ensure you have the following installed:

  • Java Development Kit (JDK) - Version 21 or higher (Temurin OpenJDK recommended)
  • Android Studio - Latest stable version recommended
  • Git - For version control
  • Gradle - Use the Gradle wrapper included in this repo (./gradlew); no separate install required
  • Android SDK & command-line tools – Installed and managed via Android Studio SDK Manager

🔧 Setting Up the Development Environment

1. Get the Source Code

All contributions happen through a personal fork of the repository.

  • If you haven’t forked the project yet, see the Contribution Workflow for step-by-step instructions.
  • Once you have a fork, clone it to your machine and open it in Android Studio.
  1. Go to the Thunderbird for Android repository
  2. Click the Fork button in the top-right corner
  3. Create a fork under your GitHub account

2. Import the Project into Android Studio

  1. Open Android Studio
  2. Select Open an Existing Project
  3. Navigate to the cloned repository and open it
  4. Wait for project sync and indexing

3. Configure Android Studio

For the best development experience, we recommend the following settings:

  • Recommended plugins:
    • Kotlin Multiplatform (usually bundled; if not, install from the JetBrains Marketplace)

🏗️ Building the Project

Building from Android Studio

  1. Select the app module (e.g., app-thunderbird or app-k9mail) in the Run/Debug Configuration dropdown
  2. Select build variant you want to work with (Debug/Release) from Build Variants window
  3. Click the Build button or press Ctrl+F9 (Windows/Linux) or Cmd+F9 (macOS)

Building from Command Line

A Gradle wrapper is included in the project, so you can build the project from the command line without installing Gradle globally. Run the following commands from the root of the project, where ./gradlew is the Gradle wrapper script and the command to build runs tests and other checks, while assemble only compiles the code and packages the APK.

# Build all variants
./gradlew assemble
./gradlew build

# Build debug or release variant
./gradlew assembleDebug
./gradlew assembleRelease

# Build a specific app module
./gradlew :app-thunderbird:assembleDebug
./gradlew :app-k9mail:assembleDebug

# Build a specific library/feature module
./gradlew :module-name:build

Replace module-name with the actual name of the module you want to build.

🚀 Running the Application

Running on an Emulator

  1. Set up an Android Virtual Device (AVD) in Android Studio with a recent API level with Google APIs image.
  2. Select the AVD from the device dropdown.
  3. Click the Run button or press Shift+F10 (Windows/Linux) or Ctrl+R (macOS)

Running on a Physical Device

  1. Enable Developer Options and USB Debugging on your device
  2. Connect your device to your computer via USB and confirm trust dialog if prompted
  3. Select your device from the device dropdown and click Run

🧪 Running Tests

# Run all tests across modules
./gradlew test

# Run unit tests for a specific module
./gradlew :module-name:test

# Run instrumented tests (device/emulator required)
./gradlew connectedAndroidTest

See the Testing Guide for details.

🔍 Checking Code Quality

Maintaining high code quality is essential for the long-term sustainability of the Thunderbird for Android project. The project uses several tools and practices to ensure code quality:

  • Static Analysis Tools: Android Lint, Detekt, and Spotless
  • Code Style Guidelines: Kotlin style guide and project-specific conventions
  • Testing: Unit tests, integration tests, and UI tests
  • Code Reviews: Peer review process for all code changes
  • Continuous Integration: Automated checks for build success, tests, and code quality

To run the basic code quality checks:

# Run lint checks
./gradlew lint

# Run detekt
./gradlew detekt

# Check code formatting
./gradlew spotlessCheck

# Apply code formatting fixes
./gradlew spotlessApply

See the Code Quality Guide for more details.

🐛 Debugging

Using the Debugger

  1. Set breakpoints in your code by clicking in the gutter next to the line numbers
  2. Start debugging by clicking the Debug button or pressing Shift+F9 (Windows/Linux) or Ctrl+D (macOS)
  3. Use the debugger controls to step through code, inspect variables, and evaluate expressions

See the Android Studio Debugger Guide for a detailed description.

Logging

Use the project’s core logging API net.thunderbird.core.logging.Logger, which is provided via dependency injection (Koin). Avoid logging personally identifiable information (PII).

Example with DI (Koin):

private const val TAG = "ExampleActivity"

class ExampleActivity : ComponentActivity() {
    private val logger: Logger by inject()

    fun doSomething() {
        logger.debug(tag = TAG) { "Debug message" }
        
        try {
            // Some code that might throw an exception
        } catch (exception: Exception) {
            logger.error(tag = TAG, throwable = exception) { "An error occurred" }
        }
    }
}

Profiling

Use Android Studio’s built-in Android Profiler to monitor:

  • CPU usage
  • Memory allocation
  • Network activity

For performance-sensitive code, also consider Baseline Profiles or Macrobenchmark tests.

Last change: , commit: 335ed15

🤝 Contribution Workflow

The contribution workflow for the Thunderbird for Android project explains the process of contributing code, from finding an issue to getting your pull request merged.

✅ Quick Workflow

- [ ] Find an issue (or open a bug report)
- [ ] Fork → clone → add upstream remote
- [ ] Create a descriptive branch from `main`
- [ ] Make focused changes + update docs/tests
- [ ] Run `./gradlew check` locally (matches CI)
- [ ] Commit with Conventional Commits (Fix: #123)
- [ ] Push branch to your fork
- [ ] Open a pull request with description/screenshots
- [ ] Respond to review feedback → update branch
- [ ] Once merged: delete branch, sync fork, celebrate 🎉

🔍 Finding an Issue to Work On

Exploring Issues

Before starting work, find an appropriate issue to work on:

Requesting New Features / Ideas

We don’t track new ideas or feature requests in GitHub Issues. Mozilla connect is where feature proposals, product decisions, and larger design conversations happen.

  • Start a discussion in Mozilla Connect - Ideas
  • Once a feature is accepted and work is planned, maintainers will create the corresponding GitHub issue(s).

Reporting Bugs

If you’ve found a bug that’s not yet tracked:

  • Open a new GitHub issue
  • Use the bug template and provide detailed reproduction steps.

Discussing Your Plan

Before coding:

  1. Comment on the GitHub issue you want to work on.
  2. Explain your intended approach.
  3. Wait for maintainer feedback to ensure alignment and avoid duplicate work.

🍴 Forking and Cloning

To contribute code, you’ll need to work with your own fork of the repository:

  1. Go to the Thunderbird for Android repository
  2. Click the Fork button in the top-right corner
  3. Select your GitHub account as the destination for the fork
  4. Wait for GitHub to create your fork

📥 Cloning Your Fork

After forking, clone your fork to your local machine:

# Clone your fork
git clone https://github.com/YOUR-USERNAME/thunderbird-android.git

# Navigate to the project directory
cd thunderbird-android

# Add the upstream repository as a remote to your fork
git remote add upstream https://github.com/thunderbird/thunderbird-android.git

Replace YOUR-USERNAME with your GitHub username.

🌿 Creating a Branch

Always create a new branch from the latest main:

# Ensure you're on the main branch
git checkout main

# Pull latest changes
git pull upstream main

# Create a new branch
git checkout -b fix-issue-123

Use a descriptive branch name that reflects the issue you’re addressing, such as:

  • fix-issue-123
  • add-feature-xyz
  • improve-performance-abc

💻 Making Changes

When making changes:

  1. Follow the Code Quality Guide for styling and tooling.
  2. Keep your changes focused on the specific issue.
  3. Write clear, concise, and well-documented code
  4. Document non-obvious logic and update docs if needed.
  5. Add or update tests (see Testing Guide)

✍️ Commit Best Practices

  • Write clear commit messages following the Git Commit Guide
  • Use Conventional Commits
  • Make small, focused commits that address a single concern
  • Reference the issue number in your commit message (e.g., “Fix #123: Add validation for email input”)

Example of a good commit message:

fix(email): add validation for email input

Add regex pattern for email validation.
Display error message for invalid emails.
Add unit tests for validation logic.
Fixes #123

🧪 Testing and Checks

Before submitting your changes:

  1. Run the existing tests to ensure you haven’t broken anything:

    ./gradlew test
    
  2. Write new tests for your changes:

    • Unit tests for business logic
    • Integration tests for component interactions
    • UI tests for user interface changes
  3. Ensure all tests pass:

    ./gradlew check
    
  4. Run lint checks to ensure code quality:

    ./gradlew lint
    

For more detailed information about testing, see the Testing Guide.

📤 Pushing Changes

Once your changes are ready:

# Push your branch to your fork
git push origin your-branch-name

If you rebased:

git push --force-with-lease origin your-branch-name

📬 Submitting a Pull Request

To submit your changes for review:

  1. Go to the Thunderbird for Android repository
  2. Click Pull requests -> New pull request -> compare across forks
  3. Set:
    • Base repo: thunderbird/thunderbird-android
    • Base branch: main
    • Head repo: your fork & branch
  4. Select your fork and branch as the source
  5. Click Create pull request

Pull Request Description

Write a clear and concise description for your pull request:

  1. Reference the issue number (e.g., “Fixes #123”, “Resolves #456”)
  2. Summarize the changes you made
  3. Explain your approach and any important decisions
  4. Include screenshots or videos for UI changes
  5. Mention any related issues or pull requests

Example:

## Title
fix(email): add validation for email input

## Description
Fixes #123

This PR adds email validation to the login form. It:
- Implements regex-based validation for email inputs
- Shows error messages for invalid emails
- Adds unit tests for the validation logic

## Screenshots
[Screenshot of error message]

## Testing
1. Enter an invalid email (e.g., "test@")
2. Verify that an error message appears
3. Enter a valid email
4. Verify that the error message disappears

👀 Code Review Process

After submitting your pull request:

  1. Automated checks will run to verify your changes once approved by a maintainer.
  2. Maintainers and other contributors will review your code.
  3. They may suggest changes.
  4. Respond to feedback and make necessary changes.
  5. Push additional commits to your branch as needed.
  6. Once approved, a maintainer will merge your pull request.

👉 For expectations and etiquette, see Code Review Guide.

🔄 Keeping Your Fork Updated

To keep your fork in sync with the main repository:

# Fetch changes from the upstream repository
git fetch upstream

# Checkout your local main branch
git checkout main

# Merge changes from upstream/main into your local main branch
git merge upstream/main

# Push the updated main branch to your fork
git push origin main

If you’re working on a branch and need to update it with the latest changes from main:

# Checkout your branch
git checkout your-branch-name

# Rebase your branch on the latest upstream main
git rebase upstream/main

# Force push the updated branch to your fork
git push --force-with-lease origin your-branch-name

🔁 Iterative Development

Most pull requests go through several rounds of feedback and changes:

  1. Submit initial implementation
  2. Receive feedback
  3. Make changes
  4. Request re-review
  5. Repeat until approved

📝 After Your Pull Request is Merged

After your pull request is merged:

  1. Delete your branch on GitHub.

  2. Update your local repository:

    git checkout main
    git pull upstream main
    git push origin main
    
  3. Delete your local branch:

    git branch -d your-branch-name
    
  4. Celebrate your contribution! 🎉

🚫 Common Issues and Solutions

Merge Conflicts

If your branch has conflicts with the main branch:

git fetch upstream
git checkout fix-issue-123
git rebase upstream/main
# resolve conflicts, then
git add .
git rebase --continue
git push --force-with-lease origin fix-issue-123

Failed CI Checks

If continuous integration checks fail:

  1. Check the CI logs to understand the failure.
  2. Fix the issues locally.
  3. Commit and push your changes.
  4. CI checks will automatically run again.

🙏 Contribution Etiquette

  • Be respectful and professional in all interactions.
  • Follow the Mozilla Community Participation Guidelines
  • Be patient with the review process.
  • Help review other contributors’ pull requests.
  • Ask questions if you’re unsure about something.
  • Thank others for their help and feedback.

👉 See Code review guide for more details.

Last change: , commit: 335ed15

🏗️ Development Guide

This document summarizes project-specific conventions for developers.

For full details, see:

📦 Modules

  • Follow Module Organization and Module Structure
  • Place new code in feature:*, core:*, or library:* modules
  • Do not add new code in legacy:* modules
  • Keep API/impl separation in all modules

🏛️ Architecture

  • Follow Architecture
  • Dependencies must flow in one direction only
  • UI built with Jetpack Compose + MVI pattern
  • Domain logic implemented in Use Cases
  • Data handled via Repository pattern

⚙️ Dependency Injection

  • Use Koin with constructor injection
  • Avoid static singletons and service locators

🧪 Testing

  • Follow the Testing Guide for frameworks and strategy.
  • Project conventions:
    • Name the object under test testSubject
    • Prefer fakes over mocks when possible
    • Use descriptive test names and AAA (Arrange–Act–Assert) pattern

🎨 Code Style

  • Follow the Code Quality Guide for formatting and tooling.
  • Code style quick reminders
    • Prefer immutability (val over var)
    • Use extension functions for utilities
    • Keep functions small and focused

Do’s and Don’ts

  • ✅ Write modular, testable code with clear boundaries
  • ✅ Document non-obvious logic and decisions (link ADRs when appropriate)
  • ✅ Keep module boundaries clean
  • ✅ Add or update tests for new/changed code
  • ✅ Run Spotless, Detekt, and Lint checks locally before committing
  • ❌ Don’t commit new code to legacy:* modules, unless strictly necessary
  • ❌ Don’t bypass the architecture and layering (e.g., UI calling data sources directly)
  • ❌ Don’t introduce circular dependencies between modules
Last change: , commit: 335ed15

🔍 Code Quality Guide

This document provides comprehensive guidelines for maintaining high code quality in the Thunderbird for Android project. Following these guidelines ensures that the codebase remains:

  • Maintainable: Easy to understand, modify, and extend
  • Reliable: Functions correctly and consistently
  • Efficient: Uses resources effectively
  • Secure: Protects user data and privacy
  • Testable: Is verified through automated tests

🛠️ Static Analysis Tools

We use static analysis tools to automatically detect code quality issues and enforce standards.

Android Lint

Android Lint checks for potential bugs, optimization opportunities, and Android-specific issues:

# Run lint checks for all modules
./gradlew lint

# Run lint checks for a specific module
./gradlew :module-name:lint

Common issues detected:

  • Unused resources
  • Accessibility issues
  • Performance optimizations
  • Internationalization problems
  • Security vulnerabilities

Configuration

The project’s lint configuration is in config/lint/lint.xml. You can customize lint rules for specific modules by adding a lint block to the module’s build.gradle.kts file:

android {
    lint {
        abortOnError = false
        warningsAsErrors = false
        
        // Ignore specific issues
        disable += listOf("InvalidPackage", "MissingTranslation")
        // Enable specific issues
        enable += listOf("RtlHardcoded", "RtlCompat", "RtlEnabled")
    }
}

Detekt

Detekt analyzes Kotlin code for code smells, complexity issues, and potential bugs:

# Run detekt for all modules
./gradlew detekt

# Run detekt for a specific module
./gradlew :module-name:detekt

Detekt checks for:

  • Code complexity (cyclomatic complexity, long methods, etc.)
  • Potential bugs (empty blocks, unreachable code, etc.)
  • Code style issues (naming conventions, formatting, etc.)
  • Performance issues (inefficient collection operations, etc.)

Configuration

The project’s Detekt configuration is defined in the config/detekt/detekt.yml file. This file specifies which rules to apply and their severity levels. The detekt plugin is configured in the build-plugin/src/main/kotlin/thunderbird.quality.detekt.gradle.kts file.

Spotless

Spotless ensures consistent code formatting across the codebase:

# Check if code formatting meets standards
./gradlew spotlessCheck

# Apply automatic formatting fixes
./gradlew spotlessApply

Spotless enforces:

  • Consistent indentation
  • Line endings
  • Import ordering
  • Whitespace usage

Configuration

The project’s Spotless plugin is configured in the build-plugin/src/main/kotlin/thunderbird.quality.spotless.gradle.kts file. We use ktlint for Kotlin formatting. The rules are defined in the .editorconfig file and as editorconfig overrides in the Spotless configuration.

configure<SpotlessExtension> {
    kotlin {
        target(
            "src/*/java/*.kt",
            "src/*/kotlin/*.kt",
            "src/*/java/**/*.kt",
            "src/*/kotlin/**/*.kt",
        )

        ktlint(libs.versions.ktlint.get())
            .setEditorConfigPath("${project.rootProject.projectDir}/.editorconfig")
            .editorConfigOverride(
                mapOf(
                    "ktlint_code_style" to "intellij_idea",
                    "ktlint_standard_function-signature" to "disabled",
                ),
            )
    }
}

For Markdown we use Flexmark and no further configuration is needed.

📝 Code Style Guidelines

Kotlin Style Guide

The project follows the Kotlin style guide with some project-specific adaptations:

  1. Naming Conventions:
    • Use camelCase for variables, functions, and methods
    • Use PascalCase for classes, interfaces, enums and type parameters
    • Use UPPER_SNAKE_CASE for constants and enum constants
    • Prefix interface implementations with Default or a specific name, e.g.:
    • DefaultEmailRepository implements EmailRepository
    • InMemoryCache implements Cache
  2. Formatting:
    • Use 4 spaces for indentation
    • Limit line length to 120 characters
    • Use ./gradlew spotlessApply to enforce formatting automatically
  3. Comments:
    • Use KDoc comments for public APIs
    • Include a summary, parameter descriptions, and return value description
    • Document exceptions that might be thrown
  4. File Organization:
    • One class per file (with exceptions for related small classes)
    • Package structure should reflect the module structure
    • Imports should be organized alphabetically

Kotlin Best Practices

  • Prefer val (immutable) over var when possible
  • Use null-safety (?., ?:, requireNotNull, checkNotNull)
  • Use extension functions to enhance existing classes
  • Leverage Kotlin’s functional programming features (map, filter, etc.) for cleaner code
  • Use data classes for model objects
  • Implement ‘sealed classes’ for representing finite sets of options
  • Use coroutines for asynchronous operations
  • Use flow for reactive programming

Android Best Practices

  • Follow the Android app architecture guidelines
  • Use Jetpack libraries when appropriate
  • Manage lifecycle properly (ViewModel, LifecycleOwner)
  • Handle configuration changes (rotation, locale, dark mode)
  • Optimize for different screen sizes and orientations
  • Follow Material 3 design guidelines

🔒 Security Practices

Security is critical. Always:

  • Validate all input
  • Avoid logging sensitive data
  • Use HTTPS/TLS for all network traffic
  • Store secrets securely (e.g., Android Keystore, EncryptedSharedPreferences)
  • Apply least privilege to permissions
  • Follow Android’s security best practices

🧪 Testing

Comprehensive testing is a critical aspect of code quality.

Key expectations:

  • Write tests for new/changed code
  • Use meaningful, deterministic tests
  • Follow Arrange–Act–Assert pattern
  • Maintain high coverage

👉 See Testing Guide for frameworks, patterns, and CI coverage rules.

🔄 Continuous Integration

The project uses GitHub Actions for continuous integration. Each pull request triggers automated checks for:

  • Build success
  • Test execution
  • Lint issues
  • Detekt issues
  • Spotless formatting

The CI configuration is defined in the .github/workflows directory. The main workflow file is android.yml, which defines the CI pipeline for Android builds.

👉 See Code Review Guide for PR expectations and etiquette.

Last change: , commit: 335ed15

👁️ Code Review Guide

This guide outlines best practices for creating and reviewing pull requests (PRs) in the Thunderbird for Android project. It is intended to help both authors and reviewers ensure high-quality contributions.

✅ Quick PR checklist (for authors)

Paste this into your PR to self-check:

- [ ] Focused scope (< ~800 LOC); clear description and rationale
- [ ] UI changes: screenshots/videos; accessibility (TalkBack, contrast, touch targets)
- [ ] Tests added/updated; CI green (see [Testing Guide](testing-guide.md))
- [ ] Architecture: business logic outside UI; module API/impl respected; DI via constructor/Koin
- [ ] Performance: no main-thread blocking; Compose recompositions reasonable; hot paths allocation-lean
- [ ] Security/privacy: inputs validated; no PII in logs; TLS; secure storage; permission changes documented
- [ ] i18n: No new localizable strings unless justified; translations policy followed
- [ ] Release train: feature flags set; uplift label + risk/impact (if applicable)
- [ ] Docs/CHANGELOG updated; issues linked (Fixes #123); PR title/commits clear

🧑‍💻 For Code Authors (self‑review checklist)

  1. Scope and clarity
    • Keep the PR focused on a single concern; split large or mixed changes.
    • Keep PRs small (aim for <~ 800 lines of code (LOC))
    • Provide a clear description: problem, approach, rationale, alternatives considered.
    • For UI changes: include screenshots/videos for UI changes and note any UX impacts.
    • Use Draft PRs for early feedback
  2. Tests
    • Include tests matching the change type (unit/integration/UI).
    • Use AAA pattern and use assertK. Prefer fakes over mocks.
    • Name tests descriptively; cover edge cases and error conditions.
    • See the Testing Guide for frameworks and best practises.
  3. Architecture & module boundaries
    • Follow modular rules: API vs implementation separation; no leaking implementation across module boundaries.
    • Only depend on :feature:foo:api externally; :feature:foo:impl is internal.
    • Respect MVI/Compose patterns in the UI layer; keep business logic out of UI implementation.
    • Prefer constructor injection with Koin; keep constructors simple and dependencies explicit.
  4. Code quality & style
    • Keep functions small, clear naming, avoid duplication.
    • Add KDoc for public API.
    • Run Spotless, Detekt and Lint locally.
    • Follow the Code Quality Guide
  5. Performance & threading
    • Use coroutines with appropriate dispatchers; avoid blocking the main thread.
    • Watch allocations in hot paths, avoid unnecessary recompositions in Compose.
    • For critical changes, check baseline profiling or startup metrics.
  6. Security & privacy
    • Validate inputs, avoid logging Personally Identifiable Information (PII).
    • Use TLS and safe storage APIs.
    • Review permission use and document your rationale to them in the PR description.
  7. Accessibility
    • Provide contentDescription and TalkBack support.
    • Ensure sufficient contrast, touch targets and dynamic text sizing (up to 200%).
  8. i18n
    • Follow strings policy: don’t modify translations here; avoid late string changes; see managing strings.
    • No string concatenation with localized text; use placeholders.
  9. Feature flags & release train awareness
  10. Documentation & metadata
    • Update relevant docs, CHANGELOG entries and add context as needed.
    • Link relevant issues using GitHub keywords so they auto-close on merge (Fixes #123, Resolves #456).
    • For commit format, see the Git Commit Guide
  11. CI status
    • Ensure CI is green
    • Fix issues or request re-run if failures are unrelated/flaky.

👀 For Code Reviewers (what to look for)

  1. Correctness & requirements
    • Does the change solve the stated problem? Any edge cases missed? Are invariants upheld?
  2. Architecture & boundaries
    • Adheres to module API/impl separation and project architecture (UI: Compose/MVI, Domain, Data).
    • No cross‑module leaks; dependencies flow in the right direction.
  3. Readability & maintainability
    • Code is easy to follow; good names; small functions; comments where necessary; public APIs documented.
  4. Test quality
    • Adequate tests exist and are meaningful.
    • Negative/error paths covered.
    • Tests are deterministic and prefer fakes.
  5. Performance
    • No obvious inefficiencies; avoids allocations on hot paths.
    • Background work is appropriate.
    • Compose recomposition reasonable.
  6. Security, privacy, and permissions
    • No new vulnerabilities; safe defaults; least privilege permissions.
    • Secrets not committed; logs avoid personal identifiable information.
    • Permission rationale provided if applicable.
  7. Accessibility & i18n
    • Accessible UI; strings externalized; no hard‑coded locales.
    • Respects translations policy, only english source files.
  8. Consistency & style
    • Matches existing patterns.
    • Formatting and static analysis clean (Spotless/Detekt/Lint).
  9. Release train considerations
    • Feature flags set correctly for target branch
    • Consider if an uplift is necessary.
  10. CI status
    • CI is green -> good to merge.
    • If failures are unrelated or flaky, do a re-run OR leave a note.
    • Don’t merge with failing checks!

🤝 Review etiquette

  • Be kind, specific, and actionable.
  • Prefer questions over directives. Explain trade‑offs.
  • Use severity tags if appropriate to weight your comments:
    • Nit: trivial style/readability; non-blocking.
    • Suggestion: improves design/maintainability; author’s call.
    • Blocking: must be addressed for correctness, safety, or architecture.
  • Avoid scope creep and request follow‑ups for non‑critical issues.
  • Acknowledge good practices and improvements.
  • When disagreeing, provide reasoning and seek consensus.
  • Use GitHub suggestions for trivial fixes where possible.
Last change: , commit: 335ed15

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: , commit: 335ed15

🧪 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: , commit: 335ed15

🌐 Translations

This document explains how you can help translate Thunderbird for Android into your language.

  • All translations for Thunderbird for Android are managed in Thunderbird for Android Weblate project.
  • The Source language is English (American English, represented as en).
  • Translations are done only in Weblate, not in this repository.
  • The Thunderbird team regularly syncs Weblate with the repository to pull in translation updates.

note

If you are a developer and need to add or manage strings or languages in the codebase, see managing strings.

🚀 Getting started with Weblate

Before contributing, familiarize yourself with documentation.

To start translating Thunderbird for Android:

  1. Create a Weblate account.
  2. Go to the Thunderbird for Android Weblate project.
  3. Select your language from the list of languages.
  4. Start translating strings through the Weblate web interface.

🔑 Translation Rules

  • Translate only on Weblate - never edit translation directly in Git.
  • Preserve technical placeholders and formatting:
    • Keep placeholders like %1$s or %1$d unchanged.
    • Match punctuation with the source string unless language rules require otherwise.
  • Don’t change meaning - if the English source string changes meaning, developers will create a new string key.

💡 Tips for Translators

  • Use the Comments section in Weblate to ask questions or provide context.
  • Check regularly for new strings to translate.
  • Join the Matrix channel to connect with other translators and developers.

📦 Adding or Removing Languages

Languages included in app builds are decided by the development team based on translation coverage.

  • A language must reach at least 70% coverage across all project components before being shipped.
  • If coverage falls below 60% for an extended period, the language may be removed from builds.
  • These thresholds are evaluated at each release cycle.

You can still contribute translations below the 70% threshold in Weblate. Once coverage improves, the language will be added to future builds.

✅ Becoming a Reviewer

Reviewers help maintain translation quality by approving or correcting contributions.

To propose yourself as a reviewer:

  1. Make consistent contributions for your language in Weblate.

  2. Go to the Matrix channel

  3. Post a short message like:

    Hi, I would like to become a reviewer for [Language Name].
    My Weblate username is [Your Username].  
    Here is a link to my contributions: [Your Weblate Profile or Component Link].
    
  4. The Thunderbird team will review your request and grant reviewer rights for that language if appropriate.

🌍 Requesting a New Language

If your language is not available in Weblate yet, you can request it be added.

To propose a new language:

  1. Go to the Matrix channel

  2. Post a short message like:

    Hi, I would like to request adding a new language to Thunderbird for Android.  
    Language: [Language Name]  
    Code: [e.g., fr, pt_BR]
    Any special notes: [Optional]
    
  3. A team member will create the language in Weblate and confirm in the channel.

  4. Once it’s available, you can start translating immediately.

Inclusion into the app follows our translation coverage policy, see Adding or Removing Languages.

🙏 Thank You!

Every translation improves Thunderbird for Android for users worldwide. We greatly appreciate your help in making the app accessible in more languages!

Last change: , commit: 335ed15

📝 Managing Strings & Languages

This document explains how developers manage our english source strings and add/remove languages in the Thunderbird for Android project.

note

Translators: If you want to contribute translations, see Translations. This document is developer-focused.

📖 Approach

  • We use Android’s resource system for localizing strings and Compose Multiplatform Resources for localizing strings in common code (Kotlin Multiplatform).
  • Source language is English (American English, represented as en).
  • Source strings are modified only in this repository (via pull requests).
  • Translations are managed exclusively in Weblate and merged into the repository by the Thunderbird team.
  • Languages are added/removed when they reach 70% translation or fall below 60%.

🔄 Changing Source Strings

Source strings are always stored in res/values/strings.xml or (English, en).

They must be managed carefully to avoid breaking existing translations.

  • Do not edit translation files directly in Git. Translations should always be updated in Weblate.

🔧 Mechanical/Global Changes

If a mechanical or global change to translations is required (for example, renaming placeholders or fixing formatting across all languages):

  1. Lock components in Weblate (maintenance page).
  2. Commit all outstanding changes.
  3. Push Weblate changes (creates a PR).
  4. Merge the Weblate PR.
  5. Apply your mechanical change in a separate PR.
  6. Wait for Weblate sync to propagate your merged PR.
  7. Unlock components in Weblate.

This ensures translators do not work on outdated strings and avoids merge conflicts.

➕ Adding a String

  1. Add the new string in the appropriate res/values/strings.xml file.
  2. Do not add translations.
    • After merge, Weblate will pull the new string.
    • Translators can then add translations in Weblate.

✏️ Changing a String

There are two kinds of changes to source strings:

🔤 Typos or Grammar Fixes

Correcting minor errors (spelling, capitalization, punctuation, grammar) in the English source is allowed:

  • Keep the same key — translations will remain valid.

Example:

  • Changing “Recieve” to “Receive” or “email” to “Email”.

🧭 Changing Meaning

caution

Never reuse an existing key for a changed meaning — this would cause translators’ work to become misleading or incorrect.

If the meaning of the string changes (new wording, different context, updated functionality):

  1. Add a new key with the new string.
  2. Update all references in the source code to use the new key.
  3. Delete the old key from res/values/strings.xml.
  4. Delete the old key’s translations from all res/values-*/strings.xml files.
  5. Build the project to ensure there are no references to the old key remaining.

This ensures there are no stale or misleading translations left behind.

Example:

  • Old: “Check mail now” (action_check_mail)
  • New: “Sync mail” (action_sync_mail)

Steps:

  • Add new key action_sync_mail with value “Sync mail” to res/values/strings.xml.
  • Update all code references from R.string.action_check_mail to R.string.action_sync_mail.
  • Remove action_check_mail from res/values/strings.xml and all res/values-*/strings.xml
  • Build the project to ensure no references to action_check_mail remain.
  • After the next sync, Weblate will prompt translators to provide translations for action_sync_mail.

❌ Removing a String

  1. Delete the key from res/values/strings.xml.
  2. Delete the key’s translations from all res/values-*/strings.xml files.
  3. Build the project to ensure there are no references to the removed key remaining.

🔀 Merging Weblate PRs

When merging Weblate-generated PRs:

  • Check plural forms for cs, lt, sk locales. Weblate does not handle these correctly (issue).
  • Ensure both many and other forms are present.
    • If unsure, reusing values from many or other is acceptable.

🌍 Managing Languages

We use Gradle’s androidResources.localeFilters to control which languages are bundled.

This must stay in sync with the string array supported_languages so the in-app picker shows only available locales.

🔎 Checking Translation Coverage

Before adding a language, we require that it is at least 70% translated in Weblate.

We provide a Translation CLI script to check translation coverage:

./scripts/translation --token <weblate-token>

# Specify the low 60% threshold
./scripts/translation --token <weblate-token> --threshold 60
  • Requires a Weblate API token
  • Default threshold is 70% (can be changed with --threshold <N>)

For example code integration, run with –print-all:

./scripts/translation --token <weblate-token> --print-all

This output can be used to update:

  • resourceConfigurations in app-k9mail/build.gradle.kts and app-thunderbird/build.gradle.kts
  • supported_languages in legacy/core/src/res/values/arrays_general_settings_values.xml

➖ Removing a Language

  1. Remove language code from androidResources.localeFilters in:
    • app-thunderbird/build.gradle.kts
    • app-k9mail/build.gradle.kts
  2. Remove entry from supported_languages in:
    • app/core/src/main/res/values/arrays_general_settings_values.xml

➕ Adding a Language

  1. Add the code to androidResources.localeFilters in both app modules.
  2. Add entry to supported_languages in:
    • app/core/src/main/res/values/arrays_general_settings_values.xml
  3. Add corresponding display name in:
    • app/ui/legacy/src/main/res/values/arrays_general_settings_strings.xml (sorted by Unicode default collation order).
  4. Ensure indexes match between language_entries and language_values.

important

The order of entries in language_entries and language_values must match exactly. Incorrect ordering will cause mismatches in the language picker.

🧩 Adding a Component to Weblate

When a new module contains translatable strings, a new Weblate component must be created.

Steps:

  1. Go to Add Component.
  2. Choose From existing component.
  3. Name your component.
  4. For Component, select Thunderbird for Android / K-9 Mail/ui-legacy.
  5. Continue → Select Specify configuration manually.
  6. Set file format to Android String Resource.
  7. File mask: path/to/module/src/main/res/values-*/strings.xml
  8. Base file: path/to/module/src/main/res/values/strings.xml
  9. Uncheck Edit base file.
  10. License: Apache License 2.0.
  11. Save.

⚠️ Language Code Differences

Android sometimes uses codes that differ from Weblate (e.g. Hebrew = iw in Android but he in Weblate).

Automation tools must map between systems. See LanguageCodeLoader.kt for an example.

You could find a more complete list of differences in the Android documentation and Unicode and internationalization support

Last change: , commit: 335ed15

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: , commit: 335ed15

🏗️ 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.

Last change: , commit: 335ed15

📦 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: , commit: 335ed15

📦 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: , commit: 335ed15

📦 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: , commit: 335ed15

🎨 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: , commit: 335ed15

🎭 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: , commit: 335ed15

🎨 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: , commit: 335ed15

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.

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: , commit: 335ed15

🔙 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: , commit: 335ed15

Architecture Decision Records

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]

Last change: , commit: 335ed15

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: , commit: 335ed15

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: , commit: 335ed15

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: , commit: 335ed15

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: , commit: 335ed15

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: , commit: 335ed15

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: , commit: 335ed15

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: , commit: 335ed15

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: , commit: 335ed15

Installing ADB (Android Debug Bridge)

Android Debug Bridge (adb) is a versatile command-line tool that lets you communicate with an Android-powered device connected to a computer via USB. One of its useful features is the ability to capture a debug log (logcat).

See: Collect and Share Debug Logs → Step 3.

Download and Install

You don’t need the full Android SDK to use adb. Instead, download the smaller Android Platform Tools which include adb and other essential utilities.

  1. Go to the Android Platform Tools download page.
  2. Download the package for your operating system (Windows, macOS, or Linux).
  3. Extract the contents of the downloaded archive to a location on your computer.
  4. Run adb directly from that folder.

If you plan to use adb often, add the platform-tools folder to your system PATH so you can run adb from any terminal or command prompt.

Enable Debugging on Your Device

Before adb can communicate with your Android device, you must enable USB debugging:

  1. On your device, go to SettingsAbout phone.
  2. Tap Build number seven times to enable Developer options. You may need to enter your device PIN or password to confirm.
  3. Go back to Settings and select System (or directly Developer options on some devices).
  4. Enable USB debugging.

For more detailed instructions, refer to the official guide.

Connect Your Device

  1. Connect your Android device to your computer via USB.
  2. The first time you connect, unlock your device and accept the RSA key prompt to authorize the connection.
  3. You may choose to always allow connections from this computer.

Windows

You may need to install a USB driver for your device if it’s not recognized, see the OEM USB Drivers page for instructions.

Linux

You may need to add a udev rules file. See here for details.

Verify Connection

Run the following from inside the platform-tools folder (or anywhere, if adb is in your PATH):

adb devices

If connected, you should see your device listed under List of devices attached.

  • If the device is listed as unauthorized, check your device for the RSA prompt and accept it.
  • If the device is listed as offline:
  1. Try killing the adb server with adb kill-server
  2. Disable and enable USB debugging on the device.
  3. Start the adb server again with adb start-serverd
  4. Reconnect the device and check adb devices again.

When Finished

When you’re done using adb, you can simply disconnect your device. If you want to stop the adb server, run:

adb kill-server
Last change: , commit: 335ed15

Collecting Debug Logs

Thunderbird for Android can produce debug logs to help diagnose problems and errors. This guide explains how to enable logging, reproduce the issue, collect logs, and share them with the team.

Before you start

  • If the app is crashing on startup, jump directly to Method B: Using a PC with ADB.
  • Logs may include sensitive information (e.g., email addresses, server hostnames). You should redact passwords.
  • When possible, share the complete log to maximize debugging value.

Step 1: Enable debug logging

  1. Open Thunderbird for Android.
  2. Go to: SettingsGeneral settingsDebugging.
  3. Check: Enable debug logging.

If Thunderbird crashes before you can reach Settings, jump directly to Method B: Using a PC with ADB.

Step 2: Reproduce the problem

Perform the actions that lead to the error or crash. This ensures the relevant events are captured in the log.

Take note of:

  • The steps you performed (e.g. “Opened folder → tapped compose → app crashed”).
  • The exact time the issue occurred (to help locate it in the log).

This information makes it easier to match the log entries to the problem when reviewing your report.

Step 3: Collect the debug log

Choose one of the following methods:

Method A: From within the app

Use this if the app is not crashing during startup.

  • Go to: SettingsGeneral settingsDebugging.
  • Tap the menu and select: Export logs.
  • Choose a location to save the log file.

Method B: Using a PC with ADB

Use this if the app crashes on startup or you prefer collecting logs via a computer.

Requirements:

Steps:

  1. Connect your device to the computer and verify ADB sees it:

    adb devices
    

    You should see your device listed. If not, ensure drivers are installed (Windows) and USB debugging is enabled.

  2. Find Thunderbird’s process ID (PID):

    • On Linux / macOS:

      adb shell ps | grep net.thunderbird.android
      # For K-9 Mail:
      adb shell ps | grep com.fsck.k9
      
    • On Windows (Command Prompt):

      adb shell ps -A | findstr net.thunderbird.android
      # For K-9 Mail:
      adb shell ps -A | findstr com.fsck.k9
      

    Example output:

    u0_a153       5191   587 4468612 112380 SyS_epoll_wait      0 S net.thunderbird.android
    

    In this example, the PID is 5191.

  3. Capture the debug log to a file:

    adb logcat -d --pid=<PID> > thunderbird-log.txt
    

    Replace <PID> (including brackets) with the actual number.

    • If you see an ADB error like > was unexpected at this time., it usually means you forgot to replace <PID> with the actual number. To capture ongoing logs while reproducing the issue:
    adb logcat --pid=<PID> > thunderbird-log.txt
    

    Stop the command with Ctrl+C (Windows/Linux) or Command+C (macOS).

Tips:

  • If the app restarts and gets a new PID, repeat step 2 to obtain the current PID and run the capture command again.
  • If pgrep is unavailable, see Troubleshooting ADB

Step 4: Check Logs for Sensitive Information

Debug logs may include details about your account or device. While most of this is safe to share, here are some things you may want to remove before attaching logs:

  • Passwords: Look for lines with AUTH, LOGIN, or PASSWORD. Replace with password with redacted-password.
  • Personal Identifiable Information (PII): Email addresses, phone numbers, or real names. Consider replacing with placeholders like redacted-pii.
  • Server Hostnames/IPs: If you’re concerned about privacy, replace with redacted-mail-server.
  • OAuth Tokens: Look for lines containing oauth= or token=. Replace with redacted-oauth-token.

How to Quickly Search Logs

  • On Windows: Open the file in Notepad or another text editor and use Ctrl+F to search.

  • On macOS/Linux: Use grep in the terminal. For example, to find passwords:

    grep -iE 'auth|login|password' thunderbird-log.txt
    

Step 5: Report the Issue and Attach Logs

  1. Create a new issue in our bug tracker
  2. Include the following:
    • Thunderbird for Android version number (see Find out version number).
    • A clear description of the problem and ideally steps to reproduce it.
    • The collected log file as an attachment.
    • Any relevant screenshots or screen recordings.

Troubleshooting ADB

If adb devices shows unauthorized, accept the RSA key prompt on your device. If it doesn’t appear:

adb kill-server
adb start-server

On Windows, install OEM USB drivers if your device isn’t detected.

Last change: , commit: 335ed15

Find Your App Version

For bug reports it’s important for the developers to know which version of Thunderbird you’re using. This is especially true if you attach debug logs, since they show exactly which part of the code was active when the error occurred. Because the apps are updated frequently, version numbers (and code line references) can change quickly.

Follow the instructions below to find the exact version number you’re running.

Methods

There are several ways to check the version number: inside the app, through Android’s system settings, or with ADB.

In the App (About screen)

  1. Start Thunderbird.
  2. Go to the Settings.
  3. Select About.
  4. The version string is shown under Version.

From Android: App Info

If the app won’t start, Android usually displays the version number at the bottom of its App info screen.

  1. Open the system Settings app.
  2. Navigate to Apps (or Apps & notifications).
  3. Find and select Thunderbird from the list of installed apps.
  4. Scroll down to the bottom of the screen to find the version number.

Alternatively, you can long-press the Thunderbird icon in your app drawer or home screen, then tap the “App info” (i) icon that appears.

Using ADB (Advanced)

If you have ADB set up, you can retrieve the version number via the command line:

adb shell dumpsys package net.thunderbird.android | grep versionName
adb shell dumpsys package net.thunderbird.android.beta | grep versionCode
adb shell dumpsys package net.thunderbird.android.daily | grep versionCode

# For K-9 Mail:
adb shell dumpsys package com.fsck.k9 | grep versionName

This will return a line like:

versionName=12.1

Special Note for Custom Builds

If you’ve built your own version of the app from the source repository, please include the commit hash in your bug report along with the version number.

Last change: , commit: 335ed15

Thunderbird for Android Release Documentation

Please see the sub-pages for release documentation

Last change: , commit: 335ed15

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 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 4 weeks
  • 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. It is tested and suitable for general use. Uplifts to Release are limited to stability/security fixes only.

  • Branch: release
  • Purpose: Stable releases
  • Release Cadence: Major releases every 4 weeks. Minor release 2 weeks after a major release with the option to skip if not needed.
  • Merge Cadence: Every 4 weeks
  • 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.

Sample Release Timeline

MilestoneDetailsDate
TfA 14.0a1 startsAug 28
TfA 12.0Sep 1
TfA 13.0b1Sep 1
TfA 13.0bXIf neededSep 8
TfA 12.1If neededSep 15
TfA 13.0bXIf neededSep 15
TfA 14.0a1 soft freeze startsSep 18
TfA 13.0bXIf neededSep 22
TfA merge 13.0 beta->releaseSep 22
TfA merge 14.0 main->betaSep 25
TfA 15.0a1 startsSep 25
TfA 13.0Sep 29
TfA 14.0b1Sep 29

Soft Freeze

A week long soft freeze occurs for the main branch prior to merging into the beta branch. During this time:

  • Risky code should not land
  • Disabled feature flags should not be enabled

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.

Uplifts

Uplifts should be avoided if possible and fixes should ride the train. There are cases, however, where a bug is severe enough to warrant an uplift. If the urgency of a fix requires it to uplifted to the Beta or Release channel before the next merge, the uplift process must be followed.

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 the reasons the patch is needed and risks involved in taking the patch.

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.

Examples: Fixes for security vulnerabilies, dataloss, or a crash that affects a large number of users.

Uplift Process

  1. The requestor creates a pull request against the target uplift branch.
  2. The requestor adds a comment to the pull request with the Approval Request template filled out.
  3. The release driver reviews the uplift request, merging if approved, or closing with a comment if rejected.

Template for uplift requests:

[Approval Request]
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):

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 the daily build. Every 4 weeks:

  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, and the alpha/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.1, 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.1 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 remains on the same sequence for each branch. For example:

  • If the version code on the beta branch is 20 at 9.0b4, it will be 21 at 10.0b1.
  • If the version code on the release branch is 12 at 8.1, it will be 13 at 9.0.

Our application IDs are specific to the branch they are on. For example:

  • Beta always uses net.thunderbird.android.beta as the app ID for TfA.
  • Release always uses net.thunderbird.android as the app ID for TfA.
  • Release always uses com.fsck.k9 as the app ID for K-9.

Milestones

We use 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.

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: , commit: 335ed15

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: , commit: 335ed15

Developer Release Checklist

This checklist is for developers. It summarizes what you (as a contributor/feature owner) should do before the scheduled merges in our release train:

  • main → beta
  • beta → release

For the full release-driver process (branch locks, announcements, publishing), see Release → Release Process.

Ongoing (between merges)

Do these as part of regular development:

  • Identify potential uplifts early
    • Add risk and user impact notes to the issue/PR; ensure the fix lands on main and bakes on Daily first — see Uplifts and Uplift Criteria
  • Strings and translations
    • Avoid late string changes; if unavoidable, keep them small, so translators can catch up
    • Prefer not changing localizable strings for uplifts
    • Follow Changing translations in this repository only when truly necessary
  • Quality signals you own
    • Watch crash/ANR reports and GitHub issues for your area of work and investigate regressions
  • Project management
    • Keep your issues in the project up to date (assignees, labels, status) and link PRs to issues
    • Ensure your issues are added to the project sprint board and assigned to the current sprint
    • Review the sprint board regularly and pick up backlog items as capacity allows, especially bugs and regressions
    • When reviewing external contributions:
      • Add the issue to the appropriate parent issue if not done already (e.g. [EPIC] Mobile Foundations QX 20XX)
      • Add the issue to the project sprint board and assign it to the current sprint

Before main → beta (developer responsibilities)

note

A one-week Soft Freeze occurs before merging main into beta.

During soft freeze:

  • Avoid landing risky code changes
  • Do not enable feature flags that are currently disabled

Goal: Changes on main are safe to expose to a broader audience.

  • Feature flags
    • Ensure flags match the rules for beta
      • New features are disabled by default unless explicitly approved for beta
      • Not-ready features must be disabled
    • Prepare and merge a PR on main with necessary flag changes (before merge day)
  • Translations
    • Ensure translation updates needed for your features are merged to main
    • If no Weblate PR is pending, trigger one and help review it (fix conflicts if needed)

Before beta → release (developer responsibilities)

Goal: Changes on beta are safe for general availability.

  • Feature flags
    • Verify flags align with rules for release
      • Features are disabled unless explicitly approved for release
      • Not-ready features remain disabled
    • If changes are required, open a PR on main and request uplift to beta following the criteria in Uplift Criteria
  • Translations
    • No new string changes at this stage; confirm your changes don’t introduce them
  • Stability checks you can influence
    • Review crash/ANR reports and GitHub issues for changes affecting beta and release
    • Investigate regressions and propose fixes if needed
    • Ensure your changes have been tested on beta and address any issues found

Optional: PR checklist snippet

Paste the following snippet into your PR description to help reviewers and release drivers verify readiness for merge:

- [ ] Feature flags set according to target branch rules ([beta](../ci/RELEASE.md#feature-flags) / [release](../ci/RELEASE.md#feature-flags))
- [ ] Tests added/updated; CI green on affected modules
- [ ] No new localizable strings (or justified and coordinated)
- [ ] Translations accounted for (Weblate PR merged or not required)
- [ ] Uplift label and risk/impact notes added if proposing uplift ([criteria](../ci/RELEASE.md#uplift-criteria))

After merges (what developers should verify)

  • Validate outcome for your changes
    • Watch crash/ANR and error reports for regressions related to your changes after rollout
    • Be prepared to propose/prepare a hotfix via the uplift process if necessary

note

Merge-day coordination (branch locks, Matrix announcements, running scripts) is handled by release drivers. See Merge Process for details.

Last change: , commit: 335ed15

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: , commit: 335ed15

📖 Threat Modeling Guide

This guide walks you through how to apply the STRIDE methodology for identifying and addressing security risks in Thunderbird’s mobile clients. Each step includes examples specific to Android/iOS email handling. Use it during design, feature work, and release planning. Keep sessions short and focused.

note

Start small. Pick a single feature or flow. The goal is to surface and mitigate risks early, not to produce perfect documentation.

STRIDE Quick Reference

We’ll use the STRIDE framework to help identify common types of threats:

  • Spoofing: Impersonating someone or something else (e.g., faking identity, app, servers).
  • Tampering: Unauthorized modification of data, code or configuration (e.g., altering a file, changing network packets).
  • Repudiation: Denying actions that occurred without reliable attribution/logging
  • Information Disclosure: Revealing sensitive data to unauthorized individuals (e.g., leaking PII, credentials).
  • Denial of Service: Preventing legitimate users from accessing a service or resource (e.g., crashing a server, exhausting resources).
  • Elevation of Privilege: Gaining unauthorized access to higher-level permissions (e.g., a regular user becoming an administrator).

How to use this guide

Below is a simple, repeatable workflow. Each section provides prompts and examples. Capture notes in your feature’s issue/PR or a short doc.


Threat Modeling Workflow

1. Project Overview

What to do:

Write down what Thunderbird Mobile is, what it does, and its major features. This frames the context of your threat model.

Example:

  • Project Name: Thunderbird for Android
  • Description: A mobile email client providing access to IMAP/POP3/SMTP accounts with support for OAuth, local message storage, PGP/SMIME, and notifications.
  • Key Features: Account setup, secure login, message sync, local storage, notifications, attachments, encryption support.

2. Define Scope

What to do:

Pick one area to model at a time. Define clear boundaries. Don’t try to cover the entire app in one go.

Examples:

  • Account authentication and credential storage
  • OAuth2 login for Gmail/Outlook providers
  • Message synchronization with mail servers: connection management, IDLE, message fetch
  • Local message storage and full‑text index
  • Attachment handling: view/save/share flow
  • Background services and notifications (Android)
  • OpenPGP signing/encryption/decryption (Android)

3. Draw a System Diagram

What to do:

Draw a simple diagram (use Mermaid) of the project within your chosen scope. Identify:

  • Key Components: UI, storage, crypto, background jobs, network services
  • Data Flows: How information moves between components.
  • Trust Boundaries: Where different levels of trust exist (e.g., separating user input from backend processing, public network vs. internal network).
  • Actors: User, external systems, third-party services, Attackers
  • Data Stores: Local database (messages, headers, metadata), file storage (attachments, cache), Android Keystore (tokens/keys), logs/metrics (if enabled), cryptographic keyrings

Example (Account Authentication):

  • Actors: User, Thunderbird app, IMAP/SMTP server, OAuth provider, Attacker, Push providers, OS keychain, other apps via Intents/Shares
  • Data Flows:
    • User enters credentials or OAuth flow
    • App exchanges tokens with server
    • Tokens stored locally in Keystore/Secure Enclave
  • Trust Boundaries:
    • User input (untrusted) → App UI
    • App → Mail server (TLS required)
    • App → Local storage (protected by OS sandbox & keystore)

Use this Mermaid diagram as a starting point; copy it into your notes and adjust for your scope.

flowchart LR
   subgraph Interaction 
       USER[End User]
       ATTACKER[Attacker: Malicious App, Network, User]
   end 
    
  subgraph "Mobile Client"
     APP[Mobile App]
     CRYPTO[Crypto: OpenPGP/S-MIME]
     STORAGE[Local Storage: SQLite, Files, KeyStore]
     BACKGROUND[Background Services: Sync, Notifications]
     OAUTH[OAuth via Browser/Custom Tabs]
  end

  subgraph "External Services"
     OAUTH_PROVIDER[(OAuth2 Providers: Google/Microsoft/etc)]
     PUSH[(Push: FCM, APNs)]
     MAIL[(IMAP/POP3 Servers)]
     SMTP[(SMTP Servers)]
     AUTO_CONFIG[(Autoconfig/Autodiscover)]
  end

  USER -->|Uses app| APP
  ATTACKER -.->|Phishing, MITM, Malicious app| APP
  APP <--> STORAGE
  APP <--> CRYPTO
  APP <--> BACKGROUND
  APP -->|OAuth browser| OAUTH --> OAUTH_PROVIDER --> APP
  BACKGROUND <--> PUSH
  BACKGROUND <--> MAIL
  BACKGROUND <--> SMTP
  APP <--> AUTO_CONFIG

  classDef external fill:#fff3,stroke:#f66,stroke-width:2px;
  class MAIL,AUTO_CONFIG,OAUTH_PROVIDER,PUSH,SMTP external;

4. Identify Assets

What to do:

List what you want to protect for your scope.

  • Data: Credentials, tokens, email content, attachments, keys, contacts
  • Functionality: Ability to send/receive email, sync, notifications
  • Reputation: User trust, brand image, compliance with store policies
  • Availability: Access to email, notifications working, sync reliability

5. Identify Threats (Using STRIDE)

What to do:

For each component, data flow, and interaction identified in your diagram, ask STRIDE questions:

Example:

Component / Data Flow / InteractionSpoofingTamperingRepudiationInformation DisclosureDoSElevation of Privilege
OAuth2 Login (App ↔ OAuth Provider)Fake OAuth page via phishing/intent hijackIntercept/alter redirect URIUser denies consent laterTokens/PII leaked in logs/URLsProvider rate limitsApp requests excessive scopes
Password Auth (App ↔ Mail/SMTP)MITM captures creds / server spoofModify TLS sessionDeny failed loginsCredentials in memory/backupsBrute-force lockoutsApp uses elevated IMAP/SMTP roles
IMAP/SMTP TLSSpoof certs without pinningDowngrade protocol/cipherMetadata leaks in trafficConnection exhaustionProtocol quirks abused
Local StorageMalicious app pretends to be ThunderbirdDB/file tampering on rooted deviceUser denies local actionLeaks via backups/cacheStorage exhaustionSandbox escape
Keystore / KeychainFake app tries to use aliasKey misuseExtraction on rooted/jailbroken devicesKeystore unavailableHardware bypass
OpenPGP / S-MIMEKey ownership spoofingKeyring/signature tamperingDispute sent signaturesDecrypted data in RAM/tmpExpensive crypto stalls UIAccess other apps’ keys
Attachments & SharingMalicious target app interceptsTemp file tamperingWorld-readable URIsHuge file upload stallsExcess file access
NotificationsFake notification spoofAlter PendingIntentsSensitive lockscreen previewsSpam stormsPrivileged actions triggered
Background SyncFake wakeups/intentsJob params alteredLogs leak PIIEndless sync drains batteryPrivileged execution
AutodiscoveryMalicious config hostAlter SRV/HTTP responseDomain/email leaked to third partiesSlow setup blocksElevated server access
Inter-app IntentsOther app claims TB filtersExtras modifiedData leaked to unintended receiversIntent floodsBypass auth gates
Logging / TelemetryLog modificationUser denies actionsPII in logsDisk filledLogs help attacker
iOS Keychain & APIsPhishing ASWebAuthSessionKeychain tamperKeys/files leak to iCloudBackground fetch abusedApp Group misuse

6. Add Mitigations

What to do:

For each threat, describe what you’ll do about it.

Example:

Threat IdentifiedProposed Mitigation
OAuth redirect interception / fake OAuth pageUse system browser (Custom Tabs / ASWebAuthSession); claimed HTTPS redirect URIs; verify state/nonce; PKCE; minimize scopes; store tokens in Keystore/Secure Enclave; rotate refresh tokens
Redirect URI tamperingEnforce exact redirect match; strict validation
Excessive OAuth scopesScope minimization; review/limit permissions
MITM during password authenticationTLS 1.2+ (prefer 1.3); strict hostname verification; consider pinning for known providers; prefer implicit TLS over STARTTLS
Credential leakage (memory, logs, backups)Encrypt at rest with Keystore-derived keys; zeroize memory; redact logs; protect screenshots; mark sensitive dirs as no-backup
IMAP/SMTP STARTTLS downgradePrefer implicit TLS; reject weak suites; enforce hostname checks
Local DB tamperingEncrypt per-account DB keys (wrapped by Keystore); integrity checks
Local DB leakage via backups/cacheInternal app storage only; opt-out sensitive paths from auto backup; FileProvider URIs with time-limited grants
Spoofed notificationsOnly accept OS push channels (FCM/APNs); verify app identity
Sensitive lock-screen previewsRedact by default; user toggle for previews; use immutable PendingIntents and explicit components
PendingIntent tamperingUse FLAG_IMMUTABLE; explicit component declarations
Attachment exfiltration via world-readable filesSAF/DocumentFile APIs; FileProvider URIs; no world-readable files; enforce MIME checks and size limits
Sync loops draining battery/dataBackoff with jitter; network/battery constraints; circuit breakers; validate job inputs
Malicious autodiscovery configsPrefer HTTPS; validate domains; ship trusted provider directory; warn on insecure configs
OpenPGP/S-MIME plaintext residueIsolate decrypted buffers; wipe temp files; constant-time crypto; disable remote content by default; clear trust UX
Token theft from logsStructured logging with allowlist fields; redact headers/URLs
Intent spoofing / inter-app abuseMinimize exported components; explicit intents; verify caller UID; require permissions
Logging/Telemetry leaksOpt-in; redact PII; bounded retention; local-first storage
iOS Keychain/data leakageUse kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly; careful App Groups; minimize iCloud backup; strong ATS/TLS

What to do:

Label each threat High/Medium/Low based on likelihood and impact.

  • High: likely and/or severe impact → prioritize now
  • Medium: plausible and moderate → schedule soon
  • Low: unlikely or low impact → backlog/accept with rationale

Example:

  • High: Credential theft, insecure storage, MITM, data leakage
  • Medium: DoS via login brute force, excessive sync, notification spam
  • Low: Repudiation of sent messages, log tampering

Perfect 🚀 — here’s a ready-to-use Markdown template file your team can copy for any new feature or flow in Thunderbird Mobile. It includes placeholders for each section, an empty system diagram stub, and separate Threats and Mitigations tables.


📖 Threat Modeling Template

Use this template when modeling a new feature or flow.


Title

1. Project Overview

  • Project Name:
  • Description:
  • Key Features:

2. Scope

  • Scope for this session

3. System Diagram

flowchart LR
  USER[User] --> APP[Thunderbird App]
  ATTACKER[Attacker] -.-> APP

  subgraph Device
    APP --> COMPONENT1[Component 1]
    APP --> COMPONENT2[Component 2]
  end

  subgraph External
    SERVICE1[(External Service 1)]
    SERVICE2[(External Service 2)]
  end

  APP --> SERVICE1
  APP --> SERVICE2

4. Assets

  • Data:
  • Functionality:
  • Reputation:
  • Availability:

5. Threats

Component / FlowSpoofingTamperingRepudiationInformation DisclosureDoSElevation of Privilege
Example Flow A
Example Flow B
Example Flow C

6. Mitigations

Threat IdentifiedProposed Mitigation
Threat 1Mitigation 1
Threat 2Mitigation 2
Threat 3Mitigation 3

7. Risk Ranking

  • High: Likely and/or severe → prioritize now.
  • Medium: Plausible and moderate → schedule soon.
  • Low: Unlikely or low impact → backlog or accept with rationale.

🛡️ Example 1: Account Authentication

1. Project Overview

  • Project Name: Thunderbird for Android/iOS – Account Authentication
  • Description: Handling account setup, login, and credential storage for IMAP/SMTP or OAuth2 providers.
  • Key Features: Password auth, OAuth2 flows (Google/Microsoft), token storage, secure connections.

2. Scope

  • Account authentication and credential storage.

3. System Diagram

flowchart LR
  USER[User] --> APP[Thunderbird App]
  ATTACKER[Attacker] -.-> APP

  subgraph Device
    APP --> STORAGE[Local Storage: DB/Files]
    APP --> KEYSTORE[Keystore/Secure Enclave]
    APP --> OAUTH_FLOW[OAuth Flow via Browser]
  end

  subgraph External
    OAUTH_PROVIDER[(OAuth Provider)]
    MAIL[(IMAP/POP3 Servers)]
    SMTP[(SMTP Servers)]
  end

  OAUTH_FLOW --> OAUTH_PROVIDER --> APP
  APP --> MAIL
  APP --> SMTP

4. Assets

  • Data: User credentials, OAuth tokens, session cookies.
  • Functionality: Secure login, ability to sync and send mail.
  • Reputation: Privacy-first branding.
  • Availability: Reliable authentication, minimal lockouts.

5. Threats

Component / FlowSpoofingTamperingRepudiationInformation DisclosureDoSElevation of Privilege
OAuth2 LoginFake OAuth page via phishingRedirect URI manipulationUser denies consentTokens leaked in logs/URLsProvider rate limitsExcessive scopes
Password AuthMITM server spoofModify TLS sessionUser denies failed loginCredentials in memory/backupsBrute-force lockoutsUse of admin roles
IMAP/SMTP TLSSpoofed certificatesDowngrade attackMetadata leakageConnection exhaustionProtocol quirks abused
Local StorageMalicious app pretends to be ThunderbirdDB/file tamperingUser denies local actionBackups/cache leaksStorage exhaustionSandbox escape
KeystoreFake app tries to access aliasMisuse of keyKey extraction on rooted devicesKeystore unavailableHardware bypass

6. Mitigations

Threat IdentifiedProposed Mitigation
Fake OAuth loginSystem browser (Custom Tabs/ASWebAuthSession); claimed HTTPS redirect URIs; PKCE; validate state/nonce
Redirect tamperingStrict URI validation; reject mismatches
Excessive scopesScope minimization; permission reviews
MITM on password loginTLS 1.2+ (prefer 1.3); strict hostname verification; pin known providers
Credential leakageKeystore/Keychain storage; redact logs; disable backups; zeroize memory; protect screenshots
TLS downgradePrefer implicit TLS; reject weak suites; enforce hostname checks
DB tamperingEncrypt per-account DB; integrity seals
Backup/cache leaksInternal storage only; opt-out backups; FileProvider URIs
Keystore misuseUse StrongBox/TEE; require user auth for sensitive ops; handle key invalidation

7. Risk Ranking

  • High: Credential theft, token leakage, MITM, TLS downgrade.
  • Medium: Brute-force lockouts, storage exhaustion.
  • Low: Repudiation of login attempts.

🔔 Example 2: Push Notifications

1. Project Overview

  • Project Name: Thunderbird for Android/iOS – Push Notifications
  • Description: Handling push notifications via FCM (Android) or APNs (iOS) to wake the app for secure fetch.
  • Key Features: Receive push, show notifications, fetch mail securely.

2. Scope

  • Push notification handling (end-to-end from server push to user notification).

3. System Diagram

flowchart TB
  USER[User] --> APP[Thunderbird App]
  ATTACKER[Attacker] -.-> APP

  subgraph Device
    APP --> NOTIF_HANDLER[Notification Handler]
    NOTIF_HANDLER --> FETCHER[Secure Fetcher]
    FETCHER --> DB[(Encrypted Local DB)]
  end

  subgraph External
    FCM[(FCM - Android)]
    APNS[(APNs - iOS)]
    MAIL[(Mail Server IMAP/SMTP)]
  end

  FCM --> NOTIF_HANDLER
  APNS --> NOTIF_HANDLER
  NOTIF_HANDLER -->|TLS fetch| MAIL

4. Assets

  • Data: Notification payloads, message IDs, device tokens.
  • Functionality: Timely, trustworthy notifications.
  • Reputation: No privacy leaks in lock-screen banners.
  • Availability: Notifications work without spam or battery drain.

5. Threats

Component / FlowSpoofingTamperingRepudiationInformation DisclosureDoSElevation of Privilege
Push Channel (FCM/APNs)Fake/local notificationPayload alteredUser disputes receiptPayload leaks sender/subjectNotification floodMalicious PendingIntent abuse
Post-Push FetchMITM fetch requestInject mailbox updatesToken leakage in logsRetry stormsFetcher over-privileged
Notification UIModify notification actionLockscreen preview leaksNotification spam wakes devicePrivileged screen opened

6. Mitigations

Threat IdentifiedProposed Mitigation
Fake/local notificationOnly accept OS push channels; verify channel IDs
Payload tamperingOpaque IDs in payloads; fetch details securely
Metadata leakageRedact payloads; fetch details after wake; redact lock-screen previews by default
Notification spamRate limits; quotas; quiet hours; batching
PendingIntent abuseUse FLAG_IMMUTABLE; explicit component targets
MITM fetchTLS 1.3; strict cert validation; pinning
Token leakage in logsRedact tokens/headers; structured logging
Notification action abuseRequire unlock/biometric for sensitive actions

7. Risk Ranking

  • High: Metadata leaks in push payloads, MITM on fetch, PendingIntent abuse.
  • Medium: Notification spam floods, retry storms.
  • Low: Repudiation of notification receipt.
Last change: , commit: 335ed15

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: , commit: 335ed15