Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 24 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ 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

### 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





## [2.4.0] - 2026-07-15

### Added
Expand Down Expand Up @@ -148,7 +170,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
5 changes: 5 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,7 @@ 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 javax.inject.Inject

@HiltAndroidApp
Expand All @@ -27,6 +28,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 @@ -40,6 +44,7 @@ internal open class App : Application(), Configuration.Provider {
appWidgetRefreshScheduler.ensureScheduled(AppWidgetRefreshReason.APP_START)
// Initialize btleplug for Bluetooth support (required before any BLE usage)
BluetoothInit.ensureInitialized()
pubkyAuthHandlerRegistrar.start()
}

companion object {
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
48 changes: 25 additions & 23 deletions app/src/main/java/to/bitkit/repositories/BackupRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import to.bitkit.di.IoDispatcher
import to.bitkit.di.json
import to.bitkit.ext.formatPlural
import to.bitkit.ext.nowMillis
import to.bitkit.ext.runSuspendCatching
import to.bitkit.models.ActivityBackupV1
import to.bitkit.models.BackupCategory
import to.bitkit.models.BackupItemStatus
Expand Down Expand Up @@ -425,7 +426,7 @@ class BackupRepo @Inject constructor(
}
}

suspend fun triggerBackup(category: BackupCategory) = withContext(ioDispatcher) {
suspend fun triggerBackup(category: BackupCategory): Result<Unit> = withContext(ioDispatcher) {
Logger.debug("Backup starting for: '$category'", context = TAG)

val backupRequired = currentTimeMillis()
Expand All @@ -435,7 +436,13 @@ class BackupRepo @Inject constructor(
it.copy(running = true, required = backupRequired)
}

vssBackupClient.putObject(key = category.name, data = getBackupDataBytes(category))
val data = runSuspendCatching { getBackupDataBytes(category) }
.getOrElse {
markBackupFailed(category, backupRequired, it)
return@withContext Result.failure(it)
}

vssBackupClient.putObject(key = category.name, data = data)
.onSuccess {
runningBackups -= category
failedBackupRequired -= category
Expand All @@ -447,18 +454,21 @@ class BackupRepo @Inject constructor(
}
Logger.info("Backup succeeded for: '$category'", context = TAG)
}
.onFailure { e ->
runningBackups -= category
cacheStore.updateBackupStatus(category) {
if (it.required == backupRequired) {
failedBackupRequired[category] = backupRequired
} else {
failedBackupRequired -= category
}
it.copy(running = false)
}
Logger.error("Backup failed for: '$category'", e, context = TAG)
.onFailure { markBackupFailed(category, backupRequired, it) }
.map {}
}

private suspend fun markBackupFailed(category: BackupCategory, backupRequired: Long, e: Throwable) {
runningBackups -= category
cacheStore.updateBackupStatus(category) {
if (it.required == backupRequired) {
failedBackupRequired[category] = backupRequired
} else {
failedBackupRequired -= category
}
it.copy(running = false)
}
Logger.error("Backup failed for: '$category'", e, context = TAG)
}

private suspend fun getBackupDataBytes(category: BackupCategory): ByteArray = when (category) {
Expand Down Expand Up @@ -534,16 +544,8 @@ class BackupRepo @Inject constructor(

private suspend fun getWalletBackupDataBytes(): ByteArray {
val transfers = db.transferDao().getAll()
val privateReservations = privatePaykitAddressReservationRepo.get().backupSnapshot()
.onFailure {
Logger.warn("Failed to snapshot private Paykit reservations", it, context = TAG)
}
.getOrThrow()
val paykitSdkBackupState = privatePaykitRepo.get().backupSnapshot()
.onFailure {
Logger.warn("Failed to snapshot Paykit SDK state", it, context = TAG)
}
.getOrThrow()
val privateReservations = privatePaykitAddressReservationRepo.get().backupSnapshot().getOrThrow()
val paykitSdkBackupState = privatePaykitRepo.get().backupSnapshot().getOrThrow()

val payload = WalletBackupV1(
createdAt = currentTimeMillis(),
Expand Down
Loading
Loading