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 app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ class PubkyRepo @Inject constructor(
initializationReady.await()
}

suspend fun republishIdentityIfNeeded(): Result<Unit> = withContext(ioDispatcher) {
runSuspendCatching { pubkyService.republishIdentityIfNeeded(publicKey.value) }
}

suspend fun initialize() = withContext(ioDispatcher) {
runSuspendCatching {
ensureServiceInitialized()
Expand Down
63 changes: 58 additions & 5 deletions app/src/main/java/to/bitkit/services/PaykitSdkService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,12 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.lightningdevkit.ldknode.Network
import to.bitkit.data.keychain.Keychain
import to.bitkit.env.Env
import to.bitkit.ext.fromHex
import to.bitkit.ext.nowMillis
import to.bitkit.ext.runSuspendCatching
import to.bitkit.ext.toHex
import to.bitkit.models.PubkyAuthRequestError
Expand All @@ -98,6 +100,8 @@ import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds

data class PaykitPreparedPrivateContactPayment(
val resolution: PaykitPrivateContactPaymentResolution,
Expand Down Expand Up @@ -163,6 +167,16 @@ class PaykitSdkService @Inject constructor(
private val sessionProvider = PaykitSdkSessionProvider(keychain)
private val paymentAdapter = PaykitSdkPaymentAdapter()
private val pubkyClientConfig by lazy { paykitPubkyClientConfig() }
private var bootstrapFactory = {
PubkySessionBootstrap.withPubkyClientConfig(
clientId = BitkitPaykitSdkConfig.clientId,
pubkyClient = pubkyClientConfig,
)
}
private val cachedBootstrap by lazy { bootstrapFactory() }
private val identityRepublishMutex = Mutex()
private var republishPublicKey: String? = null
private var nextIdentityRepublishAt = 0L
private val handleMutex = Mutex()
private val operationMutex = Mutex()
private val setupMutex = Mutex()
Expand All @@ -182,8 +196,14 @@ class PaykitSdkService @Inject constructor(
)
}

internal constructor(context: Context, keychain: Keychain, sdkFactory: () -> PaykitSdk) : this(context, keychain) {
internal constructor(
context: Context,
keychain: Keychain,
bootstrapFactory: (() -> PubkySessionBootstrap)? = null,
sdkFactory: () -> PaykitSdk,
) : this(context, keychain) {
this.sdkFactory = sdkFactory
if (bootstrapFactory != null) this.bootstrapFactory = bootstrapFactory
isSetup.complete(Unit)
}

Expand All @@ -198,6 +218,7 @@ class PaykitSdkService @Inject constructor(

try {
PaykitAndroid.initializeOrThrow(context)
republishIdentityIfNeeded()
operationMutex.withLock {
var handle = handle()
try {
Expand Down Expand Up @@ -230,6 +251,31 @@ class PaykitSdkService @Inject constructor(
}
}

suspend fun republishIdentityIfNeeded(publicKey: String? = null, now: Long = nowMillis()) {
if (!identityRepublishMutex.tryLock()) return
Comment thread
ben-kaufman marked this conversation as resolved.
try {
withTimeoutOrNull(IDENTITY_REPUBLISH_TIMEOUT) {
runSuspendCatching {
if (!isSetup.isCompleted) PaykitAndroid.initializeOrThrow(context)
val identity = (publicKey ?: sessionProvider.loadLocalSecretKey()?.let(::pubkyPublicKeyFromSecret))
?.let(PubkyPublicKeyFormat::normalized) ?: return@runSuspendCatching
if (identity == republishPublicKey && now < nextIdentityRepublishAt) return@runSuspendCatching

republishPublicKey = identity
nextIdentityRepublishAt = now + IDENTITY_REPUBLISH_RETRY_INTERVAL.inWholeMilliseconds
if (bootstrap().republishIdentity(identity)) {
nextIdentityRepublishAt = now + IDENTITY_REPUBLISH_INTERVAL.inWholeMilliseconds
Logger.debug("Republished Pubky identity", context = TAG)
} else {
Logger.debug("Found no Pubky identity record to republish", context = TAG)
}
}.onFailure { Logger.warn("Failed to republish Pubky identity", it, context = TAG) }
}
} finally {
identityRepublishMutex.unlock()
}
}

suspend fun currentPublicKey(): String? {
isSetup.await()
return operationMutex.withLock {
Expand Down Expand Up @@ -956,6 +1002,7 @@ class PaykitSdkService @Inject constructor(
val handle = handle()
handle.initialize()
publishReceiverMarkerIfLiveSessionAvailable(handle)
republishIdentityIfNeeded(publicKey = result.publicKey)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Recording a verified negative here, since this is the one line where android and iOS differ structurally and I want the reason written down before someone "aligns" the two.

This republish is the last statement of activateBootstrapResult, which runs inside activateRegisteredIdentity's try { } finally { if (!activated) clearRegisteredIdentityActivationLocked() } (:371). That rollback is NonCancellable and deletes PAYKIT_SESSION, PUBKY_SECRET_KEY and PAYKIT_SDK_STATE. Because runSuspendCatching deliberately rethrows CancellationException, a cancel escaping the republish would trip it — and on the signup path (PubkyRepo :1033 registerIdentity → :1038 approveRingAuth → :1041 activateRegisteredIdentity) that happens after the homeserver account and the Ring approval are already consumed, so a retry gets a 409 "User already exists".

Two things close it, and it's worth knowing both because they're independent:

  1. The timeout can't trigger it. withTimeoutOrNull catches its own TimeoutCancellationException by identity check inside republishIdentityIfNeeded (:257), so it never escapes to this line. Before c61ede68b the call here was unbounded; now the worst case is a 5s delay.
  2. Caller cancellation can't reach it either, at either head. PubkyRepo.approveSignupAuth goes through PubkyService.activateRegisteredIdentity, which is wrapped in ServiceQueue.CORE.background { } = withContext(scope.coroutineContext) where that context carries CORE's own SupervisorJob. withContext with a Job re-parents the block to it — the same mechanism as withContext(NonCancellable) — so cancelling viewModelScope leaves the caller suspended rather than cancelling the block. Nothing exposes a cancel API for that scope.

The conservative version, if you'd rather not lean on the ServiceQueue re-parenting: the window is bounded to 5s instead of unbounded.

The property to preserve: if this call ever moves out of ServiceQueue.CORE.background, or the timeout is removed, the rollback becomes reachable from a cancel again. Cancellation during :1003 handle.initialize() or :1004 publishReceiverMarkerIfLiveSessionAvailable is a pre-existing instance of the same shape on master, untouched by this PR.

For contrast, iOS is safe here for a completely different reason — its republishIdentityIfNeeded is async, not async throws, so it structurally cannot propagate into the equivalent catch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed against the full signup path. Activation stays inside ServiceQueue.CORE.background with its own job, and the republish timeout is handled by withTimeoutOrNull. UI cancellation therefore does not trigger identity rollback during this call. Keeping the existing behavior and cancellation handling, with no additional production change.

}

private suspend fun clearRegisteredIdentityActivationLocked() = withContext(NonCancellable) {
Expand Down Expand Up @@ -1007,10 +1054,7 @@ class PaykitSdkService @Inject constructor(
sdkFactory().also { sdk = it }
}

private fun bootstrap() = PubkySessionBootstrap.withPubkyClientConfig(
clientId = BitkitPaykitSdkConfig.clientId,
pubkyClient = pubkyClientConfig,
)
private fun bootstrap() = cachedBootstrap

private fun approvalBootstrap(authUrl: String, approvedClientId: String): PubkySessionBootstrap {
val requestClientId = parsePubkyAuthUrl(authUrl).clientId.orEmpty()
Expand Down Expand Up @@ -1039,6 +1083,15 @@ class PaykitSdkService @Inject constructor(
companion object {
private const val TAG = "PaykitSdkService"

/** Minimum delay between successful identity republications. */
private val IDENTITY_REPUBLISH_INTERVAL = 30.minutes

/** Minimum delay before retrying missing records or failed publication. */
private val IDENTITY_REPUBLISH_RETRY_INTERVAL = 1.minutes

/** Maximum time identity maintenance may delay its caller. */
private val IDENTITY_REPUBLISH_TIMEOUT = 5.seconds

fun localSecretKey(secretKeyHex: String): PubkyLocalSecretKey =
PubkyLocalSecretKey(secretKeyHex.fromHex())

Expand Down
6 changes: 6 additions & 0 deletions app/src/main/java/to/bitkit/services/PubkyService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class PubkyService @Inject constructor(
paykitSdkService.initialize()
}

suspend fun republishIdentityIfNeeded(publicKey: String? = null) =
paykitSdkService.republishIdentityIfNeeded(publicKey)

// region Session management

suspend fun importSession(secret: String): String = ServiceQueue.CORE.background {
Expand Down Expand Up @@ -144,6 +147,7 @@ class PubkyService @Inject constructor(
approvedClientId: String,
secretKeyHex: String,
) = ServiceQueue.CORE.background {
paykitSdkService.republishIdentityIfNeeded(publicKeyFromSecret(secretKeyHex))
paykitSdkService.approveAuth(authUrl, expectedCapabilities, approvedClientId, secretKeyHex)
}

Expand All @@ -152,6 +156,7 @@ class PubkyService @Inject constructor(
secretKeyHex: String,
timeout: Duration = AUTHORIZATION_TIMEOUT,
) = ServiceQueue.CORE.background {
paykitSdkService.republishIdentityIfNeeded(publicKeyFromSecret(secretKeyHex))
withTimeoutOrNull(timeout) {
approvePubkyAuth(authUrl, secretKeyHex)
} ?: throw PubkyRingAuthTimeoutError()
Expand All @@ -164,6 +169,7 @@ class PubkyService @Inject constructor(
secretKeyHex: String,
claim: PubkyAuthCompanionClaim,
) = ServiceQueue.CORE.background {
paykitSdkService.republishIdentityIfNeeded(publicKeyFromSecret(secretKeyHex))
paykitSdkService.approveAuthWithCompanionClaim(
authUrl = authUrl,
expectedCapabilities = expectedCapabilities,
Expand Down
7 changes: 6 additions & 1 deletion app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -745,7 +745,10 @@ class AppViewModel @Inject constructor(
isOnline
.drop(1)
.filter { it == ConnectivityState.CONNECTED }
.collect { refreshPrivatePaykitEndpointsIfEnabled("network restored") }
.collect {
if (paykitPaymentRequestPollingJob?.isActive == true) pubkyRepo.republishIdentityIfNeeded()
refreshPrivatePaykitEndpointsIfEnabled("network restored")
}
}
}

Expand Down Expand Up @@ -836,6 +839,7 @@ class AppViewModel @Inject constructor(
if (paykitPaymentRequestPollingJob?.isActive == true) return

paykitPaymentRequestPollingJob = viewModelScope.launch {
if (isOnline.value == ConnectivityState.CONNECTED) pubkyRepo.republishIdentityIfNeeded()
var refreshIntervalIndex = 0
var maintenanceIntervalIndex = 0
var maintenanceDelay = PAYKIT_MAINTENANCE_INTERVALS.first()
Expand All @@ -845,6 +849,7 @@ class AppViewModel @Inject constructor(
maintenanceDelay -= refreshInterval
val refreshMaintenance = maintenanceDelay <= Duration.ZERO
if (refreshMaintenance) {
if (isOnline.value == ConnectivityState.CONNECTED) pubkyRepo.republishIdentityIfNeeded()
privatePaykitRepo.refreshKnownSavedContactEndpoints("payment request polling")
maintenanceIntervalIndex =
(maintenanceIntervalIndex + 1).coerceAtMost(PAYKIT_MAINTENANCE_INTERVALS.lastIndex)
Expand Down
126 changes: 126 additions & 0 deletions app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package to.bitkit.services

import com.synonym.paykit.PaykitSdk
import com.synonym.paykit.PubkySessionBootstrap
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.test.currentTime
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.doSuspendableAnswer
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import kotlin.test.assertEquals
import kotlin.test.assertTrue

@OptIn(ExperimentalCoroutinesApi::class)
class PubkyIdentityRepublishTest {
private val publicKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"

@Test
fun `successful publication is throttled and reuses bootstrap`() = runTest {
val bootstrap = mock<PubkySessionBootstrap>()
whenever(bootstrap.republishIdentity(any())).thenReturn(true)
var factories = 0
val service = PaykitSdkService(
context = mock(),
keychain = mock(),
bootstrapFactory = {
factories++
bootstrap
},
sdkFactory = { mock() },
)

service.republishIdentityIfNeeded(publicKey, now = 0)
service.republishIdentityIfNeeded("pubky$publicKey", now = 1_799_000)
service.republishIdentityIfNeeded(publicKey, now = 1_800_000)

verify(bootstrap, times(2)).republishIdentity("pubky$publicKey")
assertEquals(1, factories)
}

@Test
fun `missing records and failures retry before success interval`() = runTest {
for (fails in listOf(false, true)) {
val bootstrap = mock<PubkySessionBootstrap>()
if (fails) {
whenever(bootstrap.republishIdentity(any())).thenThrow(IllegalStateException("offline"))
} else {
whenever(bootstrap.republishIdentity(any())).thenReturn(false)
}
val service = PaykitSdkService(mock(), mock(), { bootstrap }) { mock() }

service.republishIdentityIfNeeded(publicKey, now = 0)
service.republishIdentityIfNeeded(publicKey, now = 59_000)
service.republishIdentityIfNeeded(publicKey, now = 60_000)

verify(bootstrap, times(2)).republishIdentity("pubky$publicKey")
}
}

@Test
fun `new identity has separate throttle without restoring a session`() = runTest {
val bootstrap = mock<PubkySessionBootstrap>()
whenever(bootstrap.republishIdentity(any())).thenReturn(true)
val sdk = mock<PaykitSdk>()
val service = PaykitSdkService(mock(), mock(), { bootstrap }) { sdk }
val otherKey = publicKey.dropLast(1) + "y"

service.republishIdentityIfNeeded(publicKey, now = 0)
service.republishIdentityIfNeeded(otherKey, now = 0)

verify(bootstrap).republishIdentity("pubky$publicKey")
verify(bootstrap).republishIdentity("pubky$otherKey")
verify(sdk, never()).initialize()
verify(sdk, never()).identityStatus()
}

@Test
fun `concurrent triggers do not overlap publication`() = runTest {
val gate = CompletableDeferred<Boolean>()
val bootstrap = mock<PubkySessionBootstrap>()
whenever(bootstrap.republishIdentity(any())).doSuspendableAnswer { gate.await() }
val service = PaykitSdkService(mock(), mock(), { bootstrap }) { mock() }
val first = async { service.republishIdentityIfNeeded(publicKey, now = 0) }
runCurrent()

service.republishIdentityIfNeeded(publicKey, now = 3_600_000)
verify(bootstrap).republishIdentity("pubky$publicKey")

gate.complete(true)
first.await()
}

@Test
fun `timeout and cancellation release publication for retry`() = runTest {
for (cancel in listOf(false, true)) {
val bootstrap = mock<PubkySessionBootstrap>()
whenever(bootstrap.republishIdentity(any())).doSuspendableAnswer { awaitCancellation() }
val service = PaykitSdkService(mock(), mock(), { bootstrap }) { mock() }
val start = currentTime
val caller = async { service.republishIdentityIfNeeded(publicKey, now = 0) }

if (cancel) {
runCurrent()
caller.cancelAndJoin()
assertTrue(caller.isCancelled)
} else {
caller.await()
assertEquals(5_000L, currentTime - start)
}

whenever(bootstrap.republishIdentity(any())).thenReturn(true)
service.republishIdentityIfNeeded(publicKey, now = 60_000)
verify(bootstrap, times(2)).republishIdentity("pubky$publicKey")
}
}
}
10 changes: 8 additions & 2 deletions app/src/test/java/to/bitkit/services/PubkyServiceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import org.junit.Test
import org.mockito.Mockito.mockStatic
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.spy
import org.mockito.kotlin.whenever
import to.bitkit.async.ServiceQueue
import to.bitkit.ext.runSuspendCatching
import to.bitkit.test.BaseUnitTest
Expand All @@ -28,7 +31,8 @@ class PubkyServiceTest : BaseUnitTest() {
String::class.java,
Continuation::class.java,
)
val sut = PubkyService(mock())
val sut = spy(PubkyService(mock()))
doReturn("pubky-test").whenever(sut).publicKeyFromSecret("secret")
var cancelled = false
mockStatic(binding).use { native ->
native.`when`<Any?> { approve.invoke(null, "auth", "secret", null) }.thenAnswer {
Expand Down Expand Up @@ -63,13 +67,15 @@ class PubkyServiceTest : BaseUnitTest() {
Continuation::class.java,
)
val cancellation = CancellationException("cancelled")
val sut = spy(PubkyService(mock()))
doReturn("pubky-test").whenever(sut).publicKeyFromSecret("secret")
mockStatic(binding).use { native ->
native.`when`<Any?> { approve.invoke(null, "auth", "secret", null) }.thenThrow(cancellation)

assertEquals(
cancellation.javaClass,
assertFailsWith<CancellationException> {
PubkyService(mock()).approveRingAuth("auth", "secret", 50.milliseconds)
sut.approveRingAuth("auth", "secret", 50.milliseconds)
}.javaClass,
)
}
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1271.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improved Pubky identity discovery by periodically refreshing existing identity records while Bitkit is open.
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" }
barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" }
biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" }
bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.14" }
paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc54" }
paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc55" }
bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" }
camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" }
camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" }
Expand Down