This file provides guidance to AI agents like Cursor/Claude Code/Codex/WARP when working with code in this repository.
# compile
./gradlew compileDevDebugKotlin
# Build for dev
./gradlew assembleDevDebug
# Run unit tests
./gradlew testDevDebugUnitTest
# Run specific unit test file
./gradlew testDevDebugUnitTest --tests LightningRepoTest
# Run instrumented tests
./gradlew connectedDevDebugAndroidTest
# Build for E2E tests (UI hooks enabled, local Electrum by default)
E2E=true ./gradlew assembleDevRelease
# Build for E2E tests with geoblocking disabled
GEO=false E2E=true ./gradlew assembleDevRelease
# Build for E2E tests using network Electrum (not local; staging/mainnet based on flavor)
E2E=true E2E_BACKEND=network ./gradlew assembleTnetRelease
# Lint using detekt
./gradlew detekt
# Auto-format using detekt
./gradlew detekt --auto-correct
# Update detekt baseline
./gradlew detektBaseline
# Install dev build
./gradlew installDevDebug
# Clean build artifacts
./gradlew clean- Language: Kotlin
- UI Framework: Jetpack Compose with Material3
- Architecture: MVVM with Hilt dependency injection
- Database: Room
- Networking: Ktor
- Bitcoin/Lightning: LDK Node, bitkitcore library
- State Management: StateFlow, SharedFlow
- Navigation: Compose Navigation with strongly typed routes
- Push Notifications: Firebase
- Storage: DataStore with JSON files
- app/src/main/java/to/bitkit/
- App.kt: Application class with Hilt setup
- ui/: All UI components
- MainActivity.kt: Single activity hosting all screens
- screens/: Feature-specific screens organized by domain
- components/: Reusable UI components
- theme/: Material3 theme configuration
- viewmodels/: Shared ViewModels for business logic
- repositories/: Data access layer
- services/: Core services (Lightning, Currency, etc.)
- data/: Data layer: database, DTOs, and data stores
- di/: Dependency Injection: Hilt modules
- models/: Domain models
- ext/: Kotlin extensions
- utils/: Utility functions
- usecases/: Domain layer: use cases
- Single Activity Architecture: MainActivity hosts all screens via Compose Navigation
- Repository Pattern: Repositories abstract data sources from ViewModels
- Service Layer: Core business logic in services (LightningService, WalletService)
- Reactive State Management: ViewModels expose UI state via StateFlow
- Coroutine-based Async: All async operations use Kotlin coroutines
- dev: Regtest network for development
- tnet: Testnet network
- mainnet: Production
GlobalScope.launch { } // Use viewModelScope
val result = nullable!!.doSomething() // Use safe calls
Text("Send Payment") // Use string resources
class Service(@Inject val vm: ViewModel) // Never inject VMs
suspend fun getData() = runBlocking { } // Use withContextviewModelScope.launch { }
val result = nullable?.doSomething() ?: default
Text(stringResource(R.string.send_payment))
class Service {
fun process(data: Data)
}
suspend fun getData() = withContext(Dispatchers.IO) { }- Main Activity:
app/src/main/java/to/bitkit/ui/MainActivity.kt - Navigation:
app/src/main/java/to/bitkit/ui/ContentView.kt - Lightning Service:
app/src/main/java/to/bitkit/services/LightningService.kt - App ViewModel:
app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt - Wallet ViewModel:
app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt
private val _uiState = MutableStateFlow(InitialState)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun updateState(action: Action) {
viewModelScope.launch {
_uiState.update { it.copy(/* fields */) }
}
}suspend fun getData(): Result<Data> = withContext(Dispatchers.IO) {
runCatching {
apiService.fetchData()
}.onFailure {
Logger.error("Failed", it, context = TAG)
}
}- USE coding rules from
.cursor/default.rules.mdc - ALWAYS run
./gradlew compileDevDebugKotlinafter code changes to verify code compiles - ALWAYS run
./gradlew testDevDebugUnitTestafter code changes to verify tests succeed and fix accordingly - ALWAYS run
./gradlew detektafter code changes to check for new lint issues and fix accordingly - ALWAYS ask clarifying questions to ensure an optimal plan when encountering functional or technical uncertainties in requests
- ALWAYS when fixing lint or test failures prefer to do the minimal amount of changes to fix the issues
- USE single-line commit messages under 50 chars; use conventional commit messages template format:
feat: add something new - USE
git diff HEAD sourceFilePathto diff an uncommitted file against the last commit - NEVER capitalize words in commit messages
- ALWAYS create a
*-backupbranch before performing a rebase - ALWAYS suggest 3 commit messages with confidence score ratings, e.g.
fix: show toast on resolution (90%). In plan mode, include them at the end of the plan. If the user picks one via plan update, commit after implementation. Outside plan mode, suggest after implementation completes. In both cases, rungit statusto check ALL uncommitted changes after completing code edits - ALWAYS check existing code patterns before implementing new features
- USE existing extensions and utilities rather than creating new ones
- ALWAYS use or create
Contextextension properties inext/Context.ktinstead of rawcontext.getSystemService()casts - ALWAYS apply the YAGNI (You Ain't Gonna Need It) principle for new code
- ALWAYS reuse existing constants
- ALWAYS ensure a method exist before calling it
- ALWAYS remove unused code after refactors
- ALWAYS follow Material3 design guidelines for UI components
- ALWAYS ensure proper error handling in coroutines
- ALWAYS acknowledge datastore async operations run synchronously in a suspend context
- NEVER use
runBlockingin suspend functions - ALWAYS pass the TAG as context to
Loggercalls, e.g.Logger.debug("message", context = TAG) - NEVER add
e =named parameter to Logger calls - NEVER manually append the
Throwable's message or any other props to the string passed as the 1st param ofLogger.*calls, its internals are already enriching the final log message with the details of theThrowablepassed via theearg - ALWAYS wrap parameter values in log messages with single quotes, e.g.
Logger.info("Received event '$eventName'", context = TAG) - ALWAYS start log messages with a verb, e.g.
Logger.info("Received payment for '$hash'", context = TAG) - ALWAYS log errors at the final handling layer where the error is acted upon, not in intermediate layers that just propagate it
- ALWAYS use the Result API instead of try-catch
- NEVER wrap methods returning
Result<T>in try-catch - PREFER to use
itinstead of explicit named parameters in lambdas e.g.fn().onSuccess { log(it) }.onFailure { log(it) } - NEVER inject ViewModels as dependencies - Only android activities and composable functions can use viewmodels
- ALWAYS co-locate screen-specific ViewModels in the same package as their screen; only place ViewModels in
viewmodels/when shared across multiple screens - NEVER hardcode strings and always preserve string resources
- ALWAYS localize in ViewModels using injected
@ApplicationContext, e.g.context.getString() - ALWAYS use
rememberfor expensive Compose computations - ALWAYS declare
modifier: Modifier = Modifier,as the FIRST optional parameter in composable declarations - ALWAYS pass
modifier = ...as the LAST argument in composable calls - ALWAYS add trailing commas in multi-line declarations; NEVER add a trailing comma to
modifier = ...at call sites - ALWAYS use
navController.navigateTo(route)for simple navigation; NEVER use rawnavController.navigate(route)—navigateToprevents duplicate destinations - ALWAYS prefer
VerticalSpacer,HorizontalSpacer,FillHeightandFillWidthoverSpacerwhen applicable - PREFER declaring small dependant classes, constants, interfaces or top-level functions in the same file with the core class where these are used
- ALWAYS create data classes for state AFTER viewModel class in same file
- ALWAYS return early where applicable, PREFER guard-like
ifconditions likeif (condition) return - USE
docs/as target dir of saved files when asked to create documentation for new features - NEVER write code in the documentation files
- NEVER add code comments to private functions, classes, etc
- ALWAYS use
_uiState.update { }, NEVER use_stateFlow.value = - ALWAYS add the warranted changes in unit tests to keep the unit tests succeeding
- ALWAYS follow the patterns of the existing code in
app/src/testwhen writing new unit tests - ALWAYS be mindful of thread safety when working with mutable lists & state
- ALWAYS split screen composables into parent accepting viewmodel + inner private child accepting state and callbacks
Content() - ALWAYS name lambda parameters in a composable function using present tense, NEVER use past tense
- ALWAYS use
whenever { mock.suspendCall() }for suspend stubs if not insidetest{}fn blocks - ALWAYS use
whenever(mock.call())for non-suspend stubs and for suspend stubs if insidetest{}fn blocks - NEVER use the old, deprecated
wheneverBlocking - ALWAYS prefer
kotlin.testasserts overorg.junit.Assertin unit tests - ALWAYS use a deterministic locale in unit tests to ensure consistent results across CI and local runs
- ALWAYS add a locale parameter with default value
Locale.getDefault()to methods that depend on locale - ALWAYS add business logic to repository layer via methods returning
Result<T>and use it in ViewModels - ALWAYS order upstream architectural data flow this way:
UI -> ViewModel -> Repository -> RUSTand vice versa for downstream - ALWAYS add new localizable string resources in alphabetical order in
strings.xml - NEVER add string resources for strings used only in dev settings screens and previews and never localize acronyms
- ALWAYS use template in
.github/pull_request_template.mdfor PR descriptions - ALWAYS wrap
ULongnumbers withUSatin arithmetic operations, to guard against overflows - PREFER to use one-liners with
run {}when applicable, e.g.override fun someCall(value: String) = run { this.value = value } - ALWAYS add imports instead of inline fully-qualified names
- PREFER to place
@Suppress()annotations at the narrowest possible scope - ALWAYS wrap suspend functions in
withContext(ioDispatcher)if in domain layer, using ctor injected prop@IoDispatcher private val ioDispatcher: CoroutineDispatcher - ALWAYS position
companion objectat the top of the class - NEVER use
Exceptiondirectly, useAppErrorinstead - ALWAYS inherit custom exceptions from
AppError - ALWAYS prefer
requireNotNull(someNullable) { "error message" }orcheckNotNull { "someErrorMessage" }over!!or?: SomeAppError() - ALWAYS prefer Kotlin
Durationfor timeouts and delays - ALWAYS prefer
when (subject)with Kotlin guard conditions (if) over condition-basedwhen {}withistype checks, e.g.when (event) { is Foo if event.x == y -> ... }instead ofwhen { event is Foo && event.x == y -> ... } - ALWAYS prefer
sealed interfaceoversealed classwhen no shared state or constructor is needed - NEVER duplicate error logging in
.onFailure {}if the called method already logs the same error internally - ALWAYS use
ImmutableList/ImmutableMap/ImmutableSetinstead ofList/Map/Setfor composable function parameters and UiState data class fields - ALWAYS annotate UiState data classes with
@Immutable; use@Stableinstead when any field holds a non-immutable type (e.g.Throwable, external library types frombitkitcore/ldknode/vssclient, or types containing plainList/Map/Set) - ALWAYS use
.toImmutableList(),.toImmutableMap(),.toImmutableSet()when producing collections for UI state - ALWAYS use
persistentListOf(),persistentMapOf(),persistentSetOf()for default values in UiState fields
- ALWAYS add exactly ONE entry per PR under
## [Unreleased]inCHANGELOG.mdforfeat:andfix:PRs; skip forchore:,ci:,refactor:,test:,docs:unless the change is user-facing - NEVER add multiple changelog lines for the same PR — summarize all changes in a single concise entry
- USE standard Keep a Changelog categories:
### Added,### Changed,### Deprecated,### Removed,### Fixed,### Security - ALWAYS append
#PR_NUMBERat the end of each changelog entry when the PR number is known - ALWAYS place new entries at the top of their category section (newest first)
- NEVER modify released version sections — only edit
## [Unreleased] - ALWAYS create category headings on demand (don't add empty stubs)
- App IDs per flavor:
to.bitkit.dev(dev/regtest),to.bitkit.tnet(testnet),to.bitkit(mainnet) - ALWAYS use
adb shell "run-as to.bitkit.dev ..."to access the app's private data directory (debug builds only) - App files root:
files/(relative, insiderun-ascontext) - Key paths:
files/logs/— app log files (e.g.bitkit_2026-02-09_21-04-16.log)files/bitcoin/wallet0/ldk/— LDK node storage (graph cache, dumps)files/bitcoin/wallet0/core/— bitkit-core storagefiles/datastore/— DataStore preferences and JSON stores
- To read a file:
adb shell "run-as to.bitkit.dev cat files/logs/bitkit_YYYY-MM-DD_HH-MM-SS.log" - To list files:
adb shell "run-as to.bitkit.dev ls -la files/logs/" - To find files:
adb shell "run-as to.bitkit.dev find files/ -name '*.log' -o -name '*.txt'" - ALWAYS download device files to
.ai/{name}_{timestamp}/when needed for debugging (e.g..ai/logs_1770671066/) - To download:
adb shell "run-as to.bitkit.dev cat files/path/to/file" > .ai/folder_timestamp/filename - ALWAYS try reading device logs automatically via adb BEFORE asking user to provide log files
- Use
LightningNodeServiceto manage background notifications while the node is running - Use
LightningServiceto wrap node's RUST APIs and manage the inner lifecycle of the node - Use
LightningRepoto defining the business logic for the node operations, usually delegating toLightningService - Use
WakeNodeWorkerto manage the handling of remote notifications received via cloud messages - Use
*Servicesto wrap rust library code exposed via bindings - Use CQRS pattern of Command + Handler like it's done in the
NotifyPaymentReceived+NotifyPaymentReceivedHandlersetup