From f90abd9b34ed4520f64043d7096b4f7432c4adaf Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Tue, 22 Sep 2026 17:19:43 +0200 Subject: [PATCH 01/24] feat: export bitkit pubky via content provider --- app/src/main/AndroidManifest.xml | 12 ++ .../data/sharedpubky/SharedPubkyContract.kt | 35 ++++++ .../data/sharedpubky/SharedPubkyProvider.kt | 116 ++++++++++++++++++ .../sharedpubky/SharedPubkyContractTest.kt | 36 ++++++ .../sharedpubky/SharedPubkyProviderTest.kt | 111 +++++++++++++++++ changelog.d/next/0000.added.md | 1 + 6 files changed, 311 insertions(+) create mode 100644 app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyContract.kt create mode 100644 app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyProvider.kt create mode 100644 app/src/test/java/to/bitkit/data/sharedpubky/SharedPubkyContractTest.kt create mode 100644 app/src/test/java/to/bitkit/data/sharedpubky/SharedPubkyProviderTest.kt create mode 100644 changelog.d/next/0000.added.md diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 518327d9ae..e2bc15557b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -52,6 +52,11 @@ android:usesPermissionFlags="neverForLocation" tools:targetApi="31" /> + + + + + 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/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/changelog.d/next/0000.added.md b/changelog.d/next/0000.added.md new file mode 100644 index 0000000000..7182ce3c88 --- /dev/null +++ b/changelog.d/next/0000.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. From c84e83efcc1aa558f1d39273b424a53ded4b22b7 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Tue, 22 Sep 2026 17:45:46 +0200 Subject: [PATCH 02/24] feat: list pubky ring identities on choice --- app/src/main/AndroidManifest.xml | 3 +- .../data/sharedpubky/SharedPubkyClient.kt | 82 +++++++ .../java/to/bitkit/repositories/PubkyRepo.kt | 9 + .../ui/screens/profile/PubkyChoiceScreen.kt | 140 ++++++------ .../screens/profile/PubkyChoiceViewModel.kt | 204 ++++-------------- app/src/main/res/values/strings.xml | 6 +- .../to/bitkit/repositories/PubkyRepoTest.kt | 3 + .../profile/PubkyChoiceViewModelTest.kt | 120 +++-------- 8 files changed, 240 insertions(+), 327 deletions(-) create mode 100644 app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyClient.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e2bc15557b..4b28ce50ca 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,7 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> - + @@ -17,6 +17,7 @@ android:name="android.hardware.camera" android:required="false" /> + diff --git a/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyClient.kt b/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyClient.kt new file mode 100644 index 0000000000..966a51b0c9 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyClient.kt @@ -0,0 +1,82 @@ +package to.bitkit.data.sharedpubky + +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +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.data.sharedpubky.SharedPubkyContract.RING_AUTHORITY +import to.bitkit.data.sharedpubky.SharedPubkyContract.RING_PACKAGE +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "SharedPubkyClient" + +@Singleton +class SharedPubkyClient @Inject constructor( + @ApplicationContext private val context: Context, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { + suspend fun listRingIdentities(): Result> = withContext(ioDispatcher) { + runSuspendCatching { + if (!isRingProviderTrusted()) return@runSuspendCatching persistentListOf() + + 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 + val isTrusted = packageManager.resolveContentProvider(RING_AUTHORITY, 0)?.packageName == RING_PACKAGE && + packageManager.checkSignatures(context.packageName, RING_PACKAGE) == PackageManager.SIGNATURE_MATCH + + if (!isTrusted) Logger.warn("Skipped shared pubky query, ring provider unavailable", context = TAG) + return isTrusted + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 17817cdc95..107b1089df 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 @@ -38,6 +39,7 @@ 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.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.isPaykitIdentityError @@ -87,6 +89,7 @@ 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, @@ -289,6 +292,12 @@ class PubkyRepo @Inject constructor( // endregion + // region Shared pubky + + suspend fun ringIdentities(): Result> = sharedPubkyClient.listRingIdentities() + + // endregion + // region Ring auth flow suspend fun startAuthentication(): Result { 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 854541911d..700067ea0d 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 @@ -26,23 +24,24 @@ 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 @@ -50,7 +49,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 BG_IMAGE_WIDTH_FRACTION = 0.83f private const val TAG_OFFSET_X = -0.179f private const val TAG_OFFSET_Y = 0.13f @@ -68,17 +66,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() @@ -97,13 +89,6 @@ 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() }, ) } @@ -112,10 +97,6 @@ private fun Content( uiState: PubkyChoiceUiState, onBackClick: () -> Unit, onCreateProfile: () -> Unit, - onImportWithRing: () -> Unit, - onCancelAuth: () -> Unit, - onDownloadRing: () -> Unit, - onDismissDialog: () -> Unit, ) { Box( modifier = Modifier @@ -164,45 +145,54 @@ 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 -> 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 = {}, + 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 @@ -211,6 +201,8 @@ private fun OptionCard( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, + caption: String? = null, + trailing: (@Composable () -> Unit)? = null, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -235,54 +227,58 @@ 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 = {}, + ) } } @Preview(showBackground = true) @Composable -private fun Preview() { +private fun PreviewCreate() { AppThemeSurface { Content( - uiState = PubkyChoiceUiState(), + uiState = PubkyChoiceUiState(isLoading = false), onBackClick = {}, onCreateProfile = {}, - onImportWithRing = {}, - onCancelAuth = {}, - onDownloadRing = {}, - onDismissDialog = {}, ) } } 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..49be5438f3 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,12 @@ 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 @@ -17,202 +14,75 @@ import kotlinx.coroutines.flow.asStateFlow 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.Toast +import to.bitkit.models.PubkyPublicKeyFormat 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) { - _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 + if (it) _uiState.update { state -> state.copy(navigateToProfile = true) } } - - 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() { - 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) } - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.common__error), - description = it.message, - ) - } - } - .onFailure { - Logger.error("Auth approval failed", it, context = TAG) - _uiState.update { it.copy(isWaitingForRing = false) } - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.profile__auth_error_title), - description = it.message, - ) - } } } - 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("Listing ring identities failed", 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7ac0ae8f9f..00f2139db4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -656,10 +656,12 @@ This Bitkit claim is not supported. Failed to read selected image Create profile with Bitkit - Create a new pubky and profile in Bitkit, or import an existing profile with Pubky Ring. + New pubky + Create a new pubky and profile in Bitkit. + Use an existing pubky from Pubky Ring. Import with Pubky Ring Loading your profile… - Join the\n<accent>pubky web</accent> + Enter the\n<accent>freedom web</accent> Waiting for Pubky Ring… Failed to create profile Create Profile diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 5b8d001cea..be86ad459f 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -44,6 +44,7 @@ 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.ext.runSuspendCatching import to.bitkit.models.PubkyAuthClaim import to.bitkit.models.PubkyAuthRequest @@ -77,6 +78,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() @@ -106,6 +108,7 @@ class PubkyRepoTest : BaseUnitTest() { ioDispatcher = testDispatcher, pubkyService = pubkyService, keychain = keychain, + sharedPubkyClient = sharedPubkyClient, imageLoader = imageLoader, pubkyStore = pubkyStore, settingsStore = settingsStore, 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..ea497eae46 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,144 +1,94 @@ 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 -import to.bitkit.models.Toast import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest -import to.bitkit.ui.shared.toast.ToastEventBus import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class PubkyChoiceViewModelTest : BaseUnitTest() { - private val context: Context = mock() - private val packageManager: PackageManager = mock() + companion object { + private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + } + private val pubkyRepo: PubkyRepo = mock() - private val pendingImportContacts = MutableStateFlow>(emptyList()) private val isAuthenticated = MutableStateFlow(false) - private val authCancelEvents = MutableSharedFlow(extraBufferCapacity = 1) 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(pubkyRepo.isAuthenticated).thenReturn(isAuthenticated) - whenever(pubkyRepo.authCancelEvents).thenReturn(authCancelEvents) + whenever { pubkyRepo.ringIdentities() }.thenReturn(Result.success(persistentListOf())) } private fun createSut() { - sut = PubkyChoiceViewModel( - context = context, - pubkyRepo = pubkyRepo, - ) + sut = PubkyChoiceViewModel(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) - } - - @Test - fun `clearProfileNavigation clears profile redirect`() = test { - createSut() - isAuthenticated.value = true - advanceUntilIdle() - - sut.clearProfileNavigation() - - assertFalse(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 `waitForApproval prepareImport failure clears loading and emits no navigation`() = test { + fun `listing failure shows no identities`() = test { + whenever(pubkyRepo.ringIdentities()).thenReturn(Result.failure(RuntimeException("query failed"))) 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() 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) - - effectsJob.cancel() - toastJob.cancel() + assertFalse(sut.uiState.value.isLoading) + assertTrue(sut.uiState.value.identities.isEmpty()) } @Test - fun `startRingAuth shows dialog when Ring is not installed`() = test { + fun `session restoration redirects to profile when already authenticated`() = test { + isAuthenticated.value = true 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() advanceUntilIdle() - assertTrue(sut.uiState.value.showRingNotInstalledDialog) - assertTrue(effects.isEmpty()) - assertTrue(toasts.isEmpty()) - verify(pubkyRepo, never()).startAuthentication() - - effectsJob.cancel() - toastJob.cancel() + assertTrue(sut.uiState.value.navigateToProfile) } @Test - fun `onRingLaunchFailed shows dialog without toast`() = test { + fun `clearProfileNavigation clears profile redirect`() = test { 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() + isAuthenticated.value = true advanceUntilIdle() - assertFalse(sut.uiState.value.isWaitingForRing) - assertTrue(sut.uiState.value.showRingNotInstalledDialog) - assertTrue(effects.isEmpty()) - assertTrue(toasts.isEmpty()) - verify(pubkyRepo).cancelAuthentication() + sut.clearProfileNavigation() - effectsJob.cancel() - toastJob.cancel() + assertFalse(sut.uiState.value.navigateToProfile) } } From 34bd1b49fc41f481f0452d800739f6b43393bd75 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Tue, 22 Sep 2026 18:17:50 +0200 Subject: [PATCH 03/24] feat: adopt a pubky ring identity --- .../java/to/bitkit/data/keychain/Keychain.kt | 1 + .../data/sharedpubky/SharedPubkyContract.kt | 1 + .../java/to/bitkit/repositories/PubkyRepo.kt | 97 ++++++++++++++----- .../to/bitkit/services/PaykitSdkService.kt | 17 +++- .../screens/profile/CreateProfileViewModel.kt | 5 + .../ui/screens/profile/PubkyChoiceScreen.kt | 9 +- .../screens/profile/PubkyChoiceViewModel.kt | 30 +++++- .../to/bitkit/repositories/PubkyRepoTest.kt | 27 +++++- .../bitkit/services/PaykitSdkServiceTest.kt | 25 ++++- .../profile/CreateProfileViewModelTest.kt | 2 + .../profile/PubkyChoiceViewModelTest.kt | 53 +++++++++- 11 files changed, 233 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index fefa721567..fc812de56d 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -237,6 +237,7 @@ class Keychain @Inject constructor( PAYKIT_PENDING_PAYMENT_PROOFS, PAYKIT_PRESENTED_PAYMENT_REQUESTS, PUBKY_SECRET_KEY, + SHARED_PUBKY_SOURCE, } } diff --git a/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyContract.kt b/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyContract.kt index 4791e15c6a..b38d08372d 100644 --- a/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyContract.kt +++ b/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyContract.kt @@ -15,6 +15,7 @@ object SharedPubkyContract { const val RING_PACKAGE = "app.pubkyring" const val RING_AUTHORITY = RING_PACKAGE + AUTHORITY_SUFFIX const val RING_PERMISSION = "$RING_PACKAGE.permission.READ_SHARED_PUBKY" + const val RING_SOURCE_PREFIX = "$RING_PACKAGE:" fun isValidSecret( secretKeyHex: String, diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 107b1089df..79ab5c3942 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -40,6 +40,7 @@ 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 @@ -296,6 +297,37 @@ class PubkyRepo @Inject constructor( suspend fun ringIdentities(): Result> = sharedPubkyClient.listRingIdentities() + 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)}'" + } + keychain.upsertString( + Keychain.Key.SHARED_PUBKY_SOURCE.name, + "${SharedPubkyContract.RING_SOURCE_PREFIX}$pubky", + ) + + runSuspendCatching { pubkyService.signIn(secretKeyHex) }.getOrElse { + Logger.warn("Retrying sign up after sign in failed", it, context = TAG) + val homegate = fetchHomegateSignupCode() + pubkyService.signUp(secretKeyHex, homegate.homeserverPubky, homegate.signupCode) + } + + _publicKey.update { publicKey.ensurePubkyPrefix() } + _authState.update { PubkyAuthState.Authenticated } + notifyBackupStateChanged() + Logger.info("Adopted ring identity for '${redacted(publicKey)}'", context = TAG) + loadProfile() + loadContacts() + _profile.value != null + }.onFailure { + runCatching { keychain.delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) } + } + } + // endregion // region Ring auth flow @@ -601,24 +633,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 @@ -633,6 +649,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, @@ -1091,7 +1129,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) @@ -1104,7 +1142,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( @@ -1127,6 +1165,8 @@ class PubkyRepo @Inject constructor( suspend fun snapshotSessionBackupState(): Result = runSuspendCatching { withContext(ioDispatcher) { + if (keychain.exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)) return@withContext null + val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) if (!secretKeyHex.isNullOrEmpty()) { return@withContext PubkySessionBackupV1(kind = PubkySessionBackupKind.LocalSeed) @@ -1166,6 +1206,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 @@ -1207,8 +1248,7 @@ 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() @@ -1378,8 +1418,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() @@ -1399,6 +1445,11 @@ class PubkyRepo @Inject constructor( null } + 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" diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 15a47473a1..d75a4fc519 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -87,6 +87,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 +171,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 = { @@ -209,8 +212,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) @@ -995,7 +999,7 @@ class PaykitSdkService @Inject constructor( keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, access.exportSessionSecret()) sessionProvider.persistReceiverNoiseSecretKey(access.exportReceiverNoiseSecretKey()) val localSecret = access.exportLocalSecretKey() - if (shouldStoreLocalSecret && localSecret != null) { + if (shouldStoreLocalSecret && localSecret != null && sessionProvider.adoptedPubky() == null) { keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex(localSecret)) } else { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) @@ -1219,6 +1223,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) @@ -1273,6 +1278,7 @@ internal class PaykitSdkSessionProvider( override fun clearSessionAccess() { clearLiveSessionAccess() keychain.accessBlocking { + delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) clearPubkySessionCredentials(::delete) } } @@ -1281,9 +1287,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/ui/screens/profile/CreateProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileViewModel.kt index 9b1aafcef3..a687ea5119 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,6 +48,11 @@ 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) + return@launch + } pubkyRepo.deriveKeys() .onSuccess { (publicKey, _) -> _uiState.update { it.copy(derivedPublicKey = publicKey) } 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 700067ea0d..2c1d75729d 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 @@ -89,6 +89,7 @@ fun PubkyChoiceScreen( uiState = uiState, onBackClick = onBackClick, onCreateProfile = onNavigateToCreateProfile, + onIdentityClick = viewModel::onIdentityClick, ) } @@ -97,6 +98,7 @@ private fun Content( uiState: PubkyChoiceUiState, onBackClick: () -> Unit, onCreateProfile: () -> Unit, + onIdentityClick: (String) -> Unit, ) { Box( modifier = Modifier @@ -157,7 +159,8 @@ private fun Content( VerticalSpacer(24.dp) when { - uiState.isLoading -> LoadingState(text = stringResource(R.string.profile__choice_loading_profile)) + uiState.isLoading || uiState.adoptingPubky != null -> + LoadingState(text = stringResource(R.string.profile__choice_loading_profile)) uiState.identities.isEmpty() -> OptionCard( iconResId = R.drawable.ic_user_plus, @@ -172,7 +175,7 @@ private fun Content( OptionCard( iconResId = R.drawable.ic_lock_key, text = identity.name, - onClick = {}, + onClick = { onIdentityClick(identity.pubky) }, caption = identity.caption, trailing = { PubkyContactAvatar( @@ -267,6 +270,7 @@ private fun PreviewIdentities() { ), onBackClick = {}, onCreateProfile = {}, + onIdentityClick = {}, ) } } @@ -279,6 +283,7 @@ private fun PreviewCreate() { uiState = PubkyChoiceUiState(isLoading = false), onBackClick = {}, onCreateProfile = {}, + 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 49be5438f3..6dd1f48494 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,9 +1,11 @@ package to.bitkit.ui.screens.profile +import android.content.Context 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.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -14,8 +16,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.R 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 @@ -23,6 +28,7 @@ private const val TAG = "PubkyChoiceViewModel" @HiltViewModel class PubkyChoiceViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val pubkyRepo: PubkyRepo, ) : ViewModel() { private val _uiState = MutableStateFlow(PubkyChoiceUiState()) @@ -35,11 +41,33 @@ class PubkyChoiceViewModel @Inject constructor( loadIdentities() viewModelScope.launch { pubkyRepo.isAuthenticated.collectLatest { - if (it) _uiState.update { state -> state.copy(navigateToProfile = true) } + if (it && _uiState.value.adoptingPubky == null) { + _uiState.update { state -> state.copy(navigateToProfile = true) } + } } } } + fun onIdentityClick(pubky: String) { + viewModelScope.launch { + _uiState.update { it.copy(adoptingPubky = pubky) } + pubkyRepo.adoptRingIdentity(pubky) + .onSuccess { hasProfile -> + _uiState.update { it.copy(adoptingPubky = null, navigateToProfile = hasProfile) } + if (!hasProfile) _effects.emit(PubkyChoiceEffect.NavigateToCreateProfile) + } + .onFailure { + 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), + description = it.message, + ) + } + } + } + fun clearProfileNavigation() { _uiState.update { it.copy(navigateToProfile = false) } } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index be86ad459f..b81266e2c9 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -774,7 +774,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() } @@ -1289,6 +1289,31 @@ class PubkyRepoTest : BaseUnitTest() { ) } + @Test + 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() + + 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 `snapshotSessionBackupState should return null when no pubky credentials exist`() = test { whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) 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..48e7f5bc7e 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,6 +2,7 @@ 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 @@ -28,6 +29,7 @@ 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(pubkyRepo.publicKey).thenReturn(MutableStateFlow(null)) whenever { pubkyRepo.deriveKeys() }.thenReturn(Result.success("pubkyalice" to "secret")) whenever { pubkyRepo.fetchRemoteProfile(any()) }.thenReturn(Result.success(null)) 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 ea497eae46..90a62d4ed7 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,18 +1,24 @@ package to.bitkit.ui.screens.profile +import android.content.Context import kotlinx.collections.immutable.persistentListOf 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.mock import org.mockito.kotlin.whenever +import to.bitkit.R import to.bitkit.models.PubkyProfile +import to.bitkit.models.Toast import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.shared.toast.ToastEventBus import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -21,6 +27,7 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" } + private val context: Context = mock() private val pubkyRepo: PubkyRepo = mock() private val isAuthenticated = MutableStateFlow(false) @@ -28,12 +35,13 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { @Before fun setUp() { + whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization Failed") whenever(pubkyRepo.isAuthenticated).thenReturn(isAuthenticated) whenever { pubkyRepo.ringIdentities() }.thenReturn(Result.success(persistentListOf())) } private fun createSut() { - sut = PubkyChoiceViewModel(pubkyRepo = pubkyRepo) + sut = PubkyChoiceViewModel(context = context, pubkyRepo = pubkyRepo) } @Test @@ -71,6 +79,49 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { assertTrue(sut.uiState.value.identities.isEmpty()) } + @Test + fun `onIdentityClick navigates to profile when the adopted identity has a profile`() = test { + whenever(pubkyRepo.adoptRingIdentity(RING_PUBKY)).thenReturn(Result.success(true)) + createSut() + + sut.onIdentityClick(RING_PUBKY) + advanceUntilIdle() + + assertTrue(sut.uiState.value.navigateToProfile) + } + + @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 effectsJob = launch { sut.effects.collect { effects.add(it) } } + + sut.onIdentityClick(RING_PUBKY) + advanceUntilIdle() + + 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(RuntimeException("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 From fadcecaef2381e285d56d11b0452c8be29d052a0 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Tue, 22 Sep 2026 18:40:47 +0200 Subject: [PATCH 04/24] feat: clear adopted pubky on source loss --- .../java/to/bitkit/repositories/PubkyRepo.kt | 21 +++++++++++++ .../to/bitkit/services/PaykitSdkService.kt | 2 ++ .../java/to/bitkit/services/PubkyService.kt | 4 +++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 12 ++++++++ app/src/main/res/values/strings.xml | 1 + .../to/bitkit/repositories/PubkyRepoTest.kt | 30 +++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 1 + 7 files changed, 71 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 79ab5c3942..483dda86d5 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -138,6 +138,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() @@ -230,6 +233,8 @@ class PubkyRepo @Inject constructor( loadContacts() } } + + checkAdoptedSourcePresent() } private fun hasSavedSession(): Boolean = runCatching { @@ -297,6 +302,22 @@ class PubkyRepo @Inject constructor( suspend fun ringIdentities(): Result> = sharedPubkyClient.listRingIdentities() + fun clearAdoptedSourceLost() { + _adoptedSourceLost.update { false } + } + + private suspend fun checkAdoptedSourcePresent() { + val reference = keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name) ?: return + val ringPubkys = sharedPubkyClient.listRingIdentities().getOrElse { return } + if (ringPubkys.any { "${SharedPubkyContract.RING_SOURCE_PREFIX}$it" == reference }) return + + 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 } + } + suspend fun adoptRingIdentity(pubky: String): Result = withContext(ioDispatcher) { runSuspendCatching { ensureServiceInitialized() diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index d75a4fc519..602b46e7e1 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -956,6 +956,8 @@ class PaykitSdkService @Inject constructor( } } + suspend fun clearSessionAccess() = operationMutex.withLock { clearRegisteredIdentityActivationLocked() } + suspend fun forgetSessionAccess() { isSetup.await() operationMutex.withLock { diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index b0d468aa50..35c63ea8dc 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -56,6 +56,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()) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f6a8de6aad..d9828264f3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -510,6 +510,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.Navigate(route = Routes.PubkyChoice)) + pubkyRepo.clearAdoptedSourceLost() + } + } + } observeReceiveSheetInvoice() observeLdkNodeEvents() observeLightningUsableChannels() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 00f2139db4..4f57266292 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -711,6 +711,7 @@ 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 diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index b81266e2c9..07656822c6 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.async import kotlinx.coroutines.awaitCancellation @@ -45,6 +46,7 @@ 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 @@ -115,6 +117,11 @@ class PubkyRepoTest : BaseUnitTest() { 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) @@ -1314,6 +1321,29 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(keychain) { delete(Keychain.Key.SHARED_PUBKY_SOURCE.name) } } + @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 `snapshotSessionBackupState should return null when no pubky credentials exist`() = test { whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 06c8ef1456..419cdcecbe 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -345,6 +345,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 } From 372f44043ce73a920ec6de62b85708b1785f99df Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Tue, 22 Sep 2026 19:08:36 +0200 Subject: [PATCH 05/24] refactor: remove pubky ring relay auth --- app/src/main/AndroidManifest.xml | 4 - .../java/to/bitkit/models/BackupPayloads.kt | 2 +- .../to/bitkit/models/PubkyRingAuthCallback.kt | 70 --- .../java/to/bitkit/repositories/PubkyRepo.kt | 289 +---------- .../to/bitkit/services/PaykitSdkService.kt | 68 +-- .../java/to/bitkit/services/PubkyService.kt | 21 - .../java/to/bitkit/viewmodels/AppViewModel.kt | 33 +- app/src/main/res/values/strings.xml | 11 - .../models/PubkyRingAuthCallbackTest.kt | 77 --- .../to/bitkit/repositories/PubkyRepoTest.kt | 453 +----------------- .../viewmodels/AppViewModelSendFlowTest.kt | 19 +- docs/pubky.md | 47 +- 12 files changed, 67 insertions(+), 1027 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/models/PubkyRingAuthCallback.kt delete mode 100644 app/src/test/java/to/bitkit/models/PubkyRingAuthCallbackTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4b28ce50ca..cd89e25350 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,10 +7,6 @@ - - - - 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 483dda86d5..ec7caae991 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -18,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 @@ -52,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 @@ -61,29 +57,18 @@ 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( @@ -111,12 +96,6 @@ class PubkyRepo @Inject constructor( private val loadContactsMutex = 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() @@ -218,7 +197,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 -> { @@ -338,7 +316,6 @@ class PubkyRepo @Inject constructor( } _publicKey.update { publicKey.ensurePubkyPrefix() } - _authState.update { PubkyAuthState.Authenticated } notifyBackupStateChanged() Logger.info("Adopted ring identity for '${redacted(publicKey)}'", context = TAG) loadProfile() @@ -351,219 +328,6 @@ class PubkyRepo @Inject constructor( // endregion - // region Ring auth flow - - 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 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 - } - } - - 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) - 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 cancelAuthentication() { - 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 - } - 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) - } - } - } - - 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 - } - } - } - - 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 - } - }.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() - } - - private fun endAuthAttempt() { - _activeAuthAttemptId.update { null } - _approvedAuthAttemptId.update { null } - _authCancelEvents.tryEmit(Unit) - restoreAuthStateAfterAuthFlow() - } - - private fun restoreAuthStateAfterAuthFlow() { - _authState.update { if (_publicKey.value == null) PubkyAuthState.Idle else PubkyAuthState.Authenticated } - } - - // endregion - // region Payment endpoints suspend fun removeBitkitPaymentEndpoints(): Result = withContext(ioDispatcher) { @@ -722,7 +486,6 @@ class PubkyRepo @Inject constructor( status = null, ) _publicKey.update { publicKey } - _authState.update { PubkyAuthState.Authenticated } _profile.update { createdProfile } cacheMetadata(createdProfile) settingsStore.setPubkyProfileSetupPending(false) @@ -737,6 +500,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)) { @@ -1123,7 +906,6 @@ class PubkyRepo @Inject constructor( } _publicKey.update { publicKey } - _authState.update { PubkyAuthState.Authenticated } var pendingSaved = false try { settingsStore.setPubkyProfileSetupPending(true) @@ -1187,21 +969,9 @@ class PubkyRepo @Inject constructor( suspend fun snapshotSessionBackupState(): Result = runSuspendCatching { withContext(ioDispatcher) { 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 - 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, - ) - } - - null + PubkySessionBackupV1(kind = PubkySessionBackupKind.LocalSeed) } } @@ -1236,19 +1006,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() @@ -1276,7 +1037,6 @@ class PubkyRepo @Inject constructor( notifyBackupStateChanged() _publicKey.update { publicKey } - _authState.update { PubkyAuthState.Authenticated } true } @@ -1491,7 +1251,6 @@ class PubkyRepo @Inject constructor( _contactsLoadVersion.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 602b46e7e1..fd28192051 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 @@ -194,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 = { @@ -310,15 +308,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()), ) @@ -326,7 +321,6 @@ class PaykitSdkService @Inject constructor( activateBootstrapResult( result = result, previousPublicKey = previousPublicKey, - shouldStoreLocalSecret = includeLocalSecret, ) } notifyBackupStateChanged() @@ -351,7 +345,6 @@ class PaykitSdkService @Inject constructor( activateBootstrapResult( result = result, previousPublicKey = previousPublicKey, - shouldStoreLocalSecret = true, ) } notifyBackupStateChanged() @@ -382,7 +375,6 @@ class PaykitSdkService @Inject constructor( activateBootstrapResult( result = result, previousPublicKey = previousPublicKey, - shouldStoreLocalSecret = true, ) activated = true } finally { @@ -404,56 +396,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, @@ -961,7 +909,6 @@ class PaykitSdkService @Inject constructor( suspend fun forgetSessionAccess() { isSetup.await() operationMutex.withLock { - activeAuthRequest = null try { withStateRevisionTracking { handle -> handle.forgetSessionAccess() @@ -980,7 +927,6 @@ class PaykitSdkService @Inject constructor( private suspend fun clearStateLocked() { keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) - activeAuthRequest = null resetRuntime() notifyBackupStateChanged() } @@ -994,14 +940,11 @@ class PaykitSdkService @Inject constructor( } } - 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 && sessionProvider.adoptedPubky() == 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) @@ -1011,9 +954,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) diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 35c63ea8dc..6a1dc87ca8 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() } @@ -115,23 +111,6 @@ class PubkyService @Inject constructor( // 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() - } - - // endregion - // region Auth approval suspend fun parseAuthUrl(url: String) = ServiceQueue.CORE.background { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index d9828264f3..005dd07410 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -120,8 +120,6 @@ import to.bitkit.models.NodeLifecycleState import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat -import to.bitkit.models.PubkyRingAuthCallback -import to.bitkit.models.PubkyRingAuthCallbackHandlingResult import to.bitkit.models.SamRockSetupRequest import to.bitkit.models.SendFailureDetails import to.bitkit.models.Suggestion @@ -5489,12 +5487,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, @@ -5526,7 +5518,7 @@ class AppViewModel @Inject constructor( val isSignup = PubkyAuthRequest.isSignupUrl(authUrl) if (isSignup && rejectPubkySignupForExistingIdentity()) return - if (!isSignup && pubkyRepo.publicKey.value == null) { + if (!isSignup && (pubkyRepo.publicKey.value == null || !pubkyRepo.hasSecretKey())) { ToastEventBus.send( type = Toast.ToastType.WARNING, title = context.getString(R.string.pubky_auth__no_identity), @@ -5534,14 +5526,6 @@ class AppViewModel @Inject constructor( ) return } - - if (!isSignup && !pubkyRepo.hasSecretKey()) { - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.profile__auth_approval_ring_only), - ) - return - } showSheet(Sheet.PubkyAuth(authUrl)) } @@ -5563,21 +5547,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, "") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4f57266292..9dc27496f2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -635,7 +635,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,10 +658,8 @@ New pubky Create a new pubky and profile in Bitkit. Use an existing pubky from Pubky Ring. - Import with 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… @@ -699,14 +696,6 @@ 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. 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 07656822c6..9f58735414 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -51,8 +51,6 @@ 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 @@ -259,107 +257,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" @@ -400,270 +297,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() @@ -1280,22 +913,6 @@ 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) - 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(), - ) - } - @Test fun `snapshotSessionBackupState should return null for an adopted ring identity`() = test { whenever(keychain.exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)).thenReturn(true) @@ -1564,18 +1181,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 @@ -1593,24 +1206,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) @@ -1705,16 +1300,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) } @@ -1748,17 +1339,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"))) } @@ -1996,27 +1583,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( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 419cdcecbe..a650ca2333 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2818,16 +2818,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() @@ -3098,11 +3088,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `pubky auth deeplink keeps Ring-only guidance for an imported identity`() = test { + fun `pubky auth deeplink shows identity required 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__no_identity)).thenReturn("Pubky Identity Required") + whenever(context.getString(R.string.pubky_auth__no_identity_desc)).thenReturn("Create a Pubky identity") advanceUntilIdle() val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw" @@ -3112,8 +3103,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(sut.currentSheet.value) verify(toastManager).enqueue( check { - assertEquals("Use Ring", it.title) - assertNull(it.description) + assertEquals("Pubky Identity Required", it.title) + assertEquals("Create a Pubky identity", it.description) } ) } 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 | From 998cb300d9c6abd7d4e21d147f83c6a2a8031ae4 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Tue, 22 Sep 2026 21:27:39 +0200 Subject: [PATCH 06/24] chore: rename changelog fragment --- changelog.d/next/{0000.added.md => 1329.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{0000.added.md => 1329.added.md} (100%) diff --git a/changelog.d/next/0000.added.md b/changelog.d/next/1329.added.md similarity index 100% rename from changelog.d/next/0000.added.md rename to changelog.d/next/1329.added.md From 81ff2148e0a1b438d080ebcd29e11d6b85018cda Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Wed, 23 Sep 2026 19:17:01 +0200 Subject: [PATCH 07/24] fix: block profile save after failed lookup --- .../screens/profile/CreateProfileViewModel.kt | 34 +++++++++++++++---- .../profile/CreateProfileViewModelTest.kt | 32 +++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) 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 a687ea5119..09149f8eb7 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 @@ -50,13 +50,13 @@ class CreateProfileViewModel @Inject constructor( _uiState.update { it.copy(isLoading = true) } pubkyRepo.publicKey.value?.let { publicKey -> _uiState.update { it.copy(derivedPublicKey = publicKey) } - checkForExistingProfile(publicKey) + checkForExistingProfile(publicKey, hasSession = true) return@launch } pubkyRepo.deriveKeys() .onSuccess { (publicKey, _) -> _uiState.update { it.copy(derivedPublicKey = publicKey) } - checkForExistingProfile(publicKey) + checkForExistingProfile(publicKey, hasSession = false) } .onFailure { Logger.error("Failed to derive keys", it, context = TAG) @@ -70,13 +70,18 @@ class CreateProfileViewModel @Inject constructor( } } - private suspend fun checkForExistingProfile(publicKey: String) { + /** + * With a session the homeserver is known, so a failed lookup is not treated as "no profile": + * saving then could replace an existing profile with an empty one. + */ + private suspend fun checkForExistingProfile(publicKey: String, hasSession: 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, @@ -87,12 +92,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 (!hasSession) { + 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, + ) } } @@ -169,6 +184,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 @@ -206,6 +225,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/test/java/to/bitkit/ui/screens/profile/CreateProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/CreateProfileViewModelTest.kt index 48e7f5bc7e..5c4702ac63 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 @@ -8,7 +8,10 @@ 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 @@ -29,6 +32,7 @@ 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.fetchRemoteProfile(any()) }.thenReturn(Result.success(null)) @@ -61,4 +65,32 @@ 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(Exception("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(Exception("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()) + } } From 703e7dd82bdc0323ed581ab89b2f8e2f74642da4 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 24 Sep 2026 07:42:53 +0200 Subject: [PATCH 08/24] fix: keep ring pubky when ring is unavailable --- .../data/sharedpubky/SharedPubkyClient.kt | 11 +-- .../data/sharedpubky/SharedPubkyClientTest.kt | 91 +++++++++++++++++++ 2 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 app/src/test/java/to/bitkit/data/sharedpubky/SharedPubkyClientTest.kt diff --git a/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyClient.kt b/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyClient.kt index 966a51b0c9..6ae9bb414f 100644 --- a/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyClient.kt +++ b/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyClient.kt @@ -5,7 +5,6 @@ import android.content.pm.PackageManager import android.net.Uri import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext @@ -18,12 +17,9 @@ import to.bitkit.data.sharedpubky.SharedPubkyContract.RING_PACKAGE import to.bitkit.di.IoDispatcher import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyPublicKeyFormat -import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton -private const val TAG = "SharedPubkyClient" - @Singleton class SharedPubkyClient @Inject constructor( @ApplicationContext private val context: Context, @@ -31,7 +27,7 @@ class SharedPubkyClient @Inject constructor( ) { suspend fun listRingIdentities(): Result> = withContext(ioDispatcher) { runSuspendCatching { - if (!isRingProviderTrusted()) return@runSuspendCatching persistentListOf() + 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)) { @@ -73,10 +69,7 @@ class SharedPubkyClient @Inject constructor( private fun isRingProviderTrusted(): Boolean { val packageManager = context.packageManager - val isTrusted = packageManager.resolveContentProvider(RING_AUTHORITY, 0)?.packageName == RING_PACKAGE && + return packageManager.resolveContentProvider(RING_AUTHORITY, 0)?.packageName == RING_PACKAGE && packageManager.checkSignatures(context.packageName, RING_PACKAGE) == PackageManager.SIGNATURE_MATCH - - if (!isTrusted) Logger.warn("Skipped shared pubky query, ring provider unavailable", context = TAG) - return isTrusted } } 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 + }, + ) + }, + ) + } +} From 90e5649c6de06c5bfcdd5f5e9b945619663c248a Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 24 Sep 2026 07:43:00 +0200 Subject: [PATCH 09/24] fix: recheck ring pubky on foreground --- .../java/to/bitkit/repositories/PubkyRepo.kt | 30 +++++++++++++------ app/src/main/java/to/bitkit/ui/ContentView.kt | 1 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 4 +++ .../to/bitkit/repositories/PubkyRepoTest.kt | 28 +++++++++++++++++ 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index ec7caae991..e22c41cfc5 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -94,6 +94,7 @@ 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 _profile = MutableStateFlow(null) @@ -284,16 +285,27 @@ class PubkyRepo @Inject constructor( _adoptedSourceLost.update { false } } - private suspend fun checkAdoptedSourcePresent() { - val reference = keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name) ?: return - val ringPubkys = sharedPubkyClient.listRingIdentities().getOrElse { return } - if (ringPubkys.any { "${SharedPubkyContract.RING_SOURCE_PREFIX}$it" == reference }) return + suspend fun checkAdoptedSource(): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (initializationReady.isCompleted) checkAdoptedSourcePresent() + }.onFailure { Logger.warn("Failed to check adopted ring identity", it, context = TAG) } + } - 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 } + private suspend fun checkAdoptedSourcePresent() { + if (!adoptedSourceCheckMutex.tryLock()) return + try { + val reference = keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name) ?: return + val ringPubkys = sharedPubkyClient.listRingIdentities().getOrElse { return } + if (ringPubkys.any { "${SharedPubkyContract.RING_SOURCE_PREFIX}$it" == reference }) return + + 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() + } } suspend fun adoptRingIdentity(pubky: String): Result = withContext(ioDispatcher) { diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 57488f528c..8615ada71d 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() diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 2a2a740ce9..aeee44ded5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -665,6 +665,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 diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 9f58735414..5d32e481c6 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -961,6 +961,34 @@ class PubkyRepoTest : BaseUnitTest() { 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 fun `snapshotSessionBackupState should return null when no pubky credentials exist`() = test { whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) From d90c704faaff545b5fe961b21d4ea43ddb13c85f Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 24 Sep 2026 07:43:12 +0200 Subject: [PATCH 10/24] fix: restore contact import after pubky pick --- .../java/to/bitkit/repositories/PubkyRepo.kt | 2 +- .../screens/profile/PubkyChoiceViewModel.kt | 10 +++++-- .../profile/PubkyChoiceViewModelTest.kt | 29 +++++++++++++++++-- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index e22c41cfc5..e5e14dafd1 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -846,7 +846,7 @@ class PubkyRepo @Inject constructor( _pendingImportProfile.update { ownProfile } _pendingImportContacts.update { contacts } } - } + }.onFailure { Logger.warn("Failed to prepare contact import", it, context = TAG) } suspend fun clearPendingImport() = withContext(ioDispatcher) { _pendingImportProfile.update { null } 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 6dd1f48494..3c94fc4aa8 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 @@ -53,8 +53,14 @@ class PubkyChoiceViewModel @Inject constructor( _uiState.update { it.copy(adoptingPubky = pubky) } pubkyRepo.adoptRingIdentity(pubky) .onSuccess { hasProfile -> - _uiState.update { it.copy(adoptingPubky = null, navigateToProfile = hasProfile) } - if (!hasProfile) _effects.emit(PubkyChoiceEffect.NavigateToCreateProfile) + if (hasProfile) pubkyRepo.prepareImport() + _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("Failed to adopt ring identity", it, context = TAG) 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 90a62d4ed7..3cf30ecf44 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 @@ -30,6 +30,7 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { private val context: Context = mock() private val pubkyRepo: PubkyRepo = mock() private val isAuthenticated = MutableStateFlow(false) + private val pendingImportContacts = MutableStateFlow>(emptyList()) private lateinit var sut: PubkyChoiceViewModel @@ -37,7 +38,9 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { fun setUp() { whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization Failed") whenever(pubkyRepo.isAuthenticated).thenReturn(isAuthenticated) + whenever(pubkyRepo.pendingImportContacts).thenReturn(pendingImportContacts) whenever { pubkyRepo.ringIdentities() }.thenReturn(Result.success(persistentListOf())) + whenever { pubkyRepo.prepareImport() }.thenReturn(Result.success(Unit)) } private fun createSut() { @@ -80,14 +83,36 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { } @Test - fun `onIdentityClick navigates to profile when the adopted identity has a profile`() = 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() - assertTrue(sut.uiState.value.navigateToProfile) + assertEquals(PubkyChoiceEffect.NavigateToContactImportOverview, effects.single()) + assertFalse(sut.uiState.value.navigateToProfile) + assertNull(sut.uiState.value.adoptingPubky) + effectsJob.cancel() + } + + @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() + val effects = mutableListOf() + val effectsJob = launch { sut.effects.collect { effects.add(it) } } + + sut.onIdentityClick(RING_PUBKY) + advanceUntilIdle() + + assertEquals(PubkyChoiceEffect.NavigateToPayContacts, effects.single()) + assertFalse(sut.uiState.value.navigateToProfile) + assertNull(sut.uiState.value.adoptingPubky) + effectsJob.cancel() } @Test From caacc1557f2734b7dccd3707319b41cf84df2baa Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 24 Sep 2026 07:43:12 +0200 Subject: [PATCH 11/24] fix: block save after lookup fail for stored key --- .../main/java/to/bitkit/repositories/PubkyRepo.kt | 4 ++++ .../ui/screens/profile/CreateProfileViewModel.kt | 12 ++++-------- .../screens/profile/CreateProfileViewModelTest.kt | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index e5e14dafd1..5b5246c64d 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -862,6 +862,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() || 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 09149f8eb7..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 @@ -50,13 +50,13 @@ class CreateProfileViewModel @Inject constructor( _uiState.update { it.copy(isLoading = true) } pubkyRepo.publicKey.value?.let { publicKey -> _uiState.update { it.copy(derivedPublicKey = publicKey) } - checkForExistingProfile(publicKey, hasSession = true) + checkForExistingProfile(publicKey, isSignedUp = true) return@launch } pubkyRepo.deriveKeys() .onSuccess { (publicKey, _) -> _uiState.update { it.copy(derivedPublicKey = publicKey) } - checkForExistingProfile(publicKey, hasSession = false) + checkForExistingProfile(publicKey, isSignedUp = pubkyRepo.hasStoredSecretKey()) } .onFailure { Logger.error("Failed to derive keys", it, context = TAG) @@ -70,11 +70,7 @@ class CreateProfileViewModel @Inject constructor( } } - /** - * With a session the homeserver is known, so a failed lookup is not treated as "no profile": - * saving then could replace an existing profile with an empty one. - */ - private suspend fun checkForExistingProfile(publicKey: String, hasSession: Boolean) { + private suspend fun checkForExistingProfile(publicKey: String, isSignedUp: Boolean) { pubkyRepo.fetchRemoteProfile(publicKey) .onSuccess { profile -> if (profile != null) { @@ -96,7 +92,7 @@ class CreateProfileViewModel @Inject constructor( } } .onFailure { error -> - if (!hasSession) { + if (!isSignedUp) { Logger.debug("No existing remote profile found for '$publicKey'", context = TAG) _uiState.update { it.copy(isLoading = false) } return@onFailure 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 5c4702ac63..8d51241b32 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 @@ -35,6 +35,7 @@ class CreateProfileViewModelTest : BaseUnitTest() { 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( @@ -80,6 +81,20 @@ class CreateProfileViewModelTest : BaseUnitTest() { 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(Exception("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(Exception("no homeserver"))) From 42bdc1b497bf235f95d693849613f143d428cc39 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 24 Sep 2026 07:43:12 +0200 Subject: [PATCH 12/24] fix: scroll pubky choice list --- .../bitkit/ui/screens/profile/PubkyChoiceScreen.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 2c1d75729d..0503bde50c 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 @@ -12,8 +12,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +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 @@ -37,7 +39,6 @@ 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.PubkyContactAvatar @@ -136,7 +137,12 @@ private fun Content( actions = { DrawerNavIcon() }, ) - Column(modifier = Modifier.padding(horizontal = 32.dp)) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 32.dp) + ) { VerticalSpacer(24.dp) Display( @@ -192,8 +198,6 @@ private fun Content( } } } - - FillHeight() } } } From 078aacfbfdce8c20d4b353a2ec1d1d0b93fe33c5 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 24 Sep 2026 07:43:12 +0200 Subject: [PATCH 13/24] chore: document shared pubky contract --- .../data/sharedpubky/SharedPubkyContract.kt | 16 +++++++++++++++- .../ui/screens/profile/PubkyChoiceViewModel.kt | 2 +- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyContract.kt b/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyContract.kt index b38d08372d..aeef8e94dc 100644 --- a/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyContract.kt +++ b/app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyContract.kt @@ -7,14 +7,28 @@ 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 - const val RING_PERMISSION = "$RING_PACKAGE.permission.READ_SHARED_PUBKY" + + /** Prefix of the stored `app.pubkyring:` source reference that marks an adopted Ring pubky. */ const val RING_SOURCE_PREFIX = "$RING_PACKAGE:" fun isValidSecret( 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 3c94fc4aa8..0b0b6ce9cb 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 @@ -81,7 +81,7 @@ class PubkyChoiceViewModel @Inject constructor( private fun loadIdentities() { viewModelScope.launch { val pubkys = pubkyRepo.ringIdentities().getOrElse { - Logger.warn("Listing ring identities failed", it, context = TAG) + Logger.warn("Failed to list ring identities", it, context = TAG) persistentListOf() } val identities = pubkys.map { pubky -> From bcff99947748b971daf16a3aabe026970b3db522 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 24 Sep 2026 08:39:45 +0200 Subject: [PATCH 14/24] fix: drop stale screens on pubky loss --- app/src/main/java/to/bitkit/ui/ContentView.kt | 3 +++ app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 8615ada71d..9f1868c61c 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -363,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/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index aeee44ded5..f88fdd625b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -545,7 +545,7 @@ class AppViewModel @Inject constructor( type = Toast.ToastType.ERROR, title = context.getString(R.string.profile__source_lost), ) - mainScreenEffect(MainScreenEffect.Navigate(route = Routes.PubkyChoice)) + mainScreenEffect(MainScreenEffect.NavigateToPubkyChoice) pubkyRepo.clearAdoptedSourceLost() } } @@ -5888,6 +5888,7 @@ sealed class MainScreenEffect { val clearStack: Boolean = false, ) : MainScreenEffect() + data object NavigateToPubkyChoice : MainScreenEffect() data object WipeWallet : MainScreenEffect() data class ProcessClipboardAutoRead(val data: String) : MainScreenEffect() } From d4f77e67706160206820256d230b85121da73e56 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 24 Sep 2026 08:39:45 +0200 Subject: [PATCH 15/24] chore: log failed ring listing --- app/src/main/java/to/bitkit/repositories/PubkyRepo.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 5b5246c64d..d2ad8572ae 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -295,7 +295,10 @@ class PubkyRepo @Inject constructor( if (!adoptedSourceCheckMutex.tryLock()) return try { val reference = keychain.loadString(Keychain.Key.SHARED_PUBKY_SOURCE.name) ?: return - val ringPubkys = sharedPubkyClient.listRingIdentities().getOrElse { return } + val ringPubkys = sharedPubkyClient.listRingIdentities().getOrElse { + Logger.warn("Failed to list ring identities", it, context = TAG) + return + } if (ringPubkys.any { "${SharedPubkyContract.RING_SOURCE_PREFIX}$it" == reference }) return Logger.warn("Adopted ring identity '${redacted(reference)}' is gone, clearing session", context = TAG) From 9b8c86a912fcd0902f38f192220f44ef00a55aa7 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 24 Sep 2026 09:43:37 +0200 Subject: [PATCH 16/24] fix: sign up ring keys only without a record --- .../java/to/bitkit/repositories/PubkyRepo.kt | 4 +- .../to/bitkit/services/PaykitSdkService.kt | 6 +++ .../java/to/bitkit/services/PubkyService.kt | 4 ++ .../to/bitkit/repositories/PubkyRepoTest.kt | 38 +++++++++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index d2ad8572ae..c2ea3d587f 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -325,7 +325,9 @@ class PubkyRepo @Inject constructor( ) runSuspendCatching { pubkyService.signIn(secretKeyHex) }.getOrElse { - Logger.warn("Retrying sign up after sign in failed", it, context = TAG) + val hasIdentityRecord = runSuspendCatching { pubkyService.hasIdentityRecord(publicKey) }.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) } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index fd28192051..5265598543 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -292,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 { diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 6a1dc87ca8..3735b17bfa 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -109,6 +109,10 @@ class PubkyService @Inject constructor( Unit } + suspend fun hasIdentityRecord(publicKey: String): Boolean = ServiceQueue.CORE.background { + paykitSdkService.hasIdentityRecord(publicKey) + } + // endregion // region Auth approval diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 5d32e481c6..9ad5e3e7c7 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -938,6 +938,37 @@ class PubkyRepoTest : BaseUnitTest() { 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 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 `initialize should clear an adopted identity that is gone from pubky ring`() = test { stubAdoptedRingSource() @@ -1645,6 +1676,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") From 6dca12c23df26896b8e9a92158687b646b04cec2 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 24 Sep 2026 09:43:37 +0200 Subject: [PATCH 17/24] fix: resume create profile after ring adopt --- .../java/to/bitkit/repositories/PubkyRepo.kt | 5 +++- .../to/bitkit/repositories/PubkyRepoTest.kt | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index c2ea3d587f..5f7a882786 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -337,7 +337,10 @@ class PubkyRepo @Inject constructor( Logger.info("Adopted ring identity for '${redacted(publicKey)}'", context = TAG) loadProfile() loadContacts() - _profile.value != null + 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) } } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 9ad5e3e7c7..91a50bc6f1 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -969,6 +969,33 @@ class PubkyRepoTest : BaseUnitTest() { 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() From c132f058d7c340977d9f936ac3cee789538af545 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 24 Sep 2026 11:14:20 +0200 Subject: [PATCH 18/24] chore: log failed ring record check --- .../main/java/to/bitkit/repositories/PubkyRepo.kt | 4 +++- .../java/to/bitkit/repositories/PubkyRepoTest.kt | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 5f7a882786..ffcd4fe699 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -325,7 +325,9 @@ class PubkyRepo @Inject constructor( ) runSuspendCatching { pubkyService.signIn(secretKeyHex) }.getOrElse { - val hasIdentityRecord = runSuspendCatching { pubkyService.hasIdentityRecord(publicKey) }.getOrNull() + 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() diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 91a50bc6f1..c4eecb06bf 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -952,6 +952,19 @@ class PubkyRepoTest : BaseUnitTest() { 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() From bd367e53ec5b2520ffbbc850172c7b8fb0e441df Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 24 Sep 2026 12:06:42 +0200 Subject: [PATCH 19/24] test: use app errors in profile tests --- .../ui/screens/profile/CreateProfileViewModelTest.kt | 10 +++++++--- .../ui/screens/profile/PubkyChoiceViewModelTest.kt | 8 ++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) 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 8d51241b32..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 @@ -18,6 +18,7 @@ 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 @@ -70,7 +71,7 @@ class CreateProfileViewModelTest : BaseUnitTest() { @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(Exception("timeout"))) + whenever(pubkyRepo.fetchRemoteProfile(any())).thenReturn(Result.failure(CreateProfileTestAppError("timeout"))) sut = CreateProfileViewModel(context = context, pubkyRepo = pubkyRepo) sut.onNameChange("Alice") @@ -84,7 +85,7 @@ class CreateProfileViewModelTest : BaseUnitTest() { @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(Exception("timeout"))) + whenever(pubkyRepo.fetchRemoteProfile(any())).thenReturn(Result.failure(CreateProfileTestAppError("timeout"))) sut = CreateProfileViewModel(context = context, pubkyRepo = pubkyRepo) sut.onNameChange("Alice") @@ -97,7 +98,8 @@ class CreateProfileViewModelTest : BaseUnitTest() { @Test fun `save should still create a new pubky when the lookup fails before sign-up`() = test { - whenever { pubkyRepo.fetchRemoteProfile(any()) }.thenReturn(Result.failure(Exception("no homeserver"))) + 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) @@ -109,3 +111,5 @@ class CreateProfileViewModelTest : BaseUnitTest() { 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 3cf30ecf44..2f8f9de6ce 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 @@ -16,6 +16,7 @@ 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 @@ -73,7 +74,7 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { @Test fun `listing failure shows no identities`() = test { - whenever(pubkyRepo.ringIdentities()).thenReturn(Result.failure(RuntimeException("query failed"))) + whenever(pubkyRepo.ringIdentities()).thenReturn(Result.failure(PubkyChoiceTestAppError("query failed"))) createSut() advanceUntilIdle() @@ -133,7 +134,8 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { @Test fun `onIdentityClick clears the adopting identity and toasts when adoption fails`() = test { - whenever(pubkyRepo.adoptRingIdentity(RING_PUBKY)).thenReturn(Result.failure(RuntimeException("adopt failed"))) + whenever(pubkyRepo.adoptRingIdentity(RING_PUBKY)) + .thenReturn(Result.failure(PubkyChoiceTestAppError("adopt failed"))) createSut() val toasts = mutableListOf() val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } @@ -168,3 +170,5 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { assertFalse(sut.uiState.value.navigateToProfile) } } + +private class PubkyChoiceTestAppError(message: String) : AppError(message) From aa6db63849529b1a88fad7abfce69fa7cb06279a Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 24 Sep 2026 12:09:09 +0200 Subject: [PATCH 20/24] fix: toast failed ring contact import --- .../java/to/bitkit/repositories/PubkyRepo.kt | 2 +- .../screens/profile/PubkyChoiceViewModel.kt | 11 +++++++- .../profile/PubkyChoiceViewModelTest.kt | 28 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index ffcd4fe699..0f96387073 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -856,7 +856,7 @@ class PubkyRepo @Inject constructor( _pendingImportProfile.update { ownProfile } _pendingImportContacts.update { contacts } } - }.onFailure { Logger.warn("Failed to prepare contact import", it, context = TAG) } + } suspend fun clearPendingImport() = withContext(ioDispatcher) { _pendingImportProfile.update { null } 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 0b0b6ce9cb..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 @@ -53,7 +53,16 @@ class PubkyChoiceViewModel @Inject constructor( _uiState.update { it.copy(adoptingPubky = pubky) } pubkyRepo.adoptRingIdentity(pubky) .onSuccess { hasProfile -> - if (hasProfile) pubkyRepo.prepareImport() + 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 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 2f8f9de6ce..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 @@ -38,6 +38,7 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { @Before fun setUp() { whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization Failed") + whenever(context.getString(R.string.common__error)).thenReturn("Error") whenever(pubkyRepo.isAuthenticated).thenReturn(isAuthenticated) whenever(pubkyRepo.pendingImportContacts).thenReturn(pendingImportContacts) whenever { pubkyRepo.ringIdentities() }.thenReturn(Result.success(persistentListOf())) @@ -105,7 +106,9 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { whenever(pubkyRepo.adoptRingIdentity(RING_PUBKY)).thenReturn(Result.success(true)) 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.onIdentityClick(RING_PUBKY) advanceUntilIdle() @@ -113,7 +116,32 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { assertEquals(PubkyChoiceEffect.NavigateToPayContacts, effects.single()) assertFalse(sut.uiState.value.navigateToProfile) assertNull(sut.uiState.value.adoptingPubky) + assertTrue(toasts.isEmpty()) + effectsJob.cancel() + toastJob.cancel() + } + + @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() + 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.onIdentityClick(RING_PUBKY) + advanceUntilIdle() + + 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 From 0dc4e7701f35465623a55f81e678b074bf580b10 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 24 Sep 2026 14:38:35 +0200 Subject: [PATCH 21/24] fix: clear stale ring reference on signup --- .../java/to/bitkit/repositories/PubkyRepo.kt | 1 + .../to/bitkit/repositories/PubkyRepoTest.kt | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 0f96387073..e1c8fb257d 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -912,6 +912,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, diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index c4eecb06bf..4ad07809da 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -216,6 +216,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() From 420fef976545fe529e6509a53c6df0e3f68be214 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 24 Sep 2026 14:40:34 +0200 Subject: [PATCH 22/24] fix: re-sign in adopted pubky with ring key --- .../java/to/bitkit/repositories/PubkyRepo.kt | 12 +++++- .../to/bitkit/repositories/PubkyRepoTest.kt | 37 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index e1c8fb257d..5fe0f97fef 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -239,7 +239,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) @@ -1253,6 +1253,16 @@ 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) diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 4ad07809da..82270cd6fe 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1245,6 +1245,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" From 94940c13cb6a40474acd08f22d590e962a2b5d2f Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 24 Sep 2026 14:42:48 +0200 Subject: [PATCH 23/24] fix: ask for ring when pubky key is unreadable --- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 11 ++++++++++- app/src/main/res/values/strings.xml | 2 ++ .../to/bitkit/viewmodels/AppViewModelSendFlowTest.kt | 11 ++++++----- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f88fdd625b..3e74144426 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5639,7 +5639,7 @@ class AppViewModel @Inject constructor( val isSignup = PubkyAuthRequest.isSignupUrl(authUrl) if (isSignup && rejectPubkySignupForExistingIdentity()) return - if (!isSignup && (pubkyRepo.publicKey.value == null || !pubkyRepo.hasSecretKey())) { + if (!isSignup && pubkyRepo.publicKey.value == null) { ToastEventBus.send( type = Toast.ToastType.WARNING, title = context.getString(R.string.pubky_auth__no_identity), @@ -5647,6 +5647,15 @@ class AppViewModel @Inject constructor( ) return } + + if (!isSignup && !pubkyRepo.hasSecretKey()) { + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.pubky_auth__use_ring), + description = context.getString(R.string.pubky_auth__use_ring_desc), + ) + return + } showSheet(Sheet.PubkyAuth(authUrl)) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fce0c5d949..b064a6cfc5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -713,6 +713,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/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 00ec0b5493..97cb4b4cf4 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -3180,12 +3180,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `pubky auth deeplink shows identity required toast without a usable secret key`() = 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.pubky_auth__no_identity)).thenReturn("Pubky Identity Required") - whenever(context.getString(R.string.pubky_auth__no_identity_desc)).thenReturn("Create a Pubky identity") + 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" @@ -3193,10 +3193,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() assertNull(sut.currentSheet.value) + verify(context, never()).getString(R.string.pubky_auth__no_identity) verify(toastManager).enqueue( check { - assertEquals("Pubky Identity Required", it.title) - assertEquals("Create a Pubky identity", it.description) + assertEquals("Use Pubky Ring", it.title) + assertEquals("Open Pubky Ring", it.description) } ) } From 3ff19f553c383c6cce58087d7ba9a45cc6cc4e64 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Sun, 27 Sep 2026 20:53:19 +0200 Subject: [PATCH 24/24] fix: keep last pubky above the nav bar --- .../main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt | 2 ++ 1 file changed, 2 insertions(+) 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 e165aee5d2..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 @@ -10,6 +10,7 @@ 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 @@ -142,6 +143,7 @@ private fun Content( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) + .navigationBarsPadding() .padding(horizontal = 32.dp) ) { VerticalSpacer(24.dp)