Technical Documentation: kurikomi-labs/komi-store
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (README.md)
[!CAUTION]
Free and Open-Source Android is under threat. Google will turn Android into a locked-down platform, restricting your essential freedom to install apps of your choice. Make your voice heard – keepandroidopen.org.
| 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|
Download
[!IMPORTANT]
macOS Users: You may see a warning that Apple cannot verify Komi Store. This happens because the app is distributed outside the App Store and is not notarized yet. Allow it via System Settings → Privacy & Security → Open Anyway, or runxattr -dr com.apple.quarantine /Applications/Komi-Store.appafter installing.
Scoop
scoop bucket add komi-store https://github.com/komi-store/komi-store-scoop-bucket
scoop install komi-store/komi-storeWinGet
winget install zed.rainxch.githubstore[!TIP]
macOS Users: Install Komi Store with Homebrew.
Homebrew
brew tap komi-store/komi-store
brew install --cask komi-storeFeatures
- Smart discovery
- Recently Released
- Release browser & installs
- Download mirror system
- Rich details screen
- App management
- Collections
- Tweaks
- Localization (Available in 14 languages)
- Network & performance
Team
| rainxchzed | illumiat | KelvinCrag | YumeYuka |
|---|
Join Community
APK Signing Certificate
SHA-256:B7:F2:8E:19:8E:48:C1:93:B0:38:C6:5D:92:DD:F7:BC:07:7B:0D:B5:9E:BC:9B:25:0A:6D:AC:48:C1:18:03:CA
Business Inquiries
Komi Store is open to partnerships, sponsorships, and integrations.
If you're interested in working together, reach out:
📧 Email: [email protected]
💬 Discord: Join our community
Disclaimer
Komi Store only helps you discover and download release assets that are already published on
GitHub, Codeberg and other forges by third‑party developers.
The contents, safety, and behavior of those downloads are entirely the responsibility of their
respective authors and distributors, not this project.
By using Komi Store, you understand and agree that you install and run any downloaded software at
your own risk.
This project does not review, validate, or guarantee that any installer is safe, free of malware, or
fit for any particular purpose.
Code Style (`docs/code-style.md`)
Komi Store code style
Status: adopted on 2026-10-19 from the decisions in findings.md. Some rules get their automated check in follow-up work; until then they apply to new and touched code. Links point at examples in cfa6bd30.
This file is for people and agents writing code here. CLAUDE.md files point to it. A tag after a rule says what enforces it:
- [Spotless]:
./gradlew spotlessCheckin CI: ktlint 1.8.0 with the Compose rules, on files changed againstmain - [detekt]:
./gradlew detektin CI, with per-module baselines - [Konsist]:
./gradlew :konsist-test:testin CI - [review]: maintainer review, and agents' own review before opening a PR; the mechanical ones move to Konsist in follow-up work
When a rule and nearby code disagree, follow the rule. Fix the nearby code only if your change already touches it.
1. Formatting and files
- ktlint with the Compose rules decides formatting. Run
./gradlew spotlessApplybefore pushing; nothing formats on compile. [Spotless] - No wildcard imports, including
res.*. ktlint'sno-wildcard-importsis still disabled in.editorconfig. [review] - One top-level public type per file, named after it. [review]
- Production files stay under 800 lines. Today's larger files are on an allowlist that can only shrink. [Konsist]
2. Comments
- No KDoc, no comments that restate the code. [review]
- Comment only a non-obvious invariant, tricky concurrency or a workaround, in one or two lines. Good: AppsRepositoryImpl.kt:300-301, BackgroundRequest.kt:13-14.
- No em-dashes anywhere: code, strings, logs, comments. [review]
3. Modules and packages
- Packages are
zed.rainxch.<feature>.<layer>, e.g.zed.rainxch.devprofile.data(hyphens dropped). [Konsist] - Common domain code imports no Ktor, Room, Compose, lifecycle, Koin or Android types. Platform
actuals may. [Konsist] - Presentation never imports a data package. [Konsist]
- Repository interfaces live in domain,
XRepositoryImplin data. [review] - No
java.*orandroid.*incommonMain. Bad: AppsViewModel.kt:74. [review]
4. ViewModels
The recommended shape, already used by 11 ViewModels (AppsViewModel.kt:113-128, StarredReposViewModel.kt:39-55):
class FooViewModel(
private val repository: FooRepository,
) : ViewModel() {
private var hasLoadedInitialData = false
private val _state = MutableStateFlow(FooState())
val state =
_state
.onStart {
if (!hasLoadedInitialData) {
load()
hasLoadedInitialData = true
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), FooState())
private val _events = Channel<FooEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()
fun onAction(action: FooAction) {
when (action) {
FooAction.OnRefresh -> load()
}
}
}FooActionandFooEventare sealed. [Konsist]- One public input:
onAction. No other public functions. [review] - Update state with
_state.update { }, never_state.value = _state.value.copy(...). [review] - Combine flows with
combine, not several collectors writing shadowvars. Bad: HomeViewModel.kt:64-86. [review] - Route arguments come from the route object or one typed params class, never positional
parametersOf(A8). Bad: ViewModelsModule.kt:47-52. [review]
5. State
- A
data class FooStatewith defaults,ImmutableList/ImmutableSetfor collections. [review] - No
@Stable/@Immutableon screen state; keep@Stablefor real state holders like KomiToastState.kt:8. [review] - Derive values in one place before the UI sees them. Good: RawDetailsStateMapper.kt:11. [review]
- No sentinel strings or nullable fields used as flags. Bad: AppsViewModel.kt:880. [review]
- Errors in state are typed, not
String. [review]
6. Errors and cancellation
- Never wrap suspend calls in
runCatching; it swallowsCancellationException. [review] - A broad
catcharound suspend code rethrowsCancellationExceptionfirst. Good: DetailsViewModel.kt:208-212. [review] Throwable.messagenever reaches the user. Map typed errors to resources at render time. Good: the typed cases in MirrorPickerRoot.kt:240-245 (its fallback case on the next line still shows exception text). Bad: DetailsViewModel.kt:3009. [review]- Fallible repository calls return
Result<T>with typed domain exceptions. A typed outcome to copy: ProxyTestOutcome.kt:3-27. - Log with the injected
KomiStoreLoggerand pass the throwable, never only.message. [review]
7. Navigation
- Routes are
@Serializablemembers ofGithubStoreGraph(GithubStoreGraph.kt:5). [review] - A click that only navigates is a lambda on the Screen (
onNavigateToDetails), not an Action. [review] - The ViewModel sends a
Navigate*event only when navigation depends on async work. [review] - No empty
-> Unitbranches for navigation inonAction. Bad: HomeViewModel.kt:212. [review]
8. Composables
- A public
FooRoot(callbacks, viewModel = koinViewModel())collects withcollectAsStateWithLifecycle, handles events withObserveAsEvents, and calls a private statelessFooScreen(state, onAction, modifier). Closest today: ImportStarsRoot.kt:64-88, which still lacks themodifierparameter, as every route-level screen does. [review] - Every composable that emits UI takes
modifier: Modifier = Modifieras its first optional parameter. [Spotless] - Pass leaf components the values they need, not the whole screen state. Bad: SmartInstallButton.kt:91-99. [review]
- No business logic,
koinInject, or dispatcher switching inside composables. Bad: About.kt:98-100, About.kt:140-149. [review] - Lazy list items have stable keys. [review]
- Anything named
rememberXcallsremember. Bad: MarkdownUtils.kt:17. [review]
9. Design system
- Use
Komi*components andLocalPersonality.currenttokens. NoMaterialTheme.*, noColor(0x...)literals, noisSystemInDarkTheme(). Bad: DetailsRoot.kt:305, ExpandableDetails.kt:89. [review] - Before writing a component, look in core/presentation. Shared needs (empty and error states, favourite toggle, search bar, sort dropdown) belong there, not in a feature. [review]
- New core components dispatch on the personality with private Manga / Classic implementations, like KomiButton.kt:70-123. [review]
- Public core components and stateless screens have a preview wrapped in
PersonalityPreview(PersonalityPreview.kt:16). [review]
10. Strings and accessibility
- All user-visible text comes from
Res.string, content descriptions included. Bad: KomiChip.kt:255. [review] - Counts use ``, not "(s)". [review]
- Toggles and selectable rows set
RoleandstateDescription; error states announce themselves withliveRegion. [review] - New keys go into the default
strings.xmlwith a feature prefix (tweaks_...). [review]
11. Data layer and coroutines
- DTOs live in
dto/; mappers live inmappers/astoDomain()extensions. [review] - Use the injected HTTP clients. No
HttpClient {}in feature code: those skip the proxy and are never closed. Bad: FeedRepositoryImpl.kt:41. [review] - Long-lived work runs on the injected app scope, not a private
CoroutineScopein a singleton. [review] - No
runBlockingoutside app startup. [review]
12. DI
- Data modules declare
single<FooRepository> { FooRepositoryImpl(named = get()) }. [review] - ViewModels use
viewModelOf(::FooViewModel)unless they take route arguments. [review] - No stateful
objectsingletons outside DI, and nonamed("test")for production clients. Bad: ProxyManager.kt:23. [review]
13. Tests
kotlin.test, hand-written fakes, no mocking library. [review]- Test names are snake_case sentences, like UpdateVerdictTest.kt:62. [review]
- Shared fakes live in one place per module instead of one copy per test file. [review]
- New ViewModels come with tests once
kotlinx-coroutines-testand Turbine are in the version catalog (follow-up).
14. Docs
- CLAUDE.md files state only what the code can't tell you in a minute. [review]
- Change a CLAUDE.md in the same PR as the code it describes. [review]
Agents Architecture (`docs/agents/architecture.md`)
Architecture
Clean Architecture with MVVM. Domain holds contracts, models and pure logic. Data implements them with Ktor, Room and Koin. Presentation holds ViewModels (StateFlow state, Channel events) and Compose UI. The ViewModel and screen shapes are in docs/code-style.md, sections 4 and 8.
Modules
composeApp/ entry points, navigation, DI wiring (commonMain / androidMain / jvmMain)
core/domain interfaces, models, use cases (no framework deps)
core/data repositories, Ktor, Room, Koin, platform implementations
core/presentation Personality design system (Classic / Manga), Komi* components, 14-locale strings
feature/ apps auth details dev-profile favourites feed home profile recently-viewed repo-pages search starred tweaks
konsist-test/ architecture rules
build-logic/convention convention pluginsEach feature has up to three sub-modules: domain/, data/, presentation/. profile, recently-viewed and tweaks are presentation-only. favourites and starred also have domain/ and data/ modules, currently empty.
Navigation
Routes are members of the @Serializable sealed interface GithubStoreGraph in composeApp/.../app/navigation/GithubStoreGraph.kt; AppNavigation.kt wires them. Two route arguments matter beyond their names:
DetailsScreen.packageName(set from Apps) opens Details on that installed app when a repository ships several apps.DetailsScreen.sourceHostis non-null for Codeberg, Forgejo and custom-forge repositories and routes everyDetailsRepositorycall throughForgejoClientRegistryinstead of the GitHub-backed path.
RepoPullsScreen is registered but nothing navigates to it yet.
DI
Koin. Each feature's data module declares its bindings in data/di/SharedModule.kt. ViewModels are registered in composeApp/.../app/di/ViewModelsModule.kt (viewModelOf(::X), or viewModel { ... } when they take route arguments). Modules are assembled in initKoin.kt.
Core repositories (`core/domain`)
FavouritesRepository, StarredRepository, InstalledAppsRepository, SeenReposRepository, HiddenReposRepository, TweaksRepository, UserSessionRepository (signed-in user, token, session expiry), ProxyRepository, RateLimitRepository, ExternalImportRepository, MirrorRepository, AnnouncementsRepository, CacheRepository, BrowseFilterStore, HostTokenRepository. SearchHistoryRepository lives in feature/search/domain.
System interfaces with Android and Desktop implementations: Installer, InstallerStatusProvider, PackageMonitor, SystemInstallSerializer.
Convention plugins (`build-logic/convention/`)
convention.kmp.library (domain and data), convention.cmp.library (core/presentation), convention.cmp.feature (feature presentation), convention.cmp.application (the app), convention.room, convention.buildkonfig, convention.quality (detekt and Kover, applied by the library and app conventions). The Compose conventions also apply the stability analyzer; Android Lint is configured in configureKotlinAndroid.
Adding a feature
- Create
feature/<name>/{domain,data,presentation}/with the matching convention plugin. includethe modules insettings.gradle.kts.- Domain interfaces, then the implementation and its Koin module in
data/di/SharedModule.kt, then the ViewModel and screen. - Add the route to
GithubStoreGraph.kt, wire it inAppNavigation.kt, register the ViewModel inViewModelsModule.ktand the data module ininitKoin.kt. - Add a
feature/<name>/CLAUDE.mdwith what a reader can't see from the code in a minute.
Agents Checks (`docs/agents/checks.md`)
Checks
CI (.github/workflows/pr-checks.yml) runs on every pull request and every push to main. Existing findings live in committed baselines, so a check fails only on new issues.
./gradlew spotlessApply # format files changed vs origin/main (ktlint 1.8.0 + Compose rules + .editorconfig)
./gradlew detekt # static analysis; baseline: <module>/detekt-baseline.xml
./gradlew lintDebug # Android Lint; baseline: <module>/lint-baseline.xml
./gradlew jvmTest koverXmlReport # tests and merged coverage (build/reports/kover)
./gradlew compileDebugKotlinAndroid; ./gradlew debugStabilityCheck # Compose stability vs <module>/stability/*.stability
./gradlew :konsist-test:test # architecture rules
./gradlew spotlessInstallGitPrePushHook # opt-in pre-push format checkCodeQL (codeql.yml) and Dependabot (dependabot.yml) run separately.
Spotless
Spotless checks only files changed against origin/main, but it checks the whole file. The codebase was formatted once in #1080 (listed in .git-blame-ignore-revs). Compose rule findings (missing modifier, parameter order, naming) are not auto-fixable and have to be fixed by hand in files you touch.
Baselines
ktlint findings the formatter can't fix (mostly Compose rules) are listed per file and rule in config/ktlint/baseline.txt, and Spotless suppresses exactly those. When you fix one, delete its line.
Refresh a baseline only for an intentional change: detektBaseline, updateLintBaseline or debugStabilityDump. The stability analyzer (0.7.0) shares one output file across a module's compilations, so run its tasks in their own Gradle invocation right after compileDebugKotlinAndroid (fixed upstream only in releases built for Kotlin 2.4). Kotlin must match the stability analyzer's Kotlin version (see its version map) when bumping either.
Warnings as errors
Kotlin warnings are errors (allWarningsAsErrors). A module can opt out with komi.warningsAsErrors=false in its own gradle.properties; none does.
Konsist
The rules and their allowlists live in konsist-test/src/test/kotlin/zed/rainxch/konsist/. An allowlist can only shrink: the file-size test fails when an allowlisted file drops under the limit, so remove the entry in the same PR.
DocsRulesTest reads AGENTS.md, CLAUDE.md, CONTRIBUTING.md, docs/agents/*.md and feature/*/CLAUDE.md. A backticked route such as GithubStoreGraph.DetailsScreen must exist in the graph, and any backticked name ending in Screen or Graph must still exist in production code. When you rename a route or screen, update the docs in the same PR.
Agent config
The Agent config job runs agnix over the repository (AGENTS.md, the CLAUDE.md files, .claude/ and the rest) with its default rules, and fails only on errors. It runs in CI only.
Agents Distribution (`docs/agents/distribution.md`)
Distribution (maintainers)
Desktop installers are built by .github/workflows/build-desktop-platforms.yml on every push to the generate-installers branch. Its release job reads projectVersionName and creates the draft release v<version>.
Windows signing (SignPath Foundation)
The sign-windows job runs after every push to generate-installers. The SignPath action is pinned to a commit SHA. Secrets: SIGNPATH_API_TOKEN, SIGNPATH_ORGANIZATION_ID (1ecf111e-...). The variable SIGNPATH_SIGNING_POLICY_SLUG is test-signing until the production certificate is issued, then release-signing. Project slug GitHub-Store, artifact configuration slug initial. The unsigned artifact is deleted after signing, so only windows-installers-signed reaches the draft release.
WinGet
.github/workflows/winget-publish.yml fires on release: [released] and uses vedantmgoyal9/winget-releaser to publish the MSI (installers-regex: '\.msi$') as zed.rainxch.githubstore. The secret WINGET_TOKEN is a PAT with Contents and Pull requests write access on komi-store/winget-pkgs (a fork of microsoft/winget-pkgs). The workflow doesn't set fork-user; the action defaults it to the repository owner, komi-store.
Scoop
.github/workflows/scoop-publish.yml fires on release (or by hand) and dispatches komi-store-release to komi-store/komi-store-scoop-bucket with the secret SCOOP_BUCKET_TOKEN.
macOS (Homebrew)
The cask lives in the tap komi-store/komi-store (repository homebrew-komi-store): brew install --cask komi-store. The app is unsigned for now, so users run xattr -dr com.apple.quarantine /Applications/Komi-Store.app after installing. CI builds .dmg and .pkg on every push to generate-installers. On release, .github/workflows/homebrew-tap-publish.yml updates Casks/komi-store.rb in the tap with the secret HOMEBREW_TAP_TOKEN; it can also be run by hand for a given tag.
Agents Forges (`docs/agents/forges.md`)
Codeberg, Forgejo and Gitea
Forge repositories are fetched directly from the forge, with no backend in between.
- Clients: one
ForgejoApiClientper host (60 s request, 30 s connect and socket timeouts, exponential retry on 5xx andIOException).ForgejoClientRegistry.clientFor(host)caches them behind a Mutex; it is proxy-aware and drops cached clients when the proxy changes. - IDs:
RepoIdCodecpacks a 23-bit host fingerprint and the 40-bit raw id into the existing 64-bitrepoIdslot (sign bit set = foreign source), so the GitHub-shaped schema stays as it is. - URLs:
RepositoryUrlParserrecognises GitHub, Codeberg, gitea.com, git.disroot.org and user-added forge hosts. - README:
/contents/README.md?ref={branch}; Forgejo has no/readmeendpoint. - License: the first of
LICENSE,LICENSE.md,LICENSE.txtandCOPYINGfound under/contents/, matched against SPDX headers. - Downloads: the sum of
asset.download_countacross releases. - Details: a non-null
DetailsScreen.sourceHostroutes everyDetailsRepositorycall throughForgejoClientRegistry.
Per-host access tokens
HostTokenRepository stores HostToken(host, token, displayName, createdAtEpochMillis) entries as one JSON list under a single KSafe key (AES-256-GCM). Tweaks, Access Tokens (HostTokensScreen) adds, validates and deletes them. HostTokenInterceptor (a Ktor plugin on every ForgejoApiClient and the GitHub-direct client; never the backend, test or download clients) adds Authorization: token $pat to an https request whose host, with its port, matches a saved token, reading it per request so edits apply without a restart. An Authorization already set, such as the GitHub account sign-in, wins. HostNames.apiHostToTokenHost maps api.github.com, uploads.github.com and raw.githubusercontent.com to the github.com token. Ktor drops the header on cross-host redirects, and the mirror rewrite drops it when the host changes.
Agents Platform (`docs/agents/platform.md`)
Platform notes
Installing apps on Android
Silent installs go through Shizuku (ShizukuProvider, then AIDL, then pm install -S), Dhizuku, or root through libsu (services/root/RootServiceManager). services/installer/SilentInstallerDispatcher in core/data/src/androidMain picks one from the installer setting and falls back to the standard installer on failure.
Desktop logs
CrashReporter.install(), called at the start of DesktopApp.main, tees stdout and stderr to a rotating session.log and writes crash-<ts>.log on an uncaught exception.
| OS | Path |
|---|---|
| macOS | ~/Library/Logs/Komi-Store/ |
| Windows | %LOCALAPPDATA%/Komi-Store/logs/ |
| Linux | $XDG_STATE_HOME/Komi-Store/logs/ (or ~/.local/state/Komi-Store/logs/) |
Android logs go to Logcat.
Encrypted storage
KSafe provides AES-256-GCM, backed by the hardware Keystore on Android. Persisted credentials and preferences go through the extension functions in core/data/secure/KSafeSafe.kt (safeGet, safePut, safeDelete, safeGetFlow), which log and return null or false on a transient failure instead of throwing through coroutine scopes. A few raw calls remain, for example in HostTokenRepositoryImpl.
Gradle
Configuration cache and build cache are on. The Gradle daemon gets 4 GB of heap and the Kotlin daemon 3 GB (gradle.properties). Library versions live in gradle/libs.versions.toml.
Agents Releases And Assets (`docs/agents/releases-and-assets.md`)
Releases, assets and installed apps
How a release asset is matched to the platform and to an installed app. All of this is pure logic in core/domain/utils, shared by Details and the update checker, with tests in core/domain/src/commonTest (start with UpdateVerdictTest). AssetMatchingReplayTest in core/domain/src/jvmTest replays a corpus of real repositories collected by tools/asset-replay/.
AssetVariant: token, glob and stem fingerprints of an asset name, plusversionMajor.tagGlobblanks a name's own release version and adjacent build ids, so callers pass each name's release tag.AssetOwnership: which installed app owns an asset when a repository ships several apps. Filter first, then glob, then stem; ties go to the matching major version.assetPlatformOf: the platform an asset is for.newestReleasePerPlatform: the newest release for each platform, the device's platform first. Drives the Details platform chips.ReleaseLines: which app a tag belongs to in a monorepo (auth-v4.4.25belongs toauth). Renames and Details chips stay within a line.
DetailsScreen.packageName, set when Details opens from Apps, selects the installed app in a multi-app repository.
In the Library, an update or re-download starts from the match the last update check stored on the installed app (latestReleaseId and the latestAsset* fields). A fresh release lookup runs only when nothing is stored or a download from the stored match failed (AppsViewModel.resolveUpdateTarget).
Agents Sign In (`docs/agents/sign-in.md`)
Sign-in and the backend
GitHub OAuth
GITHUB_CLIENT_ID comes from local.properties; without the key, the build uses the built-in client id (BuildKonfigConventionPlugin). An empty value overrides it and breaks sign-in. Deep links: githubstore://auth (web-OAuth handoff), githubstore://callback (leftover from the device flow), githubstore://repo, githubstore://apps; the full set is in DeepLinkParser.kt.
Sign-in flow (web OAuth first)
- Web OAuth with PKCE and a handoff.
feature/auth/data/crypto/PkceGeneratormints(state, codeVerifier, codeChallenge).WebAuthApi.registerPOSTs verifier, challenge and state tohttps://github-store.org/auth/register(a Cloudflare Worker stores them in Workers KV) and returnsauthUrl. The user authorizes ongithub.com, GitHub redirects togithub-store.org/auth/callback?code&state, and the Worker exchanges the code viaapi.github-store.org. The backend stores(handoffId -> access_token)for 60 s in Postgres with an atomicDELETE ... RETURNING, then bounces back togithubstore://auth?handoff=<handoffId>&state=<state>. The app reads the handoff withWebAuthApi.consumeHandoff(GETDEL semantics). - Device flow through the backend's
/v1/auth/device/startand/poll.AuthPath(BackendorDirect) is kept inSavedStateHandleand only escalates fromBackendtoDirecton infrastructure errors. - Personal access token pasted by the user (
signInWithPat): validated against/user, and saved optimistically when GitHub is unreachable.
Backend rate limits: 10 device starts and 200 device polls per hour per IP. Endpoints are in core/data/network/BackendEndpoints.kt (BACKEND_ORIGIN, WEB_ORIGIN).
The `X-GitHub-Token` header
The client attaches the user's token as X-GitHub-Token when TokenStore.currentToken() is non-null on /v1/search, /v1/search/explore, /v1/repo (including POST .../refresh), /v1/releases, /v1/readme, /v1/user and /v1/users/{user}/repos. The backend re-sends it as Authorization: token $token to GitHub; without it, the backend round-robins a pool of 4 service tokens.
- An upstream 401 comes back as a backend
502and is treated like "GitHub unreachable": the client falls back throughshouldFallbackToGithubOrRethrow. - A
429gets no fallback (the same limit applies), only backoff. UnauthorizedInterceptorsits only on the direct-GitHub client;UserSessionRepositoryImpldebounces consecutive 401s by token snapshot.
Agents Translation (`docs/agents/translation.md`)
Translation providers
The TranslationProvider enum has GOOGLE, YOUDAO, LIBRE_TRANSLATE, DEEPL and MICROSOFT. Each provider's settings are stored through TweaksRepository (KSafe-encrypted), and TranslationRepositoryImpl.resolveTranslator() picks the implementation.
- LibreTranslate uses the bundled
translate.disroot.orgmirror when the user's URL setting is blank. - DeepL sends keys ending in
:fxtoapi-free.deepl.com. - Microsoft uses No-Trace by default: text is never stored and never used for training.
Quality Findings (`docs/quality/findings.md`)
Code quality research: Komi Store and kotlinconf-app
Status: decisions made on 2026-10-19 (section 5). The rules they produce live in docs/code-style.md; this file keeps the evidence.
- Komi Store:
origin/mainatcfa6bd30(2026-10-18). Links into our code are permalinks to that commit, so they stay correct as the code moves. - kotlinconf-app:
2189155, links are pinned to it.
How this was made
- 8 read-only research agents walked both codebases on the same 15 dimensions: 2 on kotlinconf-app (non-UI and UI), 6 covering all 13 Komi features,
core/*,composeApp,build-logicand CI. - Lint numbers come from actually running ktlint 1.8.0 with the Compose rules on all 37 modules in a scratch worktree (section 2).
- Every link was checked twice. A script confirmed that each file and line exists, then three verifier agents re-read all 277 citations against the code: 259 were right as written and 18 were corrected. The bugs in section 3 marked "verified" were also re-read by hand.
- Agents can still misread code. Treat a single citation as a pointer, not proof, until someone looks.
TL;DR
- kotlinconf-app is a source of techniques, not a quality bar. It has no lint, two client test files, a 587-line god service, Boolean flags for one-off events and no stateless screen layer. Copying it wholesale would lower our bar in several places (section 1).
- Our biggest problem is internal drift, not missing kotlinconf patterns. Five ViewModel shapes, three ways to navigate, three repository error contracts, no rule for user-facing text, and
CancellationExceptionswallowed in dozens of places (section 7). - Lint covers 1 of 37 modules. With ktlint on everywhere, hand-written code has 6,184 violations. A one-time
ktlintFormatfixes all but 201 of them and touches 551 files (section 2). - The CLAUDE.md files describe code that no longer exists: routes, repositories, components, injected dependencies, cache TTLs. Agents read them on every task. Fixing the docs is the cheapest high-leverage step (section 8).
- The research found real bugs, listed apart from style in section 3: per-host access tokens are never sent (regression since #666), the Desktop database is wiped on the next schema bump, a rate-limit exception can escape into
viewModelScope, and more.
1. What kotlinconf-app is, next to us
| Aspect | kotlinconf-app | Komi Store |
|---|---|---|
| Modules | :core, :app:ui-components, one :app:shared with every screen (settings.gradle.kts:35-44) |
37 modules, features split into domain / data / presentation |
| Build logic | none; the Android block is copied in 3 build files (build.gradle.kts:25-31) | 9 convention plugins in build-logic |
| DI | Metro 1.4, compile-time graphs (YearGraph.kt:10-22) | Koin 4.1 |
| Navigation | Navigation 3 with per-tab back stacks (NavState.kt:22-28) | Navigation Compose 2.9, one graph (GithubStoreGraph.kt:5) |
| ViewModel API | several StateFlows plus public methods (ScheduleViewModel.kt:111) |
mostly State + onAction(sealed) + Channel events |
| One-off events | Boolean StateFlow plus a reset call (FeedbackViewModel.kt:37-38) |
Channel + ObserveAsEvents (ObserveAsEvents.kt:13) |
| Loading and error UI | one generic ErrorLoadingState and one renderer (ErrorLoadingContent.kt:21-53) |
nothing shared; about 19 feature-local Loading/Error/Empty composables |
| Screen structure | screen takes the ViewModel as a default parameter, no stateless layer (ScheduleScreen.kt:87-93) | XRoot + stateless XScreen(state, onAction) in most features |
| Design system | custom, built on foundation, no Material (KotlinConfTheme.kt:41-56) | custom Personality system on top of Material 3 |
| Previews | 79, all in ui-components, none for screens | 75 in core/presentation, a handful in features |
| Static analysis | none | ktlint on composeApp only |
| Tests | 2 client test files, 242 lines | 35 test files, strong pure-logic tests, no ViewModel tests |
| CI | builds every platform, runs jvmTest (build-all.yml:25-27) |
packaging only, nothing runs on pull requests |
What to take from it is a handful of specific techniques (section 4), not its architecture. Several of its own weak spots are listed by the agents: a god service that ViewModels depend on concretely (ConferenceService.kt:46), ViewModels passed into child composables (ScheduleScreen.kt:311), a global navigation channel (NavHost.kt:91), and empty results shown as errors (ScheduleViewModel.kt:166-167).
2. Measurements
Lint
| What | Number |
|---|---|
| Modules ktlint runs on today | 1 of 37 (composeApp, through AndroidApplicationComposeConventionPlugin.kt:13) |
| Violations in hand-written code, ktlint on everywhere | 6,184 in 588 files |
| Top rules | multiline-expression-wrapping 1,335; function-signature 898; indent 622; chain-method-continuation 395; when-entry-bracing 393; class-signature 329 |
| Worst modules | core/data 1,365; feature/tweaks 850; core/domain 744; feature/apps 707; feature/details 662 |
Diff of a one-time ktlintFormat |
551 files, +16,094 / -12,334 lines |
| Left after that format | 201 in 132 files: 170 Compose rules, 31 standard rules |
| Is the format safe? | Yes: a trial run compiled for JVM and Android debug and all 411 JVM tests passed |
| What the 201 are | compose:modifier-missing-check 60, compose:parameter-naming 35, compose:param-order-check 23, standard:package-name 13 (snake_case packages), compose:lambda-param-in-effect 10, others under 10 each |
Run details: ktlint 1.8.0 (already pinned in KtlintConventionPlugin.kt:11), the project .editorconfig, plus io.nlopez.compose.rules:ktlint:0.6.0.
Three things came out of making the run work. The first two concern ktlint-gradle, which #1056 replaces with Spotless; the third still decides which Compose rules version works:
- The generated-code filter does not work. KtlintConventionPlugin.kt:15 excludes
**/generated/**, but ktlint-gradle matches patterns relative to each source root, so KSP Room code and Compose resource accessors were linted too: 104,706 extra violations from generated files. Excluding by absolute path (it.file.invariantSeparatorsPath.contains("/build/")) fixes it. - The format-on-build hook breaks once ktlint is on everywhere. build.gradle.kts:21-34 makes
preBuildand compile depend onktlintFormat. Incore:datathat creates a task cycle through KSP (preBuild -> ktlintFormat -> kspDebugKotlinAndroid -> preDebugBuild). It also rewrites sources during a compile. - compose-rules 0.6.1 and later crash on ktlint 1.8.0. They are built with Kotlin 2.4 and call a stdlib function the ktlint worker does not have (
NoSuchMethodError: sequenceOf). 0.6.0 is the newest release built on Kotlin 2.2.21 and runs fine.
Detekt
Measured on cfa6bd30 with detekt 1.23.8:
| What | Number |
|---|---|
| Default config | 3,040 findings in 467 files. MagicNumber 785, FunctionNaming 626 (composables are PascalCase), TooGenericExceptionCaught 332, LongMethod 244 |
| A Compose-aware config (prototype, not committed) | 1,008 in 223 files. MagicNumber 463 (non-UI code), ReturnCount 88, LongMethod 83, CyclomaticComplexMethod 80, SwallowedException 26, EmptyCatchBlock 4, PrintStackTrace 4 |
| Longest functions | the NavHost lambda in AppNavigation.kt:92 (899 lines), TweaksViewModel.onAction TweaksViewModel.kt:561 (822), preprocessMarkdown.kt:3 (408), DetailsRoot.kt:106 (396), SearchRoot.kt:237 (383), AppsRoot.kt:214 (355) |
Detekt 1.23.8 is built on Kotlin 2.0.21; running it in our Gradle build needs its Kotlin dependencies pinned to that version. #1056 runs it in CI with per-module baselines, so only new findings fail a PR (D12).
Tests, CI, review
| What | Number |
|---|---|
| Test files | 35. core/domain has 289 tests (UpdateVerdictTest alone 60, plus a 48-project corpus replay); core/data 11 files, mostly downloads; core/presentation 2; feature/apps/presentation 2; feature/details/domain 1 |
| ViewModel tests | 0 |
| Test libraries in the version catalog | none; modules add kotlin("test") one by one (build.gradle.kts:15) |
| CI that runs on pull requests | none at cfa6bd30 (build-desktop-platforms.yml:3-6 runs on pushes to generate-installers); pr-checks.yml (#1056) now compiles JVM and Android debug, runs jvmTest with coverage, Spotless, detekt, Android Lint, the Compose stability check and Konsist |
| What CONTRIBUTING says | "CI lints every PR" (CONTRIBUTING.md:153) and "No CI runs against PRs" (CONTRIBUTING.md:251) |
| CodeRabbit | dropped on 2026-10-19 and its config removed. It used to read every **/CLAUDE.md as review guidelines, so stale docs misled it too. |
Size hot spots
| File | Lines |
|---|---|
| DetailsViewModel.kt | 3,278 (22 dependencies, an onAction of about 400 lines) |
| AppsViewModel.kt | 2,446 (12 dependencies, 67 action types in one when) |
| TweaksViewModel.kt | 1,722 (86 launch calls) |
| DesktopInstaller.kt | 1,387 |
| SearchRoot.kt | 1,119 |
| AppNavigation.kt | 1,035 (every destination inline in one NavHost) |
3. Bugs and risks found on the way
Not style. Each deserves an issue or a small fix PR of its own, separate from the style work.
| # | What | Where | Status |
|---|---|---|---|
| 1 | Per-host access tokens are never sent. HostTokenInterceptor was added in #634 (aa5de4c0) and deleted in #666 (c94f50b2). Since then HostTokenRepository is read only by the Tweaks screen and the Forgejo client sets no Authorization header. Users who add a Codeberg/Forgejo token get no effect. |
HostTokensViewModel.kt:28, ForgejoApiClient.kt:52-56 | verified (git history + grep) |
| 2 | Desktop wipes its database on the next schema bump. Desktop uses fallbackToDestructiveMigration(true) with only MIGRATION_19_20 registered. Android registers the full chain. |
initDatabase.kt:22-23 | verified |
| 3 | A rate-limit exception escapes into viewModelScope. getIssues returns Result but rethrows RateLimitException; IssuesViewModel calls it in launch with no catch, so the exception goes to the default handler (a crash on Android). |
RepoPagesRepositoryImpl.kt:75-76, IssuesViewModel.kt:138-142 | verified |
| 4 | "Update all failed" shows an array identity. getString(Res.string.update_all_failed, arrayOf(e.message)) passes the array as one format argument. |
AppsViewModel.kt:1534-1537 | verified |
| 5 | Most Tweaks events are dropped. Events not listed fall into else -> Unit; 11 of the 14 event types are dropped, including proxy save and proxy test errors, so the user gets no feedback. |
TweaksRoot.kt:108 | verified |
| 6 | Developer profiles never show as organizations when loaded through the backend. isOrganization is derived from userType, which the mapper never sets. |
UserProfileNetworkToDomain.kt:6-24, DeveloperProfile.kt:20-22 | verified |
| 7 | Search load-more may read stale flags. remember { derivedStateOf { ... state.isLoadingMore ... } } has no key and captures the first state parameter. |
SearchRoot.kt:246-272 | likely; needs a device check |
| 8 | The same platform filter behaves differently. Feed sends "ios" for iOS, Home sends nothing. |
FeedRepositoryImpl.kt:148, CachedRepositoriesDataSourceImpl.kt:467 | verified |
| 9 | Sign-in ignores the user's proxy. GitHubAuthApi builds its own HttpClient {} with no proxy configuration, unlike the injected clients. |
GitHubAuthApi.kt:37-53 | verified |
| 10 | The auth ViewModel collects the token flow on the injected app scope, so the collector outlives the ViewModel. | AuthenticationViewModel.kt:41, AuthenticationViewModel.kt:64 | reported |
| 11 | Each Retry on Details starts another untracked observeInstalledApp collector. |
DetailsViewModel.kt:1197 | reported |
| 12 | The developer-profile filter launches a new job per keystroke without cancelling the old one, so stale results can win. | DeveloperProfileViewModel.kt:144 | reported |
| 13 | Every GitHub request runs runBlocking to read the token. |
HttpClientFactory.kt:91, DefaultTokenStore.kt:68-69 | reported |
| 14 | BootReceiver calls runBlocking on the main thread after goAsync(). |
BootReceiver.kt:15-19 | reported |
| 15 | The external-installer dialog is unreachable: showExternalInstallerPrompt is never set to true. |
RawDetailsState.kt:77, DetailsRoot.kt:478 | reported |
| 16 | 19 string keys are missing in 11 locales (10 in the two Chinese ones), and whatsnew/22.json exists in only 2 of 14 locale folders. |
strings.xml:164 (pause) |
reported |
Fixes: bug 1 in #1063, bug 2 in #1062, bugs 3 and 4 in progress; bugs 5 to 9 are to be filed as issues.
4. Adopt: cheap, clear wins
These need no big decision. Each says what kotlinconf does, where we stand, and the proposed rule.
A1. Never swallow cancellation
- kotlinconf rethrows
CancellationExceptionbefore every broad catch (ApplicationApi.kt:47-51). - We do it in most places too (DetailsViewModel.kt:208-212, BackendFallbackPolicy.kt:8-14, KSafeSafe.kt:13-21), but
runCatchingaround suspend calls undoes it: 120runCatchingin core/data alone. Examples that swallow cancellation: SlowDownloadDetectorImpl.kt:78 (wrapssafeGet, which rethrows on purpose), ExternalImportRepositoryImpl.kt:76-82, SearchViewModel.kt:538-539, GitHubAuthApi.kt:164, HostTokensViewModel.kt:208, AppsViewModel.kt:1528, UpdateCheckWorker.kt:83. - Rule: no
runCatchingaround suspend calls; use one sharedsuspendRunCatchinghelper that rethrowsCancellationException. Broad catches rethrow cancellation first. - Enforce: a Konsist rule with an allowlist (follow-up).
A2. One shared load / error / empty renderer
- kotlinconf: one sealed
ErrorLoadingState<T>and oneErrorLoadingContentthat waits 100 ms before showing loading or error (no flash on fast loads), cross-fades and offers retry (ErrorLoadingContent.kt:35-53). Its smaller error component also announces itself withliveRegion(ErrorLoading.kt:76-78); ours should do that for every error state. - We have none in core and about 19 local copies: HomeRoot.kt:350, FeedRoot.kt:351, RepoPagesError.kt:23, DeveloperProfileRoot.kt:228-272, StarredReposRoot.kt:212-259.
- Rule: new screens use a shared
KomiLoadStaterenderer in core/presentation; old ones move when touched. Keep ourPersonalitylook. - Do not copy kotlinconf's "empty means error" (ScheduleViewModel.kt:166-167); empty is its own state.
A3. Resolve user text at render; never show `e.message`
- kotlinconf ViewModels expose values (minutes as
Int?), and composables format them (ScheduleScreen.kt:545-547). We already do this in one place: typedTestResultmapped at render (MirrorPickerRoot.kt:240-245), though its fallback case still shows exception text (MirrorPickerRoot.kt:246). - Elsewhere ViewModels resolve strings and fall back to exception text: DetailsViewModel.kt:3009, IssuesViewModel.kt:112, HomeViewModel.kt:290, ExternalImportViewModel.kt:294, AuthenticationViewModel.kt:776-797. English exception text then reaches the UI, from the data layer (RepoPagesRepositoryImpl.kt:333-338 into toasts, DeeplTranslator.kt:61 into the translation card) and from ViewModels themselves (AppsViewModel.kt:1233).
ErrorStateeven classifies errors by searching the (maybe translated) message for "404" or "rate limit" (ErrorState.kt:194-207). - Rule: state holds typed errors; composables map them to strings. No
e.messagein UI. The exact shape is decision D2.
A4. Accessibility semantics in shared components
- kotlinconf:
heading()on titles,liveRegionon errors and counts,stateDescriptionandRoleon toggles,clearAndSetSemanticson child toggles inside a toggleable row (SettingsItem.kt:36-78, TalkCard.kt:222-240). - Ours: hardcoded English content descriptions in core components (KomiChip.kt:255, KomiTextField.kt:361, KomiSheet.kt:401, KomiToast.kt:272).
- Rule: every user-visible string in core, content descriptions included, comes from resources; toggles and selectable rows set
RoleandstateDescription.
A5. Previews where they pay off
- kotlinconf: one preview wrapper (PreviewHelper.kt:13-31) with androidx's
@PreviewLightDark(77 previews) and one custom wide multipreview (3 previews, MultiPreviews.kt:7-11) make 79 previews cheap; a fakeInteractionSourcepreviews hover and focus (PreviewInteractionSource.kt:9-17). It has zero screen previews because it has no stateless screens. - Ours: the
PersonalityPreviewwrapper (PersonalityPreview.kt:16) renders one personality per call, and core components write one preview per personality: 75 previews in core. But 25 core component files have none, and features have 0 to 4 each (feed, home and recently-viewed have none). OurXScreen(state, onAction)split lets us preview whole screens, which kotlinconf cannot. - Rule: every public core component and every stateless
XScreengets at least one preview with fake state. Add the hover/focus trick for desktop.
A6. Power-assert for tests
- kotlinconf turns on the Kotlin power-assert compiler plugin for
assertEquals/assertTrue(build.gradle.kts:147-156), so failures print the values involved with no assertion library. - Rule: apply it to test source sets in a convention plugin.
A7. No wildcard imports
- kotlinconf sets the IDE star-import threshold to 1024 (.editorconfig:2-3).
- We disable ktlint's
no-wildcard-imports(.editorconfig:5) and mix styles: wildcardres.*in AppsViewModel.kt:73 and TweaksRoot.kt:47, explicit imports in most files. - Rule: re-enable
no-wildcard-imports, set the same IDE thresholds.
A8. Typed route arguments, no positional `parametersOf`
- kotlinconf passes route arguments through typed assisted factories (DocumentsViewModel.kt:54-59).
- We pass up to six positional parameters and read them by index: ViewModelsModule.kt:47-52 against AppNavigation.kt:479-489. A default
koinViewModel()with no params then hitsparams[0](DetailsRoot.kt:115). - Rule: ViewModels with route arguments read the route object (
SavedStateHandle.toRoute<...>()) or one typed params class.
A9. Short, true CLAUDE.md files
- kotlinconf's CLAUDE.md lists modules, commands and conventions that match the code (it only skips the small admin app module).
- Ours describe things that are gone (section 8). Rule: CLAUDE.md files state only what a reader cannot get from the code in a minute, and are fixed in the same PR that changes the thing they describe.
A10. Pin every GitHub Action by commit SHA
- We already pin SignPath (build-desktop-platforms.yml:158) but use
@mainfor winget (winget-publish.yml:12) and tags elsewhere. kotlinconf uses tags everywhere. - Rule: all third-party actions pinned by SHA with the version in a comment.
Worth knowing, not rules
- A scoped DI sub-graph rebuilt when a key changes (YearGraph.kt:10-19).
- A logger that buffers until the real logging policy is known (BufferedDelegatingLogger.kt:51-64).
- Tab reselect as a
SharedFlowthat screens use to scroll to top (Navigator.kt:54-64). - A slot-based compact / medium / large detail layout (AdaptiveDetailLayout.kt:29-38), relevant for desktop.
@SerialNameon every route so renames don't break saved back stacks (Routes.kt:17-19).- Explicit backing fields (
val x: StateFlow<T>+field = MutableStateFlow(...)) replace the_statepair (FeedbackViewModel.kt:34-35). kotlinconf compiles them on Kotlin 2.4.10 with no flag; we are on 2.3.10, so check availability before adopting.
5. Decide: your call
Decided on 2026-10-19: option A for D1 to D11. D12 was overtaken: CodeRabbit is no longer used, detekt runs in CI with baselines through #1056, and the mechanical rules move to Konsist.
D1. One ViewModel shape
Today, at least five shapes exist:
| Shape | Examples |
|---|---|
_state + onStart { if (!hasLoaded) load() } + stateIn(WhileSubscribed(5_000)) + onAction + Channel |
AppsViewModel.kt:113-128, DetailsViewModel.kt:174-192, TweaksViewModel.kt:93-128, 11 ViewModels in total |
asStateFlow() + init { load() } (the shape root CLAUDE.md documents) |
ProfileViewModel.kt:21-33, CategoryListViewModel.kt:30, IssuesViewModel.kt:37-42 |
| public methods or bare state, no Action or Event types | DetailsAboutViewModel.kt:37-126, AppInfoViewModel.kt:8-12, WhatsNewViewModel.kt:24-31 |
onAction but no events |
PullsViewModel.kt, SecurityViewModel.kt |
| several public flows | AnnouncementsViewModel.kt:38-64 |
- Option A (recommended): the dominant first shape. State loads when the screen subscribes, not when the ViewModel is created, and it is already in 11 ViewModels. Name the
5_000once. Add a Konsist test: everyXViewModelexposesstate: StateFlow<XState>andfun onAction(action: XAction). - Option B: kotlinconf's several flows plus methods. Less boilerplate for tiny screens, but it splits the API and pushes one-off events into Boolean flags.
- Option C: keep the documented
asStateFlow() + initshape. Simpler, but it loads on creation and would mean changing the 11 ViewModels that already use A.
D2. Error contract and user text
Repositories use three contracts today: kotlin.Result (Feed FeedRepositoryImpl.kt:54, repo-pages RepoPagesRepository.kt:16), thrown exceptions (Details DetailsRepository.kt:13), and silently emitting empty results (Home HomeRepositoryImpl.kt:477-486). One interface mixes Result with plain throwing calls (AuthenticationRepository.kt:10-23).
- Option A (recommended):
- Fallible repository calls return
Result<T>, and failures are typed domain exceptions.RateLimitExceptionalready exists; add NotFound and Offline. - Cancellation is never caught.
- ViewModels keep a typed error in state, and composables map it to a string resource. We already have one good model to copy: ProxyTestOutcome.kt:3-27.
- Fallible repository calls return
- Option B: a shared
UiText(resource plus arguments, or a raw string) that ViewModels put in state. A common Android pattern, but it keeps string decisions in ViewModels and still invitesUiText.Raw(e.message). - Option C: a project-wide sealed
DataErrorhierarchy with its ownResulttype. The most type-safe, and the largest migration.
D3. One navigation rule
Three routes exist, sometimes on one screen:
- Root intercepts the action and the ViewModel keeps an empty branch: HomeRoot.kt:102 with HomeViewModel.kt:212, and DetailsRoot.kt:161-181.
- ViewModel sends a Navigate event: StarredPickerViewModel.kt:72, CategoryListViewModel.kt:62.
- Direct callbacks with dead ViewModel branches: TweaksViewModel.kt:561-573.
On Apps, back and starred go one way and repo navigation the other (AppsRoot.kt:150-156 against AppsViewModel.kt:453-465).
- Option A (recommended): clicks that only navigate are plain lambdas on the Screen (
onNavigateToDetails), not Actions. The ViewModel emits aNavigate*event only when navigation depends on async work, for example after sign-in. No navigation cases inside Action types, so no-> Unitbranches. - Option B: keep "Root intercepts the Action" everywhere. Least churn, but every navigation Action needs an empty ViewModel branch forever.
D4. How to roll ktlint out
Measured in section 2:
- Option A (recommended):
- One mechanical
ktlintFormatPR at a quiet moment: 551 files, compile-checked in a trial run. - Add its commit to
.git-blame-ignore-revs. - Fix or baseline the 201 that remain, then fail CI on new violations.
- Cost: open PRs and volunteer branches need a rebase.
- One mechanical
- Option B: a ktlint baseline of all 6,184 violations and no mass format. ktlint baselines match on line and column, so any edit that shifts lines re-surfaces old violations in touched files. That causes noise for months.
- Option C: format module by module over several PRs. Smaller rebases, but CI can only gate modules that are done.
- Since then #1056 runs ktlint through Spotless on files changed against
main, so formatting blocks from day one without baselines. The one-time format still matters: until it lands, touching a file means reformatting all of it in that PR.
D5. Remove the format-on-build hook
- build.gradle.kts:21-34 rewrites sources during compile, hides violations from CI, and creates a task cycle in KSP modules once ktlint is on everywhere (section 2).
- Recommended: remove it. #1056 does, and formats through
spotlessApplywith an opt-in pre-push hook.
D6. Compose lint rules
- 170 findings after the format pass. Most are cheap: a missing
modifierparameter on screens and components (60, for example DetailsRoot.kt:530), parameter naming (35), parameter order (23). - Recommended: on, fix the cheap ones in the same rollout, and turn off by
.editorconfigany rule we disagree with (for examplecompositionlocal-allowlist, given ourLocal*design).
D7. One logging style
- core/data has about 366 static Kermit
Logger.calls and 21 calls on the injectedKomiStoreLogger, sometimes in the same class (UserSessionRepositoryImpl.kt:107 against UserSessionRepositoryImpl.kt:177). About half of the error logs interpolate.messageand drop the stack trace (InstalledAppsRepositoryImpl.kt:167). - Recommended: the injected
KomiStoreLoggereverywhere, with the throwable always passed, never.message.
D8. A size ratchet for god classes
- Recommended:
- A Konsist test that fails when a production file grows past 800 lines.
- An allowlist of today's offenders (section 2) that can only shrink.
- Split along existing seams when touched. Examples: Details download/install/translation, Apps update/import/link, the four copies of proxy-config code in TweaksViewModel.kt:684-726, TweaksViewModel.kt:1512-1530, TweaksViewModel.kt:1600-1628 and TweaksViewModel.kt:1639-1674.
- No big-bang refactors.
D9. Stability annotations
- Strong skipping is the Compose compiler default on our Kotlin version. kotlinconf puts no stability annotations on state classes; its only
@Stableis a state holder (NotificationBar.kt:17), the same split recommended here. - Ours are random: HomeState.kt:10 is
@Stable, FeedState.kt:11@Immutable, the apps states have none. - Recommended:
- No
@Stableor@Immutableon screen state classes. KeepImmutableListin state, which we already do almost everywhere. - Keep
@Stableon real state-holder classes like KomiToastState.kt:8.
- No
D10. Test stack and the first ViewModel tests
- No ViewModel tests and no coroutine test tooling exist today.
- Recommended:
- Add
kotlinx-coroutines-testand Turbine to the version catalog, with fakes over mocks as now. - Make one small feature the reference: starred (1,089 lines) or favourites (1,402).
- Write its ViewModel tests, then copy the pattern.
- Add
D11. Metro and Navigation 3
- Recommended: not now.
- Metro would mean migrating 33 ViewModels and about 100 Koin singletons. The real pain it would remove, positional
parametersOf, is fixed more cheaply by A8. - Navigation 3's per-tab back stacks are attractive for the bottom bar and desktop sidebar, but Navigation Compose 2.9 works today.
- Revisit both if DI bugs or tab-state complaints become real.
D12. Detekt: review comments or a CI gate
- Resolved: CodeRabbit is no longer used. detekt runs in Gradle and CI with per-module baselines through #1056 (option B), and the mechanical rules this research wanted become Konsist tests.
6. Keep ours
| What | Why it beats or matches kotlinconf |
|---|---|
Convention plugins in build-logic |
kotlinconf copies Android setup between modules (build.gradle.kts:15-20) |
| Feature modules split into domain / data / presentation | keeps features apart; kotlinconf has one client module |
XRoot + stateless XScreen(state, onAction) |
enables screen previews and tests; kotlinconf passes ViewModels into children (SessionScreen.kt:197) |
Channel events + ObserveAsEvents |
kotlinconf's Boolean flags need manual resets and drop repeated values (StartNotificationsViewModel.kt:40) |
ImmutableList in state |
fine with strong skipping; kotlinconf uses plain List |
One shared strings.xml with 14 locales |
one place for translators; kotlinconf splits resources between two modules and duplicates 86 drawables |
Personality design system with a public dispatcher and private Manga / Classic implementations |
richer than kotlinconf's theme, and features have almost stopped using raw Material (KomiButton.kt:70-123) |
modifier as the first optional parameter on public core components |
matches the Compose API guidelines; kotlinconf is inconsistent (SpeakerCard.kt:32-33). Three of our markdown composables still take no modifier (AlertBlockQuote.kt:72, ExpandableDetails.kt:50, SyntaxHighlightedCode.kt:41) |
| Pure logic tests | UpdateVerdictTest (60 tests) and the corpus replay go well beyond kotlinconf's 242 test lines |
| Koin and Navigation Compose 2.9 | see D11 |
7. Internal drift
This is where most of the work is. Grouped by theme; each group lists the main examples, not every site.
ViewModels
- Five state shapes (D1). Loading starts in
init, inonStart, or both: DetailsViewModel.kt:199-201, AppsViewModel.kt:130-132. One screen even starts throughonStart, anOnStartaction and aLaunchedEffecttogether (ExternalImportViewModel.kt:79-83, ExternalImportViewModel.kt:96-98, ExternalImportRoot.kt:86-90). - State built from mutable shadow
vars plus a manualrebuild()instead ofcombine: HomeViewModel.kt:64-86, FeedViewModel.kt:58-75. - Non-atomic
_state.value = _state.value.copy(...)mixed with_state.updatein one class: DetailsViewModel.kt:535. - Dead Actions never sent from UI: 13 in TweaksAction.kt:16, five in AuthenticationAction.kt:16-22, RecentlyViewedAction.kt:14, FeedAction.kt:11-12.
- Three near-identical translation ViewModels: DetailsAboutViewModel.kt, DetailsWhatsNewViewModel.kt:48-131, MarkdownViewerViewModel.kt:52-137.
State
- Flat data classes of 60 to 70 fields: AppsState.kt:24-110, RawDetailsState.kt:25-97, TweaksState.kt:28-105.
- Nullable fields and sentinel strings used as flags:
advancedFilterError = "invalid"(AppsViewModel.kt:880), error codes in aString?(TweaksState.kt:46). - Fields written but never read, or read but never written:
installError/downloadErrornever rendered,isInstallingnever set (RawDetailsState.kt:50),isLoadingnever true (FavouritesState.kt:11, RecentlyViewedState.kt:9). - Details has the best pattern: a raw state mapped once into an
@Immutableview state with derived fields (RawDetailsStateMapper.kt:11).
Composables
- Root/Screen split missing: ExternalImportRoot.kt:50, HostTokensRoot.kt:105, MirrorPickerRoot.kt:68, HiddenRepositoriesRoot.kt:53, SkippedUpdatesRoot.kt:55, LicensesRoot.kt:44.
- No route-level screen takes a
modifier, and several components don't either (60compose:modifier-missing-checkhits). - Whole state objects passed into leaf components: LinkAppBottomSheet.kt:66-69, SmartInstallButton.kt:91-99.
- Business logic in composables: version math in SmartInstallButton.kt:105-177, markdown preprocessing on
Dispatchers.Defaultin About.kt:140-149, URL validation in DeveloperProfileRoot.kt:81-85. koinInject<HttpClient>(named("test"))inside composables: About.kt:98-100, RepoMarkdown.kt:28, and 4 more.- Two lifecycle-observer styles: TweaksRoot.kt:75-84 against AuthenticationRoot.kt:121.
Design system
- Spacing tokens exist (Spacing.kt:7) and are used 0 times in features, against 1,382 raw
dpliterals and 285fontSizeoverrides. - Core components bypass
LocalPersonalitythroughMaterialTheme, whose typography is the Material default becausePersonalityThemeonly bridges colors: AdaptiveListDetail.kt:201, MarkdownUtils.kt:31. README markdown therefore ignores the personality fonts. isSystemInDarkTheme()ignores the in-app theme setting: ExpandableDetails.kt:89, AppHeader.kt:121.rememberMarkdownColors()andrememberMarkdownTypography()never callremember(MarkdownUtils.kt:17).- Duplicated components:
FavoriteTogglex3 (DeveloperRepoItem.kt:231, FavouriteRepositoryItem.kt:198, StarredRepositoryItem.kt:235, with different alphas), platform pickers (FeedPlatformPicker.kt:47 against HomePlatformPicker.kt:49),SourceChipx2, search bars x3, sort dropdowns x3,StatusDotx2. - Hardcoded colors: DetailsRoot.kt:305, DowngradeWarningColor.kt:8, PersonalityTile.kt:41-46.
Data layer
- Feature modules build their own
HttpClients that are never closed and skip the proxy: FeedRepositoryImpl.kt:41, CachedRepositoriesDataSourceImpl.kt:45, GitHubAuthApi.kt:38. - Proxy setup is written 4 times with different rules (HttpClientFactory.android.kt:31-33, HttpClientFactory.jvm.kt:37, AndroidDownloader.kt:112-113), and the two downloaders are near-copies (585 and 568 lines, 47 differing lines); only Desktop is tested.
- Three caching strategies across Home and Feed; DTOs and mappers in four different places (
dto/+mappers/, private inside the repository HomeRepositoryImpl.kt:661-672, inline StarredRepositoryImpl.kt:139-159). - 10 classes create private
CoroutineScopes that are cancelled only in aclose()nothing calls, or not at all (BackendApiClient.kt:55, TweaksRepositoryImpl.kt:46, ...), while an app scope is injected elsewhere (DefaultDownloadOrchestrator.kt:52). - Three migration styles, and Desktop is destructive (bug 2).
DI
- Production HTTP client qualified
"test": SharedModule.kt:350-354. - Stateful singleton
objects outside DI: ProxyManager.kt:23, BackendRateLimitTracker.kt:8, AuthDeepLinkBus.kt:9, TweaksDeepLinkBus.kt:9. - Service location in Android components: UpdateCheckWorker.kt:35-41, BootReceiver.kt:20.
- An unqualified app
CoroutineScopesingleton resolved by bare type (SharedModule.kt:93-95).
Naming
- Actions:
OnX(DetailsAction.kt:80) against bare verbs (AuthenticationAction.kt:6). Events named like Actions (HomeEvent.kt:4). XStateagainstXUiState(IssuesUiState.kt:8).- Favourite / Favorite (FavouritesAction.kt:9 against ImportStarsAction.kt:10).
- GitHub / Github (GitHubClientProvider.kt:20 against GithubAssetAuth.kt:3).
- Enum entries in PascalCase (DiscoveryPlatform.kt:4) against SCREAMING_CASE (InstallSource.kt:3).
- snake_case packages (
data_source,use_cases,local/data_store): 13 ktlintpackage-namehits. - Search domain uses package
zed.rainxch.domaininstead ofzed.rainxch.search.domain(SearchRepository.kt:1). - A repository interface and a model share a name (StarredRepository.kt:4-7).
- Repository read names:
getX()/observeX()/xFlow()/getXAsFlow()for the same idea (FavouritesRepository.kt:7, AnnouncementsRepository.kt:8, ExternalImportRepository.kt:10, InstalledAppsRepository.kt:19).
Hygiene
- Dead code:
awaitDeviceToken, 146 lines (AuthenticationRepositoryImpl.kt:99)parseGithubUrl(AppsViewModel.kt:2331)reportIssue()(ReportIssue.kt:31)- the unreachable
RepoPullsScreenroute (GithubStoreGraph.kt:132) - unreferenced components (TweaksStubScreen.kt:34)
- empty favourites and starred domain/data modules still wired into the build (settings.gradle.kts:61-62, settings.gradle.kts:72-73)
- JVM-only APIs in
commonMain: AppsViewModel.kt:74 (java.io.File), PkceGenerator.kt:3-4, TweaksViewModel.kt:165 (String.format). - Blank lines left where comments were stripped: AppsViewModel.kt:199, TweaksViewModel.kt:756.
- Fully qualified names in bodies: AppsViewModel.kt:1944, TweaksViewModel.kt:358.
- Redundant
@OptIn(ExperimentalTime::class)when the convention plugin already opts in (KotlinMultiplatform.kt:21). - 72 em-dashes in core/data sources, mostly log strings.
- Hardcoded English in the data and domain layers: ForgeKind.kt:15, HostNames.kt:27, UpdateCheckWorker.kt:160-162.
Tests
- Test naming is split: snake_case sentences (UpdateVerdictTest.kt:62) and camelCase (InstallBindingTest.kt:71).
- The 30-method
InstalledAppsRepositoryfake is rewritten in 4 test files (DefaultDownloadOrchestratorParkTest.kt:250, SyncInstalledAppsUseCaseParkTest.kt:352, ...). - A replay test copies production logic it is meant to check (AssetMatchingReplayTest.kt:59).
8. Docs drift
Agents read these files on every task. Each wrong line here produces wrong code later.
Status: fixed by #1065 and #1076; the table stays as the record of what had drifted.
| Claim | Reality |
|---|---|
| Root CLAUDE.md: version 1.8.3 (code 18) | the catalog says 1.9.3 / 22 (libs.versions.toml:59-60) |
| Feature list has 11 features, typo "homeP" | 13 features; feed and repo-pages are missing (settings.gradle.kts:69-71, settings.gradle.kts:58-60) |
| favourites and starred are presentation-only | their domain/data modules exist and are empty; profile and tweaks are presentation-only too and are not listed |
Route list includes HomeScreen, OnboardingScreen |
neither exists; 13 real routes are missing (GithubStoreGraph.kt:8) |
Core repositories include AuthenticationState, ThemesRepository, TelemetryRepository, SearchHistoryRepository |
the first is now UserSessionRepository, the next two don't exist, the last is in feature/search/domain (SearchHistoryRepository.kt:5) |
HostTokenInterceptor injects per-host PATs |
it was deleted in #666 (bug 1) |
KSafe wraps every persisted credential through safeGet / safePut |
raw calls exist, including the token store itself (HostTokenRepositoryImpl.kt:138) |
| Tech: Landscapist 2.9.5 | images use Coil 3 (build.gradle.kts:15-17) |
| core/presentation is a "Material 3 theme" | it is the custom Personality system; Material only receives colors (PersonalityThemeProvider.kt:39-47) |
Every ViewModel follows the _state + _events + onAction template |
18 of 33 do, counting stateIn as allowed; the rest vary (D1) |
Packages are zed.rainxch.{module}.{layer} |
search domain breaks it (SearchRepository.kt:1) |
feature/home: 7-day cache TTL, dedupe by fullName |
1 hour (CachedRepositoriesDataSourceImpl.kt:480), dedupe by id (HomeViewModel.kt:264) |
feature/details: injects TelemetryRepository, ProfileRepository; DownloadStage ends in DONE |
real deps differ (DetailsViewModel.kt:145); there is no DONE (DownloadStage.kt:3-9) |
| feature/tweaks: sections Account, Language, Network; Android restart works | none of those sections exist; the Android restart is empty (RestartApp.android.kt:3-5) |
| feature/search: queries are debounced | no debounce exists |
feature/auth: no retry loops on top of HttpRequestRetry |
withRetry(3) wraps a client that already retries (GitHubAuthApi.kt:67) |
CONTRIBUTING: CI lints every PR; MVI; wire ViewModels in initKoin.kt |
no PR CI; root CLAUDE.md says MVVM; ViewModels live in ViewModelsModule.kt |
Recommendation: rewrite root CLAUDE.md to a short factual index, delete the stale parts of the feature files rather than patching every line, add the missing feed and repo-pages files, and point everything at one style guide.
9. Where each rule lives
The higher the rung, the less it depends on anyone remembering.
| Rule | Rung | Status |
|---|---|---|
| Formatting, imports, naming basics | Spotless (ktlint 1.8.0) | blocking on files changed against main |
| Compose API basics (modifier, parameter order, naming) | Spotless with the Compose rules | same |
| Complexity, exceptions, smells | detekt | per-module baselines, new findings fail |
| Android Lint | lintDebug |
per-module baselines |
| Compose stability | stability analyzer | committed stability/*.stability files |
| Common domain code imports no Ktor, Room, Compose, lifecycle, Koin or Android | Konsist | passing |
| Presentation never imports data | Konsist | passing; MirrorPickerViewModel is allowlisted for the core.data import that came with #1015 |
Packages under zed.rainxch.<owner>.<layer> |
Konsist | passing, 7 search files allowlisted |
ViewModels named *ViewModel and only in presentation or composeApp |
Konsist | passing |
Top-level *Action / *Event types are sealed |
Konsist | passing |
| File size ratchet, 800 lines (D8) | Konsist | passing, 15 files allowlisted |
XViewModel shape (D1) |
Konsist | follow-up, with an allowlist of today's off-pattern ViewModels |
No runCatching around suspend calls (A1), no e.message in presentation (A3), no MaterialTheme or Color(0x...) in features, no koinInject in composables |
Konsist | follow-up, with allowlists |
| Previews (A5), CLAUDE.md changes with the code (A9) | review | |
All of the above, plus compile and jvmTest with coverage, on every PR |
GitHub Actions pr-checks.yml |
in place |
| Everything else | docs/code-style.md |
in place |
10. Follow-up work
In order:
- Done in #1076. Contributor docs: a short public
AGENTS.md, a slimmer publicCLAUDE.md, a refreshedCONTRIBUTING.md, a PR template, and the feature CLAUDE.md files corrected against the code (already prepared). - Open fix PRs land first, after #1056 and this change: #1063 (bug 1), #1062 (bug 2), the rate-limit fix (bugs 3 and 4), and other open work, so none of them has to rebase across a mass format.
- Done in #1080. The one-time format (D4) as its own PR at a quiet moment: one
spotlessApplyover every file with the ratchet off for that run, the Compose findings fixed or suppressed, and thefeature:details:presentationwarnings opt-out dropped with its five warnings fixed. Then the squash SHA goes into.git-blame-ignore-revs. - Code changes from the decisions, one PR each:
- power-assert (A6), no wildcard imports (A7) and no stability annotations on screen state (D9), right after the format
- error handling (D2, A1, A3) with Konsist rules for
runCatchingaround suspend calls ande.messagein presentation - core UI: a shared load / error / empty renderer (A2), localized content descriptions (A4), previews (A5)
- ViewModels and navigation: the D1 shape as a Konsist rule, navigation lambdas (D3), typed route arguments (A8)
- the test stack and the first ViewModel tests (D10)
- logging through the injected
KomiStoreLogger(D7)
- Done. Issues for bugs 5 to 9: #1071 to #1075.
--- METRICS ---
- Files Extracted: 11
- Estimated Token Budget: ~34633 tokens
- Canonical Reference: https://codewiki.google/github.com/kurikomi-labs/komi-store