Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .cursor/notes/libs.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ This document provides a comprehensive reference for all libraries used in the b
### Layout
- **ConstraintLayout Compose**: https://developer.android.com/jetpack/compose/layouts/constraintlayout

### WebKit
- **Documentation**: https://developer.android.com/jetpack/androidx/releases/webkit
- **WebMessageListener**: https://developer.android.com/develop/ui/views/layout/webapps/native-api-access-jsbridge

## Architecture & Dependency Injection

### Hilt
Expand Down
26 changes: 25 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.4.1] - 2026-08-21

### Changed
- Bitkit now keeps the Lightning node running a little longer when you briefly leave the app, so quick trips to another app no longer reload the wallet on return. #1146

### Fixed
- Lightning node teardown now releases native resources deterministically and no longer restarts the node when the app is only briefly backgrounded. #1100
- Fixed native library compatibility on Android devices using 16 KB memory pages. #1107
- Lightning node shutdown and peer persistence now complete without native crashes or app hangs. #1122
- Fixed Pubky authorization links opening Bitkit when the feature is unavailable or no local identity can approve them. #1162
- Fixed the wallet backup failing repeatedly when Paykit state could not be read. #1092
- Hardened background task error handling to prevent rare crashes. #1094

### Security
- Lightning no longer automatically starts from outdated channel monitor data after a storage mismatch. #1155
- Wallet backups no longer fall back to unauthenticated VSS when LNURL-auth is missing. #1156
- Shop checkout only accepts Bitrefill payment requests, and payment links wait until the wallet is unlocked. #1158
- Wallet backups now use VSS 0.5.23, which rejects unauthenticated encryption. #1164




Comment on lines +28 to +31

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

only one empty newline should land in master, not an issue here, just FYI


## [2.4.0] - 2026-07-15

### Added
Expand Down Expand Up @@ -148,7 +171,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- About screen (content merged into Support) #857
- Standalone General, Security, and Advanced settings screens (merged into tabs) #857

