Skip to content

Commit 0898b0d

Browse files
JOHNJOHN
authored andcommitted
chore: update Kotlin code
1 parent d53a267 commit 0898b0d

File tree

9 files changed

+523
-23
lines changed

9 files changed

+523
-23
lines changed

app/src/main/java/to/bitkit/paykit/services/DirectoryService.kt

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,10 @@ class DirectoryService @Inject constructor(
197197
val isConfigured: Boolean get() = authenticatedTransport != null
198198

199199
/**
200-
* Discover noise endpoint for a recipient
200+
* Discover noise endpoint for a recipient.
201+
*
202+
* First tries the FFI-based discovery, then falls back to direct HTTP fetch
203+
* (which works better in Android emulators that can't resolve pkarr DNS).
201204
*/
202205
suspend fun discoverNoiseEndpoint(recipientPubkey: String): NoiseEndpointInfo? {
203206
val transport = unauthenticatedTransport ?: run {
@@ -208,14 +211,68 @@ class DirectoryService @Inject constructor(
208211
}
209212
}
210213

211-
return try {
212-
discoverNoiseEndpoint(transport, recipientPubkey)
214+
// Try FFI-based discovery first
215+
val ffiResult = try {
216+
discoverNoiseEndpoint(transport, recipientPubkey)
213217
} catch (e: Exception) {
214-
Logger.error("Failed to discover Noise endpoint for $recipientPubkey", e, context = TAG)
218+
Logger.warn("FFI discover Noise endpoint failed for $recipientPubkey: ${e.message}, trying direct HTTP", context = TAG)
215219
null
216220
}
221+
222+
if (ffiResult != null) {
223+
return ffiResult
224+
}
225+
226+
// Fallback: fetch via direct HTTP using our Kotlin adapter
227+
return discoverNoiseEndpointViaHttp(recipientPubkey)
217228
}
218229

230+
/**
231+
* Fallback: Discover Noise endpoint via direct HTTP request.
232+
* This works in Android emulators where pkarr DNS resolution fails.
233+
* Uses withContext(Dispatchers.IO) to run the blocking HTTP call off the main thread.
234+
*/
235+
private suspend fun discoverNoiseEndpointViaHttp(recipientPubkey: String): NoiseEndpointInfo? =
236+
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
237+
// Ensure we use the default homeserver if not configured
238+
val effectiveHomeserverURL = homeserverURL ?: HomeserverDefaults.defaultHomeserverURL
239+
val adapter = pubkyStorage.createUnauthenticatedAdapter(effectiveHomeserverURL)
240+
val noisePath = PaykitV0Protocol.noiseEndpointPath()
241+
Logger.debug("Fetching Noise endpoint via HTTP: ${effectiveHomeserverURL.value}$noisePath for $recipientPubkey", context = TAG)
242+
243+
try {
244+
val result = adapter.get(recipientPubkey, noisePath)
245+
if (!result.success || result.content == null) {
246+
Logger.debug("No Noise endpoint found for $recipientPubkey via HTTP", context = TAG)
247+
return@withContext null
248+
}
249+
250+
// Parse the JSON response
251+
val json = org.json.JSONObject(result.content!!)
252+
val host = json.optString("host") ?: ""
253+
val port = json.optInt("port", 0)
254+
val pubkey = json.optString("pubkey") ?: ""
255+
val metadata: String? = if (json.has("metadata")) json.optString("metadata") else null
256+
257+
if (pubkey.isEmpty()) {
258+
Logger.warn("Noise endpoint for $recipientPubkey has no pubkey", context = TAG)
259+
return@withContext null
260+
}
261+
262+
Logger.debug("Discovered Noise endpoint for $recipientPubkey via HTTP: $host:$port", context = TAG)
263+
NoiseEndpointInfo(
264+
recipientPubkey = recipientPubkey,
265+
host = host,
266+
port = port.toUShort(),
267+
serverNoisePubkey = pubkey,
268+
metadata = metadata,
269+
)
270+
} catch (e: Exception) {
271+
Logger.error("Failed to discover Noise endpoint via HTTP for $recipientPubkey", e, context = TAG)
272+
null
273+
}
274+
}
275+
219276
/**
220277
* Publish our noise endpoint to the directory
221278
*/
@@ -551,7 +608,7 @@ removeNoiseEndpoint(transport)
551608
suspend fun publishSubscriptionProposal(
552609
proposal: to.bitkit.paykit.models.SubscriptionProposal,
553610
subscriberPubkey: String,
554-
) {
611+
) = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
555612
// Auto-restore from keychain if not configured
556613
if (!isConfigured) tryRestoreFromKeychain()
557614
val adapter = authenticatedAdapter ?: throw DirectoryError.NotConfigured
@@ -855,9 +912,10 @@ removeNoiseEndpoint(transport)
855912
}
856913

