diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 518327d9ae..cd89e25350 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,20 +3,17 @@
xmlns:tools="http://schemas.android.com/tools">
-
+
-
-
-
-
+
@@ -52,6 +49,11 @@
android:usesPermissionFlags="neverForLocation"
tools:targetApi="31" />
+
+
+
+
+
> = withContext(ioDispatcher) {
+ runSuspendCatching {
+ check(isRingProviderTrusted()) { "Ring provider unavailable" }
+
+ val uri = Uri.parse("content://$RING_AUTHORITY/$PATH_IDENTITIES")
+ val cursor = requireNotNull(context.contentResolver.query(uri, null, null, null, null)) {
+ "Ring identities query returned no cursor"
+ }
+ cursor.use {
+ buildList {
+ val column = it.getColumnIndexOrThrow(COLUMN_PUBKY)
+ while (it.moveToNext()) {
+ it.getString(column)?.let(::add)
+ }
+ }
+ }.toImmutableList()
+ }
+ }
+
+ suspend fun ringCredential(pubky: String): Result = withContext(ioDispatcher) {
+ runSuspendCatching {
+ requireNotNull(readCredential(pubky)) { "Ring credential unavailable" }
+ }
+ }
+
+ internal fun readCredential(pubky: String): String? {
+ if (!isRingProviderTrusted()) return null
+
+ val uri = Uri.parse("content://$RING_AUTHORITY/$PATH_IDENTITIES/$pubky/$PATH_CREDENTIAL")
+ val cursor = requireNotNull(context.contentResolver.query(uri, null, null, null, null)) {
+ "Ring credential query returned no cursor"
+ }
+ return cursor.use {
+ if (it.count != 1 || !it.moveToFirst()) return null
+ val rowPubky = it.getString(it.getColumnIndexOrThrow(COLUMN_PUBKY))
+ val secretKeyHex = it.getString(it.getColumnIndexOrThrow(COLUMN_SECRET_KEY)).orEmpty()
+
+ if (!PubkyPublicKeyFormat.matches(rowPubky, pubky)) return null
+ secretKeyHex.takeIf { hex -> SharedPubkyContract.isValidSecret(hex, pubky) }
+ }
+ }
+
+ private fun isRingProviderTrusted(): Boolean {
+ val packageManager = context.packageManager
+ return packageManager.resolveContentProvider(RING_AUTHORITY, 0)?.packageName == RING_PACKAGE &&
+ packageManager.checkSignatures(context.packageName, RING_PACKAGE) == PackageManager.SIGNATURE_MATCH
+ }
+}
diff --git a/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyContract.kt b/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyContract.kt
new file mode 100644
index 0000000000..aeef8e94dc
--- /dev/null
+++ b/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyContract.kt
@@ -0,0 +1,50 @@
+package to.bitkit.data.sharedpubky
+
+import to.bitkit.models.PubkyPublicKeyFormat
+import to.bitkit.services.PaykitSdkService
+
+private const val PUBKY_PREFIX = "pubky"
+private val SECRET_KEY_PATTERN = Regex("[0-9a-f]{64}")
+
+object SharedPubkyContract {
+ /** Appended to an applicationId to form its shared pubky provider authority, same as in Pubky Ring. */
+ const val AUTHORITY_SUFFIX = ".sharedpubky"
+
+ /** Provider path listing the app's pubkys, one [COLUMN_PUBKY] row each. */
+ const val PATH_IDENTITIES = "v1/identities"
+
+ /** Final segment of `v1/identities//credential`, which returns that pubky's secret key. */
+ const val PATH_CREDENTIAL = "credential"
+
+ /** Public key column of both the identities and credential rows. */
+ const val COLUMN_PUBKY = "pubky"
+
+ /** Secret key column of the credential row, as 64 lowercase hex characters. */
+ const val COLUMN_SECRET_KEY = "secret_key"
+
+ /** Pubky Ring's Android applicationId. */
+ const val RING_PACKAGE = "app.pubkyring"
+
+ /** Authority of Pubky Ring's shared pubky provider. */
+ const val RING_AUTHORITY = RING_PACKAGE + AUTHORITY_SUFFIX
+
+ /** Prefix of the stored `app.pubkyring:` source reference that marks an adopted Ring pubky. */
+ const val RING_SOURCE_PREFIX = "$RING_PACKAGE:"
+
+ fun isValidSecret(
+ secretKeyHex: String,
+ pubky: String,
+ derivePublicKey: (String) -> String = PaykitSdkService::publicKeyFromSecret,
+ ): Boolean = PubkyPublicKeyFormat.matches(pubkyFromSecret(secretKeyHex, derivePublicKey), pubky)
+
+ internal fun pubkyFromSecret(
+ secretKeyHex: String,
+ derivePublicKey: (String) -> String,
+ ): String? {
+ if (!SECRET_KEY_PATTERN.matches(secretKeyHex)) return null
+ return runCatching { derivePublicKey(secretKeyHex) }
+ .getOrNull()
+ ?.let(PubkyPublicKeyFormat::normalized)
+ ?.removePrefix(PUBKY_PREFIX)
+ }
+}
diff --git a/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyProvider.kt b/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyProvider.kt
new file mode 100644
index 0000000000..d90f09c2ca
--- /dev/null
+++ b/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyProvider.kt
@@ -0,0 +1,116 @@
+package to.bitkit.data.sharedpubky
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.UriMatcher
+import android.content.pm.PackageManager
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import android.os.Binder
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import to.bitkit.data.keychain.Keychain
+import to.bitkit.data.sharedpubky.SharedPubkyContract.AUTHORITY_SUFFIX
+import to.bitkit.data.sharedpubky.SharedPubkyContract.COLUMN_PUBKY
+import to.bitkit.data.sharedpubky.SharedPubkyContract.COLUMN_SECRET_KEY
+import to.bitkit.data.sharedpubky.SharedPubkyContract.PATH_CREDENTIAL
+import to.bitkit.data.sharedpubky.SharedPubkyContract.PATH_IDENTITIES
+import to.bitkit.models.PubkyPublicKeyFormat
+import to.bitkit.services.PaykitSdkService
+import to.bitkit.utils.Logger
+
+private const val TAG = "SharedPubkyProvider"
+private const val MATCH_IDENTITIES = 1
+private const val MATCH_CREDENTIAL = 2
+private const val MIME_SUBTYPE = "vnd.to.bitkit.sharedpubky"
+private const val PUBKY_PATH_INDEX = 2
+
+class SharedPubkyProvider : ContentProvider() {
+ internal var keychainProvider: () -> Keychain? = {
+ context?.applicationContext?.let {
+ EntryPointAccessors.fromApplication(it, SharedPubkyEntryPoint::class.java).keychain()
+ }
+ }
+
+ internal var derivePublicKey: (String) -> String = PaykitSdkService::publicKeyFromSecret
+
+ private val uriMatcher by lazy {
+ UriMatcher(UriMatcher.NO_MATCH).apply {
+ val authority = "${context?.packageName}$AUTHORITY_SUFFIX"
+ addURI(authority, PATH_IDENTITIES, MATCH_IDENTITIES)
+ addURI(authority, "$PATH_IDENTITIES/*/$PATH_CREDENTIAL", MATCH_CREDENTIAL)
+ }
+ }
+
+ override fun onCreate() = true
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?,
+ ): Cursor? {
+ if (!isCallerTrusted(Binder.getCallingUid())) throw SecurityException("Caller signature mismatch")
+
+ val match = uriMatcher.match(uri)
+ val cursor = when (match) {
+ MATCH_IDENTITIES -> MatrixCursor(arrayOf(COLUMN_PUBKY))
+ MATCH_CREDENTIAL -> MatrixCursor(arrayOf(COLUMN_PUBKY, COLUMN_SECRET_KEY))
+ else -> return null
+ }
+
+ val secretKeyHex = loadSecretKey().getOrElse { return null } ?: return cursor
+ val pubky = SharedPubkyContract.pubkyFromSecret(secretKeyHex, derivePublicKey) ?: return cursor
+
+ when (match) {
+ MATCH_IDENTITIES -> cursor.addRow(arrayOf(pubky))
+ MATCH_CREDENTIAL -> if (PubkyPublicKeyFormat.matches(uri.pathSegments.getOrNull(PUBKY_PATH_INDEX), pubky)) {
+ cursor.addRow(arrayOf(pubky, secretKeyHex))
+ }
+ }
+ Logger.debug("Served shared pubky '${PubkyPublicKeyFormat.redacted(pubky)}'", context = TAG)
+
+ return cursor
+ }
+
+ override fun getType(uri: Uri): String? = when (uriMatcher.match(uri)) {
+ MATCH_IDENTITIES -> "vnd.android.cursor.dir/$MIME_SUBTYPE"
+ MATCH_CREDENTIAL -> "vnd.android.cursor.item/$MIME_SUBTYPE"
+ else -> null
+ }
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri = throw UnsupportedOperationException()
+
+ override fun update(
+ uri: Uri,
+ values: ContentValues?,
+ selection: String?,
+ selectionArgs: Array?,
+ ): Int = throw UnsupportedOperationException()
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int =
+ throw UnsupportedOperationException()
+
+ internal fun isCallerTrusted(uid: Int): Boolean {
+ val context = context ?: return false
+ val packageManager = context.packageManager
+ val callerPackages = packageManager.getPackagesForUid(uid).orEmpty()
+
+ return callerPackages.isNotEmpty() && callerPackages.all {
+ packageManager.checkSignatures(context.packageName, it) == PackageManager.SIGNATURE_MATCH
+ }
+ }
+
+ private fun loadSecretKey(): Result =
+ runCatching { keychainProvider()?.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)?.takeIf { it.isNotBlank() } }
+}
+
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface SharedPubkyEntryPoint {
+ fun keychain(): Keychain
+}
diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt
index 3b00a3ab61..b5be0c4e61 100644
--- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt
+++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt
@@ -42,7 +42,6 @@ data class MetadataBackupV1(
@Serializable
data class PubkySessionBackupV1(
val kind: PubkySessionBackupKind,
- val sessionSecret: String? = null,
)
@Serializable
@@ -50,6 +49,7 @@ enum class PubkySessionBackupKind {
@SerialName("localSeed")
LocalSeed,
+ /** Legacy: sessions imported through the removed Pubky Ring relay flow; restored as no identity. */
@SerialName("externalSession")
ExternalSession,
}
diff --git a/app/src/main/java/to/bitkit/models/PubkyRingAuthCallback.kt b/app/src/main/java/to/bitkit/models/PubkyRingAuthCallback.kt
deleted file mode 100644
index 7f57732ac8..0000000000
--- a/app/src/main/java/to/bitkit/models/PubkyRingAuthCallback.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-package to.bitkit.models
-
-import android.net.Uri
-
-private const val NONCE_PARAM = "nonce"
-
-sealed interface PubkyRingAuthCallback {
- companion object {
- private const val BITKIT_SCHEME = "bitkit"
- private const val PUBKY_AUTH_HOST = "pubky-auth"
- private const val SUCCESS_PATH = "/success"
- private const val CANCEL_PATH = "/cancel"
- private const val ERROR_PATH = "/error"
- private const val ERROR_MESSAGE_PARAM = "errorMessage"
-
- fun parse(uri: Uri): PubkyRingAuthCallback? {
- if (uri.scheme != BITKIT_SCHEME || uri.host != PUBKY_AUTH_HOST) return null
-
- val nonce = uri.getQueryParameter(NONCE_PARAM)?.takeIf { it.isNotBlank() }
- return when (uri.path) {
- SUCCESS_PATH -> Success(nonce)
- CANCEL_PATH -> Cancel(nonce)
- ERROR_PATH -> Error(uri.getQueryParameter(ERROR_MESSAGE_PARAM), nonce)
- else -> null
- }
- }
- }
-
- val nonce: String?
-
- data class Success(override val nonce: String?) : PubkyRingAuthCallback
- data class Cancel(override val nonce: String?) : PubkyRingAuthCallback
- data class Error(val message: String?, override val nonce: String?) : PubkyRingAuthCallback
-}
-
-sealed interface PubkyRingAuthCallbackHandlingResult {
- data object Ignored : PubkyRingAuthCallbackHandlingResult
- data object Handled : PubkyRingAuthCallbackHandlingResult
- data class TrustedError(val message: String?) : PubkyRingAuthCallbackHandlingResult
-}
-
-object PubkyRingAuthUrlBuilder {
- const val SUCCESS_CALLBACK = "bitkit://pubky-auth/success"
- const val CANCEL_CALLBACK = "bitkit://pubky-auth/cancel"
- const val ERROR_CALLBACK = "bitkit://pubky-auth/error"
- const val SOURCE = "Bitkit"
-
- fun addCallbacks(authUrl: String, nonce: String? = null): String? {
- val uri = Uri.parse(authUrl)
- if (uri.scheme.isNullOrBlank()) return null
-
- return uri.buildUpon()
- .appendQueryParameter("x-success", callbackUrl(SUCCESS_CALLBACK, nonce))
- .appendQueryParameter("x-cancel", callbackUrl(CANCEL_CALLBACK, nonce))
- .appendQueryParameter("x-error", callbackUrl(ERROR_CALLBACK, nonce))
- .appendQueryParameter("x-source", SOURCE)
- .build()
- .toString()
- }
-
- private fun callbackUrl(baseUrl: String, nonce: String?): String {
- if (nonce.isNullOrBlank()) return baseUrl
-
- return Uri.parse(baseUrl)
- .buildUpon()
- .appendQueryParameter(NONCE_PARAM, nonce)
- .build()
- .toString()
- }
-}
diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
index 8f1c052339..9711ff9577 100644
--- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -9,6 +9,7 @@ import com.synonym.paykit.PubkyAuthCompanionClaim
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.post
+import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
@@ -17,11 +18,9 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
@@ -38,6 +37,8 @@ import to.bitkit.data.SettingsStore
import to.bitkit.data.hasPaykitState
import to.bitkit.data.keychain.Keychain
import to.bitkit.data.paykitDisabled
+import to.bitkit.data.sharedpubky.SharedPubkyClient
+import to.bitkit.data.sharedpubky.SharedPubkyContract
import to.bitkit.di.IoDispatcher
import to.bitkit.env.Env
import to.bitkit.ext.isPaykitIdentityError
@@ -49,8 +50,6 @@ import to.bitkit.models.PubkyProfile
import to.bitkit.models.PubkyProfileData
import to.bitkit.models.PubkyProfileLink
import to.bitkit.models.PubkyPublicKeyFormat
-import to.bitkit.models.PubkyRingAuthCallback
-import to.bitkit.models.PubkyRingAuthCallbackHandlingResult
import to.bitkit.models.PubkySessionBackupKind
import to.bitkit.models.PubkySessionBackupV1
import to.bitkit.services.PaykitReceiverPaths
@@ -58,35 +57,25 @@ import to.bitkit.services.PubkyService
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import java.io.ByteArrayOutputStream
-import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.min
-enum class PubkyAuthState { Idle, Authenticating, Authenticated }
-
-data class PubkyRingAuthRequest(
- val authUrl: String,
- val callbackNonce: String,
-)
-
sealed class PubkyContactError(message: String) : AppError(message) {
data object AlreadyExists : PubkyContactError("Contact already exists")
data object CannotAddSelf : PubkyContactError("Cannot add your own pubky as a contact")
data object InvalidFormat : PubkyContactError("Invalid pubky key format")
}
-private class PubkyAuthAttemptInactive : AppError("Auth attempt is no longer active")
data object PubkyAlreadySignedInError : AppError("Already signed in")
-private enum class AuthAttemptWaitResult { Approved, Inactive }
-
@Suppress("TooManyFunctions", "LargeClass", "LongParameterList")
@Singleton
class PubkyRepo @Inject constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val pubkyService: PubkyService,
private val keychain: Keychain,
+ private val sharedPubkyClient: SharedPubkyClient,
private val imageLoader: ImageLoader,
private val pubkyStore: PubkyStore,
private val settingsStore: SettingsStore,
@@ -105,14 +94,9 @@ class PubkyRepo @Inject constructor(
private val initializeMutex = Mutex()
private val loadProfileMutex = Mutex()
private val loadContactsMutex = Mutex()
+ private val adoptedSourceCheckMutex = Mutex()
private var isServiceInitialized = false
- private val _authState = MutableStateFlow(PubkyAuthState.Idle)
- private val _activeAuthAttemptId = MutableStateFlow(null)
- private val _approvedAuthAttemptId = MutableStateFlow(null)
- private val _authCancelEvents = MutableSharedFlow(extraBufferCapacity = 1)
- val authCancelEvents = _authCancelEvents.asSharedFlow()
-
private val _profile = MutableStateFlow(null)
val profile: StateFlow = _profile.asStateFlow()
@@ -137,6 +121,9 @@ class PubkyRepo @Inject constructor(
private val _sessionRestorationFailed = MutableStateFlow(false)
val sessionRestorationFailed: StateFlow = _sessionRestorationFailed.asStateFlow()
+ private val _adoptedSourceLost = MutableStateFlow(false)
+ val adoptedSourceLost: StateFlow = _adoptedSourceLost.asStateFlow()
+
private val _pendingImportProfile = MutableStateFlow(null)
val pendingImportProfile: StateFlow = _pendingImportProfile.asStateFlow()
@@ -214,7 +201,6 @@ class PubkyRepo @Inject constructor(
}
is InitResult.Restored -> {
_publicKey.update { result.publicKey }
- _authState.update { PubkyAuthState.Authenticated }
Logger.info("Restored paykit session for '${redacted(result.publicKey)}'", context = TAG)
}
is InitResult.RestorationFailed -> {
@@ -229,6 +215,8 @@ class PubkyRepo @Inject constructor(
loadContacts()
}
}
+
+ checkAdoptedSourcePresent()
}
private fun hasSavedSession(): Boolean = runCatching {
@@ -254,7 +242,7 @@ class PubkyRepo @Inject constructor(
InitResult.Restored(publicKey)
}.getOrElse {
Logger.warn("Failed to restore paykit session, attempting re-sign-in", it, context = TAG)
- resolveSignedInSession(savedSessionSecret, storedSecretKeyHex)
+ resolveSignedInSession(savedSessionSecret, storedSecretKeyHex ?: adoptedSecretKeyHex())
}
} else {
resolveSignedInSession(savedSessionSecret, storedSecretKeyHex)
@@ -292,215 +280,75 @@ class PubkyRepo @Inject constructor(
// endregion
- // region Ring auth flow
+ // region Shared pubky
- suspend fun startAuthentication(): Result {
- val attemptId = UUID.randomUUID().toString()
- _activeAuthAttemptId.update { attemptId }
- _approvedAuthAttemptId.update { null }
- _authState.update { PubkyAuthState.Authenticating }
- return try {
- runSuspendCatching {
- val authUrl = withContext(ioDispatcher) { pubkyService.startAuth() }
- PubkyRingAuthRequest(authUrl = authUrl, callbackNonce = attemptId)
- }.onFailure {
- _activeAuthAttemptId.update { null }
- restoreAuthStateAfterAuthFlow()
- }
- } catch (e: CancellationException) {
- _activeAuthAttemptId.update { null }
- restoreAuthStateAfterAuthFlow()
- throw e
- }
- }
+ suspend fun ringIdentities(): Result> = sharedPubkyClient.listRingIdentities()
- suspend fun completeAuthentication(): Result {
- val attemptId = _activeAuthAttemptId.value ?: return Result.failure(PubkyAuthAttemptInactive())
- var shouldRevokeSessionOnFailure = false
- return try {
- val result = runSuspendCatching {
- waitForAuthApproval(attemptId)
- withContext(ioDispatcher) {
- withContext(NonCancellable) {
- shouldRevokeSessionOnFailure = true
- pubkyService.completeAuth()
- }
- ensureAuthAttemptActive(attemptId)
- val pk = requireNotNull(pubkyService.currentPublicKey()?.ensurePubkyPrefix()) {
- "No active Pubky session"
- }
- ensureAuthAttemptActive(attemptId)
-
- settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) }
- notifyBackupStateChanged()
-
- pk
- }
- }
-
- if (result.isFailure) {
- revokeCompletedAuthSessionIfNeeded(shouldRevokeSessionOnFailure)
- if (_activeAuthAttemptId.value == attemptId) {
- _activeAuthAttemptId.update { null }
- }
- if (_approvedAuthAttemptId.value == attemptId) {
- _approvedAuthAttemptId.update { null }
- }
- restoreAuthStateAfterAuthFlow()
- }
-
- result.onSuccess { pk ->
- if (_activeAuthAttemptId.value == attemptId) {
- _activeAuthAttemptId.update { null }
- }
- if (_approvedAuthAttemptId.value == attemptId) {
- _approvedAuthAttemptId.update { null }
- }
- _publicKey.update { pk }
- _authState.update { PubkyAuthState.Authenticated }
- shouldRevokeSessionOnFailure = false
- Logger.info("Completed pubky auth for '${redacted(pk)}'", context = TAG)
- loadProfile()
- loadContacts()
- }.map { }
- } catch (e: CancellationException) {
- revokeCompletedAuthSessionIfNeeded(shouldRevokeSessionOnFailure)
- if (_activeAuthAttemptId.value == attemptId) {
- _activeAuthAttemptId.update { null }
- }
- if (_approvedAuthAttemptId.value == attemptId) {
- _approvedAuthAttemptId.update { null }
- }
- restoreAuthStateAfterAuthFlow()
- throw e
- }
+ fun clearAdoptedSourceLost() {
+ _adoptedSourceLost.update { false }
}
- private suspend fun revokeCompletedAuthSessionIfNeeded(shouldRevokeSession: Boolean) {
- if (!shouldRevokeSession) return
- discardAbandonedSession()
- }
-
- private suspend fun discardAbandonedSession() {
- val revocationError = runSuspendCatching {
- withContext(NonCancellable + ioDispatcher) {
- pubkyService.signOut()
- }
- }.exceptionOrNull() ?: return
-
- Logger.warn("Failed to revoke abandoned Pubky session", revocationError, context = TAG)
+ suspend fun checkAdoptedSource(): Result = withContext(ioDispatcher) {
runSuspendCatching {
- withContext(NonCancellable + ioDispatcher) {
- pubkyService.forgetSessionAccess()
- }
- }.onFailure {
- Logger.warn("Failed to forget abandoned Pubky session access", it, context = TAG)
- withContext(NonCancellable + ioDispatcher) {
- clearLocalState(publicPaykitCleanupPending = true)
- }
- }
+ if (initializationReady.isCompleted) checkAdoptedSourcePresent()
+ }.onFailure { Logger.warn("Failed to check adopted ring identity", it, context = TAG) }
}
- suspend fun cancelAuthentication() {
+ private suspend fun checkAdoptedSourcePresent() {
+ if (!adoptedSourceCheckMutex.tryLock()) return
try {
- runSuspendCatching {
- withContext(ioDispatcher) { pubkyService.cancelAuth() }
- }.onFailure { Logger.warn("Failed to cancel auth", it, context = TAG) }
- } finally {
- endAuthAttempt()
- }
- }
-
- fun cancelAuthenticationSync() {
- scope.launch { cancelAuthentication() }
- }
-
- suspend fun handleAuthCallback(callback: PubkyRingAuthCallback): PubkyRingAuthCallbackHandlingResult {
- if (!isCurrentAuthCallback(callback)) {
- return handleInvalidAuthCallback(callback)
- }
-
- return when (callback) {
- is PubkyRingAuthCallback.Success -> {
- Logger.info("Received Pubky Ring auth success callback", context = TAG)
- _activeAuthAttemptId.value?.let { attemptId ->
- _approvedAuthAttemptId.update { attemptId }
- }
- PubkyRingAuthCallbackHandlingResult.Handled
+ val reference = keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name) ?: return
+ val ringPubkys = sharedPubkyClient.listRingIdentities().getOrElse {
+ Logger.warn("Failed to list ring identities", it, context = TAG)
+ return
}
- is PubkyRingAuthCallback.Cancel -> {
- Logger.info("Received Pubky Ring auth cancel callback", context = TAG)
- cancelAuthentication()
- PubkyRingAuthCallbackHandlingResult.Handled
- }
- is PubkyRingAuthCallback.Error -> {
- Logger.warn("Received Pubky Ring auth error callback", context = TAG)
- cancelAuthentication()
- PubkyRingAuthCallbackHandlingResult.TrustedError(callback.message)
- }
- }
- }
+ if (ringPubkys.any { "${SharedPubkyContract.RING_SOURCE_PREFIX}$it" == reference }) return
- private fun handleInvalidAuthCallback(
- callback: PubkyRingAuthCallback,
- ): PubkyRingAuthCallbackHandlingResult {
- if (_activeAuthAttemptId.value == null) {
- Logger.warn("Ignoring Pubky Ring auth callback with missing or invalid nonce", context = TAG)
- return PubkyRingAuthCallbackHandlingResult.Ignored
- }
-
- return when (callback) {
- is PubkyRingAuthCallback.Success -> {
- Logger.warn("Ignoring Pubky Ring auth success callback with missing or invalid nonce", context = TAG)
- PubkyRingAuthCallbackHandlingResult.Ignored
- }
- is PubkyRingAuthCallback.Cancel -> {
- Logger.warn("Ignoring Pubky Ring auth cancel callback with missing or invalid nonce", context = TAG)
- PubkyRingAuthCallbackHandlingResult.Ignored
- }
- is PubkyRingAuthCallback.Error -> {
- Logger.warn("Ignoring Pubky Ring auth error callback with missing or invalid nonce", context = TAG)
- PubkyRingAuthCallbackHandlingResult.Ignored
- }
+ Logger.warn("Adopted ring identity '${redacted(reference)}' is gone, clearing session", context = TAG)
+ runSuspendCatching { pubkyService.clearSessionAccess() }
+ .onFailure { Logger.warn("Failed to clear adopted session access", it, context = TAG) }
+ clearLocalState()
+ _adoptedSourceLost.update { true }
+ } finally {
+ adoptedSourceCheckMutex.unlock()
}
}
- private fun isCurrentAuthCallback(callback: PubkyRingAuthCallback): Boolean {
- val activeAuthAttemptId = _activeAuthAttemptId.value ?: return false
- return callback.nonce == activeAuthAttemptId ||
- (callback is PubkyRingAuthCallback.Success && callback.nonce == null)
- }
-
- private suspend fun waitForAuthApproval(attemptId: String) {
- if (_approvedAuthAttemptId.value == attemptId) return
-
- val result = combine(_approvedAuthAttemptId, _activeAuthAttemptId) { approvedAttemptId, activeAttemptId ->
- when {
- approvedAttemptId == attemptId -> AuthAttemptWaitResult.Approved
- activeAttemptId != attemptId -> AuthAttemptWaitResult.Inactive
- else -> null
+ suspend fun adoptRingIdentity(pubky: String): Result = withContext(ioDispatcher) {
+ runSuspendCatching {
+ ensureServiceInitialized()
+ val secretKeyHex = sharedPubkyClient.ringCredential(pubky).getOrThrow()
+ val publicKey = pubkyService.publicKeyFromSecret(secretKeyHex)
+ require(PubkyPublicKeyFormat.matches(publicKey, pubky)) {
+ "Ring credential does not match '${redacted(pubky)}'"
}
- }.first { it != null }
-
- if (result != AuthAttemptWaitResult.Approved) throw PubkyAuthAttemptInactive()
- }
-
- private fun ensureAuthAttemptActive(attemptId: String?) {
- if (attemptId == null) return
- if (_activeAuthAttemptId.value == attemptId) return
-
- throw PubkyAuthAttemptInactive()
- }
+ keychain.upsertString(
+ Keychain.Key.SHARED_PUBKY_SOURCE.name,
+ "${SharedPubkyContract.RING_SOURCE_PREFIX}$pubky",
+ )
- private fun endAuthAttempt() {
- _activeAuthAttemptId.update { null }
- _approvedAuthAttemptId.update { null }
- _authCancelEvents.tryEmit(Unit)
- restoreAuthStateAfterAuthFlow()
- }
+ runSuspendCatching { pubkyService.signIn(secretKeyHex) }.getOrElse {
+ val hasIdentityRecord = runSuspendCatching { pubkyService.hasIdentityRecord(publicKey) }
+ .onFailure { Logger.warn("Failed to check ring identity record", it, context = TAG) }
+ .getOrNull()
+ if (hasIdentityRecord != false) throw it
+ Logger.warn("Signing up ring identity without a published record", it, context = TAG)
+ val homegate = fetchHomegateSignupCode()
+ pubkyService.signUp(secretKeyHex, homegate.homeserverPubky, homegate.signupCode)
+ }
- private fun restoreAuthStateAfterAuthFlow() {
- _authState.update { if (_publicKey.value == null) PubkyAuthState.Idle else PubkyAuthState.Authenticated }
+ _publicKey.update { publicKey.ensurePubkyPrefix() }
+ notifyBackupStateChanged()
+ Logger.info("Adopted ring identity for '${redacted(publicKey)}'", context = TAG)
+ loadProfile()
+ loadContacts()
+ val hasProfile = _profile.value != null
+ runSuspendCatching { settingsStore.setPubkyProfileSetupPending(!hasProfile) }
+ .onFailure { Logger.warn("Failed to save pending profile setup", it, context = TAG) }
+ hasProfile
+ }.onFailure {
+ runCatching { keychain.delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) }
+ }
}
// endregion
@@ -595,24 +443,8 @@ class PubkyRepo @Inject constructor(
val result = runSuspendCatching {
withContext(ioDispatcher) {
settingsStore.setPubkyProfileSetupPending(false)
- val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
- val publicKeyZ32 = if (!storedSecretKeyHex.isNullOrEmpty()) {
- pubkyService.signIn(storedSecretKeyHex)
- pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix()
- } else {
- val (publicKey, secretKeyHex) = deriveKeys().getOrThrow()
- val signupDetails: Pair = Env.e2eHomeserverPubky?.let { it to null }
- ?: fetchHomegateSignupCode().let { it.homeserverPubky to it.signupCode }
-
- shouldRevokeSessionOnFailure = true
- runSuspendCatching {
- pubkyService.signUp(secretKeyHex, signupDetails.first, signupDetails.second)
- }.getOrElse {
- Logger.warn("Retrying sign in after sign up failed", it, context = TAG)
- pubkyService.signIn(secretKeyHex)
- }
- publicKey
- }
+ val publicKeyZ32 = _publicKey.value
+ ?: createLocalIdentitySession { shouldRevokeSessionOnFailure = true }
val imageUrl = publishIdentityProfile(name, bio, links, tags, avatarBytes)
shouldRevokeSessionOnFailure = false
@@ -627,6 +459,28 @@ class PubkyRepo @Inject constructor(
}
}
+ private suspend fun createLocalIdentitySession(markSessionCreated: () -> Unit): String {
+ keychain.delete(Keychain.Key.SHARED_PUBKY_SOURCE.name)
+ val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
+ if (!storedSecretKeyHex.isNullOrEmpty()) {
+ pubkyService.signIn(storedSecretKeyHex)
+ return pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix()
+ }
+
+ val (publicKey, secretKeyHex) = deriveKeys().getOrThrow()
+ val signupDetails: Pair = Env.e2eHomeserverPubky?.let { it to null }
+ ?: fetchHomegateSignupCode().let { it.homeserverPubky to it.signupCode }
+
+ markSessionCreated()
+ runSuspendCatching {
+ pubkyService.signUp(secretKeyHex, signupDetails.first, signupDetails.second)
+ }.getOrElse {
+ Logger.warn("Retrying sign in after sign up failed", it, context = TAG)
+ pubkyService.signIn(secretKeyHex)
+ }
+ return publicKey
+ }
+
private suspend fun publishIdentityProfile(
name: String,
bio: String,
@@ -657,7 +511,6 @@ class PubkyRepo @Inject constructor(
status = null,
)
_publicKey.update { publicKey }
- _authState.update { PubkyAuthState.Authenticated }
_profile.update { createdProfile }
cacheMetadata(createdProfile)
settingsStore.setPubkyProfileSetupPending(false)
@@ -672,6 +525,26 @@ class PubkyRepo @Inject constructor(
discardAbandonedSession()
}
+ private suspend fun discardAbandonedSession() {
+ val revocationError = runSuspendCatching {
+ withContext(NonCancellable + ioDispatcher) {
+ pubkyService.signOut()
+ }
+ }.exceptionOrNull() ?: return
+
+ Logger.warn("Failed to revoke abandoned Pubky session", revocationError, context = TAG)
+ runSuspendCatching {
+ withContext(NonCancellable + ioDispatcher) {
+ pubkyService.forgetSessionAccess()
+ }
+ }.onFailure {
+ Logger.warn("Failed to forget abandoned Pubky session access", it, context = TAG)
+ withContext(NonCancellable + ioDispatcher) {
+ clearLocalState(publicPaykitCleanupPending = true)
+ }
+ }
+ }
+
suspend fun uploadAvatar(imageBytes: ByteArray): Result = runSuspendCatching {
withContext(ioDispatcher) {
requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) {
@@ -1006,6 +879,10 @@ class PubkyRepo @Inject constructor(
managedSecretKeyFor(publicKey) != null
}.getOrDefault(false)
+ suspend fun hasStoredSecretKey(): Boolean = withContext(ioDispatcher) {
+ keychain.exists(Keychain.Key.PUBKY_SECRET_KEY.name)
+ }
+
suspend fun hasIdentity(): Boolean = withContext(ioDispatcher) {
_publicKey.value != null ||
!keychain.loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrEmpty() ||
@@ -1042,6 +919,7 @@ class PubkyRepo @Inject constructor(
val (publicKey, secretKeyHex) = deriveKeys().getOrThrow()
if (hasIdentity()) throw PubkyAlreadySignedInError
+ keychain.delete(Keychain.Key.SHARED_PUBKY_SOURCE.name)
settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) }
val registeredSession = pubkyService.registerIdentity(
secretKeyHex = secretKeyHex,
@@ -1062,7 +940,6 @@ class PubkyRepo @Inject constructor(
}
_publicKey.update { publicKey }
- _authState.update { PubkyAuthState.Authenticated }
var pendingSaved = false
try {
settingsStore.setPubkyProfileSetupPending(true)
@@ -1089,7 +966,7 @@ class PubkyRepo @Inject constructor(
approvedClientId: String,
): Result = runSuspendCatching {
withContext(ioDispatcher) {
- val secretKeyHex = requireNotNull(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) {
+ val secretKeyHex = requireNotNull(activeSecretKeyHex()) {
"No secret key available — use Ring to manage authorizations"
}
pubkyService.approveAuth(authUrl, expectedCapabilities, approvedClientId, secretKeyHex)
@@ -1102,7 +979,7 @@ class PubkyRepo @Inject constructor(
unsignedPayload: ByteArray,
): Result = runSuspendCatching {
withContext(ioDispatcher) {
- val secretKeyHex = requireNotNull(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) {
+ val secretKeyHex = requireNotNull(activeSecretKeyHex()) {
"No secret key available — use Ring to manage authorizations"
}
pubkyService.approveAuthWithCompanionClaim(
@@ -1125,20 +1002,10 @@ class PubkyRepo @Inject constructor(
suspend fun snapshotSessionBackupState(): Result = runSuspendCatching {
withContext(ioDispatcher) {
- val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
- if (!secretKeyHex.isNullOrEmpty()) {
- return@withContext PubkySessionBackupV1(kind = PubkySessionBackupKind.LocalSeed)
- }
-
- val sessionSecret = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)
- if (!sessionSecret.isNullOrEmpty()) {
- return@withContext PubkySessionBackupV1(
- kind = PubkySessionBackupKind.ExternalSession,
- sessionSecret = sessionSecret,
- )
- }
+ if (keychain.exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)) return@withContext null
+ if (keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrEmpty()) return@withContext null
- null
+ PubkySessionBackupV1(kind = PubkySessionBackupKind.LocalSeed)
}
}
@@ -1164,6 +1031,7 @@ class PubkyRepo @Inject constructor(
clearAuthenticatedState()
runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) }
runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) }
+ runCatching { keychain.delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) }
when (backup?.kind) {
null -> Unit
@@ -1172,19 +1040,10 @@ class PubkyRepo @Inject constructor(
val secretKeyHex = deriveLocalSecretKeyFromWalletSeed()
keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex)
pubkyService.signIn(secretKeyHex)
- val publicKey = pubkyService.publicKeyFromSecret(secretKeyHex).ensurePubkyPrefix()
- _publicKey.update { publicKey }
- _authState.update { PubkyAuthState.Authenticated }
+ _publicKey.update { pubkyService.publicKeyFromSecret(secretKeyHex).ensurePubkyPrefix() }
}
- PubkySessionBackupKind.ExternalSession -> {
- val sessionSecret = requireNotNull(backup.sessionSecret?.takeIf { it.isNotBlank() }) {
- "Missing session secret in backup"
- }
- val publicKey = pubkyService.importExternalSession(sessionSecret).ensurePubkyPrefix()
- _publicKey.update { publicKey }
- _authState.update { PubkyAuthState.Authenticated }
- }
+ PubkySessionBackupKind.ExternalSession -> Unit
}
notifyBackupStateChanged()
@@ -1205,15 +1064,13 @@ class PubkyRepo @Inject constructor(
suspend fun refreshSessionIfPossible(): Result = runSuspendCatching {
withContext(ioDispatcher) {
- val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
- ?: return@withContext false
+ val storedSecretKeyHex = activeSecretKeyHex() ?: return@withContext false
pubkyService.signIn(storedSecretKeyHex)
val publicKey = pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix()
notifyBackupStateChanged()
_publicKey.update { publicKey }
- _authState.update { PubkyAuthState.Authenticated }
true
}
@@ -1376,8 +1233,14 @@ class PubkyRepo @Inject constructor(
}
private suspend fun managedSecretKeyFor(publicKey: String): String? = withContext(ioDispatcher) {
+ val bareKey = publicKey.removePrefix(PUBKY_PREFIX)
val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
- ?: return@withContext null
+ ?: return@withContext bareKey
+ .takeIf {
+ keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name) ==
+ "${SharedPubkyContract.RING_SOURCE_PREFIX}$bareKey"
+ }
+ ?.let { sharedPubkyClient.ringCredential(it).getOrNull() }
val derivedPublicKey = runCatching {
pubkyService.publicKeyFromSecret(secretKeyHex).ensurePubkyPrefix()
@@ -1397,6 +1260,21 @@ class PubkyRepo @Inject constructor(
null
}
+ private suspend fun adoptedSecretKeyHex(): String? {
+ val pubky = runCatching { keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name) }.getOrNull()
+ ?.substringAfter(SharedPubkyContract.RING_SOURCE_PREFIX, "")
+ ?.takeIf { it.isNotBlank() }
+ ?: return null
+ return sharedPubkyClient.ringCredential(pubky)
+ .onFailure { Logger.warn("Failed to read adopted ring credential", it, context = TAG) }
+ .getOrNull()
+ }
+
+ private suspend fun activeSecretKeyHex(): String? {
+ val publicKey = _publicKey.value ?: return keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
+ return managedSecretKeyFor(publicKey)
+ }
+
private suspend fun deriveLocalSecretKeyFromWalletSeed(): String = withContext(ioDispatcher) {
val mnemonic = requireNotNull(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)) {
"BIP39 mnemonic not found in keychain"
@@ -1418,7 +1296,6 @@ class PubkyRepo @Inject constructor(
_contactsLoadCompletionVersion.update { 0L }
clearPendingImport()
_sessionRestorationFailed.update { false }
- _authState.update { PubkyAuthState.Idle }
}
private fun markContactsLoaded() {
diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
index 9879e3d44f..ecbfdffb84 100644
--- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
+++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
@@ -43,7 +43,6 @@ import com.synonym.paykit.PrivateReceivingDetailReservationResponse
import com.synonym.paykit.PrivateReceivingDetailReservationResponseKind
import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport
import com.synonym.paykit.PubkyAuthCompanionClaim
-import com.synonym.paykit.PubkyAuthRequest
import com.synonym.paykit.PubkyClientConfig
import com.synonym.paykit.PubkyLocalSecretKey
import com.synonym.paykit.PubkyProfile
@@ -87,6 +86,8 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.lightningdevkit.ldknode.Network
import to.bitkit.async.BaseCoroutineScope
import to.bitkit.data.keychain.Keychain
+import to.bitkit.data.sharedpubky.SharedPubkyClient
+import to.bitkit.data.sharedpubky.SharedPubkyContract
import to.bitkit.di.IoDispatcher
import to.bitkit.env.Env
import to.bitkit.ext.fromHex
@@ -169,10 +170,11 @@ internal object PaykitReceiverPaths {
class PaykitSdkService @Inject constructor(
@ApplicationContext private val context: Context,
private val keychain: Keychain,
+ sharedPubky: SharedPubkyClient,
@IoDispatcher ioDispatcher: CoroutineDispatcher,
) : BaseCoroutineScope(ioDispatcher, TAG) {
private val stateStore = PaykitSdkStateBlobStore(keychain)
- private val sessionProvider = PaykitSdkSessionProvider(keychain)
+ private val sessionProvider = PaykitSdkSessionProvider(keychain, sharedPubky)
private val paymentAdapter = PaykitSdkPaymentAdapter()
private val pubkyClientConfig by lazy { paykitPubkyClientConfig() }
private var bootstrapFactory = {
@@ -191,7 +193,6 @@ class PaykitSdkService @Inject constructor(
private var isSetup = CompletableDeferred()
private var setupFailed = false
private var sdk: PaykitSdk? = null
- private var activeAuthRequest: PubkyAuthRequest? = null
private val _backupStateVersion = MutableStateFlow(0L)
val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow()
private var sdkFactory: () -> PaykitSdk = {
@@ -209,8 +210,9 @@ class PaykitSdkService @Inject constructor(
keychain: Keychain,
bootstrapFactory: (() -> PubkySessionBootstrap)? = null,
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
+ sharedPubky: SharedPubkyClient = SharedPubkyClient(context, ioDispatcher),
sdkFactory: () -> PaykitSdk,
- ) : this(context, keychain, ioDispatcher) {
+ ) : this(context, keychain, sharedPubky, ioDispatcher) {
this.sdkFactory = sdkFactory
if (bootstrapFactory != null) this.bootstrapFactory = bootstrapFactory
isSetup.complete(Unit)
@@ -290,6 +292,12 @@ class PaykitSdkService @Inject constructor(
?: Logger.debug("Continuing while Pubky identity publication is pending", context = TAG)
}
+ /** Rebroadcasts the identity record when one exists. Returns false only when the network reports none. */
+ suspend fun hasIdentityRecord(publicKey: String): Boolean {
+ isSetup.await()
+ return bootstrap().republishIdentity(publicKey)
+ }
+
suspend fun currentPublicKey(): String? {
isSetup.await()
return operationMutex.withLock {
@@ -306,15 +314,12 @@ class PaykitSdkService @Inject constructor(
}
}
- suspend fun importSession(
- secret: String,
- includeLocalSecret: Boolean = true,
- ): PubkySessionBootstrapResult {
+ suspend fun importSession(secret: String): PubkySessionBootstrapResult {
isSetup.await()
val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() }
val result = bootstrap().importSession(
sessionSecret = secret,
- localSecretKey = if (includeLocalSecret) sessionProvider.loadLocalSecretKey() else null,
+ localSecretKey = sessionProvider.loadLocalSecretKey(),
receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(),
requiredCapabilities = requiredSessionCapabilities(paykitSdkConfig()),
)
@@ -322,7 +327,6 @@ class PaykitSdkService @Inject constructor(
activateBootstrapResult(
result = result,
previousPublicKey = previousPublicKey,
- shouldStoreLocalSecret = includeLocalSecret,
)
}
notifyBackupStateChanged()
@@ -347,7 +351,6 @@ class PaykitSdkService @Inject constructor(
activateBootstrapResult(
result = result,
previousPublicKey = previousPublicKey,
- shouldStoreLocalSecret = true,
)
}
notifyBackupStateChanged()
@@ -378,7 +381,6 @@ class PaykitSdkService @Inject constructor(
activateBootstrapResult(
result = result,
previousPublicKey = previousPublicKey,
- shouldStoreLocalSecret = true,
)
activated = true
} finally {
@@ -400,56 +402,12 @@ class PaykitSdkService @Inject constructor(
activateBootstrapResult(
result = result,
previousPublicKey = previousPublicKey,
- shouldStoreLocalSecret = true,
)
}
notifyBackupStateChanged()
return result
}
- suspend fun startAuth(): String {
- isSetup.await()
- return operationMutex.withLock {
- val request = bootstrap().startSignInAuth(requiredCapabilities())
- activeAuthRequest = request
- request.authorizationUrl()
- }
- }
-
- suspend fun completeAuth(): PubkySessionBootstrapResult {
- isSetup.await()
- return operationMutex.withLock {
- val request = requireNotNull(activeAuthRequest) { "No active Pubky auth request" }
- val previousPublicKey = currentSdkStatePublicKeyLocked()
- var completed = false
- try {
- request.complete(
- localSecretKey = null,
- receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(),
- requiredCapabilities = requiredCapabilities(),
- ).also {
- activateBootstrapResult(
- result = it,
- previousPublicKey = previousPublicKey,
- shouldStoreLocalSecret = false,
- )
- notifyBackupStateChanged()
- completed = true
- }
- } finally {
- activeAuthRequest = null
- if (!completed) resetRuntime()
- }
- }
- }
-
- suspend fun cancelAuth() {
- isSetup.await()
- operationMutex.withLock {
- activeAuthRequest = null
- }
- }
-
suspend fun approveAuth(
authUrl: String,
expectedCapabilities: String,
@@ -952,10 +910,11 @@ class PaykitSdkService @Inject constructor(
}
}
+ suspend fun clearSessionAccess() = operationMutex.withLock { clearRegisteredIdentityActivationLocked() }
+
suspend fun forgetSessionAccess() {
isSetup.await()
operationMutex.withLock {
- activeAuthRequest = null
try {
withStateRevisionTracking { handle ->
handle.forgetSessionAccess()
@@ -974,7 +933,6 @@ class PaykitSdkService @Inject constructor(
private suspend fun clearStateLocked() {
keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name)
- activeAuthRequest = null
resetRuntime()
notifyBackupStateChanged()
}
@@ -983,14 +941,11 @@ class PaykitSdkService @Inject constructor(
return handle().identityStatus()?.publicKey
}
- private suspend fun persistSessionAccess(
- access: PubkySessionAccess,
- shouldStoreLocalSecret: Boolean,
- ) {
+ private suspend fun persistSessionAccess(access: PubkySessionAccess) {
keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, access.exportSessionSecret())
sessionProvider.persistReceiverNoiseSecretKey(access.exportReceiverNoiseSecretKey())
val localSecret = access.exportLocalSecretKey()
- if (shouldStoreLocalSecret && localSecret != null) {
+ if (localSecret != null && sessionProvider.adoptedPubky() == null) {
keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex(localSecret))
} else {
keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name)
@@ -1000,9 +955,8 @@ class PaykitSdkService @Inject constructor(
private suspend fun activateBootstrapResult(
result: PubkySessionBootstrapResult,
previousPublicKey: String?,
- shouldStoreLocalSecret: Boolean,
) {
- persistSessionAccess(result.sessionAccess, shouldStoreLocalSecret)
+ persistSessionAccess(result.sessionAccess)
sessionProvider.setLiveSessionAccess(result.sessionAccess)
if (!PubkyPublicKeyFormat.matches(previousPublicKey, result.publicKey)) {
keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name)
@@ -1214,6 +1168,7 @@ private class PaykitSdkStateBlobStore(
internal class PaykitSdkSessionProvider(
private val keychain: Keychain,
+ private val sharedPubky: SharedPubkyClient,
) : SdkPubkySessionProvider {
private val lock = Any()
private val receiverNoiseKeyStore = PaykitReceiverNoiseKeyStore(keychain)
@@ -1268,6 +1223,7 @@ internal class PaykitSdkSessionProvider(
override fun clearSessionAccess() {
clearLiveSessionAccess()
keychain.accessBlocking {
+ delete(Keychain.Key.SHARED_PUBKY_SOURCE.name)
clearPubkySessionCredentials(::delete)
}
}
@@ -1276,9 +1232,14 @@ internal class PaykitSdkSessionProvider(
const val STALE_SESSION_RESTORE_CONTEXT = "restore Pubky grant session from platform provider"
}
+ fun adoptedPubky(): String? = keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name)
+ ?.substringAfter(SharedPubkyContract.RING_SOURCE_PREFIX, "")
+ ?.takeIf { it.isNotBlank() }
+
fun loadLocalSecretKey(): PubkyLocalSecretKey? {
val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
?.takeIf { it.isNotBlank() }
+ ?: adoptedPubky()?.let { sharedPubky.readCredential(it) }
?: return null
return PaykitSdkService.localSecretKey(secretKeyHex)
}
diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt
index b0d468aa50..3735b17bfa 100644
--- a/app/src/main/java/to/bitkit/services/PubkyService.kt
+++ b/app/src/main/java/to/bitkit/services/PubkyService.kt
@@ -40,10 +40,6 @@ class PubkyService @Inject constructor(
paykitSdkService.importSession(secret).publicKey
}
- suspend fun importExternalSession(secret: String): String = ServiceQueue.CORE.background {
- paykitSdkService.importSession(secret, includeLocalSecret = false).publicKey
- }
-
suspend fun currentPublicKey(): String? = ServiceQueue.CORE.background {
paykitSdkService.currentPublicKey()
}
@@ -56,6 +52,10 @@ class PubkyService @Inject constructor(
paykitSdkService.forgetSessionAccess()
}
+ suspend fun clearSessionAccess() = ServiceQueue.CORE.background {
+ paykitSdkService.clearSessionAccess()
+ }
+
suspend fun removeBitkitPaymentEndpoints() = ServiceQueue.CORE.background {
val endpointError = runSuspendCatching {
val report = paykitSdkService.syncPublicEndpoints(emptyList())
@@ -109,21 +109,8 @@ class PubkyService @Inject constructor(
Unit
}
- // endregion
-
- // region Auth flow (Ring)
-
- suspend fun startAuth(): String = ServiceQueue.CORE.background {
- paykitSdkService.startAuth()
- }
-
- suspend fun completeAuth(): Unit = ServiceQueue.CORE.background {
- paykitSdkService.completeAuth()
- Unit
- }
-
- suspend fun cancelAuth() = ServiceQueue.CORE.background {
- paykitSdkService.cancelAuth()
+ suspend fun hasIdentityRecord(publicKey: String): Boolean = ServiceQueue.CORE.background {
+ paykitSdkService.hasIdentityRecord(publicKey)
}
// endregion
diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt
index c067d77d79..b527dabdf7 100644
--- a/app/src/main/java/to/bitkit/ui/ContentView.kt
+++ b/app/src/main/java/to/bitkit/ui/ContentView.kt
@@ -296,6 +296,7 @@ fun ContentView(
appWidgetRefreshScheduler.requestCatchUp(AppWidgetRefreshReason.APP_FOREGROUND)
currencyViewModel.triggerRefresh()
blocktankViewModel.refreshOrders()
+ appViewModel.checkAdoptedPubkySource()
appViewModel.refreshPublicPaykitEndpoints()
appViewModel.refreshPrivatePaykitEndpoints()
appViewModel.startPaykitPaymentRequestPolling()
@@ -362,6 +363,9 @@ fun ContentView(
navController.navigateTo(it.route)
}
+ MainScreenEffect.NavigateToPubkyChoice ->
+ navController.navigateTo(Routes.PubkyChoice) { popUpTo(Routes.Home) }
+
is MainScreenEffect.ProcessClipboardAutoRead -> {
val isOnHome = navController.currentDestination?.hasRoute() == true
if (!isOnHome) {
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileViewModel.kt
index 9b1aafcef3..d8c037eef4 100644
--- a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileViewModel.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileViewModel.kt
@@ -48,10 +48,15 @@ class CreateProfileViewModel @Inject constructor(
private fun deriveAndCheckRemote() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
+ pubkyRepo.publicKey.value?.let { publicKey ->
+ _uiState.update { it.copy(derivedPublicKey = publicKey) }
+ checkForExistingProfile(publicKey, isSignedUp = true)
+ return@launch
+ }
pubkyRepo.deriveKeys()
.onSuccess { (publicKey, _) ->
_uiState.update { it.copy(derivedPublicKey = publicKey) }
- checkForExistingProfile(publicKey)
+ checkForExistingProfile(publicKey, isSignedUp = pubkyRepo.hasStoredSecretKey())
}
.onFailure {
Logger.error("Failed to derive keys", it, context = TAG)
@@ -65,13 +70,14 @@ class CreateProfileViewModel @Inject constructor(
}
}
- private suspend fun checkForExistingProfile(publicKey: String) {
+ private suspend fun checkForExistingProfile(publicKey: String, isSignedUp: Boolean) {
pubkyRepo.fetchRemoteProfile(publicKey)
.onSuccess { profile ->
if (profile != null) {
_uiState.update {
it.copy(
isLoading = false,
+ remoteLookupFailed = false,
isRestoring = true,
name = profile.name,
bio = profile.bio,
@@ -82,12 +88,22 @@ class CreateProfileViewModel @Inject constructor(
)
}
} else {
- _uiState.update { it.copy(isLoading = false) }
+ _uiState.update { it.copy(isLoading = false, remoteLookupFailed = false) }
}
}
- .onFailure {
- Logger.debug("No existing remote profile found for '$publicKey'", context = TAG)
- _uiState.update { it.copy(isLoading = false) }
+ .onFailure { error ->
+ if (!isSignedUp) {
+ Logger.debug("No existing remote profile found for '$publicKey'", context = TAG)
+ _uiState.update { it.copy(isLoading = false) }
+ return@onFailure
+ }
+ Logger.warn("Failed to look up the existing profile for '$publicKey'", error, context = TAG)
+ _uiState.update { it.copy(isLoading = false, remoteLookupFailed = true) }
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.common__error),
+ description = error.message,
+ )
}
}
@@ -164,6 +180,10 @@ class CreateProfileViewModel @Inject constructor(
}
fun save() {
+ if (_uiState.value.remoteLookupFailed) {
+ deriveAndCheckRemote()
+ return
+ }
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true) }
val state = _uiState.value
@@ -201,6 +221,7 @@ data class CreateProfileUiState(
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val isRestoring: Boolean = false,
+ val remoteLookupFailed: Boolean = false,
val showAddLinkSheet: Boolean = false,
val showAddTagSheet: Boolean = false,
)
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt
index 04c84e6a2c..a48ea24504 100644
--- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt
@@ -1,7 +1,5 @@
package to.bitkit.ui.screens.profile
-import android.content.Intent
-import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -12,12 +10,15 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -28,23 +29,23 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.collections.immutable.persistentListOf
import to.bitkit.R
+import to.bitkit.models.PubkyProfile
import to.bitkit.ui.components.BodyM
import to.bitkit.ui.components.BodyMSB
+import to.bitkit.ui.components.Caption13Up
import to.bitkit.ui.components.Display
-import to.bitkit.ui.components.FillHeight
import to.bitkit.ui.components.GradientCircularProgressIndicator
import to.bitkit.ui.components.HorizontalSpacer
-import to.bitkit.ui.components.SecondaryButton
+import to.bitkit.ui.components.PubkyContactAvatar
import to.bitkit.ui.components.VerticalSpacer
-import to.bitkit.ui.scaffold.AppAlertDialog
import to.bitkit.ui.scaffold.AppTopBar
import to.bitkit.ui.scaffold.DrawerNavIcon
import to.bitkit.ui.shared.util.screen
@@ -52,8 +53,6 @@ import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.theme.Colors
import to.bitkit.ui.utils.withAccent
-private const val PUBKY_RING_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=to.pubky.ring"
-
private const val TAG_WIDTH_FRACTION = 0.736f
private const val TAG_OFFSET_X = -0.197f
private const val TAG_OFFSET_Y = 0.067f
@@ -71,17 +70,11 @@ fun PubkyChoiceScreen(
onNavigateToProfile: () -> Unit,
onBackClick: () -> Unit,
) {
- val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.effects.collect {
when (it) {
- is PubkyChoiceEffect.OpenRingAuth -> runCatching {
- context.startActivity(it.intent)
- }.onFailure {
- viewModel.onRingLaunchFailed()
- }
PubkyChoiceEffect.NavigateToCreateProfile -> onNavigateToCreateProfile()
PubkyChoiceEffect.NavigateToContactImportOverview -> onNavigateToContactImportOverview()
PubkyChoiceEffect.NavigateToPayContacts -> onNavigateToPayContacts()
@@ -100,13 +93,7 @@ fun PubkyChoiceScreen(
uiState = uiState,
onBackClick = onBackClick,
onCreateProfile = onNavigateToCreateProfile,
- onImportWithRing = { viewModel.startRingAuth() },
- onCancelAuth = { viewModel.cancelAuth() },
- onDownloadRing = {
- viewModel.dismissRingNotInstalledDialog()
- context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PUBKY_RING_PLAY_STORE_URL)))
- },
- onDismissDialog = { viewModel.dismissRingNotInstalledDialog() },
+ onIdentityClick = viewModel::onIdentityClick,
)
}
@@ -115,10 +102,7 @@ private fun Content(
uiState: PubkyChoiceUiState,
onBackClick: () -> Unit,
onCreateProfile: () -> Unit,
- onImportWithRing: () -> Unit,
- onCancelAuth: () -> Unit,
- onDownloadRing: () -> Unit,
- onDismissDialog: () -> Unit,
+ onIdentityClick: (String) -> Unit,
) {
Box(
modifier = Modifier
@@ -155,7 +139,13 @@ private fun Content(
actions = { DrawerNavIcon() },
)
- Column(modifier = Modifier.padding(horizontal = 32.dp)) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .navigationBarsPadding()
+ .padding(horizontal = 32.dp)
+ ) {
VerticalSpacer(24.dp)
Display(
@@ -166,45 +156,53 @@ private fun Content(
VerticalSpacer(8.dp)
BodyM(
- text = stringResource(R.string.profile__choice_description),
+ text = stringResource(
+ if (uiState.identities.isEmpty()) {
+ R.string.profile__choice_description
+ } else {
+ R.string.profile__choice_description_ring
+ }
+ ),
color = Colors.White64,
)
VerticalSpacer(24.dp)
- if (uiState.isLoadingAfterAuth) {
- LoadingState(text = stringResource(R.string.profile__choice_loading_profile))
- } else if (uiState.isWaitingForRing) {
- WaitingForRingState(onCancel = onCancelAuth)
- } else {
- OptionCard(
+ when {
+ uiState.isLoading || uiState.adoptingPubky != null ->
+ LoadingState(text = stringResource(R.string.profile__choice_loading_profile))
+
+ uiState.identities.isEmpty() -> OptionCard(
iconResId = R.drawable.ic_user_plus,
text = stringResource(R.string.profile__choice_create),
onClick = onCreateProfile,
+ caption = stringResource(R.string.profile__choice_create_caption),
modifier = Modifier.testTag("PubkyChoiceCreate")
)
- VerticalSpacer(8.dp)
- OptionCard(
- iconResId = R.drawable.ic_lock_key,
- text = stringResource(R.string.profile__choice_import),
- onClick = onImportWithRing,
- modifier = Modifier.testTag("PubkyChoiceImport")
- )
+
+ else -> uiState.identities.forEachIndexed { index, identity ->
+ if (index > 0) VerticalSpacer(8.dp)
+ OptionCard(
+ iconResId = R.drawable.ic_lock_key,
+ text = identity.name,
+ onClick = { onIdentityClick(identity.pubky) },
+ caption = identity.caption,
+ trailing = {
+ PubkyContactAvatar(
+ profile = PubkyProfile.forDisplay(
+ publicKey = identity.pubky,
+ name = identity.name,
+ imageUrl = identity.imageUrl,
+ ),
+ size = 32.dp,
+ )
+ },
+ modifier = Modifier.testTag("PubkyChoiceIdentity")
+ )
+ }
}
}
-
- FillHeight()
}
}
-
- if (uiState.showRingNotInstalledDialog) {
- AppAlertDialog(
- title = stringResource(R.string.profile__ring_not_installed_title),
- text = stringResource(R.string.profile__ring_not_installed_description),
- confirmText = stringResource(R.string.profile__ring_download),
- onConfirm = onDownloadRing,
- onDismiss = onDismissDialog,
- )
- }
}
@Composable
@@ -213,6 +211,8 @@ private fun OptionCard(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
+ caption: String? = null,
+ trailing: (@Composable () -> Unit)? = null,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -237,54 +237,60 @@ private fun OptionCard(
)
}
HorizontalSpacer(16.dp)
- BodyMSB(text = text, color = Colors.White)
+ Column(modifier = Modifier.weight(1f)) {
+ caption?.let { Caption13Up(text = it, color = Colors.White64) }
+ BodyMSB(text = text, color = Colors.White)
+ }
+ if (trailing != null) {
+ trailing()
+ }
}
}
@Composable
-private fun WaitingForRingState(onCancel: () -> Unit) {
+private fun LoadingState(text: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
GradientCircularProgressIndicator(modifier = Modifier.size(20.dp))
HorizontalSpacer(12.dp)
- BodyM(
- text = stringResource(R.string.profile__choice_waiting_ring),
- color = Colors.White64,
- )
+ BodyM(text = text, color = Colors.White64)
}
- VerticalSpacer(16.dp)
- SecondaryButton(
- text = stringResource(R.string.common__cancel),
- onClick = onCancel,
- )
}
+@Preview(showBackground = true)
@Composable
-private fun LoadingState(text: String) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth()
- ) {
- GradientCircularProgressIndicator(modifier = Modifier.size(20.dp))
- HorizontalSpacer(12.dp)
- BodyM(text = text, color = Colors.White64)
+private fun PreviewIdentities() {
+ AppThemeSurface {
+ Content(
+ uiState = PubkyChoiceUiState(
+ isLoading = false,
+ identities = persistentListOf(
+ RingIdentity(
+ pubky = "a967rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roimbr4",
+ caption = "A967...MBR4",
+ name = "Satoshi Nakamoto",
+ imageUrl = null,
+ ),
+ ),
+ ),
+ onBackClick = {},
+ onCreateProfile = {},
+ onIdentityClick = {},
+ )
}
}
@Preview(showBackground = true)
@Composable
-private fun Preview() {
+private fun PreviewCreate() {
AppThemeSurface {
Content(
- uiState = PubkyChoiceUiState(),
+ uiState = PubkyChoiceUiState(isLoading = false),
onBackClick = {},
onCreateProfile = {},
- onImportWithRing = {},
- onCancelAuth = {},
- onDownloadRing = {},
- onDismissDialog = {},
+ onIdentityClick = {},
)
}
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt
index 5414de9a61..17eef049ff 100644
--- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt
@@ -1,15 +1,14 @@
package to.bitkit.ui.screens.profile
import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.Job
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -18,133 +17,63 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import to.bitkit.R
-import to.bitkit.models.PubkyRingAuthUrlBuilder
+import to.bitkit.models.PubkyPublicKeyFormat
import to.bitkit.models.Toast
import to.bitkit.repositories.PubkyRepo
import to.bitkit.ui.shared.toast.ToastEventBus
import to.bitkit.utils.Logger
import javax.inject.Inject
+private const val TAG = "PubkyChoiceViewModel"
+
@HiltViewModel
class PubkyChoiceViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val pubkyRepo: PubkyRepo,
) : ViewModel() {
- companion object {
- private const val TAG = "PubkyChoiceViewModel"
- internal const val PUBKY_RING_PACKAGE = "to.pubky.ring"
- }
-
private val _uiState = MutableStateFlow(PubkyChoiceUiState())
val uiState = _uiState.asStateFlow()
private val _effects = MutableSharedFlow(extraBufferCapacity = 1)
val effects = _effects.asSharedFlow()
- private var approvalJob: Job? = null
-
init {
- viewModelScope.launch {
- pubkyRepo.authCancelEvents.collect {
- approvalJob?.cancel()
- approvalJob = null
- _uiState.update { it.copy(isWaitingForRing = false, isLoadingAfterAuth = false) }
- }
- }
+ loadIdentities()
viewModelScope.launch {
pubkyRepo.isAuthenticated.collectLatest {
- if (it && approvalJob?.isActive != true && !_uiState.value.isLoadingAfterAuth) {
+ if (it && _uiState.value.adoptingPubky == null) {
_uiState.update { state -> state.copy(navigateToProfile = true) }
}
}
}
}
- override fun onCleared() {
- super.onCleared()
- if (_uiState.value.isWaitingForRing) {
- pubkyRepo.cancelAuthenticationSync()
- }
- }
-
- fun startRingAuth() {
- viewModelScope.launch {
- if (_uiState.value.isWaitingForRing) {
- approvalJob?.cancel()
- approvalJob = null
- _uiState.update { it.copy(isWaitingForRing = false) }
- pubkyRepo.cancelAuthentication()
- }
-
- if (!isRingInstalled()) {
- showRingNotInstalledDialog()
- return@launch
- }
-
- pubkyRepo.startAuthentication()
- .onSuccess { authRequest ->
- val callbackAuthUrl = PubkyRingAuthUrlBuilder.addCallbacks(
- authUrl = authRequest.authUrl,
- nonce = authRequest.callbackNonce,
- ) ?: authRequest.authUrl
- val ringIntent = createRingAuthIntent(callbackAuthUrl)
- if (!canOpenWithRing(ringIntent)) {
- cancelAuthAndShowRingDialog()
- return@launch
- }
-
- _uiState.update { it.copy(isWaitingForRing = true) }
- _effects.emit(PubkyChoiceEffect.OpenRingAuth(ringIntent))
- waitForApproval()
- }
- .onFailure {
- Logger.error("Starting Ring auth failed", it, context = TAG)
- ToastEventBus.send(
- type = Toast.ToastType.ERROR,
- title = context.getString(R.string.profile__auth_error_title),
- description = it.message,
- )
- }
- }
- }
-
- fun onRingLaunchFailed() {
+ fun onIdentityClick(pubky: String) {
viewModelScope.launch {
- cancelAuthAndShowRingDialog()
- }
- }
-
- @VisibleForTesting
- internal fun waitForApproval() {
- if (approvalJob?.isActive == true) return
-
- approvalJob = viewModelScope.launch {
- pubkyRepo.completeAuthentication()
- .onSuccess {
- _uiState.update { it.copy(isWaitingForRing = false, isLoadingAfterAuth = true) }
- pubkyRepo.prepareImport()
- .onSuccess {
- _uiState.update { state -> state.copy(isLoadingAfterAuth = false) }
- val hasContacts = pubkyRepo.pendingImportContacts.value.isNotEmpty()
- if (hasContacts) {
- _effects.emit(PubkyChoiceEffect.NavigateToContactImportOverview)
- } else {
- _effects.emit(PubkyChoiceEffect.NavigateToPayContacts)
- }
- }
- .onFailure {
- Logger.error("Preparing contact import failed", it, context = TAG)
- _uiState.update { state -> state.copy(isLoadingAfterAuth = false) }
+ _uiState.update { it.copy(adoptingPubky = pubky) }
+ pubkyRepo.adoptRingIdentity(pubky)
+ .onSuccess { hasProfile ->
+ if (hasProfile) {
+ pubkyRepo.prepareImport().onFailure {
+ Logger.error("Failed to prepare contact import", it, context = TAG)
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.common__error),
description = it.message,
)
}
+ }
+ _uiState.update { it.copy(adoptingPubky = null) }
+ val effect = when {
+ !hasProfile -> PubkyChoiceEffect.NavigateToCreateProfile
+ pubkyRepo.pendingImportContacts.value.isEmpty() -> PubkyChoiceEffect.NavigateToPayContacts
+ else -> PubkyChoiceEffect.NavigateToContactImportOverview
+ }
+ _effects.emit(effect)
}
.onFailure {
- Logger.error("Auth approval failed", it, context = TAG)
- _uiState.update { it.copy(isWaitingForRing = false) }
+ Logger.error("Failed to adopt ring identity", it, context = TAG)
+ _uiState.update { state -> state.copy(adoptingPubky = null) }
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.profile__auth_error_title),
@@ -154,65 +83,49 @@ class PubkyChoiceViewModel @Inject constructor(
}
}
- fun cancelAuth() {
- viewModelScope.launch {
- approvalJob?.cancel()
- approvalJob = null
- pubkyRepo.cancelAuthentication()
- _uiState.update { it.copy(isWaitingForRing = false, isLoadingAfterAuth = false) }
- }
- }
-
- fun dismissRingNotInstalledDialog() {
- _uiState.update { it.copy(showRingNotInstalledDialog = false) }
- }
-
fun clearProfileNavigation() {
_uiState.update { it.copy(navigateToProfile = false) }
}
- @VisibleForTesting
- internal fun createRingAuthIntent(authUrl: String): Intent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl)).apply {
- setPackage(PUBKY_RING_PACKAGE)
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- }
-
- @VisibleForTesting
- internal fun isRingInstalled(): Boolean =
- context.packageManager.getLaunchIntentForPackage(PUBKY_RING_PACKAGE) != null
-
- @VisibleForTesting
- internal fun canOpenWithRing(intent: Intent): Boolean =
- intent.resolveActivity(context.packageManager) != null
-
- private suspend fun cancelAuthAndShowRingDialog() {
- approvalJob?.cancel()
- approvalJob = null
- pubkyRepo.cancelAuthentication()
- showRingNotInstalledDialog()
- }
-
- private fun showRingNotInstalledDialog() {
- _uiState.update {
- it.copy(
- isWaitingForRing = false,
- isLoadingAfterAuth = false,
- showRingNotInstalledDialog = true,
- )
+ private fun loadIdentities() {
+ viewModelScope.launch {
+ val pubkys = pubkyRepo.ringIdentities().getOrElse {
+ Logger.warn("Failed to list ring identities", it, context = TAG)
+ persistentListOf()
+ }
+ val identities = pubkys.map { pubky ->
+ val profile = pubkyRepo.fetchRemoteProfile(pubky).getOrNull()
+ val truncatedKey = PubkyPublicKeyFormat.display(pubky)
+ RingIdentity(
+ pubky = pubky,
+ caption = truncatedKey.uppercase(),
+ name = profile?.name?.takeIf { it.isNotBlank() } ?: truncatedKey,
+ imageUrl = profile?.imageUrl,
+ )
+ }.toImmutableList()
+
+ _uiState.update { it.copy(isLoading = false, identities = identities) }
}
}
}
@Immutable
data class PubkyChoiceUiState(
- val isWaitingForRing: Boolean = false,
- val isLoadingAfterAuth: Boolean = false,
- val showRingNotInstalledDialog: Boolean = false,
+ val isLoading: Boolean = true,
+ val identities: ImmutableList = persistentListOf(),
+ val adoptingPubky: String? = null,
val navigateToProfile: Boolean = false,
)
+@Immutable
+data class RingIdentity(
+ val pubky: String,
+ val caption: String,
+ val name: String,
+ val imageUrl: String?,
+)
+
sealed interface PubkyChoiceEffect {
- data class OpenRingAuth(val intent: Intent) : PubkyChoiceEffect
data object NavigateToCreateProfile : PubkyChoiceEffect
data object NavigateToContactImportOverview : PubkyChoiceEffect
data object NavigateToPayContacts : PubkyChoiceEffect
diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
index 63156f3ef6..c95e9d15a7 100644
--- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@@ -123,8 +123,6 @@ import to.bitkit.models.PubkyAuthRequest
import to.bitkit.models.PubkyContactLink
import to.bitkit.models.PubkyProfile
import to.bitkit.models.PubkyPublicKeyFormat
-import to.bitkit.models.PubkyRingAuthCallback
-import to.bitkit.models.PubkyRingAuthCallbackHandlingResult
import to.bitkit.models.SamRockSetupRequest
import to.bitkit.models.SendFailureDetails
import to.bitkit.models.Suggestion
@@ -543,6 +541,18 @@ class AppViewModel @Inject constructor(
}
}
}
+ viewModelScope.launch {
+ pubkyRepo.adoptedSourceLost.collect { lost ->
+ if (lost) {
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__source_lost),
+ )
+ mainScreenEffect(MainScreenEffect.NavigateToPubkyChoice)
+ pubkyRepo.clearAdoptedSourceLost()
+ }
+ }
+ }
observeReceiveSheetInvoice()
observeLdkNodeEvents()
observeLightningUsableChannels()
@@ -658,6 +668,10 @@ class AppViewModel @Inject constructor(
viewModelScope.launch { refreshPrivatePaykitEndpointsIfEnabled("foreground") }
}
+ fun checkAdoptedPubkySource() {
+ viewModelScope.launch { pubkyRepo.checkAdoptedSource() }
+ }
+
private suspend fun refreshPublicPaykitEndpointsIfEnabled(forceRefreshLightning: Boolean = false) {
val settings = settingsStore.data.first()
if (!isPaykitEnabled.value || !settings.sharesPublicPaykitEndpoints) return
@@ -5662,12 +5676,6 @@ class AppViewModel @Inject constructor(
return@launch
}
- PubkyRingAuthCallback.parse(uri)?.let {
- if (!isPaykitEnabled.value) return@launch
- handlePubkyRingAuthCallback(it)
- return@launch
- }
-
if (PubkyAuthRequest.isProtocolUrl(value)) {
launchScan(
source = ScanSource.DEEPLINK,
@@ -5716,7 +5724,8 @@ class AppViewModel @Inject constructor(
if (!isSignup && !pubkyRepo.hasSecretKey()) {
ToastEventBus.send(
type = Toast.ToastType.WARNING,
- title = context.getString(R.string.profile__auth_approval_ring_only),
+ title = context.getString(R.string.pubky_auth__use_ring),
+ description = context.getString(R.string.pubky_auth__use_ring_desc),
)
return
}
@@ -5741,21 +5750,6 @@ class AppViewModel @Inject constructor(
return true
}
- private suspend fun handlePubkyRingAuthCallback(callback: PubkyRingAuthCallback) {
- when (val result = pubkyRepo.handleAuthCallback(callback)) {
- is PubkyRingAuthCallbackHandlingResult.TrustedError -> {
- ToastEventBus.send(
- type = Toast.ToastType.ERROR,
- title = context.getString(R.string.profile__auth_error_title),
- description = result.message ?: context.getString(R.string.other__qr_error_text),
- )
- }
- PubkyRingAuthCallbackHandlingResult.Handled,
- PubkyRingAuthCallbackHandlingResult.Ignored,
- -> Unit
- }
- }
-
// TODO Temporary fix while these schemes can't be decoded https://github.com/synonymdev/bitkit-core/issues/70
private fun String.removeLightningSchemes(): String = LIGHTNING_SCHEME_PATTERNS.fold(this) { acc, regex ->
acc.replace(regex, "")
@@ -5977,6 +5971,7 @@ sealed class MainScreenEffect {
val clearStack: Boolean = false,
) : MainScreenEffect()
+ data object NavigateToPubkyChoice : MainScreenEffect()
data object WipeWallet : MainScreenEffect()
data class ProcessClipboardAutoRead(val data: String) : MainScreenEffect()
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 4efb27a6af..dd70950c2c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -638,7 +638,6 @@
OK
Requested permissions
Requester ID: %1$s
- Use Ring to manage authorizations
A service is requesting permission to access and edit your <accent>%1$s</accent> data.
Unknown service
Authorization Successful
@@ -659,11 +658,11 @@
This Bitkit claim is not supported.
Failed to read selected image
Create profile with Bitkit
+ New pubky
Create a new pubky and profile in Bitkit.
- Import with Pubky Ring
+ Use an existing pubky from Pubky Ring.
Loading your profile…
Enter the\n<accent>freedom web</accent>
- Waiting for Pubky Ring…
Failed to create profile
Create Profile
Restoring your existing profile…
@@ -701,18 +700,11 @@
Scan to add {name}
Restore Profile
Try Again
- Please authorize Bitkit with Pubky Ring, your mobile keychain for the next web.
- Join the\n<accent>pubky web</accent>
- Authorize
- Download
- Loading your profile…
- Pubky Ring is required to authorize your profile. Would you like to download it?
- Pubky Ring Not Installed
- Waiting for authorization from Pubky Ring…
Your profile session has expired. Please reconnect to restore your profile.
Disconnect
This will disconnect your Pubky profile from Bitkit. You can reconnect at any time.
Disconnect Profile
+ This pubky is no longer available in Pubky Ring. Your profile has been disconnected.
Suggestions
Suggestions To Add
Your Name
@@ -722,6 +714,8 @@
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.
+ Use Pubky Ring
+ Bitkit can\'t read the key for this pubky from Pubky Ring. Open Pubky Ring to approve this request.
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/data/sharedpubky/SharedPubkyClientTest.kt b/app/src/test/java/to/bitkit/data/sharedpubky/SharedPubkyClientTest.kt
new file mode 100644
index 0000000000..367f640a0f
--- /dev/null
+++ b/app/src/test/java/to/bitkit/data/sharedpubky/SharedPubkyClientTest.kt
@@ -0,0 +1,91 @@
+package to.bitkit.data.sharedpubky
+
+import android.app.Application
+import android.content.ContentProvider
+import android.content.Context
+import android.content.pm.PackageInfo
+import android.content.pm.ProviderInfo
+import android.content.pm.Signature
+import android.database.MatrixCursor
+import androidx.test.core.app.ApplicationProvider
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+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.Shadows
+import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowContentResolver
+import to.bitkit.test.BaseUnitTest
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@Config(application = Application::class, sdk = [34])
+@RunWith(RobolectricTestRunner::class)
+class SharedPubkyClientTest : BaseUnitTest() {
+ companion object {
+ private const val PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ private const val TRUSTED_SIGNATURE = "beef"
+ }
+
+ private val context = ApplicationProvider.getApplicationContext()
+ private val ringProvider = mock()
+ private lateinit var sut: SharedPubkyClient
+
+ @Before
+ fun setUp() {
+ Shadows.shadowOf(context.packageManager)
+ .getInternalMutablePackageInfo(context.packageName).signatures = arrayOf(Signature(TRUSTED_SIGNATURE))
+ ShadowContentResolver.registerProviderInternal(SharedPubkyContract.RING_AUTHORITY, ringProvider)
+ whenever(ringProvider.query(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenAnswer {
+ MatrixCursor(arrayOf(SharedPubkyContract.COLUMN_PUBKY)).apply {
+ addRow(arrayOf(PUBKY))
+ addRow(arrayOf(null))
+ }
+ }
+ sut = SharedPubkyClient(context, testDispatcher)
+ }
+
+ @Test
+ fun `listRingIdentities reads pubkys from a trusted ring provider`() = test {
+ installRing(TRUSTED_SIGNATURE)
+
+ assertEquals(listOf(PUBKY), sut.listRingIdentities().getOrThrow())
+ }
+
+ @Test
+ fun `ring provider with another signature is never queried`() = test {
+ installRing("dead")
+
+ assertTrue(sut.listRingIdentities().isFailure)
+ assertTrue(sut.ringCredential(PUBKY).isFailure)
+ verify(ringProvider, never()).query(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())
+ }
+
+ @Test
+ fun `listRingIdentities fails without pubky ring`() = test {
+ assertTrue(sut.listRingIdentities().isFailure)
+ verify(ringProvider, never()).query(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())
+ }
+
+ private fun installRing(signature: String) {
+ Shadows.shadowOf(context.packageManager).installPackage(
+ PackageInfo().apply {
+ packageName = SharedPubkyContract.RING_PACKAGE
+ signatures = arrayOf(Signature(signature))
+ providers = arrayOf(
+ ProviderInfo().apply {
+ name = "to.pubkyring.SharedPubkyProvider"
+ packageName = SharedPubkyContract.RING_PACKAGE
+ authority = SharedPubkyContract.RING_AUTHORITY
+ },
+ )
+ },
+ )
+ }
+}
diff --git a/app/src/test/java/to/bitkit/data/sharedpubky/SharedPubkyContractTest.kt b/app/src/test/java/to/bitkit/data/sharedpubky/SharedPubkyContractTest.kt
new file mode 100644
index 0000000000..50967d6338
--- /dev/null
+++ b/app/src/test/java/to/bitkit/data/sharedpubky/SharedPubkyContractTest.kt
@@ -0,0 +1,36 @@
+package to.bitkit.data.sharedpubky
+
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class SharedPubkyContractTest {
+ companion object {
+ private const val SECRET_KEY_HEX = "8f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8"
+ private const val PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ private const val OTHER_PUBKY = "1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ }
+
+ @Test
+ fun `isValidSecret accepts a secret deriving the pubky`() {
+ assertTrue(SharedPubkyContract.isValidSecret(SECRET_KEY_HEX, PUBKY) { PUBKY })
+ assertTrue(SharedPubkyContract.isValidSecret(SECRET_KEY_HEX, "pubky$PUBKY") { PUBKY })
+ }
+
+ @Test
+ fun `isValidSecret rejects a malformed secret`() {
+ assertFalse(SharedPubkyContract.isValidSecret(SECRET_KEY_HEX.drop(2), PUBKY) { PUBKY })
+ assertFalse(SharedPubkyContract.isValidSecret(SECRET_KEY_HEX.uppercase(), PUBKY) { PUBKY })
+ assertFalse(SharedPubkyContract.isValidSecret("z".repeat(SECRET_KEY_HEX.length), PUBKY) { PUBKY })
+ }
+
+ @Test
+ fun `isValidSecret rejects a secret deriving another pubky`() {
+ assertFalse(SharedPubkyContract.isValidSecret(SECRET_KEY_HEX, OTHER_PUBKY) { PUBKY })
+ }
+
+ @Test
+ fun `isValidSecret rejects a secret the sdk cannot derive`() {
+ assertFalse(SharedPubkyContract.isValidSecret(SECRET_KEY_HEX, PUBKY) { error("invalid secret") })
+ }
+}
diff --git a/app/src/test/java/to/bitkit/data/sharedpubky/SharedPubkyProviderTest.kt b/app/src/test/java/to/bitkit/data/sharedpubky/SharedPubkyProviderTest.kt
new file mode 100644
index 0000000000..14104b0f06
--- /dev/null
+++ b/app/src/test/java/to/bitkit/data/sharedpubky/SharedPubkyProviderTest.kt
@@ -0,0 +1,111 @@
+package to.bitkit.data.sharedpubky
+
+import android.content.Context
+import android.content.pm.Signature
+import android.net.Uri
+import androidx.test.core.app.ApplicationProvider
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import dagger.hilt.android.testing.HiltTestApplication
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows
+import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowBinder
+import to.bitkit.data.keychain.Keychain
+import to.bitkit.test.BaseUnitTest
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+
+@HiltAndroidTest
+@Config(application = HiltTestApplication::class, sdk = [34])
+@RunWith(RobolectricTestRunner::class)
+class SharedPubkyProviderTest : BaseUnitTest() {
+ companion object {
+ private const val TRUSTED_UID = 10001
+ private const val UNTRUSTED_UID = 10002
+ private const val SECRET_KEY_HEX = "8f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8"
+ private const val PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ private const val OTHER_PUBKY = "1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ }
+
+ @get:Rule(order = 1)
+ val hiltRule = HiltAndroidRule(this)
+
+ private val context = ApplicationProvider.getApplicationContext()
+ private val keychain = mock()
+ private val provider = Robolectric.buildContentProvider(SharedPubkyProvider::class.java).create().get()
+
+ @Before
+ fun setUp() {
+ hiltRule.inject()
+ provider.keychainProvider = { keychain }
+ provider.derivePublicKey = { "pubky$PUBKY" }
+
+ val packageManager = Shadows.shadowOf(context.packageManager)
+ packageManager.getInternalMutablePackageInfo(context.packageName).signatures = arrayOf(Signature("beef"))
+ packageManager.setPackagesForUid(TRUSTED_UID, context.packageName)
+ ShadowBinder.setCallingUid(TRUSTED_UID)
+ }
+
+ @Test
+ fun `untrusted caller is rejected`() {
+ ShadowBinder.setCallingUid(UNTRUSTED_UID)
+
+ assertFalse(provider.isCallerTrusted(UNTRUSTED_UID))
+ assertFailsWith { provider.query(identitiesUri()) }
+ }
+
+ @Test
+ fun `identities are empty without a stored secret`() {
+ whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null)
+
+ val cursor = requireNotNull(provider.query(identitiesUri()))
+
+ assertEquals(0, cursor.count)
+ }
+
+ @Test
+ fun `identities and credential expose the stored secret`() {
+ whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX)
+
+ val identities = requireNotNull(provider.query(identitiesUri()))
+ val credential = requireNotNull(provider.query(credentialUri(PUBKY)))
+ identities.moveToFirst()
+ credential.moveToFirst()
+
+ assertEquals(1, identities.count)
+ assertEquals(PUBKY, identities.getString(identities.getColumnIndexOrThrow(SharedPubkyContract.COLUMN_PUBKY)))
+ assertEquals(1, credential.count)
+ assertEquals(
+ SECRET_KEY_HEX,
+ credential.getString(credential.getColumnIndexOrThrow(SharedPubkyContract.COLUMN_SECRET_KEY)),
+ )
+ }
+
+ @Test
+ fun `credential is empty for another pubky`() {
+ whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX)
+
+ val cursor = requireNotNull(provider.query(credentialUri(OTHER_PUBKY)))
+
+ assertEquals(0, cursor.count)
+ }
+
+ private fun identitiesUri(): Uri = uriOf(SharedPubkyContract.PATH_IDENTITIES)
+
+ private fun credentialUri(pubky: String): Uri =
+ uriOf("${SharedPubkyContract.PATH_IDENTITIES}/$pubky/${SharedPubkyContract.PATH_CREDENTIAL}")
+
+ private fun uriOf(path: String): Uri =
+ Uri.parse("content://${context.packageName}${SharedPubkyContract.AUTHORITY_SUFFIX}/$path")
+
+ private fun SharedPubkyProvider.query(uri: Uri) = query(uri, null, null, null, null)
+}
diff --git a/app/src/test/java/to/bitkit/models/PubkyRingAuthCallbackTest.kt b/app/src/test/java/to/bitkit/models/PubkyRingAuthCallbackTest.kt
deleted file mode 100644
index 8e753f7b4c..0000000000
--- a/app/src/test/java/to/bitkit/models/PubkyRingAuthCallbackTest.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-package to.bitkit.models
-
-import androidx.core.net.toUri
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.annotation.Config
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertNull
-
-@RunWith(RobolectricTestRunner::class)
-@Config(sdk = [34])
-class PubkyRingAuthCallbackTest {
- @Test
- fun `addCallbacks adds Ring x-callback params`() {
- val url = checkNotNull(
- PubkyRingAuthUrlBuilder.addCallbacks(
- authUrl = "pubkyauth://auth?relay=https%3A%2F%2Frelay.example",
- nonce = "12345678-1234-1234-1234-123456789ABC",
- ),
- ) { "Auth URL should be valid" }
- val uri = url.toUri()
-
- assertEquals("https://relay.example", uri.getQueryParameter("relay"))
- assertEquals(
- "bitkit://pubky-auth/success?nonce=12345678-1234-1234-1234-123456789ABC",
- uri.getQueryParameter("x-success"),
- )
- assertEquals(
- "bitkit://pubky-auth/cancel?nonce=12345678-1234-1234-1234-123456789ABC",
- uri.getQueryParameter("x-cancel"),
- )
- assertEquals(
- "bitkit://pubky-auth/error?nonce=12345678-1234-1234-1234-123456789ABC",
- uri.getQueryParameter("x-error"),
- )
- assertEquals(PubkyRingAuthUrlBuilder.SOURCE, uri.getQueryParameter("x-source"))
- }
-
- @Test
- fun `parse returns success cancel and error callbacks`() {
- assertEquals(
- PubkyRingAuthCallback.Success(nonce = null),
- PubkyRingAuthCallback.parse("bitkit://pubky-auth/success".toUri()),
- )
- assertEquals(
- PubkyRingAuthCallback.Cancel(nonce = null),
- PubkyRingAuthCallback.parse("bitkit://pubky-auth/cancel".toUri()),
- )
- assertEquals(
- PubkyRingAuthCallback.Error(message = "Denied", nonce = null),
- PubkyRingAuthCallback.parse("bitkit://pubky-auth/error?errorMessage=Denied".toUri()),
- )
- }
-
- @Test
- fun `parse returns nonce when callback includes value`() {
- assertEquals(
- PubkyRingAuthCallback.Error(message = "Denied", nonce = "abc"),
- PubkyRingAuthCallback.parse("bitkit://pubky-auth/error?nonce=abc&errorMessage=Denied".toUri()),
- )
- }
-
- @Test
- fun `parse treats bare nonce as missing`() {
- assertEquals(
- PubkyRingAuthCallback.Cancel(nonce = null),
- PubkyRingAuthCallback.parse("bitkit://pubky-auth/cancel?nonce".toUri()),
- )
- }
-
- @Test
- fun `parse rejects other deeplinks`() {
- assertNull(PubkyRingAuthCallback.parse("bitkit://wallet/success".toUri()))
- assertNull(PubkyRingAuthCallback.parse("https://pubky-auth/success".toUri()))
- }
-}
diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
index 541aaaf60e..51d8f2cb69 100644
--- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
+++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
@@ -20,6 +20,7 @@ import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
+import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
@@ -48,12 +49,12 @@ import to.bitkit.data.PubkyStoreData
import to.bitkit.data.SettingsData
import to.bitkit.data.SettingsStore
import to.bitkit.data.keychain.Keychain
+import to.bitkit.data.sharedpubky.SharedPubkyClient
+import to.bitkit.data.sharedpubky.SharedPubkyContract
import to.bitkit.ext.runSuspendCatching
import to.bitkit.models.PubkyAuthClaim
import to.bitkit.models.PubkyAuthRequest
import to.bitkit.models.PubkyProfile
-import to.bitkit.models.PubkyRingAuthCallback
-import to.bitkit.models.PubkyRingAuthCallbackHandlingResult
import to.bitkit.models.PubkySessionBackupKind
import to.bitkit.models.PubkySessionBackupV1
import to.bitkit.services.PubkyRingAuthTimeoutError
@@ -82,6 +83,7 @@ class PubkyRepoTest : BaseUnitTest() {
private val pubkyService = mock()
private val keychain = mock()
+ private val sharedPubkyClient = mock()
private val imageLoader = mock()
private val pubkyStore = mock()
private val settingsStore = mock()
@@ -111,12 +113,18 @@ class PubkyRepoTest : BaseUnitTest() {
ioDispatcher = testDispatcher,
pubkyService = pubkyService,
keychain = keychain,
+ sharedPubkyClient = sharedPubkyClient,
imageLoader = imageLoader,
pubkyStore = pubkyStore,
settingsStore = settingsStore,
httpClient = httpClient,
)
+ private fun stubAdoptedRingSource() {
+ whenever(keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name))
+ .thenReturn(SharedPubkyContract.RING_SOURCE_PREFIX + VALID_SELF_KEY.removePrefix("pubky"))
+ }
+
@Test
fun `initial state should have no public key`() = test {
assertNull(sut.publicKey.value)
@@ -213,6 +221,33 @@ class PubkyRepoTest : BaseUnitTest() {
assertEquals(VALID_SELF_KEY, sut.publicKey.value)
}
+ @Test
+ fun `signup clears a stale ring reference before registering`() = test {
+ val events = mutableListOf()
+ val registeredSession = mock()
+ stubSignupKeys()
+ stubAdoptedRingSource()
+ whenever(keychain.delete(Keychain.Key.SHARED_PUBKY_SOURCE.name)).thenAnswer { events += "clear" }
+ whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenAnswer {
+ events += "register"
+ registeredSession
+ }
+ whenever(pubkyService.activateRegisteredIdentity(registeredSession)).thenAnswer { events += "activate" }
+
+ assertTrue(sut.approveSignupAuth(directSignupRequest()).isSuccess)
+ assertEquals(listOf("clear", "register", "activate"), events)
+ }
+
+ @Test
+ fun `signup keeps the ring reference when already signed in`() = test {
+ stubAdoptedRingSource()
+ whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("session")
+
+ assertTrue(sut.approveSignupAuth(directSignupRequest()).isFailure)
+ verifyBlocking(keychain, never()) { delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) }
+ verifyBlocking(pubkyService, never()) { registerIdentity(any(), any(), any()) }
+ }
+
@Test
fun `Ring signup stops when registration fails`() = test {
val request = ringSignupRequest()
@@ -254,107 +289,6 @@ class PubkyRepoTest : BaseUnitTest() {
assertTrue(runSuspendCatching { sut.hasIdentity() }.isFailure)
}
- @Test
- fun `startAuthentication should return auth uri on success`() = test {
- val authUri = "pubky://auth?capabilities=..."
- whenever(pubkyService.startAuth()).thenReturn(authUri)
-
- val result = sut.startAuthentication()
-
- assertTrue(result.isSuccess)
- assertEquals(authUri, result.getOrNull()?.authUrl)
- assertNotNull(result.getOrNull()?.callbackNonce)
- }
-
- @Test
- fun `startAuthentication should reset state on failure`() = test {
- whenever(pubkyService.startAuth()).thenAnswer { throw TestAppError("Auth failed") }
-
- val result = sut.startAuthentication()
-
- assertTrue(result.isFailure)
- sut.isAuthenticated.test(timeout = 500.milliseconds) {
- assertFalse(awaitItem())
- }
- }
-
- @Test
- fun `completeAuthentication should save session and update state`() = test {
- val testSecret = "session_secret"
- val testPk = VALID_SELF_KEY.removePrefix("pubky")
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- whenever(pubkyService.completeAuth()).thenReturn(Unit)
- whenever(pubkyService.currentPublicKey()).thenReturn(testPk)
-
- val pubkyProfile = createPubkyProfile(name = "User")
- whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true))
- .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = pubkyProfile))
- whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(testSecret)
-
- val authRequest = startAuthForTesting()
- approveAuthForTesting(authRequest)
- val result = sut.completeAuthentication()
-
- assertTrue(result.isSuccess)
- assertEquals(VALID_SELF_KEY, sut.publicKey.value)
- assertTrue(sut.isAuthenticated.value)
- }
-
- @Test
- fun `completeAuthentication should load contacts automatically`() = test {
- val testSecret = "session_secret"
- val testPk = VALID_SELF_KEY.removePrefix("pubky")
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- whenever(pubkyService.completeAuth()).thenReturn(Unit)
- whenever(pubkyService.currentPublicKey()).thenReturn(testPk)
- val pubkyProfile = createPubkyProfile(name = "User")
- whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true))
- .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = pubkyProfile))
- whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(testSecret)
-
- val authRequest = startAuthForTesting()
- approveAuthForTesting(authRequest)
- val result = sut.completeAuthentication()
-
- assertTrue(result.isSuccess)
- verify(pubkyService).contactRecords()
- }
-
- @Test
- fun `completeAuthentication should reset state on failure`() = test {
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- whenever(pubkyService.completeAuth()).thenAnswer { throw TestAppError("Failed") }
-
- val authRequest = startAuthForTesting()
- approveAuthForTesting(authRequest)
- val result = sut.completeAuthentication()
-
- assertTrue(result.isFailure)
- assertFalse(sut.isAuthenticated.value)
- assertNull(sut.publicKey.value)
- verifyBlocking(pubkyService) { signOut() }
- }
-
- @Test
- fun `completeAuthentication should fail when auth attempt inactive`() = test {
- val result = sut.completeAuthentication()
-
- assertTrue(result.isFailure)
- verifyBlocking(pubkyService, never()) { completeAuth() }
- }
-
- @Test
- fun `completeAuthentication should fail when auth is canceled before approval`() = test {
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- sut.startAuthentication()
-
- val result = async { sut.completeAuthentication() }
- sut.cancelAuthentication()
-
- assertTrue(result.await().isFailure)
- verifyBlocking(pubkyService, never()) { completeAuth() }
- }
-
@Test
fun `approveAuth should forward requested capabilities`() = test {
val authUrl = "pubkyauth://signin?caps=/pub/bitkit.to/:rw"
@@ -395,270 +329,6 @@ class PubkyRepoTest : BaseUnitTest() {
}
}
- @Test
- fun `completeAuthentication should forget session when canceled session revocation fails`() = test {
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- whenever(pubkyService.completeAuth()).thenAnswer {
- runBlocking { sut.cancelAuthentication() }
- Unit
- }
- whenever(pubkyService.signOut()).thenAnswer { throw TestAppError("Server error") }
-
- val authRequest = startAuthForTesting()
- approveAuthForTesting(authRequest)
- val result = sut.completeAuthentication()
-
- assertTrue(result.isFailure)
- verifyBlocking(pubkyService) { signOut() }
- verifyBlocking(pubkyService) { forgetSessionAccess() }
- }
-
- @Test
- fun `completeAuthentication clears credentials when abandoned session cleanup fails`() = test {
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- whenever(pubkyService.completeAuth()).thenAnswer {
- runBlocking { sut.cancelAuthentication() }
- Unit
- }
- whenever(pubkyService.signOut()).thenAnswer { throw TestAppError("Server error") }
- whenever(pubkyService.forgetSessionAccess()).thenAnswer { throw TestAppError("Cleanup error") }
-
- val authRequest = startAuthForTesting()
- approveAuthForTesting(authRequest)
- val result = sut.completeAuthentication()
-
- assertTrue(result.isFailure)
- assertFalse(sut.isAuthenticated.value)
- verify(keychain).delete(Keychain.Key.PAYKIT_SESSION.name)
- verify(keychain).delete(Keychain.Key.PUBKY_SECRET_KEY.name)
- assertTrue(settingsFlow.value.publicPaykitCleanupPending)
- }
-
- @Test
- fun `completeAuthentication should revoke session when canceled during completion`() = test {
- val completionStarted = CompletableDeferred()
- val finishCompletion = CompletableDeferred()
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- whenever(pubkyService.completeAuth()).doSuspendableAnswer {
- completionStarted.complete(Unit)
- finishCompletion.await()
- }
-
- val authRequest = startAuthForTesting()
- approveAuthForTesting(authRequest)
- val result = async { sut.completeAuthentication() }
- completionStarted.await()
-
- result.cancel()
- verifyBlocking(pubkyService, never()) { signOut() }
- finishCompletion.complete(Unit)
- result.join()
-
- verifyBlocking(pubkyService) { signOut() }
- }
-
- @Test
- fun `completeAuthentication should keep session when canceled during profile load`() = test {
- val profileLoadStarted = CompletableDeferred()
- val finishProfileLoad = CompletableDeferred()
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- whenever(pubkyService.completeAuth()).thenReturn(Unit)
- whenever(pubkyService.currentPublicKey()).thenReturn(VALID_SELF_KEY.removePrefix("pubky"))
- whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer {
- profileLoadStarted.complete(Unit)
- finishProfileLoad.await()
- createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())
- }
-
- val authRequest = startAuthForTesting()
- approveAuthForTesting(authRequest)
- val result = async { sut.completeAuthentication() }
- profileLoadStarted.await()
-
- assertTrue(sut.isAuthenticated.value)
- result.cancel()
- finishProfileLoad.complete(Unit)
- result.join()
-
- assertTrue(sut.isAuthenticated.value)
- verifyBlocking(pubkyService, never()) { signOut() }
- }
-
- @Test
- fun `cancelAuthentication should reset state to idle`() = test {
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- sut.startAuthentication()
-
- sut.cancelAuthentication()
-
- assertFalse(sut.isAuthenticated.value)
- }
-
- @Test
- fun `cancelAuthentication should keep restored profile authenticated`() = test {
- authenticateForTesting()
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- sut.startAuthentication()
-
- sut.cancelAuthentication()
-
- assertTrue(sut.isAuthenticated.value)
- assertNotNull(sut.publicKey.value)
- }
-
- @Test
- fun `handleAuthCallback should reject invalid success nonce`() = test {
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- sut.startAuthentication()
-
- val result = sut.handleAuthCallback(PubkyRingAuthCallback.Success(nonce = "invalid"))
-
- assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, result)
- verifyBlocking(pubkyService, never()) { cancelAuth() }
- }
-
- @Test
- fun `handleAuthCallback should trust missing success nonce for active auth`() = test {
- val testPk = VALID_SELF_KEY.removePrefix("pubky")
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- whenever(pubkyService.completeAuth()).thenReturn(Unit)
- whenever(pubkyService.currentPublicKey()).thenReturn(testPk)
- whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true))
- .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile()))
- sut.startAuthentication()
-
- val callbackResult = sut.handleAuthCallback(PubkyRingAuthCallback.Success(nonce = null))
- val result = sut.completeAuthentication()
-
- assertEquals(PubkyRingAuthCallbackHandlingResult.Handled, callbackResult)
- assertTrue(result.isSuccess)
- assertTrue(sut.isAuthenticated.value)
- }
-
- @Test
- fun `handleAuthCallback should ignore invalid cancel nonce`() = test {
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- sut.startAuthentication()
-
- val result = sut.handleAuthCallback(PubkyRingAuthCallback.Cancel(nonce = "invalid"))
-
- assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, result)
- assertFalse(sut.isAuthenticated.value)
- verifyBlocking(pubkyService, never()) { cancelAuth() }
- }
-
- @Test
- fun `handleAuthCallback should ignore invalid error nonce`() = test {
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- sut.startAuthentication()
-
- val result = sut.handleAuthCallback(
- PubkyRingAuthCallback.Error(message = "Forged error", nonce = "invalid"),
- )
-
- assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, result)
- verifyBlocking(pubkyService, never()) { cancelAuth() }
- }
-
- @Test
- fun `handleAuthCallback should keep active auth after missing cancel nonce`() = test {
- val testPk = VALID_SELF_KEY.removePrefix("pubky")
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- whenever(pubkyService.completeAuth()).thenReturn(Unit)
- whenever(pubkyService.currentPublicKey()).thenReturn(testPk)
- whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true))
- .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile()))
- val authRequest = startAuthForTesting()
-
- val callbackResult = sut.handleAuthCallback(PubkyRingAuthCallback.Cancel(nonce = null))
- approveAuthForTesting(authRequest)
- val result = sut.completeAuthentication()
-
- assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, callbackResult)
- assertTrue(result.isSuccess)
- assertTrue(sut.isAuthenticated.value)
- verifyBlocking(pubkyService, never()) { cancelAuth() }
- }
-
- @Test
- fun `handleAuthCallback should keep active auth after missing error nonce`() = test {
- val testPk = VALID_SELF_KEY.removePrefix("pubky")
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- whenever(pubkyService.completeAuth()).thenReturn(Unit)
- whenever(pubkyService.currentPublicKey()).thenReturn(testPk)
- whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true))
- .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile()))
- val authRequest = startAuthForTesting()
-
- val callbackResult = sut.handleAuthCallback(
- PubkyRingAuthCallback.Error(message = "Forged error", nonce = null),
- )
- approveAuthForTesting(authRequest)
- val result = sut.completeAuthentication()
-
- assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, callbackResult)
- assertTrue(result.isSuccess)
- assertTrue(sut.isAuthenticated.value)
- verifyBlocking(pubkyService, never()) { cancelAuth() }
- }
-
- @Test
- fun `handleAuthCallback should keep active auth after invalid cancel nonce`() = test {
- val testPk = VALID_SELF_KEY.removePrefix("pubky")
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- whenever(pubkyService.completeAuth()).thenReturn(Unit)
- whenever(pubkyService.currentPublicKey()).thenReturn(testPk)
- whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true))
- .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile()))
- val authRequest = startAuthForTesting()
-
- val callbackResult = sut.handleAuthCallback(PubkyRingAuthCallback.Cancel(nonce = "invalid"))
- approveAuthForTesting(authRequest)
- val result = sut.completeAuthentication()
-
- assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, callbackResult)
- assertTrue(result.isSuccess)
- assertTrue(sut.isAuthenticated.value)
- verifyBlocking(pubkyService, never()) { cancelAuth() }
- }
-
- @Test
- fun `handleAuthCallback should keep active auth after invalid error nonce`() = test {
- val testPk = VALID_SELF_KEY.removePrefix("pubky")
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- whenever(pubkyService.completeAuth()).thenReturn(Unit)
- whenever(pubkyService.currentPublicKey()).thenReturn(testPk)
- whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true))
- .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile()))
- val authRequest = startAuthForTesting()
-
- val callbackResult = sut.handleAuthCallback(
- PubkyRingAuthCallback.Error(message = "Forged error", nonce = "invalid"),
- )
- approveAuthForTesting(authRequest)
- val result = sut.completeAuthentication()
-
- assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, callbackResult)
- assertTrue(result.isSuccess)
- assertTrue(sut.isAuthenticated.value)
- verifyBlocking(pubkyService, never()) { cancelAuth() }
- }
-
- @Test
- fun `handleAuthCallback should trust matching error nonce`() = test {
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- val authRequest = checkNotNull(sut.startAuthentication().getOrNull()) {
- "Auth request should be returned"
- }
-
- val result = sut.handleAuthCallback(
- PubkyRingAuthCallback.Error(message = "Ring failed", nonce = authRequest.callbackNonce),
- )
-
- assertEquals(PubkyRingAuthCallbackHandlingResult.TrustedError("Ring failed"), result)
- verifyBlocking(pubkyService) { cancelAuth() }
- }
-
@Test
fun `createIdentity should forget session when incomplete session revocation fails`() = test {
val httpClient = identityHttpClient()
@@ -776,7 +446,7 @@ class PubkyRepoTest : BaseUnitTest() {
assertTrue(sut.isAuthenticated.value)
assertTrue(sut.createIdentity("Updated", "", emptyList(), emptyList(), null).isSuccess)
- verifyBlocking(pubkyService, times(2)) { signIn("local-secret") }
+ verifyBlocking(pubkyService, never()) { signIn(any()) }
verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) }
verifyBlocking(pubkyService, never()) { signOut() }
verifyBlocking(pubkyService, never()) { forgetSessionAccess() }
@@ -1276,19 +946,150 @@ class PubkyRepoTest : BaseUnitTest() {
}
@Test
- fun `snapshotSessionBackupState should use external session when no local seed exists`() = test {
- whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null)
+ fun `snapshotSessionBackupState should return null for an adopted ring identity`() = test {
+ whenever(keychain.exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)).thenReturn(true)
whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("session_secret")
val result = sut.snapshotSessionBackupState()
- assertEquals(
- PubkySessionBackupV1(
- kind = PubkySessionBackupKind.ExternalSession,
- sessionSecret = "session_secret",
- ),
- result.getOrNull(),
- )
+ assertNull(result.getOrNull())
+ }
+
+ @Test
+ fun `adoptRingIdentity should reject a mismatching credential and clear the reference`() = test {
+ val ringPubky = VALID_SELF_KEY.removePrefix("pubky")
+ whenever(sharedPubkyClient.ringCredential(ringPubky)).thenReturn(Result.success("ring_secret"))
+ whenever(pubkyService.publicKeyFromSecret("ring_secret"))
+ .thenReturn(VALID_CONTACT_KEY_A.removePrefix("pubky"))
+
+ val result = sut.adoptRingIdentity(ringPubky)
+
+ assertTrue(result.isFailure)
+ assertNull(sut.publicKey.value)
+ verifyBlocking(pubkyService, never()) { signIn(any()) }
+ verifyBlocking(keychain) { delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) }
+ }
+
+ @Test
+ fun `adoptRingIdentity should not sign up when sign in fails for a published identity`() = test {
+ val ringPubky = stubRingCredential()
+ whenever(pubkyService.signIn("ring_secret")).thenAnswer { throw TestAppError("Relay unavailable") }
+ whenever(pubkyService.hasIdentityRecord(ringPubky)).thenReturn(true)
+
+ val result = sut.adoptRingIdentity(ringPubky)
+
+ assertTrue(result.isFailure)
+ assertNull(sut.publicKey.value)
+ verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) }
+ verifyBlocking(keychain) { delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) }
+ }
+
+ @Test
+ fun `adoptRingIdentity should not sign up when the identity record check fails`() = test {
+ val ringPubky = stubRingCredential()
+ whenever(pubkyService.signIn("ring_secret")).thenAnswer { throw TestAppError("Relay unavailable") }
+ whenever(pubkyService.hasIdentityRecord(ringPubky)).thenAnswer { throw TestAppError("No responses") }
+
+ val result = sut.adoptRingIdentity(ringPubky)
+
+ assertTrue(result.isFailure)
+ verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) }
+ verifyBlocking(keychain) { delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) }
+ }
+
+ @Test
+ fun `adoptRingIdentity should sign up a ring identity without a published record`() = test {
+ val httpClient = identityHttpClient()
+ sut = createSut(httpClient)
+ val ringPubky = stubRingCredential()
+ whenever(pubkyService.signIn("ring_secret")).thenAnswer { throw TestAppError("No homeserver") }
+ whenever(pubkyService.hasIdentityRecord(ringPubky)).thenReturn(false)
+ whenever(pubkyService.signUp("ring_secret", "test-homeserver", "test-code")).thenReturn(Unit)
+
+ val result = sut.adoptRingIdentity(ringPubky)
+ httpClient.close()
+
+ assertTrue(result.isSuccess)
+ assertEquals(VALID_SELF_KEY, sut.publicKey.value)
+ verifyBlocking(pubkyService) { signUp("ring_secret", "test-homeserver", "test-code") }
+ }
+
+ @Test
+ fun `adoptRingIdentity should mark profile setup pending when the pubky has no profile`() = test {
+ val ringPubky = stubRingCredential()
+ whenever(pubkyService.signIn("ring_secret")).thenReturn(Unit)
+ whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).thenReturn(null)
+
+ val result = sut.adoptRingIdentity(ringPubky)
+
+ assertEquals(false, result.getOrNull())
+ assertTrue(profileSetupPending.value)
+ assertEquals(VALID_SELF_KEY, sut.publicKey.value)
+ }
+
+ @Test
+ fun `adoptRingIdentity should clear profile setup pending when the pubky has a profile`() = test {
+ profileSetupPending.value = true
+ val ringPubky = stubRingCredential()
+ whenever(pubkyService.signIn("ring_secret")).thenReturn(Unit)
+ whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true))
+ .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile()))
+
+ val result = sut.adoptRingIdentity(ringPubky)
+
+ assertEquals(true, result.getOrNull())
+ assertFalse(profileSetupPending.value)
+ }
+
+ @Test
+ fun `initialize should clear an adopted identity that is gone from pubky ring`() = test {
+ stubAdoptedRingSource()
+ whenever(sharedPubkyClient.listRingIdentities())
+ .thenReturn(Result.success(persistentListOf(VALID_CONTACT_KEY_A.removePrefix("pubky"))))
+
+ sut.initialize()
+
+ assertTrue(sut.adoptedSourceLost.value)
+ verifyBlocking(pubkyService) { clearSessionAccess() }
+ }
+
+ @Test
+ fun `initialize should keep an adopted identity when the ring listing fails`() = test {
+ stubAdoptedRingSource()
+ whenever(sharedPubkyClient.listRingIdentities()).thenReturn(Result.failure(TestAppError("Unavailable")))
+
+ sut.initialize()
+
+ assertFalse(sut.adoptedSourceLost.value)
+ verifyBlocking(pubkyService, never()) { clearSessionAccess() }
+ }
+
+ @Test
+ fun `checkAdoptedSource should clear an adopted identity removed from pubky ring after startup`() = test {
+ stubAdoptedRingSource()
+ whenever(sharedPubkyClient.listRingIdentities())
+ .thenReturn(Result.success(persistentListOf(VALID_CONTACT_KEY_A.removePrefix("pubky"))))
+
+ val result = sut.checkAdoptedSource()
+
+ assertTrue(result.isSuccess)
+ assertTrue(sut.adoptedSourceLost.value)
+ verifyBlocking(pubkyService) { clearSessionAccess() }
+ }
+
+ @Test
+ fun `checkAdoptedSource should skip while initialization is running`() = test {
+ val imported = CompletableDeferred()
+ whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session")
+ whenever(pubkyService.importSession("saved_session")).doSuspendableAnswer { imported.await() }
+ stubAdoptedRingSource()
+ whenever(sharedPubkyClient.listRingIdentities()).thenReturn(Result.success(persistentListOf()))
+ val repo = createSut()
+
+ repo.checkAdoptedSource()
+
+ assertFalse(repo.adoptedSourceLost.value)
+ verifyBlocking(pubkyService, never()) { clearSessionAccess() }
}
@Test
@@ -1468,6 +1269,43 @@ class PubkyRepoTest : BaseUnitTest() {
verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) }
}
+ @Test
+ fun `initialize should re-sign in an adopted identity with the ring credential`() = test {
+ val session = "stale_session"
+ stubAdoptedRingSource()
+ val ringPubky = stubRingCredential()
+ whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session)
+ whenever(pubkyService.importSession(session)).thenAnswer { throw TestAppError("Expired") }
+ whenever(sharedPubkyClient.listRingIdentities()).thenReturn(Result.success(persistentListOf(ringPubky)))
+ whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true))
+ .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Ring User")))
+
+ sut.initialize()
+
+ assertEquals(VALID_SELF_KEY, sut.publicKey.value)
+ assertFalse(sut.sessionRestorationFailed.value)
+ verifyBlocking(pubkyService) { signIn("ring_secret") }
+ verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) }
+ }
+
+ @Test
+ fun `initialize should keep an adopted session when the ring credential is unavailable`() = test {
+ val session = "stale_session"
+ stubAdoptedRingSource()
+ whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session)
+ whenever(pubkyService.importSession(session)).thenAnswer { throw TestAppError("Expired") }
+ whenever(sharedPubkyClient.ringCredential(VALID_SELF_KEY.removePrefix("pubky")))
+ .thenReturn(Result.failure(TestAppError("Unavailable")))
+ whenever(sharedPubkyClient.listRingIdentities()).thenReturn(Result.failure(TestAppError("Unavailable")))
+
+ sut.initialize()
+
+ assertTrue(sut.sessionRestorationFailed.value)
+ verifyBlocking(pubkyService, never()) { signIn(any()) }
+ verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) }
+ verifyBlocking(keychain, never()) { delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) }
+ }
+
@Test
fun `refreshSessionIfPossible should refresh session when local secret key exists`() = test {
val secretKey = "local_secret"
@@ -1530,18 +1368,14 @@ class PubkyRepoTest : BaseUnitTest() {
}
@Test
- fun `restoreSessionBackupState should save external session backups`() = test {
- whenever(pubkyService.importExternalSession("external_session")).thenReturn(VALID_SELF_KEY)
-
+ fun `restoreSessionBackupState should restore no identity for legacy external session backups`() = test {
val result = sut.restoreSessionBackupState(
- PubkySessionBackupV1(
- kind = PubkySessionBackupKind.ExternalSession,
- sessionSecret = "external_session",
- ),
+ PubkySessionBackupV1(kind = PubkySessionBackupKind.ExternalSession),
)
assertTrue(result.isSuccess)
- assertEquals(VALID_SELF_KEY, sut.publicKey.value)
+ assertNull(sut.publicKey.value)
+ assertFalse(sut.isAuthenticated.value)
}
@Test
@@ -1559,24 +1393,6 @@ class PubkyRepoTest : BaseUnitTest() {
verifyBlocking(keychain) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) }
}
- @Test
- fun `restoreSessionBackupState should import external session when forgetting current session fails`() = test {
- whenever(pubkyService.forgetSessionAccess()).thenAnswer { throw TestAppError("Forget failed") }
- whenever(pubkyService.importExternalSession("external_session")).thenReturn(VALID_SELF_KEY)
-
- val result = sut.restoreSessionBackupState(
- PubkySessionBackupV1(
- kind = PubkySessionBackupKind.ExternalSession,
- sessionSecret = "external_session",
- ),
- )
-
- assertTrue(result.isSuccess)
- assertEquals(VALID_SELF_KEY, sut.publicKey.value)
- verifyBlocking(keychain) { delete(Keychain.Key.PAYKIT_SESSION.name) }
- verifyBlocking(keychain) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) }
- }
-
@Test
fun `restore without backup clears credentials when forgetting current session fails`() = test {
authenticateForTesting(publicKey = VALID_SELF_KEY)
@@ -1714,16 +1530,12 @@ class PubkyRepoTest : BaseUnitTest() {
secret = oldSecret,
profileName = "Initial Old",
)
- whenever(pubkyService.completeAuth()).thenReturn(Unit)
- whenever(pubkyService.currentPublicKey()).thenReturn(newPublicKey)
whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(newSecret)
+ whenever(pubkyService.importSession(newSecret)).thenReturn(newPublicKey)
whenever(pubkyService.contactRecords()).thenReturn(emptyList())
val staleProfile = createPubkyProfile(name = "Stale Old")
whenever(pubkyService.resolveContactProfile(oldPublicKey.ensurePubkyPrefixForTest(), true)).thenAnswer {
- runBlocking {
- approveAuthForTesting(startAuthForTesting())
- sut.completeAuthentication()
- }
+ runBlocking { sut.initialize() }
createResolution(oldPublicKey.ensurePubkyPrefixForTest(), pubkyProfile = staleProfile)
}
@@ -1757,17 +1569,13 @@ class PubkyRepoTest : BaseUnitTest() {
)
sut.addContact(existingContact.publicKey, existingProfile = existingContact)
- whenever(pubkyService.completeAuth()).thenReturn(Unit)
- whenever(pubkyService.currentPublicKey()).thenReturn(newPublicKey)
val newProfile = createPubkyProfile(name = "New User")
whenever(pubkyService.resolveContactProfile(newPublicKey.ensurePubkyPrefixForTest(), true))
.thenReturn(createResolution(newPublicKey.ensurePubkyPrefixForTest(), pubkyProfile = newProfile))
- whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(oldSecret)
+ whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(newSecret)
+ whenever(pubkyService.importSession(newSecret)).thenReturn(newPublicKey)
whenever(pubkyService.contactRecords()).thenAnswer {
- runBlocking {
- approveAuthForTesting(startAuthForTesting())
- sut.completeAuthentication()
- }
+ runBlocking { sut.initialize() }
listOf(createContactRecord(staleContactKey, profile = createPaykitProfile("Stale Contact")))
}
@@ -2025,27 +1833,13 @@ class PubkyRepoTest : BaseUnitTest() {
profileName: String = "Test",
) {
val prefixedPublicKey = publicKey.ensurePubkyPrefixForTest()
- whenever { pubkyService.completeAuth() }.thenReturn(Unit)
- whenever { pubkyService.currentPublicKey() }.thenReturn(publicKey)
- val pubkyProfile = createPubkyProfile(name = profileName)
- whenever { pubkyService.resolveContactProfile(prefixedPublicKey, true) }
- .thenReturn(createResolution(prefixedPublicKey, pubkyProfile = pubkyProfile))
whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(secret)
+ whenever { pubkyService.importSession(secret) }.thenReturn(publicKey)
+ whenever { pubkyService.resolveContactProfile(prefixedPublicKey, true) }
+ .thenReturn(createResolution(prefixedPublicKey, pubkyProfile = createPubkyProfile(name = profileName)))
whenever { pubkyService.contactRecords() }.thenReturn(emptyList())
- approveAuthForTesting(startAuthForTesting())
- sut.completeAuthentication()
- }
-
- private suspend fun startAuthForTesting(authUri: String = "auth_uri"): PubkyRingAuthRequest {
- whenever { pubkyService.startAuth() }.thenReturn(authUri)
- return checkNotNull(sut.startAuthentication().getOrNull()) {
- "Auth request should be returned"
- }
- }
-
- private suspend fun approveAuthForTesting(authRequest: PubkyRingAuthRequest) {
- sut.handleAuthCallback(PubkyRingAuthCallback.Success(nonce = authRequest.callbackNonce))
+ sut.initialize()
}
private fun identityHttpClient() = HttpClient(
@@ -2073,6 +1867,13 @@ class PubkyRepoTest : BaseUnitTest() {
status = status,
)
+ private suspend fun stubRingCredential(): String {
+ val ringPubky = VALID_SELF_KEY.removePrefix("pubky")
+ whenever(sharedPubkyClient.ringCredential(ringPubky)).thenReturn(Result.success("ring_secret"))
+ whenever(pubkyService.publicKeyFromSecret("ring_secret")).thenReturn(ringPubky)
+ return ringPubky
+ }
+
private suspend fun stubSignupKeys() {
whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("seed words")
whenever(pubkyService.deriveSecretKey("seed words")).thenReturn("secret")
diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt
index 005e78b71e..e61d30889c 100644
--- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt
+++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt
@@ -20,6 +20,7 @@ import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import to.bitkit.data.keychain.Keychain
+import to.bitkit.data.sharedpubky.SharedPubkyClient
import to.bitkit.ext.fromHex
import to.bitkit.ext.toHex
import to.bitkit.models.PubkyAuthRequestError
@@ -32,6 +33,10 @@ import kotlin.test.assertNull
import kotlin.test.assertTrue
class PaykitSdkServiceTest {
+ companion object {
+ private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ }
+
@Test
fun `registered identity activation persists credentials or clears partial activation`() = runTest {
for (failure in listOf(null, "session", "secret", "initialize", "cancel")) {
@@ -224,7 +229,7 @@ class PaykitSdkServiceTest {
fun `external session retains private payment access`() {
val keychain = mock()
val sessionSecret = "external-session"
- val provider = PaykitSdkSessionProvider(keychain)
+ val provider = PaykitSdkSessionProvider(keychain, mock())
whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(sessionSecret)
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null)
@@ -232,10 +237,24 @@ class PaykitSdkServiceTest {
assertNull(provider.loadLocalSecretKey())
}
+ @Test
+ fun `adopted identity loads its secret key from the ring provider`() {
+ val keychain = mock()
+ val sharedPubky = mock()
+ val provider = PaykitSdkSessionProvider(keychain, sharedPubky)
+ whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null)
+ whenever(keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name)).thenReturn("app.pubkyring:$RING_PUBKY")
+ whenever(sharedPubky.readCredential(RING_PUBKY)).thenReturn(null)
+
+ assertEquals(RING_PUBKY, provider.adoptedPubky())
+ assertNull(provider.loadLocalSecretKey())
+ verify(sharedPubky).readCredential(RING_PUBKY)
+ }
+
@Test
fun `stale session can be deferred until sdk initialization completes`() {
val keychain = mock()
- val provider = PaykitSdkSessionProvider(keychain)
+ val provider = PaykitSdkSessionProvider(keychain, mock())
whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved-session")
assertTrue(provider.canDeferStaleSession("restore Pubky grant session from platform provider"))
@@ -247,7 +266,7 @@ class PaykitSdkServiceTest {
@Test
fun `missing session or unrelated identity failures are not deferred`() {
val keychain = mock()
- val provider = PaykitSdkSessionProvider(keychain)
+ val provider = PaykitSdkSessionProvider(keychain, mock())
whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(null)
assertTrue(!provider.canDeferStaleSession("restore Pubky grant session from platform provider"))
diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/CreateProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/CreateProfileViewModelTest.kt
index dc26e64277..28fef51b6e 100644
--- a/app/src/test/java/to/bitkit/ui/screens/profile/CreateProfileViewModelTest.kt
+++ b/app/src/test/java/to/bitkit/ui/screens/profile/CreateProfileViewModelTest.kt
@@ -2,18 +2,23 @@ package to.bitkit.ui.screens.profile
import android.content.Context
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import to.bitkit.R
import to.bitkit.models.Toast
import to.bitkit.repositories.PubkyRepo
import to.bitkit.test.BaseUnitTest
import to.bitkit.ui.shared.toast.ToastEventBus
+import to.bitkit.utils.AppError
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@@ -28,7 +33,10 @@ class CreateProfileViewModelTest : BaseUnitTest() {
fun setUp() {
whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization Failed")
whenever(context.getString(R.string.profile__create_error)).thenReturn("Create failed")
+ whenever(context.getString(R.string.common__error)).thenReturn("Error")
+ whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(null))
whenever { pubkyRepo.deriveKeys() }.thenReturn(Result.success("pubkyalice" to "secret"))
+ whenever { pubkyRepo.hasStoredSecretKey() }.thenReturn(false)
whenever { pubkyRepo.fetchRemoteProfile(any()) }.thenReturn(Result.success(null))
sut = CreateProfileViewModel(
@@ -59,4 +67,49 @@ class CreateProfileViewModelTest : BaseUnitTest() {
effectsJob.cancel()
toastJob.cancel()
}
+
+ @Test
+ fun `save should not publish after a failed lookup for a signed-in pubky`() = test {
+ whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyalice"))
+ whenever(pubkyRepo.fetchRemoteProfile(any())).thenReturn(Result.failure(CreateProfileTestAppError("timeout")))
+ sut = CreateProfileViewModel(context = context, pubkyRepo = pubkyRepo)
+
+ sut.onNameChange("Alice")
+ advanceUntilIdle()
+ sut.save()
+ advanceUntilIdle()
+
+ verify(pubkyRepo, never()).createIdentity(any(), any(), any(), any(), anyOrNull())
+ }
+
+ @Test
+ fun `save should not publish after a failed lookup for a stored pubky`() = test {
+ whenever(pubkyRepo.hasStoredSecretKey()).thenReturn(true)
+ whenever(pubkyRepo.fetchRemoteProfile(any())).thenReturn(Result.failure(CreateProfileTestAppError("timeout")))
+ sut = CreateProfileViewModel(context = context, pubkyRepo = pubkyRepo)
+
+ sut.onNameChange("Alice")
+ advanceUntilIdle()
+ sut.save()
+ advanceUntilIdle()
+
+ verify(pubkyRepo, never()).createIdentity(any(), any(), any(), any(), anyOrNull())
+ }
+
+ @Test
+ fun `save should still create a new pubky when the lookup fails before sign-up`() = test {
+ whenever(pubkyRepo.fetchRemoteProfile(any()))
+ .thenReturn(Result.failure(CreateProfileTestAppError("no homeserver")))
+ whenever(pubkyRepo.createIdentity(any(), any(), any(), any(), anyOrNull())).thenReturn(Result.success(Unit))
+ sut = CreateProfileViewModel(context = context, pubkyRepo = pubkyRepo)
+
+ sut.onNameChange("Alice")
+ advanceUntilIdle()
+ sut.save()
+ advanceUntilIdle()
+
+ verify(pubkyRepo).createIdentity(any(), any(), any(), any(), anyOrNull())
+ }
}
+
+private class CreateProfileTestAppError(message: String) : AppError(message)
diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt
index d338e9a84f..fece646fec 100644
--- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt
+++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt
@@ -1,17 +1,14 @@
package to.bitkit.ui.screens.profile
import android.content.Context
-import android.content.pm.PackageManager
+import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.mock
-import org.mockito.kotlin.never
-import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import to.bitkit.R
import to.bitkit.models.PubkyProfile
@@ -19,126 +16,187 @@ import to.bitkit.models.Toast
import to.bitkit.repositories.PubkyRepo
import to.bitkit.test.BaseUnitTest
import to.bitkit.ui.shared.toast.ToastEventBus
+import to.bitkit.utils.AppError
import kotlin.test.assertEquals
import kotlin.test.assertFalse
+import kotlin.test.assertNull
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class PubkyChoiceViewModelTest : BaseUnitTest() {
+ companion object {
+ private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ }
+
private val context: Context = mock()
- private val packageManager: PackageManager = mock()
private val pubkyRepo: PubkyRepo = mock()
- private val pendingImportContacts = MutableStateFlow>(emptyList())
private val isAuthenticated = MutableStateFlow(false)
- private val authCancelEvents = MutableSharedFlow(extraBufferCapacity = 1)
+ private val pendingImportContacts = MutableStateFlow>(emptyList())
private lateinit var sut: PubkyChoiceViewModel
@Before
fun setUp() {
- whenever(context.packageManager).thenReturn(packageManager)
- whenever(context.getString(R.string.common__error)).thenReturn("Error")
whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization Failed")
- whenever(pubkyRepo.pendingImportContacts).thenReturn(pendingImportContacts)
+ whenever(context.getString(R.string.common__error)).thenReturn("Error")
whenever(pubkyRepo.isAuthenticated).thenReturn(isAuthenticated)
- whenever(pubkyRepo.authCancelEvents).thenReturn(authCancelEvents)
+ whenever(pubkyRepo.pendingImportContacts).thenReturn(pendingImportContacts)
+ whenever { pubkyRepo.ringIdentities() }.thenReturn(Result.success(persistentListOf()))
+ whenever { pubkyRepo.prepareImport() }.thenReturn(Result.success(Unit))
}
private fun createSut() {
- sut = PubkyChoiceViewModel(
- context = context,
- pubkyRepo = pubkyRepo,
- )
+ sut = PubkyChoiceViewModel(context = context, pubkyRepo = pubkyRepo)
}
@Test
- fun `session restoration redirects to profile when already authenticated`() = test {
- isAuthenticated.value = true
+ fun `ring identities are listed with their remote profile`() = test {
+ whenever(pubkyRepo.ringIdentities()).thenReturn(Result.success(persistentListOf(RING_PUBKY)))
+ whenever(pubkyRepo.fetchRemoteProfile(RING_PUBKY)).thenReturn(
+ Result.success(PubkyProfile.forDisplay(RING_PUBKY, name = "Satoshi", imageUrl = "https://image"))
+ )
createSut()
advanceUntilIdle()
- assertTrue(sut.uiState.value.navigateToProfile)
+ assertFalse(sut.uiState.value.isLoading)
+ assertEquals(
+ persistentListOf(
+ RingIdentity(
+ pubky = RING_PUBKY,
+ caption = "3RSD...W5XG",
+ name = "Satoshi",
+ imageUrl = "https://image",
+ )
+ ),
+ sut.uiState.value.identities,
+ )
}
@Test
- fun `clearProfileNavigation clears profile redirect`() = test {
+ fun `listing failure shows no identities`() = test {
+ whenever(pubkyRepo.ringIdentities()).thenReturn(Result.failure(PubkyChoiceTestAppError("query failed")))
createSut()
- isAuthenticated.value = true
+
advanceUntilIdle()
- sut.clearProfileNavigation()
+ assertFalse(sut.uiState.value.isLoading)
+ assertTrue(sut.uiState.value.identities.isEmpty())
+ }
+ @Test
+ fun `onIdentityClick continues to contact import when the adopted identity has follows`() = test {
+ whenever(pubkyRepo.adoptRingIdentity(RING_PUBKY)).thenReturn(Result.success(true))
+ pendingImportContacts.value = listOf(PubkyProfile.placeholder("pubky$RING_PUBKY"))
+ createSut()
+ val effects = mutableListOf()
+ val effectsJob = launch { sut.effects.collect { effects.add(it) } }
+
+ sut.onIdentityClick(RING_PUBKY)
+ advanceUntilIdle()
+
+ assertEquals(PubkyChoiceEffect.NavigateToContactImportOverview, effects.single())
assertFalse(sut.uiState.value.navigateToProfile)
+ assertNull(sut.uiState.value.adoptingPubky)
+ effectsJob.cancel()
}
@Test
- fun `waitForApproval prepareImport failure clears loading and emits no navigation`() = test {
+ fun `onIdentityClick continues to pay contacts when the adopted identity has no follows`() = test {
+ whenever(pubkyRepo.adoptRingIdentity(RING_PUBKY)).thenReturn(Result.success(true))
createSut()
- whenever(pubkyRepo.completeAuthentication()).thenReturn(Result.success(Unit))
- whenever(pubkyRepo.prepareImport()).thenReturn(Result.failure(RuntimeException("Import failed")))
-
val effects = mutableListOf()
val toasts = mutableListOf()
val effectsJob = launch { sut.effects.collect { effects.add(it) } }
val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } }
- sut.waitForApproval()
+ sut.onIdentityClick(RING_PUBKY)
advanceUntilIdle()
- assertFalse(sut.uiState.value.isLoadingAfterAuth)
- assertFalse(sut.uiState.value.isWaitingForRing)
- assertTrue(effects.isEmpty())
- assertTrue(toasts.isNotEmpty())
- assertEquals(Toast.ToastType.ERROR, toasts.last().type)
- assertEquals("Error", toasts.last().title)
- assertEquals("Import failed", toasts.last().description)
-
+ assertEquals(PubkyChoiceEffect.NavigateToPayContacts, effects.single())
+ assertFalse(sut.uiState.value.navigateToProfile)
+ assertNull(sut.uiState.value.adoptingPubky)
+ assertTrue(toasts.isEmpty())
effectsJob.cancel()
toastJob.cancel()
}
@Test
- fun `startRingAuth shows dialog when Ring is not installed`() = test {
+ fun `onIdentityClick toasts and continues to pay contacts when the follows lookup fails`() = test {
+ whenever(pubkyRepo.adoptRingIdentity(RING_PUBKY)).thenReturn(Result.success(true))
+ whenever(pubkyRepo.prepareImport()).thenReturn(Result.failure(PubkyChoiceTestAppError("follows failed")))
createSut()
- whenever(packageManager.getLaunchIntentForPackage(PubkyChoiceViewModel.PUBKY_RING_PACKAGE))
- .thenReturn(null)
-
val effects = mutableListOf()
val toasts = mutableListOf()
val effectsJob = launch { sut.effects.collect { effects.add(it) } }
val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } }
- sut.startRingAuth()
+ sut.onIdentityClick(RING_PUBKY)
advanceUntilIdle()
- assertTrue(sut.uiState.value.showRingNotInstalledDialog)
- assertTrue(effects.isEmpty())
- assertTrue(toasts.isEmpty())
- verify(pubkyRepo, never()).startAuthentication()
-
+ assertEquals(PubkyChoiceEffect.NavigateToPayContacts, effects.single())
+ assertNull(sut.uiState.value.adoptingPubky)
+ val toast = toasts.single()
+ assertEquals(Toast.ToastType.ERROR, toast.type)
+ assertEquals("Error", toast.title)
+ assertEquals("follows failed", toast.description)
effectsJob.cancel()
toastJob.cancel()
}
@Test
- fun `onRingLaunchFailed shows dialog without toast`() = test {
+ fun `onIdentityClick continues to profile creation when the adopted identity has no profile`() = test {
+ whenever(pubkyRepo.adoptRingIdentity(RING_PUBKY)).thenReturn(Result.success(false))
createSut()
val effects = mutableListOf()
- val toasts = mutableListOf()
val effectsJob = launch { sut.effects.collect { effects.add(it) } }
- val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } }
- sut.onRingLaunchFailed()
+ sut.onIdentityClick(RING_PUBKY)
advanceUntilIdle()
- assertFalse(sut.uiState.value.isWaitingForRing)
- assertTrue(sut.uiState.value.showRingNotInstalledDialog)
- assertTrue(effects.isEmpty())
- assertTrue(toasts.isEmpty())
- verify(pubkyRepo).cancelAuthentication()
-
+ assertEquals(PubkyChoiceEffect.NavigateToCreateProfile, effects.single())
+ assertFalse(sut.uiState.value.navigateToProfile)
+ assertNull(sut.uiState.value.adoptingPubky)
effectsJob.cancel()
+ }
+
+ @Test
+ fun `onIdentityClick clears the adopting identity and toasts when adoption fails`() = test {
+ whenever(pubkyRepo.adoptRingIdentity(RING_PUBKY))
+ .thenReturn(Result.failure(PubkyChoiceTestAppError("adopt failed")))
+ createSut()
+ val toasts = mutableListOf()
+ val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } }
+
+ sut.onIdentityClick(RING_PUBKY)
+ advanceUntilIdle()
+
+ assertNull(sut.uiState.value.adoptingPubky)
+ assertFalse(sut.uiState.value.navigateToProfile)
+ assertEquals(1, toasts.size)
toastJob.cancel()
}
+
+ @Test
+ fun `session restoration redirects to profile when already authenticated`() = test {
+ isAuthenticated.value = true
+ createSut()
+
+ advanceUntilIdle()
+
+ assertTrue(sut.uiState.value.navigateToProfile)
+ }
+
+ @Test
+ fun `clearProfileNavigation clears profile redirect`() = test {
+ createSut()
+ isAuthenticated.value = true
+ advanceUntilIdle()
+
+ sut.clearProfileNavigation()
+
+ assertFalse(sut.uiState.value.navigateToProfile)
+ }
}
+
+private class PubkyChoiceTestAppError(message: String) : AppError(message)
diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
index 81701df9e3..27b8dc61e9 100644
--- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
+++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
@@ -356,6 +356,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
whenever { widgetsRepo.refreshEnabledWidgets() }.thenReturn(Unit)
whenever { lightningRepo.updateGeoBlockState() }.thenReturn(Unit)
whenever(pubkyRepo.sessionRestorationFailed).thenReturn(MutableStateFlow(false))
+ whenever(pubkyRepo.adoptedSourceLost).thenReturn(MutableStateFlow(false))
whenever(pubkyRepo.publicKey).thenReturn(pubkyPublicKey)
whenever { pubkyRepo.republishIdentityIfNeeded() }.thenReturn(Result.success(Unit))
whenever { pubkyRepo.hasIdentity() }.thenAnswer { pubkyPublicKey.value != null }
@@ -3140,16 +3141,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
verify(pubkyRepo, never()).hasSecretKey()
}
- @Test
- fun `pubky ring callback deeplink is ignored when Paykit UI is disabled`() = test {
- val intent = Intent(Intent.ACTION_VIEW, "bitkit://pubky-auth/success".toUri())
-
- sut.handleDeeplinkIntent(intent)
- advanceUntilIdle()
-
- verify(pubkyRepo, never()).handleAuthCallback(any())
- }
-
@Test
fun `pubky auth deeplink shows approval sheet when Paykit UI is enabled`() = test {
enablePaykitUi()
@@ -3420,11 +3411,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
}
@Test
- fun `pubky auth deeplink keeps Ring-only guidance for an imported identity`() = test {
+ fun `pubky auth deeplink shows Pubky Ring toast without a usable secret key`() = test {
enablePaykitUi()
pubkyPublicKey.value = testPublicKey
whenever(pubkyRepo.hasSecretKey()).thenReturn(false)
- whenever(context.getString(R.string.profile__auth_approval_ring_only)).thenReturn("Use Ring")
+ whenever(context.getString(R.string.pubky_auth__use_ring)).thenReturn("Use Pubky Ring")
+ whenever(context.getString(R.string.pubky_auth__use_ring_desc)).thenReturn("Open Pubky Ring")
advanceUntilIdle()
val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw"
@@ -3432,10 +3424,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
advanceUntilIdle()
assertNull(sut.currentSheet.value)
+ verify(context, never()).getString(R.string.pubky_auth__no_identity)
verify(toastManager).enqueue(
check {
- assertEquals("Use Ring", it.title)
- assertNull(it.description)
+ assertEquals("Use Pubky Ring", it.title)
+ assertEquals("Open Pubky Ring", it.description)
}
)
}
diff --git a/changelog.d/next/1329.added.md b/changelog.d/next/1329.added.md
new file mode 100644
index 0000000000..7182ce3c88
--- /dev/null
+++ b/changelog.d/next/1329.added.md
@@ -0,0 +1 @@
+Bitkit can now use a pubky you already keep in Pubky Ring, and shares its own pubky with Pubky Ring.
diff --git a/docs/pubky.md b/docs/pubky.md
index 5a1abdd94f..ba5cbaba00 100644
--- a/docs/pubky.md
+++ b/docs/pubky.md
@@ -4,41 +4,29 @@ Paykit issuers should follow the [Paykit issuer interoperability contract](payki
## Overview
-Bitkit integrates [Pubky](https://pubky.org) decentralized identity, allowing users to connect their Pubky profile via [Pubky Ring](https://play.google.com/store/apps/details?id=to.pubky.ring) authentication. Once connected, the user's profile name and avatar appear on the home screen header, a full profile page shows their bio, links, and a shareable QR code, and the contacts screen shows followed Pubky users.
+Bitkit integrates [Pubky](https://pubky.org) decentralized identity. A user either creates a pubky in Bitkit or adopts one that [Pubky Ring](https://pubky.org) already owns, read through the shared pubky content provider. Once an identity is active, the user's profile name and avatar appear on the home screen header, a full profile page shows their bio, links, and a shareable QR code, and the contacts screen shows followed Pubky users.
-## Auth Flow
+## Identity Flow
```
-ProfileIntroScreen → PubkyRingAuthScreen → ProfileScreen
+ProfileIntroScreen → PubkyChoiceScreen → CreateProfileScreen → ProfileScreen
```
1. **ProfileIntroScreen** — presents the Pubky feature and a "Continue" button
-2. **PubkyRingAuthScreen** — initiates authentication via Pubky Ring deep link (`pubkyauth://`), waits for approval via relay, then completes session import
-3. **ProfileScreen** — displays the authenticated user's profile (name, bio, links, QR code)
+2. **PubkyChoiceScreen** — lists the pubkys Pubky Ring owns, or offers creating one in Bitkit when there are none
+3. **CreateProfileScreen** — signs the identity up on a homeserver and publishes the profile
+4. **ProfileScreen** — displays the active identity's profile (name, bio, links, QR code)
-### Deep Link Flow
-
-The auth handshake uses a relay-based protocol:
-
-1. `PubkyService.startAuth()` generates a `pubkyauth://` URL with required capabilities
-2. The URL is opened via `ACTION_VIEW` intent, launching Pubky Ring
-3. Pubky Ring prompts the user to approve the requested capabilities
-4. `PubkyService.completeAuth()` blocks on the relay until Ring sends approval, returning a session secret
-5. `PubkyService.importSession()` activates the session, returning the user's public key
-6. The session secret is persisted in Keychain for restoration on next launch
-
-### Auth State Machine (`PubkyAuthState`)
-
-- **Idle** — no authentication in progress
-- **Authenticating** — `startAuth()` has been called, waiting for relay setup
-- **Authenticated** — session active, profile available
+An adopted Ring identity keeps its secret in Pubky Ring: Bitkit stores only the reference and reads the
+secret just-in-time for signing. Bitkit still acts as an authenticator for incoming `pubkyauth://`
+requests, which are approved from the Pubky auth approval sheet.
## Service Layer (`PubkyService`)
Delegates Pubky operations to `PaykitSdkService`, which uses:
-- **paykit-ffi** (`com.synonym:paykit-android`) — session management, Ring auth, profile/contact resolution, and bounded file fetching
- - `startSignInAuth()`, `fetchPubkyProfile()`, `fetchPubkyFollows()`, `resolveContactProfile()`, `fetchPubkyFileBounded()`
+- **paykit-ffi** (`com.synonym:paykit-android`) — session management, auth approval, profile/contact resolution, and bounded file fetching
+ - `fetchPubkyProfile()`, `fetchPubkyFollows()`, `resolveContactProfile()`, `fetchPubkyFileBounded()`
- **bitkit-core** (`com.synonym:bitkit-core-android`) — mnemonic-to-seed conversion for receiver noise-key derivation
- `mnemonicToSeed()`
@@ -46,7 +34,7 @@ All calls are dispatched on `ServiceQueue.CORE` (single-thread executor) to ensu
## Repository Layer (`PubkyRepo`)
-Manages auth state, session lifecycle, and profile data. Singleton scoped.
+Manages session lifecycle, identity adoption, and profile data. Singleton scoped.
### Initialization
@@ -69,7 +57,7 @@ Manages auth state, session lifecycle, and profile data. Singleton scoped.
|---|---|
| `profile` | Full `PubkyProfile` or null |
| `publicKey` | Authenticated user's public key |
-| `isAuthenticated` | Derived from internal auth state |
+| `isAuthenticated` | True while a public key is set |
| `displayName` | Profile name with cached fallback |
| `displayImageUri` | Profile image URI with cached fallback |
| `isLoadingProfile` | Loading indicator |
@@ -88,7 +76,7 @@ Manages auth state, session lifecycle, and profile data. Singleton scoped.
```
ContactsIntroScreen → (if authenticated) ContactsScreen → ContactDetailScreen
- → (if not authenticated) PubkyRingAuthScreen → ContactsScreen
+ → (if not authenticated) PubkyChoiceScreen → ContactsScreen
```
1. **ContactsIntroScreen** — presents the contacts feature with a "Continue" button; marks `hasSeenContactsIntro` in settings
@@ -148,15 +136,16 @@ bodies can still be buffered by the Pubky client before Paykit regains control.
| File | Purpose |
|---|---|
| `services/PubkyService.kt` | FFI wrapper |
-| `repositories/PubkyRepo.kt` | Auth state and session management |
+| `repositories/PubkyRepo.kt` | Session management and identity adoption |
+| `data/sharedpubky/SharedPubkyClient.kt` | Reads Pubky Ring's shared pubky provider |
| `data/PubkyImageFetcher.kt` | Coil fetcher for pubky:// URIs |
| `di/ImageModule.kt` | Hilt module providing ImageLoader |
| `data/PubkyStore.kt` | DataStore for cached profile metadata |
| `models/PubkyProfile.kt` | Domain model |
| `ui/components/PubkyImage.kt` | Image composable |
| `ui/screens/profile/ProfileIntroScreen.kt` | Intro screen |
-| `ui/screens/profile/PubkyRingAuthScreen.kt` | Auth screen |
-| `ui/screens/profile/PubkyRingAuthViewModel.kt` | Auth ViewModel |
+| `ui/screens/profile/PubkyChoiceScreen.kt` | Identity choice screen |
+| `ui/screens/profile/PubkyChoiceViewModel.kt` | Identity choice ViewModel |
| `ui/screens/profile/ProfileScreen.kt` | Profile display |
| `ui/screens/profile/ProfileViewModel.kt` | Profile ViewModel |
| `ui/screens/contacts/ContactsIntroScreen.kt` | Contacts intro screen |