[Unreleased]: https://github.com/synonymdev/bitkit-android/compare/v2.4.0...HEAD
[Unreleased]: https://github.com/synonymdev/bitkit-android/compare/v2.4.1...HEAD
[2.4.1]: https://github.com/synonymdev/bitkit-android/compare/v2.4.0...v2.4.1
[2.4.0]: https://github.com/synonymdev/bitkit-android/compare/v2.3.2...v2.4.0
[2.3.2]: https://github.com/synonymdev/bitkit-android/compare/v2.3.1...v2.3.2
[2.3.1]: https://github.com/synonymdev/bitkit-android/compare/v2.3.0...v2.3.1
Expand Down
5 changes: 3 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,8 @@ android {
applicationId = "to.bitkit"
minSdk = 28
targetSdk = 36
versionCode = 187
versionName = "2.4.0"
versionCode = 188
versionName = "2.4.1"
testInstrumentationRunner = "to.bitkit.test.HiltTestRunner"
bitkitAndroidTestAnnotation?.let {
testInstrumentationRunnerArguments["annotation"] = it
Expand Down Expand Up @@ -368,6 +368,7 @@ dependencies {
implementation(libs.core.ktx)
implementation(libs.core.splashscreen)
implementation(libs.appcompat)
implementation(libs.webkit)
implementation(libs.activity.compose)
implementation(libs.material)
implementation(libs.datastore.preferences)
Expand Down
15 changes: 14 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@
<data android:scheme="lnurlw" />
<data android:scheme="lnurlc" />
<data android:scheme="lnurlp" />
<data android:scheme="pubkyauth" />
</intent-filter>

<!-- NFC -->
Expand Down Expand Up @@ -164,6 +163,20 @@
android:resource="@xml/shortcuts" />
</activity>

<!-- Enabled only while Bitkit can authorize pubkyauth requests locally. -->
<activity-alias
android:name=".ui.MainActivityPubkyAuth"
android:targetActivity=".ui.MainActivity"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="pubkyauth" />
</intent-filter>
</activity-alias>

<service
android:name=".fcm.FcmService"
android:exported="false">
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/to/bitkit/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import to.bitkit.appwidget.AppWidgetRefreshReason
import to.bitkit.appwidget.AppWidgetRefreshScheduler
import to.bitkit.env.Env
import to.bitkit.services.BluetoothInit
import to.bitkit.services.PubkyAuthHandlerRegistrar
import to.bitkit.utils.Logger
import javax.inject.Inject

@HiltAndroidApp
Expand All @@ -27,6 +29,9 @@ internal open class App : Application(), Configuration.Provider {
@Inject
lateinit var appWidgetRefreshScheduler: AppWidgetRefreshScheduler

@Inject
lateinit var pubkyAuthHandlerRegistrar: PubkyAuthHandlerRegistrar

override val workManagerConfiguration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
Expand All @@ -35,14 +40,26 @@ internal open class App : Application(), Configuration.Provider {
override fun onCreate() {
super.onCreate()
Env.initAppStoragePath(filesDir.absolutePath)
installUncaughtExceptionLogger()
SingletonImageLoader.setSafe { imageLoader }
currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) }
appWidgetRefreshScheduler.ensureScheduled(AppWidgetRefreshReason.APP_START)
// Initialize btleplug for Bluetooth support (required before any BLE usage)
BluetoothInit.ensureInitialized()
pubkyAuthHandlerRegistrar.start()
}

private fun installUncaughtExceptionLogger() {
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
Logger.error("Uncaught exception on thread '${thread.name}'", throwable, context = TAG)
previous?.uncaughtException(thread, throwable)
}
}

companion object {
private const val TAG = "App"

@SuppressLint("StaticFieldLeak") // Should be safe given its manual memory management
internal var currentActivity: CurrentActivity? = null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,13 @@ import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.lightningdevkit.ldknode.Event
import to.bitkit.App
import to.bitkit.R
import to.bitkit.appwidget.AppWidgetRefreshReason
import to.bitkit.appwidget.AppWidgetRefreshScheduler
import to.bitkit.async.appScope
import to.bitkit.data.CacheStore
import to.bitkit.di.UiDispatcher
import to.bitkit.domain.commands.NotifyChannelReady
Expand Down Expand Up @@ -53,7 +52,7 @@ class LightningNodeService : Service() {
@UiDispatcher
lateinit var uiDispatcher: CoroutineDispatcher

private val serviceScope by lazy { CoroutineScope(SupervisorJob() + uiDispatcher) }
private val serviceScope by lazy { appScope(uiDispatcher, TAG) }

@Inject
lateinit var lightningRepo: LightningRepo
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/to/bitkit/async/BaseCoroutineScope.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import kotlin.coroutines.CoroutineContext

open class BaseCoroutineScope(
private val dispatcher: CoroutineDispatcher,
) : CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher) {
tag: String,
) : CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher + loggingExceptionHandler(tag)) {

@Throws(InterruptedException::class)
protected fun <T> runBlocking(
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/java/to/bitkit/async/CoroutineScopes.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package to.bitkit.async

import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import to.bitkit.utils.Logger

fun loggingExceptionHandler(tag: String): CoroutineExceptionHandler =
CoroutineExceptionHandler { _, throwable ->
Logger.error("Uncaught coroutine exception", throwable, context = tag)
}

fun appScope(dispatcher: CoroutineDispatcher, tag: String): CoroutineScope =
CoroutineScope(dispatcher + SupervisorJob() + loggingExceptionHandler(tag))
5 changes: 3 additions & 2 deletions app/src/main/java/to/bitkit/async/ServiceQueue.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import to.bitkit.ext.runSuspendCatching
import to.bitkit.utils.AppError
import java.util.concurrent.Executors
import java.util.concurrent.ThreadFactory
Expand All @@ -20,14 +21,14 @@ enum class ServiceQueue {
coroutineContext: CoroutineContext = scope.coroutineContext,
block: suspend CoroutineScope.() -> T,
): T = runBlocking(coroutineContext) {
runCatching { block() }.getOrElse { throw AppError(it) }
runSuspendCatching { block() }.getOrElse { throw AppError(it) }
}

suspend fun <T> background(
coroutineContext: CoroutineContext = scope.coroutineContext,
block: suspend CoroutineScope.() -> T,
): T = withContext(coroutineContext) {
runCatching { block() }.getOrElse { throw AppError(it) }
runSuspendCatching { block() }.getOrElse { throw AppError(it) }
}
}

Expand Down
28 changes: 12 additions & 16 deletions app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import com.synonym.vssclient.VssItem
import com.synonym.vssclient.vssDelete
import com.synonym.vssclient.vssGet
import com.synonym.vssclient.vssListKeys
import com.synonym.vssclient.vssNewClient
import com.synonym.vssclient.vssNewClientWithLnurlAuth
import com.synonym.vssclient.vssStore
import kotlinx.coroutines.CompletableDeferred
Expand All @@ -19,6 +18,7 @@ import to.bitkit.data.keychain.Keychain
import to.bitkit.di.IoDispatcher
import to.bitkit.env.Env
import to.bitkit.utils.Logger
import to.bitkit.utils.ServiceError
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
Expand Down Expand Up @@ -51,22 +51,18 @@ class VssBackupClient @Inject constructor(
val vssStoreId = vssStoreIdProvider.getVssStoreId(walletIndex)
Logger.verbose("Building VSS client with vssUrl: '$vssUrl'", context = TAG)
Logger.verbose("Building VSS client with lnurlAuthServerUrl: '$lnurlAuthServerUrl'", context = TAG)
if (lnurlAuthServerUrl.isNotEmpty()) {
val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)

vssNewClientWithLnurlAuth(
baseUrl = vssUrl,
storeId = vssStoreId,
mnemonic = mnemonic,
passphrase = passphrase,
lnurlAuthServerUrl = lnurlAuthServerUrl,
)
} else {
vssNewClient(
baseUrl = vssUrl,
storeId = vssStoreId,
)
if (lnurlAuthServerUrl.isBlank()) {
throw ServiceError.VssAuthRequired()
}
val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)

vssNewClientWithLnurlAuth(
baseUrl = vssUrl,
storeId = vssStoreId,
mnemonic = mnemonic,
passphrase = passphrase,
lnurlAuthServerUrl = lnurlAuthServerUrl,
)
isSetup.complete(Unit)
Logger.info("VSS client setup with server: '$vssUrl'", context = TAG)
}
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import to.bitkit.data.keychain.Keychain
import to.bitkit.di.IoDispatcher
import to.bitkit.env.Env
import to.bitkit.utils.Logger
import to.bitkit.utils.ServiceError
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
Expand Down Expand Up @@ -52,6 +53,9 @@ class VssBackupClientLdk @Inject constructor(
?: throw MnemonicNotAvailableException()

withTimeout(30.seconds) {
if (Env.lnurlAuthServerUrl.isBlank()) {
throw ServiceError.VssAuthRequired()
}
val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)
vssNewLdkClientWithLnurlAuth(
baseUrl = Env.vssServerUrl,
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/to/bitkit/data/keychain/Keychain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class Keychain @Inject constructor(
private val db: AppDb,
@ApplicationContext private val context: Context,
@IoDispatcher private val dispatcher: CoroutineDispatcher,
) : BaseCoroutineScope(dispatcher) {
) : BaseCoroutineScope(dispatcher, TAG) {
companion object {
private const val TAG = "Keychain"
private const val CAUSE_CHAIN_DEPTH = 4
Expand Down
Loading
Loading