diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c20fd33b3b..5132e9089b 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 eddec111d1..833e2d156c 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
@@ -66,6 +69,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()
@@ -98,6 +105,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..a6ce704c84 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 }
@@ -553,25 +554,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()
+ 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 }
- 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 +601,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 +634,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 +976,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 +1003,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 +1415,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 39bfef26d6..088e0221d9 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
@@ -158,6 +160,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() {
@@ -266,6 +282,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() }
@@ -865,6 +915,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)
@@ -913,13 +972,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..ebcfa03acf 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)
@@ -48,17 +57,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 +79,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..ac3a82bf7c 100644
--- a/app/src/main/java/to/bitkit/services/PubkyService.kt
+++ b/app/src/main/java/to/bitkit/services/PubkyService.kt
@@ -1,14 +1,18 @@
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 to.bitkit.async.ServiceQueue
import to.bitkit.ext.runSuspendCatching
import to.bitkit.utils.AppError
import javax.inject.Inject
import javax.inject.Singleton
+import com.synonym.bitkitcore.parsePubkyAuthUrl as parseLegacyPubkyAuthUrl
@Suppress("TooManyFunctions")
@Singleton
@@ -76,6 +80,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 +123,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 +139,10 @@ class PubkyService @Inject constructor(
paykitSdkService.approveAuth(authUrl, expectedCapabilities, approvedClientId, secretKeyHex)
}
+ suspend fun approveRingAuth(authUrl: String, secretKeyHex: String) = ServiceQueue.CORE.background {
+ approvePubkyAuth(authUrl, secretKeyHex)
+ }
+
suspend fun approveAuthWithCompanionClaim(
authUrl: String,
expectedCapabilities: String,
diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt
index 3ebba1bb58..2c7996ea25 100644
--- a/app/src/main/java/to/bitkit/ui/ContentView.kt
+++ b/app/src/main/java/to/bitkit/ui/ContentView.kt
@@ -449,6 +449,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()
@@ -626,6 +627,7 @@ fun ContentView(
) {
Box(modifier = Modifier.fillMaxSize()) {
var isHomeCalculatorInputActive by remember { mutableStateOf(false) }
+ val pubkyProfileSetupNavigation = remember { PubkyProfileSetupNavigation() }
RootNavHost(
navController = navController,
@@ -646,6 +648,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()
@@ -705,6 +726,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,
@@ -1502,7 +1543,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 8cb85ac8a7..8a427bfbef 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
@@ -380,23 +380,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..56765eb343 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) {
@@ -273,8 +294,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 +328,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/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
index 91dfb48792..6e072a709a 100644
--- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@@ -114,6 +114,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
@@ -130,6 +131,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
@@ -182,6 +184,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
@@ -1709,7 +1712,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) {
@@ -1921,21 +1924,15 @@ class AppViewModel @Inject constructor(
routePubkyKeys: Boolean = false,
contactPaymentContext: ContactPaymentContext? = null,
preserveUntilComplete: Boolean = false,
+ allowPubkyAuth: Boolean = isMainScanner,
) {
if (!_isAuthenticated.value) {
- enqueueDeferredScan(
- source = source,
- data = data,
- startDelay = startDelay,
- routePubkyKeys = routePubkyKeys,
- contactPaymentContext = contactPaymentContext,
- )
+ enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext, allowPubkyAuth)
return
}
val normalized = data.removeLightningSchemes()
val scanId = scanLogId(data)
-
val scheduled = scheduledScan
val isSameActiveScan = normalized == scheduled?.normalizedInput &&
scheduled.job.isActive &&
@@ -1946,16 +1943,16 @@ 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
}
val previousJob = scheduled?.job
val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) {
scanMutex.withLock {
- setActiveContactPaymentContext(contactPaymentContext)
+ prepareContactPaymentContextForScan(normalized, allowPubkyAuth, contactPaymentContext)
if (startDelay > Duration.ZERO) delay(startDelay)
- handleScan(data, routePubkyKeys)
+ handleScan(data, routePubkyKeys, contactPaymentContext, allowPubkyAuth)
}
}
val nextScheduledScan = ScheduledScan(
@@ -1981,7 +1978,7 @@ class AppViewModel @Inject constructor(
}
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 {
@@ -1995,6 +1992,7 @@ class AppViewModel @Inject constructor(
startDelay: Duration,
routePubkyKeys: Boolean,
contactPaymentContext: ContactPaymentContext?,
+ allowPubkyAuth: Boolean,
) {
val scanId = scanLogId(data)
val normalized = data.removeLightningSchemes()
@@ -2008,6 +2006,7 @@ class AppViewModel @Inject constructor(
startDelay = startDelay,
routePubkyKeys = routePubkyKeys,
contactPaymentContext = contactPaymentContext,
+ allowPubkyAuth = allowPubkyAuth,
)
return
}
@@ -2026,6 +2025,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)
@@ -2064,6 +2064,7 @@ class AppViewModel @Inject constructor(
routePubkyKeys = pending.routePubkyKeys,
contactPaymentContext = pending.contactPaymentContext,
preserveUntilComplete = true,
+ allowPubkyAuth = pending.allowPubkyAuth,
)
}
@@ -2415,6 +2416,7 @@ class AppViewModel @Inject constructor(
startDelay: Duration = Duration.ZERO,
routePubkyKeys: Boolean = false,
contactPaymentContext: ContactPaymentContext? = null,
+ allowPubkyAuth: Boolean = isMainScanner,
) {
launchScan(
source = ScanSource.SCAN_RESULT,
@@ -2422,6 +2424,7 @@ class AppViewModel @Inject constructor(
startDelay = startDelay,
routePubkyKeys = routePubkyKeys,
contactPaymentContext = contactPaymentContext,
+ allowPubkyAuth = allowPubkyAuth,
)
}
@@ -2436,7 +2439,11 @@ class AppViewModel @Inject constructor(
privatePaymentContext = privatePaymentContext,
incomingPaymentRequest = incomingPaymentRequest,
)
- onScanResult(paymentRequest, contactPaymentContext = context)
+ onScanResult(
+ data = paymentRequest,
+ contactPaymentContext = context,
+ allowPubkyAuth = false,
+ )
}
fun preserveContactPaymentContext(paymentHash: String) {
@@ -2453,7 +2460,21 @@ class AppViewModel @Inject constructor(
private suspend fun handleScan(
result: String,
routePubkyKeys: Boolean,
+ contactPaymentContext: ContactPaymentContext?,
+ allowPubkyAuth: Boolean,
) = withContext(bgDispatcher) {
+ val input = result.removeLightningSchemes()
+
+ if (PubkyAuthRequest.isProtocolUrl(input) && !allowPubkyAuth) {
+ 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@withContext
+ }
+
val contactPaymentProfile = activeContactPaymentProfile()
val isPaymentRequest = activeIncomingPaymentRequest() != null
// always reset state on new scan
@@ -2461,7 +2482,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)) {
@@ -2486,16 +2506,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 (isPaykitEnabled.value) {
handlePubkyAuth(input)
} else {
hideSheet()
@@ -2624,12 +2637,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
}
@@ -2642,6 +2650,25 @@ class AppViewModel @Inject constructor(
isSubmittingPaymentRequest = false
}
+ 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
@@ -2649,6 +2676,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)
@@ -4629,9 +4665,14 @@ class AppViewModel @Inject constructor(
return@launch
}
- if (uri.scheme == PUBKYAUTH_SCHEME) {
- if (!isPaykitEnabled.value) return@launch
- handlePubkyAuth(uri.toString())
+ if (PubkyAuthRequest.isProtocolUrl(uri.toString())) {
+ if (!isPaykitEnabled.value || !walletRepo.walletExists()) return@launch
+ launchScan(
+ source = ScanSource.DEEPLINK,
+ data = uri.toString(),
+ startDelay = SCREEN_TRANSITION_DELAY,
+ allowPubkyAuth = true,
+ )
return@launch
}
@@ -4653,7 +4694,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),
@@ -4662,7 +4706,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),
@@ -4672,6 +4716,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 -> {
@@ -4756,7 +4818,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. */
@@ -4793,6 +4854,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 ed794ef4ec..07fcfb9a9d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -684,8 +684,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..7796c7539f 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,88 @@ 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://?caps=rw", "pubkyauth://signin?caps=rw", "pubkyauth://grant?caps=rw")
+ val unrelatedUrls = listOf("pubkyring://auth", "pubkyring://direct_signup")
+ 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,
+ signupUrls.filter { it.startsWith("pubkyauth:") } + authUrls,
+ authAlias,
+ )
+ assertRoutes(packageManager, application.packageName, listOf(signupUrls.first()) + 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..287a411b6c 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,7 +43,9 @@ 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
@@ -74,12 +79,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 +116,116 @@ 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 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()) }
+ 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()) }
+ 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 +658,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()
@@ -1563,6 +1864,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..8f91d4810c 100644
--- a/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt
+++ b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt
@@ -1,5 +1,6 @@
package to.bitkit.services
+import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -7,6 +8,7 @@ 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
@@ -16,14 +18,22 @@ 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()
@@ -48,7 +58,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 +80,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 +106,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 +121,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() {
publicKey.value = null
runCurrent()
- verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED)
+ verifyComponentStates(authEnabled = false, signupEnabled = true)
}
@Test
@@ -119,7 +136,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() {
isPaykitEnabled.value = false
runCurrent()
- verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED)
+ verifyComponentStates(authEnabled = false, signupEnabled = false)
}
@Test
@@ -130,7 +147,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() {
sut.start(backgroundScope)
runCurrent()
- verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED)
+ verifyComponentStates(authEnabled = false, signupEnabled = false)
}
@Test
@@ -145,11 +162,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 +177,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/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/PubkyAuthApprovalViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt
index bc6617bd8e..358c0461b1 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,15 @@ 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.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
@@ -104,6 +110,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 +189,65 @@ 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())
+ verifyBlocking(pubkyRepo, never()) { approveSignupAuth(request) }
+ sut.cancelLocalAuth(authUrl)
+ 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 `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 +653,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/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
index 75eb224dda..20f69313ed 100644
--- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
+++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
@@ -56,6 +56,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
@@ -219,6 +220,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
private val paykitPaymentRequestHistory = 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)
@@ -280,6 +286,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()) }
@@ -1918,21 +1925,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())
@@ -1941,18 +2020,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"
@@ -5016,6 +5163,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.