857914
/**
858-
* Publish profile to Pubky directory
915+
* Publish profile to Pubky directory.
916+
* Uses withContext(Dispatchers.IO) to run the blocking HTTP call off the main thread.
859917
*/
860-
suspend fun publishProfile(profile: PubkyProfile) {
918+
suspend fun publishProfile(profile: PubkyProfile) = withContext(Dispatchers.IO) {
861919
// Auto-restore from keychain if not configured
862920
if (!isConfigured) tryRestoreFromKeychain()
863921
val adapter = authenticatedAdapter ?: throw DirectoryError.NotConfigured

app/src/main/java/to/bitkit/paykit/services/PubkyRingBridge.kt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ class PubkyRingBridge @Inject constructor(
6262
private val pubkyStorageAdapter: PubkyStorageAdapter,
6363
private val callbackParser: PubkyRingCallbackParser,
6464
private val secureHandoffHandler: SecureHandoffHandler,
65+
private val keyManager: to.bitkit.paykit.KeyManager,
6566
) {
6667

6768
companion object {
@@ -648,6 +649,7 @@ class PubkyRingBridge @Inject constructor(
648649
val session = pollRelayForSession(requestId)
649650
if (session != null) {
650651
sessionCache[session.pubkey] = session
652+
persistSession(session)
651653
pendingCrossDeviceRequestId = null
652654
pendingCrossDeviceEphemeralSk = null
653655
return@withContext session
@@ -1032,6 +1034,11 @@ class PubkyRingBridge @Inject constructor(
10321034
try {
10331035
val json = kotlinx.serialization.json.Json.encodeToString(PubkySession.serializer(), session)
10341036
keychainStorage.setString("pubky.session.${session.pubkey}", json)
1037+
// Also store in DirectoryService format so tryRestoreFromKeychain works
1038+
keychainStorage.setString("pubky.identity.public", session.pubkey)
1039+
keychainStorage.setString("pubky.session.secret", session.sessionSecret)
1040+
// Also update KeyManager with the identity's public key for subscription operations
1041+
keyManager.storePublicKey(session.pubkey)
10351042
Logger.debug("Persisted session for ${session.pubkey.take(12)}...", context = TAG)
10361043
} catch (e: Exception) {
10371044
Logger.error("Failed to persist session", e, context = TAG)
@@ -1049,7 +1056,15 @@ class PubkyRingBridge @Inject constructor(
10491056
val json = keychainStorage.getString(key) ?: continue
10501057
val session = kotlinx.serialization.json.Json.decodeFromString<PubkySession>(json)
10511058
sessionCache[session.pubkey] = session
1059+
// Update KeyManager with identity pubkey (ensures subscriptions work after app restart)
1060+
keyManager.storePublicKey(session.pubkey)
1061+
// Also store in DirectoryService format so tryRestoreFromKeychain works
1062+
keychainStorage.setString("pubky.identity.public", session.pubkey)
1063+
keychainStorage.setString("pubky.session.secret", session.sessionSecret)
10521064
Logger.info("Restored session for ${session.pubkey.take(12)}...", context = TAG)
1065+
1066+
// Verify Noise endpoint is published (fallback publish if Ring didn't)
1067+
verifyOrPublishNoiseEndpoint(session.pubkey)
10531068
} catch (e: Exception) {
10541069
Logger.error("Failed to restore session from $key", e, context = TAG)
10551070
}
@@ -1058,6 +1073,48 @@ class PubkyRingBridge @Inject constructor(
10581073
Logger.info("Restored ${sessionCache.size} sessions from keychain", context = TAG)
10591074
}
10601075

1076+
/**
1077+
* Verify that Noise endpoint is published for a pubkey, publishing it if not.
1078+
* This is needed when Ring fails to publish during handoff.
1079+
*/
1080+
private suspend fun verifyOrPublishNoiseEndpoint(pubkey: String) {
1081+
try {
1082+
// Get cached keypair from KeyManager (uses epoch 0 by default)
1083+
val keypair = keyManager.getCachedNoiseKeypair()
1084+
if (keypair != null) {
1085+
secureHandoffHandler.ensureNoiseEndpointPublished(
1086+
pubkey = pubkey,
1087+
noisePubkeyHex = keypair.publicKeyHex,
1088+
deviceId = keypair.deviceId,
1089+
)
1090+
} else {
1091+
// Just verify - can't publish without keypair
1092+
val isPublished = secureHandoffHandler.verifyNoiseEndpointPublished(pubkey)
1093+
if (!isPublished) {
1094+
Logger.warn("Cannot publish Noise endpoint: no keypair cached for ${pubkey.take(12)}...", context = TAG)
1095+
}
1096+
}
1097+
} catch (e: Exception) {
1098+
Logger.warn("Error checking Noise endpoint for ${pubkey.take(12)}...: ${e.message}", context = TAG)
1099+
}
1100+
}
1101+
1102+
/**
1103+
* Ensure any cached session's pubkey is stored in KeyManager.
1104+
* Call this if sessions exist in memory but weren't properly persisted.
1105+
*/
1106+
suspend fun ensureIdentitySynced() {
1107+
for (session in sessionCache.values) {
1108+
keyManager.storePublicKey(session.pubkey)
1109+
// Also persist the session if not already persisted
1110+
val existingJson = keychainStorage.getString("pubky.session.${session.pubkey}")
1111+
if (existingJson == null) {
1112+
persistSession(session)
1113+
Logger.info("Synced session for ${session.pubkey.take(12)}...", context = TAG)
1114+
}
1115+
}
1116+
}
1117+
10611118
/**
10621119
* Get all cached sessions
10631120
*/

app/src/main/java/to/bitkit/paykit/services/SecureHandoffHandler.kt

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,9 @@ class SecureHandoffHandler @Inject constructor(
7373
cacheAndPersistResult(result, payload.deviceId, scope, onSessionPersisted)
7474
schedulePayloadDeletion(result.session, requestId, scope)
7575

76-
// Verify Ring published the Noise endpoint (non-blocking diagnostic)
76+
// Verify Ring published the Noise endpoint, or publish it ourselves as fallback
7777
scope.launch {
78-
val noisePublished = verifyNoiseEndpointPublished(pubkey)
79-
if (!noisePublished) {
80-
Logger.warn(
81-
"Noise endpoint was not published by Ring - encrypted payment channels may not work",
82-
context = TAG,
83-
)
84-
}
78+
ensureNoiseEndpointPublished(pubkey, result.noiseKeypair0?.publicKey, payload.deviceId)
8579
}
8680

8781
result
@@ -275,6 +269,65 @@ class SecureHandoffHandler @Inject constructor(
275269
}
276270
}
277271

272+
/**
273+
* Ensure the Noise endpoint is published for discoverability.
274+
*
275+
* First verifies if Ring already published it. If not, publishes it ourselves
276+
* using the keypair and session we received during handoff.
277+
*
278+
* @param pubkey The user's pubkey in z32 format
279+
* @param noisePubkeyHex The X25519 Noise public key (hex encoded) from epoch 0
280+
* @param deviceId The device ID used for this connection
281+
*/
282+
suspend fun ensureNoiseEndpointPublished(
283+
pubkey: String,
284+
noisePubkeyHex: String?,
285+
deviceId: String,
286+
) = withContext(Dispatchers.IO) {
287+
try {
288+
Logger.debug("Verifying Noise endpoint for ${pubkey.take(12)}...", context = TAG)
289+
290+
// Check if endpoint already exists and is valid
291+
val endpoint = directoryServiceProvider.get().discoverNoiseEndpoint(pubkey)
292+
if (endpoint != null && endpoint.host != "pending") {
293+
Logger.info(
294+
"Noise endpoint already published for ${pubkey.take(12)}...: host=${endpoint.host}, port=${endpoint.port}",
295+
context = TAG,
296+
)
297+
return@withContext
298+
}
299+
300+
// Endpoint missing or has placeholder values - publish it ourselves
301+
if (noisePubkeyHex == null) {
302+
Logger.warn("Cannot publish Noise endpoint: no keypair available", context = TAG)
303+
return@withContext
304+
}
305+
306+
Logger.info("Ring did not publish Noise endpoint - publishing as fallback", context = TAG)
307+
308+
// Publish with "pending" host/port - will be updated when Noise server starts
309+
val directoryService = directoryServiceProvider.get()
310+
if (!directoryService.isConfigured) {
311+
if (!directoryService.tryRestoreFromKeychain()) {
312+
Logger.warn("Cannot publish Noise endpoint: DirectoryService not configured", context = TAG)
313+
return@withContext
314+
}
315+
}
316+
317+
directoryService.publishNoiseEndpoint(
318+
host = "pending",
319+
port = 0,
320+
noisePubkey = noisePubkeyHex,
321+
metadata = """{"provisioned_by":"bitkit-fallback","device_id":"$deviceId"}""",
322+
)
323+
Logger.info("Published Noise endpoint for ${pubkey.take(12)}... as fallback", context = TAG)
324+
} catch (e: CancellationException) {
325+
throw e
326+
} catch (e: Exception) {
327+
Logger.warn("Error ensuring Noise endpoint: ${e.message}", e, context = TAG)
328+
}
329+
}
330+
278331
/**
279332
* Verify that Ring published the Noise endpoint during handoff.
280333
*

app/src/main/java/to/bitkit/paykit/viewmodels/SubscriptionsViewModel.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.asStateFlow
88
import kotlinx.coroutines.flow.update
99
import kotlinx.coroutines.launch
1010
import to.bitkit.paykit.KeyManager
11+
import to.bitkit.paykit.services.PubkyRingBridge
1112
import to.bitkit.paykit.models.AutoPayRule
1213
import to.bitkit.paykit.models.PeerSpendingLimit
1314
import to.bitkit.paykit.models.Subscription
@@ -31,6 +32,7 @@ class SubscriptionsViewModel @Inject constructor(
3132
private val directoryService: DirectoryService,
3233
private val autoPayStorage: AutoPayStorage,
3334
private val keyManager: KeyManager,
35+
private val pubkyRingBridge: PubkyRingBridge,
3436
) : ViewModel() {
3537
companion object {
3638
private const val TAG = "SubscriptionsViewModel"
@@ -49,6 +51,10 @@ class SubscriptionsViewModel @Inject constructor(
4951
val showingAddSubscription: StateFlow<Boolean> = _showingAddSubscription.asStateFlow()
5052

5153
init {
54+
// Ensure any cached session's pubkey is synced to KeyManager
55+
viewModelScope.launch {
56+
pubkyRingBridge.ensureIdentitySynced()
57+
}
5258
loadSubscriptions()
5359
loadIncomingProposals()
5460
}

0 commit comments

Comments
 (0)