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 |