Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
dbb4b40
feat: share paykit state across apps
ben-kaufman Oct 1, 2026
bfe5c7f
chore: rename changelog fragment
ben-kaufman Oct 1, 2026
7d4fcde
docs: clarify paykit integration contracts
ben-kaufman Oct 1, 2026
dd190bf
fix: tighten paykit attribution and cleanup reporting
ben-kaufman Oct 1, 2026
517114b
test: cover paykit key generation and rotation
ben-kaufman Oct 1, 2026
b5a231d
fix: guard paykit execution and cache contact backfills
ben-kaufman Oct 1, 2026
b68738e
fix: prune completed paykit acceptance records
ben-kaufman Oct 1, 2026
b62f6b2
fix: preserve paykit payment recovery and contact choices
ben-kaufman Oct 1, 2026
453bc70
refactor: simplify paykit contact backfill
ben-kaufman Oct 1, 2026
8362c93
chore: update Paykit to rc59
ben-kaufman Oct 1, 2026
967e4c0
fix: preserve private sharing settings during contact cleanup
ben-kaufman Oct 1, 2026
40aef85
test: align request presentation with shared paykit
ben-kaufman Oct 1, 2026
d68ecbb
test: align private paykit settings stub
ben-kaufman Oct 1, 2026
a371aa6
fix: reduce paykit sync overhead and retry session setup
ben-kaufman Oct 1, 2026
0665c49
fix: keep private message sync off frequent request polls
ben-kaufman Oct 2, 2026
5da2ef2
fix: preserve paykit activation and recurring payments
ben-kaufman Oct 2, 2026
8203350
fix: preserve paykit recovery and payment details
ben-kaufman Oct 2, 2026
2458eae
fix: stop redundant paykit polling
ben-kaufman Oct 2, 2026
2ca29dc
fix: guard paykit payment authorization and cleanup
ben-kaufman Oct 2, 2026
8823181
style: format paykit backfill test
ben-kaufman Oct 2, 2026
2f42424
fix: yield between paykit handshake advances
ben-kaufman Oct 2, 2026
7d3c196
fix: consolidate paykit background retries
ben-kaufman Oct 2, 2026
09c6426
fix: receive Paykit messages on each inbox poll
ben-kaufman Oct 2, 2026
10a3a64
fix: report private endpoint withdrawal failures
ben-kaufman Oct 2, 2026
eaf6a09
fix: coalesce paykit preparation and reduce inbox polling
ben-kaufman Oct 2, 2026
0fdff06
fix: coordinate private contact preparation and request discovery
ben-kaufman Oct 2, 2026
85f1963
Avoid transport maintenance when opening subscription reminders
ben-kaufman Oct 2, 2026
bb4637d
fix: exclude sdk queue waits from request discovery timeout
ben-kaufman Oct 2, 2026
75e5429
fix: reduce redundant paykit synchronization
ben-kaufman Oct 3, 2026
4951920
fix: keep contact payments responsive during background sync
ben-kaufman Oct 3, 2026
09e224c
fix: avoid duplicate private cleanup registry sync
ben-kaufman Oct 3, 2026
dc73b29
fix: integrate master profile updates and Paykit rc60
ben-kaufman Oct 3, 2026
288c7d8
perf: use rc61 and separate inbox polling from maintenance
ben-kaufman Oct 3, 2026
172cb30
chore: update Paykit to rc62
ben-kaufman Oct 3, 2026
a29e32d
perf: coalesce overlapping paykit refreshes
ben-kaufman Oct 4, 2026
e8a7686
fix: block deleted contacts without peer records
ben-kaufman Oct 4, 2026
fbe136e
Batch private Paykit list withdrawal
ben-kaufman Oct 5, 2026
b4b2141
fix: preserve Android security providers during crypto setup
ben-kaufman Oct 5, 2026
e4e5f75
Avoid redundant Paykit startup and failed-write reads
ben-kaufman Oct 5, 2026
e7aad37
Skip linked-peer handshake advancement during drains
ben-kaufman Oct 5, 2026
0eaae43
Document foreground Paykit inbox polling
ben-kaufman Oct 5, 2026
a38f515
fix: refresh Paykit requests after state changes
ben-kaufman Oct 5, 2026
b752e7d
fix: preserve Paykit cleanup and request presentation state
ben-kaufman Oct 5, 2026
cd4648b
fix: preserve Paykit presentation and queue ordering
ben-kaufman Oct 5, 2026
3266919
fix: complete active Paykit calls before cancellation
ben-kaufman Oct 5, 2026
3f61604
perf: reuse identity status during private publication
ben-kaufman Oct 5, 2026
11f6861
chore: merge master into shared paykit runtime
ben-kaufman Oct 5, 2026
b43b5d7
Merge master and preserve Paykit session guards
ben-kaufman Oct 5, 2026
d44d5bb
Refresh payment requests only for relevant proof changes
ben-kaufman Oct 5, 2026
0d21ff9
fix: keep Paykit request controls responsive during refresh
ben-kaufman Oct 5, 2026
98a1570
fix: contain identity failures during proof cleanup
ben-kaufman Oct 5, 2026
bb6cef7
fix: retry pending endpoint cleanup during maintenance
ben-kaufman Oct 5, 2026
b101e89
fix: prepare incoming payments in the send sheet
ben-kaufman Oct 6, 2026
9ffb08a
fix: prioritize incoming request work during polling
ben-kaufman Oct 6, 2026
57c9cab
fix: retain subscription reminders through refresh failures
ben-kaufman Oct 6, 2026
5310b75
perf: prioritize sharing withdrawal completion
ben-kaufman Oct 6, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import androidx.navigation.compose.rememberNavController
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.flow.flowOf
import org.junit.Before
import org.junit.Rule
import org.junit.Test
Expand Down Expand Up @@ -70,6 +71,7 @@ class DrawerMenuWidgetsTest {
hasSeenShopIntro = true,
onBeforeNavigate = {},
showWidgets = true,
profileIdentityExists = flowOf(false),
)
}
}
Expand Down Expand Up @@ -102,6 +104,7 @@ class DrawerMenuWidgetsTest {
hasSeenShopIntro = true,
onBeforeNavigate = {},
showWidgets = true,
profileIdentityExists = flowOf(false),
onOpenWidgetsHome = { openWidgetsHome.value = true },
)
if (openWidgetsHome.value) {
Expand Down Expand Up @@ -138,6 +141,7 @@ class DrawerMenuWidgetsTest {
hasSeenShopIntro = true,
onBeforeNavigate = {},
showWidgets = false,
profileIdentityExists = flowOf(false),
onOpenWidgetsHome = { error("Should not request home widgets page") },
onOpenWidgetsSheet = { openWidgetsSheet.value = true },
)
Expand Down Expand Up @@ -177,6 +181,7 @@ class DrawerMenuWidgetsTest {
hasSeenShopIntro = true,
onBeforeNavigate = {},
showWidgets = true,
profileIdentityExists = flowOf(true),
isPaykitEnabled = true,
)
}
Expand All @@ -201,6 +206,7 @@ class DrawerMenuWidgetsTest {
hasSeenShopIntro = true,
onBeforeNavigate = {},
showWidgets = true,
profileIdentityExists = flowOf(true),
isPaykitEnabled = false,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,11 @@ class CreatePaymentRequestScreenTest {

private val target = PaykitPaymentRequestTarget(
publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg",
receiverPath = "bitkit/wallet",
)

private val request = PaykitPaymentRequest(
paymentRequestId = "payment-request",
counterparty = target.publicKey,
counterpartyReceiverPath = target.receiverPath,
amountValue = "0.00025",
amountSats = draft.amountSats,
note = draft.note,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ package to.bitkit.ui.screens.paymentrequests

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.assertTextEquals
Expand Down Expand Up @@ -40,6 +42,31 @@ class PaymentRequestsScreenTest {
@get:Rule
val composeTestRule = createComposeRule()

@Test
fun detailsDisablePayWhilePreparationIsPending() {
val isPreparing = mutableStateOf(false)
val request = request()
composeTestRule.setContent {
PaymentRequestsTestSurface {
IncomingPaymentRequestDetailsContent(
request = request,
contact = PubkyProfile.placeholder(request.counterparty),
isPending = true,
isPreparing = isPreparing.value,
onBack = {},
onPay = {},
onDismiss = { Result.success(Unit) },
)
}
}

composeTestRule.onNodeWithTag("PaymentRequestDetailsPay").assertIsEnabled()
composeTestRule.runOnIdle { isPreparing.value = true }
composeTestRule.onNodeWithTag("PaymentRequestDetailsPay").assertIsNotEnabled()
composeTestRule.runOnIdle { isPreparing.value = false }
composeTestRule.onNodeWithTag("PaymentRequestDetailsPay").assertIsEnabled()
}

@Test
fun queueShowsIncomingRequestAndSeeAllAction() {
val request = request(id = "incoming")
Expand Down Expand Up @@ -289,7 +316,6 @@ class PaymentRequestsScreenTest {
private fun request(id: String = "request") = PaykitPaymentRequest(
paymentRequestId = id,
counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg",
counterpartyReceiverPath = "bitkit/wallet",
amountValue = "0.00025",
amountSats = 25_000uL,
note = "Dinner",
Expand All @@ -301,7 +327,6 @@ class PaymentRequestsScreenTest {
private fun subscription(note: String) = PaykitSubscription(
paymentRequestId = "subscription",
counterparty = request().counterparty,
counterpartyReceiverPath = "bitkit/wallet",
amountValue = "0.00025",
amountSats = 25_000uL,
note = note,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ class CreateSubscriptionScreenTest {

@Test
fun recipientAllowsExactlyOneSelectionAndChangesExpiry() {
val second = PaykitPaymentRequestTarget("pubky" + "z".repeat(52), "bitkit/wallet")
val second = PaykitPaymentRequestTarget("pubky" + "z".repeat(52))
var selected by mutableStateOf<PaykitPaymentRequestTarget?>(null)
var expiration by mutableStateOf(PaymentRequestExpiration.Week)
var proposedTo: PaykitPaymentRequestTarget? = null
Expand Down Expand Up @@ -158,13 +158,12 @@ class CreateSubscriptionScreenTest {
composeTestRule.onNodeWithText("OK").assertIsDisplayed()
}

private val target = PaykitPaymentRequestTarget("pubky" + "y".repeat(52), "bitkit/wallet")
private val target = PaykitPaymentRequestTarget("pubky" + "y".repeat(52))
private val contact = PubkyProfile.forDisplay(target.publicKey, "Anna", null)
private val startsAt = Instant.parse("2027-01-15T08:00:00Z")
private val subscription = PaykitSubscription(
paymentRequestId = "creator-proposal",
counterparty = target.publicKey,
counterpartyReceiverPath = target.receiverPath,
amountValue = "0.00001",
amountSats = 1000uL,
note = "Support",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,39 +1,159 @@
@file:OptIn(ExperimentalTime::class)

package to.bitkit.ui.screens.wallets.send

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeRight
import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import to.bitkit.models.FeeRate
import to.bitkit.models.PubkyProfile
import to.bitkit.repositories.PaykitPaymentRequest
import to.bitkit.test.annotations.ComposeUi
import to.bitkit.ui.components.Sheet
import to.bitkit.ui.components.SheetHost
import to.bitkit.ui.shared.modifiers.sheetHeight
import to.bitkit.ui.sheets.SendRoute
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.viewmodels.OnchainFeeUi
import to.bitkit.viewmodels.SendMethod
import to.bitkit.viewmodels.SendUiState
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.ExperimentalTime

@HiltAndroidTest
@ComposeUi
@OptIn(ExperimentalMaterial3Api::class)
class SendConfirmScreenTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)

@get:Rule
val composeTestRule = createComposeRule()

@Before
fun setup() {
hiltRule.inject()
}

@Test
fun preparingRequestShowsSavedMetadataUntilConfirmationIsReady() {
val request = PaykitPaymentRequest(
paymentRequestId = "preparing",
counterparty = "requester",
amountValue = "5000",
amountSats = 5_000u,
note = "Dinner",
expiresAt = null,
acceptedPaymentEndpointIdentifiers = listOf("bitcoin"),
)
val contact = PubkyProfile.placeholder(request.counterparty).copy(name = "Coffee House")
val preparation = mutableStateOf<PaykitPaymentRequest?>(request)
val state = mutableStateOf(
SendUiState(
amount = 99_000u,
isAmountInputValid = true,
payMethod = SendMethod.LIGHTNING,
isInitialSubscriptionPayment = true,
initialSubscriptionPaymentAutoStartPending = true,
paymentRequestNote = "Stale note",
),
)
var paymentAttempts = 0
var dismissCount = 0
var visibleCount = 0
lateinit var sheetState: SheetState
composeTestRule.setContent {
AppThemeSurface {
CompositionLocalProvider(LocalInspectionMode provides true) {
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
SheetHost(
shouldExpand = true,
visibilityKey = Sheet.Send(SendRoute.Confirm, preparingRequest = preparation.value),
onVisible = { visibleCount++ },
onDismiss = { dismissCount++ },
sheetState = sheetState,
sheets = {
SendConfirmContent(
uiState = state.value,
isNodeRunning = preparation.value == null,
isLoading = false,
showBiometrics = false,
preparingRequest = preparation.value,
preparingContact = contact,
onSwipeToConfirm = { paymentAttempts++ },
modifier = Modifier.sheetHeight()
)
},
content = { Box(Modifier.fillMaxSize()) },
)
}
}
}

composeTestRule.onNodeWithTag("PaymentRequestConfirm").assertIsDisplayed()
composeTestRule.onNodeWithTag("PaymentRequestFrom").assertTextEquals("Coffee House")
composeTestRule.onNodeWithTag("PaymentRequestFor").assertTextEquals("Dinner")
composeTestRule.onNodeWithTag("PaymentRequestPreparing").assertIsDisplayed().assertIsNotEnabled()
composeTestRule.onNodeWithTag("SendConfirmToggleDetails").assertDoesNotExist()
composeTestRule.onNodeWithText("Stale note").assertDoesNotExist()
composeTestRule.onNodeWithTag("PaymentRequestPreparing").performTouchInput { swipeRight() }
composeTestRule.runOnIdle {
assertEquals(0, paymentAttempts)
assertEquals(SheetValue.Expanded, sheetState.currentValue)
assertEquals(1, visibleCount)
state.value = SendUiState(
amount = request.amountSats,
isPaymentRequest = true,
isAmountInputValid = true,
contactPaymentProfile = contact,
paymentRequestNote = request.note,
incomingPaymentRequestId = request.id,
)
preparation.value = null
}

composeTestRule.onNodeWithTag("PaymentRequestPreparing").assertDoesNotExist()
composeTestRule.onNodeWithTag("PaymentRequestConfirm").assertIsDisplayed()
composeTestRule.onNodeWithTag("PaymentRequestFrom").assertTextEquals("Coffee House")
composeTestRule.onNodeWithTag("PaymentRequestFor").assertTextEquals("Dinner")
composeTestRule.onNodeWithTag("GRAB").assertIsDisplayed()
composeTestRule.runOnIdle {
assertEquals(SheetValue.Expanded, sheetState.currentValue)
assertEquals(SheetValue.Expanded, sheetState.targetValue)
assertEquals(2, visibleCount)
assertEquals(0, dismissCount)
}
}

@Test
fun initialOnchainSubscriptionShowsFeeBeforeConfirmation() {
val state = SendUiState(
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/to/bitkit/data/CacheStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ class CacheStore internal constructor(
store.updateData { it.copy(onchainAddress = address) }
}

suspend fun setActivityContactDetached(activityId: String, walletId: String, detached: Boolean) {
val id = scopedActivityId(walletId, activityId)
store.updateData {
val contacts = it.detachedActivityContacts
it.copy(detachedActivityContacts = if (detached) contacts + id else contacts - id)
}
}

suspend fun saveBolt11(bolt11: String, paymentHash: String) {
store.updateData { it.copy(bolt11 = bolt11, bolt11PaymentHash = paymentHash) }
}
Expand Down Expand Up @@ -171,6 +179,7 @@ data class AppCacheData(
val balance: BalanceState? = null,
val backupStatuses: Map<BackupCategory, BackupItemStatus> = mapOf(),
val deletedActivities: List<String> = listOf(),
val detachedActivityContacts: Set<String> = emptySet(),
val pendingBoostActivities: List<PendingBoostActivity> = listOf(),
val backgroundReceive: NewTransactionSheetDetails? = null,
val addressSearchLastUsedReceiveIndexes: Map<String, Int> = mapOf(),
Expand All @@ -180,6 +189,9 @@ data class AppCacheData(
/** LNURL-pay comments by payment hash, kept until the sent payment's activity stores them. */
val pendingLightningMessages: Map<String, String> = mapOf(),
) {
fun isContactDetached(activityId: String, walletId: String): Boolean =
scopedActivityId(walletId, activityId) in detachedActivityContacts

fun isActivityDeleted(activityId: String, walletId: String): Boolean =
scopedActivityId(walletId, activityId) in deletedActivities ||
walletId == WalletScope.default && activityId in deletedActivities
Expand Down
6 changes: 3 additions & 3 deletions app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ data class PrivatePaykitCacheData(
@Serializable
data class PrivatePaykitContactCacheData(
val remoteEndpoints: List<PrivatePaykitStoredPaymentEntryData> = emptyList(),
val consumedPrivatePaymentListVersionsByReceiverPath: Map<String, ULong> = emptyMap(),
val localInvoicesByReceiverPath: Map<String, PrivatePaykitStoredInvoiceData> = emptyMap(),
val consumedPrivatePaymentListVersion: ULong? = null,
val localInvoice: PrivatePaykitStoredInvoiceData? = null,
val receivedInvoicePaymentHashes: List<String> = emptyList(),
val publishedPrivatePaymentReceiverPaths: Set<String> = emptySet(),
val hasPublishedPrivatePaymentList: Boolean = false,
)

@Serializable
Expand Down
5 changes: 3 additions & 2 deletions app/src/main/java/to/bitkit/data/keychain/Keychain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,11 @@ class Keychain @Inject constructor(
PIN,
PIN_ATTEMPTS_REMAINING,
PAYKIT_SESSION,
PAYKIT_RECEIVER_NOISE_SECRET_KEY,
PAYKIT_SDK_STATE,
PAYKIT_KEY_GENERATION,
Comment thread
jvsena42 marked this conversation as resolved.
PAYKIT_RECOVERY_BACKUP,
PAYKIT_PENDING_BACKUP_RESTORE,
PAYKIT_PENDING_PAYMENT_PROOFS,
PAYKIT_ACCEPTED_PAYMENT_REQUESTS,
PAYKIT_PRESENTED_PAYMENT_REQUESTS,
PUBKY_SECRET_KEY,
SHARED_PUBKY_SOURCE,
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/to/bitkit/di/EnvModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import java.util.Locale
import javax.inject.Qualifier
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.time.TimeSource

@Module
@InstallIn(SingletonComponent::class)
Expand All @@ -25,6 +26,9 @@ object EnvModule {
@Provides
fun provideClock(): Clock = Clock.System

@Provides
fun provideTimeSource(): TimeSource = TimeSource.Monotonic

@Provides
@SubscriptionClock
fun provideSubscriptionClock(clock: Clock): Clock = SubscriptionClockOffset.subscriptionClock(clock)
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@ fun Throwable.isPaykitIdentityError(): Boolean =

fun Throwable.isPaykitRecoveryRequired(): Boolean =
generateSequence(this) { it.cause }.any { it is PaykitException.RecoveryRequired }

fun Throwable.isPaykitTemporarilyUnavailable(): Boolean =
generateSequence(this) { it.cause }.any {
it is PaykitException.ConcurrentUpdate || it is PaykitException.SharedStateBusy ||
it is PaykitException.Transport
}
Loading
Loading