{"id":51185730,"url":"https://github.com/aoreshkov/kmp-ledger","last_synced_at":"2026-06-27T10:00:50.192Z","repository":{"id":357458543,"uuid":"1234777152","full_name":"aoreshkov/kmp-ledger","owner":"aoreshkov","description":"Kotlin Multiplatform (Android + Desktop) reference app demonstrating clean architecture, convention plugins, Room 3, Navigation 3, and Koin annotation DI with Compose Multiplatform.","archived":false,"fork":false,"pushed_at":"2026-06-23T20:57:44.000Z","size":733,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-23T22:27:33.854Z","etag":null,"topics":["android","clean-architecture","compose-multiplatform","desktop","koin","kotlin","kotlin-multiplatform","kotlin-multiplatform-library","navigation3","room-persistence-library"],"latest_commit_sha":null,"homepage":"","language":"Kotlin","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/aoreshkov.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":".github/CODEOWNERS","security":".github/SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-10T16:20:49.000Z","updated_at":"2026-06-23T20:57:48.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/aoreshkov/kmp-ledger","commit_stats":null,"previous_names":["aoreshkov/kmp-ledger"],"tags_count":4,"template":false,"template_full_name":null,"purl":"pkg:github/aoreshkov/kmp-ledger","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/aoreshkov%2Fkmp-ledger","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/aoreshkov%2Fkmp-ledger/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/aoreshkov%2Fkmp-ledger/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/aoreshkov%2Fkmp-ledger/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/aoreshkov","download_url":"https://codeload.github.com/aoreshkov/kmp-ledger/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/aoreshkov%2Fkmp-ledger/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34848932,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-27T02:00:06.362Z","response_time":126,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["android","clean-architecture","compose-multiplatform","desktop","koin","kotlin","kotlin-multiplatform","kotlin-multiplatform-library","navigation3","room-persistence-library"],"created_at":"2026-06-27T10:00:42.765Z","updated_at":"2026-06-27T10:00:50.176Z","avatar_url":"https://github.com/aoreshkov.png","language":"Kotlin","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Ledger\n\n![Kotlin](https://img.shields.io/badge/Kotlin-2.4%2B-7F52FF?logo=kotlin\u0026logoColor=white)\n![Compose Multiplatform](https://img.shields.io/badge/Compose-Multiplatform-4285F4?logo=jetpackcompose\u0026logoColor=white)\n![Room 3](https://img.shields.io/badge/Database-Room%203-3DDC84?logo=android\u0026logoColor=white)\n![Navigation 3](https://img.shields.io/badge/Navigation-3-blue)\n![License](https://img.shields.io/badge/License-MIT-yellow.svg)\n\n**Supported Platforms:**\n\n![Android](https://img.shields.io/badge/Platform-Android-3DDC84?style=for-the-badge\u0026logo=android\u0026logoColor=white)\n![iOS](https://img.shields.io/badge/Platform-iOS-000000?style=for-the-badge\u0026logo=apple\u0026logoColor=white)\n![Desktop](https://img.shields.io/badge/Platform-Desktop-ED8B00?style=for-the-badge\u0026logo=openjdk\u0026logoColor=white)\n\n---\n\n**Ledger** is a Kotlin Multiplatform reference project for Android, iOS, and Desktop. Its primary goal is to demonstrate production-grade architecture and design patterns using the latest Jetpack and Compose Multiplatform libraries — including several that are still in alpha or beta. It is intentionally simple in domain (basic financial postings) so that the architecture, not the business logic, is the focus.\n\n\u003e **Note:** Kotlin, Compose Multiplatform, Navigation 3, Koin, and Coroutines are all on stable releases. The exceptions are libraries the wider Kotlin ecosystem hasn't stabilized yet: Room 3 / AndroidX SQLite (Release Candidate), AndroidX Lifecycle and Material3 Adaptive (Beta), and Material3 components (Alpha). Pinned versions are recorded in [`gradle/libs.versions.toml`](gradle/libs.versions.toml).\n\n---\n\n## Screenshots\n\n|                      Posting List                       |                      Posting Detail                       |                      Edit Posting                       |\n|:-------------------------------------------------------:|:---------------------------------------------------------:|:-------------------------------------------------------:|\n| \u003cimg src=\".github/assets/postingList.webp\" width=\"250\"\u003e | \u003cimg src=\".github/assets/postingDetail.webp\" width=\"250\"\u003e | \u003cimg src=\".github/assets/postingEdit.webp\" width=\"250\"\u003e |\n\n---\n\n## Tech Stack\n\n| Library | Version | Role |\n|---|---|---|\n| Kotlin | 2.4.0 | Language and compiler |\n| Kotlin Gradle Plugin | 2.4.0 | Build tooling |\n| Compose Multiplatform | 1.11.1 | Shared UI (Android, iOS, Desktop) |\n| Room 3 / SQLite | 3.0.0-rc01 / 2.7.0-rc01 | Local database with KMP support |\n| Navigation 3 | 1.1.3 (runtime) / 1.1.1 (ui) | Type-safe declarative navigation |\n| Koin | 4.2.2 | Dependency injection with annotation processing |\n| Kermit | 2.1.0 | Kotlin Multiplatform logging |\n| Kover | 0.9.8 | Kotlin Multiplatform code coverage |\n| SLF4J / Logback | 2.0.18 / 1.5.34 | Desktop logging implementation |\n| Swift Export | Experimental | Direct Kotlin-to-Swift bridge (No Obj-C) |\n| Kotlinx Coroutines | 1.11.0 | Async and Flow-based data streams |\n| Lifecycle / ViewModel | 2.11.0-beta02 | State management and lifecycle-aware components |\n| Material3 Adaptive | 1.3.0-beta02 | List/detail adaptive layouts |\n| Material3 (Compose) | 1.11.0-alpha07 | Material You components |\n| Android SDK | compile/target 37, min 24 | Android target |\n\n---\n\n## Architecture\n\nLedger follows a strict unidirectional layered architecture. Each layer depends only on the layer directly below it, and all domain types flow upward through mappers — never raw database entities.\n\n```\n┌─────────────────────────────────────┐\n│ androidApp / iosApp / desktopApp    │  Platform entry points\n└───────┬──────────────┬──────────────┘\n        │              │\n        │      ┌───────▼───────┐\n        │      │   iosExport   │  Swift Export bridge\n        │      └───────┬───────┘\n        │              │\n┌───────▼──────────────▼──────────────┐\n│              core:ui                │  App composable, theme, NavDisplay setup\n│           core:navigation           │  Navigator, StartDestination\n│           core:bootstrap            │  Root Koin module wiring\n└────────────────┬────────────────────┘\n                 │\n┌────────────────▼────────────────────┐\n│       feature:posting:impl          │  Screens, ViewModels, DI\n│       feature:posting:api           │  Navigation keys (NavKey contracts)\n└────────────────┬────────────────────┘\n                 │\n┌────────────────▼────────────────────┐\n│           core:domain               │  Use cases (SavePosting, GetPosting, …)\n│           core:common               │  DataResult, shared utilities\n│           core:compose              │  Shared Compose components\n└────────────────┬────────────────────┘\n                 │\n┌────────────────▼────────────────────┐\n│            core:data                │  PostingRepository, mappers\n└────────────────┬────────────────────┘\n                 │\n┌────────────────▼────────────────────┐\n│           core:database             │  Room 3 database, DAOs, entities, TypeConverters\n└─────────────────────────────────────┘\n\nSupporting modules (no layer dependency):\n  core:model   — pure Kotlin domain models (Posting, NewPosting)\n  core:test    — shared test utilities (FakePostingRepository)\n  build-logic  — Gradle convention plugins\n```\n\n### Module responsibilities\n\n| Module | Responsibility |\n|---|---|\n| `core:model` | Pure Kotlin data classes with no framework dependency. The single source of truth for domain types. |\n| `core:database` | Room 3 entities, DAOs, TypeConverters, and platform-specific database builders. |\n| `core:data` | `PostingRepository` interface and its `OfflineFirstPostingRepository` implementation. Contains entity↔model mappers. |\n| `core:domain` | One use case per operation (`GetPostingUseCase`, `SavePostingUseCase`, `DeletePostingUseCase`, `GetPostingsUseCase`). Each wraps a repository call with a single responsibility. |\n| `core:common` | `DataResult\u003cT\u003e` sealed interface and the `Flow\u003cT\u003e.asResult()` extension, the `runCatchingCancellable` cancellation-safe result helper, plus `AppDispatchers` interface and `DispatcherModule` Koin binding. |\n| `core:compose` | Shared Compose components used across feature modules (e.g. `LabeledField`). |\n| `core:navigation` | `Navigator` (backstack wrapper) and `StartDestination` value class. Framework-agnostic. |\n| `core:ui` | Root `App` composable, Material 3 theme, `NavDisplay` wiring. |\n| `core:bootstrap` | Root Koin module that wires all sub-modules together and provides `StartDestination` and `SavedStateConfiguration`. |\n| `core:test` | `FakePostingRepository`, `PlatformComposeUiTest` expect/actual, and shared posting fixture builders (`posting()`, `newPosting()`, `postings()`). Consumed by all test source sets. |\n| `iosExport` | Bridge module for Swift Export. Contains the `MainViewController` and Koin initialization for iOS. |\n| `feature:posting:api` | `NavKey` data classes (`PostingList`, `PostingDetail`, `PostingEdit`) and their serializers module. Consumed by both the feature impl and the bootstrap/navigation modules. |\n| `feature:posting:impl` | `PostingListScreen`, `PostingDetailsScreen`, `PostingEditScreen`, their ViewModels, and the Koin navigation module (`postingNavigationModule`). |\n\n---\n\n## Design Patterns\n\n### 1. Convention plugins (`build-logic`)\n\nRather than duplicating Gradle configuration across modules, all shared setup lives in three composable convention plugins:\n\n```\nledger.kotlin.multiplatform          → KMP + Android library targets, JVM 21, kotlin-test\n  └─ (applied automatically)          → + JetBrains Kover (code coverage)\n  └─ ledger.kotlin.multiplatform.koin     → + Koin core, annotations, and compiler plugin\n       └─ ledger.kotlin.multiplatform.koin.compose  → + Compose, resources, ui-test, core:test\n```\n\nEach module picks the plugin that matches its needs. A UI feature module uses one line:\n\n```kotlin\n// feature/posting/impl/build.gradle.kts\nplugins {\n    id(\"ledger.kotlin.multiplatform.koin.compose\")\n}\n```\n\nA pure domain module uses the lighter variant:\n\n```kotlin\n// core/domain/build.gradle.kts\nplugins {\n    id(\"ledger.kotlin.multiplatform.koin\")\n}\n```\n\nThe compose convention plugin can also emit Compose compiler stability/skippability reports on demand:\n\n```bash\n./gradlew assemble -Pledger.composeCompilerReports=true\n```\n\nReports are written to each module's `build/compose_compiler/` directory and are off by default so normal builds aren't slowed.\n\n### 2. Feature API / Implementation split\n\nEach feature is split into two modules:\n\n- **`feature:posting:api`** — contains only the `NavKey` sealed types that other modules reference for navigation. Has no dependency on Compose or domain logic.\n- **`feature:posting:impl`** — contains the screens, ViewModels, and DI. Depends on `api` but is never depended on by any other feature or core module.\n\nThis means modules that need to navigate *to* a feature only depend on `api`, keeping compile-time coupling minimal.\n\n### 3. `DataResult` + `asResult()` for UI state\n\n`DataResult\u003cT\u003e` is a sealed interface that wraps any Flow into three standard states:\n\n```kotlin\nsealed interface DataResult\u003cout T\u003e {\n    data class Success\u003cT\u003e(val data: T) : DataResult\u003cT\u003e\n    data class Error(val exception: Throwable) : DataResult\u003cNothing\u003e\n    data object Loading : DataResult\u003cNothing\u003e\n}\n\nfun \u003cT\u003e Flow\u003cT\u003e.asResult(): Flow\u003cDataResult\u003cT\u003e\u003e = map { DataResult.Success(it) }\n    .onStart { emit(DataResult.Loading) }\n    .catch { emit(DataResult.Error(it)) }\n```\n\nViewModels call `.asResult()` on any domain Flow and `when`-switch the result directly into a sealed UI state, keeping the mapping explicit and exhaustive:\n\n```kotlin\ngetPostingsUseCase()\n    .asResult()\n    .map { result -\u003e\n        when (result) {\n            is DataResult.Loading -\u003e PostingListUiState.Loading\n            is DataResult.Success -\u003e if (result.data.isEmpty()) PostingListUiState.Empty\n                                     else PostingListUiState.Success(result.data)\n            is DataResult.Error   -\u003e PostingListUiState.Error\n        }\n    }\n```\n\n### 4. `expect`/`actual` for platform database initialisation\n\nRoom 3 requires a platform-specific builder. Ledger uses an `expect class` to enforce that every platform provides its own builder, while common code only sees the abstract `RoomDatabase.Builder\u003cLedgerDatabase\u003e`:\n\n```kotlin\n// commonMain — contract\n@Module\nexpect class PlatformDatabaseModule\n\n// androidMain — Android implementation using Context\n@Module actual class PlatformDatabaseModule {\n    @Single\n    fun provideRoomBuilder(@Provided context: Context): RoomDatabase.Builder\u003cLedgerDatabase\u003e =\n        Room.databaseBuilder(context, name = context.getDatabasePath(\"ledger.db\").absolutePath)\n}\n\n// jvmMain — Desktop implementation with OS-aware file path\n@Module actual class PlatformDatabaseModule {\n    @Single\n    fun provideRoomBuilder(): RoomDatabase.Builder\u003cLedgerDatabase\u003e =\n        Room.databaseBuilder(name = jvmDatabaseFile().absolutePath)\n}\n```\n\nThe JVM implementation resolves the correct data directory for macOS (`~/Library/Application Support`), Windows (`%APPDATA%`), and Linux (`$XDG_DATA_HOME` or `~/.local/share`).\n\n### 5. Koin annotation-driven DI\n\nAll modules and bindings are declared with Koin annotations rather than DSL, enabling compile-time validation of the dependency graph:\n\n```kotlin\n@Module(includes = [DataModule::class])\n@ComponentScan(\"app.oreshkov.ledger.core.domain\")\nclass DomainModule\n\n@Factory\nclass GetPostingUseCase(private val repository: PostingRepository) { ... }\n\n@KoinViewModel\nclass PostingDetailsViewModel(\n    @Provided private val getPostingUseCase: GetPostingUseCase,\n    @Provided private val deletePostingUseCase: DeletePostingUseCase,\n    @InjectedParam private val postingId: String\n) : ViewModel()\n```\n\n### 6. Navigation 3 with `koinEntryProvider`\n\nScreens are registered as Koin navigation entries, keeping navigation and DI fully integrated without manual ViewModel factories:\n\n```kotlin\nval postingNavigationModule = module {\n    navigation\u003cPostingList\u003e(metadata = ListDetailSceneStrategy.listPane()) {\n        val navigator = LocalNavigator.current\n        PostingListScreen(\n            onNavigateToEdit = { id -\u003e navigator.goTo(PostingEdit(id)) },\n            onNavigateToDetails = { id -\u003e navigator.goTo(PostingDetail(id)) },\n            viewModel = koinViewModel()\n        )\n    }\n    navigation\u003cPostingDetail\u003e(metadata = ListDetailSceneStrategy.detailPane()) { route -\u003e\n        val navigator = LocalNavigator.current\n        PostingDetailsScreen(\n            onNavigateBack = { navigator.goBack() },\n            viewModel = koinViewModel(parameters = { parametersOf(route.id) })\n        )\n    }\n}\n```\n\n### 7. Multiplatform Logging with Kermit\n\nLedger uses **Kermit** for unified logging. Platform-specific writers are provided via `expect`/`actual` functions in `core:common`, ensuring that logs are routed to the appropriate system (Logcat on Android, OSLog on iOS, and SLF4J/Logback on Desktop).\n\n### 8. Injectable dispatcher seam (`AppDispatchers`)\n\n`AppDispatchers` is an injectable interface over the coroutine dispatchers used by the data layer:\n\n```kotlin\ninterface AppDispatchers {\n    val io: CoroutineDispatcher\n    val default: CoroutineDispatcher\n}\n```\n\nProduction code receives `DefaultAppDispatchers` (backed by `Dispatchers.IO` / `Dispatchers.Default`) via Koin. Tests supply a `TestAppDispatchers` backed by a `TestDispatcher`, so `withContext` and `flowOn` calls run on the test scheduler without any platform-specific thread semantics. `DispatcherModule` in `core:common` wires the production binding; `core:data` consumes it via `DataModule`.\n\n---\n\n## Testing Strategy\n\nAll tests use pure Kotlin — no mocking framework.\n\n**Fakes over mocks:** `FakePostingRepository` is a full in-memory implementation of `PostingRepository` backed by a `MutableStateFlow`. It exposes `insertedPostings`, `deletedPostings`, and `updatedPostings` lists for assertions, and `failNextWrite` / `shouldThrowOnGetById` flags to simulate error conditions without any mock library.\n\n**ViewModel tests** use `UnconfinedTestDispatcher` set as the main dispatcher in `@BeforeTest`, ensuring coroutines and `StateFlow` updates run eagerly and can be asserted synchronously.\n\n**Code Coverage:**\nThe project uses **JetBrains Kover** for multiplatform coverage tracking. Coverage is automatically collected for all `commonMain` logic across JVM and Android targets. Reports are aggregated at the root project level and filtered to exclude generated code (Koin factories, Compose singletons, etc.).\n\nCoverage is **enforced**, not just reported: CI runs `koverVerify`, which fails the build if coverage drops below the configured floors. The root project sets aggregate floors (88% line / 60% branch / 84% instruction), and the pure-logic modules `core:data`, `core:domain`, and `feature:posting:api` each add stricter per-module floors (90% line / 85% branch).\n\nIn CI, the `check` job runs `koverXmlReport koverVerify` and posts a coverage summary as a comment on each pull request, alongside a test-results summary with inline annotations for any failures. The aggregated HTML report is also uploaded as a build artifact. Docs-only changes skip the build and test jobs entirely (a `changes` path filter gates them, with a single `ci-success` status check as the required gate).\n\nA separate `instrumented-tests` job runs the Android smoke suite on a **Gradle Managed Device** (`aospAtd30`: Pixel 2, API 30, AOSP ATD). This exercises the real shipped APK — real Koin startup, real platform Room database, real Navigation 3 graph — keeping end-to-end integration coverage separate from the JVM host-test pass. Supply-chain hardening is provided by a `dependency-review` workflow and Dependabot-maintained, SHA-pinned GitHub Actions.\n\n**Layer coverage:**\n\n| Layer | Test approach |\n|---|---|\n| `core:database` | DAO tests against a real in-memory Room 3 database |\n| `core:data` | `OfflineFirstPostingRepositoryTest` with `FakePostingDao` |\n| `core:domain` | Use case tests with `FakePostingRepository` |\n| `core:common` | `DataResultTest` for the `asResult()` extension |\n| `feature:posting:impl` | ViewModel unit tests + Compose UI tests (screen-level) |\n| `core:ui` | App-level Compose UI test |\n| DI modules | Compile-time validation (Koin Compiler Plugin) + runtime `verify()` (`KoinModuleTest`) + App-level integration tests |\n\n---\n\n## Getting Started\n\n### Prerequisites\n\n- Android Studio Meerkat or newer\n- JDK 21+\n\n### Run on Android\n\n```bash\n./gradlew :androidApp:installDebug\n```\n\n### Run on Desktop\n\n```bash\n./gradlew :desktopApp:run\n```\n\n### Run on iOS\n\n1.  Open `iosApp/iosApp.xcodeproj` in Xcode.\n2.  Select a simulator or real device.\n3.  Click **Run**.\n\n\u003e **Note:** The iOS app uses the experimental **Swift Export** for direct Kotlin-to-Swift interoperability. For ProMotion (120Hz) support, ensure `CADisableMinimumFrameDurationOnPhone` is set to `YES` in the **Target \u003e Info** tab of the Xcode project (Xcode 13+ manages this via a generated plist).\n\n### Run all tests\n\n```bash\n./gradlew allTests\n```\n\n### Generate Coverage Report\n\n```bash\n./gradlew koverHtmlReport\n```\nThe aggregated report will be generated at `build/reports/kover/html/index.html`.\n\n---\n\n## Project Structure\n\n```\nkmp-ledger/\n├── build-logic/                  # Convention plugins\n│   └── src/main/kotlin/\n│       ├── ledger.kotlin.multiplatform.gradle.kts\n│       ├── ledger.kotlin.multiplatform.koin.gradle.kts\n│       └── ledger.kotlin.multiplatform.koin.compose.gradle.kts\n├── gradle/\n│   └── libs.versions.toml        # Centralised version catalog\n├── androidApp/                   # Android entry point\n├── iosApp/                       # iOS entry point (Swift)\n├── desktopApp/                   # Desktop (JVM) entry point\n├── iosExport/                    # Swift Export bridge for iOS\n├── core/\n│   ├── bootstrap/                # Root DI wiring\n│   ├── common/                   # DataResult, asResult()\n│   ├── compose/                  # Shared Compose components\n│   ├── data/                     # Repository implementations and mappers\n│   ├── database/                 # Room 3 database, DAOs, TypeConverters\n│   ├── domain/                   # Use cases\n│   ├── model/                    # Pure domain models\n│   ├── navigation/               # Navigator, StartDestination\n│   ├── test/                     # Shared test utilities\n│   └── ui/                       # App composable, theme\n└── feature/\n    └── posting/\n        ├── api/                  # NavKey contracts\n        └── impl/                 # Screens, ViewModels, DI\n```\n\n---\n\n## License\n\nThis project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details.\n\n---\n\n## Releasing\n\nTo release a new version:\n\n1. Update `ledger.version.name` and `ledger.version.code` in `gradle.properties`.\n2. Add the new version and its changes to `CHANGELOG.md` under the `## [Unreleased]` section.\n3. Once ready, change `## [Unreleased]` to `## [x.y.z] - YYYY-MM-DD`.\n4. Commit and push your changes to `main`.\n5. Create and push a new tag:\n   ```bash\n   git tag v1.0.0\n   git push origin v1.0.0\n   ```\n6. The GitHub Release workflow will automatically build the binaries and create a GitHub Release with the changelog notes.\n\n\u003e **Note:** The release workflow includes a `verify-version` job that fails immediately if the pushed tag does not match `ledger.version.name` in `gradle.properties`. It also generates a `SHA256SUMS` file attached to the release and publishes a [SLSA build provenance attestation](https://slsa.dev) via `actions/attest`.\n\n### Android release signing\n\nThe release workflow signs the Android APK when the following **repository secrets**\n(Settings → Secrets and variables → Actions) are present. If they are absent, the\nrelease job logs a warning and produces an unsigned APK; local `assembleRelease`\nbuilds are always unsigned.\n\n| Secret | Description |\n|---|---|\n| `ANDROID_KEYSTORE_BASE64` | The signing keystore (`.jks`/`.keystore`) file, base64-encoded without line wrapping (`base64 -w0 your.jks`). |\n| `ANDROID_KEYSTORE_PASSWORD` | Password for the keystore (store password). |\n| `ANDROID_KEY_ALIAS` | Alias of the signing key inside the keystore. |\n| `ANDROID_KEY_PASSWORD` | Password for that key (equal to the store password for PKCS12 keystores). |\n\nThe alias and passwords are chosen when the keystore is created\n(`keytool -genkeypair -keystore your.jks -alias \u003calias\u003e -keyalg RSA -keysize 2048 -validity 10000 -storetype PKCS12`).\nKeep the keystore backed up and private — for Google Play distribution it is your\n**upload key**, and updates must be signed with the same key.","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Faoreshkov%2Fkmp-ledger","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Faoreshkov%2Fkmp-ledger","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Faoreshkov%2Fkmp-ledger/lists"}