Contributing to Thunderbird for Android
Welcome to the Thunderbird for Android project! We’re excited to have you here and welcome your contributions.
Getting Started
Before you start contributing, please take a moment to familiarize yourself with the following:
- Mozilla Community Participation Guidelines: https://www.mozilla.org/en-US/about/governance/policies/participation/
- Frequently Asked Questions: https://forum.k9mail.app/c/faq
- Support Forum: https://forum.k9mail.app/
Bug Reports and Feature Requests
If you encounter a bug or have a feature request, please follow these steps:
- Search the existing issues to see if your issue or feature has already been reported.
- If you can’t find an existing issue, please open a new issue on GitHub.
Translations
If you’d like to help to translate K-9 Mail / Thunderbird for Android, please visit the Weblate - K-9 Mail/Thunderbird project.
Contributing Code
Thank you for your willingness to contribute code! Here’s how you can get started:
1. Find an issue:
- Check the issue tracker for open issues.
- Look for issues labeled good first issue for a good starting point.
- Propose a new feature by opening a new issue
- Avoid issues labeled unconfirmed or tb-team as they are not yet ready for contributions.
2. Discuss your plan:
- Leave a comment on the issue you want to work on, explaining what you plan to do. This helps avoid duplicate work and gets you feedback from the team.
3. Fork the repository:
- Create your own fork of the Thunderbird for Android repository on GitHub.
4. Create a branch:
- Start a new branch from the
main
branch to keep your changes separate. - Name your branch descriptively (e.g.,
fix-issue-123
oradd-feature-xyz
).
5. Make your changes:
- Write your code and commit it to your branch.
- Follow our Code Style Guidelines
6. Test your changes:
- Run the project’s tests to make sure everything works and that your changes don’t introduce any regressions.
- If applicable, write new tests to cover your changes.
7. Push your changes:
- Upload your branch to your forked repository.
8. Open a pull request:
- Create a pull request to merge your changes into the main project.
- Provide a clear and concise description of your changes, including:
- A reference to the issue you’re addressing.
- A summary of the changes you made.
- Any relevant screenshots or testing results.
Thank You!
Thank you for taking the time to contribute to Thunderbird for Android! We appreciate your help in making the project better and more useful for everyone.
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
Type | Use for… | Example |
---|---|---|
feat | New features | feat(camera): add zoom support |
fix | Bug fixes | fix(auth): handle empty username crash |
docs | Documentation only | docs(readme): update setup instructions |
style | Code style (no logic changes) | style: reformat settings screen |
refactor | Code changes (no features/fixes) | refactor(nav): simplify stack setup |
test | Adding/editing tests | test(api): add unit test for login |
chore | Tooling, CI, dependencies | chore(ci): update GitHub Actions config |
revert | Reverting previous commits | revert: 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.
Scope | Use for… | Example |
---|---|---|
auth | Authentication | feat(auth): add login functionality |
settings | User settings | feat(settings): add dark mode toggle |
build | Build system | fix(build): improve build performance |
ui | UI/theme | refactor(ui): split theme into modules |
deps | Dependencies | chore(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.
🧪 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:
- Arrange: Set up the test conditions and inputs
- Act: Perform the action being tested
- 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:
- JUnit 4
- AssertK for assertions
- Robolectric (for Android framework classes)
Location:
- Tests should be in the same module as the code being tested
- Should be in the
src/test
directory orsrc/{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
orsrc/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.
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
- Write tests for any code that is not adequately covered by tests.
- Use the “Convert Java File to Kotlin File” action in IntelliJ or Android Studio to convert the Java code.
- Fix any issues that prevent the code from compiling after the automatic conversion.
- Commit the changes as separate commits:
- The change of file extension (e.g.
example.java
->example.kt
). - 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.
- The change of file extension (e.g.
- Refactor the code to improve readability and maintainability. This includes:
- Removing unnecessary code.
- Using Kotlin’s standard library functions, language features, null safety and coding conventions.
Additional Tips
- Use
when
expressions instead ofif-else
statements. - Use
apply
andalso
to perform side effects on objects. - Use
@JvmField
to expose a Kotlin property as a field in Java.
Resources
- Kotlin Coding Conventions
- Calling Kotlin from Java
- Calling Java from Kotlin
- Kotlin and Android | Android Developers
🏗️ 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
andapp-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:
- 💾 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
- 🧠 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
- 🖼️ 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:
-
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() }
-
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>() }
-
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)) } }
-
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) } }
-
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) } } } }
-
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)) } }
-
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 likeLogger
andLogSink
- Multiple implementations (composite, console) allow for flexible logging targets
- Composite implementation enables logging to multiple sinks simultaneously
- Core logging API (
- 🔄 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 theLogger
interface and delegates to aLogSink
- 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
- Logger: The front-facing interface that application code interacts with to create log entries
- 📋 Log Levels:
VERBOSE
: Most detailed log level for debuggingDEBUG
: Detailed information for diagnosing problemsINFO
: General information about application flowWARN
: Potential issues that don’t affect functionalityERROR
: Issues that affect functionality but don’t crash the application
🛠️ How to Implement Logging
When adding logging to your code:
-
Inject a Logger into your class:
class AccountRepository( private val apiClient: ApiClient, private val logger: Logger, ) { // Repository implementation }
-
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
- Use
-
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)
-
Include relevant context in log messages:
logger.info { "Syncing account: ${account.email}, folders: ${folders.size}" }
-
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) }
-
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
andOpenPgpEncryptionExtractor
handle encrypted emails - Local storage encryption for sensitive data like account credentials
- End-to-end encryption using OpenPGP (via the
- 🔑 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:
-
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)
-
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)
-
Validate user input to prevent injection attacks:
// Validate input before using it if (!InputValidator.isValidEmailAddress(userInput)) { throw ValidationError("Invalid email address") }
-
Use secure network connections:
// The networking modules enforce TLS by default // Make sure to use the provided clients rather than creating your own val networkClient = get<NetworkClient>()
🧪 Testing Strategy
The architecture supports comprehensive testing:
- 🔬 Unit Tests: Test individual components in isolation
- 🔌 Integration Tests: Test interactions between components
- 📱 UI Tests: Test the UI behavior and user flows
See the Testing guide document for more details on how to write and run tests for the application.
🔙 Legacy Integration
The application includes legacy code that is gradually being migrated to the new architecture:
- 📦 Legacy Modules: Contain code from the original K-9 Mail application
- 🔄 Migration Strategy: Gradual migration to the new architecture
- 🔌 Integration Points: Clear interfaces between legacy and new code
For more details on the legacy integration, see the Legacy Integration document.
🔄 User Flows
The User Flows provides visual representations of typical user flows through the application, helping to understand how different components interact.
📦 Module Organization
The Thunderbird for Android project is following a modularization approach, where the codebase is divided into multiple distinct modules. These modules encapsulate specific functionality and can be developed, tested, and maintained independently. This modular architecture promotes reusability, scalability, and maintainability of the codebase.
This document outlines the adopted module organization for the Thunderbird for Android project, serving as a guide for developers to understand the codebase structure and ensure consistent architectural patterns.
📂 Module Overview
The modules are organized into several types, each serving a specific purpose in the overall architecture:
graph TB subgraph APP[App Modules] direction TB APP_TB["`**:app-thunderbird**<br>Thunderbird for Android`"] APP_K9["`**:app-k9mail**<br>K-9 Mail`"] end subgraph COMMON[App Common Module] direction TB COMMON_APP["`**:app-common**<br>Integration Code`"] end subgraph FEATURE[Feature Modules] direction TB FEATURE_ACCOUNT["`**:feature:account**`"] FEATURE_SETTINGS["`**:feature:settings**`"] FEATURE_ONBOARDING["`**:feature:onboarding**`"] FEATURE_MAIL["`**:feature:mail**`"] end subgraph CORE[Core Modules] direction TB CORE_UI["`**:core:ui**`"] CORE_COMMON["`**:core:common**`"] CORE_ANDROID["`**:core:android**`"] CORE_NETWORK["`**:core:network**`"] CORE_DATABASE["`**:core:database**`"] CORE_TESTING["`**:core:testing**`"] end subgraph LIBRARY[Library Modules] direction TB LIB_AUTH["`**:library:auth**`"] LIB_CRYPTO["`**:library:crypto**`"] LIB_STORAGE["`**:library:storage**`"] end subgraph LEGACY[Legacy Modules] direction TB LEGACY_K9["`**:legacy**`"] LEGACY_MAIL["`**:mail**`"] LEGACY_BACKEND["`**:backend**`"] end APP ~~~ COMMON COMMON ~~~ FEATURE FEATURE ~~~ CORE CORE ~~~ LIBRARY LIBRARY ~~~ LEGACY APP --> |depends on| COMMON COMMON --> |depends on| FEATURE FEATURE --> |depends on| CORE CORE --> |depends on| LIBRARY COMMON --> |depends on<br>as legacy bridge| LEGACY classDef app fill:#d9e9ff,stroke:#000000,color:#000000 classDef app_module fill:#4d94ff,stroke:#000000,color:#000000 classDef common fill:#e6e6e6,stroke:#000000,color:#000000 classDef common_module fill:#999999,stroke:#000000,color:#000000 classDef feature fill:#d9ffd9,stroke:#000000,color:#000000 classDef feature_module fill:#33cc33,stroke:#000000,color:#000000 classDef core fill:#e6cce6,stroke:#000000,color:#000000 classDef core_module fill:#cc99cc,stroke:#000000,color:#000000 classDef library fill:#fff0d0,stroke:#000000,color:#000000 classDef library_module fill:#ffaa33,stroke:#000000,color:#000000 classDef legacy fill:#ffe6e6,stroke:#000000,color:#000000 classDef legacy_module fill:#ff9999,stroke:#000000,color:#000000 linkStyle default stroke:#999,stroke-width:2px linkStyle 0,1,2,3,4 stroke-width:0px class APP app class APP_TB,APP_K9 app_module class COMMON common class COMMON_APP common_module class FEATURE feature class FEATURE_ACCOUNT,FEATURE_SETTINGS,FEATURE_ONBOARDING,FEATURE_MAIL feature_module class CORE core class CORE_UI,CORE_COMMON,CORE_ANDROID,CORE_DATABASE,CORE_NETWORK,CORE_TESTING core_module class LIBRARY library class LIB_AUTH,LIB_CRYPTO,LIB_STORAGE library_module class LEGACY legacy class LEGACY_MAIL,LEGACY_BACKEND,LEGACY_K9 legacy_module
Module Types
📱 App Modules
The App Modules (app-thunderbird
and app-k9mail
) contain the application-specific code, including:
- Application entry points and initialization logic
- Final dependency injection setup
- Navigation configuration
- Integration with feature modules solely for that application
- App-specific themes and resources (strings, themes, etc.)
🔄 App Common Module
The app-common
module acts as the central hub for shared code between both applications. This module serves as the
primary “glue” that binds various feature
modules together, providing a seamless integration point. It also contains:
- Shared application logic
- Feature coordination
- Common dependency injection setup
- Legacy code bridges and adapters
What Should Go in App Common
The app-common module should contain:
- 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.
- Example:
- 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.
- 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.
- Example:
- Legacy Code Bridges/Adapters: Implementations of interfaces defined in feature modules that delegate to legacy code.
- Example:
DefaultAccountProfileLocalDataSource
implementsAccountProfileLocalDataSource
fromfeature:account:core
and delegates to legacy account code. - These bridges isolate legacy code and prevent direct dependencies on it from feature modules.
- Example:
What Should NOT Go in App Common
The following should NOT be placed in app-common:
- 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.
- Example: Mail composition logic should be in
- 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.
- 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.
- 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.
- Example: A new calendar feature should be in
Decision Criteria for New Contributors
When deciding whether code belongs in app-common or a feature module, consider:
- Is it shared between both applications? If yes, it might belong in app-common.
- Is it specific to a single feature domain? If yes, it belongs in that feature module.
- Does it bridge to legacy code? If yes, it belongs in app-common.
- Does it coordinate between multiple features? If yes, it might belong in app-common.
- 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, notimplementation
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
📦 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 modulescore:<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 implementationsfeature:<feature-name>:impl-<variant>
for variant-specific implementationscore:<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 implementationfeature:account:impl-yahoo
- Yahoo-specific implementationfeature: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 utilitiescore:<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 implementationscore:<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 codecore:<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:
- One-Way Dependencies:
- Modules should not depend on each other in a circular manner
- Dependencies should form a directed acyclic graph (DAG)
- API-Implementation Separation:
- Modules should depend only on API modules, not implementation modules
- Implementation modules should be referenced only in dependency injection setup
- 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
- 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
- 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:
- Distinct Functionality: The code represents a distinct piece of functionality with clear boundaries
- Reusability: The functionality could be reused across multiple features or applications
- Build Performance: Breaking down large modules improves build performance
- Testing: Isolation improves testability
When to Split a Module
Split an existing module when:
- Size: The module has grown too large (>10,000 lines of code as a rough guideline)
- Complexity: The module has become too complex with many responsibilities
- Dependencies: The module has too many dependencies
- Build Time: The module takes too long to build
When to Keep Modules Together
Keep functionality in the same module when:
- Cohesion: The functionality is highly cohesive and tightly coupled
- Small Size: The functionality is small and simple
- Single Responsibility: The functionality represents a single responsibility
📦 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:
- API-First Design: Define clear public interfaces before implementation
- Single Responsibility: Each feature module should have a single, well-defined responsibility
- Minimal Dependencies: Minimize dependencies between feature modules
- Proper Layering: Follow Clean Architecture principles within each feature
- Testability: Design features to be easily testable in isolation
- Documentation: Document the purpose and usage of each feature module
- Consistent Naming: Follow the established naming conventions
- Feature Flags: Use feature flags for gradual rollout and A/B testing
- Accessibility: Ensure all features are accessible to all users
- 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
- API (
📊 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
🎨 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 aK9MailTheme2
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:
- Data travels in one direction only
- State changes are predictable and traceable
- Components have clear, single responsibilities
- The UI is a pure function of the application state
In our implementation, the flow follows this cycle:
- User Interaction: The user interacts with the UI (e.g., clicks a button)
- Event Dispatch: The UI captures this interaction as an Event and dispatches it to the ViewModel
- Event Processing: The ViewModel processes the Event and determines what Action to take
- Action Execution: The ViewModel executes an Action, typically by calling a Use Case
- Domain Logic: The Use Case performs business logic, often involving repositories
- Result Return: The Use Case returns a Result to the ViewModel
- State Update: The ViewModel updates the State based on the Result
- UI Rendering: The UI observes the State change and re-renders accordingly
- 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:
-
Predictability: Since data flows in only one direction, the system behavior becomes more predictable and easier to reason about.
-
Debugging: Tracing issues becomes simpler because you can follow the data flow from source to destination without worrying about circular dependencies.
-
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.
-
Testability: Each component in the flow can be tested in isolation with clear inputs and expected outputs.
-
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
-
Scalability: The pattern scales well as the application grows because new features can follow the same consistent pattern.
-
Maintainability: Code is easier to maintain because changes in one part of the flow don’t unexpectedly affect other parts.
-
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:
- Screen Composables: Top-level composables that represent a full screen
- Content Composables: Composables that render the UI based on the state
- 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
Navigation Setup
To set up navigation in the app, you need to:
- Define route constants
- Create a NavHost with composable destinations
- Handle navigation callbacks in screens
- 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(),
)
}
}
}
Navigation in Screens
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
}
Navigation in ViewModels
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
- User Interaction: The user interacts with the UI (e.g., clicks a button, enters text)
- Event Dispatch: The View captures this interaction and dispatches an Event to the ViewModel
- Event Processing: The ViewModel processes the Event and determines what action to take
- Action Execution: The ViewModel executes an Action, typically by calling a Use Case
- Domain Logic: The Use Case executes business logic, often involving repositories or other domain services
- Result Handling: The Use Case returns a result to the ViewModel
- State Update: The ViewModel updates its State based on the result
- UI Update: The View observes the State change and updates the UI accordingly
- 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:
- State Immutability: The State is an immutable data class that represents the entire UI state
- Single Source of Truth: The ViewModel is the single source of truth for the State
- State Updates: Only the ViewModel can update the State, using the
updateState
method - State Observation: The View observes the State using
collectAsStateWithLifecycle()
and recomposes when it changes - 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:
- View:
- Render UI based on State
- Capture user interactions
- Dispatch Events to ViewModel
- Handle Effects (e.g., navigation)
- ViewModel:
- Process Events
- Execute Actions (Use Cases)
- Update State
- Emit Effects
- Use Cases:
- Execute business logic
- Coordinate with repositories and domain services
- Return results to ViewModel
- 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
🎭 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:
- Consistency: Provide a unified look and feel across all applications while allowing for brand-specific customization
- Flexibility: Support different visual identities for different applications (Thunderbird, K-9 Mail) using the same underlying system
- Extensibility: Enable easy addition of new theme components or modification of existing ones
- Maintainability: Centralize theme definitions to simplify updates and changes
- 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:
- 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
- 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
- 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:
- App-specific themes (ThunderbirdTheme2, K9MailTheme2) define their visual identity through a ThemeConfig
- 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
- Composables access theme properties through the MainTheme object
- 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:
- ThemeConfig aggregates all components and provides them to the
MainTheme
- MainTheme makes components available through
CompositionLocal
providers - Composables access theme components through the
MainTheme
object - Components like
ThemeColorScheme
andThemeShapes
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()
}
🎨 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 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
- Atomic Design Methodology | Atomic Design by Brad Frost
- Atomic Design: Getting Started | Blog | We Are Mobile First
User Flows
The user flows diagrams below illustrate typical paths users take through the application, helping developers understand how different components interact from a user perspective.
For information about the repository structure and module organization, see the Project Structure document.
Reading email
Sending email
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.
🔙 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:
- Legacy Modules:
legacy:*
,mail:*
, andbackend:*
modules containing existing functionality - Interfaces: Well-defined interfaces in
feature:*:api
andcore:*
modules - App Common Bridge: The
:app-common
module that implements these interfaces and delegates to legacy code - 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:
- Implementing interfaces:
app-common
provides concrete implementations for interfaces that newer modules define. - Delegating to legacy code: Internally, these
app-common
implementations will delegate calls, adapt data, and manage interactions with the underlyinglegacy:*
,mail:*
, andbackend:*
modules. - 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 theapp-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:
- 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. - 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. - Implementation: The
:app-common
module provides concrete implementations for these interfaces. - 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:*
). - 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:
-
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.
-
Adapter Implementations: Classes in
:app-common
that implement interfaces from the new architecture but delegate to legacy code internally. -
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:
- Modern Interfaces:
AccountProfileRepository
infeature:account:api
defines the high-level contract for account profile managementAccountProfileLocalDataSource
infeature:account:core
defines the data access contract
- Modern Data Structure:
AccountProfile
infeature:account:api
is a clean, immutable data class that represents account profile information in the new architecture. - Repository Implementation:
DefaultAccountProfileRepository
infeature:account:core
implements theAccountProfileRepository
interface and depends onAccountProfileLocalDataSource
. - Bridge Implementation:
DefaultAccountProfileLocalDataSource
inapp-common
implements theAccountProfileLocalDataSource
interface and serves as the bridge to legacy code. - Legacy Access: The bridge uses
DefaultLegacyAccountWrapperManager
to access legacy account data:LegacyAccountWrapperManager
incore:android:account
defines the contract for legacy account accessLegacyAccountWrapper
incore:android:account
is an immutable wrapper around the legacyLegacyAccount
class
- Data Conversion: The bridge uses a dedicated mapper class to convert between modern
AccountProfile
objects and legacy account data. - Dependency Injection: The
appCommonAccountModule
inapp-common
registersDefaultAccountProfileLocalDataSource
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:
- 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
- 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
- Test Doubles:
- Create fake implementations of bridge classes for testing other components
- Example:
FakeLegacyAccountWrapperManager
can be used to test components that depend onLegacyAccountWrapperManager
- 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
- Isolate the Bridge: Test the bridge implementation in isolation by using fake or mock implementations of legacy dependencies.
- Test Both Directions: For data conversion, test both directions (legacy to domain and domain to legacy).
- Cover Edge Cases: Test edge cases such as null values, empty collections, and error conditions.
- Use Clear Test Structure: Structure tests with clear arrange, act, and assert sections.
- Create Reusable Test Fixtures: Create helper methods for creating test data to make tests more readable and maintainable.
- Test Reactive Behavior: For reactive code (using Flows, LiveData, etc.), use appropriate testing utilities (e.g., Turbine for Flow testing).
- Verify Integration: In addition to unit tests, create integration tests that verify the bridge works correctly with actual legacy code.
- 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:
- Identify Functionality: Pinpoint specific functionalities within legacy modules that need to be modernized.
- Define Interfaces: Ensure clear interfaces are defined (typically in feature
api
modules) for this functionality. - Entity Modeling: Create proper domain entity models that represent the business objects as immutable data classes.
- Implement in New Modules: Re-implement the functionality within new, dedicated feature
impl
modules or core modules. - Update Bridge (Optional): If
:app-common
was bridging to this specific legacy code, its bridge implementation can be updated or removed. - Switch DI Configuration: Update the dependency injection to provide the new modern implementation instead of the legacy bridge.
- 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:
- Identify: Account profile functionality in legacy modules needs modernization.
- Define Interfaces:
AccountProfileRepository
interface is defined infeature:account:api
AccountProfileLocalDataSource
interface is defined infeature:account:core
- Entity Modeling: Create
AccountProfile
as an immutable data class infeature:account:api
. - Implement: Create a new implementation of
AccountProfileLocalDataSource
in a modern module, e.g.,feature:account:impl
. - Update Bridge: Update or remove
DefaultAccountProfileLocalDataSource
inapp-common
. - Switch DI: Update
appCommonAccountModule
to provide the new implementation instead ofDefaultAccountProfileLocalDataSource
. - 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
Architecture Decision Records
The docs/architecture/adr folder contains the architecture decision records (ADRs) for our project.
ADRs are short text documents that serve as a historical context for the architecture decisions we make over the course of the project.
What is an ADR?
An Architecture Decision Record (ADR) is a document that captures an important architectural decision made along with its context and consequences. ADRs record the decision making process and allow others to understand the rationale behind decisions, providing insight and facilitating future decision-making processes.
Format of an ADR
We adhere to Michael Nygard’s ADR format proposal, where each ADR document should contain:
- Title: A short descriptive name for the decision.
- Link to Issue: A link to the issue that prompted the decision.
- Link to Pull Request: A link to the pull request that implements the ADR.
- Link to Tracking Issue: A link to the tracking issue, if applicable.
- Status: The current status of the decision (proposed, accepted, rejected, deprecated, superseded)
- Context: The context that motivates this decision.
- Decision: The change that we’re proposing and/or doing.
- 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:
- Create an issue in the repository, get consensus from at least one other project contributor.
- Make a post on the mobile-planning list to announce your ADR. You can use the below template as needed.
- Create a pull request in the repository linking the issue.
- 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:
- Proposed: The ADR is under consideration.
- Accepted: The decision described in the ADR has been accepted and should be adhered to, unless it is superseded by another ADR.
- Rejected: The decision described in the ADR has been rejected.
- Deprecated: The decision described in the ADR is no longer relevant due to changes in system context.
- Superseded: The decision described in the ADR has been replaced by another decision.
Each ADR will have a status indicating its current life-cycle stage. An ADR can be updated over time, either to change the status or to add more information.
Contributions
We welcome contributions in the form of new ADRs or updates to existing ones. Please ensure all contributions follow the standard format and provide clear and concise information.
Appendix: Intent to Adopt Template
You may use this template in your Intent to Adopt email as noted above. Tweak it as you feel is useful.
Hello everyone,
I’m writing to share an intent to adopt a new architecture decision: [ADR-[Number]] [Title of ADR]
This change addresses [brief summary of the problem] and proposes [brief description of the approach].
This decision is based on [briefly mention motivating factors, constraints, or technical context].
You can read the full proposal here: [link to ADR]
If you have feedback or concerns, please respond in the linked issue. We plan to finalize the decision after [proposed date], factoring in discussion at that time.
Thanks, [Your Name]
Switch from Java to Kotlin
- Pull Request: #7221
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.
UI - Wrap Material Components in Atomic Design System
- Pull Request: #7221
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.
Switch Test Assertions from Truth to assertk
- Pull Request: #7242
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.
Naming Conventions for Interfaces and Their Implementations
- Pull Request: #7794
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:
- Interface Naming: Name interfaces as if they were classes, using a clear and descriptive name. Avoid using the “IInterface” pattern.
- Implementation Naming: Use a prefix that clearly indicates the relationship between the interface and
implementation, such as
DatabaseMessageStore
orInMemoryMessageStore
for theMessageStore
interface. - Descriptive Names: Use descriptive names for interfaces and implementing classes that accurately reflect their purpose and functionality.
- Platform-specific Implementations: Use the platform name as a prefix for interface implementations specific to
that platform, e.g.
AndroidPowerManager
. - App-specific Implementations: Use the prefix
K9
for K-9 Mail andTb
for Thunderbird when app-specific implementations are needed, e.g.K9AppNameProvider
andTbAppNameProvider
. - Flexibility: If no brief descriptive name fits and there is only one production implementation, use the prefix
Default
, likeDefaultImapFolder
.
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.
Central Management of Android Project Dependencies and Gradle Configurations via Build-Plugin Module
- Pull Request: #7803
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
- 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.
- 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.
- 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
- 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.
- 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.
- 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.
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.
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:
- App Modules:
app-thunderbird
andapp-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 theapp-common
module for shared functionalities and may selectively integratefeature
andcore
to setup app-specific needs.
- 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 variousfeature
modules together, providing a seamless integration point. While it can depend onlibrary
modules for additional functionalities, its main purpose is to orchestrate the interactions among thefeature
andcore
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.
- 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 oncore
modules and may interact with otherfeature
orlibrary
modules.
- 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.
- 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
andlibrary
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.
Change Shared Modules package to net.thunderbird
- Issue: #9012
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
orapp-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
Thunderbird for Android Release Documentation
Please see the sub-pages for release documentation
Releases
Thunderbird for Android follows a release train model to ensure timely and predictable releases. This model allows for regular feature rollouts, stability improvements, and bug fixes.
Branches in the Release Train Model
Daily
Daily builds are used for initial testing of new features and changes. Feature flags are additionally used to work on features that are not yet ready for consumption.
- Branch:
main
- Purpose: Active development of new features and improvements
- Release Cadence: Daily
- Audience: Developers and highly technical users who want to test the bleeding edge of Thunderbird. Daily builds are unstable and not recommended for production use.
- Availability: Daily builds are available on the Play Store internal channel. APKs are available on ftp.mozilla.org.
Beta
After features are stabilized in Daily, they are merged into the Beta branch for broader testing. Uplifts are limited to bug/security fixes only. The Beta branch serves as a preview of what will be included in the next stable release, allowing for user feedback and final adjustments before general availability.
- Branch:
beta
- Purpose: Pre-release testing
- Release Cadence: Weekly with the option to skip if not needed
- Merge Cadence: Every 2 Months
- Audience: Early adopters and testers. Testers are encouraged to provide error logs and help reproduce issues filed.
- Availability: Beta builds are available from the Play Store and F-Droid.
Release
This branch represents the stable version of Thunderbird, which is released to the public. It is tested and suitable for general use. Bug fixes and minor updates are periodically applied between major releases. Uplifts to Release are limited to stability/security fixes only.
- Branch:
release
- Purpose: Stable releases
- Release Cadence: Major releases every 2 months. Minor releases every 2 weeks with the option to skip if not needed.
- Merge Cadence: Every 2 months
- Audience: General users. Users may be filing bug reports or leaving reviews to express their level of satisfaction.
- Availability: Release builds are available from the Play Store and F-Droid.
Example Feature Release Flow
- A new feature is developed and merged via pull requests into the
main
branch. - Every 2 months,
main
is merged into thebeta
branch for external testing and feedback. - Every 2 months,
beta
is merged into therelease
branch, and a release is made available to all users.
Example Bug Release Flow
- A high-impact bug is fixed and merged via pull request into the
main
branch. - After it has received adequate testing on
daily
, the fix is cherry-picked (uplifted) to thebeta
branch and released in the next scheduled beta. - After it has received adequate testing on
beta
, the fix is cherry-picked (uplifted) to therelease
branch and released in the next stable minor release.
Sample Release Timeline
Milestone | Details | Date |
---|---|---|
TfA 11.0a1 starts | Feb 28 | |
TfA merge 11.0a1 main->beta | May 2 | |
TfA 11.0b1 | May 5 | |
TfA 11.0bX | If needed | May 12 |
TfA 11.0bX | If needed | May 19 |
TfA 11.0bX | If needed | May 26 |
TfA 11.0bX | If needed | Jun 2 |
TfA 11.0bX | If needed | Jun 9 |
TfA 11.0bX | If needed | Jun 16 |
TfA 11.0bX | If needed | Jun 23 |
TfA 11.0bX | If needed | Jun 30 |
TfA merge 11.0bX beta->release | Jun 30 | |
TfA 11.0 | Jul 7 | |
TfA 11.X | If needed | Jul 21 |
TfA 11.X | If needed | Aug 4 |
TfA 11.X | If needed | Aug 18 |
TfA 11.X | If needed | Sep 1 |
Feature Flags
Thunderbird for Android uses Feature Flags to disable features not yet ready for consumption.
- On
main
, feature flags are enabled as soon as developers have completed all pull requests related to the feature. - On
beta
, feature flags remain enabled unless the feature has not been fully completed and the developers would like to pause the feature. - On
release
, feature flags are disabled until an explicit decision has been made to enable the feature for all users.
Versioning System
Version Names
Thunderbird for Android stable release versions follow the X.Y
format, where:
- X (Major version): Incremented for each new release cycle.
- Y (Patch version): Incremented when changes are added to an existing major version.
For beta builds, the suffix b1
is appended, where the number increments for each beta. For daily builds, the suffix a1
is appended, which remains constant.
Version Codes
The version code is an internal version number for Android that helps determine whether one version is more recent than another.
The version code for beta and release is an integer value that increments for each new release.
The version code for daily is calculated based on the date and has the format yDDDHHmm
:
- y: The number of years since a base year, with 2023 as the starting point (e.g., 2024 is 1)
- DDD: The day of the year in 3 digits, zero-padded
- HH: The hour of the day in 2 digits (00–23)
- mm: The minute of the hour in 2 digits
For example:
2024-02-09 16:45
→1 | 040 | 16 | 45
→10401645
2025-10-12 09:23
→2 | 285 | 09 | 23
→22850923
2122-02-09 16:45
→99 | 040 | 16 | 45
→990401645
Merge Days
Active development occurs on the main
branch and becomes part of daily. Every 2 months:
main
is merged intobeta
, for testing.beta
is merged intorelease
, making it publicly available.
On the former, main
carries over to beta
, where the community can test the changes as part of “Thunderbird Beta for Testers” (net.thunderbird.android.beta
) until the next merge day.
On the latter, code that was in beta goes to release, where the general population receives product updates (net.thunderbird.android
).
When a merge occurs, the version name is carried forward to the next branch. However, the alpha and beta suffixes are removed/reset accordingly. For example, let’s say we are shortly before the Thunderbird 9.0 release. The latest releases were Thunderbird 8.2, Thunderbird Beta 9.0b4, and Thunderbird Daily 10.0a1. Here is what happens:
- The
beta
branch is merged torelease
. The resulting version on release changes from 8.2 to 9.0. - The
main
branch is merged tobeta
. The resulting version on beta changes from 9.0b4 to 10.0b1 - The
main
branch version number is changed from 10.0a1 to 11.0a1
While the version name changes, it must be ensured that the version code stays the same for each branch. Our application IDs are specific to the branch they are on. For example:
- Beta always uses
net.thunderbird.android.beta
as the app ID. Let’s say the version code is 20 at 9.0b4, it will be 21 at 10.0b1. - Likewise, when 9.0b4 becomes 9.0, if the version code on beta is 20 and on release it is 12, then 9.0 becomes 13 and not 21.
Milestones
We’re using GitHub Milestones to track work for each major release. There is only one milestone for the whole major release, so work going into 9.0 and 9.1 would both be in the “Thunderbird 9” milestone. Each milestone has the due date set to the anticipated release date.
There are exactly three open milestones at any given time, some of our automation depends on this being the case. The milestone with the date furthest into the future is the target for the main
branch, the one closest is the target for the release
branch. When an uplift occurs, the milestone is changed to the respective next target.
Learn more on the milestones page
Merge Process
The merge process enables various benefits, including:
- Carrying forward main branch history to beta, and beta branch history to release.
- No branch history is lost.
- Git tags are retained in the git log.
- Files/code that is unique per branch can remain that way (e.g. notes files such as changelog_master.xml, version codes).
The following steps are taken when merging main into beta:
- Lock the main branch with the ‘CLOSED TREE (main)’ ruleset
- 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
- Review merge results and ensure correctness
- Ensure feature flags are following the rules
- Push the merge
- Submit a pull request that increments the version in main
- Open a new milestone for the new version on github
- Once the version increment is merged into main, unlock the branch
- 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:
- 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
- Review merge results and ensure correctness
- Ensure feature flags are following the rules
- Push the merge
- Close the milestone for the version that was previously in release
- Send a message to the #tb-mobile-dev:mozilla.org channel to notify of merge completion
Merges are performed with the do_merge.sh
script.
The following will merge main into beta:
scripts/ci/merges/do_merge.sh beta
And the following will merge beta into release:
scripts/ci/merges/do_merge.sh release
Be sure to review merge results and ensure correctness before pushing to the repository.
Files of particular importance are:
- app-k9mail/build.gradle.kts
- app-thunderbird/build.gradle.kts
- app-k9mail/src/main/res/raw/changelog_master.xml
These build.gradle.kts files must be handled as described in “Merge Days” section above. This is part of the do_merge.sh automation. The app-k9mail/src/main/res/raw/changelog_master.xml should not include any beta notes in the release branch.
Branch Uplifts
If the urgency of a fix requires it to be included in the Beta or Release channel before the next merge, the uplift process is followed. If possible, uplifts should be avoided and patches should “ride the train” instead, following the merge day cycle.
Uplift Criteria
Beta uplifts should:
- Be limited to bug/security fixes only (features ride the train).
- Not change any localizable strings
- Have tests, or a strong statement of what can be done in the absence of tests.
- Have landed in main and stabilized on the daily channel.
- Have a comment in the GitHub issue assessing performance impact, risk, and reasons the patch is needed on beta.
Release uplifts should additionally:
- Be limited to stability/security fixes only (features ride the train).
- Have landed in beta and stabilized on the beta channel.
Uplift Process
- The requestor adds the “task: uplift to beta” or “task: uplift to release” label to a merged pull request.
- The requestor makes a comment in the associated issue with the Approval Request Comment template filled out.
- The release driver reviews all uplift requests and, retaining the label for approved uplifts and removing the label for rejected uplifts.
- The release driver runs the Uplift Merges action for the specified target branch, which will remove the label, adjust the milestone, cherry-pick the commits, and push to the target branch.
Template for uplift requests:
[Approval Request Comment]
Original Issue/Pull request:
Regression caused by (issue #):
User impact if declined:
Testing completed (on daily, etc.):
Risk to taking this patch (and alternatives if risky):
Releases
Releases for both K-9 and Thunderbird for Android are automated with github actions. Daily builds are scheduled with the Daily Builds action and all builds are performed by the Shippable Build & Signing action.
For the historical manual release process, see Releasing.
Release Process
These are the general steps for a release:
- Perform merge or uplifts. Each release is the result of either a merge or uplift.
- Draft release notes at thunderbird-notes.
- Trigger build via the Shippable Build & Signing action.
- 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
- 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
- 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.
- 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.
- Update F-Droid to new TfA and K-9 releases by sending a pull request to fdroiddata
- Send community updates to Matrix channels, and beta or planning mailing lists as needed.
- 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.
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 uploadsthunderbird-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.
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.
Create K-9 Mail releases
One-time setup
- Create a
.signing
folder in the root of the Git repository, if it doesn’t exist yet. - Download the
k9-release-signing.jks
andk9.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
-
Install fdroidserver by following the installation instructions.
- On MacOS, it’s best to install the latest version from source, because the version in Homebrew has some issues.
-
Install the android command line tools if not available already.
brew install --cask android-commandlinetools
-
Install latest fdroidserver from source:
python -m venv fdroidserver-env source fdroidserver-env/bin/activate pip install git+https://gitlab.com/fdroid/fdroidserver.git
-
To use fdroidserver from the command line, you need to activate the virtual environment before each use:
source fdroidserver-env/bin/activate
-
To deactivate the virtual environment:
deactivate
-
- On MacOS, it’s best to install the latest version from source, because the version in Homebrew has some issues.
-
Sign up for a Gitlab account and fork the fdroiddata repository.
-
Clone your fork of the fdroiddata repository.
Release a beta version
-
Update versionCode and versionName in
app-k9mail/build.gradle.kts
-
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).
-
Update the metadata link to point to K-9 Mail’s data:
ln --symbolic --no-dereference --force app-metadata/com.fsck.k9 metadata
-
Commit the changes. Message: “Version $versionName”
-
Run
./gradlew clean :app-k9mail:assembleRelease --no-build-cache --no-configuration-cache
-
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
-
Tag as $versionName, e.g.
6.508
-
Copy
app-k9mail/build/outputs/apk/release/app-k9mail-release.apk
ask9-${versionName}.apk
to Google Drive (MZLA Team > K9 > APKs) -
Change versionName in
app-k9mail/build.gradle.kts
to next version name followed by-SNAPSHOT
-
Commit the changes. Message: “Prepare for version $newVersionName”
-
Update
gh-pages
branch with the new change log -
Push
main
branch -
Push tags
-
Push
gh-pages
branch
Create release on GitHub
- Go to https://github.com/thunderbird/thunderbird-android/tags and select the appropriate tag
- Click “Create release from tag”
- 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
-
Fetch the latest changes from the fdroiddata repository.
-
Switch to a new branch in your copy of the fdroiddata repository.
-
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 adjustingversionName
,versionCode
, andcommit
(use the tag name). LeaveCurrentVersion
andCurrentVersionCode
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
-
Commit the changes. Message: “Update K-9 Mail to $newVersionName (beta)”
-
Run
fdroid build --latest com.fsck.k9
to build the project using F-Droid’s toolchain. -
Push the changes to your fork of the fdroiddata repository.
-
Open a merge request on Gitlab. (The message from the server after the push in the previous step should contain a URL)
-
Select the App update template and fill it out.
-
Create merge request and the F-Droid team will do the rest.
Create release on Google Play
- Go to the Google Play Console
- Select the K-9 Mail app
- Click on Open testing in the left sidebar
- Click on Create new release
- Upload the APK to App bundles
- Fill out Release name (e.g. “$versionCode ($versionName)”)
- Fill out Release notes (copy from
app-metadata/com.fsck.k9/en-US/changelogs/${versionCode}.txt
) - Click Next
- Review the release
- Configure a full rollout for beta versions
- On the Publishing overview page, click Send change for review
- Wait for the review to complete
- 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.
-
Switch to the appropriate maintenance branch, e.g.
6.8-MAINT
-
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
) -
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).
-
Update the metadata link to point to K-9 Mail’s data:
ln --symbolic --no-dereference --force app-metadata/com.fsck.k9 metadata
-
Commit the changes. Message: “Version $versionName”
-
Run
./gradlew clean :app-k9mail:assembleRelease --no-build-cache --no-configuration-cache
-
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
-
Tag as $versionName, e.g.
6.800
-
Copy
app-k9mail/build/outputs/apk/release/app-k9mail-release.apk
ask9-${versionName}.apk
to Google Drive (MZLA Team > K9 > APKs) -
Update
gh-pages
branch with the new change log. Create a new file if it’s the first stable release in a series. -
Push maintenance branch
-
Push tags
-
Push
gh-pages
branch
Create release on GitHub
- Go to https://github.com/thunderbird/thunderbird-android/tags and select the appropriate tag
- Click “Create release from tag”
- 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
-
Fetch the latest changes from the fdroiddata repository.
-
Switch to a new branch in your copy of the fdroiddata repository.
-
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 adjustingversionName
,versionCode
, andcommit
(use the tag name). ChangeCurrentVersion
andCurrentVersionCode
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
-
Commit the changes. Message: “Update K-9 Mail to $newVersionName”
-
Run
fdroid build --latest com.fsck.k9
to build the project using F-Droid’s toolchain. -
Push the changes to your fork of the fdroiddata repository.
-
Open a merge request on Gitlab. (The message from the server after the push in the previous step should contain a URL)
-
Select the App update template and fill it out.
-
Create merge request and the F-Droid team will do the rest.
Create release on Google Play
- Go to the Google Play Console
- Select the K-9 Mail app
- Click on Production in the left sidebar
- Click on Create new release
- Upload the APK to App bundles
- Fill out Release name (e.g. “$versionCode ($versionName)”)
- Fill out Release notes (copy from
app-k9mail/fastlane/metadata/android/en-US/changelogs/${versionCode}.txt
) - Click Next
- Review the release
- Start with a staged rollout (usually 20%)
- On the Publishing overview page, click Send change for review
- Wait for the review to complete
- In case of a rejection, fix the issues and repeat the process
- 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
.
Managing strings
We use Android’s resource system to localize user-visible strings in our apps.
Our source language is English (American English to be more precise, but simply “English” (en) on Weblate).
Translations of source strings happen exclusively in our Weblate project. This means the source language is only modified by changes to this repository, i.e. via pull requests. Translations are only updated on Weblate and then merged into this repository by the Thunderbird team. This is to avoid overlapping changes in both repositories that will lead to merge conflicts.
Adding a string
Add a new string to the appropriate res/values/strings.xml
file.
Please don’t add any translations for this new string to this repository. If you can also provide a translation for the new string, wait until the change is merged into this repository and propagated to Weblate. Then translate the new string on Weblate.
Changing a string
Changing a string should be avoided. Weblate doesn’t automatically invalidate translations when a source string is changed. This can be worked around by removing the old string and adding a new one. Make sure to only modify the source language. It’s fine for the translations to then contain unused strings. The next merge with Weblate will remove those.
Removing a string
Remove the source string from res/values/strings.xml
. Don’t modify translations under res/values-<lang>/strings.xml
.
The next merge from Weblate will automatically get rid of the translated strings.
Changing translations in this repository
This should be avoided whenever possible, as it can create merge conflicts between Weblate and this repository. If you need to change individual strings, please translate them on Weblate instead. If a mechanical change is necessary across all languages, this should be discussed with the core team who will use this procedure:
- Lock all components on Weblate by clicking the “Lock” button in the repository maintenance screen.
- Commit all outstanding changes by clicking the “Commit” button in the same screen.
- Trigger creating a pull request containing translation updates from Weblate by clicking the “Push” button in the repository maintenance screen.
- Merge that pull request containing updates from Weblate into this repository.
- Create a pull request to change the translated files, following the established procedures to get it merged. Make sure you’ve rebased against the latest changes.
- Wait for the changes in this repository to be automatically propagated to and processed by Weblate.
- Unlock components on Weblate by clicking the “Unlock” button in the repository maintenance screen.
Managing translations
Right now we’re using the androidResources.localeFilters
mechanism provided by the Android Gradle Plugin to limit
which languages are included in builds of the app,
See localFilters.
This list needs to be kept in sync with the string array supported_languages
, so the in-app language picker offers
exactly the languages that are included in the app.
Removing a language
- Remove the language code from the
androidResources.localeFilters
list inapp-thunderbird/build.gradle.kts
andapp-k9mail/build.gradle.kts
. - Remove the entry from
supported_languages
inapp/core/src/main/res/values/arrays_general_settings_values.xml
.
Adding a language
- Add the language code to the
androidResources.localeFilters
list inapp-thunderbird/build.gradle.kts
andapp-k9mail/build.gradle.kts
. - Add an entry to
supported_languages
inapp/core/src/main/res/values/arrays_general_settings_values.xml
. - Make sure that
language_values
inapp/core/src/main/res/values/arrays_general_settings_values.xml
contains an entry for the language code you just added. If not:- Add the language name (in its native script) to
language_entries
inapp/ui/legacy/src/main/res/values/arrays_general_settings_strings.xml
. Please note that this list should be ordered using the Unicode default collation order. - Add the language code to
language_values
inapp/core/src/main/res/values/arrays_general_settings_values.xml
so that the index in the list matches that of the newly added entry inlanguage_entries
.
- Add the language name (in its native script) to
Adding a component on Weblate
When adding a new code module that is including translatable strings, a new components needs to be added to Weblate.
- Go the the Weblate page to add a component.
- Switch to the “From existing component” tab.
- Enter a name for the component.
- For “Component”, select “K-9 Mail/Thunderbird/ui-legacy”.
- Press the “Continue” button.
- Under “Choose translation files to import”, select “Specify configuration manually”.
- Press the “Continue” button.
- For “File format”, select “Android String Resource”.
- Under “File mask”, enter the path to the string resource files with a wildcard,
e.g.
feature/account/common/src/main/res/values-*/strings.xml
. - Under “Monolingual base language file”, enter the path to the string source file,
e.g.
feature/account/common/src/main/res/values/strings.xml
. - Uncheck “Edit base file”.
- For “Translation license”, select “Apache License 2.0”.
- Press the “Save” button.
Things to note
For some languages Android uses different language codes than typical translation tools, e.g. Hebrew’s code is he on Weblate, but iw on Android. When writing automation tools, there needs to be a mapping step involved.
See translation-cli for an example.
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:
- mdbook-external-links for opening external links in a new tab.
- mdbook-last-changed for last change date inclusion.
- mdbook-mermaid for diagram generation.
- mdbook-pagetoc for automatic page table of contents.
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.