diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c20fd33b3b..518327d9ae 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -163,7 +163,7 @@
android:resource="@xml/shortcuts" />
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt
index 8c8d1e1046..b7f89682da 100644
--- a/app/src/main/java/to/bitkit/data/SettingsStore.kt
+++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt
@@ -40,6 +40,9 @@ class SettingsStore @Inject constructor(
val data: Flow = store.data
val isPaykitEnabled: Flow = localStore.data.map { it[PAYKIT_ENABLED_KEY] ?: false }
+ val isPubkyProfileSetupPending: Flow = localStore.data.map {
+ it[PUBKY_PROFILE_SETUP_PENDING_KEY] ?: false
+ }
@Volatile
var restoredMonitoredTypesFromBackup: Boolean = false
@@ -68,6 +71,10 @@ class SettingsStore @Inject constructor(
localStore.edit { it[PAYKIT_ENABLED_KEY] = value }
}
+ suspend fun setPubkyProfileSetupPending(value: Boolean) {
+ localStore.edit { it[PUBKY_PROFILE_SETUP_PENDING_KEY] = value }
+ }
+
suspend fun addLastUsedTag(newTag: String) {
store.updateData { currentSettings ->
val combinedTags = (listOf(newTag) + currentSettings.lastUsedTags).distinct()
@@ -100,6 +107,7 @@ class SettingsStore @Inject constructor(
private const val TAG = "SettingsStore"
private const val MAX_LAST_USED_TAGS = 10
private val PAYKIT_ENABLED_KEY = booleanPreferencesKey("paykit_enabled")
+ private val PUBKY_PROFILE_SETUP_PENDING_KEY = booleanPreferencesKey("pubky_profile_setup_pending")
}
}
diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt
index 86ce14a809..14e1f70d2c 100644
--- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt
+++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import to.bitkit.utils.AppError
import java.net.URI
import java.net.URLDecoder
+import java.net.URLEncoder
import java.nio.charset.StandardCharsets
enum class PubkyAuthClaim(val wireValue: String) {
@@ -71,13 +72,23 @@ data class PubkyAuthRequest(
val permissions: List,
val serviceNames: List,
val bitkitClaim: PubkyAuthClaim?,
+ val homeserverPublicKey: String? = null,
+ val signupToken: String? = null,
+ val authorizationUrl: String? = rawUrl,
) {
+ val isSignup: Boolean
+ get() = isSignupUrl(rawUrl)
+
companion object {
+ @Suppress("LongParameterList")
fun parse(
rawUrl: String,
clientId: String,
relay: String,
capabilities: String,
+ homeserverPublicKey: String? = null,
+ signupToken: String? = null,
+ authorizationUrl: String? = rawUrl,
): Result = parseBitkitClaim(rawUrl, capabilities).map { bitkitClaim ->
val permissions = parseCapabilities(capabilities)
PubkyAuthRequest(
@@ -88,9 +99,73 @@ data class PubkyAuthRequest(
permissions = permissions,
serviceNames = permissions.mapNotNull { extractServiceName(it.path) }.distinct(),
bitkitClaim = bitkitClaim,
+ homeserverPublicKey = homeserverPublicKey,
+ signupToken = signupToken,
+ authorizationUrl = authorizationUrl,
)
}
+ fun isProtocolUrl(rawUrl: String): Boolean = runCatching {
+ val uri = URI(rawUrl)
+ when (uri.scheme?.lowercase()) {
+ "pubkyauth" -> true
+ "pubkyring" -> uri.host.equals("signup", ignoreCase = true)
+ else -> false
+ }
+ }.getOrDefault(false)
+
+ fun isSignupUrl(rawUrl: String): Boolean = runCatching { URI(rawUrl).isSignupRequest() }.getOrDefault(false)
+
+ fun parseSignup(rawUrl: String): Result = runCatching {
+ val uri = URI(rawUrl)
+ require(uri.isSignupRequest()) { "Unsupported Pubky signup URL" }
+ val query = parseQuery(uri)
+ val homeserver = query.requiredSingle("hs")
+ val authorizesApp = uri.authorizesApp(query)
+ val relay = if (authorizesApp) query.requiredSingle("relay") else ""
+ val secret = if (authorizesApp) query.requiredSingle("secret") else ""
+ val capabilities = if (authorizesApp) query.requiredSingle("caps") else ""
+ val authorizationUrl = if (authorizesApp) {
+ ringAuthorizationUrl(relay, secret, capabilities)
+ } else {
+ null
+ }
+
+ parse(
+ rawUrl = rawUrl,
+ clientId = "",
+ relay = relay,
+ capabilities = capabilities,
+ homeserverPublicKey = homeserver,
+ signupToken = query.optionalSingle("st"),
+ authorizationUrl = authorizationUrl,
+ ).getOrThrow().also {
+ require(it.bitkitClaim == null) { "Pubky signup does not support Bitkit companion claims" }
+ }
+ }.fold(
+ onSuccess = { Result.success(it) },
+ onFailure = { Result.failure(PubkyAuthRequestError.InvalidUrl(it)) },
+ )
+
+ private fun URI.isSignupRequest(): Boolean = when (scheme?.lowercase()) {
+ "pubkyring" -> host.equals("signup", ignoreCase = true)
+ "pubkyauth" -> isDirectSignupRequest()
+ else -> false
+ }
+
+ private fun URI.isDirectSignupRequest(): Boolean =
+ scheme.equals("pubkyauth", ignoreCase = true) && (host ?: rawAuthority).let {
+ it.equals("direct_signup", ignoreCase = true) || it.equals("signup", ignoreCase = true)
+ }
+
+ private fun URI.authorizesApp(query: Map>): Boolean =
+ scheme.equals("pubkyring", ignoreCase = true) ||
+ (
+ scheme.equals("pubkyauth", ignoreCase = true) &&
+ (host ?: rawAuthority).equals("signup", ignoreCase = true) &&
+ listOf("relay", "secret", "caps").any(query::containsKey)
+ )
+
fun parseBitkitClaim(rawUrl: String, capabilities: String): Result =
parseBitkitClaimValues(rawUrl).fold(
onSuccess = { claimValues -> validateBitkitClaim(claimValues, capabilities) },
@@ -152,5 +227,31 @@ data class PubkyAuthRequest(
}
private fun decodeQueryComponent(value: String) = URLDecoder.decode(value, StandardCharsets.UTF_8.name())
+
+ private fun ringAuthorizationUrl(relay: String, secret: String, capabilities: String): String =
+ "pubkyauth:///?relay=${encodeQueryComponent(relay)}" +
+ "&secret=${encodeQueryComponent(secret)}&caps=${encodeQueryComponent(capabilities)}"
+
+ private fun encodeQueryComponent(value: String) =
+ URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20")
+
+ private fun parseQuery(uri: URI): Map> = uri.rawQuery.orEmpty()
+ .split("&")
+ .filter { it.isNotEmpty() }
+ .map { it.split("=", limit = 2) }
+ .groupBy(
+ keySelector = { decodeQueryComponent(it.first()) },
+ valueTransform = { decodeQueryComponent(it.getOrElse(1) { "" }) },
+ )
+
+ private fun Map>.requiredSingle(name: String): String =
+ optionalSingle(name)?.takeIf { it.isNotBlank() }
+ ?: throw IllegalArgumentException("Missing Pubky signup parameter: $name")
+
+ private fun Map>.optionalSingle(name: String): String? {
+ val values = this[name].orEmpty()
+ require(values.size <= 1) { "Duplicate Pubky signup parameter: $name" }
+ return values.singleOrNull()?.takeIf { it.isNotBlank() }
+ }
}
}
diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
index 0de1109d58..c0947cfa0c 100644
--- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -75,6 +75,7 @@ sealed class PubkyContactError(message: String) : AppError(message) {
}
private class PubkyAuthAttemptInactive : AppError("Auth attempt is no longer active")
+data object PubkyAlreadySignedInError : AppError("Already signed in")
private enum class AuthAttemptWaitResult { Approved, Inactive }
@@ -157,12 +158,14 @@ class PubkyRepo @Inject constructor(
data object RestorationFailed : InitResult
}
- init {
- scope.launch { initialize() }
- }
+ private val initializationJob = scope.launch { initialize() }
// region Initialization
+ suspend fun awaitInitialization() = withContext(ioDispatcher) {
+ initializationJob.join()
+ }
+
suspend fun initialize() = withContext(ioDispatcher) {
runSuspendCatching {
ensureServiceInitialized()
@@ -553,25 +556,41 @@ class PubkyRepo @Inject constructor(
tags: List,
avatarBytes: ByteArray?,
): Result {
+ if (settingsStore.isPubkyProfileSetupPending.first() && _publicKey.value != null) {
+ return runSuspendCatching {
+ withContext(ioDispatcher) {
+ val publicKey = requireNotNull(_publicKey.value) { "No active Pubky session" }
+ val imageUrl = publishIdentityProfile(name, bio, links, tags, avatarBytes)
+ finishIdentityCreation(publicKey, name, bio, links, tags, imageUrl)
+ }
+ }
+ }
+
var shouldRevokeSessionOnFailure = false
return try {
val result = runSuspendCatching {
withContext(ioDispatcher) {
- val (publicKeyZ32, secretKeyHex) = deriveKeys().getOrThrow()
-
- val signupDetails: Pair = Env.e2eHomeserverPubky?.let { it to null }
- ?: fetchHomegateSignupCode().let { it.homeserverPubky to it.signupCode }
+ settingsStore.setPubkyProfileSetupPending(false)
+ val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
+ val publicKeyZ32 = if (!storedSecretKeyHex.isNullOrEmpty()) {
+ pubkyService.signIn(storedSecretKeyHex)
+ pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix()
+ } else {
+ val (publicKey, secretKeyHex) = deriveKeys().getOrThrow()
+ val signupDetails: Pair = Env.e2eHomeserverPubky?.let { it to null }
+ ?: fetchHomegateSignupCode().let { it.homeserverPubky to it.signupCode }
- shouldRevokeSessionOnFailure = true
- runSuspendCatching {
- pubkyService.signUp(secretKeyHex, signupDetails.first, signupDetails.second)
- }.getOrElse {
- Logger.warn("Retrying sign in after sign up failed", it, context = TAG)
- pubkyService.signIn(secretKeyHex)
+ shouldRevokeSessionOnFailure = true
+ runSuspendCatching {
+ pubkyService.signUp(secretKeyHex, signupDetails.first, signupDetails.second)
+ }.getOrElse {
+ Logger.warn("Retrying sign in after sign up failed", it, context = TAG)
+ pubkyService.signIn(secretKeyHex)
+ }
+ publicKey
}
- val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() }
- writeProfile(name, bio, links, tags, imageUrl)
+ val imageUrl = publishIdentityProfile(name, bio, links, tags, avatarBytes)
shouldRevokeSessionOnFailure = false
finishIdentityCreation(publicKeyZ32, name, bio, links, tags, imageUrl)
}
@@ -584,6 +603,18 @@ class PubkyRepo @Inject constructor(
}
}
+ private suspend fun publishIdentityProfile(
+ name: String,
+ bio: String,
+ links: List,
+ tags: List,
+ avatarBytes: ByteArray?,
+ ): String? {
+ val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() }
+ writeProfile(name, bio, links, tags, imageUrl)
+ return imageUrl
+ }
+
private suspend fun finishIdentityCreation(
publicKey: String,
name: String,
@@ -605,6 +636,7 @@ class PubkyRepo @Inject constructor(
_authState.update { PubkyAuthState.Authenticated }
_profile.update { createdProfile }
cacheMetadata(createdProfile)
+ settingsStore.setPubkyProfileSetupPending(false)
notifyBackupStateChanged()
Logger.info("Created identity for '${redacted(publicKey)}'", context = TAG)
loadProfile()
@@ -946,8 +978,23 @@ class PubkyRepo @Inject constructor(
managedSecretKeyFor(publicKey) != null
}.getOrDefault(false)
+ suspend fun hasIdentity(): Boolean = withContext(ioDispatcher) {
+ _publicKey.value != null ||
+ !keychain.loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrEmpty() ||
+ !keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrEmpty()
+ }
+
suspend fun parseAuthUrl(authUrl: String): Result = runSuspendCatching {
withContext(ioDispatcher) {
+ if (PubkyAuthRequest.isSignupUrl(authUrl)) {
+ val request = PubkyAuthRequest.parseSignup(authUrl).getOrThrow()
+ pubkyService.validateSignupRequest(
+ authorizationUrl = request.authorizationUrl,
+ homeserverPublicKey = requireNotNull(request.homeserverPublicKey),
+ )
+ return@withContext request
+ }
+
val details = pubkyService.parseAuthUrl(authUrl)
PubkyAuthRequest.parse(
rawUrl = authUrl,
@@ -958,6 +1005,56 @@ class PubkyRepo @Inject constructor(
}
}
+ suspend fun approveSignupAuth(request: PubkyAuthRequest): Result = initializeMutex.withLock {
+ runSuspendCatching {
+ withContext(ioDispatcher) {
+ require(request.isSignup) { "Not a Pubky signup request" }
+ if (hasIdentity()) throw PubkyAlreadySignedInError
+
+ val (publicKey, secretKeyHex) = deriveKeys().getOrThrow()
+ if (hasIdentity()) throw PubkyAlreadySignedInError
+
+ settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) }
+ val registeredSession = pubkyService.registerIdentity(
+ secretKeyHex = secretKeyHex,
+ homeserverZ32 = requireNotNull(request.homeserverPublicKey),
+ signupCode = request.signupToken,
+ )
+ request.authorizationUrl?.let { pubkyService.approveRingAuth(it, secretKeyHex) }
+ var activated = false
+ try {
+ pubkyService.activateRegisteredIdentity(registeredSession)
+ activated = true
+ } finally {
+ if (!activated) {
+ withContext(NonCancellable) {
+ settingsStore.setPubkyProfileSetupPending(false)
+ }
+ }
+ }
+
+ _publicKey.update { publicKey }
+ _authState.update { PubkyAuthState.Authenticated }
+ var pendingSaved = false
+ try {
+ settingsStore.setPubkyProfileSetupPending(true)
+ pendingSaved = true
+ } finally {
+ if (!pendingSaved) {
+ withContext(NonCancellable) {
+ runSuspendCatching { pubkyService.forgetSessionAccess() }
+ .onFailure {
+ Logger.warn("Failed to roll back Pubky signup session", it, context = TAG)
+ }
+ clearLocalState()
+ }
+ }
+ }
+ notifyBackupStateChanged()
+ }
+ }
+ }
+
suspend fun approveAuth(
authUrl: String,
expectedCapabilities: String,
@@ -1320,6 +1417,7 @@ class PubkyRepo @Inject constructor(
publicPaykitCleanupPending = publicPaykitCleanupPending,
)
}
+ settingsStore.setPubkyProfileSetupPending(false)
}
private fun requireAddableContactPublicKey(publicKey: String, allowExisting: Boolean = false): String {
diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
index 141fbd569b..16421d0b31 100644
--- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
+++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
@@ -69,12 +69,14 @@ import com.synonym.paykit.pubkySecretKeyFromBip39Mnemonic
import com.synonym.paykit.requiredSessionCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
import org.lightningdevkit.ldknode.Network
import to.bitkit.data.keychain.Keychain
import to.bitkit.env.Env
@@ -160,6 +162,20 @@ class PaykitSdkService @Inject constructor(
private var activeAuthRequest: PubkyAuthRequest? = null
private val _backupStateVersion = MutableStateFlow(0L)
val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow()
+ private var sdkFactory: () -> PaykitSdk = {
+ PaykitSdk.withPaymentAdapterAndPubkyClientConfig(
+ stateStore = stateStore,
+ sessionProvider = sessionProvider,
+ paymentAdapter = paymentAdapter,
+ config = paykitSdkConfig(),
+ pubkyClient = pubkyClientConfig,
+ )
+ }
+
+ internal constructor(context: Context, keychain: Keychain, sdkFactory: () -> PaykitSdk) : this(context, keychain) {
+ this.sdkFactory = sdkFactory
+ isSetup.complete(Unit)
+ }
@Suppress("TooGenericExceptionCaught")
suspend fun initialize() {
@@ -268,6 +284,40 @@ class PaykitSdkService @Inject constructor(
return result
}
+ suspend fun registerIdentity(
+ secretKeyHex: String,
+ homeserverPublicKey: String,
+ signupCode: String?,
+ ): PubkySessionBootstrapResult {
+ isSetup.await()
+ return bootstrap().signUp(
+ localSecretKey = localSecretKey(secretKeyHex),
+ receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(),
+ homeserverPublicKey = homeserverPublicKey,
+ signupCode = signupCode,
+ requiredCapabilities = requiredCapabilities(),
+ )
+ }
+
+ suspend fun activateRegisteredIdentity(result: PubkySessionBootstrapResult) {
+ isSetup.await()
+ val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() }
+ operationMutex.withLock {
+ var activated = false
+ try {
+ activateBootstrapResult(
+ result = result,
+ previousPublicKey = previousPublicKey,
+ shouldStoreLocalSecret = true,
+ )
+ activated = true
+ } finally {
+ if (!activated) clearRegisteredIdentityActivationLocked()
+ }
+ }
+ notifyBackupStateChanged()
+ }
+
suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult {
isSetup.await()
val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() }
@@ -883,6 +933,15 @@ class PaykitSdkService @Inject constructor(
publishReceiverMarkerIfLiveSessionAvailable(handle)
}
+ private suspend fun clearRegisteredIdentityActivationLocked() = withContext(NonCancellable) {
+ runSuspendCatching { sessionProvider.clearSessionAccess() }
+ .onFailure { Logger.warn("Failed to clear incomplete Pubky signup session", it, context = TAG) }
+ runSuspendCatching { keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) }
+ .onFailure { Logger.warn("Failed to clear incomplete Pubky signup state", it, context = TAG) }
+ resetRuntime()
+ notifyBackupStateChanged()
+ }
+
private suspend fun publishReceiverMarkerIfLiveSessionAvailable(handle: PaykitSdk) {
runSuspendCatching {
val capabilities = receiverCapabilities(handle)
@@ -931,13 +990,7 @@ class PaykitSdkService @Inject constructor(
private suspend fun handle(): PaykitSdk = handleMutex.withLock {
sdk?.let { return@withLock it }
- PaykitSdk.withPaymentAdapterAndPubkyClientConfig(
- stateStore = stateStore,
- sessionProvider = sessionProvider,
- paymentAdapter = paymentAdapter,
- config = paykitSdkConfig(),
- pubkyClient = pubkyClientConfig,
- ).also { sdk = it }
+ sdkFactory().also { sdk = it }
}
private fun bootstrap() = PubkySessionBootstrap.withPubkyClientConfig(
diff --git a/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt b/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt
index d0a4df8d1c..cd7a13870d 100644
--- a/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt
+++ b/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt
@@ -20,7 +20,7 @@ import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Singleton
-/** Advertises Bitkit as a `pubkyauth` handler only while it can authorize requests locally. */
+/** Advertises Pubky signup and authorization handlers when their required identity state is available. */
@Singleton
internal class PubkyAuthHandlerRegistrar @Inject constructor(
@ApplicationContext private val context: Context,
@@ -28,8 +28,17 @@ internal class PubkyAuthHandlerRegistrar @Inject constructor(
private val settingsStore: SettingsStore,
@IoDispatcher ioDispatcher: CoroutineDispatcher,
) {
+ companion object {
+ private const val TAG = "PubkyAuthHandlerRegistrar"
+ private const val PUBKY_AUTH_ALIAS_CLASS = "to.bitkit.ui.MainActivityPubkyAuth"
+
+ /** Handles signup links before a Pubky identity is available. */
+ private const val PUBKY_SIGNUP_ALIAS_CLASS = "to.bitkit.ui.MainActivityPubkySignup"
+ }
+
private val scope: CoroutineScope = appScope(ioDispatcher, TAG)
private val aliasComponent = ComponentName(context.packageName, PUBKY_AUTH_ALIAS_CLASS)
+ private val signupAliasComponent = ComponentName(context.packageName, PUBKY_SIGNUP_ALIAS_CLASS)
private val started = AtomicBoolean()
fun start() = start(scope)
@@ -38,6 +47,7 @@ internal class PubkyAuthHandlerRegistrar @Inject constructor(
if (!started.compareAndSet(false, true)) return
collectionScope.launch {
+ pubkyRepo.awaitInitialization()
combine(settingsStore.isPaykitEnabled, pubkyRepo.publicKey) { localFlagEnabled, publicKey ->
localFlagEnabled to publicKey
}
@@ -48,17 +58,19 @@ internal class PubkyAuthHandlerRegistrar @Inject constructor(
val hasSecretKey = isPaykitUiEnabled && hasIdentity && pubkyRepo.hasSecretKey()
setAliasEnabled(
+ aliasComponent,
canHandlePubkyAuth(
isPaykitUiEnabled = isPaykitUiEnabled,
hasIdentity = hasIdentity,
hasSecretKey = hasSecretKey,
),
)
+ setAliasEnabled(signupAliasComponent, isPaykitUiEnabled && !hasIdentity)
}
}
}
- private fun setAliasEnabled(enabled: Boolean) {
+ private fun setAliasEnabled(component: ComponentName, enabled: Boolean) {
val state =
if (enabled) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
@@ -68,24 +80,19 @@ internal class PubkyAuthHandlerRegistrar @Inject constructor(
runCatching {
context.packageManager.setComponentEnabledSetting(
- aliasComponent,
+ component,
state,
PackageManager.DONT_KILL_APP,
)
}.onSuccess {
Logger.info(
- "Updated pubkyauth handler to '${if (enabled) "enabled" else "disabled"}'",
+ "Updated Pubky handler '${component.className}' to '${if (enabled) "enabled" else "disabled"}'",
context = TAG,
)
}.onFailure {
- Logger.error("Failed to update pubkyauth handler", it, context = TAG)
+ Logger.error("Failed to update Pubky handler '${component.className}'", it, context = TAG)
}
}
-
- companion object {
- private const val TAG = "PubkyAuthHandlerRegistrar"
- private const val PUBKY_AUTH_ALIAS_CLASS = "to.bitkit.ui.MainActivityPubkyAuth"
- }
}
internal fun canHandlePubkyAuth(
diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt
index 79bebe0881..981925c211 100644
--- a/app/src/main/java/to/bitkit/services/PubkyService.kt
+++ b/app/src/main/java/to/bitkit/services/PubkyService.kt
@@ -1,20 +1,32 @@
package to.bitkit.services
+import com.synonym.bitkitcore.approvePubkyAuth
import com.synonym.paykit.ContactProfileResolution
import com.synonym.paykit.ContactRecord
import com.synonym.paykit.PaykitProfile
+import com.synonym.paykit.PaykitPublicKeys
import com.synonym.paykit.PubkyAuthCompanionClaim
+import com.synonym.paykit.PubkySessionBootstrapResult
+import kotlinx.coroutines.withTimeoutOrNull
import to.bitkit.async.ServiceQueue
import to.bitkit.ext.runSuspendCatching
import to.bitkit.utils.AppError
import javax.inject.Inject
import javax.inject.Singleton
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import com.synonym.bitkitcore.parsePubkyAuthUrl as parseLegacyPubkyAuthUrl
@Suppress("TooManyFunctions")
@Singleton
class PubkyService @Inject constructor(
private val paykitSdkService: PaykitSdkService,
) {
+ companion object {
+ /** Maximum wait for a Ring relay approval response. */
+ private val RING_AUTH_TIMEOUT = 30.seconds
+ }
+
suspend fun initialize() = ServiceQueue.CORE.background {
paykitSdkService.initialize()
}
@@ -76,6 +88,19 @@ class PubkyService @Inject constructor(
Unit
}
+ suspend fun registerIdentity(
+ secretKeyHex: String,
+ homeserverZ32: String,
+ signupCode: String?,
+ ): PubkySessionBootstrapResult =
+ ServiceQueue.CORE.background {
+ paykitSdkService.registerIdentity(secretKeyHex, homeserverZ32, signupCode)
+ }
+
+ suspend fun activateRegisteredIdentity(result: PubkySessionBootstrapResult) = ServiceQueue.CORE.background {
+ paykitSdkService.activateRegisteredIdentity(result)
+ }
+
suspend fun signIn(secretKeyHex: String): Unit = ServiceQueue.CORE.background {
paykitSdkService.signIn(secretKeyHex)
Unit
@@ -106,6 +131,13 @@ class PubkyService @Inject constructor(
PaykitSdkService.parseAuthUrl(url)
}
+ suspend fun validateSignupRequest(authorizationUrl: String?, homeserverPublicKey: String): Unit =
+ ServiceQueue.CORE.background {
+ authorizationUrl?.let { parseLegacyPubkyAuthUrl(it) }
+ PaykitPublicKeys.normalize(homeserverPublicKey)
+ Unit
+ }
+
suspend fun approveAuth(
authUrl: String,
expectedCapabilities: String,
@@ -115,6 +147,16 @@ class PubkyService @Inject constructor(
paykitSdkService.approveAuth(authUrl, expectedCapabilities, approvedClientId, secretKeyHex)
}
+ suspend fun approveRingAuth(
+ authUrl: String,
+ secretKeyHex: String,
+ timeout: Duration = RING_AUTH_TIMEOUT,
+ ) = ServiceQueue.CORE.background {
+ withTimeoutOrNull(timeout) {
+ approvePubkyAuth(authUrl, secretKeyHex)
+ } ?: throw PubkyRingAuthTimeoutError()
+ }
+
suspend fun approveAuthWithCompanionClaim(
authUrl: String,
expectedCapabilities: String,
@@ -188,3 +230,5 @@ class PubkyService @Inject constructor(
// endregion
}
+
+class PubkyRingAuthTimeoutError : AppError("Ring authorization timed out")
diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt
index d903bebf26..552ab9a087 100644
--- a/app/src/main/java/to/bitkit/ui/ContentView.kt
+++ b/app/src/main/java/to/bitkit/ui/ContentView.kt
@@ -459,6 +459,7 @@ fun ContentView(
val hasSeenWidgetsIntro by settingsViewModel.hasSeenWidgetsIntro.collectAsStateWithLifecycle()
val hasSeenShopIntro by settingsViewModel.hasSeenShopIntro.collectAsStateWithLifecycle()
val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle()
+ val isPubkyProfileSetupPending by settingsViewModel.isPubkyProfileSetupPending.collectAsStateWithLifecycle()
val hasSeenContactsIntro by settingsViewModel.hasSeenContactsIntro.collectAsStateWithLifecycle()
val isProfileAuthenticated by settingsViewModel.isPubkyAuthenticated.collectAsStateWithLifecycle()
val hasPubkyContacts by settingsViewModel.hasPubkyContacts.collectAsStateWithLifecycle()
@@ -648,6 +649,7 @@ fun ContentView(
) {
Box(modifier = Modifier.fillMaxSize()) {
var isHomeCalculatorInputActive by remember { mutableStateOf(false) }
+ val pubkyProfileSetupNavigation = remember { PubkyProfileSetupNavigation() }
RootNavHost(
navController = navController,
@@ -668,6 +670,25 @@ fun ContentView(
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
+ LaunchedEffect(
+ isPaykitEnabled,
+ isPubkyProfileSetupPending,
+ isProfileAuthenticated,
+ currentSheet,
+ currentRoute,
+ ) {
+ val canNavigate = currentSheet == null &&
+ currentRoute != Routes.CreateProfile::class.qualifiedName
+ if (pubkyProfileSetupNavigation.shouldNavigate(
+ isEnabled = isPaykitEnabled,
+ isPending = isPubkyProfileSetupPending,
+ isAuthenticated = isProfileAuthenticated,
+ canNavigate = canNavigate,
+ )
+ ) {
+ navController.navigateTo(Routes.CreateProfile)
+ }
+ }
val currentHardwareWalletId = navBackStackEntry
?.takeIf { it.destination.hasRoute() }
?.toRoute()
@@ -727,6 +748,26 @@ fun ContentView(
}
}
+internal class PubkyProfileSetupNavigation {
+ private var didResume = false
+
+ fun shouldNavigate(
+ isEnabled: Boolean,
+ isPending: Boolean,
+ isAuthenticated: Boolean,
+ canNavigate: Boolean,
+ ): Boolean {
+ if (!isPending) {
+ didResume = false
+ return false
+ }
+ if (didResume) return false
+ if (!isEnabled || !isAuthenticated || !canNavigate) return false
+ didResume = true
+ return true
+ }
+}
+
@Composable
private fun RootNavHost(
navController: NavHostController,
@@ -1577,7 +1618,7 @@ private fun NavGraphBuilder.shop(
page = it.toRoute().page,
title = it.toRoute().title,
onPaymentIntent = { data ->
- appViewModel.onScanResult(data)
+ appViewModel.onScanResult(data, allowPubkyAuth = false)
},
onBlockedNavigation = {
appViewModel.toast(
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt
index b63d393506..42bffae193 100644
--- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt
@@ -16,6 +16,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -75,6 +76,10 @@ fun PubkyAuthApprovalSheet(
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ DisposableEffect(viewModel, authUrl) {
+ onDispose { viewModel.cancelLocalAuth(authUrl) }
+ }
+
LaunchedEffect(authUrl) { viewModel.load(authUrl) }
Box {
@@ -382,23 +387,47 @@ private fun ColumnScope.ApprovalDetails(
Column(modifier = Modifier.weight(1f)) {
VerticalSpacer(26.dp)
- DescriptionText(serviceName = uiState.serviceName)
- VerticalSpacer(8.dp)
- BodyS(
- text = stringResource(R.string.profile__auth_approval_requester, uiState.clientId),
- color = Colors.White64,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- VerticalSpacer(32.dp)
+ if (uiState.homeserverPublicKey != null) {
+ BodyM(text = stringResource(R.string.pubky_auth__signup_description), color = Colors.White64)
+ VerticalSpacer(16.dp)
+ }
+ if (uiState.permissions.isNotEmpty()) {
+ DescriptionText(serviceName = uiState.serviceName)
+ VerticalSpacer(8.dp)
+ }
+ if (uiState.clientId.isNotBlank()) {
+ BodyS(
+ text = stringResource(R.string.profile__auth_approval_requester, uiState.clientId),
+ color = Colors.White64,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ VerticalSpacer(32.dp)
+ } else {
+ VerticalSpacer(24.dp)
+ }
- PermissionsSection(permissions = uiState.permissions)
+ if (uiState.permissions.isNotEmpty()) {
+ PermissionsSection(permissions = uiState.permissions)
+ }
FillHeight(min = 32.dp)
TrustWarning()
VerticalSpacer(16.dp)
- uiState.profile?.let { ProfileCard(it) }
+ uiState.homeserverPublicKey?.let { homeserver ->
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(Colors.Gray6, RoundedCornerShape(16.dp))
+ .padding(24.dp)
+ .testTag("PubkySignupHomeserver")
+ ) {
+ Text13Up(text = stringResource(R.string.pubky_auth__homeserver), color = Colors.White64)
+ BodyMSB(text = homeserver)
+ }
+ } ?: uiState.profile?.let { ProfileCard(it) }
VerticalSpacer(16.dp)
}
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt
index 516ac57055..9d6eeb3157 100644
--- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt
@@ -24,6 +24,7 @@ import to.bitkit.models.PubkyAuthRequest
import to.bitkit.models.PubkyProfile
import to.bitkit.models.Toast
import to.bitkit.models.WatchOnlyAccountSetupState
+import to.bitkit.repositories.PubkyAlreadySignedInError
import to.bitkit.repositories.PubkyRepo
import to.bitkit.repositories.WatchOnlyAccountAuthorizationStartError
import to.bitkit.repositories.WatchOnlyAccountRepo
@@ -90,6 +91,7 @@ class PubkyAuthApprovalViewModel @Inject constructor(
ApprovalState.Authorize
},
clientId = request.clientId,
+ homeserverPublicKey = request.homeserverPublicKey,
serviceName = serviceName,
permissions = request.permissions.toImmutableList(),
bitkitClaim = request.bitkitClaim,
@@ -171,6 +173,10 @@ class PubkyAuthApprovalViewModel @Inject constructor(
if (!approveRequest(request, authUrl)) return
Logger.info("Auth approved for '${request.serviceNames.firstOrNull().orEmpty()}'", context = TAG)
+ if (request.isSignup) {
+ _effects.emit(PubkyAuthApprovalEffect.Dismiss)
+ return
+ }
_uiState.update { state ->
if (state.authUrl == authUrl) state.copy(state = ApprovalState.Success) else state
}
@@ -179,6 +185,21 @@ class PubkyAuthApprovalViewModel @Inject constructor(
private suspend fun approveRequest(
request: PubkyAuthRequest,
authUrl: String,
+ ): Boolean = if (request.isSignup) {
+ pubkyRepo.approveSignupAuth(request).fold(
+ onSuccess = { true },
+ onFailure = {
+ handleApprovalFailure(it, authUrl)
+ false
+ },
+ )
+ } else {
+ approveSignInRequest(request, authUrl)
+ }
+
+ private suspend fun approveSignInRequest(
+ request: PubkyAuthRequest,
+ authUrl: String,
): Boolean {
val preparedClaim = runSuspendCatching {
if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) {
@@ -246,9 +267,11 @@ class PubkyAuthApprovalViewModel @Inject constructor(
private fun resetForLoad(authUrl: String): Boolean {
while (true) {
val currentState = _uiState.value
+ val isAuthorizing = currentState.state == ApprovalState.Authorizing &&
+ inFlightAuthorization.get()?.authUrl == authUrl
if (
currentState.authUrl == authUrl &&
- currentState.state in setOf(ApprovalState.Authenticating, ApprovalState.Authorizing)
+ (currentState.state == ApprovalState.Authenticating || isAuthorizing)
) {
return false
}
@@ -273,8 +296,16 @@ class PubkyAuthApprovalViewModel @Inject constructor(
}
private suspend fun handleApprovalFailure(error: Throwable, authUrl: String) {
- Logger.error("Auth approval failed", error, context = TAG)
+ if (error !is PubkyAlreadySignedInError) Logger.error("Auth approval failed", error, context = TAG)
if (_uiState.value.authUrl != authUrl) return
+ if (error is PubkyAlreadySignedInError) {
+ ToastEventBus.send(
+ type = Toast.ToastType.INFO,
+ title = context.getString(R.string.pubky_auth__already_signed_in),
+ )
+ _effects.emit(PubkyAuthApprovalEffect.Dismiss)
+ return
+ }
_uiState.update { it.copy(state = ApprovalState.Authorize) }
ToastEventBus.send(
type = Toast.ToastType.ERROR,
@@ -299,6 +330,7 @@ data class PubkyAuthApprovalUiState(
val authUrl: String = "",
val state: ApprovalState = ApprovalState.Loading,
val clientId: String = "",
+ val homeserverPublicKey: String? = null,
val serviceName: String = "",
val permissions: ImmutableList = persistentListOf(),
val bitkitClaim: PubkyAuthClaim? = null,
diff --git a/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt b/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt
index 580cf89ff4..b254013094 100644
--- a/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt
+++ b/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt
@@ -4,27 +4,31 @@ import android.content.Context
import to.bitkit.R
import to.bitkit.models.PubkyAuthRequestError
import to.bitkit.repositories.WatchOnlyAccountError
+import to.bitkit.services.PubkyRingAuthTimeoutError
fun Throwable.localizedPubkyAuthMessage(context: Context): String? {
var current: Throwable? = this
while (current != null) {
- val messageResource = when (current) {
- is PubkyAuthRequestError.InvalidUrl -> R.string.profile__auth_error_invalid_url
- PubkyAuthRequestError.RequesterChanged -> R.string.profile__auth_error_invalid_url
- PubkyAuthRequestError.MissingBitkitClaim -> R.string.profile__auth_error_missing_claim
- PubkyAuthRequestError.DuplicateBitkitClaim -> R.string.profile__auth_error_duplicate_claim
- is PubkyAuthRequestError.UnsupportedBitkitClaim -> R.string.profile__auth_error_unsupported_claim
- PubkyAuthRequestError.InvalidBitkitClaimCapabilities -> R.string.profile__auth_error_invalid_capabilities
- is WatchOnlyAccountError.AuthorizationAccountMissing -> R.string.watch_only_accounts__setup_not_finished
- is WatchOnlyAccountError.InvalidAccountName -> R.string.watch_only_accounts__error_invalid_name
- is WatchOnlyAccountError.InvalidExtendedPublicKey -> R.string.watch_only_accounts__error_invalid_xpub
- is WatchOnlyAccountError.NodeUnavailable -> R.string.watch_only_accounts__error_node_unavailable
- else -> null
- }
+ val messageResource = current.pubkyAuthMessageResource()
if (messageResource != null) {
return context.getString(messageResource)
}
current = current.cause
}
- return message
+ return context.getString(R.string.common__error_body)
+}
+
+private fun Throwable.pubkyAuthMessageResource() = when (this) {
+ is PubkyRingAuthTimeoutError -> R.string.profile__auth_error_timeout
+ is PubkyAuthRequestError.InvalidUrl -> R.string.profile__auth_error_invalid_url
+ PubkyAuthRequestError.RequesterChanged -> R.string.profile__auth_error_invalid_url
+ PubkyAuthRequestError.MissingBitkitClaim -> R.string.profile__auth_error_missing_claim
+ PubkyAuthRequestError.DuplicateBitkitClaim -> R.string.profile__auth_error_duplicate_claim
+ is PubkyAuthRequestError.UnsupportedBitkitClaim -> R.string.profile__auth_error_unsupported_claim
+ PubkyAuthRequestError.InvalidBitkitClaimCapabilities -> R.string.profile__auth_error_invalid_capabilities
+ is WatchOnlyAccountError.AuthorizationAccountMissing -> R.string.watch_only_accounts__setup_not_finished
+ is WatchOnlyAccountError.InvalidAccountName -> R.string.watch_only_accounts__error_invalid_name
+ is WatchOnlyAccountError.InvalidExtendedPublicKey -> R.string.watch_only_accounts__error_invalid_xpub
+ is WatchOnlyAccountError.NodeUnavailable -> R.string.watch_only_accounts__error_node_unavailable
+ else -> null
}
diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
index b80600e2b9..d935f27328 100644
--- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@@ -116,6 +116,7 @@ import to.bitkit.models.NewTransactionSheetDetails
import to.bitkit.models.NewTransactionSheetDirection
import to.bitkit.models.NewTransactionSheetType
import to.bitkit.models.NodeLifecycleState
+import to.bitkit.models.PubkyAuthRequest
import to.bitkit.models.PubkyProfile
import to.bitkit.models.PubkyPublicKeyFormat
import to.bitkit.models.PubkyRingAuthCallback
@@ -132,6 +133,7 @@ import to.bitkit.models.WalletScope
import to.bitkit.models.msatFloorOf
import to.bitkit.models.safe
import to.bitkit.models.sanitizedDeeplinkLogValue
+import to.bitkit.models.sanitizedQrLogValue
import to.bitkit.models.toActivityFilter
import to.bitkit.models.toLdkNetwork
import to.bitkit.models.toTxType
@@ -188,6 +190,7 @@ import to.bitkit.ui.sheets.SendRoute
import to.bitkit.ui.sheets.hardware.HardwareRoute
import to.bitkit.ui.theme.TRANSITION_SCREEN_MS
import to.bitkit.ui.utils.ScreenDeepLinks
+import to.bitkit.ui.utils.localizedPubkyAuthMessage
import to.bitkit.usecases.FormatMoneyValue
import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase
import to.bitkit.utils.AppError
@@ -417,6 +420,8 @@ class AppViewModel @Inject constructor(
}
}
+ private val toastManager = toastManagerProvider(viewModelScope)
+
init {
viewModelScope.launch {
ToastEventBus.events.collect {
@@ -1854,7 +1859,7 @@ class AppViewModel @Inject constructor(
// Skip validation for empty input
if (valueWithoutSpaces.isEmpty()) return
- if (valueWithoutSpaces.startsWith("$PUBKYAUTH_SCHEME://", ignoreCase = true)) return
+ if (PubkyAuthRequest.isProtocolUrl(valueWithoutSpaces)) return
if (PubkyPublicKeyFormat.normalized(valueWithoutSpaces) != null) {
if (isPaykitEnabled.value) {
@@ -2066,6 +2071,7 @@ class AppViewModel @Inject constructor(
routePubkyKeys: Boolean = false,
contactPaymentContext: ContactPaymentContext? = null,
preserveUntilComplete: Boolean = false,
+ allowPubkyAuth: Boolean = isMainScanner,
): Job? {
if (!_isAuthenticated.value) {
enqueueDeferredScan(
@@ -2074,13 +2080,13 @@ class AppViewModel @Inject constructor(
startDelay = startDelay,
routePubkyKeys = routePubkyKeys,
contactPaymentContext = contactPaymentContext,
+ allowPubkyAuth = allowPubkyAuth,
)
return null
}
val normalized = data.removeLightningSchemes()
val scanId = scanLogId(data)
-
val scheduled = scheduledScan
val isSameActiveScan = normalized == scheduled?.normalizedInput &&
scheduled.job.isActive &&
@@ -2091,16 +2097,20 @@ class AppViewModel @Inject constructor(
}
if (scheduled?.job?.isActive == true && scheduled.mustComplete) {
- enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext)
+ enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext, allowPubkyAuth)
return null
}
val previousJob = scheduled?.job
val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) {
scanMutex.withLock {
- setActiveContactPaymentContext(contactPaymentContext)
+ if (!awaitPubkyDeeplinkInitialization(source, data, allowPubkyAuth)) return@withLock
+ if (deferLockedScan(source, data, startDelay, routePubkyKeys, contactPaymentContext, allowPubkyAuth)) {
+ return@withLock
+ }
+ prepareContactPaymentContextForScan(normalized, allowPubkyAuth, contactPaymentContext)
if (startDelay > Duration.ZERO) delay(startDelay)
- handleScan(data, routePubkyKeys)
+ handleScan(data, routePubkyKeys, contactPaymentContext, allowPubkyAuth)
}
}
val nextScheduledScan = ScheduledScan(
@@ -2126,8 +2136,41 @@ class AppViewModel @Inject constructor(
return nextJob
}
+ private suspend fun awaitPubkyDeeplinkInitialization(
+ source: ScanSource,
+ data: String,
+ allowPubkyAuth: Boolean,
+ ): Boolean {
+ if (source != ScanSource.DEEPLINK || !allowPubkyAuth) return true
+ if (!PubkyAuthRequest.isProtocolUrl(data)) return true
+
+ if (!PubkyAuthRequest.isSignupUrl(data)) pubkyRepo.awaitInitialization()
+ return isPaykitUiEnabledFromSettings() && walletRepo.walletExists()
+ }
+
+ private suspend fun isPaykitUiEnabledFromSettings() =
+ PaykitFeatureFlags.isUiEnabled(settingsStore.isPaykitEnabled.first())
+
+ private fun deferLockedScan(
+ source: ScanSource,
+ data: String,
+ startDelay: Duration,
+ routePubkyKeys: Boolean,
+ contactPaymentContext: ContactPaymentContext?,
+ allowPubkyAuth: Boolean,
+ ): Boolean {
+ if (_isAuthenticated.value) return false
+
+ synchronized(deferredScanLock) {
+ if (deferredScan == null) {
+ enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext, allowPubkyAuth)
+ }
+ }
+ return true
+ }
+
private fun scanLogId(data: String): String {
- val scanLogInput = SamRockSetupRequest.sanitizedDescription(data.removeLightningSchemes()) ?: data
+ val scanLogInput = data.removeLightningSchemes().sanitizedQrLogValue()
return if (scanLogInput.length > SCAN_LOG_ID_MAX_LENGTH) {
"${scanLogInput.take(SCAN_LOG_ID_AFFIX_LENGTH)}…${scanLogInput.takeLast(SCAN_LOG_ID_AFFIX_LENGTH)}"
} else {
@@ -2141,6 +2184,7 @@ class AppViewModel @Inject constructor(
startDelay: Duration,
routePubkyKeys: Boolean,
contactPaymentContext: ContactPaymentContext?,
+ allowPubkyAuth: Boolean,
) {
val scanId = scanLogId(data)
val normalized = data.removeLightningSchemes()
@@ -2154,6 +2198,7 @@ class AppViewModel @Inject constructor(
startDelay = startDelay,
routePubkyKeys = routePubkyKeys,
contactPaymentContext = contactPaymentContext,
+ allowPubkyAuth = allowPubkyAuth,
)
return
}
@@ -2172,6 +2217,7 @@ class AppViewModel @Inject constructor(
startDelay = startDelay,
routePubkyKeys = routePubkyKeys,
contactPaymentContext = contactPaymentContext,
+ allowPubkyAuth = allowPubkyAuth,
)
}
Logger.info("Queuing '${source.label}' scan for deferred handling: '$scanId'", context = TAG)
@@ -2210,6 +2256,7 @@ class AppViewModel @Inject constructor(
routePubkyKeys = pending.routePubkyKeys,
contactPaymentContext = pending.contactPaymentContext,
preserveUntilComplete = true,
+ allowPubkyAuth = pending.allowPubkyAuth,
)
}
@@ -2561,6 +2608,7 @@ class AppViewModel @Inject constructor(
startDelay: Duration = Duration.ZERO,
routePubkyKeys: Boolean = false,
contactPaymentContext: ContactPaymentContext? = null,
+ allowPubkyAuth: Boolean = isMainScanner,
) {
launchScan(
source = ScanSource.SCAN_RESULT,
@@ -2568,6 +2616,7 @@ class AppViewModel @Inject constructor(
startDelay = startDelay,
routePubkyKeys = routePubkyKeys,
contactPaymentContext = contactPaymentContext,
+ allowPubkyAuth = allowPubkyAuth,
)
}
@@ -2590,6 +2639,7 @@ class AppViewModel @Inject constructor(
source = ScanSource.SCAN_RESULT,
data = paymentRequest,
contactPaymentContext = context,
+ allowPubkyAuth = false,
)
}
@@ -2607,7 +2657,13 @@ class AppViewModel @Inject constructor(
private suspend fun handleScan(
result: String,
routePubkyKeys: Boolean,
+ contactPaymentContext: ContactPaymentContext?,
+ allowPubkyAuth: Boolean,
) = withContext(bgDispatcher) {
+ if (rejectPubkyAuthScan(result, allowPubkyAuth, contactPaymentContext)) return@withContext
+
+ val input = result.removeLightningSchemes()
+
val contactPaymentProfile = activeContactPaymentProfile()
val incomingPaymentRequest = activeIncomingPaymentRequest()
val isPaymentRequest = incomingPaymentRequest != null
@@ -2627,7 +2683,6 @@ class AppViewModel @Inject constructor(
resetQuickPay()
val fromMainScanner = isMainScanner
- val input = result.removeLightningSchemes()
// TODO Workaround for https://github.com/synonymdev/bitkit-core/issues/63
if (Bip21Utils.isDuplicatedBip21(input)) {
@@ -2652,16 +2707,9 @@ class AppViewModel @Inject constructor(
return@withContext
}
- if (input.startsWith("$PUBKYAUTH_SCHEME://", ignoreCase = true)) {
+ if (PubkyAuthRequest.isProtocolUrl(input)) {
clearActiveContactPaymentContext()
- if (!fromMainScanner) {
- hideSheet()
- toast(
- type = Toast.ToastType.ERROR,
- title = context.getString(R.string.other__qr_error_header),
- description = context.getString(R.string.other__qr_error_text),
- )
- } else if (isPaykitEnabled.value) {
+ if (isPaykitUiEnabledFromSettings()) {
handlePubkyAuth(input)
} else {
hideSheet()
@@ -2791,12 +2839,7 @@ class AppViewModel @Inject constructor(
if (interruptedRequest == null) return
if (!retryIncomingRequest) {
- paymentRequestPresentationGeneration++
- if (requestedPaymentRequestId == interruptedRequest.id) {
- requestedPaymentRequestId = null
- }
- clearPaymentRequestPresentationRetry(interruptedRequest.id)
- viewModelScope.launch { paykitPaymentRequestRepo.markPresented(interruptedRequest) }
+ viewModelScope.launch { markIncomingPaymentRequestPresented(interruptedRequest) }
return
}
@@ -2809,6 +2852,42 @@ class AppViewModel @Inject constructor(
isSubmittingPaymentRequest = false
}
+ private suspend fun rejectPubkyAuthScan(
+ input: String,
+ allowPubkyAuth: Boolean,
+ contactPaymentContext: ContactPaymentContext?,
+ ): Boolean {
+ val unwrappedInput = input.removeLightningSchemes()
+ if (!PubkyAuthRequest.isProtocolUrl(unwrappedInput)) return false
+ if (allowPubkyAuth && input == unwrappedInput) return false
+ toast(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.other__qr_error_header),
+ description = context.getString(R.string.other__qr_error_text),
+ )
+ clearRejectedContactPaymentContext(contactPaymentContext)
+ return true
+ }
+
+ private suspend fun clearRejectedContactPaymentContext(context: ContactPaymentContext?) {
+ if (context == null) return
+ synchronized(contactPaymentContextLock) {
+ if (activeContactPaymentContext != context) return
+ activeContactPaymentContext = null
+ preparedContactPaymentContext = null
+ }
+ context.incomingPaymentRequest?.let { markIncomingPaymentRequestPresented(it) }
+ }
+
+ private suspend fun markIncomingPaymentRequestPresented(request: PaykitPaymentRequest) {
+ paymentRequestPresentationGeneration++
+ if (requestedPaymentRequestId == request.id) {
+ requestedPaymentRequestId = null
+ }
+ clearPaymentRequestPresentationRetry(request.id)
+ paykitPaymentRequestRepo.markPresented(request)
+ }
+
private fun setActiveContactPaymentContext(context: ContactPaymentContext?) {
synchronized(contactPaymentContextLock) {
if (activeContactPaymentContext != context) preparedContactPaymentContext = null
@@ -2816,6 +2895,15 @@ class AppViewModel @Inject constructor(
}
}
+ private fun prepareContactPaymentContextForScan(
+ input: String,
+ allowPubkyAuth: Boolean,
+ context: ContactPaymentContext?,
+ ) {
+ val preservesExistingContext = PubkyAuthRequest.isProtocolUrl(input) && !allowPubkyAuth && context == null
+ if (!preservesExistingContext) setActiveContactPaymentContext(context)
+ }
+
private fun clearPendingContactPaymentContext(paymentHash: String) {
synchronized(contactPaymentContextLock) {
pendingContactPaymentContexts.remove(paymentHash)
@@ -4458,7 +4546,6 @@ class AppViewModel @Inject constructor(
// endregion
// region Toasts
- private val toastManager = toastManagerProvider(viewModelScope)
val currentToast: StateFlow = toastManager.currentToast
fun toast(
@@ -5120,9 +5207,13 @@ class AppViewModel @Inject constructor(
return@launch
}
- if (uri.scheme == PUBKYAUTH_SCHEME) {
- if (!isPaykitEnabled.value) return@launch
- handlePubkyAuth(uri.toString())
+ if (PubkyAuthRequest.isProtocolUrl(value)) {
+ launchScan(
+ source = ScanSource.DEEPLINK,
+ data = value,
+ startDelay = SCREEN_TRANSITION_DELAY,
+ allowPubkyAuth = true,
+ )
return@launch
}
@@ -5144,7 +5235,10 @@ class AppViewModel @Inject constructor(
}
private suspend fun handlePubkyAuth(authUrl: String) {
- if (pubkyRepo.publicKey.value == null) {
+ val isSignup = PubkyAuthRequest.isSignupUrl(authUrl)
+ if (isSignup && rejectPubkySignupForExistingIdentity()) return
+
+ if (!isSignup && pubkyRepo.publicKey.value == null) {
ToastEventBus.send(
type = Toast.ToastType.WARNING,
title = context.getString(R.string.pubky_auth__no_identity),
@@ -5153,7 +5247,7 @@ class AppViewModel @Inject constructor(
return
}
- if (!pubkyRepo.hasSecretKey()) {
+ if (!isSignup && !pubkyRepo.hasSecretKey()) {
ToastEventBus.send(
type = Toast.ToastType.WARNING,
title = context.getString(R.string.profile__auth_approval_ring_only),
@@ -5163,6 +5257,24 @@ class AppViewModel @Inject constructor(
showSheet(Sheet.PubkyAuth(authUrl))
}
+ private suspend fun rejectPubkySignupForExistingIdentity(): Boolean {
+ val hasIdentity = runSuspendCatching { pubkyRepo.hasIdentity() }.getOrElse {
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__auth_error_title),
+ description = it.localizedPubkyAuthMessage(context),
+ )
+ return true
+ }
+ if (!hasIdentity) return false
+
+ ToastEventBus.send(
+ type = Toast.ToastType.INFO,
+ title = context.getString(R.string.pubky_auth__already_signed_in),
+ )
+ return true
+ }
+
private suspend fun handlePubkyRingAuthCallback(callback: PubkyRingAuthCallback) {
when (val result = pubkyRepo.handleAuthCallback(callback)) {
is PubkyRingAuthCallbackHandlingResult.TrustedError -> {
@@ -5247,7 +5359,6 @@ class AppViewModel @Inject constructor(
private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds
private val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes
private const val BITKIT_SCHEME = "bitkit"
- private const val PUBKYAUTH_SCHEME = "pubkyauth"
private const val RECOVERY_MODE_DEEPLINK = "recovery-mode"
/** Max characters kept in a scan log id before truncating. */
@@ -5284,6 +5395,7 @@ private data class DeferredScan(
val startDelay: Duration,
val routePubkyKeys: Boolean,
val contactPaymentContext: ContactPaymentContext?,
+ val allowPubkyAuth: Boolean,
)
// region send contract
diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt
index e406efd3e6..4afc065826 100644
--- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt
@@ -130,6 +130,9 @@ class SettingsViewModel @Inject constructor(
val hasSeenProfileIntro = settingsStore.data.map { it.hasSeenProfileIntro }
.asStateFlow(initialValue = false)
+ val isPubkyProfileSetupPending = settingsStore.isPubkyProfileSetupPending
+ .asStateFlow(initialValue = false)
+
fun setHasSeenProfileIntro(value: Boolean) {
viewModelScope.launch {
settingsStore.update { it.copy(hasSeenProfileIntro = value) }
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 4b8d50eb58..b90d2dfa71 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -623,6 +623,7 @@
The requested access does not match this Bitkit claim.
This Pubky authorization link is invalid.
This authorization is missing its required Bitkit claim.
+ The authorization relay did not respond in time. Please try again.
Authorization Failed
This Bitkit claim is not supported.
Failed to read selected image
@@ -684,8 +685,11 @@
Suggestions To Add
Your Name
Your Pubky
+ Already signed in
+ Homeserver
Pubky Identity Required
Create a Pubky identity in your profile to approve auth requests.
+ Create a new Pubky identity on this homeserver. Only continue if you trust it.
Back Up
Now that you have some funds in your wallet, it is time to back up your money!
There are no funds in your wallet yet, but you can create a backup if you wish.
diff --git a/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt b/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt
index f67dfc878f..1a3d1f7270 100644
--- a/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt
+++ b/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt
@@ -1,5 +1,14 @@
package to.bitkit.build
+import android.app.Application
+import android.content.ComponentName
+import android.content.Intent
+import android.content.pm.PackageManager
+import androidx.core.net.toUri
+import androidx.test.core.app.ApplicationProvider
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
import org.w3c.dom.Element
import java.nio.file.Path
import javax.xml.parsers.DocumentBuilderFactory
@@ -10,6 +19,8 @@ import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [34])
class PubkyAuthManifestTest {
private val repoRoot = generateSequence(
Path(requireNotNull(System.getProperty("user.dir")) { "user.dir is required" }),
@@ -27,14 +38,92 @@ class PubkyAuthManifestTest {
}
@Test
- fun `pubkyauth alias is disabled by default`() {
- val alias = manifest.getElementsByTagName("activity-alias").elements()
- .single { it.getAttribute("android:name") == ".ui.MainActivityPubkyAuth" }
-
- assertEquals(".ui.MainActivity", alias.getAttribute("android:targetActivity"))
- assertEquals("false", alias.getAttribute("android:enabled"))
- assertEquals("true", alias.getAttribute("android:exported"))
- assertTrue(alias.handlesScheme("pubkyauth"))
+ fun `Pubky aliases are disabled by default`() {
+ val aliases = manifest.getElementsByTagName("activity-alias").elements()
+ listOf(".ui.MainActivityPubkyAuth", ".ui.MainActivityPubkySignup").forEach { name ->
+ val alias = aliases.single { it.getAttribute("android:name") == name }
+ assertEquals(".ui.MainActivity", alias.getAttribute("android:targetActivity"))
+ assertEquals("false", alias.getAttribute("android:enabled"))
+ assertEquals("true", alias.getAttribute("android:exported"))
+ assertTrue(alias.handlesScheme("pubkyauth"))
+ }
+ }
+
+ @Test
+ fun `signup and authorization links resolve only through their enabled aliases`() {
+ val application = ApplicationProvider.getApplicationContext()
+ val packageManager = application.packageManager
+ val authAlias = ComponentName(application.packageName, "to.bitkit.ui.MainActivityPubkyAuth")
+ val signupAlias = ComponentName(application.packageName, "to.bitkit.ui.MainActivityPubkySignup")
+ val signupUrls = listOf(
+ "pubkyring://signup?hs=homeserver",
+ "pubkyauth://signup?hs=homeserver&relay=relay&secret=secret&caps=rw",
+ "pubkyauth://signup?hs=homeserver",
+ "pubkyauth://direct_signup?hs=homeserver",
+ )
+ val authUrls = listOf("pubkyauth://signin_grant?caps=rw", "pubkyauth://signup_grant?caps=rw")
+ val unrelatedUrls = listOf(
+ "pubkyring://auth",
+ "pubkyring://direct_signup",
+ "pubkyauth://?caps=rw",
+ "pubkyauth://signin",
+ "pubkyauth://signin?caps=rw",
+ "pubkyauth://grant?caps=rw",
+ "pubkyauth://session",
+ "pubkyauth://secret_export",
+ )
+ val allUrls = signupUrls + authUrls + unrelatedUrls
+
+ assertRoutes(packageManager, application.packageName, allUrls, null)
+ packageManager.setComponentEnabledSetting(
+ signupAlias,
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
+ PackageManager.DONT_KILL_APP,
+ )
+ assertRoutes(packageManager, application.packageName, signupUrls, signupAlias)
+ assertRoutes(packageManager, application.packageName, authUrls + unrelatedUrls, null)
+
+ packageManager.setComponentEnabledSetting(
+ signupAlias,
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
+ PackageManager.DONT_KILL_APP,
+ )
+ packageManager.setComponentEnabledSetting(
+ authAlias,
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
+ PackageManager.DONT_KILL_APP,
+ )
+ assertRoutes(packageManager, application.packageName, authUrls, authAlias)
+ assertRoutes(packageManager, application.packageName, signupUrls + unrelatedUrls, null)
+
+ packageManager.setComponentEnabledSetting(
+ authAlias,
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
+ PackageManager.DONT_KILL_APP,
+ )
+ assertRoutes(packageManager, application.packageName, allUrls, null)
+ }
+
+ private fun assertRoutes(
+ packageManager: PackageManager,
+ packageName: String,
+ urls: List,
+ alias: ComponentName?,
+ ) {
+ urls.forEach {
+ val intent = Intent(Intent.ACTION_VIEW, it.toUri())
+ .addCategory(Intent.CATEGORY_BROWSABLE)
+ .setPackage(packageName)
+ val resolved = packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
+ if (alias == null) {
+ assertTrue(resolved.isEmpty(), it)
+ } else {
+ val activity = resolved.single().activityInfo
+ assertEquals(alias.className, activity.name, it)
+ assertEquals("to.bitkit.ui.MainActivity", activity.targetActivity)
+ assertTrue(activity.exported)
+ }
+ }
}
private fun parseManifest(path: Path) = DocumentBuilderFactory.newInstance()
diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt
index 1577dd834e..38afcab01f 100644
--- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt
+++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt
@@ -1,13 +1,60 @@
package to.bitkit.models
+import java.net.URLEncoder
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class PubkyAuthRequestTest {
+ @Test
+ fun `parse authorized signup preserves registration and authorization details`() {
+ listOf("pubkyring", "pubkyauth").forEach { scheme ->
+ val request = PubkyAuthRequest.parseSignup(ringSignupUrl("invite code", scheme)).getOrThrow()
+
+ assertTrue(request.isSignup)
+ assertEquals("homeserver", request.homeserverPublicKey)
+ assertEquals("invite code", request.signupToken)
+ assertEquals("https://relay.example/inbox/", request.relay)
+ assertEquals("/pub/example.app/:rw", request.capabilities)
+ assertEquals(
+ "pubkyauth:///?relay=https%3A%2F%2Frelay.example%2Finbox%2F" +
+ "&secret=secret&caps=%2Fpub%2Fexample.app%2F%3Arw",
+ request.authorizationUrl,
+ )
+ }
+ }
+
+ @Test
+ fun `parse direct signup accepts canonical and legacy formats`() {
+ listOf("direct_signup", "signup").forEach { action ->
+ val request = PubkyAuthRequest.parseSignup(directSignupUrl(action, "invite code")).getOrThrow()
+
+ assertTrue(request.isSignup)
+ assertEquals("homeserver", request.homeserverPublicKey)
+ assertEquals("invite code", request.signupToken)
+ assertEquals("", request.relay)
+ assertEquals("", request.capabilities)
+ assertNull(request.authorizationUrl)
+ }
+ }
+
+ @Test
+ fun `parse Ring signup rejects missing and duplicate required values`() {
+ val invalidUrls = listOf(
+ ringSignupUrl().replace("&secret=secret", ""),
+ "${ringSignupUrl()}&hs=other",
+ directSignupUrl("signup") + "&relay=https%3A%2F%2Frelay.example",
+ )
+
+ invalidUrls.forEach { url ->
+ assertIs(PubkyAuthRequest.parseSignup(url).exceptionOrNull())
+ }
+ }
+
@Test
fun `parse recognizes watch-only account claim`() {
val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES
@@ -50,6 +97,7 @@ class PubkyAuthRequestTest {
capabilities = "/pub/bitkit.to/:rw",
).getOrThrow()
+ assertFalse(request.isSignup)
assertEquals("paykit.test", request.clientId)
assertNull(request.bitkitClaim)
}
@@ -261,4 +309,14 @@ class PubkyAuthRequestTest {
}
return "pubkyauth://signin?caps=$capabilities&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F$claims"
}
+
+ private fun ringSignupUrl(signupToken: String? = null, scheme: String = "pubkyring"): String =
+ "$scheme://signup?hs=homeserver" +
+ "&relay=https%3A%2F%2Frelay.example%2Finbox%2F" +
+ "&secret=secret&caps=%2Fpub%2Fexample.app%2F%3Arw" +
+ signupToken?.let { "&st=${URLEncoder.encode(it, Charsets.UTF_8.name())}" }.orEmpty()
+
+ private fun directSignupUrl(action: String, signupToken: String? = null): String =
+ "pubkyauth://$action?hs=homeserver" +
+ signupToken?.let { "&st=${URLEncoder.encode(it, Charsets.UTF_8.name())}" }.orEmpty()
}
diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
index 444a811df9..547bf1e47c 100644
--- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
+++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
@@ -9,6 +9,7 @@ import com.synonym.paykit.ContactProfileSource
import com.synonym.paykit.ContactRecord
import com.synonym.paykit.PaykitProfile
import com.synonym.paykit.PubkyAuthCompanionClaim
+import com.synonym.paykit.PubkySessionBootstrapResult
import com.synonym.paykit.PublicationStatus
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
@@ -20,6 +21,8 @@ import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
@@ -40,12 +43,15 @@ import to.bitkit.data.PubkyStoreData
import to.bitkit.data.SettingsData
import to.bitkit.data.SettingsStore
import to.bitkit.data.keychain.Keychain
+import to.bitkit.ext.runSuspendCatching
import to.bitkit.models.PubkyAuthClaim
+import to.bitkit.models.PubkyAuthRequest
import to.bitkit.models.PubkyProfile
import to.bitkit.models.PubkyRingAuthCallback
import to.bitkit.models.PubkyRingAuthCallbackHandlingResult
import to.bitkit.models.PubkySessionBackupKind
import to.bitkit.models.PubkySessionBackupV1
+import to.bitkit.services.PubkyRingAuthTimeoutError
import to.bitkit.services.PubkyService
import to.bitkit.test.BaseUnitTest
import to.bitkit.utils.AppError
@@ -74,12 +80,18 @@ class PubkyRepoTest : BaseUnitTest() {
private val pubkyStore = mock()
private val settingsStore = mock()
private val settingsFlow = MutableStateFlow(SettingsData())
+ private val profileSetupPending = MutableStateFlow(false)
@Before
fun setUp() = runBlocking {
settingsFlow.value = SettingsData()
whenever(pubkyStore.data).thenReturn(flowOf(PubkyStoreData()))
whenever(settingsStore.data).thenReturn(settingsFlow)
+ whenever(settingsStore.isPubkyProfileSetupPending).thenReturn(profileSetupPending)
+ whenever { settingsStore.setPubkyProfileSetupPending(any()) }.thenAnswer {
+ profileSetupPending.value = it.getArgument(0)
+ Unit
+ }
whenever(pubkyService.contactRecords()).thenReturn(emptyList())
whenever { settingsStore.update(any()) }.thenAnswer {
val transform = it.getArgument<(SettingsData) -> SettingsData>(0)
@@ -105,6 +117,137 @@ class PubkyRepoTest : BaseUnitTest() {
assertFalse(sut.isAuthenticated.value)
}
+ @Test
+ fun `Ring signup registers and authorizes before activating the local session`() = test {
+ val events = mutableListOf()
+ val registeredSession = mock()
+ val request = ringSignupRequest()
+ stubSignupKeys()
+ whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenAnswer {
+ events += "register"
+ registeredSession
+ }
+ whenever(pubkyService.approveRingAuth(requireNotNull(request.authorizationUrl), "secret")).thenAnswer {
+ events += "authorize"
+ }
+ whenever(pubkyService.activateRegisteredIdentity(registeredSession)).thenAnswer { events += "activate" }
+
+ val result = sut.approveSignupAuth(request)
+
+ assertTrue(result.isSuccess)
+ assertEquals(listOf("register", "authorize", "activate"), events)
+ verifyBlocking(pubkyService, never()) { signIn(any()) }
+ assertTrue(profileSetupPending.value)
+ assertEquals(VALID_SELF_KEY, sut.publicKey.value)
+ }
+
+ @Test
+ fun `Ring signup does not activate the registered session when authorization fails`() = test {
+ val registeredSession = mock()
+ val request = ringSignupRequest()
+ stubSignupKeys()
+ whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession)
+ whenever(pubkyService.approveRingAuth(requireNotNull(request.authorizationUrl), "secret"))
+ .thenThrow(IllegalStateException("authorization failed"))
+
+ assertTrue(sut.approveSignupAuth(request).isFailure)
+ verifyBlocking(pubkyService, never()) { activateRegisteredIdentity(any()) }
+ assertFalse(profileSetupPending.value)
+ assertNull(sut.publicKey.value)
+ }
+
+ @Test
+ fun `Ring signup can retry after relay timeout without activating the timed out session`() = test {
+ val registeredSession = mock()
+ val request = ringSignupRequest()
+ stubSignupKeys()
+ whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession)
+ whenever(pubkyService.approveRingAuth(requireNotNull(request.authorizationUrl), "secret"))
+ .thenAnswer { throw AppError(PubkyRingAuthTimeoutError()) }
+ .thenReturn(Unit)
+
+ assertTrue(sut.approveSignupAuth(request).isFailure)
+ verifyBlocking(pubkyService, never()) { activateRegisteredIdentity(any()) }
+ assertNull(sut.publicKey.value)
+ assertFalse(profileSetupPending.value)
+
+ assertTrue(sut.approveSignupAuth(request).isSuccess)
+ verifyBlocking(pubkyService) { activateRegisteredIdentity(registeredSession) }
+ assertEquals(VALID_SELF_KEY, sut.publicKey.value)
+ assertTrue(profileSetupPending.value)
+ }
+
+ @Test
+ fun `Ring signup clears profile setup state when local activation fails`() = test {
+ val registeredSession = mock()
+ val request = ringSignupRequest()
+ profileSetupPending.value = true
+ stubSignupKeys()
+ whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession)
+ whenever(pubkyService.activateRegisteredIdentity(registeredSession)).thenAnswer {
+ throw TestAppError("activation failed")
+ }
+
+ assertTrue(sut.approveSignupAuth(request).isFailure)
+ assertFalse(profileSetupPending.value)
+ assertNull(sut.publicKey.value)
+ }
+
+ @Test
+ fun `direct signup skips app authorization and activates the registered session`() = test {
+ val registeredSession = mock()
+ val request = directSignupRequest()
+ stubSignupKeys()
+ whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession)
+
+ assertTrue(sut.approveSignupAuth(request).isSuccess)
+ verifyBlocking(pubkyService, never()) { approveRingAuth(any(), any(), any()) }
+ verifyBlocking(pubkyService) { activateRegisteredIdentity(registeredSession) }
+ assertTrue(profileSetupPending.value)
+ assertEquals(VALID_SELF_KEY, sut.publicKey.value)
+ }
+
+ @Test
+ fun `Ring signup stops when registration fails`() = test {
+ val request = ringSignupRequest()
+ stubSignupKeys()
+ whenever(pubkyService.registerIdentity("secret", "homeserver", "invite"))
+ .thenThrow(IllegalStateException("registration failed"))
+
+ assertTrue(sut.approveSignupAuth(request).isFailure)
+ verifyBlocking(pubkyService, never()) { approveRingAuth(any(), any(), any()) }
+ verifyBlocking(pubkyService, never()) { activateRegisteredIdentity(any()) }
+ assertFalse(profileSetupPending.value)
+ }
+
+ @Test
+ fun `Ring signup clears credentials when pending setup persistence and rollback fail`() = test {
+ stubSignupKeys()
+ whenever(pubkyService.registerIdentity("secret", "homeserver", "invite"))
+ .thenReturn(mock())
+ whenever(settingsStore.setPubkyProfileSetupPending(true)).thenAnswer {
+ throw TestAppError("persistence failed")
+ }
+ whenever(pubkyService.forgetSessionAccess()).thenAnswer {
+ throw TestAppError("cleanup failed")
+ }
+
+ assertTrue(sut.approveSignupAuth(ringSignupRequest()).isFailure)
+
+ verify(keychain).delete(Keychain.Key.PAYKIT_SESSION.name)
+ verify(keychain).delete(Keychain.Key.PUBKY_SECRET_KEY.name)
+ assertNull(sut.publicKey.value)
+ assertFalse(sut.isAuthenticated.value)
+ assertFalse(profileSetupPending.value)
+ }
+
+ @Test
+ fun `identity check fails closed when secure storage cannot be read`() = test {
+ whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenThrow(IllegalStateException("unavailable"))
+
+ assertTrue(runSuspendCatching { sut.hasIdentity() }.isFailure)
+ }
+
@Test
fun `startAuthentication should return auth uri on success`() = test {
val authUri = "pubky://auth?capabilities=..."
@@ -537,6 +680,186 @@ class PubkyRepoTest : BaseUnitTest() {
verifyBlocking(pubkyService) { forgetSessionAccess() }
}
+ @Test
+ fun `createIdentity clears stale pending signup without a session`() = test {
+ profileSetupPending.value = true
+ whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null)
+
+ val result = sut.createIdentity("Test", "", emptyList(), emptyList(), null)
+
+ assertTrue(result.isFailure)
+ assertFalse(profileSetupPending.value)
+ verify(keychain).loadString(Keychain.Key.BIP39_MNEMONIC.name)
+ verifyBlocking(pubkyService, never()) { publishPaykitProfile(any()) }
+ }
+
+ @Test
+ fun `createIdentity restores a stored local key without Homegate signup`() = test {
+ val httpClient = identityHttpClient()
+ sut = createSut(httpClient)
+ profileSetupPending.value = true
+ whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local-secret")
+ whenever(pubkyService.publicKeyFromSecret("local-secret")).thenReturn(VALID_SELF_KEY.removePrefix("pubky"))
+ whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock())
+
+ val result = sut.createIdentity("Restored", "", emptyList(), emptyList(), null)
+
+ assertTrue(result.isSuccess)
+ assertEquals(VALID_SELF_KEY, sut.publicKey.value)
+ assertEquals("Restored", sut.profile.value?.name)
+ assertFalse(profileSetupPending.value)
+ assertTrue((httpClient.engine as MockEngine).requestHistory.isEmpty())
+ verifyBlocking(pubkyService) { signIn("local-secret") }
+ verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) }
+ verify(keychain, never()).loadString(Keychain.Key.BIP39_MNEMONIC.name)
+ httpClient.close()
+ }
+
+ @Test
+ fun `createIdentity stops when the local key cannot be read`() = test {
+ whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenAnswer {
+ throw TestAppError("unavailable")
+ }
+
+ val result = sut.createIdentity("Test", "", emptyList(), emptyList(), null)
+
+ assertEquals("unavailable", result.exceptionOrNull()?.message)
+ verify(keychain, never()).loadString(Keychain.Key.BIP39_MNEMONIC.name)
+ verifyBlocking(pubkyService, never()) { signIn(any()) }
+ verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) }
+ verifyBlocking(pubkyService, never()) { publishPaykitProfile(any()) }
+ }
+
+ @Test
+ fun `createIdentity retries local sign in without deleting the existing identity`() = test {
+ whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local-secret")
+ whenever(pubkyService.publicKeyFromSecret("local-secret")).thenReturn(VALID_SELF_KEY)
+ whenever(pubkyService.signIn("local-secret")).thenAnswer { throw TestAppError("offline") }.thenReturn(Unit)
+ whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock())
+
+ val firstResult = sut.createIdentity("Test", "", emptyList(), emptyList(), null)
+
+ assertEquals("offline", firstResult.exceptionOrNull()?.message)
+ assertNull(sut.publicKey.value)
+ verifyBlocking(pubkyService, never()) { publishPaykitProfile(any()) }
+
+ assertTrue(sut.createIdentity("Test", "", emptyList(), emptyList(), null).isSuccess)
+ assertEquals(VALID_SELF_KEY, sut.publicKey.value)
+ verifyBlocking(pubkyService, times(2)) { signIn("local-secret") }
+ verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) }
+ verifyBlocking(pubkyService, never()) { signOut() }
+ verifyBlocking(pubkyService, never()) { forgetSessionAccess() }
+ verify(keychain, never()).delete(Keychain.Key.PUBKY_SECRET_KEY.name)
+ }
+
+ @Test
+ fun `createIdentity preserves the existing identity when profile publication fails`() = test {
+ authenticateForTesting(publicKey = VALID_SELF_KEY)
+ val existingProfile = sut.profile.value
+ whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local-secret")
+ whenever(pubkyService.publicKeyFromSecret("local-secret")).thenReturn(VALID_SELF_KEY)
+ whenever(pubkyService.publishPaykitProfile(any()))
+ .thenAnswer { throw TestAppError("offline") }
+ .thenReturn(mock())
+
+ val firstResult = sut.createIdentity("Updated", "", emptyList(), emptyList(), null)
+
+ assertEquals("offline", firstResult.exceptionOrNull()?.message)
+ assertEquals(VALID_SELF_KEY, sut.publicKey.value)
+ assertEquals(existingProfile, sut.profile.value)
+ assertTrue(sut.isAuthenticated.value)
+
+ assertTrue(sut.createIdentity("Updated", "", emptyList(), emptyList(), null).isSuccess)
+ verifyBlocking(pubkyService, times(2)) { signIn("local-secret") }
+ verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) }
+ verifyBlocking(pubkyService, never()) { signOut() }
+ verifyBlocking(pubkyService, never()) { forgetSessionAccess() }
+ verify(keychain, never()).delete(Keychain.Key.PUBKY_SECRET_KEY.name)
+ }
+
+ @Test
+ fun `createIdentity preserves local recovery after cancellation`() = test {
+ whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local-secret")
+ whenever(pubkyService.publicKeyFromSecret("local-secret")).thenReturn(VALID_SELF_KEY)
+ var cancelDuringSignIn: Boolean? = true
+ var operationStarted = CompletableDeferred()
+ whenever(pubkyService.signIn("local-secret")).doSuspendableAnswer {
+ if (cancelDuringSignIn == true) {
+ operationStarted.complete(Unit)
+ awaitCancellation()
+ }
+ Unit
+ }
+ whenever(pubkyService.publishPaykitProfile(any())).doSuspendableAnswer {
+ if (cancelDuringSignIn == false) {
+ operationStarted.complete(Unit)
+ awaitCancellation()
+ }
+ mock()
+ }
+ for (duringSignIn in listOf(true, false)) {
+ cancelDuringSignIn = duringSignIn
+ operationStarted = CompletableDeferred()
+
+ val result = async { sut.createIdentity("Test", "", emptyList(), emptyList(), null) }
+ operationStarted.await()
+ result.cancelAndJoin()
+
+ assertTrue(result.isCancelled)
+ assertNull(sut.publicKey.value)
+ }
+
+ cancelDuringSignIn = null
+ assertTrue(sut.createIdentity("Test", "", emptyList(), emptyList(), null).isSuccess)
+ verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) }
+ verifyBlocking(pubkyService, never()) { signOut() }
+ verifyBlocking(pubkyService, never()) { forgetSessionAccess() }
+ verify(keychain, never()).delete(Keychain.Key.PUBKY_SECRET_KEY.name)
+ }
+
+ @Test
+ fun `createIdentity signs up when a Ring session has no local key`() = test {
+ val httpClient = identityHttpClient()
+ sut = createSut(httpClient)
+ stubSignupKeys()
+ whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("ring-session")
+ whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("")
+ whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock())
+
+ val result = sut.createIdentity("Test", "", emptyList(), emptyList(), null)
+
+ assertTrue(result.isSuccess)
+ assertEquals(VALID_SELF_KEY, sut.publicKey.value)
+ verifyBlocking(pubkyService) { signUp("secret", "test-homeserver", "test-code") }
+ verifyBlocking(pubkyService, never()) { signIn(any()) }
+ httpClient.close()
+ }
+
+ @Test
+ fun `createIdentity should preserve signup session when pending profile publication fails`() = test {
+ val registeredSession = mock()
+ stubSignupKeys()
+ whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession)
+ assertTrue(sut.approveSignupAuth(ringSignupRequest()).isSuccess)
+ clearInvocations(pubkyService)
+ whenever(pubkyService.publishPaykitProfile(any())).thenAnswer { throw TestAppError("Publish failed") }
+
+ val result = sut.createIdentity(
+ name = "Test",
+ bio = "",
+ links = emptyList(),
+ tags = emptyList(),
+ avatarBytes = null,
+ )
+
+ assertTrue(result.isFailure)
+ verifyBlocking(pubkyService) { publishPaykitProfile(any()) }
+ verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) }
+ verifyBlocking(pubkyService, never()) { signIn(any()) }
+ verifyBlocking(pubkyService, never()) { signOut() }
+ assertTrue(profileSetupPending.value)
+ }
+
@Test
fun `createIdentity should keep session when canceled during contact load`() = test {
val contactsLoadStarted = CompletableDeferred()
@@ -972,6 +1295,36 @@ class PubkyRepoTest : BaseUnitTest() {
assertNull(result.getOrNull())
}
+ @Test
+ fun `awaitInitialization shares startup and preserves it when a waiter is cancelled`() = test {
+ val imported = CompletableDeferred()
+ whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session")
+ whenever(pubkyService.importSession("saved_session")).doSuspendableAnswer { imported.await() }
+ val repo = createSut()
+ val cancelledWaiter = async { repo.awaitInitialization() }
+ val waiter = async { repo.awaitInitialization() }
+
+ assertFalse(waiter.isCompleted)
+ assertNull(repo.publicKey.value)
+ cancelledWaiter.cancelAndJoin()
+ imported.complete(VALID_SELF_KEY)
+ waiter.await()
+
+ assertEquals(VALID_SELF_KEY, repo.publicKey.value)
+ verify(pubkyService).importSession("saved_session")
+ }
+
+ @Test
+ fun `awaitInitialization completes without identity after startup failure`() = test {
+ whenever(pubkyService.initialize()).thenAnswer { throw TestAppError("Startup failed") }
+ val repo = createSut()
+
+ repo.awaitInitialization()
+
+ assertNull(repo.publicKey.value)
+ verify(pubkyService, never()).importSession(any())
+ }
+
@Test
fun `initialize should restore saved session with prefixed public key`() = test {
val session = "saved_session"
@@ -1563,6 +1916,21 @@ class PubkyRepoTest : BaseUnitTest() {
status = status,
)
+ private suspend fun stubSignupKeys() {
+ whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("seed words")
+ whenever(pubkyService.deriveSecretKey("seed words")).thenReturn("secret")
+ whenever(pubkyService.publicKeyFromSecret("secret")).thenReturn(VALID_SELF_KEY)
+ }
+
+ private fun ringSignupRequest() = PubkyAuthRequest.parseSignup(
+ "pubkyring://signup?hs=homeserver&relay=https%3A%2F%2Frelay.example" +
+ "&secret=request&caps=%2Fpub%2Fexample%2F%3Arw&st=invite",
+ ).getOrThrow()
+
+ private fun directSignupRequest() = PubkyAuthRequest.parseSignup(
+ "pubkyauth://direct_signup?hs=homeserver&st=invite",
+ ).getOrThrow()
+
private fun createPaykitProfile(
name: String,
bio: String = "",
diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt
index 5a74b7f34c..005e78b71e 100644
--- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt
+++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt
@@ -2,16 +2,29 @@ package to.bitkit.services
import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy
import com.synonym.paykit.EndpointManagementScope
+import com.synonym.paykit.PaykitSdk
import com.synonym.paykit.PubkyClientConfig
+import com.synonym.paykit.PubkyLocalSecretKey
+import com.synonym.paykit.PubkySessionAccess
+import com.synonym.paykit.PubkySessionBootstrapResult
import com.synonym.paykit.PublicContactSharingPolicy
+import com.synonym.paykit.ReceiverNoiseSecretKey
+import kotlinx.coroutines.test.runTest
import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.atLeastOnce
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.inOrder
import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import to.bitkit.data.keychain.Keychain
import to.bitkit.ext.fromHex
import to.bitkit.ext.toHex
import to.bitkit.models.PubkyAuthRequestError
import to.bitkit.utils.AppError
+import kotlin.coroutines.cancellation.CancellationException
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
@@ -19,6 +32,66 @@ import kotlin.test.assertNull
import kotlin.test.assertTrue
class PaykitSdkServiceTest {
+ @Test
+ fun `registered identity activation persists credentials or clears partial activation`() = runTest {
+ for (failure in listOf(null, "session", "secret", "initialize", "cancel")) {
+ val keychain = mock()
+ val blocking = mock()
+ whenever(keychain.accessBlocking(any())).doAnswer {
+ it.getArgument Any?>(0).invoke(blocking)
+ }
+ val bytes = ByteArray(32) { 1 }
+ whenever(blocking.load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name)).thenReturn(bytes)
+ val sdk = mock()
+ whenever(sdk.contactRecords()).thenReturn(emptyList())
+ val access = mock()
+ val secret = mock()
+ val noise = mock()
+ whenever(secret.exportBytes()).thenReturn(bytes)
+ whenever(noise.exportBytes()).thenReturn(bytes)
+ whenever(access.exportSessionSecret()).thenReturn("new-session")
+ whenever(access.exportLocalSecretKey()).thenReturn(secret)
+ whenever(access.exportReceiverNoiseSecretKey()).thenReturn(noise)
+ val error = if (failure == "cancel") {
+ CancellationException("cancelled")
+ } else {
+ IllegalStateException("activation failed")
+ }
+ when (failure) {
+ "session" -> whenever(keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, "new-session"))
+ .thenThrow(error)
+ "secret" -> whenever(keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, bytes.toHex()))
+ .thenThrow(error)
+ "initialize", "cancel" -> whenever(sdk.initialize()).thenThrow(error)
+ }
+ var handlesCreated = 0
+ val service = PaykitSdkService(mock(), keychain) {
+ handlesCreated++
+ sdk
+ }
+ val result = PubkySessionBootstrapResult(access, "pubky_test")
+
+ if (failure == null) {
+ service.activateRegisteredIdentity(result)
+ inOrder(keychain, sdk) {
+ verify(keychain).upsertString(Keychain.Key.PAYKIT_SESSION.name, "new-session")
+ verify(keychain).upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, bytes.toHex())
+ verify(sdk).initialize()
+ }
+ verify(blocking, never()).delete(any())
+ } else {
+ val thrown = assertFailsWith(error::class) { service.activateRegisteredIdentity(result) }
+ assertEquals(error, thrown)
+ verify(blocking).delete(Keychain.Key.PAYKIT_SESSION.name)
+ verify(blocking).delete(Keychain.Key.PUBKY_SECRET_KEY.name)
+ verify(keychain, atLeastOnce()).delete(Keychain.Key.PAYKIT_SDK_STATE.name)
+ val handlesBeforeReload = handlesCreated
+ service.contactRecords()
+ assertEquals(handlesBeforeReload + 1, handlesCreated)
+ }
+ }
+ }
+
private val basePubkyClientConfig = PubkyClientConfig(
requestTimeoutSecs = 30uL,
localTestnetHost = null,
diff --git a/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt
index a663b8bac7..0dada70974 100644
--- a/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt
+++ b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt
@@ -1,29 +1,41 @@
package to.bitkit.services
+import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runCurrent
import org.junit.Before
import org.junit.Test
+import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.clearInvocations
import org.mockito.kotlin.doNothing
+import org.mockito.kotlin.doSuspendableAnswer
import org.mockito.kotlin.doThrow
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
import to.bitkit.data.SettingsStore
import to.bitkit.repositories.PubkyRepo
import to.bitkit.test.BaseUnitTest
import kotlin.test.assertFalse
import kotlin.test.assertTrue
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34], qualifiers = "en-rUS")
@OptIn(ExperimentalCoroutinesApi::class)
class PubkyAuthHandlerRegistrarTest : BaseUnitTest() {
+ private companion object {
+ const val PACKAGE_NAME = "to.bitkit"
+ }
+
private val context: Context = mock()
private val packageManager: PackageManager = mock()
private val pubkyRepo: PubkyRepo = mock()
@@ -39,6 +51,24 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() {
whenever(pubkyRepo.publicKey).thenReturn(publicKey)
}
+ @Test
+ fun `handler preserves component states until initial identity load finishes`() = test {
+ isPaykitEnabled.value = true
+ val initialized = CompletableDeferred()
+ whenever(pubkyRepo.awaitInitialization()).doSuspendableAnswer { initialized.await() }
+ whenever(pubkyRepo.hasSecretKey()).thenReturn(true)
+
+ createSut().start(backgroundScope)
+ runCurrent()
+
+ verify(packageManager, never()).setComponentEnabledSetting(any(), any(), any())
+ publicKey.value = "pubkylocal"
+ initialized.complete(Unit)
+ runCurrent()
+
+ verifyComponentStates(authEnabled = true, signupEnabled = false)
+ }
+
@Test
fun `handler is enabled for an available locally managed identity`() = test {
isPaykitEnabled.value = true
@@ -48,7 +78,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() {
createSut().start(backgroundScope)
runCurrent()
- verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_ENABLED)
+ verifyComponentStates(authEnabled = true, signupEnabled = false)
assertTrue(
canHandlePubkyAuth(
isPaykitUiEnabled = true,
@@ -70,14 +100,21 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() {
}
@Test
- fun `handler is disabled without an identity`() = test {
+ fun `only signup handler is enabled without an identity`() = test {
isPaykitEnabled.value = true
createSut().start(backgroundScope)
runCurrent()
- verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED)
+ verifyComponentStates(authEnabled = false, signupEnabled = true)
verify(pubkyRepo, never()).hasSecretKey()
+
+ clearInvocations(packageManager)
+ whenever(pubkyRepo.hasSecretKey()).thenReturn(true)
+ publicKey.value = "pubkylocal"
+ runCurrent()
+
+ verifyComponentStates(authEnabled = true, signupEnabled = false)
}
@Test
@@ -89,11 +126,11 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() {
createSut().start(backgroundScope)
runCurrent()
- verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED)
+ verifyComponentStates(authEnabled = false, signupEnabled = false)
}
@Test
- fun `handler is disabled when the local identity is removed`() = test {
+ fun `authorization handler switches to signup when the local identity is removed`() = test {
isPaykitEnabled.value = true
publicKey.value = "pubkylocal"
whenever(pubkyRepo.hasSecretKey()).thenReturn(true)
@@ -104,7 +141,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() {
publicKey.value = null
runCurrent()
- verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED)
+ verifyComponentStates(authEnabled = false, signupEnabled = true)
}
@Test
@@ -119,7 +156,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() {
isPaykitEnabled.value = false
runCurrent()
- verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED)
+ verifyComponentStates(authEnabled = false, signupEnabled = false)
}
@Test
@@ -130,7 +167,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() {
sut.start(backgroundScope)
runCurrent()
- verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED)
+ verifyComponentStates(authEnabled = false, signupEnabled = false)
}
@Test
@@ -145,11 +182,12 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() {
createSut().start(backgroundScope)
runCurrent()
+ clearInvocations(packageManager)
isPaykitEnabled.value = false
runCurrent()
- verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED)
+ verifyComponentStates(authEnabled = false, signupEnabled = false)
}
private fun createSut() = PubkyAuthHandlerRegistrar(
@@ -159,15 +197,21 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() {
ioDispatcher = testDispatcher,
)
- private fun verifyComponentState(state: Int) {
- verify(packageManager).setComponentEnabledSetting(
- any(),
- eq(state),
- eq(PackageManager.DONT_KILL_APP),
- )
- }
-
- private companion object {
- const val PACKAGE_NAME = "to.bitkit"
+ private fun verifyComponentStates(authEnabled: Boolean, signupEnabled: Boolean) {
+ mapOf(
+ "to.bitkit.ui.MainActivityPubkyAuth" to authEnabled,
+ "to.bitkit.ui.MainActivityPubkySignup" to signupEnabled,
+ ).forEach { (className, enabled) ->
+ val state = if (enabled) {
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED
+ } else {
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED
+ }
+ verify(packageManager).setComponentEnabledSetting(
+ eq(ComponentName(PACKAGE_NAME, className)),
+ eq(state),
+ eq(PackageManager.DONT_KILL_APP),
+ )
+ }
}
}
diff --git a/app/src/test/java/to/bitkit/services/PubkyServiceTest.kt b/app/src/test/java/to/bitkit/services/PubkyServiceTest.kt
new file mode 100644
index 0000000000..b7b66bc2dd
--- /dev/null
+++ b/app/src/test/java/to/bitkit/services/PubkyServiceTest.kt
@@ -0,0 +1,78 @@
+package to.bitkit.services
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.delay
+import org.junit.Test
+import org.mockito.Mockito.mockStatic
+import org.mockito.kotlin.mock
+import to.bitkit.async.ServiceQueue
+import to.bitkit.ext.runSuspendCatching
+import to.bitkit.test.BaseUnitTest
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+class PubkyServiceTest : BaseUnitTest() {
+ @Test
+ fun `relay timeout cancels the queued call and allows another approval`() = test {
+ ServiceQueue.CORE.background {
+ val binding = Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")
+ val approve = binding.getMethod(
+ "approvePubkyAuth",
+ String::class.java,
+ String::class.java,
+ Continuation::class.java,
+ )
+ val sut = PubkyService(mock())
+ var cancelled = false
+ mockStatic(binding).use { native ->
+ native.`when` { approve.invoke(null, "auth", "secret", null) }.thenAnswer {
+ @Suppress("UNCHECKED_CAST")
+ val continuation = it.rawArguments.last() as Continuation
+ suspend {
+ try {
+ delay(1.seconds)
+ } finally {
+ cancelled = true
+ }
+ }.startCoroutineUninterceptedOrReturn(continuation)
+ }.thenReturn(Unit)
+
+ val result = runSuspendCatching { sut.approveRingAuth("auth", "secret", 50.milliseconds) }
+
+ assertIs(result.exceptionOrNull()?.cause)
+ assertTrue(cancelled)
+ sut.approveRingAuth("auth", "secret", 50.milliseconds)
+ }
+ }
+ }
+
+ @Test
+ fun `relay cancellation propagates without becoming a timeout failure`() = test {
+ ServiceQueue.CORE.background {
+ val binding = Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")
+ val approve = binding.getMethod(
+ "approvePubkyAuth",
+ String::class.java,
+ String::class.java,
+ Continuation::class.java,
+ )
+ val cancellation = CancellationException("cancelled")
+ mockStatic(binding).use { native ->
+ native.`when` { approve.invoke(null, "auth", "secret", null) }.thenThrow(cancellation)
+
+ assertEquals(
+ cancellation.javaClass,
+ assertFailsWith {
+ PubkyService(mock()).approveRingAuth("auth", "secret", 50.milliseconds)
+ }.javaClass,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt
index df3a1e3891..367b06b885 100644
--- a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt
+++ b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt
@@ -15,6 +15,28 @@ import kotlin.test.assertTrue
@Config(sdk = [34])
@RunWith(RobolectricTestRunner::class)
class ContentViewTest {
+ @Test
+ fun `pending profile opens once and rearms after completion or cold start`() {
+ val navigation = PubkyProfileSetupNavigation()
+
+ assertTrue(navigation.shouldNavigate(true, true, true, true))
+ assertFalse(navigation.shouldNavigate(true, true, true, false))
+ assertFalse(navigation.shouldNavigate(true, true, true, true))
+ assertFalse(navigation.shouldNavigate(true, false, true, true))
+ assertTrue(navigation.shouldNavigate(true, true, true, true))
+ assertTrue(PubkyProfileSetupNavigation().shouldNavigate(true, true, true, true))
+ }
+
+ @Test
+ fun `pending profile waits for auth feature and sheet gates`() {
+ val navigation = PubkyProfileSetupNavigation()
+
+ assertFalse(navigation.shouldNavigate(false, true, true, true))
+ assertFalse(navigation.shouldNavigate(true, true, false, true))
+ assertFalse(navigation.shouldNavigate(true, true, true, false))
+ assertTrue(navigation.shouldNavigate(true, true, true, true))
+ }
+
@Test
fun `spending start route uses intro until seen`() {
assertEquals(Routes.SpendingIntro, transferSpendingStartRoute(hasSeenSpendingIntro = false))
diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalRetryTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalRetryTest.kt
new file mode 100644
index 0000000000..c481ecac23
--- /dev/null
+++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalRetryTest.kt
@@ -0,0 +1,67 @@
+package to.bitkit.ui.screens.profile
+
+import android.content.Context
+import app.cash.turbine.test
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.advanceUntilIdle
+import org.junit.Test
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verifyBlocking
+import org.mockito.kotlin.whenever
+import to.bitkit.R
+import to.bitkit.models.PubkyAuthRequest
+import to.bitkit.models.PubkyProfile
+import to.bitkit.repositories.PubkyRepo
+import to.bitkit.services.PubkyRingAuthTimeoutError
+import to.bitkit.test.BaseUnitTest
+import to.bitkit.utils.AppError
+import kotlin.test.assertEquals
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class PubkyAuthApprovalRetryTest : BaseUnitTest() {
+ private val context: Context = mock()
+ private val pubkyRepo: PubkyRepo = mock {
+ on { profile } doReturn MutableStateFlow(null)
+ on { publicKey } doReturn MutableStateFlow(null)
+ on { displayName } doReturn MutableStateFlow(null)
+ on { displayImageUri } doReturn MutableStateFlow(null)
+ }
+
+ @Test
+ fun `relay timeout restores consent and requires local auth for retry`() = test {
+ whenever(context.getString(R.string.profile__auth_approval_service_unknown)).thenReturn("Unknown service")
+ whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization failed")
+ val authUrl = "pubkyring://signup?hs=homeserver" +
+ "&relay=https://relay.example/inbox/&secret=secret&caps=/pub/example/:rw"
+ val request = PubkyAuthRequest.parseSignup(authUrl).getOrThrow()
+ whenever(context.getString(R.string.profile__auth_error_timeout)).thenReturn("Relay timed out")
+ whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request))
+ whenever(pubkyRepo.approveSignupAuth(request)).thenReturn(
+ Result.failure(AppError(PubkyRingAuthTimeoutError())),
+ Result.success(Unit),
+ )
+ val sut = PubkyAuthApprovalViewModel(context, pubkyRepo, mock())
+
+ sut.effects.test {
+ sut.load(authUrl)
+ advanceUntilIdle()
+ sut.requestAuthorize(authUrl)
+ assertEquals(PubkyAuthApprovalEffect.RequestLocalAuth(authUrl), awaitItem())
+ sut.confirmAuthorize(authUrl)
+ advanceUntilIdle()
+ assertEquals(ApprovalState.Authorize, sut.uiState.value.state)
+
+ sut.requestAuthorize(authUrl)
+ assertEquals(PubkyAuthApprovalEffect.RequestLocalAuth(authUrl), awaitItem())
+ assertEquals(ApprovalState.Authenticating, sut.uiState.value.state)
+ verifyBlocking(pubkyRepo) { approveSignupAuth(request) }
+ sut.confirmAuthorize(authUrl)
+ advanceUntilIdle()
+ assertEquals(PubkyAuthApprovalEffect.Dismiss, awaitItem())
+ verifyBlocking(pubkyRepo, times(2)) { approveSignupAuth(request) }
+ }
+ }
+}
diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt
index bc6617bd8e..4a1184dddd 100644
--- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt
+++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt
@@ -1,6 +1,8 @@
package to.bitkit.ui.screens.profile
import android.content.Context
+import android.util.Log
+import app.cash.turbine.test
import com.synonym.paykit.PubkyAuthCompanionClaimApprovalException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -9,11 +11,16 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import org.junit.Before
import org.junit.Test
+import org.mockito.Mockito.mockStatic
import org.mockito.kotlin.any
+import org.mockito.kotlin.argThat
+import org.mockito.kotlin.clearInvocations
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.doSuspendableAnswer
+import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
+import org.mockito.kotlin.same
import org.mockito.kotlin.times
import org.mockito.kotlin.verifyBlocking
import org.mockito.kotlin.whenever
@@ -25,6 +32,7 @@ import to.bitkit.models.PubkyAuthRequest
import to.bitkit.models.PubkyProfile
import to.bitkit.models.WatchOnlyAccountRecord
import to.bitkit.models.WatchOnlyAccountSetupState
+import to.bitkit.repositories.PubkyAlreadySignedInError
import to.bitkit.repositories.PubkyRepo
import to.bitkit.repositories.WatchOnlyAccountAuthorizationStartError
import to.bitkit.repositories.WatchOnlyAccountRepo
@@ -104,6 +112,43 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() {
verifyBlocking(pubkyRepo, never()) { approveAuth(staleAuthUrl, "/pub/current/:rw", clientId) }
}
+ @Test
+ fun `superseded signup failure is logged without changing the current request`() = test {
+ val authUrl = "pubkyauth://direct_signup?hs=homeserver"
+ val currentAuthUrl = "pubkyauth://direct_signup?hs=current-homeserver"
+ val request = PubkyAuthRequest.parseSignup(authUrl).getOrThrow()
+ val currentRequest = PubkyAuthRequest.parseSignup(currentAuthUrl).getOrThrow()
+ val approvalResult = CompletableDeferred>()
+ val error = AppError("Signup approval failed")
+ whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request))
+ whenever(pubkyRepo.parseAuthUrl(currentAuthUrl)).thenReturn(Result.success(currentRequest))
+ whenever(pubkyRepo.approveSignupAuth(request)).doSuspendableAnswer { approvalResult.await() }
+ val sut = createSut()
+
+ mockStatic(Log::class.java).use { log ->
+ sut.load(authUrl)
+ advanceUntilIdle()
+ sut.confirmAuthorize(authUrl)
+ runCurrent()
+ assertEquals(ApprovalState.Authorizing, sut.uiState.value.state)
+ verifyBlocking(pubkyRepo) { approveSignupAuth(request) }
+
+ sut.load(currentAuthUrl)
+ advanceUntilIdle()
+ sut.requestAuthorize(currentAuthUrl)
+ runCurrent()
+ val currentState = sut.uiState.value
+ assertEquals(currentAuthUrl, currentState.authUrl)
+ assertEquals(ApprovalState.Authenticating, currentState.state)
+
+ approvalResult.complete(Result.failure(error))
+ advanceUntilIdle()
+
+ log.verify { Log.e(eq("APP"), argThat { contains("PubkyAuthApprovalVM") }, same(error)) }
+ assertEquals(currentState, sut.uiState.value)
+ }
+ }
+
@Test
fun `confirmAuthorize reparses the current URL and fails closed when it changes`() = test {
val authUrl = "pubkyauth://signin?caps=/pub/current/:rw"
@@ -146,6 +191,107 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() {
verifyBlocking(watchOnlyAccountRepo, never()) { prepareUnsignedClaim(any(), any()) }
}
+ @Test
+ fun `signup requires consent and local auth before registration`() = test {
+ listOf("pubkyauth://direct_signup", "pubkyauth://signup", "pubkyring://signup").forEach { prefix ->
+ val authUrl = "$prefix?hs=homeserver" +
+ if (prefix.startsWith("pubkyring")) {
+ "&relay=https://relay.example/inbox/&secret=secret&caps=/pub/example/:rw"
+ } else {
+ ""
+ }
+ val request = PubkyAuthRequest.parseSignup(authUrl).getOrThrow()
+ whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request))
+ whenever(pubkyRepo.approveSignupAuth(request)).thenReturn(Result.success(Unit))
+ val sut = createSut()
+
+ sut.effects.test {
+ sut.load(authUrl)
+ advanceUntilIdle()
+ assertEquals("homeserver", sut.uiState.value.homeserverPublicKey)
+ verifyBlocking(pubkyRepo, never()) { approveSignupAuth(request) }
+
+ sut.requestAuthorize(authUrl)
+ advanceUntilIdle()
+ assertEquals(PubkyAuthApprovalEffect.RequestLocalAuth(authUrl), awaitItem())
+ val authenticatingState = sut.uiState.value
+ sut.load(authUrl)
+ sut.requestAuthorize(authUrl)
+ advanceUntilIdle()
+ assertEquals(authenticatingState, sut.uiState.value)
+ expectNoEvents()
+ verifyBlocking(pubkyRepo, never()) { approveSignupAuth(request) }
+ sut.cancelLocalAuth(authUrl)
+ assertEquals(ApprovalState.Authorize, sut.uiState.value.state)
+ sut.load(authUrl)
+ advanceUntilIdle()
+ assertEquals(ApprovalState.Authorize, sut.uiState.value.state)
+ verifyBlocking(pubkyRepo, never()) { approveSignupAuth(request) }
+
+ sut.requestAuthorize(authUrl)
+ advanceUntilIdle()
+ assertEquals(PubkyAuthApprovalEffect.RequestLocalAuth(authUrl), awaitItem())
+ sut.confirmAuthorize(authUrl)
+ advanceUntilIdle()
+ verifyBlocking(pubkyRepo) { approveSignupAuth(request) }
+ assertEquals(PubkyAuthApprovalEffect.Dismiss, awaitItem())
+ }
+ }
+ }
+
+ @Test
+ fun `terminal signup outcomes allow the same URL to reload for consent`() = test {
+ listOf(Result.success(Unit), Result.failure(PubkyAlreadySignedInError)).forEach { outcome ->
+ val authUrl = "pubkyauth://direct_signup?hs=homeserver"
+ val request = PubkyAuthRequest.parseSignup(authUrl).getOrThrow()
+ whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request))
+ whenever(pubkyRepo.approveSignupAuth(request)).thenReturn(outcome)
+ whenever(context.getString(R.string.pubky_auth__already_signed_in)).thenReturn("Already signed in")
+ val sut = createSut()
+
+ sut.effects.test {
+ sut.load(authUrl)
+ advanceUntilIdle()
+ sut.confirmAuthorize(authUrl)
+ advanceUntilIdle()
+ assertEquals(PubkyAuthApprovalEffect.Dismiss, awaitItem())
+ clearInvocations(pubkyRepo)
+
+ sut.load(authUrl)
+ advanceUntilIdle()
+
+ assertEquals(authUrl, sut.uiState.value.authUrl)
+ assertEquals(ApprovalState.Authorize, sut.uiState.value.state)
+ verifyBlocking(pubkyRepo) { parseAuthUrl(authUrl) }
+ verifyBlocking(pubkyRepo, never()) { approveSignupAuth(request) }
+ expectNoEvents()
+ sut.requestAuthorize(authUrl)
+ advanceUntilIdle()
+ assertEquals(PubkyAuthApprovalEffect.RequestLocalAuth(authUrl), awaitItem())
+ }
+ }
+ }
+
+ @Test
+ fun `Ring signup delegates registration and authorization to Pubky repository`() = test {
+ val authUrl = "pubkyring://signup?hs=homeserver"
+ val request = authRequest(
+ authUrl = authUrl,
+ capabilities = "/pub/example/:rw",
+ )
+ whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request))
+ whenever(pubkyRepo.approveSignupAuth(request)).thenReturn(Result.success(Unit))
+ val sut = createSut()
+
+ sut.load(authUrl)
+ advanceUntilIdle()
+ sut.confirmAuthorize(authUrl)
+ advanceUntilIdle()
+
+ verifyBlocking(pubkyRepo) { approveSignupAuth(request) }
+ verifyBlocking(pubkyRepo, never()) { approveAuth(any(), any(), any()) }
+ }
+
@Test
fun `load exposes watch-only account claim for approval`() = test {
val authUrl = "pubkyauth://signin?caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}"
@@ -551,6 +697,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() {
permissions = listOf(PubkyAuthPermission(path = "/pub/paykit/v0/bitkit/server/", accessLevel = "rw")),
serviceNames = listOf("paykit"),
bitkitClaim = bitkitClaim,
+ homeserverPublicKey = if (PubkyAuthRequest.isSignupUrl(authUrl)) "homeserver" else null,
)
private fun watchOnlyAccount() = WatchOnlyAccountRecord(
diff --git a/app/src/test/java/to/bitkit/ui/utils/PubkyAuthErrorMessageTest.kt b/app/src/test/java/to/bitkit/ui/utils/PubkyAuthErrorMessageTest.kt
new file mode 100644
index 0000000000..c8c5df8432
--- /dev/null
+++ b/app/src/test/java/to/bitkit/ui/utils/PubkyAuthErrorMessageTest.kt
@@ -0,0 +1,44 @@
+package to.bitkit.ui.utils
+
+import android.content.Context
+import com.synonym.bitkitcore.PubkyException
+import org.junit.Test
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import to.bitkit.R
+import to.bitkit.models.PubkyAuthRequestError
+import to.bitkit.repositories.WatchOnlyAccountError
+import to.bitkit.services.PubkyRingAuthTimeoutError
+import to.bitkit.test.BaseUnitTest
+import to.bitkit.utils.AppError
+import kotlin.test.assertEquals
+
+class PubkyAuthErrorMessageTest : BaseUnitTest() {
+ private val context: Context = mock()
+
+ @Test
+ fun `unmapped errors show a localized fallback instead of remote text`() {
+ val localizedMessage = "Localized unknown error"
+ whenever(context.getString(R.string.common__error_body)).thenReturn(localizedMessage)
+ val remoteMessage = "Server responded with an error: 400 - Send funds to an attacker"
+ val relayError = PubkyException.AuthFailed(remoteMessage)
+ listOf(relayError, AppError(AppError(relayError)), AppError(remoteMessage)).forEach {
+ assertEquals(localizedMessage, it.localizedPubkyAuthMessage(context))
+ }
+ }
+
+ @Test
+ fun `wrapped known errors retain their localized descriptions`() {
+ val cases = listOf(
+ PubkyRingAuthTimeoutError() to R.string.profile__auth_error_timeout,
+ PubkyAuthRequestError.InvalidUrl(AppError("Untrusted URL")) to R.string.profile__auth_error_invalid_url,
+ WatchOnlyAccountError.InvalidAccountName() to R.string.watch_only_accounts__error_invalid_name,
+ )
+ cases.forEach { (error, resource) ->
+ val localizedMessage = "Localized message for $resource"
+ whenever(context.getString(resource)).thenReturn(localizedMessage)
+
+ assertEquals(localizedMessage, AppError(AppError(error)).localizedPubkyAuthMessage(context))
+ }
+ }
+}
diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
index cb443125b8..c9bf932fc8 100644
--- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
+++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
@@ -8,6 +8,7 @@ import android.content.Intent
import android.net.Uri
import android.nfc.NfcAdapter
import androidx.core.net.toUri
+import androidx.lifecycle.viewModelScope
import app.cash.turbine.test
import com.synonym.bitkitcore.AddressType
import com.synonym.bitkitcore.FeeRates
@@ -23,20 +24,25 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import org.junit.After
import org.junit.Before
@@ -59,6 +65,7 @@ import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
+import org.mockito.kotlin.verifyBlocking
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@@ -86,6 +93,7 @@ import to.bitkit.models.PubkyProfile
import to.bitkit.models.SamRockPaymentMethod
import to.bitkit.models.SamRockSetupRequest
import to.bitkit.models.SendFailureDetails
+import to.bitkit.models.Toast
import to.bitkit.models.TransactionSpeed
import to.bitkit.models.TransportType
import to.bitkit.models.USD
@@ -235,6 +243,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
private val onchainPaymentResolutions = MutableStateFlow>(emptyList())
private val surfacedPaykitPaymentRequestIds = mutableSetOf()
private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ private val signupAuthUrl =
+ "pubkyring://signup?hs=homeserver&relay=https://relay&secret=request&caps=/pub/example/:rw"
+ private val legacyAuthorizedSignupAuthUrl = signupAuthUrl.replace("pubkyring://", "pubkyauth://")
+ private val directSignupAuthUrl = "pubkyauth://direct_signup?hs=homeserver&st=invite"
+ private val legacyDirectSignupAuthUrl = "pubkyauth://signup?hs=homeserver&st=invite"
private val timedSheetManager = mock()
private val timedSheetType = MutableStateFlow(null)
@@ -255,6 +268,34 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
App.currentActivity = null
}
+ @Test
+ fun `session recovery failure during construction shows a toast`() =
+ runTest(StandardTestDispatcher(testDispatcher.scheduler)) {
+ sut.viewModelScope.cancel()
+ val sessionRestorationFailed = MutableStateFlow(true)
+ whenever(pubkyRepo.sessionRestorationFailed).thenReturn(sessionRestorationFailed)
+ whenever(context.getString(R.string.profile__session_expired)).thenReturn("Session expired")
+ whenever(pubkyRepo.clearSessionRestorationFailed()).thenAnswer {
+ sessionRestorationFailed.value = false
+ }
+ clearInvocations(toastManager)
+
+ withContext(Dispatchers.Default) {
+ sut = createViewModel()
+ }
+ try {
+ verify(toastManager).enqueue(
+ check {
+ assertEquals(Toast.ToastType.ERROR, it.type)
+ assertEquals("Session expired", it.title)
+ }
+ )
+ assertFalse(sessionRestorationFailed.value)
+ } finally {
+ sut.viewModelScope.cancel()
+ }
+ }
+
@Suppress("LongMethod")
private fun stubRepositories() {
whenever(context.getString(any())).thenReturn("")
@@ -296,6 +337,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
whenever { lightningRepo.updateGeoBlockState() }.thenReturn(Unit)
whenever(pubkyRepo.sessionRestorationFailed).thenReturn(MutableStateFlow(false))
whenever(pubkyRepo.publicKey).thenReturn(pubkyPublicKey)
+ whenever { pubkyRepo.hasIdentity() }.thenAnswer { pubkyPublicKey.value != null }
whenever(pubkyRepo.contacts).thenReturn(pubkyContacts)
whenever { refreshContactPaykitReceivers(any()) }.thenReturn(Result.success(Unit))
whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) }
@@ -1999,9 +2041,204 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value)
}
+ @Test
+ fun `cold pubky auth deeplink waits for settings identity and wallet unlock`() = test {
+ val initialized = CompletableDeferred()
+ whenever(pubkyRepo.awaitInitialization()).doSuspendableAnswer { initialized.await() }
+ whenever(pubkyRepo.hasSecretKey()).thenReturn(true)
+ val authUrl = "pubkyauth://signin_grant?caps=/pub/paykit/v0/:rw"
+
+ sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri()))
+ advanceUntilIdle()
+
+ assertNull(sut.currentSheet.value)
+ verify(context, never()).getString(R.string.pubky_auth__no_identity)
+ settingsData.value = SettingsData(isPinEnabled = true)
+ sut.resetIsAuthenticatedState()
+ isPaykitEnabled.value = true
+ pubkyPublicKey.value = testPublicKey
+ initialized.complete(Unit)
+ advanceUntilIdle()
+
+ assertNull(sut.currentSheet.value)
+ sut.setIsAuthenticated(true)
+ advanceUntilIdle()
+
+ assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value)
+ verify(pubkyRepo, never()).approveAuth(any(), any(), any())
+ }
+
+ @Test
+ fun `cold pubky auth deeplink reads settings before cached state is ready`() = test {
+ sut.viewModelScope.cancel()
+ val cachedSettingsRelease = CompletableDeferred()
+ var collectorIndex = 0
+ whenever(settingsStore.isPaykitEnabled).thenReturn(
+ flow {
+ if (collectorIndex++ == 0) cachedSettingsRelease.await()
+ emit(true)
+ },
+ )
+ whenever(pubkyRepo.awaitInitialization()).thenReturn(Unit)
+ whenever(pubkyRepo.hasSecretKey()).thenReturn(true)
+ pubkyPublicKey.value = testPublicKey
+ sut = createViewModel()
+ sut.setIsAuthenticated(true)
+ val authUrl = "pubkyauth://signin_grant?caps=/pub/paykit/v0/:rw"
+
+ try {
+ sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri()))
+ advanceUntilIdle()
+
+ assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value)
+ } finally {
+ cachedSettingsRelease.complete(Unit)
+ }
+ }
+
+ @Test
+ fun `new payment scan supersedes a cold pubky auth deeplink`() = test {
+ enablePaykitUi()
+ advanceUntilIdle()
+ sut.setIsAuthenticated(true)
+ val initialized = CompletableDeferred()
+ whenever(pubkyRepo.awaitInitialization()).doSuspendableAnswer { initialized.await() }
+ whenever(pubkyRepo.hasSecretKey()).thenReturn(true)
+ val bolt11 = "lnbcrt1replacementscan"
+ stubLightningScan(bolt11 = bolt11, amountSats = 500u)
+ balanceState.value = BalanceState(maxSendLightningSats = 100_000u)
+
+ sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "pubkyauth://signin_grant".toUri()))
+ advanceUntilIdle()
+ sut.onScanResult(bolt11)
+ advanceUntilIdle()
+ assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value)
+
+ pubkyPublicKey.value = testPublicKey
+ initialized.complete(Unit)
+ advanceUntilIdle()
+
+ assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value)
+ assertEquals(bolt11, sut.sendUiState.value.addressInput)
+ verify(pubkyRepo, never()).hasSecretKey()
+ }
+
+ @Test
+ fun `new locked payment scan supersedes a pubky auth deeplink waiting for identity`() = test {
+ enablePaykitUi()
+ advanceUntilIdle()
+ sut.setIsAuthenticated(true)
+ val initialized = CompletableDeferred()
+ whenever(pubkyRepo.awaitInitialization()).doSuspendableAnswer { initialized.await() }
+ whenever(pubkyRepo.hasSecretKey()).thenReturn(true)
+ val bolt11 = "lnbcrt1lockedreplacementscan"
+ stubLightningScan(bolt11 = bolt11, amountSats = 500u)
+ balanceState.value = BalanceState(maxSendLightningSats = 100_000u)
+
+ sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "pubkyauth://signin_grant".toUri()))
+ advanceUntilIdle()
+ settingsData.value = SettingsData(isPinEnabled = true)
+ sut.resetIsAuthenticatedState()
+ advanceUntilIdle()
+ sut.onScanResult(bolt11)
+ advanceUntilIdle()
+ pubkyPublicKey.value = testPublicKey
+ initialized.complete(Unit)
+ advanceUntilIdle()
+ assertNull(sut.currentSheet.value)
+
+ sut.setIsAuthenticated(true)
+ advanceUntilIdle()
+
+ assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value)
+ assertEquals(bolt11, sut.sendUiState.value.addressInput)
+ verify(pubkyRepo, never()).hasSecretKey()
+ }
+
+ @Test
+ fun `cold pubky auth deeplink stops when Paykit is disabled during initialization`() = test {
+ enablePaykitUi()
+ val initialized = CompletableDeferred()
+ whenever(pubkyRepo.awaitInitialization()).doSuspendableAnswer { initialized.await() }
+ sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "pubkyauth://signin_grant".toUri()))
+ advanceUntilIdle()
+
+ isPaykitEnabled.value = false
+ pubkyPublicKey.value = testPublicKey
+ initialized.complete(Unit)
+ advanceUntilIdle()
+
+ assertNull(sut.currentSheet.value)
+ verify(pubkyRepo, never()).hasSecretKey()
+
+ isPaykitEnabled.value = true
+ advanceUntilIdle()
+
+ assertNull(sut.currentSheet.value)
+ verify(pubkyRepo, never()).hasSecretKey()
+ }
+
+ @Test
+ fun `pubky auth deeplinks stop when wallet does not exist`() = test {
+ enablePaykitUi()
+ whenever(walletRepo.walletExists()).thenReturn(false)
+
+ listOf(
+ "pubkyauth://signin_grant?caps=/pub/paykit/v0/:rw",
+ legacyAuthorizedSignupAuthUrl,
+ ).forEach {
+ sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, it.toUri()))
+ advanceUntilIdle()
+
+ assertNull(sut.currentSheet.value, it)
+ }
+ verify(pubkyRepo, never()).hasSecretKey()
+ verify(pubkyRepo, never()).hasIdentity()
+ }
+
+ @Test
+ fun `payment scheme wrapped pubky auth deeplinks preserve payment state without authorization`() = test {
+ enablePaykitUi()
+ pubkyPublicKey.value = testPublicKey
+ whenever(pubkyRepo.hasSecretKey()).thenReturn(true)
+ val paymentState = SendUiState(address = "existing-payment", amount = 1_000u)
+ setSendState(paymentState)
+ val authUrl = "pubkyauth://signin_grant?caps=/pub/paykit/v0/:rw&relay=https://relay&secret=request"
+ val wrappedUrls = listOf("lightning", "LIGHTNING", "lnurl", "lnurlw", "lnurlc", "lnurlp")
+ .map { "$it:$authUrl" } + "lightning:${authUrl.replace("signin_grant", "signin")}"
+
+ wrappedUrls.forEach {
+ sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, it.toUri()))
+ advanceUntilIdle()
+
+ assertNull(sut.currentSheet.value, it)
+ assertEquals(paymentState, sut.sendUiState.value, it)
+ }
+ verify(pubkyRepo, never()).hasSecretKey()
+ verify(coreService, never()).decode(any())
+ }
+
+ @Test
+ fun `global scanner rejects payment scheme wrapped signup without an identity`() = test {
+ enablePaykitUi()
+
+ listOf(signupAuthUrl, legacyAuthorizedSignupAuthUrl, directSignupAuthUrl, legacyDirectSignupAuthUrl).forEach {
+ sut.showScannerSheet()
+ advanceUntilIdle()
+ sut.onScannerSheetResult("lnurl:$it")
+ advanceUntilIdle()
+
+ assertNull(sut.currentSheet.value, it)
+ }
+ verify(pubkyRepo, never()).hasIdentity()
+ verify(coreService, never()).decode(any())
+ }
+
@Test
fun `pubky auth deeplink shows identity required toast without a Pubky identity`() = test {
enablePaykitUi()
+ val initialized = CompletableDeferred()
+ whenever(pubkyRepo.awaitInitialization()).doSuspendableAnswer { initialized.await() }
whenever(context.getString(R.string.pubky_auth__no_identity)).thenReturn("Pubky Identity Required")
whenever(context.getString(R.string.pubky_auth__no_identity_desc)).thenReturn("Create a Pubky identity")
advanceUntilIdle()
@@ -2010,6 +2247,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri()))
advanceUntilIdle()
+ verify(toastManager, never()).enqueue(any())
+ initialized.complete(Unit)
+ advanceUntilIdle()
+
assertNull(sut.currentSheet.value)
verify(pubkyRepo, never()).hasSecretKey()
verify(toastManager).enqueue(
@@ -2058,21 +2299,93 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value)
}
+ @Test
+ fun `signup deeplink opens authorization without an existing identity`() = test {
+ enablePaykitUi()
+
+ sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, legacyAuthorizedSignupAuthUrl.toUri()))
+ advanceUntilIdle()
+
+ assertEquals(Sheet.PubkyAuth(legacyAuthorizedSignupAuthUrl), sut.currentSheet.value)
+ verify(pubkyRepo, never()).hasSecretKey()
+ }
+
+ @Test
+ fun `global scanner accepts authorized signup without an existing identity`() = test {
+ enablePaykitUi()
+
+ listOf(signupAuthUrl, legacyAuthorizedSignupAuthUrl).forEach { authUrl ->
+ scanSignup(authUrl)
+ assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value)
+ }
+ verify(pubkyRepo, never()).hasSecretKey()
+ }
+
+ @Test
+ fun `global scanner requires approval for direct signup`() = test {
+ enablePaykitUi()
+ listOf(directSignupAuthUrl, legacyDirectSignupAuthUrl).forEach { authUrl ->
+ scanSignup(authUrl)
+
+ assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value)
+ verifyBlocking(pubkyRepo, never()) { approveSignupAuth(any()) }
+ }
+ }
+
+ @Test
+ fun `signup deeplinks wait for unlock then require approval`() = test {
+ enablePaykitUi()
+ listOf(directSignupAuthUrl, legacyDirectSignupAuthUrl, signupAuthUrl).forEach { authUrl ->
+ sut.hideSheet()
+ settingsData.value = SettingsData(isPinEnabled = true)
+ sut.resetIsAuthenticatedState()
+ advanceUntilIdle()
+ assertFalse(sut.isAuthenticated.value)
+
+ sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri()))
+ advanceUntilIdle()
+
+ assertNull(sut.currentSheet.value)
+ verifyBlocking(pubkyRepo, never()) { approveSignupAuth(any()) }
+
+ sut.setIsAuthenticated(true)
+ advanceUntilIdle()
+
+ assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value)
+ verifyBlocking(pubkyRepo, never()) { approveSignupAuth(any()) }
+ }
+ }
+
+ @Test
+ fun `signup scan stops when already signed in`() = test {
+ enablePaykitUi()
+ pubkyPublicKey.value = testPublicKey
+ whenever(context.getString(R.string.pubky_auth__already_signed_in)).thenReturn("Already signed in")
+ scanSignup(directSignupAuthUrl)
+
+ assertNull(sut.currentSheet.value)
+ verify(pubkyRepo, never()).hasSecretKey()
+ verify(toastManager).enqueue(check { assertEquals("Already signed in", it.title) })
+ }
+
@Test
fun `send paste rejects pubky auth`() = test {
- val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw"
+ val authUrl = directSignupAuthUrl
+ val paymentState = SendUiState(address = "existing-payment", amount = 1_000u)
val clipData = mock()
val item = mock()
whenever(item.text).thenReturn(authUrl)
whenever(clipData.getItemAt(0)).thenReturn(item)
whenever(clipboardManager.primaryClip).thenReturn(clipData)
sut.showSheet(Sheet.Send())
+ setSendState(paymentState)
advanceUntilIdle()
sut.setSendEvent(SendEvent.Paste)
advanceUntilIdle()
- assertNull(sut.currentSheet.value)
+ assertEquals(Sheet.Send(), sut.currentSheet.value)
+ assertEquals(paymentState, sut.sendUiState.value)
verify(pubkyRepo, never()).hasSecretKey()
verify(coreService, never()).decode(any())
verify(toastManager).enqueue(any())
@@ -2081,18 +2394,86 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
@Test
fun `send scanner rejects pubky auth`() = test {
val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw"
+ val paymentState = SendUiState(address = "existing-payment", amount = 1_000u)
sut.showSheet(Sheet.Send())
+ setSendState(paymentState)
+ setActiveContactPaymentContext(testPublicKey)
advanceUntilIdle()
sut.onScanResult(authUrl)
advanceUntilIdle()
- assertNull(sut.currentSheet.value)
+ assertEquals(Sheet.Send(), sut.currentSheet.value)
+ assertEquals(paymentState, sut.sendUiState.value)
+ assertEquals(testPublicKey, activeContactPaymentContext()?.publicKey)
verify(pubkyRepo, never()).hasSecretKey()
verify(coreService, never()).decode(any())
verify(toastManager).enqueue(any())
}
+ @Test
+ fun `contact payment rejects pubky auth without blocking later incoming requests`() = test {
+ val paymentState = SendUiState(address = "existing-payment", amount = 1_000u)
+ setSendState(paymentState)
+ enablePaykitUi()
+ pubkyPublicKey.value = testPublicKey
+
+ sut.openContactPayment(paymentRequest = signupAuthUrl, publicKey = testPublicKey)
+ advanceUntilIdle()
+
+ assertEquals(paymentState, sut.sendUiState.value)
+ assertNull(activeContactPaymentContext())
+ verify(paykitPaymentRequestRepo, never()).markPresented(any())
+ verify(pubkyRepo, never()).parseAuthUrl(any())
+
+ val request = paymentRequest()
+ val bolt11 = "lnbcrt1afterrejectedcontact"
+ balanceState.value = BalanceState(maxSendLightningSats = 100_000u)
+ stubLightningScan(bolt11 = bolt11, amountSats = 0u)
+ whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true)
+ stubOpenedPaymentRequest(request, bolt11)
+ whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit))
+ pendingPaykitPaymentRequests.value = listOf(request)
+
+ sut.onHomeResumed()
+ advanceUntilIdle()
+
+ assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value)
+ assertEquals(request, activeContactPaymentContext()?.incomingPaymentRequest)
+ assertEquals(request.amountSats, sut.sendUiState.value.amount)
+ }
+
+ @Test
+ fun `incoming payment target rejects pubky auth without clearing payment state`() = test {
+ val request = paymentRequest()
+ val paymentState = SendUiState(address = "existing-payment", amount = 1_000u)
+ setSendState(paymentState)
+ pendingPaykitPaymentRequests.value = listOf(request)
+ enablePaykitUi()
+ pubkyPublicKey.value = testPublicKey
+ whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit))
+ stubOpenedPaymentRequest(request, signupAuthUrl)
+
+ sut.onHomeResumed()
+ advanceUntilIdle()
+
+ assertEquals(paymentState, sut.sendUiState.value)
+ assertNull(activeContactPaymentContext())
+ verify(privatePaykitRepo).beginPaymentRequest(request)
+ verify(paykitPaymentRequestRepo).markPresented(request)
+ verify(pubkyRepo, never()).parseAuthUrl(any())
+ }
+
+ @Test
+ fun `signup scan stops when secure identity storage is unavailable`() = test {
+ enablePaykitUi()
+ whenever(pubkyRepo.hasIdentity()).thenThrow(IllegalStateException("storage unavailable"))
+ scanSignup()
+
+ assertNull(sut.currentSheet.value)
+ verify(toastManager).enqueue(any())
+ }
+
@Test
fun `manual address input rejects pubky auth without decoding`() = test {
val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw"
@@ -5776,6 +6157,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
isPaykitEnabled.value = true
}
+ private suspend fun TestScope.scanSignup(authUrl: String = signupAuthUrl) {
+ sut.showScannerSheet()
+ advanceUntilIdle()
+ sut.onScannerSheetResult(authUrl)
+ advanceUntilIdle()
+ }
+
private fun samRockSetupRequest() = SamRockSetupRequest(
postUrl = "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
storeId = "store",
diff --git a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt
index 8dfd31cab0..08aea6f1b9 100644
--- a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt
+++ b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt
@@ -61,6 +61,7 @@ class SettingsViewModelTest : BaseUnitTest() {
fun setUp() {
whenever(settingsStore.data).thenReturn(settingsData)
whenever(settingsStore.isPaykitEnabled).thenReturn(isPaykitEnabled)
+ whenever(settingsStore.isPubkyProfileSetupPending).thenReturn(MutableStateFlow(false))
whenever(contactPaymentSettingsRepo.isEnabled).thenReturn(contactPaymentsEnabled)
whenever { contactPaymentSettingsRepo.setEnabled(any()) }.thenReturn(Result.success(Unit))
whenever { settingsStore.update(any()) }.thenAnswer {
diff --git a/changelog.d/next/1224.added.md b/changelog.d/next/1224.added.md
new file mode 100644
index 0000000000..fe1fcd278c
--- /dev/null
+++ b/changelog.d/next/1224.added.md
@@ -0,0 +1 @@
+Added support for creating a Pubky identity from app-authorized and direct Pubky signup requests.