diff --git a/app/src/androidTest/java/to/bitkit/ui/components/TagButtonTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/TagButtonTest.kt
new file mode 100644
index 0000000000..a7212a8d4b
--- /dev/null
+++ b/app/src/androidTest/java/to/bitkit/ui/components/TagButtonTest.kt
@@ -0,0 +1,47 @@
+package to.bitkit.ui.components
+
+import androidx.compose.ui.test.assertHasClickAction
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithTag
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import to.bitkit.test.annotations.ComposeUi
+import to.bitkit.ui.theme.AppThemeSurface
+
+@HiltAndroidTest
+@ComposeUi
+class TagButtonTest {
+ @get:Rule
+ val hiltRule = HiltAndroidRule(this)
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Before
+ fun setup() {
+ hiltRule.inject()
+ }
+
+ @Test
+ fun removableTagExposesItsAction() {
+ composeTestRule.setContent {
+ AppThemeSurface {
+ TagButton(
+ text = "Founder",
+ onClick = {},
+ accessibilityLabel = "Remove Founder tag",
+ displayIconClose = true,
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithContentDescription("Remove Founder tag")
+ .assertHasClickAction()
+ composeTestRule.onNodeWithTag("Tag-Founder")
+ .assertHasClickAction()
+ }
+}
diff --git a/app/src/androidTest/java/to/bitkit/ui/components/settings/SettingsSwitchRowTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/settings/SettingsSwitchRowTest.kt
new file mode 100644
index 0000000000..5acb595cec
--- /dev/null
+++ b/app/src/androidTest/java/to/bitkit/ui/components/settings/SettingsSwitchRowTest.kt
@@ -0,0 +1,90 @@
+package to.bitkit.ui.components.settings
+
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.assertHasClickAction
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.assertIsOff
+import androidx.compose.ui.test.assertIsOn
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import to.bitkit.test.annotations.ComposeUi
+import to.bitkit.ui.theme.AppThemeSurface
+
+@HiltAndroidTest
+@ComposeUi
+class SettingsSwitchRowTest {
+ @get:Rule
+ val hiltRule = HiltAndroidRule(this)
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Before
+ fun setup() {
+ hiltRule.inject()
+ }
+
+ @Test
+ fun switchSemanticsReflectCheckedState() {
+ composeTestRule.setContent {
+ AppThemeSurface {
+ SettingsSwitchRow(
+ title = "Contact payments",
+ isChecked = true,
+ onClick = {},
+ switchTestTag = "ContactPaymentsSwitch",
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithTag("ContactPaymentsSwitch")
+ .assertIsOn()
+ .assertIsEnabled()
+ .assertHasClickAction()
+ .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Switch))
+ }
+
+ @Test
+ fun switchSemanticsReflectUncheckedState() {
+ composeTestRule.setContent {
+ AppThemeSurface {
+ SettingsSwitchRow(
+ title = "Contact payments",
+ isChecked = false,
+ onClick = {},
+ switchTestTag = "ContactPaymentsSwitch",
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithTag("ContactPaymentsSwitch").assertIsOff()
+ }
+
+ @Test
+ fun switchSemanticsReflectDisabledState() {
+ composeTestRule.setContent {
+ AppThemeSurface {
+ SettingsSwitchRow(
+ title = "Contact payments",
+ isChecked = false,
+ onClick = {},
+ enabled = false,
+ switchTestTag = "ContactPaymentsSwitch",
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithTag("ContactPaymentsSwitch")
+ .assertIsOff()
+ .assertIsNotEnabled()
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 466b64fdb9..4eae59f2cf 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,7 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
-
+
@@ -13,6 +13,12 @@
+
+
+
+
@@ -188,6 +194,13 @@
android:exported="false"
tools:node="remove" />
+
+
diff --git a/app/src/main/java/to/bitkit/data/PubkyStore.kt b/app/src/main/java/to/bitkit/data/PubkyStore.kt
index 3dcc132236..7eb5c9053b 100644
--- a/app/src/main/java/to/bitkit/data/PubkyStore.kt
+++ b/app/src/main/java/to/bitkit/data/PubkyStore.kt
@@ -7,6 +7,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.Serializable
import to.bitkit.data.serializers.PubkyStoreSerializer
+import to.bitkit.data.sharing.ExternalPubkyIdentityRef
import to.bitkit.models.PubkyProfileData
import javax.inject.Inject
import javax.inject.Singleton
@@ -38,4 +39,5 @@ data class PubkyStoreData(
val cachedName: String? = null,
val cachedImageUri: String? = null,
val contactProfileOverrides: Map = emptyMap(),
+ val externalIdentityRef: ExternalPubkyIdentityRef? = null,
)
diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt
index ab21f8f496..04889377a7 100644
--- a/app/src/main/java/to/bitkit/data/SettingsStore.kt
+++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt
@@ -47,7 +47,7 @@ class SettingsStore @Inject constructor(
suspend fun restoreFromBackup(payload: SettingsBackupV1) =
runCatching {
- val data = payload.settings.resetPin()
+ val data = payload.settings.resetPin().withDefaultPaykitPaymentMethods()
store.updateData { data }
val monitored = data.addressTypesToMonitor
@@ -164,6 +164,14 @@ fun SettingsData.resetPin() = this.copy(
isBiometricEnabled = false,
)
+fun SettingsData.areContactPaymentsEnabled(): Boolean =
+ sharesPublicPaykitEndpoints || sharesPrivatePaykitEndpoints
+
+fun SettingsData.withDefaultPaykitPaymentMethods() = copy(
+ publicPaykitLightningEnabled = true,
+ publicPaykitOnchainEnabled = true,
+)
+
fun SettingsData.hasPublicPaykitPublicationState(): Boolean =
hasConfirmedPublicPaykitEndpoints ||
sharesPublicPaykitEndpoints ||
diff --git a/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt b/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt
new file mode 100644
index 0000000000..4a3947526b
--- /dev/null
+++ b/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt
@@ -0,0 +1,408 @@
+package to.bitkit.data
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.dataStore
+import com.synonym.bitkitcore.serializedExtendedPubkey
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.Serializable
+import to.bitkit.data.serializers.WatchOnlyAccountDataSerializer
+import to.bitkit.di.IoDispatcher
+import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE
+import to.bitkit.models.WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH
+import to.bitkit.models.WatchOnlyAccountRecord
+import to.bitkit.models.WatchOnlyAccountSetupState
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private val Context.watchOnlyAccountDataStore: DataStore by dataStore(
+ fileName = "watch_only_accounts.json",
+ serializer = WatchOnlyAccountDataSerializer,
+)
+
+@Singleton
+class WatchOnlyAccountXpubSerializer @Inject constructor() {
+ fun serialize(xpub: String): ByteArray = serializedExtendedPubkey(xpub)
+}
+
+@Singleton
+class WatchOnlyAccountStore @Inject constructor(
+ @ApplicationContext context: Context,
+ @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
+ private val xpubSerializer: WatchOnlyAccountXpubSerializer,
+) {
+ private val store = context.watchOnlyAccountDataStore
+
+ val data: Flow = store.data
+
+ suspend fun load(): List = withContext(ioDispatcher) {
+ store.data.first().accounts
+ }
+
+ suspend fun loadReconciliationState(): WatchOnlyAccountReconciliationState = withContext(ioDispatcher) {
+ store.data.first().let { data ->
+ WatchOnlyAccountReconciliationState(
+ accounts = data.accounts,
+ accountsPendingRemoval = data.accountsPendingRemoval,
+ )
+ }
+ }
+
+ suspend fun backupSnapshot(): WatchOnlyAccountBackupSnapshot = withContext(ioDispatcher) {
+ store.data.first().let { data ->
+ WatchOnlyAccountBackupSnapshot(
+ accounts = data.accounts,
+ allocationState = WatchOnlyAccountAllocationState(
+ highestAccountIndexByWallet = data.highestAccountIndexByWallet,
+ pendingAccountIndexByRequest = data.pendingAccountIndexByRequest,
+ ),
+ )
+ }
+ }
+
+ suspend fun save(accounts: List) = withContext(ioDispatcher) {
+ store.updateData { current ->
+ current.copy(
+ accounts = accounts.sortedBy(WatchOnlyAccountRecord::accountIndex),
+ highestAccountIndexByWallet = current.highestAccountIndexByWallet.withAccountIndexes(accounts),
+ )
+ }
+ Unit
+ }
+
+ suspend fun restore(
+ accounts: List,
+ allocationState: WatchOnlyAccountAllocationState? = null,
+ ) = withContext(ioDispatcher) {
+ store.updateData { current ->
+ current.restoreAccounts(accounts, allocationState, xpubSerializer::serialize)
+ }
+ Unit
+ }
+
+ suspend fun clear() = withContext(ioDispatcher) {
+ store.updateData { WatchOnlyAccountData() }
+ Unit
+ }
+
+ suspend fun reserveAccountIndex(walletIndex: Int, requestFingerprint: String): Int = withContext(ioDispatcher) {
+ var reservedIndex: Int? = null
+ store.updateData { current ->
+ current.reserveAccountIndex(walletIndex, requestFingerprint).also {
+ reservedIndex = it.accountIndex
+ }.data
+ }
+ checkNotNull(reservedIndex)
+ }
+
+ suspend fun markActive(id: String) = withContext(ioDispatcher) {
+ store.updateData { current -> current.markAccountActive(id) }
+ Unit
+ }
+
+ suspend fun completeReconciliation(walletIndex: Int) = withContext(ioDispatcher) {
+ store.updateData { current -> current.completeReconciliation(walletIndex) }
+ Unit
+ }
+
+ suspend fun update(transform: (List) -> List) =
+ withContext(ioDispatcher) {
+ store.updateData { current ->
+ current.copy(accounts = transform(current.accounts).sortedBy(WatchOnlyAccountRecord::accountIndex))
+ }
+ Unit
+ }
+}
+
+@Serializable
+data class WatchOnlyAccountData(
+ val accounts: List = emptyList(),
+ val accountsPendingRemoval: List = emptyList(),
+ val highestAccountIndexByWallet: Map = emptyMap(),
+ val pendingAccountIndexByRequest: Map = emptyMap(),
+)
+
+data class WatchOnlyAccountReconciliationState(
+ val accounts: List,
+ val accountsPendingRemoval: List,
+)
+
+@Serializable
+data class WatchOnlyAccountAllocationState(
+ val highestAccountIndexByWallet: Map = emptyMap(),
+ val pendingAccountIndexByRequest: Map = emptyMap(),
+)
+
+data class WatchOnlyAccountBackupSnapshot(
+ val accounts: List,
+ val allocationState: WatchOnlyAccountAllocationState,
+)
+
+internal data class WatchOnlyAccountIndexReservation(
+ val data: WatchOnlyAccountData,
+ val accountIndex: Int,
+)
+
+internal fun WatchOnlyAccountData.reserveAccountIndex(
+ walletIndex: Int,
+ requestFingerprint: String,
+): WatchOnlyAccountIndexReservation {
+ val requestKey = "$walletIndex:$requestFingerprint"
+ pendingAccountIndexByRequest[requestKey]?.let {
+ return WatchOnlyAccountIndexReservation(data = this, accountIndex = it)
+ }
+
+ val walletKey = walletIndex.toString()
+ val highestPersistedIndex = accounts
+ .filter { it.walletIndex == walletIndex }
+ .maxOfOrNull(WatchOnlyAccountRecord::accountIndex) ?: 0
+ val highestAccountIndex = maxOf(highestAccountIndexByWallet[walletKey] ?: 0, highestPersistedIndex)
+ check(highestAccountIndex < Int.MAX_VALUE) { "Watch-only account index overflow" }
+
+ val reservedIndex = highestAccountIndex + 1
+ return WatchOnlyAccountIndexReservation(
+ data = copy(
+ highestAccountIndexByWallet = highestAccountIndexByWallet + (walletKey to reservedIndex),
+ pendingAccountIndexByRequest = pendingAccountIndexByRequest + (requestKey to reservedIndex),
+ ),
+ accountIndex = reservedIndex,
+ )
+}
+
+internal fun WatchOnlyAccountData.markAccountActive(id: String): WatchOnlyAccountData {
+ val account = checkNotNull(accounts.firstOrNull { it.id == id }) {
+ "Watch-only account '$id' not found"
+ }
+ val requestKey = account.allocationRequestKey()
+ val updatedAccounts = accounts.map {
+ if (it.id == id) {
+ it.copy(isTrackingEnabled = true, setupState = WatchOnlyAccountSetupState.Active)
+ } else {
+ it
+ }
+ }
+ return copy(
+ accounts = updatedAccounts,
+ pendingAccountIndexByRequest = pendingAccountIndexByRequest - requestKey,
+ )
+}
+
+internal fun WatchOnlyAccountData.restoreAccounts(
+ accounts: List,
+ allocationState: WatchOnlyAccountAllocationState? = null,
+ serializeXpub: (String) -> ByteArray,
+): WatchOnlyAccountData {
+ val restoredAccounts = accounts.sanitizedAccounts(serializeXpub)
+ val locallyManagedAccounts = uniqueAccountsByManagementKey(this.accounts + accountsPendingRemoval)
+ val protectedLocalAccounts = locallyManagedAccounts
+ .filter { localAccount ->
+ localAccount.setupState == WatchOnlyAccountSetupState.Authorizing ||
+ localAccount.shouldPreserveFrom(restoredAccounts)
+ }
+ .sanitizedAccounts(serializeXpub)
+ val mergedAccounts = (protectedLocalAccounts + restoredAccounts).sanitizedAccounts(serializeXpub)
+ val mergedManagementKeys = mergedAccounts.mapTo(mutableSetOf(), WatchOnlyAccountRecord::managementKey)
+ val updatedAccountsPendingRemoval = uniqueAccountsByManagementKey(this.accounts + accountsPendingRemoval)
+ .filterNot { it.managementKey() in mergedManagementKeys }
+
+ val validLocalPendingAccountIndexes = pendingAccountIndexByRequest.validPendingAccountIndexes()
+ val localHighestIndexes = highestAccountIndexByWallet
+ .validHighestAccountIndexes()
+ .withAccountIndexes(locallyManagedAccounts)
+ .withPendingAccountIndexes(validLocalPendingAccountIndexes)
+ val highestIndexes = allocationState?.highestAccountIndexByWallet
+ .orEmpty()
+ .validHighestAccountIndexes()
+ .entries
+ .fold(localHighestIndexes) { indexes, (wallet, index) ->
+ indexes + (wallet to maxOf(indexes[wallet] ?: 0, index))
+ }
+ .withAccountIndexes(locallyManagedAccounts + accounts + updatedAccountsPendingRemoval)
+
+ val retainedLocalPendingAccountIndexes = if (allocationState == null) {
+ emptyMap()
+ } else {
+ validLocalPendingAccountIndexes
+ }
+ val mergedPendingAccountIndexes = mergedPendingAccountIndexes(
+ accounts = mergedAccounts,
+ blockedAccounts = updatedAccountsPendingRemoval,
+ localPendingAccountIndexes = retainedLocalPendingAccountIndexes,
+ restoredPendingAccountIndexes = allocationState?.pendingAccountIndexByRequest.orEmpty(),
+ localHighestAccountIndexByWallet = localHighestIndexes,
+ )
+
+ return copy(
+ accounts = mergedAccounts,
+ accountsPendingRemoval = updatedAccountsPendingRemoval,
+ highestAccountIndexByWallet = highestIndexes.withPendingAccountIndexes(mergedPendingAccountIndexes),
+ pendingAccountIndexByRequest = mergedPendingAccountIndexes,
+ )
+}
+
+private fun WatchOnlyAccountRecord.managementKey(): String = "$walletIndex:$addressType:$accountIndex"
+
+private fun WatchOnlyAccountRecord.allocationRequestKey(): String = "$walletIndex:$requestFingerprint"
+
+private fun WatchOnlyAccountRecord.normalizedTrackingState(): WatchOnlyAccountRecord = when (setupState) {
+ WatchOnlyAccountSetupState.PendingDelivery -> copy(isTrackingEnabled = false)
+ WatchOnlyAccountSetupState.Authorizing -> copy(isTrackingEnabled = true)
+ WatchOnlyAccountSetupState.Active -> this
+}
+
+private fun WatchOnlyAccountRecord.isUsableAccount(
+ serializeXpub: (String) -> ByteArray,
+): Boolean = walletIndex >= 0 &&
+ accountIndex > 0 &&
+ addressType == WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE &&
+ runCatching { serializeXpub(xpub).size == WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH }
+ .getOrDefault(false)
+
+private fun List.sanitizedAccounts(
+ serializeXpub: (String) -> ByteArray,
+): List {
+ val ids = mutableSetOf()
+ val managementKeys = mutableSetOf()
+ val incompleteRequestKeys = mutableSetOf()
+
+ return mapNotNull { input ->
+ if (!input.isUsableAccount(serializeXpub)) return@mapNotNull null
+ val account = input.normalizedTrackingState()
+ val incompleteRequestKey = account
+ .takeIf { it.setupState != WatchOnlyAccountSetupState.Active }
+ ?.allocationRequestKey()
+ if (account.id in ids || account.managementKey() in managementKeys) return@mapNotNull null
+ if (incompleteRequestKey != null && incompleteRequestKey in incompleteRequestKeys) return@mapNotNull null
+ ids += account.id
+ managementKeys += account.managementKey()
+ incompleteRequestKey?.let(incompleteRequestKeys::add)
+ account
+ }.sortedWith(
+ compareBy(
+ WatchOnlyAccountRecord::walletIndex,
+ WatchOnlyAccountRecord::accountIndex,
+ WatchOnlyAccountRecord::createdAt,
+ ),
+ )
+}
+
+private fun uniqueAccountsByManagementKey(accounts: List): List =
+ accounts.distinctBy(WatchOnlyAccountRecord::managementKey)
+ .sortedWith(compareBy(WatchOnlyAccountRecord::walletIndex, WatchOnlyAccountRecord::accountIndex))
+
+private fun WatchOnlyAccountRecord.shouldPreserveFrom(
+ restoredAccounts: List,
+): Boolean {
+ val conflicts = restoredAccounts.filter {
+ it.id == id || it.managementKey() == managementKey()
+ }
+ if (conflicts.isEmpty()) return false
+ if (conflicts.any { !hasSameOwner(it) }) return true
+ return setupState == WatchOnlyAccountSetupState.Active &&
+ conflicts.all { it.setupState != WatchOnlyAccountSetupState.Active }
+}
+
+private fun WatchOnlyAccountRecord.hasSameOwner(other: WatchOnlyAccountRecord): Boolean =
+ managementKey() == other.managementKey() &&
+ requestFingerprint == other.requestFingerprint &&
+ xpub == other.xpub
+
+private data class AccountIndexKey(
+ val walletIndex: Int,
+ val accountIndex: Int,
+)
+
+private fun mergedPendingAccountIndexes(
+ accounts: List,
+ blockedAccounts: List,
+ localPendingAccountIndexes: Map,
+ restoredPendingAccountIndexes: Map,
+ localHighestAccountIndexByWallet: Map,
+): Map {
+ val activeSlots = accounts
+ .filter { it.setupState == WatchOnlyAccountSetupState.Active }
+ .mapTo(mutableSetOf()) { AccountIndexKey(it.walletIndex, it.accountIndex) }
+ val blockedSlots = blockedAccounts
+ .mapTo(mutableSetOf()) { AccountIndexKey(it.walletIndex, it.accountIndex) }
+ val pendingAccountIndexes = linkedMapOf()
+ val reservedSlots = mutableSetOf()
+
+ fun reserve(requestKey: String, accountIndex: Int, allowsHistoricalIndex: Boolean) {
+ val walletIndex = requestKey.allocationWalletIndex() ?: return
+ val slot = AccountIndexKey(walletIndex, accountIndex)
+ if (requestKey in pendingAccountIndexes || accountIndex <= 0) return
+ if (slot in reservedSlots || slot in activeSlots || slot in blockedSlots) return
+ if (
+ !allowsHistoricalIndex &&
+ accountIndex <= (localHighestAccountIndexByWallet[walletIndex.toString()] ?: 0)
+ ) {
+ return
+ }
+ pendingAccountIndexes[requestKey] = accountIndex
+ reservedSlots += slot
+ }
+
+ accounts.filter { it.setupState != WatchOnlyAccountSetupState.Active }.forEach {
+ reserve(it.allocationRequestKey(), it.accountIndex, allowsHistoricalIndex = true)
+ }
+ localPendingAccountIndexes.toSortedMap().forEach { (requestKey, accountIndex) ->
+ reserve(requestKey, accountIndex, allowsHistoricalIndex = true)
+ }
+ restoredPendingAccountIndexes.toSortedMap().forEach { (requestKey, accountIndex) ->
+ reserve(requestKey, accountIndex, allowsHistoricalIndex = false)
+ }
+
+ return pendingAccountIndexes
+}
+
+private fun Map.validHighestAccountIndexes(): Map =
+ entries.fold(emptyMap()) { indexes, (wallet, index) ->
+ val walletIndex = wallet.toIntOrNull()?.takeIf { it >= 0 }
+ ?: return@fold indexes
+ if (index <= 0) return@fold indexes
+ val walletKey = walletIndex.toString()
+ indexes + (walletKey to maxOf(indexes[walletKey] ?: 0, index))
+ }
+
+private fun Map.validPendingAccountIndexes(): Map =
+ filter { (requestKey, accountIndex) ->
+ requestKey.allocationWalletIndex() != null && accountIndex > 0
+ }
+
+private fun String.allocationWalletIndex(): Int? {
+ val separatorIndex = indexOf(':')
+ if (separatorIndex <= 0 || separatorIndex == lastIndex) return null
+ return substring(0, separatorIndex).toIntOrNull()?.takeIf { it >= 0 }
+}
+
+private fun Map.withPendingAccountIndexes(
+ pendingAccountIndexes: Map,
+): Map {
+ val updated = toMutableMap()
+ pendingAccountIndexes.forEach { (requestKey, accountIndex) ->
+ val walletIndex = requestKey.allocationWalletIndex() ?: return@forEach
+ if (accountIndex <= 0) return@forEach
+ val walletKey = walletIndex.toString()
+ updated[walletKey] = maxOf(updated[walletKey] ?: 0, accountIndex)
+ }
+ return updated
+}
+
+internal fun WatchOnlyAccountData.completeReconciliation(walletIndex: Int): WatchOnlyAccountData = copy(
+ accountsPendingRemoval = accountsPendingRemoval.filterNot { it.walletIndex == walletIndex },
+)
+
+private fun Map.withAccountIndexes(accounts: List): Map {
+ val updated = toMutableMap()
+ accounts.filter { it.walletIndex >= 0 && it.accountIndex > 0 }
+ .groupBy(WatchOnlyAccountRecord::walletIndex).forEach { (walletIndex, walletAccounts) ->
+ val highestAccountIndex = walletAccounts.maxOf(WatchOnlyAccountRecord::accountIndex)
+ val walletKey = walletIndex.toString()
+ updated[walletKey] = maxOf(updated[walletKey] ?: 0, highestAccountIndex)
+ }
+ return updated
+}
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 7eb9431a75..21730cc512 100644
--- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt
+++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt
@@ -232,7 +232,10 @@ class Keychain @Inject constructor(
PIN,
PIN_ATTEMPTS_REMAINING,
PAYKIT_SESSION,
+ PAYKIT_RECEIVER_NOISE_SECRET_KEY,
PAYKIT_SDK_STATE,
+ PUBKY_MANAGED_SECRET_QUARANTINED,
+ PUBKY_SHARED_EXPORT_ENABLED,
PUBKY_SECRET_KEY,
}
}
diff --git a/app/src/main/java/to/bitkit/data/serializers/SettingsSerializer.kt b/app/src/main/java/to/bitkit/data/serializers/SettingsSerializer.kt
index 3c112311a1..4c4ca67c78 100644
--- a/app/src/main/java/to/bitkit/data/serializers/SettingsSerializer.kt
+++ b/app/src/main/java/to/bitkit/data/serializers/SettingsSerializer.kt
@@ -3,6 +3,7 @@ package to.bitkit.data.serializers
import androidx.datastore.core.Serializer
import kotlinx.serialization.SerializationException
import to.bitkit.data.SettingsData
+import to.bitkit.data.withDefaultPaykitPaymentMethods
import to.bitkit.di.json
import to.bitkit.utils.Logger
import java.io.InputStream
@@ -13,7 +14,8 @@ object SettingsSerializer : Serializer {
override suspend fun readFrom(input: InputStream): SettingsData {
return try {
- json.decodeFromString(input.readBytes().decodeToString())
+ json.decodeFromString(input.readBytes().decodeToString())
+ .withDefaultPaykitPaymentMethods()
} catch (e: SerializationException) {
Logger.error("Failed to deserialize: $e")
defaultValue
diff --git a/app/src/main/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializer.kt b/app/src/main/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializer.kt
new file mode 100644
index 0000000000..997f90828d
--- /dev/null
+++ b/app/src/main/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializer.kt
@@ -0,0 +1,23 @@
+package to.bitkit.data.serializers
+
+import androidx.datastore.core.CorruptionException
+import androidx.datastore.core.Serializer
+import kotlinx.serialization.SerializationException
+import to.bitkit.data.WatchOnlyAccountData
+import to.bitkit.di.json
+import java.io.InputStream
+import java.io.OutputStream
+
+object WatchOnlyAccountDataSerializer : Serializer {
+ override val defaultValue = WatchOnlyAccountData()
+
+ override suspend fun readFrom(input: InputStream): WatchOnlyAccountData = try {
+ json.decodeFromString(input.readBytes().decodeToString())
+ } catch (error: SerializationException) {
+ throw CorruptionException("Failed to deserialize watch-only account data", error)
+ }
+
+ override suspend fun writeTo(t: WatchOnlyAccountData, output: OutputStream) {
+ output.write(json.encodeToString(t).encodeToByteArray())
+ }
+}
diff --git a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt
new file mode 100644
index 0000000000..a5f49a13b2
--- /dev/null
+++ b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt
@@ -0,0 +1,107 @@
+package to.bitkit.data.sharing
+
+import android.net.Uri
+import kotlinx.serialization.Serializable
+import to.bitkit.utils.AppError
+import java.util.Locale
+
+object SharedPubkyContract {
+ const val PROTOCOL_VERSION = 1
+ const val BITKIT_SOURCE = "to.bitkit"
+ const val RING_SOURCE = "app.pubkyring"
+ const val RING_AUTHORITY = "app.pubkyring.sharedpubky"
+ const val RING_READ_PERMISSION = "app.pubkyring.permission.READ_SHARED_PUBKY"
+ const val IDENTITIES_PATH = "v1/identities"
+ const val RING_IDENTITIES_URI = "content://$RING_AUTHORITY/$IDENTITIES_PATH"
+
+ const val COLUMN_PROTOCOL_VERSION = "protocol_version"
+ const val COLUMN_SOURCE_PACKAGE = "source_package"
+ const val COLUMN_PUBKY = "pubky"
+ const val COLUMN_SECRET_KEY = "secret_key"
+
+ private const val BITKIT_PUBKY_PREFIX = "pubky"
+ private const val WIRE_PUBKY_LENGTH = 52
+ private const val CREDENTIAL_SEGMENT = "credential"
+ private val wirePubkyPattern = Regex("^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$")
+ private val secretKeyPattern = Regex("^[0-9a-f]{64}$")
+
+ val publicColumns = arrayOf(
+ COLUMN_PROTOCOL_VERSION,
+ COLUMN_SOURCE_PACKAGE,
+ COLUMN_PUBKY,
+ )
+ val credentialColumns = publicColumns + COLUMN_SECRET_KEY
+
+ val ringIdentitiesUri: Uri
+ get() = Uri.parse(RING_IDENTITIES_URI)
+
+ fun ringCredentialUri(pubky: String): Uri = Uri.parse(ringCredentialUriString(pubky))
+
+ internal fun ringCredentialUriString(pubky: String): String =
+ "$RING_IDENTITIES_URI/${canonicalPubky(pubky)}/$CREDENTIAL_SEGMENT"
+
+ fun canonicalPubky(value: String): String {
+ val normalizedPubky = value.trim().lowercase(Locale.US)
+ val barePubky = if (
+ normalizedPubky.length == WIRE_PUBKY_LENGTH + BITKIT_PUBKY_PREFIX.length &&
+ normalizedPubky.startsWith(BITKIT_PUBKY_PREFIX)
+ ) {
+ normalizedPubky.removePrefix(BITKIT_PUBKY_PREFIX)
+ } else {
+ normalizedPubky
+ }
+ require(wirePubkyPattern.matches(barePubky)) { "Invalid shared Pubky public key" }
+ return barePubky
+ }
+
+ fun requireWirePubky(value: String): String {
+ require(wirePubkyPattern.matches(value)) { "Invalid shared Pubky wire public key" }
+ return value
+ }
+
+ fun toBitkitPubky(value: String): String = "$BITKIT_PUBKY_PREFIX${canonicalPubky(value)}"
+
+ fun canonicalSecretKeyHex(value: String): String {
+ require(secretKeyPattern.matches(value)) { "Invalid shared Pubky secret key" }
+ return value
+ }
+}
+
+data class SharedPubkyIdentity(
+ val protocolVersion: Int,
+ val sourcePackage: String,
+ val pubky: String,
+)
+
+class SharedPubkyCredential(
+ val identity: SharedPubkyIdentity,
+ secretKeyHex: String,
+) {
+ val secretKeyHex = SharedPubkyContract.canonicalSecretKeyHex(secretKeyHex)
+}
+
+@Serializable
+data class ExternalPubkyIdentityRef(
+ val protocolVersion: Int,
+ val sourcePackage: String,
+ val pubky: String,
+) {
+ fun validated(): ExternalPubkyIdentityRef {
+ if (protocolVersion != SharedPubkyContract.PROTOCOL_VERSION) {
+ throw SharedPubkyError.UnsupportedVersion(protocolVersion)
+ }
+ if (sourcePackage != SharedPubkyContract.RING_SOURCE) {
+ throw SharedPubkyError.UntrustedSource(sourcePackage)
+ }
+ return copy(pubky = SharedPubkyContract.requireWirePubky(pubky))
+ }
+}
+
+sealed class SharedPubkyError(message: String, cause: Throwable? = null) : AppError(message, cause) {
+ data object SourceUnavailable : SharedPubkyError("Pubky Ring identity sharing is unavailable")
+ class UntrustedSource(source: String) : SharedPubkyError("Untrusted Pubky identity source '$source'")
+ class UnsupportedVersion(version: Int) : SharedPubkyError("Unsupported Pubky sharing version '$version'")
+ data object InvalidResponse : SharedPubkyError("Pubky Ring returned an invalid shared identity")
+ data object IdentityUnavailable : SharedPubkyError("The selected Pubky Ring identity is unavailable")
+ data object IdentityConflict : SharedPubkyError("Another Pubky profile is already connected")
+}
diff --git a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt
new file mode 100644
index 0000000000..9dfd10bb10
--- /dev/null
+++ b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt
@@ -0,0 +1,137 @@
+package to.bitkit.data.sharing
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.database.Cursor
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+import to.bitkit.di.IoDispatcher
+import to.bitkit.ext.runSuspendCatching
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class SharedPubkyDiscovery @Inject constructor(
+ @ApplicationContext private val context: Context,
+ @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
+) {
+ suspend fun discoverRingIdentities(): Result> = runSuspendCatching {
+ withContext(ioDispatcher) {
+ verifyRingProvider()
+ context.contentResolver.query(
+ SharedPubkyContract.ringIdentitiesUri,
+ SharedPubkyContract.publicColumns,
+ null,
+ null,
+ null,
+ )?.use(::readPublicIdentities) ?: throw SharedPubkyError.SourceUnavailable
+ }
+ }
+
+ suspend fun readRingCredential(pubky: String): Result = runSuspendCatching {
+ withContext(ioDispatcher) {
+ verifyRingProvider()
+ val expectedPubky = SharedPubkyContract.canonicalPubky(pubky)
+ context.contentResolver.query(
+ SharedPubkyContract.ringCredentialUri(expectedPubky),
+ SharedPubkyContract.credentialColumns,
+ null,
+ null,
+ null,
+ )?.use { readCredential(it, expectedPubky) } ?: throw SharedPubkyError.IdentityUnavailable
+ }
+ }
+
+ @Suppress("ThrowsCount")
+ private fun verifyRingProvider() {
+ val packageManager = context.packageManager
+ val provider = packageManager.resolveContentProvider(
+ SharedPubkyContract.RING_AUTHORITY,
+ PackageManager.MATCH_ALL,
+ ) ?: throw SharedPubkyError.SourceUnavailable
+ if (provider.packageName != SharedPubkyContract.RING_SOURCE) {
+ throw SharedPubkyError.UntrustedSource(provider.packageName)
+ }
+ if (
+ provider.authority != SharedPubkyContract.RING_AUTHORITY ||
+ provider.readPermission != SharedPubkyContract.RING_READ_PERMISSION
+ ) {
+ throw SharedPubkyError.UntrustedSource(provider.packageName)
+ }
+ if (
+ packageManager.checkSignatures(context.packageName, provider.packageName) !=
+ PackageManager.SIGNATURE_MATCH
+ ) {
+ throw SharedPubkyError.UntrustedSource(provider.packageName)
+ }
+ }
+
+ private fun readPublicIdentities(cursor: Cursor): List {
+ val columns = cursor.requiredPublicColumns()
+ val identities = buildList {
+ while (cursor.moveToNext()) {
+ add(cursor.readIdentity(columns))
+ }
+ }
+ return identities.distinctBy { it.pubky }
+ }
+
+ @Suppress("ThrowsCount")
+ private fun readCredential(cursor: Cursor, expectedPubky: String): SharedPubkyCredential {
+ val publicColumns = cursor.requiredPublicColumns()
+ val secretKeyColumn = cursor.getColumnIndex(SharedPubkyContract.COLUMN_SECRET_KEY)
+ if (secretKeyColumn < 0 || !cursor.moveToFirst()) throw SharedPubkyError.IdentityUnavailable
+
+ val identity = cursor.readIdentity(publicColumns)
+ if (identity.pubky != expectedPubky || cursor.moveToNext()) {
+ throw SharedPubkyError.InvalidResponse
+ }
+ return runCatching {
+ SharedPubkyCredential(
+ identity = identity,
+ secretKeyHex = cursor.getString(secretKeyColumn).orEmpty(),
+ )
+ }.getOrElse {
+ throw SharedPubkyError.InvalidResponse
+ }
+ }
+
+ private fun Cursor.requiredPublicColumns() = PublicColumnIndexes(
+ protocolVersion = getColumnIndex(SharedPubkyContract.COLUMN_PROTOCOL_VERSION),
+ sourcePackage = getColumnIndex(SharedPubkyContract.COLUMN_SOURCE_PACKAGE),
+ pubky = getColumnIndex(SharedPubkyContract.COLUMN_PUBKY),
+ ).also {
+ if (it.protocolVersion < 0 || it.sourcePackage < 0 || it.pubky < 0) {
+ throw SharedPubkyError.InvalidResponse
+ }
+ }
+
+ @Suppress("ThrowsCount")
+ private fun Cursor.readIdentity(columns: PublicColumnIndexes): SharedPubkyIdentity {
+ val version = getInt(columns.protocolVersion)
+ if (version != SharedPubkyContract.PROTOCOL_VERSION) {
+ throw SharedPubkyError.UnsupportedVersion(version)
+ }
+ val sourcePackage = getString(columns.sourcePackage).orEmpty()
+ if (sourcePackage != SharedPubkyContract.RING_SOURCE) {
+ throw SharedPubkyError.UntrustedSource(sourcePackage)
+ }
+ val pubky = runCatching {
+ SharedPubkyContract.requireWirePubky(getString(columns.pubky).orEmpty())
+ }.getOrElse {
+ throw SharedPubkyError.InvalidResponse
+ }
+ return SharedPubkyIdentity(
+ protocolVersion = version,
+ sourcePackage = sourcePackage,
+ pubky = pubky,
+ )
+ }
+}
+
+private data class PublicColumnIndexes(
+ val protocolVersion: Int,
+ val sourcePackage: Int,
+ val pubky: Int,
+)
diff --git a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyProvider.kt b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyProvider.kt
new file mode 100644
index 0000000000..c7290a92e0
--- /dev/null
+++ b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyProvider.kt
@@ -0,0 +1,192 @@
+package to.bitkit.data.sharing
+
+import android.content.ContentProvider
+import android.content.ContentValues
+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.services.PaykitSdkService
+import to.bitkit.utils.Logger
+
+class SharedPubkyProvider : ContentProvider() {
+ private companion object {
+ const val TAG = "SharedPubkyProvider"
+ const val EXPORT_ENABLED = "1"
+ const val QUARANTINED = "1"
+ }
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface Dependencies {
+ fun keychain(): Keychain
+ }
+
+ private val keychain: Keychain by lazy {
+ val applicationContext = requireNotNull(context?.applicationContext) {
+ "SharedPubkyProvider context is unavailable"
+ }
+ EntryPointAccessors.fromApplication(applicationContext, Dependencies::class.java).keychain()
+ }
+
+ override fun onCreate(): Boolean = true
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?,
+ ): Cursor {
+ enforceCaller()
+ require(selection == null && selectionArgs == null && sortOrder == null) {
+ "Selection and sorting are unsupported"
+ }
+
+ val route = ProviderRoute.parse(uri, requireNotNull(context).packageName)
+ val expectedColumns = when (route) {
+ ProviderRoute.Identities -> SharedPubkyContract.publicColumns
+ is ProviderRoute.Credential -> SharedPubkyContract.credentialColumns
+ }
+ require(projection == null || projection.contentEquals(expectedColumns)) {
+ "Unsupported shared Pubky projection"
+ }
+
+ val cursor = MatrixCursor(expectedColumns)
+ val localIdentity = readLocalIdentity() ?: return cursor
+ when (route) {
+ ProviderRoute.Identities -> cursor.addRow(localIdentity.publicRow())
+ is ProviderRoute.Credential -> {
+ if (route.pubky == localIdentity.pubky) {
+ cursor.addRow(localIdentity.credentialRow())
+ }
+ }
+ }
+ return cursor
+ }
+
+ override fun getType(uri: Uri): String {
+ val packageName = requireNotNull(context).packageName
+ return when (ProviderRoute.parse(uri, packageName)) {
+ ProviderRoute.Identities -> "vnd.android.cursor.dir/vnd.$packageName.sharedpubky.identity"
+ is ProviderRoute.Credential -> "vnd.android.cursor.item/vnd.$packageName.sharedpubky.credential"
+ }
+ }
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri =
+ throw UnsupportedOperationException("Shared Pubky provider is read-only")
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int =
+ throw UnsupportedOperationException("Shared Pubky provider is read-only")
+
+ override fun update(
+ uri: Uri,
+ values: ContentValues?,
+ selection: String?,
+ selectionArgs: Array?,
+ ): Int = throw UnsupportedOperationException("Shared Pubky provider is read-only")
+
+ private fun enforceCaller() {
+ val providerContext = requireNotNull(context)
+ val caller = callingPackage
+ val isCallerPackage = caller == SharedPubkyContract.RING_SOURCE &&
+ caller in providerContext.packageManager.getPackagesForUid(Binder.getCallingUid()).orEmpty()
+ val isCallerSignedByBitkit = caller != null &&
+ providerContext.packageManager.checkSignatures(providerContext.packageName, caller) ==
+ PackageManager.SIGNATURE_MATCH
+ if (!isCallerPackage || !isCallerSignedByBitkit) {
+ throw SecurityException("Caller is not trusted for shared Pubky access")
+ }
+ }
+
+ private fun readLocalIdentity(): LocalIdentity? {
+ val isExportEnabled = runCatching {
+ keychain.loadString(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name)
+ }.onFailure {
+ Logger.warn("Failed to read shared Pubky export state", it, context = TAG)
+ }.getOrNull() == EXPORT_ENABLED
+ if (!isExportEnabled) return null
+
+ val isManagedSecretQuarantined = runCatching {
+ keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name)
+ }.onFailure {
+ Logger.warn("Failed to read managed Pubky secret quarantine", it, context = TAG)
+ }.getOrNull() == QUARANTINED
+
+ val secretKeyHex = runCatching {
+ keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
+ }.onFailure {
+ Logger.warn("Failed to read local shared Pubky identity", it, context = TAG)
+ }.getOrNull()?.takeIf { it.isNotBlank() } ?: return null
+
+ return runCatching {
+ localSharedPubkyIdentity(
+ exportEnabled = true,
+ managedSecretQuarantined = isManagedSecretQuarantined,
+ secretKeyHex = secretKeyHex,
+ publicKeyFromSecret = PaykitSdkService::publicKeyFromSecret,
+ )
+ }.onFailure {
+ Logger.warn("Failed to validate local shared Pubky identity", it, context = TAG)
+ }.getOrNull()
+ }
+
+ private sealed interface ProviderRoute {
+ data object Identities : ProviderRoute
+ data class Credential(val pubky: String) : ProviderRoute
+
+ companion object {
+ fun parse(uri: Uri, packageName: String): ProviderRoute {
+ require(uri.scheme == "content" && uri.authority == "$packageName.sharedpubky") {
+ "Unsupported shared Pubky URI"
+ }
+ val segments = uri.pathSegments
+ if (segments == listOf("v1", "identities")) return Identities
+ if (
+ segments.size == 4 &&
+ segments.take(2) == listOf("v1", "identities") &&
+ segments.last() == "credential"
+ ) {
+ return Credential(SharedPubkyContract.requireWirePubky(segments[2]))
+ }
+ throw IllegalArgumentException("Unsupported shared Pubky URI")
+ }
+ }
+ }
+}
+
+internal fun localSharedPubkyIdentity(
+ exportEnabled: Boolean,
+ managedSecretQuarantined: Boolean,
+ secretKeyHex: String?,
+ publicKeyFromSecret: (String) -> String,
+): LocalIdentity? {
+ if (!exportEnabled || managedSecretQuarantined || secretKeyHex.isNullOrBlank()) return null
+ val canonicalSecretKeyHex = SharedPubkyContract.canonicalSecretKeyHex(secretKeyHex)
+ val pubky = SharedPubkyContract.canonicalPubky(publicKeyFromSecret(canonicalSecretKeyHex))
+ return LocalIdentity(pubky = pubky, secretKeyHex = canonicalSecretKeyHex)
+}
+
+internal class LocalIdentity(
+ val pubky: String,
+ private val secretKeyHex: String,
+) {
+ fun publicRow(): Array = arrayOf(
+ SharedPubkyContract.PROTOCOL_VERSION,
+ SharedPubkyContract.BITKIT_SOURCE,
+ pubky,
+ )
+
+ fun credentialRow(): Array = arrayOf(
+ SharedPubkyContract.PROTOCOL_VERSION,
+ SharedPubkyContract.BITKIT_SOURCE,
+ pubky,
+ secretKeyHex,
+ )
+}
diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt
index f08ae0394f..04dfeb68bb 100644
--- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt
+++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt
@@ -11,6 +11,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import to.bitkit.data.AppCacheData
import to.bitkit.data.SettingsData
+import to.bitkit.data.WatchOnlyAccountAllocationState
import to.bitkit.data.WidgetsData
import to.bitkit.data.entities.TransferEntity
@@ -21,6 +22,8 @@ data class WalletBackupV1(
val transfers: List,
val privatePaykitHighestReservedReceiveIndexByAddressType: Map? = null,
val paykitSdkBackupState: String? = null,
+ val watchOnlyAccounts: List? = null,
+ val watchOnlyAccountAllocationState: WatchOnlyAccountAllocationState? = null,
)
@Serializable
diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt
index 048f00ea98..a212b0b259 100644
--- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt
+++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt
@@ -1,12 +1,42 @@
package to.bitkit.models
import androidx.compose.runtime.Immutable
+import to.bitkit.utils.AppError
+import java.net.URI
+import java.net.URLDecoder
+import java.nio.charset.StandardCharsets
+
+enum class PubkyAuthClaim(val wireValue: String) {
+ WATCH_ONLY_ACCOUNT_V1("watch-only-account-v1"),
+ ;
+
+ companion object {
+ /** Query parameter used for Bitkit-specific Pubky auth claims. */
+ const val QUERY_PARAMETER = "x-bitkit-claim"
+
+ /** Capabilities required by the watch-only Paykit Server setup flow. */
+ const val WATCH_ONLY_ACCOUNT_CAPABILITIES = "/pub/paykit/v0/bitkit/server/:rw"
+
+ fun fromWireValue(value: String) = entries.firstOrNull { it.wireValue == value }
+ }
+}
+
+sealed class PubkyAuthRequestError(cause: Throwable? = null) : AppError(cause = cause) {
+ class InvalidUrl(cause: Throwable) : PubkyAuthRequestError(cause)
+ data object MissingBitkitClaim : PubkyAuthRequestError()
+ data object DuplicateBitkitClaim : PubkyAuthRequestError()
+ data class UnsupportedBitkitClaim(val value: String) : PubkyAuthRequestError()
+ data object InvalidBitkitClaimCapabilities : PubkyAuthRequestError()
+}
@Immutable
data class PubkyAuthPermission(
val path: String,
val accessLevel: String,
) {
+ val displayPath: String
+ get() = if (path.length > 1) path.removeSuffix("/") else path
+
val displayAccess: String
get() = accessLevel.map { char ->
when (char) {
@@ -20,10 +50,71 @@ data class PubkyAuthPermission(
data class PubkyAuthRequest(
val rawUrl: String,
val relay: String,
+ val capabilities: String,
val permissions: List,
val serviceNames: List,
+ val bitkitClaim: PubkyAuthClaim?,
) {
companion object {
+ fun parse(
+ rawUrl: String,
+ relay: String,
+ capabilities: String,
+ ): Result = parseBitkitClaim(rawUrl, capabilities).map { bitkitClaim ->
+ val permissions = parseCapabilities(capabilities)
+ PubkyAuthRequest(
+ rawUrl = rawUrl,
+ relay = relay,
+ capabilities = capabilities,
+ permissions = permissions,
+ serviceNames = permissions.mapNotNull { extractServiceName(it.path) }.distinct(),
+ bitkitClaim = bitkitClaim,
+ )
+ }
+
+ fun parseBitkitClaim(rawUrl: String, capabilities: String): Result =
+ parseBitkitClaimValues(rawUrl).fold(
+ onSuccess = { claimValues -> validateBitkitClaim(claimValues, capabilities) },
+ onFailure = { Result.failure(it) },
+ )
+
+ private fun parseBitkitClaimValues(rawUrl: String): Result> = runCatching {
+ URI(rawUrl).rawQuery.orEmpty()
+ .split("&")
+ .filter { it.isNotEmpty() }
+ .map { it.split("=", limit = 2) }
+ .filter { decodeQueryComponent(it.first()) == PubkyAuthClaim.QUERY_PARAMETER }
+ .map { decodeQueryComponent(it.getOrElse(1) { "" }) }
+ }.fold(
+ onSuccess = { Result.success(it) },
+ onFailure = { Result.failure(PubkyAuthRequestError.InvalidUrl(it)) },
+ )
+
+ private fun validateBitkitClaim(
+ claimValues: List,
+ capabilities: String,
+ ): Result = when {
+ claimValues.size > 1 -> Result.failure(PubkyAuthRequestError.DuplicateBitkitClaim)
+ claimValues.isEmpty() && capabilities == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES ->
+ Result.failure(PubkyAuthRequestError.MissingBitkitClaim)
+ claimValues.isEmpty() -> Result.success(null)
+ else -> validateBitkitClaimValue(claimValues.first(), capabilities)
+ }
+
+ private fun validateBitkitClaimValue(
+ claimValue: String,
+ capabilities: String,
+ ): Result {
+ val claim = PubkyAuthClaim.fromWireValue(claimValue)
+ ?: return Result.failure(PubkyAuthRequestError.UnsupportedBitkitClaim(claimValue))
+
+ return if (capabilities == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES) {
+ Result.success(claim)
+ } else {
+ Result.failure(PubkyAuthRequestError.InvalidBitkitClaimCapabilities)
+ }
+ }
+
fun parseCapabilities(caps: String): List =
caps.split(",")
.filter { it.isNotBlank() }
@@ -40,5 +131,7 @@ data class PubkyAuthRequest(
val pubIndex = parts.indexOf("pub")
return if (pubIndex >= 0 && pubIndex + 1 < parts.size) parts[pubIndex + 1] else null
}
+
+ private fun decodeQueryComponent(value: String) = URLDecoder.decode(value, StandardCharsets.UTF_8.name())
}
}
diff --git a/app/src/main/java/to/bitkit/models/PubkyProfile.kt b/app/src/main/java/to/bitkit/models/PubkyProfile.kt
index 468c86285a..d5e846c802 100644
--- a/app/src/main/java/to/bitkit/models/PubkyProfile.kt
+++ b/app/src/main/java/to/bitkit/models/PubkyProfile.kt
@@ -67,7 +67,7 @@ data class PubkyProfile(
}
val truncatedPublicKey: String
- get() = publicKey.ellipsisMiddle(TRUNCATED_PK_LENGTH)
+ get() = PubkyPublicKeyFormat.display(publicKey)
fun withNameFallback(fallbackName: String?): PubkyProfile {
return if (name.isBlank() && !fallbackName.isNullOrBlank()) copy(name = fallbackName) else this
diff --git a/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt b/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt
index 069552f88f..fc4b16ab06 100644
--- a/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt
+++ b/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt
@@ -5,6 +5,7 @@ import to.bitkit.ext.ellipsisMiddle
import java.util.Locale
object PubkyPublicKeyFormat {
+ private const val displayEdgeLength = 4
private const val redactedLength = 16
const val maximumInputLength = 57
@@ -25,6 +26,15 @@ object PubkyPublicKeyFormat {
return normalizedLhs == normalizedRhs
}
+ fun display(input: String): String {
+ val rawKey = bounded(input).removePrefix("pubky")
+ return if (rawKey.length > displayEdgeLength * 2) {
+ "${rawKey.take(displayEdgeLength)}...${rawKey.takeLast(displayEdgeLength)}"
+ } else {
+ rawKey
+ }
+ }
+
fun redacted(input: String): String {
val normalizedInput = normalized(input) ?: input.trim()
return normalizedInput.ellipsisMiddle(redactedLength)
diff --git a/app/src/main/java/to/bitkit/models/WatchOnlyAccount.kt b/app/src/main/java/to/bitkit/models/WatchOnlyAccount.kt
new file mode 100644
index 0000000000..f632bfc26c
--- /dev/null
+++ b/app/src/main/java/to/bitkit/models/WatchOnlyAccount.kt
@@ -0,0 +1,49 @@
+package to.bitkit.models
+
+import androidx.compose.runtime.Immutable
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import org.lightningdevkit.ldknode.Network
+import to.bitkit.env.Env
+
+const val WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX = 999
+const val WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE = "nativeSegwit"
+const val WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH = 78
+
+@Serializable
+enum class WatchOnlyAccountSetupState {
+ @SerialName("pendingDelivery")
+ PendingDelivery,
+
+ @SerialName("authorizing")
+ Authorizing,
+
+ @SerialName("active")
+ Active,
+}
+
+@Serializable
+@Immutable
+data class WatchOnlyAccountRecord(
+ val id: String,
+ val walletIndex: Int,
+ val accountIndex: Int,
+ val addressType: String,
+ val xpub: String,
+ val requestFingerprint: String,
+ val createdAt: Long,
+ val name: String,
+ val isTrackingEnabled: Boolean,
+ val setupState: WatchOnlyAccountSetupState,
+) {
+ val derivationPath: String
+ get() {
+ val coinType = if (Env.network == Network.BITCOIN) 0 else 1
+ return "m/84'/$coinType'/$accountIndex'"
+ }
+}
+
+data class PreparedWatchOnlyAccountClaim(
+ val account: WatchOnlyAccountRecord,
+ val payload: ByteArray,
+)
diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
index f2a5a682e0..0ba3f1f909 100644
--- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
@@ -35,6 +35,7 @@ import to.bitkit.async.appScope
import to.bitkit.data.AppDb
import to.bitkit.data.CacheStore
import to.bitkit.data.SettingsStore
+import to.bitkit.data.WatchOnlyAccountStore
import to.bitkit.data.WidgetsStore
import to.bitkit.data.backup.VssBackupClient
import to.bitkit.data.backup.VssBackupClientLdk
@@ -90,6 +91,8 @@ class BackupRepo @Inject constructor(
private val vssBackupClientLdk: VssBackupClientLdk,
private val settingsStore: SettingsStore,
private val widgetsStore: WidgetsStore,
+ private val watchOnlyAccountStore: WatchOnlyAccountStore,
+ private val watchOnlyAccountRepo: WatchOnlyAccountRepo,
private val blocktankRepo: BlocktankRepo,
private val activityRepo: ActivityRepo,
private val pubkyRepo: PubkyRepo,
@@ -262,6 +265,17 @@ class BackupRepo @Inject constructor(
}
dataListenerJobs.add(transfersJob)
+ val watchOnlyAccountsJob = scope.launch {
+ watchOnlyAccountStore.data
+ .distinctUntilChanged()
+ .drop(1)
+ .collect {
+ if (shouldSkipBackup()) return@collect
+ markBackupRequired(BackupCategory.WALLET)
+ }
+ }
+ dataListenerJobs.add(watchOnlyAccountsJob)
+
// METADATA - Observe entire CacheStore excluding backup statuses
val cacheMetadataJob = scope.launch {
cacheStore.data
@@ -544,11 +558,14 @@ class BackupRepo @Inject constructor(
}
.getOrThrow()
+ val watchOnlyAccountSnapshot = watchOnlyAccountStore.backupSnapshot()
val payload = WalletBackupV1(
createdAt = currentTimeMillis(),
transfers = transfers,
privatePaykitHighestReservedReceiveIndexByAddressType = privateReservations,
paykitSdkBackupState = paykitSdkBackupState,
+ watchOnlyAccounts = watchOnlyAccountSnapshot.accounts,
+ watchOnlyAccountAllocationState = watchOnlyAccountSnapshot.allocationState,
)
return json.encodeToString(payload).toByteArray()
@@ -630,6 +647,11 @@ class BackupRepo @Inject constructor(
private suspend fun restoreWalletBackup(dataBytes: ByteArray): Long {
val parsed = json.decodeFromString(String(dataBytes))
db.transferDao().upsert(parsed.transfers)
+ watchOnlyAccountRepo.restore(
+ parsed.watchOnlyAccounts.orEmpty(),
+ parsed.watchOnlyAccountAllocationState,
+ )
+ lightningService.reconcileWatchOnlyAccounts()
if (!parsed.privatePaykitHighestReservedReceiveIndexByAddressType.isNullOrEmpty()) {
cacheStore.update { it.copy(onchainAddress = "", bip21 = "") }
}
diff --git a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt
new file mode 100644
index 0000000000..efbbf7216b
--- /dev/null
+++ b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt
@@ -0,0 +1,170 @@
+package to.bitkit.repositories
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
+import to.bitkit.data.SettingsData
+import to.bitkit.data.SettingsStore
+import to.bitkit.data.areContactPaymentsEnabled
+import to.bitkit.di.IoDispatcher
+import to.bitkit.ext.runSuspendCatching
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ContactPaymentSettingsRepo @Inject constructor(
+ private val settingsStore: SettingsStore,
+ private val publicPaykitRepo: PublicPaykitRepo,
+ private val privatePaykitRepo: PrivatePaykitRepo,
+ private val pubkyRepo: PubkyRepo,
+ @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
+) {
+ val isEnabled: Flow = settingsStore.data.map { it.areContactPaymentsEnabled() }
+
+ suspend fun setEnabled(isEnabled: Boolean): Result = withContext(ioDispatcher) {
+ val contacts = pubkyRepo.contacts.value.map { it.publicKey }
+ if (isEnabled) enable(contacts) else disable(contacts)
+ }
+
+ private suspend fun enable(contacts: List): Result {
+ val previous = settingsStore.data.first()
+ val canUsePrivateContactPayments = pubkyRepo.hasSecretKey()
+ return runSuspendCatching {
+ settingsStore.update {
+ it.copy(
+ hasConfirmedPublicPaykitEndpoints = true,
+ sharesPublicPaykitEndpoints = true,
+ sharesPrivatePaykitEndpoints = canUsePrivateContactPayments,
+ publicPaykitLightningEnabled = true,
+ publicPaykitOnchainEnabled = true,
+ )
+ }
+ publicPaykitRepo.syncPublishedEndpoints(publish = true).getOrThrow()
+
+ if (canUsePrivateContactPayments) {
+ privatePaykitRepo.enableSharingAndPrepareSavedContacts(
+ publicKeys = contacts,
+ requireImmediatePublication = true,
+ ).getOrThrow()
+ }
+ }.onFailure { rollbackEnabled(previous, contacts, it) }
+ }
+
+ private suspend fun rollbackEnabled(
+ previous: SettingsData,
+ contacts: List,
+ error: Throwable,
+ ) {
+ runSuspendCatching {
+ settingsStore.update {
+ it.copy(
+ hasConfirmedPublicPaykitEndpoints = previous.hasConfirmedPublicPaykitEndpoints,
+ sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints,
+ sharesPrivatePaykitEndpoints = previous.sharesPrivatePaykitEndpoints,
+ publicPaykitLightningEnabled = previous.publicPaykitLightningEnabled,
+ publicPaykitOnchainEnabled = previous.publicPaykitOnchainEnabled,
+ )
+ }
+ }.onFailure(error::addSuppressed)
+ publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints)
+ .onFailure {
+ error.addSuppressed(it)
+ markPublicPaykitRetry(error)
+ }
+ if (previous.sharesPrivatePaykitEndpoints) {
+ privatePaykitRepo.enableSharingAndPrepareSavedContacts(
+ publicKeys = contacts,
+ requireImmediatePublication = true,
+ ).onFailure(error::addSuppressed)
+ } else {
+ privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts)
+ .onFailure(error::addSuppressed)
+ }
+ }
+
+ private suspend fun disable(contacts: List): Result {
+ val previous = settingsStore.data.first()
+ runSuspendCatching {
+ settingsStore.update {
+ it.copy(
+ hasConfirmedPublicPaykitEndpoints = true,
+ sharesPublicPaykitEndpoints = false,
+ sharesPrivatePaykitEndpoints = false,
+ publicPaykitLightningEnabled = true,
+ publicPaykitOnchainEnabled = true,
+ )
+ }
+ }.onFailure {
+ return Result.failure(it)
+ }
+
+ var publicCleanupError: Throwable? = null
+ var privateCleanupError: Throwable? = null
+ publicPaykitRepo.syncPublishedEndpoints(publish = false)
+ .onFailure { publicCleanupError = it }
+
+ privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts)
+ .onFailure { privateCleanupError = it }
+
+ publicCleanupError?.let { error ->
+ runSuspendCatching {
+ settingsStore.update { settings ->
+ settings.copy(sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints)
+ }
+ }.onFailure(error::addSuppressed)
+ publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints)
+ .onFailure {
+ error.addSuppressed(it)
+ markPublicPaykitRetry(error)
+ }
+ }
+ privateCleanupError?.let { error ->
+ if (previous.sharesPrivatePaykitEndpoints) {
+ restorePrivate(contacts, error)
+ } else {
+ updatePrivatePreference(isEnabled = false, error = error)
+ }
+ }
+
+ val cleanupError = publicCleanupError ?: privateCleanupError
+ publicCleanupError?.let { publicError ->
+ privateCleanupError?.let { publicError.addSuppressed(it) }
+ }
+ cleanupError?.let { return Result.failure(it) }
+
+ return Result.success(Unit)
+ }
+
+ private suspend fun restorePrivate(
+ contacts: List,
+ error: Throwable,
+ ) {
+ if (!updatePrivatePreference(isEnabled = true, error = error)) return
+
+ privatePaykitRepo.enableSharingAndPrepareSavedContacts(
+ publicKeys = contacts,
+ requireImmediatePublication = true,
+ ).exceptionOrNull()?.let {
+ error.addSuppressed(it)
+ updatePrivatePreference(isEnabled = false, error = error)
+ return
+ }
+
+ publicPaykitRepo.syncLocalReceiverMarker().onFailure(error::addSuppressed)
+ }
+
+ private suspend fun markPublicPaykitRetry(error: Throwable) {
+ runSuspendCatching {
+ settingsStore.update { it.copy(publicPaykitCleanupPending = true) }
+ }.onFailure(error::addSuppressed)
+ }
+
+ private suspend fun updatePrivatePreference(
+ isEnabled: Boolean,
+ error: Throwable,
+ ): Boolean = runSuspendCatching {
+ settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = isEnabled) }
+ }.onFailure(error::addSuppressed).isSuccess
+}
diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt
index 92ed77da02..128d3638cc 100644
--- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt
@@ -65,6 +65,7 @@ import to.bitkit.env.Env
import to.bitkit.ext.getSatsPerVByteFor
import to.bitkit.ext.nowMillis
import to.bitkit.ext.nowTimestamp
+import to.bitkit.ext.runSuspendCatching
import to.bitkit.ext.toPeerDetailsList
import to.bitkit.ext.totalNextOutboundHtlcLimitSats
import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS
@@ -340,8 +341,12 @@ class LightningRepo @Inject constructor(
}
}
- if (getStatus()?.isRunning == true) {
+ if (lightningService.status?.isRunning == true) {
Logger.info("LDK node already running", context = TAG)
+ runSuspendCatching { lightningService.reconcileWatchOnlyAccounts() }
+ .onFailure {
+ Logger.warn("Failed to reconcile Paykit Server accounts during startup", it, context = TAG)
+ }
_lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) }
lightningService.startEventListener(::onEvent).onFailure {
Logger.warn("Failed to start event listener", it, context = TAG)
diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
index 905f0c2697..a36e1af957 100644
--- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
@@ -614,7 +614,7 @@ class PrivatePaykitRepo @Inject constructor(
}
private suspend fun prepareRelevantPrivateLinksIfAvailable(publicKeys: Collection, reason: String) {
- if (!hasLocalSecretKeyForCurrentProfile()) return
+ if (!hasLiveSessionForCurrentProfile()) return
val retryKeys = mutableListOf()
for (publicKey in publicKeys) {
@@ -1232,16 +1232,16 @@ class PrivatePaykitRepo @Inject constructor(
private suspend fun canPublishPrivateEndpoints(): Boolean {
val settings = settingsStore.data.first()
return settings.sharesPrivatePaykitEndpoints &&
- hasLocalSecretKeyForCurrentProfile() &&
+ hasLiveSessionForCurrentProfile() &&
App.currentActivity?.value != null &&
walletRepo.walletExists() &&
lightningRepo.lightningState.value.nodeLifecycleState.isRunning()
}
- private suspend fun hasLocalSecretKeyForCurrentProfile(): Boolean = runSuspendCatching {
+ private suspend fun hasLiveSessionForCurrentProfile(): Boolean = runSuspendCatching {
pubkyService.currentPublicKey() ?: return@runSuspendCatching false
val status = paykitSdkService.identityStatus() ?: return@runSuspendCatching false
- status.privateLinkCapable
+ status.liveSessionAvailable
}.getOrDefault(false)
private suspend fun isContactSharingCleanupPending(): Boolean =
diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
index dc5e9617fb..e3017f1f41 100644
--- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -5,7 +5,7 @@ import android.graphics.BitmapFactory
import coil3.ImageLoader
import com.synonym.paykit.ContactProfileResolution
import com.synonym.paykit.PaykitProfile
-import com.synonym.paykit.PubkyAuthDetails
+import com.synonym.paykit.PubkyAuthCompanionClaim
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.post
@@ -36,10 +36,18 @@ import to.bitkit.data.PubkyStore
import to.bitkit.data.SettingsStore
import to.bitkit.data.hasPaykitState
import to.bitkit.data.keychain.Keychain
+import to.bitkit.data.sharing.ExternalPubkyIdentityRef
+import to.bitkit.data.sharing.SharedPubkyContract
+import to.bitkit.data.sharing.SharedPubkyCredential
+import to.bitkit.data.sharing.SharedPubkyDiscovery
+import to.bitkit.data.sharing.SharedPubkyError
+import to.bitkit.data.sharing.SharedPubkyIdentity
import to.bitkit.di.IoDispatcher
import to.bitkit.env.Env
import to.bitkit.ext.runSuspendCatching
import to.bitkit.models.HomegateResponse
+import to.bitkit.models.PubkyAuthClaim
+import to.bitkit.models.PubkyAuthRequest
import to.bitkit.models.PubkyProfile
import to.bitkit.models.PubkyProfileData
import to.bitkit.models.PubkyProfileLink
@@ -85,6 +93,7 @@ class PubkyRepo @Inject constructor(
private val pubkyStore: PubkyStore,
private val settingsStore: SettingsStore,
private val httpClient: HttpClient,
+ private val sharedPubkyDiscovery: SharedPubkyDiscovery,
) {
companion object {
private const val TAG = "PubkyRepo"
@@ -92,11 +101,13 @@ class PubkyRepo @Inject constructor(
private const val PUBKY_SCHEME = "pubky://"
private const val AVATAR_MAX_SIZE = 400
private const val AVATAR_QUALITY = 80
+ private const val MANAGED_SECRET_QUARANTINED = "1"
+ private const val SHARED_EXPORT_ENABLED = "1"
}
private val scope = appScope(ioDispatcher, TAG)
private val serviceInitializeMutex = Mutex()
- private val initializeMutex = Mutex()
+ private val identityLifecycleMutex = Mutex()
private val loadProfileMutex = Mutex()
private val loadContactsMutex = Mutex()
private var isServiceInitialized = false
@@ -152,6 +163,7 @@ class PubkyRepo @Inject constructor(
data object NoSession : InitResult
data class Restored(val publicKey: String) : InitResult
data object RestorationFailed : InitResult
+ data object ExternalSourceUnavailable : InitResult
}
init {
@@ -167,42 +179,82 @@ class PubkyRepo @Inject constructor(
Logger.error("Failed to initialize paykit", it, context = TAG)
}.getOrNull() ?: return@withContext
- initializeMutex.withLock {
+ identityLifecycleMutex.withLock {
_sessionRestorationFailed.update { false }
val result = runSuspendCatching {
- val savedSessionSecret = runCatching {
- keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)
- }.getOrNull()
- val storedSecretKeyHex = runCatching {
- keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
- }.getOrNull()
-
- resolveSessionInitialization(
- savedSessionSecret = savedSessionSecret,
- storedSecretKeyHex = storedSecretKeyHex,
- )
+ resolveStoredSessionInitialization()
}.onFailure {
Logger.error("Failed to initialize paykit", it, context = TAG)
}.getOrNull() ?: return@withLock
- when (result) {
- is InitResult.NoSession -> {
- clearAuthenticatedState()
- Logger.debug("Found no saved paykit session", context = TAG)
- }
- is InitResult.Restored -> {
- _publicKey.update { result.publicKey }
- _authState.update { PubkyAuthState.Authenticated }
- Logger.info("Restored paykit session for '${redacted(result.publicKey)}'", context = TAG)
- loadProfile()
- loadContacts()
- }
- is InitResult.RestorationFailed -> {
+ applySessionInitialization(result)
+ }
+ }
+
+ private suspend fun resolveStoredSessionInitialization(): InitResult {
+ val savedSessionSecret = runCatching {
+ keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)
+ }.getOrNull()
+ val isManagedSecretQuarantined = runCatching {
+ keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name)
+ }.getOrNull() == MANAGED_SECRET_QUARANTINED
+ val storedSecretKeyHex = runCatching {
+ keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
+ }.getOrNull().takeUnless { isManagedSecretQuarantined }
+ val externalIdentityRef = pubkyStore.data.first().externalIdentityRef?.let { identityRef ->
+ runCatching { identityRef.validated() }.getOrElse {
+ return InitResult.ExternalSourceUnavailable
+ }
+ }
+ if (isManagedSecretQuarantined && externalIdentityRef != null) {
+ return InitResult.ExternalSourceUnavailable
+ }
+
+ return resolveSessionInitialization(
+ savedSessionSecret = savedSessionSecret.takeUnless {
+ isManagedSecretQuarantined && externalIdentityRef == null
+ },
+ storedSecretKeyHex = storedSecretKeyHex,
+ externalIdentityRef = externalIdentityRef,
+ )
+ }
+
+ private suspend fun applySessionInitialization(result: InitResult) {
+ when (result) {
+ is InitResult.NoSession -> {
+ disableLocalIdentityExport()
+ clearAuthenticatedState()
+ Logger.debug("Found no saved paykit session", context = TAG)
+ }
+ is InitResult.Restored -> restoreInitializedSession(result.publicKey)
+ is InitResult.RestorationFailed -> {
+ disableLocalIdentityExport()
+ if (pubkyStore.data.first().externalIdentityRef == null) {
clearAuthenticatedState()
- _sessionRestorationFailed.update { true }
+ } else {
+ clearAuthenticatedRuntimeState()
}
+ _sessionRestorationFailed.update { true }
}
+ is InitResult.ExternalSourceUnavailable -> {
+ clearUnavailableExternalIdentityLocked()
+ Logger.warn("Disconnected unavailable Pubky Ring identity", context = TAG)
+ }
+ }
+ }
+
+ private suspend fun restoreInitializedSession(publicKey: String) {
+ val hasLocalSecret = !keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank()
+ if (pubkyStore.data.first().externalIdentityRef == null && hasLocalSecret) {
+ enableLocalIdentityExport(publicKey)
+ } else {
+ disableLocalIdentityExport()
}
+ _publicKey.update { publicKey }
+ _authState.update { PubkyAuthState.Authenticated }
+ Logger.info("Restored paykit session for '${redacted(publicKey)}'", context = TAG)
+ loadProfile()
+ loadContacts()
}
private suspend fun ensureServiceInitialized() = withContext(ioDispatcher) {
@@ -217,10 +269,22 @@ class PubkyRepo @Inject constructor(
private suspend fun resolveSessionInitialization(
savedSessionSecret: String?,
storedSecretKeyHex: String?,
+ externalIdentityRef: ExternalPubkyIdentityRef?,
): InitResult = withContext(ioDispatcher) {
+ if (externalIdentityRef != null) {
+ return@withContext resolveExternalSession(
+ savedSessionSecret = savedSessionSecret,
+ identityRef = externalIdentityRef,
+ )
+ }
+
if (!savedSessionSecret.isNullOrEmpty()) {
runSuspendCatching {
- val publicKey = pubkyService.importSession(savedSessionSecret).ensurePubkyPrefix()
+ val publicKey = if (storedSecretKeyHex.isNullOrBlank()) {
+ pubkyService.importExternalSession(savedSessionSecret)
+ } else {
+ pubkyService.importSession(savedSessionSecret)
+ }.ensurePubkyPrefix()
InitResult.Restored(publicKey)
}.getOrElse {
Logger.warn("Failed to restore paykit session, attempting re-sign-in", it, context = TAG)
@@ -231,6 +295,42 @@ class PubkyRepo @Inject constructor(
}
}
+ private suspend fun resolveExternalSession(
+ savedSessionSecret: String?,
+ identityRef: ExternalPubkyIdentityRef,
+ ): InitResult = withContext(ioDispatcher) {
+ val sourceIdentity = sharedPubkyDiscovery.discoverRingIdentities().getOrElse {
+ return@withContext InitResult.ExternalSourceUnavailable
+ }.firstOrNull { it.matches(identityRef) }
+ ?: return@withContext InitResult.ExternalSourceUnavailable
+
+ if (!savedSessionSecret.isNullOrBlank()) {
+ runSuspendCatching {
+ val restored = canonicalBitkitPubky(pubkyService.importExternalSession(savedSessionSecret))
+ if (wirePubky(restored) != identityRef.pubky) throw SharedPubkyError.InvalidResponse
+ InitResult.Restored(restored)
+ }.onSuccess {
+ return@withContext it
+ }.onFailure {
+ Logger.warn("Failed to restore external paykit session, attempting re-sign-in", it, context = TAG)
+ }
+ }
+
+ val credential = sharedPubkyDiscovery.readRingCredential(sourceIdentity.pubky).getOrElse {
+ return@withContext InitResult.ExternalSourceUnavailable
+ }
+ if (!credential.matches(identityRef)) return@withContext InitResult.ExternalSourceUnavailable
+
+ runSuspendCatching {
+ val publicKey = signInWithExternalCredential(credential)
+ Logger.info("Re-signed in with Pubky Ring identity '${redacted(publicKey)}'", context = TAG)
+ InitResult.Restored(publicKey)
+ }.getOrElse {
+ Logger.error("Failed external re-sign-in recovery", it, context = TAG)
+ InitResult.RestorationFailed
+ }
+ }
+
private suspend fun resolveSignedInSession(
savedSessionSecret: String?,
storedSecretKeyHex: String?,
@@ -284,10 +384,11 @@ class PubkyRepo @Inject constructor(
}
}
- suspend fun completeAuthentication(): Result {
- val attemptId = _activeAuthAttemptId.value ?: return Result.failure(PubkyAuthAttemptInactive())
+ suspend fun completeAuthentication(): Result = identityLifecycleMutex.withLock {
+ val attemptId = _activeAuthAttemptId.value
+ ?: return@withLock Result.failure(PubkyAuthAttemptInactive())
var didCompleteAuth = false
- return try {
+ try {
val result = runSuspendCatching {
waitForAuthApproval(attemptId)
withContext(ioDispatcher) {
@@ -300,6 +401,7 @@ class PubkyRepo @Inject constructor(
ensureAuthAttemptActive(attemptId)
settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) }
+ disableLocalIdentityExport()
notifyBackupStateChanged()
pk
@@ -532,52 +634,63 @@ class PubkyRepo @Inject constructor(
links: List,
tags: List,
avatarBytes: ByteArray?,
- ): Result = runSuspendCatching {
- withContext(ioDispatcher) {
- val (publicKeyZ32, secretKeyHex) = deriveKeys().getOrThrow()
-
- val homegate = fetchHomegateSignupCode()
+ ): Result = identityLifecycleMutex.withLock {
+ runSuspendCatching {
+ withContext(ioDispatcher) {
+ if (pubkyStore.data.first().externalIdentityRef != null) {
+ throw SharedPubkyError.IdentityConflict
+ }
+ val (publicKeyZ32, secretKeyHex) = deriveKeys().getOrThrow()
- runSuspendCatching {
- pubkyService.signUp(secretKeyHex, homegate.homeserverPubky, homegate.signupCode)
- }.getOrElse {
- Logger.warn("Retrying sign in after sign up failed", it, context = TAG)
- pubkyService.signIn(secretKeyHex)
- }
+ val homegate = fetchHomegateSignupCode()
- val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() }
- writeProfile(name, bio, links, tags, imageUrl)
+ runSuspendCatching {
+ pubkyService.signUp(secretKeyHex, homegate.homeserverPubky, homegate.signupCode)
+ }.getOrElse {
+ Logger.warn("Retrying sign in after sign up failed", it, context = TAG)
+ pubkyService.signIn(secretKeyHex)
+ }
- val createdProfile = PubkyProfile(
- publicKey = publicKeyZ32,
- name = name,
- bio = bio,
- imageUrl = imageUrl,
- links = links,
- tags = tags,
- status = null,
- )
- _publicKey.update { publicKeyZ32 }
- _authState.update { PubkyAuthState.Authenticated }
- _profile.update { createdProfile }
- cacheMetadata(createdProfile)
- notifyBackupStateChanged()
- Logger.info("Created identity for '${redacted(publicKeyZ32)}'", context = TAG)
- loadProfile()
- loadContacts()
+ val imageUrl = avatarBytes?.let { uploadAvatarInternal(it) }
+ writeProfile(name, bio, links, tags, imageUrl)
+
+ val createdProfile = PubkyProfile(
+ publicKey = publicKeyZ32,
+ name = name,
+ bio = bio,
+ imageUrl = imageUrl,
+ links = links,
+ tags = tags,
+ status = null,
+ )
+ enableLocalIdentityExport(publicKeyZ32)
+ _publicKey.update { publicKeyZ32 }
+ _authState.update { PubkyAuthState.Authenticated }
+ _profile.update { createdProfile }
+ cacheMetadata(createdProfile)
+ notifyBackupStateChanged()
+ Logger.info("Created identity for '${redacted(publicKeyZ32)}'", context = TAG)
+ loadProfile()
+ loadContacts()
+ }
}
}
suspend fun uploadAvatar(imageBytes: ByteArray): Result = runSuspendCatching {
withContext(ioDispatcher) {
- requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) {
- "No session available"
- }
- val compressed = compressAvatar(imageBytes)
- pubkyService.uploadProfileAvatar(compressed, contentType = "image/jpeg")
+ requireExternalIdentitySource()
+ uploadAvatarInternal(imageBytes)
}
}
+ private suspend fun uploadAvatarInternal(imageBytes: ByteArray): String {
+ requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) {
+ "No session available"
+ }
+ val compressed = compressAvatar(imageBytes)
+ return pubkyService.uploadProfileAvatar(compressed, contentType = "image/jpeg")
+ }
+
suspend fun saveProfile(
name: String,
bio: String,
@@ -586,6 +699,7 @@ class PubkyRepo @Inject constructor(
imageUrl: String?,
): Result = runSuspendCatching {
withContext(ioDispatcher) {
+ requireExternalIdentitySource()
requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) {
"No session available"
}
@@ -618,6 +732,8 @@ class PubkyRepo @Inject constructor(
suspend fun deleteProfile(): Result = runSuspendCatching {
withContext(ioDispatcher) {
+ requireExternalIdentitySource()
+ disableLocalIdentityExport()
requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) {
"No session available"
}
@@ -762,6 +878,7 @@ class PubkyRepo @Inject constructor(
existingProfile: PubkyProfile? = null,
): Result = runSuspendCatching {
withContext(ioDispatcher) {
+ requireExternalIdentitySource()
val prefixedKey = requireAddableContactPublicKey(
publicKey = publicKey,
allowExisting = existingProfile != null,
@@ -781,6 +898,7 @@ class PubkyRepo @Inject constructor(
suspend fun refreshContactReceiverPaths(publicKey: String): Result = runSuspendCatching {
withContext(ioDispatcher) {
+ requireExternalIdentitySource()
val prefixedKey = requireAddableContactPublicKey(publicKey = publicKey, allowExisting = true)
val contact = _contacts.value.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, prefixedKey) }
?: return@withContext
@@ -799,6 +917,7 @@ class PubkyRepo @Inject constructor(
tags: List,
): Result = runSuspendCatching {
withContext(ioDispatcher) {
+ requireExternalIdentitySource()
val prefixedKey = publicKey.ensurePubkyPrefix()
val updatedProfile = PubkyProfile(
publicKey = prefixedKey,
@@ -822,6 +941,7 @@ class PubkyRepo @Inject constructor(
suspend fun removeContact(publicKey: String): Result = runSuspendCatching {
withContext(ioDispatcher) {
+ requireExternalIdentitySource()
val prefixedKey = publicKey.ensurePubkyPrefix()
pubkyService.removeContact(prefixedKey)
removeContactProfileOverride(prefixedKey)
@@ -833,6 +953,7 @@ class PubkyRepo @Inject constructor(
suspend fun importContacts(publicKeys: List): Result = runSuspendCatching {
withContext(ioDispatcher) {
+ requireExternalIdentitySource()
val imported = coroutineScope {
publicKeys.map { contactPk ->
val prefixedKey = contactPk.ensurePubkyPrefix()
@@ -890,34 +1011,153 @@ class PubkyRepo @Inject constructor(
// endregion
+ // region Shared Pubky identities
+
+ suspend fun discoverRingIdentities(): Result> =
+ sharedPubkyDiscovery.discoverRingIdentities()
+
+ suspend fun adoptRingIdentity(identity: SharedPubkyIdentity): Result =
+ identityLifecycleMutex.withLock {
+ var didPersistExternalIdentity = false
+ runSuspendCatching {
+ withContext(ioDispatcher) {
+ ensureServiceInitialized()
+ val canonicalIdentity = identity.validatedRingIdentity()
+ val currentIdentityRef = pubkyStore.data.first().externalIdentityRef?.validated()
+ val currentPublicKey = _publicKey.value
+ val isAlreadyActive = currentIdentityRef?.pubky == canonicalIdentity.pubky &&
+ currentPublicKey?.let(::wirePubky) == canonicalIdentity.pubky
+ if (isAlreadyActive) {
+ return@withContext
+ }
+ if (
+ currentPublicKey != null ||
+ !keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank()
+ ) {
+ throw SharedPubkyError.IdentityConflict
+ }
+
+ val credential = sharedPubkyDiscovery.readRingCredential(canonicalIdentity.pubky).getOrThrow()
+ if (!credential.identity.matches(canonicalIdentity)) throw SharedPubkyError.InvalidResponse
+
+ disableLocalIdentityExport()
+ val identityRef = canonicalIdentity.toExternalRef()
+ pubkyStore.update { it.copy(externalIdentityRef = identityRef) }
+ didPersistExternalIdentity = true
+ _authState.update { PubkyAuthState.Authenticating }
+
+ val publicKey = signInWithExternalCredential(credential)
+
+ settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) }
+ notifyBackupStateChanged()
+ _publicKey.update { publicKey }
+ _authState.update { PubkyAuthState.Authenticated }
+ Logger.info("Connected Pubky Ring identity '${redacted(publicKey)}'", context = TAG)
+ loadProfile()
+ loadContacts()
+ }
+ }.onFailure {
+ if (didPersistExternalIdentity) {
+ withContext(NonCancellable) {
+ runSuspendCatching { clearUnavailableExternalIdentityLocked() }
+ .onFailure {
+ Logger.error("Failed to roll back Pubky Ring identity connection", it, context = TAG)
+ }
+ }
+ }
+ restoreAuthStateAfterAuthFlow()
+ }
+ }
+
+ suspend fun validateExternalIdentitySource(): Boolean = identityLifecycleMutex.withLock {
+ validateExternalIdentitySourceLocked()
+ }
+
+ private suspend fun validateExternalIdentitySourceLocked(): Boolean = withContext(ioDispatcher) {
+ val identityRef = runSuspendCatching {
+ pubkyStore.data.first().externalIdentityRef?.validated()
+ }.getOrElse {
+ clearUnavailableExternalIdentityLocked()
+ return@withContext false
+ } ?: return@withContext true
+
+ val available = sharedPubkyDiscovery.discoverRingIdentities()
+ .getOrNull()
+ ?.any { it.matches(identityRef) }
+ ?: false
+ if (available) return@withContext true
+
+ clearUnavailableExternalIdentityLocked()
+ Logger.warn("Disconnected missing Pubky Ring identity '${redacted(identityRef.pubky)}'", context = TAG)
+ false
+ }
+
+ // endregion
+
// region Auth approval
suspend fun hasSecretKey(): Boolean = runSuspendCatching {
val publicKey = _publicKey.value ?: return@runSuspendCatching false
- managedSecretKeyFor(publicKey) != null
+ activeIdentitySecretKey(publicKey) != null
}.getOrDefault(false)
- suspend fun parseAuthUrl(authUrl: String): Result = runSuspendCatching {
+ suspend fun parseAuthUrl(authUrl: String): Result = runSuspendCatching {
withContext(ioDispatcher) {
- pubkyService.parseAuthUrl(authUrl)
+ val details = pubkyService.parseAuthUrl(authUrl)
+ PubkyAuthRequest.parse(
+ rawUrl = authUrl,
+ relay = details.relayUrl.orEmpty(),
+ capabilities = details.capabilities.orEmpty(),
+ ).getOrThrow()
}
}
suspend fun approveAuth(authUrl: String, expectedCapabilities: String): Result = runSuspendCatching {
withContext(ioDispatcher) {
- val secretKeyHex = requireNotNull(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) {
- "No secret key available — use Ring to manage authorizations"
+ val publicKey = requireNotNull(_publicKey.value) { "No active Pubky identity" }
+ val secretKeyHex = requireNotNull(activeIdentitySecretKey(publicKey)) {
+ "No active Pubky secret key is available"
}
pubkyService.approveAuth(authUrl, expectedCapabilities, secretKeyHex)
}
}
+ suspend fun approveAuthWithCompanionClaim(
+ authUrl: String,
+ unsignedPayload: ByteArray,
+ ): Result = runSuspendCatching {
+ withContext(ioDispatcher) {
+ val publicKey = requireNotNull(_publicKey.value) { "No active Pubky identity" }
+ val secretKeyHex = requireNotNull(activeIdentitySecretKey(publicKey)) {
+ "No active Pubky secret key is available"
+ }
+ pubkyService.approveAuthWithCompanionClaim(
+ authUrl = authUrl,
+ expectedCapabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES,
+ secretKeyHex = secretKeyHex,
+ claim = PubkyAuthCompanionClaim(
+ queryParameter = PubkyAuthClaim.QUERY_PARAMETER,
+ claimType = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue,
+ unsignedPayload = unsignedPayload,
+ ),
+ )
+ }
+ }
+
// endregion
// region Backup state
suspend fun snapshotSessionBackupState(): Result = runSuspendCatching {
withContext(ioDispatcher) {
+ if (pubkyStore.data.first().externalIdentityRef != null) return@withContext null
+ if (
+ keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) ==
+ MANAGED_SECRET_QUARANTINED
+ ) {
+ return@withContext null
+ }
+
val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
if (!secretKeyHex.isNullOrEmpty()) {
return@withContext PubkySessionBackupV1(kind = PubkySessionBackupKind.LocalSeed)
@@ -945,11 +1185,12 @@ class PubkyRepo @Inject constructor(
withContext(ioDispatcher) {
ensureServiceInitialized()
- initializeMutex.withLock {
+ identityLifecycleMutex.withLock {
+ disableLocalIdentityExport()
pubkyService.clearSessionAccess()
clearAuthenticatedState()
- runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) }
- runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) }
+ runSuspendCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) }
+ runSuspendCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) }
when (backup?.kind) {
null -> Unit
@@ -959,6 +1200,7 @@ class PubkyRepo @Inject constructor(
keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex)
pubkyService.signIn(secretKeyHex)
val publicKey = pubkyService.publicKeyFromSecret(secretKeyHex).ensurePubkyPrefix()
+ enableLocalIdentityExport(publicKey)
_publicKey.update { publicKey }
_authState.update { PubkyAuthState.Authenticated }
}
@@ -968,6 +1210,7 @@ class PubkyRepo @Inject constructor(
"Missing session secret in backup"
}
val publicKey = pubkyService.importExternalSession(sessionSecret).ensurePubkyPrefix()
+ disableLocalIdentityExport()
_publicKey.update { publicKey }
_authState.update { PubkyAuthState.Authenticated }
}
@@ -989,19 +1232,46 @@ class PubkyRepo @Inject constructor(
}
}
- suspend fun refreshSessionIfPossible(): Result = runSuspendCatching {
- withContext(ioDispatcher) {
- val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
- ?: return@withContext false
+ suspend fun refreshSessionIfPossible(): Result = identityLifecycleMutex.withLock {
+ runSuspendCatching {
+ withContext(ioDispatcher) {
+ val identityRef = pubkyStore.data.first().externalIdentityRef?.validated()
+ if (identityRef != null) {
+ if (!validateExternalIdentitySourceLocked()) return@withContext false
+ val credential = sharedPubkyDiscovery.readRingCredential(identityRef.pubky).getOrElse {
+ clearUnavailableExternalIdentityLocked()
+ return@withContext false
+ }
+ val publicKey = signInWithExternalCredential(credential)
+ if (wirePubky(publicKey) != identityRef.pubky) {
+ clearUnavailableExternalIdentityLocked()
+ return@withContext false
+ }
+ notifyBackupStateChanged()
+ _publicKey.update { publicKey }
+ _authState.update { PubkyAuthState.Authenticated }
+ return@withContext true
+ }
- pubkyService.signIn(storedSecretKeyHex)
- val publicKey = pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix()
+ val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
+ ?: return@withContext false
+ if (
+ keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) ==
+ MANAGED_SECRET_QUARANTINED
+ ) {
+ return@withContext false
+ }
- notifyBackupStateChanged()
- _publicKey.update { publicKey }
- _authState.update { PubkyAuthState.Authenticated }
+ pubkyService.signIn(storedSecretKeyHex)
+ val publicKey = pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix()
+ enableLocalIdentityExport(publicKey)
+
+ notifyBackupStateChanged()
+ _publicKey.update { publicKey }
+ _authState.update { PubkyAuthState.Authenticated }
- true
+ true
+ }
}
}
@@ -1009,7 +1279,11 @@ class PubkyRepo @Inject constructor(
// region Sign out
- suspend fun signOut(): Result {
+ suspend fun signOut(): Result = identityLifecycleMutex.withLock {
+ runSuspendCatching { disableLocalIdentityExport() }
+ .onFailure { Logger.error("Failed to disable shared Pubky export", it, context = TAG) }
+ .exceptionOrNull()
+ ?.let { return@withLock Result.failure(it) }
val hadPaykitState = settingsStore.data.first().hasPaykitState()
val endpointCleanupResult = removeBitkitPaymentEndpoints()
.onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) }
@@ -1025,10 +1299,10 @@ class PubkyRepo @Inject constructor(
)
clearLocalState(publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPaykitState)
- return result
+ result
}
- suspend fun wipeLocalState() {
+ suspend fun wipeLocalState() = identityLifecycleMutex.withLock {
clearLocalState()
}
@@ -1151,7 +1425,112 @@ class PubkyRepo @Inject constructor(
}
}
+ private suspend fun signInWithExternalCredential(credential: SharedPubkyCredential): String =
+ withContext(ioDispatcher) {
+ val identity = credential.identity.validatedRingIdentity()
+ val derivedWirePubky = wirePubky(pubkyService.publicKeyFromSecret(credential.secretKeyHex))
+ if (derivedWirePubky != identity.pubky) throw SharedPubkyError.InvalidResponse
+
+ val signedInPubky = canonicalBitkitPubky(pubkyService.signInExternal(credential.secretKeyHex))
+ if (wirePubky(signedInPubky) != identity.pubky) throw SharedPubkyError.InvalidResponse
+ if (!keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank()) {
+ throw SharedPubkyError.InvalidResponse
+ }
+ signedInPubky
+ }
+
+ private suspend fun enableLocalIdentityExport(publicKey: String) = withContext(ioDispatcher) {
+ val secretKeyHex = requireNotNull(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) {
+ "Local Pubky secret is unavailable"
+ }
+ val derivedPublicKey = canonicalBitkitPubky(pubkyService.publicKeyFromSecret(secretKeyHex))
+ if (!PubkyPublicKeyFormat.matches(derivedPublicKey, publicKey)) {
+ throw SharedPubkyError.InvalidResponse
+ }
+ keychain.delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name)
+ check(keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == null) {
+ "Failed to release managed local Pubky secret quarantine"
+ }
+ keychain.upsertString(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name, SHARED_EXPORT_ENABLED)
+ check(keychain.loadString(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) == SHARED_EXPORT_ENABLED) {
+ "Failed to verify shared Pubky export state"
+ }
+ }
+
+ private suspend fun disableLocalIdentityExport() = withContext(ioDispatcher) {
+ keychain.delete(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name)
+ check(keychain.loadString(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) == null) {
+ "Failed to disable shared Pubky export"
+ }
+ }
+
+ private suspend fun activeIdentitySecretKey(publicKey: String): String? = identityLifecycleMutex.withLock {
+ activeIdentitySecretKeyLocked(publicKey)
+ }
+
+ private suspend fun activeIdentitySecretKeyLocked(publicKey: String): String? = withContext(ioDispatcher) {
+ val identityRef = pubkyStore.data.first().externalIdentityRef?.validated()
+ ?: return@withContext managedSecretKeyFor(publicKey)
+ if (wirePubky(publicKey) != identityRef.pubky || !validateExternalIdentitySourceLocked()) {
+ return@withContext null
+ }
+
+ val credential = sharedPubkyDiscovery.readRingCredential(identityRef.pubky).getOrElse {
+ clearUnavailableExternalIdentityLocked()
+ return@withContext null
+ }
+ val isValid = runSuspendCatching {
+ credential.matches(identityRef) &&
+ wirePubky(pubkyService.publicKeyFromSecret(credential.secretKeyHex)) == identityRef.pubky
+ }.getOrDefault(false)
+ if (!isValid) {
+ clearUnavailableExternalIdentityLocked()
+ return@withContext null
+ }
+ credential.secretKeyHex
+ }
+
+ private suspend fun requireExternalIdentitySource() {
+ if (!validateExternalIdentitySource()) throw SharedPubkyError.SourceUnavailable
+ }
+
+ private suspend fun clearUnavailableExternalIdentityLocked() = withContext(ioDispatcher) {
+ val externalIdentityRef = pubkyStore.data.first().externalIdentityRef ?: return@withContext
+ disableLocalIdentityExport()
+
+ val managedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
+ if (!managedSecretKeyHex.isNullOrBlank()) {
+ keychain.upsertString(
+ Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name,
+ MANAGED_SECRET_QUARANTINED,
+ )
+ check(
+ keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) ==
+ MANAGED_SECRET_QUARANTINED
+ ) {
+ "Failed to quarantine conflicting managed local Pubky secret"
+ }
+ Logger.error(
+ "Quarantined managed local secret while clearing external identity " +
+ "'${redacted(externalIdentityRef.pubky)}'",
+ context = TAG,
+ )
+ }
+
+ pubkyService.clearExternalSessionAccess()
+ clearPublicPaykitSharingState(publicPaykitCleanupPending = false)
+ clearAuthenticatedRuntimeState()
+ pubkyStore.reset()
+ notifyBackupStateChanged()
+ }
+
private suspend fun managedSecretKeyFor(publicKey: String): String? = withContext(ioDispatcher) {
+ if (
+ keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) ==
+ MANAGED_SECRET_QUARANTINED
+ ) {
+ return@withContext null
+ }
val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
?: return@withContext null
@@ -1168,7 +1547,10 @@ class PubkyRepo @Inject constructor(
if (derivedPublicKey != null) {
Logger.warn("Ignoring stale managed secret key for '${redacted(publicKey)}'", context = TAG)
}
- runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) }
+ runSuspendCatching {
+ disableLocalIdentityExport()
+ keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name)
+ }
.onSuccess { notifyBackupStateChanged() }
null
}
@@ -1185,8 +1567,12 @@ class PubkyRepo @Inject constructor(
}
private suspend fun clearAuthenticatedState() = withContext(ioDispatcher) {
- evictPubkyImages()
runSuspendCatching { pubkyStore.reset() }
+ clearAuthenticatedRuntimeState()
+ }
+
+ private suspend fun clearAuthenticatedRuntimeState() = withContext(ioDispatcher) {
+ evictPubkyImages()
_publicKey.update { null }
_profile.update { null }
_contacts.update { emptyList() }
@@ -1201,8 +1587,10 @@ class PubkyRepo @Inject constructor(
}
private suspend fun clearLocalState(publicPaykitCleanupPending: Boolean = false) = withContext(ioDispatcher) {
- runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) }
- runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) }
+ disableLocalIdentityExport()
+ runSuspendCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) }
+ runSuspendCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) }
+ runSuspendCatching { keychain.delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) }
runSuspendCatching { clearPublicPaykitSharingState(publicPaykitCleanupPending) }
.onFailure { Logger.warn("Failed to clear public Paykit sharing state", it, context = TAG) }
notifyBackupStateChanged()
@@ -1241,6 +1629,43 @@ class PubkyRepo @Inject constructor(
private fun String.ensurePubkyPrefix(): String =
if (startsWith(PUBKY_PREFIX)) this else "$PUBKY_PREFIX$this"
+ private fun canonicalBitkitPubky(value: String): String =
+ SharedPubkyContract.toBitkitPubky(value)
+
+ private fun wirePubky(value: String): String =
+ SharedPubkyContract.canonicalPubky(value)
+
+ private fun SharedPubkyIdentity.validatedRingIdentity(): SharedPubkyIdentity {
+ if (protocolVersion != SharedPubkyContract.PROTOCOL_VERSION) {
+ throw SharedPubkyError.UnsupportedVersion(protocolVersion)
+ }
+ if (sourcePackage != SharedPubkyContract.RING_SOURCE) {
+ throw SharedPubkyError.UntrustedSource(sourcePackage)
+ }
+ return copy(pubky = SharedPubkyContract.requireWirePubky(pubky))
+ }
+
+ private fun SharedPubkyIdentity.toExternalRef() = ExternalPubkyIdentityRef(
+ protocolVersion = protocolVersion,
+ sourcePackage = sourcePackage,
+ pubky = SharedPubkyContract.requireWirePubky(pubky),
+ )
+
+ private fun SharedPubkyIdentity.matches(identityRef: ExternalPubkyIdentityRef): Boolean =
+ protocolVersion == identityRef.protocolVersion &&
+ sourcePackage == identityRef.sourcePackage &&
+ SharedPubkyContract.requireWirePubky(pubky) ==
+ SharedPubkyContract.requireWirePubky(identityRef.pubky)
+
+ private fun SharedPubkyIdentity.matches(other: SharedPubkyIdentity): Boolean =
+ protocolVersion == other.protocolVersion &&
+ sourcePackage == other.sourcePackage &&
+ SharedPubkyContract.requireWirePubky(pubky) ==
+ SharedPubkyContract.requireWirePubky(other.pubky)
+
+ private fun SharedPubkyCredential.matches(identityRef: ExternalPubkyIdentityRef): Boolean =
+ identity.matches(identityRef)
+
private fun redacted(publicKey: String): String = PubkyPublicKeyFormat.redacted(publicKey)
private fun Throwable.isMissingPubkyData(): Boolean {
diff --git a/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt b/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt
new file mode 100644
index 0000000000..1df5fe6017
--- /dev/null
+++ b/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt
@@ -0,0 +1,357 @@
+package to.bitkit.repositories
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
+import org.lightningdevkit.ldknode.AddressType
+import to.bitkit.async.ServiceQueue
+import to.bitkit.data.WatchOnlyAccountAllocationState
+import to.bitkit.data.WatchOnlyAccountStore
+import to.bitkit.data.WatchOnlyAccountXpubSerializer
+import to.bitkit.di.BgDispatcher
+import to.bitkit.ext.nowMillis
+import to.bitkit.ext.runSuspendCatching
+import to.bitkit.models.PreparedWatchOnlyAccountClaim
+import to.bitkit.models.PubkyAuthClaim
+import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX
+import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE
+import to.bitkit.models.WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH
+import to.bitkit.models.WatchOnlyAccountRecord
+import to.bitkit.models.WatchOnlyAccountSetupState
+import to.bitkit.services.LightningService
+import to.bitkit.services.WatchOnlyAccountLifecycleCoordinator
+import to.bitkit.utils.AppError
+import java.net.URI
+import java.net.URLDecoder
+import java.nio.ByteBuffer
+import java.nio.charset.StandardCharsets
+import java.security.MessageDigest
+import java.util.Base64
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.ExperimentalTime
+
+sealed class WatchOnlyAccountError : AppError() {
+ data object AuthorizationAccountMissing : WatchOnlyAccountError()
+ data object InvalidAccountName : WatchOnlyAccountError()
+ data object InvalidExtendedPublicKey : WatchOnlyAccountError()
+ data object NodeUnavailable : WatchOnlyAccountError()
+}
+
+class WatchOnlyAccountAuthorizationStartError(
+ val preserveAuthorizingState: Boolean,
+ cause: Throwable,
+) : AppError(cause.message, cause.cause ?: cause)
+
+@Singleton
+@OptIn(ExperimentalTime::class)
+class WatchOnlyAccountRepo @Inject constructor(
+ @BgDispatcher private val bgDispatcher: CoroutineDispatcher,
+ private val store: WatchOnlyAccountStore,
+ private val lightningService: LightningService,
+ private val lifecycleCoordinator: WatchOnlyAccountLifecycleCoordinator,
+ private val xpubSerializer: WatchOnlyAccountXpubSerializer,
+) {
+ val accounts: Flow> = store.data.map { it.accounts }
+ val currentWalletAccounts: Flow> = accounts.map { accounts ->
+ accounts.filter { it.walletIndex == lightningService.currentWalletIndex }
+ }
+ val currentWalletAccountCount: Flow = currentWalletAccounts.map { it.size }
+
+ suspend fun prepareUnsignedClaim(authUrl: String, name: String): PreparedWatchOnlyAccountClaim =
+ withContext(bgDispatcher) {
+ lifecycleCoordinator.withLock {
+ val normalizedName = normalizeName(name)
+ val walletIndex = lightningService.currentWalletIndex
+ val fingerprint = requestFingerprint(authUrl)
+ val current = store.load()
+ current.firstOrNull {
+ it.walletIndex == walletIndex &&
+ it.requestFingerprint == fingerprint &&
+ it.setupState != WatchOnlyAccountSetupState.Active
+ }?.let { existing ->
+ val refreshed = existing.copy(name = normalizedName)
+ if (refreshed != existing) {
+ store.save(current.map { if (it.id == existing.id) refreshed else it })
+ }
+ return@withLock PreparedWatchOnlyAccountClaim(
+ account = refreshed,
+ payload = WatchOnlyAccountClaimCodec.encode(refreshed, xpubSerializer::serialize),
+ )
+ }
+
+ val accountIndex = store.reserveAccountIndex(walletIndex, fingerprint)
+ val xpub = exportAccountXpub(accountIndex)
+ val account = WatchOnlyAccountRecord(
+ id = UUID.randomUUID().toString(),
+ walletIndex = walletIndex,
+ accountIndex = accountIndex,
+ addressType = WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE,
+ xpub = xpub,
+ requestFingerprint = fingerprint,
+ createdAt = nowMillis(),
+ name = normalizedName,
+ isTrackingEnabled = false,
+ setupState = WatchOnlyAccountSetupState.PendingDelivery,
+ )
+ store.save(current + account)
+ PreparedWatchOnlyAccountClaim(
+ account = account,
+ payload = WatchOnlyAccountClaimCodec.encode(account, xpubSerializer::serialize),
+ )
+ }
+ }
+
+ suspend fun markActive(id: String) = withContext(NonCancellable) {
+ lifecycleCoordinator.withLock {
+ if (store.load().none { it.id == id }) {
+ throw WatchOnlyAccountError.AuthorizationAccountMissing
+ }
+ store.markActive(id)
+ }
+ }
+
+ suspend fun beginAuthorization(id: String) = withContext(NonCancellable) {
+ lifecycleCoordinator.withLock {
+ val account = store.load().firstOrNull {
+ it.id == id && it.setupState != WatchOnlyAccountSetupState.Active
+ } ?: throw WatchOnlyAccountError.AuthorizationAccountMissing
+ val preserveAuthorizingState = account.setupState == WatchOnlyAccountSetupState.Authorizing
+ runSuspendCatching {
+ setAccountTracking(account, enabled = true)
+ val updateResult = runSuspendCatching {
+ store.update { accounts ->
+ accounts.map {
+ if (it.id == id) {
+ it.copy(
+ isTrackingEnabled = true,
+ setupState = WatchOnlyAccountSetupState.Authorizing,
+ )
+ } else {
+ it
+ }
+ }
+ }
+ }
+ updateResult.onFailure {
+ runSuspendCatching { setAccountTracking(account, enabled = account.isTrackingEnabled) }
+ }
+ updateResult.getOrThrow()
+ }.getOrElse {
+ throw WatchOnlyAccountAuthorizationStartError(preserveAuthorizingState, it)
+ }
+ preserveAuthorizingState
+ }
+ }
+
+ suspend fun cancelAuthorization(
+ id: String,
+ preserveAuthorizingState: Boolean = false,
+ ) = withContext(NonCancellable) {
+ lifecycleCoordinator.withLock {
+ val current = store.load()
+ val account = current.firstOrNull {
+ it.id == id && it.setupState != WatchOnlyAccountSetupState.Active
+ } ?: return@withLock
+ val shouldPreserveAuthorizingState = preserveAuthorizingState &&
+ account.setupState == WatchOnlyAccountSetupState.Authorizing
+ setAccountTracking(account, enabled = shouldPreserveAuthorizingState)
+ val saveResult = runSuspendCatching {
+ store.save(
+ current.map {
+ if (it.id == id) {
+ it.copy(
+ isTrackingEnabled = shouldPreserveAuthorizingState,
+ setupState = if (shouldPreserveAuthorizingState) {
+ WatchOnlyAccountSetupState.Authorizing
+ } else {
+ WatchOnlyAccountSetupState.PendingDelivery
+ },
+ )
+ } else {
+ it
+ }
+ },
+ )
+ }
+ saveResult.onFailure {
+ runSuspendCatching {
+ setAccountTracking(account, enabled = account.isTrackingEnabled)
+ }
+ }
+ saveResult.getOrThrow()
+ }
+ }
+
+ suspend fun rename(id: String, name: String) {
+ val normalizedName = normalizeName(name)
+ updateAccount(id) { it.copy(name = normalizedName) }
+ }
+
+ suspend fun setTrackingEnabled(id: String, enabled: Boolean) = withContext(NonCancellable) {
+ lifecycleCoordinator.withLock {
+ val current = store.load()
+ val account = current.firstOrNull { it.id == id } ?: return@withLock
+ if (account.setupState != WatchOnlyAccountSetupState.Active) return@withLock
+ if (account.isTrackingEnabled == enabled) return@withLock
+
+ setAccountTracking(account, enabled)
+ val saveResult = runSuspendCatching {
+ store.save(current.map { if (it.id == id) it.copy(isTrackingEnabled = enabled) else it })
+ }
+ saveResult.onFailure {
+ runSuspendCatching { setAccountTracking(account, enabled = !enabled) }
+ }
+ saveResult.getOrThrow()
+ }
+ }
+
+ suspend fun restore(
+ accounts: List?,
+ allocationState: WatchOnlyAccountAllocationState? = null,
+ ) {
+ lifecycleCoordinator.withLock {
+ store.restore(accounts.orEmpty(), allocationState)
+ }
+ }
+
+ suspend fun clear() {
+ lifecycleCoordinator.withLock {
+ store.clear()
+ }
+ }
+
+ private suspend fun exportAccountXpub(accountIndex: Int): String = ServiceQueue.LDK.background {
+ val node = lightningService.node ?: throw WatchOnlyAccountError.NodeUnavailable
+ node.exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, accountIndex.toUInt())
+ }
+
+ private suspend fun setAccountTracking(
+ account: WatchOnlyAccountRecord,
+ enabled: Boolean,
+ ) = ServiceQueue.LDK.background {
+ val node = lightningService.node ?: throw WatchOnlyAccountError.NodeUnavailable
+ val addressType = when (account.addressType) {
+ WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE -> AddressType.NATIVE_SEGWIT
+ else -> throw WatchOnlyAccountError.InvalidExtendedPublicKey
+ }
+ val accountIndex = account.accountIndex.toUInt()
+ val isTracked = node.listOnchainWalletAccounts().any {
+ it.addressType == addressType && it.accountIndex == accountIndex
+ }
+
+ when {
+ enabled -> {
+ val wasAdded = !isTracked
+ if (wasAdded) {
+ node.addOnchainWalletAccount(addressType, accountIndex, account.xpub)
+ }
+ val trackingResult = runSuspendCatching {
+ node.onchainPayment().revealReceiveAddressesToAccount(
+ addressType,
+ accountIndex,
+ WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(),
+ )
+ if (wasAdded) {
+ node.syncWallets()
+ }
+ }
+ trackingResult.onFailure {
+ if (wasAdded) {
+ runSuspendCatching {
+ node.removeOnchainWalletAccount(addressType, accountIndex)
+ }
+ }
+ }
+ trackingResult.getOrThrow()
+ }
+ !enabled && isTracked -> node.removeOnchainWalletAccount(addressType, accountIndex)
+ }
+ }
+
+ private suspend fun updateAccount(id: String, transform: (WatchOnlyAccountRecord) -> WatchOnlyAccountRecord) {
+ lifecycleCoordinator.withLock {
+ store.update { accounts -> accounts.map { if (it.id == id) transform(it) else it } }
+ }
+ }
+
+ private fun normalizeName(name: String): String {
+ val normalized = name.trim()
+ if (normalized.isEmpty() || normalized.length > MAX_NAME_LENGTH) {
+ throw WatchOnlyAccountError.InvalidAccountName
+ }
+ return normalized
+ }
+
+ private fun requestFingerprint(authUrl: String): String {
+ val fingerprintSource = runCatching {
+ val uri = URI(authUrl)
+ val queryValues = uri.rawQuery.orEmpty()
+ .split("&")
+ .filter(String::isNotEmpty)
+ .map { it.split("=", limit = 2) }
+ .groupBy(
+ keySelector = { decodeQueryComponent(it.first()) },
+ valueTransform = { decodeQueryComponent(it.getOrElse(1) { "" }) },
+ )
+ val relay = queryValues.singleValue("relay")
+ val secret = queryValues.singleValue("secret")
+ val capabilities = queryValues.singleValue("caps")
+ val claim = queryValues.singleValue(PubkyAuthClaim.QUERY_PARAMETER)
+ listOf(
+ requireNotNull(uri.scheme).lowercase(),
+ requireNotNull(uri.host).lowercase(),
+ uri.path.orEmpty(),
+ relay,
+ secret,
+ capabilities,
+ claim,
+ ).joinToString("\u0000")
+ }.getOrDefault(authUrl)
+ return sha256(fingerprintSource.encodeToByteArray()).toBase64()
+ }
+
+ companion object {
+ private const val MAX_NAME_LENGTH = 64
+ }
+}
+
+private fun Map>.singleValue(name: String): String {
+ val values = getValue(name)
+ return values.single().also { require(it.isNotEmpty()) }
+}
+
+private fun decodeQueryComponent(value: String): String =
+ URLDecoder.decode(value, StandardCharsets.UTF_8)
+
+object WatchOnlyAccountClaimCodec {
+ const val VERSION: Byte = 1
+ const val NATIVE_SEGWIT_ADDRESS_TYPE: Byte = 0
+ const val SERIALIZED_XPUB_LENGTH = WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH
+ const val PAYLOAD_LENGTH = 1 + 4 + 1 + SERIALIZED_XPUB_LENGTH
+
+ fun encode(
+ account: WatchOnlyAccountRecord,
+ serializeXpub: (String) -> ByteArray,
+ ): ByteArray {
+ val rawXpub = if (account.addressType == WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE) {
+ runCatching { serializeXpub(account.xpub) }.getOrNull()
+ } else {
+ null
+ }
+ if (rawXpub?.size != SERIALIZED_XPUB_LENGTH) throw WatchOnlyAccountError.InvalidExtendedPublicKey
+
+ return ByteBuffer.allocate(PAYLOAD_LENGTH)
+ .put(VERSION)
+ .putInt(account.accountIndex)
+ .put(NATIVE_SEGWIT_ADDRESS_TYPE)
+ .put(rawXpub)
+ .array()
+ }
+}
+
+private fun sha256(value: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(value)
+private fun ByteArray.toBase64(): String = Base64.getEncoder().encodeToString(this)
diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt
index c240b5da0e..25a7a7d578 100644
--- a/app/src/main/java/to/bitkit/services/LightningService.kt
+++ b/app/src/main/java/to/bitkit/services/LightningService.kt
@@ -34,6 +34,7 @@ import org.lightningdevkit.ldknode.KeychainKind
import org.lightningdevkit.ldknode.Node
import org.lightningdevkit.ldknode.NodeException
import org.lightningdevkit.ldknode.NodeStatus
+import org.lightningdevkit.ldknode.OnchainWalletAccountConfig
import org.lightningdevkit.ldknode.PaymentDetails
import org.lightningdevkit.ldknode.PaymentId
import org.lightningdevkit.ldknode.PeerDetails
@@ -45,14 +46,20 @@ import org.lightningdevkit.ldknode.defaultConfig
import to.bitkit.async.BaseCoroutineScope
import to.bitkit.async.ServiceQueue
import to.bitkit.data.SettingsStore
+import to.bitkit.data.WatchOnlyAccountStore
import to.bitkit.data.backup.VssStoreIdProvider
import to.bitkit.data.keychain.Keychain
import to.bitkit.di.BgDispatcher
import to.bitkit.env.Defaults
import to.bitkit.env.Env
+import to.bitkit.ext.runSuspendCatching
import to.bitkit.ext.uByteList
import to.bitkit.ext.uri
import to.bitkit.models.OpenChannelResult
+import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX
+import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE
+import to.bitkit.models.WatchOnlyAccountRecord
+import to.bitkit.models.WatchOnlyAccountSetupState
import to.bitkit.models.msatFloorOf
import to.bitkit.models.toAddressType
import to.bitkit.utils.AppError
@@ -72,19 +79,46 @@ import org.lightningdevkit.ldknode.AddressType as LdkAddressType
typealias NodeEventHandler = suspend (Event) -> Unit
+internal fun enabledOnchainWalletAccountConfigs(
+ records: List,
+ walletIndex: Int,
+): List = records
+ .filter { it.walletIndex == walletIndex }
+ .filter {
+ it.setupState == WatchOnlyAccountSetupState.Active ||
+ it.setupState == WatchOnlyAccountSetupState.Authorizing
+ }
+ .filter { it.isTrackingEnabled }
+ .map { record ->
+ val addressType = when (record.addressType) {
+ WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE -> LdkAddressType.NATIVE_SEGWIT
+ else -> throw IllegalArgumentException("Unsupported watch-only account address type")
+ }
+ OnchainWalletAccountConfig(
+ addressType = addressType,
+ accountIndex = record.accountIndex.toUInt(),
+ xpub = record.xpub,
+ )
+ }
+
+private fun accountKey(addressType: LdkAddressType, accountIndex: UInt): String =
+ "${addressType.name}:$accountIndex"
+
data class AddressDerivationInfo(
val address: String,
val index: Int,
)
-@Suppress("LargeClass", "TooManyFunctions")
+@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
@Singleton
class LightningService @Inject constructor(
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
private val keychain: Keychain,
private val vssStoreIdProvider: VssStoreIdProvider,
private val settingsStore: SettingsStore,
+ private val watchOnlyAccountStore: WatchOnlyAccountStore,
private val loggerLdk: LoggerLdk,
+ private val watchOnlyAccountLifecycleCoordinator: WatchOnlyAccountLifecycleCoordinator,
) : BaseCoroutineScope(bgDispatcher, TAG) {
companion object {
@@ -114,6 +148,10 @@ class LightningService @Inject constructor(
@Volatile
var node: Node? = null
+ @Volatile
+ var currentWalletIndex: Int = 0
+ private set
+
private val _syncStatusChanged = MutableSharedFlow(extraBufferCapacity = 1)
val syncStatusChanged: SharedFlow = _syncStatusChanged.asSharedFlow()
@@ -131,18 +169,20 @@ class LightningService @Inject constructor(
Logger.debug("Building node…", context = TAG)
val config = config(walletIndex, trustedPeers)
- node = build(
+ val builtNode = build(
walletIndex,
customServerUrl,
customRgsServerUrl,
config,
channelMigration,
)
+ currentWalletIndex = walletIndex
+ node = builtNode
Logger.info("LDK node setup", context = TAG)
}
- private fun config(
+ private suspend fun config(
walletIndex: Int,
trustedPeers: List?,
): Config {
@@ -164,6 +204,7 @@ class LightningService @Inject constructor(
),
probingLiquidityLimitMultiplier = 1uL,
includeUntrustedPendingInSpendable = true,
+ onchainWalletAccounts = enabledOnchainWalletAccountConfigs(watchOnlyAccountStore.load(), walletIndex),
)
}
@@ -275,6 +316,8 @@ class LightningService @Inject constructor(
feeRateCacheUpdateIntervalSecs = Env.walletSyncIntervalSecs,
),
connectionTimeoutSecs = Env.walletSyncTimeoutSecs,
+ additionalWalletFullScanBatchSize = 100u,
+ additionalWalletFullScanStopGap = 1000u,
),
)
}
@@ -292,6 +335,8 @@ class LightningService @Inject constructor(
}
}
+ reconcileWatchOnlyAccountsBestEffort()
+
// start event listener after node started
onEvent?.let { eventHandler ->
shouldListenForEvents = true
@@ -314,6 +359,66 @@ class LightningService @Inject constructor(
Logger.info("Node started", context = TAG)
}
+ private suspend fun reconcileWatchOnlyAccountsBestEffort() {
+ runSuspendCatching {
+ reconcileWatchOnlyAccounts()
+ }.onFailure { error ->
+ Logger.error("Failed to reconcile Paykit Server accounts during startup", error, context = TAG)
+ }
+ }
+
+ suspend fun reconcileWatchOnlyAccounts(syncAfterReconcile: Boolean = true) {
+ watchOnlyAccountLifecycleCoordinator.withLock {
+ val node = node ?: return@withLock
+ val reconciliationState = watchOnlyAccountStore.loadReconciliationState()
+ val walletRecords = reconciliationState.accounts.filter { it.walletIndex == currentWalletIndex }
+ val accountsPendingRemoval = reconciliationState.accountsPendingRemoval.filter {
+ it.walletIndex == currentWalletIndex
+ }
+ val desiredConfigs = enabledOnchainWalletAccountConfigs(walletRecords, currentWalletIndex)
+
+ ServiceQueue.LDK.background {
+ val trackedAccounts = node.listOnchainWalletAccounts()
+ val managedKeys = (walletRecords + accountsPendingRemoval).mapNotNull { record ->
+ when (record.addressType) {
+ WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE ->
+ accountKey(LdkAddressType.NATIVE_SEGWIT, record.accountIndex.toUInt())
+ else -> null
+ }
+ }.toSet()
+ val desiredKeys = desiredConfigs.map { accountKey(it.addressType, it.accountIndex) }.toSet()
+
+ trackedAccounts.forEach { trackedAccount ->
+ val key = accountKey(trackedAccount.addressType, trackedAccount.accountIndex)
+ if (key in managedKeys && key !in desiredKeys) {
+ node.removeOnchainWalletAccount(trackedAccount.addressType, trackedAccount.accountIndex)
+ }
+ }
+
+ desiredConfigs.forEach { config ->
+ val isTracked = trackedAccounts.any {
+ it.addressType == config.addressType && it.accountIndex == config.accountIndex
+ }
+ if (!isTracked) {
+ node.addOnchainWalletAccount(config.addressType, config.accountIndex, config.xpub)
+ }
+ node.onchainPayment().revealReceiveAddressesToAccount(
+ config.addressType,
+ config.accountIndex,
+ WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(),
+ )
+ }
+
+ if (syncAfterReconcile && desiredConfigs.isNotEmpty() && node.status().isRunning) {
+ node.syncWallets()
+ }
+ }
+ if (accountsPendingRemoval.isNotEmpty()) {
+ watchOnlyAccountStore.completeReconciliation(currentWalletIndex)
+ }
+ }
+ }
+
suspend fun stop() {
shouldListenForEvents = false
listenerJob?.cancelAndJoin()
@@ -416,6 +521,8 @@ class LightningService @Inject constructor(
suspend fun sync() {
val node = this.node ?: throw ServiceError.NodeNotSetup()
+ reconcileWatchOnlyAccounts(syncAfterReconcile = false)
+
Logger.verbose("Syncing LDK…", context = TAG)
ServiceQueue.LDK.background {
node.syncWallets()
diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
index 7df973096f..837c6daee9 100644
--- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
+++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
@@ -1,6 +1,7 @@
package to.bitkit.services
import android.content.Context
+import com.synonym.bitkitcore.mnemonicToSeed
import com.synonym.paykit.ContactPaymentResolution
import com.synonym.paykit.ContactPaymentResolutionPrivateState
import com.synonym.paykit.ContactProfileResolution
@@ -26,12 +27,14 @@ import com.synonym.paykit.PaymentPayload
import com.synonym.paykit.PaymentTarget
import com.synonym.paykit.PrivatePaymentListDeliveryReport
import com.synonym.paykit.PrivatePaymentListReservationUpdateInput
+import com.synonym.paykit.PubkyAuthCompanionClaim
import com.synonym.paykit.PubkyAuthRequest
import com.synonym.paykit.PubkyLocalSecretKey
import com.synonym.paykit.PubkyProfile
import com.synonym.paykit.PubkySessionAccess
import com.synonym.paykit.PubkySessionBootstrap
import com.synonym.paykit.PubkySessionBootstrapResult
+import com.synonym.paykit.ReceiverNoiseSecretKey
import com.synonym.paykit.ReceivingDetail
import com.synonym.paykit.ReceivingDetailReservationResponse
import com.synonym.paykit.ReceivingDetailReservationResponseKind
@@ -66,7 +69,11 @@ import to.bitkit.models.PubkyPublicKeyFormat
import to.bitkit.repositories.Endpoint
import to.bitkit.repositories.PublicPaykitRepo
import to.bitkit.utils.AppError
+import to.bitkit.utils.Logger
+import java.security.MessageDigest
import java.util.UUID
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
import javax.inject.Inject
import javax.inject.Singleton
@@ -98,7 +105,7 @@ internal object PaykitReceiverPaths {
}
@Singleton
-@Suppress("TooManyFunctions")
+@Suppress("TooManyFunctions", "LargeClass")
class PaykitSdkService @Inject constructor(
@ApplicationContext private val context: Context,
private val keychain: Keychain,
@@ -128,7 +135,9 @@ class PaykitSdkService @Inject constructor(
try {
PaykitAndroid.initializeOrThrow(context)
operationMutex.withLock {
- handle().initialize()
+ val handle = handle()
+ handle.initialize()
+ publishReceiverMarkerIfLiveSessionAvailable(handle)
}
isSetup.complete(Unit)
} catch (t: Throwable) {
@@ -161,9 +170,11 @@ class PaykitSdkService @Inject constructor(
): PubkySessionBootstrapResult {
isSetup.await()
val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() }
+ val receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey()
val result = PubkySessionBootstrap().importSession(
sessionSecret = secret,
localSecretKey = if (includeLocalSecret) sessionProvider.loadLocalSecretKey() else null,
+ receiverNoiseSecretKey = receiverNoiseSecretKey,
requiredCapabilities = requiredSessionCapabilities(paykitSdkConfig()),
)
operationMutex.withLock {
@@ -184,8 +195,10 @@ class PaykitSdkService @Inject constructor(
): PubkySessionBootstrapResult {
isSetup.await()
val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() }
+ val receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey()
val result = PubkySessionBootstrap().signUp(
localSecretKey = localSecretKey(secretKeyHex),
+ receiverNoiseSecretKey = receiverNoiseSecretKey,
homeserverPublicKey = homeserverPublicKey,
signupCode = signupCode,
requiredCapabilities = requiredCapabilities(),
@@ -201,18 +214,29 @@ class PaykitSdkService @Inject constructor(
return result
}
- suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult {
+ suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult =
+ signIn(secretKeyHex = secretKeyHex, shouldStoreLocalSecret = true)
+
+ suspend fun signInExternal(secretKeyHex: String): String =
+ signIn(secretKeyHex = secretKeyHex, shouldStoreLocalSecret = false).publicKey
+
+ private suspend fun signIn(
+ secretKeyHex: String,
+ shouldStoreLocalSecret: Boolean,
+ ): PubkySessionBootstrapResult {
isSetup.await()
val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() }
+ val receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey()
val result = PubkySessionBootstrap().signIn(
localSecretKey = localSecretKey(secretKeyHex),
+ receiverNoiseSecretKey = receiverNoiseSecretKey,
requiredCapabilities = requiredCapabilities(),
)
operationMutex.withLock {
activateBootstrapResult(
result = result,
previousPublicKey = previousPublicKey,
- shouldStoreLocalSecret = true,
+ shouldStoreLocalSecret = shouldStoreLocalSecret,
)
}
notifyBackupStateChanged()
@@ -237,6 +261,7 @@ class PaykitSdkService @Inject constructor(
try {
request.complete(
localSecretKey = null,
+ receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(),
requiredCapabilities = requiredCapabilities(),
).also {
activateBootstrapResult(
@@ -270,6 +295,21 @@ class PaykitSdkService @Inject constructor(
)
}
+ suspend fun approveAuthWithCompanionClaim(
+ authUrl: String,
+ expectedCapabilities: String,
+ secretKeyHex: String,
+ claim: PubkyAuthCompanionClaim,
+ ) {
+ isSetup.await()
+ PubkySessionBootstrap().approveAuthWithCompanionClaim(
+ authUrl = authUrl,
+ expectedCapabilities = expectedCapabilities,
+ localSecretKey = localSecretKey(secretKeyHex),
+ claim = claim,
+ )
+ }
+
suspend fun fetchFile(uri: String): ByteArray {
isSetup.await()
return operationMutex.withLock {
@@ -427,15 +467,7 @@ class PaykitSdkService @Inject constructor(
return@withStateRevisionTracking
}
- val status = handle.identityStatus()
- handle.publishPaykitReceiverMarker(
- PaykitReceiverCapabilities(
- privatePayments = status?.privateLinkCapable == true,
- paymentRequests = false,
- receipts = false,
- outgoingPayments = true,
- ),
- )
+ handle.publishPaykitReceiverMarker(receiverCapabilities(handle))
}
}
}
@@ -609,10 +641,34 @@ class PaykitSdkService @Inject constructor(
}
}
+ suspend fun clearExternalSessionAccess() {
+ operationMutex.withLock {
+ val managedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
+ disableSharedPubkyExport()
+ sessionProvider.clearLiveSessionAccess()
+ keychain.delete(Keychain.Key.PAYKIT_SESSION.name)
+ keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name)
+ check(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) == null) {
+ "Failed to clear external Pubky session"
+ }
+ check(keychain.load(Keychain.Key.PAYKIT_SDK_STATE.name) == null) {
+ "Failed to clear external Pubky SDK state"
+ }
+ check(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) == managedSecretKeyHex) {
+ "Managed local Pubky secret changed during external session cleanup"
+ }
+ activeAuthRequest = null
+ resetRuntime()
+ notifyBackupStateChanged()
+ }
+ }
+
private suspend fun clearSessionAccessLocked() {
+ disableSharedPubkyExport()
sessionProvider.clearLiveSessionAccess()
keychain.delete(Keychain.Key.PAYKIT_SESSION.name)
keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name)
+ keychain.delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name)
activeAuthRequest = null
resetRuntime()
}
@@ -643,12 +699,30 @@ class PaykitSdkService @Inject constructor(
access: PubkySessionAccess,
shouldStoreLocalSecret: Boolean,
) {
+ val localSecretKeyHex = managedSecretForSessionPersistence(
+ shouldStoreLocalSecret = shouldStoreLocalSecret,
+ exportedLocalSecretKeyHex = access.exportLocalSecretKey()?.let(::secretKeyHex),
+ existingManagedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name),
+ )
+ disableSharedPubkyExport()
keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, access.exportSessionSecret())
- val localSecret = access.exportLocalSecretKey()
- if (shouldStoreLocalSecret && localSecret != null) {
- keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex(localSecret))
- } else {
- keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name)
+ sessionProvider.persistReceiverNoiseSecretKey(access.exportReceiverNoiseSecretKey())
+ if (localSecretKeyHex != null) {
+ keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, localSecretKeyHex)
+ check(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) == localSecretKeyHex) {
+ "Failed to persist managed local Pubky secret"
+ }
+ keychain.delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name)
+ check(keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == null) {
+ "Failed to release managed local Pubky secret quarantine"
+ }
+ }
+ }
+
+ private suspend fun disableSharedPubkyExport() {
+ keychain.delete(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name)
+ check(keychain.loadString(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) == null) {
+ "Failed to disable shared Pubky export"
}
}
@@ -658,12 +732,40 @@ class PaykitSdkService @Inject constructor(
shouldStoreLocalSecret: Boolean,
) {
persistSessionAccess(result.sessionAccess, shouldStoreLocalSecret)
- sessionProvider.setLiveSessionAccess(result.sessionAccess)
+ sessionProvider.setLiveSessionAccess(
+ liveSessionAccess(
+ access = result.sessionAccess,
+ retainLocalSecret = shouldStoreLocalSecret,
+ ),
+ )
if (!PubkyPublicKeyFormat.matches(previousPublicKey, result.publicKey)) {
keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name)
}
resetRuntime()
- handle().initialize()
+ val handle = handle()
+ handle.initialize()
+ publishReceiverMarkerIfLiveSessionAvailable(handle)
+ }
+
+ private suspend fun publishReceiverMarkerIfLiveSessionAvailable(handle: PaykitSdk) {
+ runSuspendCatching {
+ val capabilities = receiverCapabilities(handle)
+ if (capabilities.privatePayments) {
+ handle.publishPaykitReceiverMarker(capabilities)
+ }
+ }.onFailure {
+ Logger.warn("Failed to publish Paykit receiver marker", it, context = TAG)
+ }
+ }
+
+ private suspend fun receiverCapabilities(handle: PaykitSdk): PaykitReceiverCapabilities {
+ val status = handle.identityStatus()
+ return PaykitReceiverCapabilities(
+ privatePayments = status?.liveSessionAvailable == true,
+ paymentRequests = false,
+ receipts = false,
+ outgoingPayments = true,
+ )
}
private fun notifyBackupStateChanged() {
@@ -718,6 +820,8 @@ class PaykitSdkService @Inject constructor(
this?.capabilities?.let { it.privatePayments && it.outgoingPayments } == true
companion object {
+ private const val TAG = "PaykitSdkService"
+
fun localSecretKey(secretKeyHex: String): PubkyLocalSecretKey =
PubkyLocalSecretKey(secretKeyHex.fromHex())
@@ -789,7 +893,12 @@ private class PaykitSdkStateBlobStore(
private class PaykitSdkSessionProvider(
private val keychain: Keychain,
) : SdkPubkySessionProvider {
+ private companion object {
+ const val QUARANTINED = "1"
+ }
+
private val lock = Any()
+ private val receiverNoiseKeyStore = PaykitReceiverNoiseKeyStore(keychain)
private var liveSessionAccess: PubkySessionAccess? = null
fun setLiveSessionAccess(access: PubkySessionAccess) = synchronized(lock) {
@@ -814,6 +923,7 @@ private class PaykitSdkSessionProvider(
return PubkySessionAccess(
sessionSecret = sessionSecret,
localSecretKey = loadLocalSecretKey(),
+ receiverNoiseSecretKey = loadOrDeriveReceiverNoiseSecretKey(),
)
}
@@ -822,17 +932,178 @@ private class PaykitSdkSessionProvider(
override fun clearSessionAccess() {
clearLiveSessionAccess()
keychain.accessBlocking {
+ delete(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name)
+ check(load(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) == null) {
+ "Failed to disable shared Pubky export"
+ }
delete(Keychain.Key.PAYKIT_SESSION.name)
delete(Keychain.Key.PUBKY_SECRET_KEY.name)
+ delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name)
}
}
fun loadLocalSecretKey(): PubkyLocalSecretKey? {
+ if (keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == QUARANTINED) {
+ return null
+ }
val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
?.takeIf { it.isNotBlank() }
?: return null
return PaykitSdkService.localSecretKey(secretKeyHex)
}
+
+ fun loadOrDeriveReceiverNoiseSecretKey(): ReceiverNoiseSecretKey =
+ receiverNoiseKeyStore.loadOrDerive()
+
+ fun persistReceiverNoiseSecretKey(key: ReceiverNoiseSecretKey) {
+ receiverNoiseKeyStore.persist(key)
+ }
+}
+
+internal fun managedSecretForSessionPersistence(
+ shouldStoreLocalSecret: Boolean,
+ exportedLocalSecretKeyHex: String?,
+ existingManagedSecretKeyHex: String?,
+): String? {
+ if (shouldStoreLocalSecret) {
+ val exportedSecret = requireNotNull(exportedLocalSecretKeyHex) {
+ "Owned Pubky session did not export its local secret"
+ }
+ check(existingManagedSecretKeyHex.isNullOrBlank() || existingManagedSecretKeyHex == exportedSecret) {
+ "Refusing to replace a different managed local Pubky secret"
+ }
+ return exportedSecret
+ }
+ check(existingManagedSecretKeyHex.isNullOrBlank()) {
+ "Refusing to activate an external Pubky session over a managed local secret"
+ }
+ return null
+}
+
+private fun liveSessionAccess(
+ access: PubkySessionAccess,
+ retainLocalSecret: Boolean,
+): PubkySessionAccess {
+ if (retainLocalSecret) return access
+ return PubkySessionAccess(
+ sessionSecret = access.exportSessionSecret(),
+ localSecretKey = null,
+ receiverNoiseSecretKey = access.exportReceiverNoiseSecretKey(),
+ )
+}
+
+internal object PaykitReceiverNoiseKeyDerivation {
+ private const val DOMAIN = "bitkit/paykit/receiver-noise-key"
+ private const val VERSION = "v1"
+
+ fun deriveFromWalletSeed(
+ mnemonic: String,
+ passphrase: String?,
+ network: String,
+ receiverPath: String,
+ ): ByteArray {
+ val seed = mnemonicToSeed(mnemonic, passphrase?.takeIf { it.isNotEmpty() })
+ return try {
+ derive(seed, network, receiverPath)
+ } finally {
+ seed.fill(0)
+ }
+ }
+
+ fun derive(seed: ByteArray, network: String, receiverPath: String): ByteArray {
+ val salt = MessageDigest.getInstance("SHA-256").digest(DOMAIN.encodeToByteArray())
+ val prk = hmacSha256(key = salt, data = seed)
+ return try {
+ val info = "$VERSION\u0000$network\u0000$receiverPath".encodeToByteArray() + byteArrayOf(0x01)
+ hmacSha256(key = prk, data = info)
+ } finally {
+ prk.fill(0)
+ }
+ }
+
+ private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray {
+ return Mac.getInstance("HmacSHA256").run {
+ init(SecretKeySpec(key, "HmacSHA256"))
+ doFinal(data)
+ }
+ }
+}
+
+internal class PaykitReceiverNoiseKeyStore(
+ private val loadBytes: () -> ByteArray?,
+ private val upsertBytes: (ByteArray) -> Unit,
+ private val deriveBytes: () -> ByteArray,
+) {
+ constructor(keychain: Keychain) : this(
+ loadBytes = {
+ keychain.accessBlocking {
+ load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name)
+ }
+ },
+ upsertBytes = { bytes ->
+ keychain.accessBlocking {
+ upsert(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name, bytes)
+ }
+ },
+ deriveBytes = {
+ val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)
+ ?: throw AppError("Mnemonic not found while deriving the Paykit receiver Noise key")
+ val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)
+ PaykitReceiverNoiseKeyDerivation.deriveFromWalletSeed(
+ mnemonic = mnemonic,
+ passphrase = passphrase,
+ network = Env.network.name.lowercase(),
+ receiverPath = PaykitReceiverPaths.WALLET,
+ )
+ },
+ )
+ private var validatedBytes: ByteArray? = null
+
+ @Synchronized
+ fun loadOrDerive(): ReceiverNoiseSecretKey {
+ return ReceiverNoiseSecretKey(validatedKeyBytes().copyOf())
+ }
+
+ @Synchronized
+ fun persist(key: ReceiverNoiseSecretKey) {
+ persistBytes(key.exportBytes())
+ }
+
+ @Synchronized
+ internal fun loadOrDeriveBytes(): ByteArray {
+ return validatedKeyBytes().copyOf()
+ }
+
+ @Synchronized
+ internal fun persistBytes(bytes: ByteArray) {
+ if (!validatedKeyBytes().contentEquals(bytes)) {
+ throw AppError("Paykit receiver Noise key changed unexpectedly")
+ }
+ }
+
+ private fun validatedKeyBytes(): ByteArray {
+ validatedBytes?.let { return it }
+
+ val derivedBytes = deriveBytes()
+ checkKeyLength(derivedBytes, "Derived Paykit receiver Noise key is invalid")
+ loadBytes()?.let { storedBytes ->
+ checkKeyLength(storedBytes, "Stored Paykit receiver Noise key is invalid")
+ if (!storedBytes.contentEquals(derivedBytes)) {
+ throw AppError("Stored Paykit receiver Noise key does not match the wallet seed")
+ }
+ } ?: upsertBytes(derivedBytes.copyOf())
+
+ validatedBytes = derivedBytes.copyOf()
+ return derivedBytes
+ }
+
+ private fun checkKeyLength(bytes: ByteArray, message: String) {
+ if (bytes.size != RECEIVER_NOISE_KEY_LENGTH) throw AppError(message)
+ }
+
+ private companion object {
+ const val RECEIVER_NOISE_KEY_LENGTH = 32
+ }
}
class PaykitSdkPaymentAdapter : SdkPaymentAdapter {
diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt
index 73b215ff36..b7219bd5b4 100644
--- a/app/src/main/java/to/bitkit/services/PubkyService.kt
+++ b/app/src/main/java/to/bitkit/services/PubkyService.kt
@@ -3,6 +3,7 @@ package to.bitkit.services
import com.synonym.paykit.ContactProfileResolution
import com.synonym.paykit.ContactRecord
import com.synonym.paykit.PaykitProfile
+import com.synonym.paykit.PubkyAuthCompanionClaim
import to.bitkit.async.ServiceQueue
import to.bitkit.ext.runSuspendCatching
import to.bitkit.utils.AppError
@@ -40,6 +41,10 @@ class PubkyService @Inject constructor(
paykitSdkService.forceSignOut()
}
+ suspend fun clearExternalSessionAccess() = ServiceQueue.CORE.background {
+ paykitSdkService.clearExternalSessionAccess()
+ }
+
suspend fun clearSessionAccess() = ServiceQueue.CORE.background {
paykitSdkService.clearSessionAccess()
}
@@ -84,6 +89,10 @@ class PubkyService @Inject constructor(
Unit
}
+ suspend fun signInExternal(secretKeyHex: String): String = ServiceQueue.CORE.background {
+ paykitSdkService.signInExternal(secretKeyHex)
+ }
+
// endregion
// region Auth flow (Ring)
@@ -117,6 +126,20 @@ class PubkyService @Inject constructor(
paykitSdkService.approveAuth(authUrl, expectedCapabilities, secretKeyHex)
}
+ suspend fun approveAuthWithCompanionClaim(
+ authUrl: String,
+ expectedCapabilities: String,
+ secretKeyHex: String,
+ claim: PubkyAuthCompanionClaim,
+ ) = ServiceQueue.CORE.background {
+ paykitSdkService.approveAuthWithCompanionClaim(
+ authUrl = authUrl,
+ expectedCapabilities = expectedCapabilities,
+ secretKeyHex = secretKeyHex,
+ claim = claim,
+ )
+ }
+
// endregion
// region File operations
diff --git a/app/src/main/java/to/bitkit/services/WatchOnlyAccountLifecycleCoordinator.kt b/app/src/main/java/to/bitkit/services/WatchOnlyAccountLifecycleCoordinator.kt
new file mode 100644
index 0000000000..4b81e21453
--- /dev/null
+++ b/app/src/main/java/to/bitkit/services/WatchOnlyAccountLifecycleCoordinator.kt
@@ -0,0 +1,13 @@
+package to.bitkit.services
+
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class WatchOnlyAccountLifecycleCoordinator @Inject constructor() {
+ private val mutex = Mutex()
+
+ suspend fun withLock(block: suspend () -> T): T = mutex.withLock { block() }
+}
diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt
index 6d6615fe64..96b7a80920 100644
--- a/app/src/main/java/to/bitkit/ui/ContentView.kt
+++ b/app/src/main/java/to/bitkit/ui/ContentView.kt
@@ -160,6 +160,7 @@ import to.bitkit.ui.settings.advanced.AddressViewerScreen
import to.bitkit.ui.settings.advanced.CoinSelectPreferenceScreen
import to.bitkit.ui.settings.advanced.ElectrumConfigScreen
import to.bitkit.ui.settings.advanced.RgsServerScreen
+import to.bitkit.ui.settings.advanced.WatchOnlyAccountsScreen
import to.bitkit.ui.settings.appStatus.AppStatusScreen
import to.bitkit.ui.settings.backgroundPayments.BackgroundPaymentsIntroScreen
import to.bitkit.ui.settings.backgroundPayments.BackgroundPaymentsSettings
@@ -173,7 +174,6 @@ import to.bitkit.ui.settings.lightning.ChannelDetailScreen
import to.bitkit.ui.settings.lightning.CloseConnectionScreen
import to.bitkit.ui.settings.lightning.LightningConnectionsScreen
import to.bitkit.ui.settings.lightning.LightningConnectionsViewModel
-import to.bitkit.ui.settings.paymentPreference.PaymentPreferenceScreen
import to.bitkit.ui.settings.pin.PinManagementScreen
import to.bitkit.ui.settings.quickPay.QuickPayIntroScreen
import to.bitkit.ui.settings.quickPay.QuickPaySettingsScreen
@@ -498,7 +498,7 @@ fun ContentView(
is Sheet.BTCPayConnection -> BTCPayConnectionSheet(sheet, appViewModel)
is Sheet.Gift -> GiftSheet(sheet, appViewModel)
- Sheet.QrScanner -> QrScanningSheet(appViewModel)
+ is Sheet.QrScanner -> QrScanningSheet(sheet, appViewModel)
is Sheet.PubkyAuth -> PubkyAuthApprovalSheet(
authUrl = sheet.authUrl,
viewModel = hiltViewModel(),
@@ -1166,7 +1166,7 @@ private fun NavGraphBuilder.contacts(
onClickContact = { navController.navigateTo(Routes.ContactDetail(it)) },
onAddContact = { navController.navigateTo(Routes.AddContact(it)) },
onScanQr = {
- appViewModel.showScannerSheet { scannedData ->
+ appViewModel.showScannerSheet(isPubkyScan = true) { scannedData ->
navController.navigateTo(Routes.AddContact(scannedData))
}
},
@@ -1194,8 +1194,9 @@ private fun NavGraphBuilder.contacts(
)
}
}
- composableWithDefaultTransitions {
+ composableWithDefaultTransitions { backStackEntry ->
PaykitRouteGuard(settingsViewModel, navController) {
+ val route = backStackEntry.toRoute()
val viewModel: ContactDetailViewModel = hiltViewModel()
ContactDetailScreen(
viewModel = viewModel,
@@ -1204,6 +1205,10 @@ private fun NavGraphBuilder.contacts(
appViewModel.openContactPayment(paymentRequest, publicKey)
},
onActivityClick = { navController.navigateTo(Routes.ContactActivity(it)) },
+ showDeleteAction = route.showDeleteAction,
+ onContactDeleted = {
+ navController.navigateTo(Routes.Contacts()) { popUpTo(Routes.Home) }
+ },
onEditContact = { navController.navigateTo(Routes.EditContact(it)) },
)
}
@@ -1224,7 +1229,13 @@ private fun NavGraphBuilder.contacts(
AddContactScreen(
viewModel = viewModel,
onBackClick = { navController.popBackStack() },
- onContactSaved = { navController.popBackStack() },
+ onContactSaved = { publicKey ->
+ navController.navigateTo(
+ Routes.ContactDetail(publicKey, showDeleteAction = true)
+ ) {
+ popUpTo(Routes.AddContact(publicKey)) { inclusive = true }
+ }
+ },
onPayContact = { paymentRequest, publicKey ->
navController.popBackStack()
appViewModel.openContactPayment(paymentRequest, publicKey)
@@ -1319,9 +1330,6 @@ private fun NavGraphBuilder.profile(
onNavigateToPayContacts = {
navController.navigateTo(Routes.PayContacts) { popUpTo(Routes.Home) }
},
- onNavigateToProfile = {
- navController.navigateTo(Routes.Profile) { popUpTo(Routes.Home) }
- },
onBackClick = { navController.popBackStack() },
)
}
@@ -1430,14 +1438,6 @@ private fun NavGraphBuilder.generalSettingsSubScreens(
onBack = { navController.popBackStack() },
)
}
- composableWithDefaultTransitions {
- PaykitRouteGuard(settingsViewModel, navController) {
- PaymentPreferenceScreen(
- onBack = { navController.popBackStack() },
- )
- }
- }
-
composableWithDefaultTransitions {
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
@@ -1473,6 +1473,9 @@ private fun NavGraphBuilder.advancedSettingsSubScreens(navController: NavHostCon
composableWithDefaultTransitions {
AddressViewerScreen(navController)
}
+ composableWithDefaultTransitions {
+ WatchOnlyAccountsScreen(navController)
+ }
composableWithDefaultTransitions {
NodeInfoScreen(navController)
}
@@ -1869,8 +1872,6 @@ fun NavController.navigateToLogDetail(fileName: String) = navigateTo(Routes.LogD
fun NavController.navigateToTransactionSpeedSettings() = navigateTo(Routes.TransactionSpeedSettings)
-fun NavController.navigateToPaymentPreferenceSettings() = navigateTo(Routes.PaymentPreferenceSettings)
-
fun NavController.navigateToCustomFeeSettings() = navigateTo(Routes.CustomFeeSettings)
fun NavController.navigateToWidgetsSettings() = navigateTo(Routes.WidgetsSettings)
@@ -1907,9 +1908,6 @@ sealed interface Routes {
@Serializable
data object TransactionSpeedSettings : Routes
- @Serializable
- data object PaymentPreferenceSettings : Routes
-
@Serializable
data object WidgetsSettings : Routes
@@ -1934,6 +1932,9 @@ sealed interface Routes {
@Serializable
data object AddressViewer : Routes
+ @Serializable
+ data object WatchOnlyAccounts : Routes
+
@Serializable
data object CustomFeeSettings : Routes
@@ -2123,7 +2124,10 @@ sealed interface Routes {
data object ContactsIntro : Routes
@Serializable
- data class ContactDetail(val publicKey: String) : Routes
+ data class ContactDetail(
+ val publicKey: String,
+ val showDeleteAction: Boolean = false,
+ ) : Routes
@Serializable
data class ContactActivity(val publicKey: String) : Routes
diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt
index 0161dd331e..4b1da48f93 100644
--- a/app/src/main/java/to/bitkit/ui/MainActivity.kt
+++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt
@@ -228,6 +228,11 @@ class MainActivity : FragmentActivity() {
handleLaunchIntent(intent)
}
+ override fun onResume() {
+ super.onResume()
+ appViewModel.onAppResumed()
+ }
+
private fun handleLaunchIntent(intent: Intent) {
if (intent.action == UsbManager.ACTION_USB_DEVICE_ATTACHED) {
handleUsbAttachIntent(intent)
diff --git a/app/src/main/java/to/bitkit/ui/components/AddLinkSheet.kt b/app/src/main/java/to/bitkit/ui/components/AddLinkSheet.kt
index ebc29c18c4..b73a9bffcd 100644
--- a/app/src/main/java/to/bitkit/ui/components/AddLinkSheet.kt
+++ b/app/src/main/java/to/bitkit/ui/components/AddLinkSheet.kt
@@ -105,7 +105,7 @@ private fun LinkFormContent(
) {
Column(
modifier = Modifier
- .sheetHeight(isModal = true)
+ .sheetHeight(SheetSize.COMPACT, isModal = true)
.gradientBackground()
.navigationBarsPadding()
.padding(horizontal = 16.dp),
@@ -162,7 +162,7 @@ internal fun SuggestionsContent(
) {
Column(
modifier = Modifier
- .sheetHeight(isModal = true)
+ .sheetHeight(SheetSize.COMPACT, isModal = true)
.gradientBackground()
.navigationBarsPadding()
.padding(horizontal = 16.dp),
diff --git a/app/src/main/java/to/bitkit/ui/components/AddTagSheet.kt b/app/src/main/java/to/bitkit/ui/components/AddTagSheet.kt
index 20a05a27e9..cf782138b7 100644
--- a/app/src/main/java/to/bitkit/ui/components/AddTagSheet.kt
+++ b/app/src/main/java/to/bitkit/ui/components/AddTagSheet.kt
@@ -95,7 +95,7 @@ private fun TagFormContent(
) {
Column(
modifier = Modifier
- .sheetHeight(isModal = true)
+ .sheetHeight(SheetSize.COMPACT, isModal = true)
.gradientBackground()
.navigationBarsPadding()
.padding(horizontal = 16.dp),
diff --git a/app/src/main/java/to/bitkit/ui/components/Button.kt b/app/src/main/java/to/bitkit/ui/components/Button.kt
index 6887ed3a32..99950de605 100644
--- a/app/src/main/java/to/bitkit/ui/components/Button.kt
+++ b/app/src/main/java/to/bitkit/ui/components/Button.kt
@@ -33,7 +33,9 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.HazeTint
@@ -101,6 +103,8 @@ fun PrimaryButton(
fullWidth: Boolean = true,
color: Color? = null,
enableGradient: Boolean = true,
+ contentColor: Color = Colors.White,
+ letterSpacing: TextUnit = 0.4.sp,
) {
val contentPadding = PaddingValues(horizontal = size.primaryHorizontalPadding.takeIf { text != null } ?: 0.dp)
val buttonShape = MaterialTheme.shapes.extraLarge
@@ -110,7 +114,8 @@ fun PrimaryButton(
enabled = enabled && !isLoading,
colors = AppButtonDefaults.primaryColors.copy(
containerColor = Color.Transparent,
- disabledContainerColor = Color.Transparent
+ disabledContainerColor = Color.Transparent,
+ contentColor = contentColor,
),
contentPadding = contentPadding,
shape = buttonShape,
@@ -152,6 +157,7 @@ fun PrimaryButton(
text?.let {
Text(
text = text,
+ style = MaterialTheme.typography.labelLarge.copy(letterSpacing = letterSpacing),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -172,6 +178,7 @@ fun SecondaryButton(
enabled: Boolean = true,
fullWidth: Boolean = true,
hazeState: HazeState? = null,
+ letterSpacing: TextUnit = 0.4.sp,
) {
val contentPadding = PaddingValues(horizontal = size.secondaryHorizontalPadding.takeIf { text != null } ?: 0.dp)
val border = size.secondaryBorder(enabled)
@@ -236,6 +243,7 @@ fun SecondaryButton(
text?.let {
Text(
text = text,
+ style = MaterialTheme.typography.labelLarge.copy(letterSpacing = letterSpacing),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
diff --git a/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt b/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt
index 93956881a2..4b86c8b1df 100644
--- a/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt
+++ b/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt
@@ -17,12 +17,10 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import to.bitkit.R
-import to.bitkit.ext.ellipsisMiddle
+import to.bitkit.models.PubkyPublicKeyFormat
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.theme.Colors
-private const val TRUNCATED_PK_LENGTH = 11
-
@Composable
fun CenteredProfileHeader(
publicKey: String,
@@ -38,7 +36,7 @@ fun CenteredProfileHeader(
modifier = modifier
) {
Text13Up(
- text = publicKey.ellipsisMiddle(TRUNCATED_PK_LENGTH),
+ text = PubkyPublicKeyFormat.display(publicKey),
color = Colors.White64,
textAlign = TextAlign.Center,
)
@@ -46,12 +44,12 @@ fun CenteredProfileHeader(
VerticalSpacer(16.dp)
if (imageUrl != null) {
- PubkyImage(uri = imageUrl, size = 100.dp)
+ PubkyImage(uri = imageUrl, size = 96.dp)
} else {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
- .size(100.dp)
+ .size(96.dp)
.clip(CircleShape)
.background(Colors.Gray5)
) {
diff --git a/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt
index 84f76d96fc..e84f9268d3 100644
--- a/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt
+++ b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt
@@ -1,5 +1,6 @@
package to.bitkit.ui.components
+import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -23,6 +24,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
@@ -76,87 +79,81 @@ fun ProfileEditForm(
val keyboardController = LocalSoftwareKeyboardController.current
Column(
- horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxSize()
.imePadding()
- .verticalScroll(rememberScrollState())
- .padding(horizontal = 32.dp)
) {
- VerticalSpacer(16.dp)
- avatarContent()
- VerticalSpacer(12.dp)
-
- TextInput(
- value = name,
- onValueChange = onNameChange,
- placeholder = stringResource(R.string.profile__edit_name_placeholder),
- singleLine = true,
- textStyle = AppTextStyles.Display.copy(textAlign = TextAlign.Center),
- colors = AppTextFieldDefaults.transparent,
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
- .fillMaxWidth()
- .testTag("ProfileEditName")
- )
- HorizontalDivider()
- VerticalSpacer(12.dp)
+ .weight(1f)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp)
+ ) {
+ VerticalSpacer(16.dp)
+ avatarContent()
+ VerticalSpacer(12.dp)
- Text13Up(
- text = resolvedPublicKeyLabel,
- color = Colors.White64,
- )
- VerticalSpacer(4.dp)
- BodyS(
- text = publicKey,
- textAlign = TextAlign.Center,
- modifier = Modifier.fillMaxWidth()
- )
- HorizontalDivider(modifier = Modifier.padding(top = 12.dp))
+ TextInput(
+ value = name,
+ onValueChange = onNameChange,
+ placeholder = stringResource(R.string.profile__edit_name_placeholder),
+ singleLine = true,
+ textStyle = AppTextStyles.Display.copy(textAlign = TextAlign.Center),
+ colors = AppTextFieldDefaults.transparent,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("ProfileEditName")
+ )
+ HorizontalDivider()
+ VerticalSpacer(12.dp)
- VerticalSpacer(16.dp)
- Text13Up(
- text = stringResource(R.string.profile__edit_bio),
- color = Colors.White64,
- modifier = Modifier.fillMaxWidth()
- )
- VerticalSpacer(8.dp)
- TextInput(
- value = bio,
- onValueChange = { onBioChange(it.take(BIO_MAX_LENGTH)) },
- placeholder = resolvedBioPlaceholder,
- minLines = 2,
- maxLines = 4,
- modifier = Modifier
- .fillMaxWidth()
- .testTag("ProfileEditBio")
- )
+ Text13Up(
+ text = resolvedPublicKeyLabel,
+ color = Colors.White64,
+ )
+ VerticalSpacer(4.dp)
+ BodyMSB(
+ text = publicKey,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+ HorizontalDivider(modifier = Modifier.padding(top = 12.dp))
- VerticalSpacer(16.dp)
- links.forEachIndexed { index, link ->
- HorizontalDivider(color = Colors.White10)
- VerticalSpacer(8.dp)
+ VerticalSpacer(16.dp)
Text13Up(
- text = link.label,
+ text = stringResource(R.string.profile__edit_bio),
color = Colors.White64,
modifier = Modifier.fillMaxWidth()
)
VerticalSpacer(8.dp)
TextInput(
- value = link.url,
- onValueChange = { onLinkUrlChange(index, it) },
- placeholder = stringResource(R.string.profile__add_link_url_placeholder),
- singleLine = true,
- trailingIcon = {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- Icon(
- painter = painterResource(R.drawable.ic_pencil_simple),
- contentDescription = null,
- tint = Colors.White64,
- modifier = Modifier.size(16.dp)
- )
+ value = bio,
+ onValueChange = { onBioChange(it.take(BIO_MAX_LENGTH)) },
+ placeholder = resolvedBioPlaceholder,
+ minLines = 2,
+ maxLines = 4,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("ProfileEditBio")
+ )
+
+ VerticalSpacer(16.dp)
+ links.forEachIndexed { index, link ->
+ HorizontalDivider(color = Colors.White10)
+ VerticalSpacer(8.dp)
+ Text13Up(
+ text = link.label,
+ color = Colors.White64,
+ modifier = Modifier.fillMaxWidth()
+ )
+ VerticalSpacer(8.dp)
+ TextInput(
+ value = link.url,
+ onValueChange = { onLinkUrlChange(index, it) },
+ placeholder = stringResource(R.string.profile__add_link_url_placeholder),
+ singleLine = true,
+ trailingIcon = {
IconButton(
onClick = { onRemoveLink(index) },
modifier = Modifier.testTag("ProfileEditLinkRemove_$index")
@@ -168,128 +165,138 @@ fun ProfileEditForm(
modifier = Modifier.size(16.dp)
)
}
- }
- },
- modifier = Modifier
- .fillMaxWidth()
- .border(
- width = 1.dp,
- color = Colors.White10,
- shape = AppShapes.small,
- )
- .testTag("ProfileEditLink_$index")
- )
- VerticalSpacer(8.dp)
- }
- Row(modifier = Modifier.fillMaxWidth()) {
- PrimaryButton(
- text = stringResource(R.string.profile__add_link),
- onClick = {
- focusManager.clearFocus(force = true)
- keyboardController?.hide()
- onAddLink()
- },
- size = ButtonSize.Small,
- fullWidth = false,
- icon = {
- Icon(
- painter = painterResource(R.drawable.ic_link),
- contentDescription = null,
- modifier = Modifier.size(16.dp)
- )
- },
- modifier = Modifier.testTag("ProfileEditAddLink")
- )
- }
-
- VerticalSpacer(16.dp)
- Text13Up(
- text = stringResource(R.string.profile__edit_tags),
- color = Colors.White64,
- modifier = Modifier.fillMaxWidth()
- )
- VerticalSpacer(8.dp)
- FlowRow(
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.fillMaxWidth()
- ) {
- tags.forEachIndexed { index, tag ->
- TagButton(
- text = tag,
- onClick = { onRemoveTag(index) },
- displayIconClose = true,
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .border(
+ width = 1.dp,
+ color = Colors.White10,
+ shape = AppShapes.small,
+ )
+ .testTag("ProfileEditLink_$index")
+ )
+ VerticalSpacer(8.dp)
+ }
+ Row(modifier = Modifier.fillMaxWidth()) {
+ PrimaryButton(
+ text = stringResource(R.string.profile__add_link),
+ onClick = {
+ focusManager.clearFocus(force = true)
+ keyboardController?.hide()
+ onAddLink()
+ },
+ size = ButtonSize.Small,
+ fullWidth = false,
+ icon = {
+ Icon(
+ painter = painterResource(R.drawable.ic_link),
+ contentDescription = null,
+ modifier = Modifier.size(16.dp)
+ )
+ },
+ modifier = Modifier.testTag("ProfileEditAddLink")
)
}
- }
- VerticalSpacer(8.dp)
- Row(modifier = Modifier.fillMaxWidth()) {
- PrimaryButton(
- text = stringResource(R.string.profile__add_tag),
- onClick = {
- focusManager.clearFocus(force = true)
- keyboardController?.hide()
- onAddTag()
- },
- size = ButtonSize.Small,
- fullWidth = false,
- icon = {
- Icon(
- painter = painterResource(R.drawable.ic_tag),
- contentDescription = null,
- modifier = Modifier.size(16.dp)
- )
- },
- modifier = Modifier.testTag("ProfileEditAddTag")
- )
- }
- VerticalSpacer(16.dp)
- if (showFooterNote) {
- HorizontalDivider(color = Colors.White10)
VerticalSpacer(16.dp)
- BodyS(
- text = resolvedFooterNote,
+ Text13Up(
+ text = stringResource(R.string.profile__edit_tags),
color = Colors.White64,
+ modifier = Modifier.fillMaxWidth()
)
- }
+ VerticalSpacer(8.dp)
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ tags.forEachIndexed { index, tag ->
+ TagButton(
+ text = tag,
+ onClick = { onRemoveTag(index) },
+ displayIconClose = true,
+ )
+ }
+ }
+ VerticalSpacer(8.dp)
+ Row(modifier = Modifier.fillMaxWidth()) {
+ PrimaryButton(
+ text = stringResource(R.string.profile__add_tag),
+ onClick = {
+ focusManager.clearFocus(force = true)
+ keyboardController?.hide()
+ onAddTag()
+ },
+ size = ButtonSize.Small,
+ fullWidth = false,
+ icon = {
+ Icon(
+ painter = painterResource(R.drawable.ic_tag),
+ contentDescription = null,
+ modifier = Modifier.size(16.dp)
+ )
+ },
+ modifier = Modifier.testTag("ProfileEditAddTag")
+ )
+ }
- if (onDelete != null) {
- Column {
- VerticalSpacer(16.dp)
- HorizontalDivider()
+ VerticalSpacer(16.dp)
+ if (showFooterNote) {
+ HorizontalDivider(color = Colors.White10)
VerticalSpacer(16.dp)
- Text13Up(
- text = stringResource(R.string.profile__edit_delete_section),
+ BodyS(
+ text = resolvedFooterNote,
color = Colors.White64,
- modifier = Modifier.fillMaxWidth()
)
- VerticalSpacer(8.dp)
- Row(modifier = Modifier.fillMaxWidth()) {
- PrimaryButton(
- text = deleteLabel,
- onClick = onDelete,
- size = ButtonSize.Small,
- fullWidth = false,
- icon = {
- Icon(
- painter = painterResource(R.drawable.ic_trash),
- contentDescription = null,
- tint = Colors.Red,
- modifier = Modifier.size(16.dp)
- )
- },
- modifier = Modifier.testTag("ProfileEditDelete")
+ }
+
+ if (onDelete != null) {
+ Column {
+ VerticalSpacer(16.dp)
+ HorizontalDivider()
+ VerticalSpacer(16.dp)
+ Text13Up(
+ text = stringResource(R.string.profile__edit_delete_section),
+ color = Colors.White64,
+ modifier = Modifier.fillMaxWidth()
)
+ VerticalSpacer(8.dp)
+ Row(modifier = Modifier.fillMaxWidth()) {
+ PrimaryButton(
+ text = deleteLabel,
+ onClick = onDelete,
+ size = ButtonSize.Small,
+ fullWidth = false,
+ color = Colors.White10,
+ enableGradient = false,
+ contentColor = Colors.Brand,
+ icon = {
+ Icon(
+ painter = painterResource(R.drawable.ic_trash),
+ contentDescription = null,
+ tint = Colors.Brand,
+ modifier = Modifier.size(16.dp)
+ )
+ },
+ modifier = Modifier.testTag("ProfileEditDelete")
+ )
+ }
}
}
+
+ VerticalSpacer(32.dp)
}
- FillHeight()
- VerticalSpacer(16.dp)
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(Color.Transparent, Color.Black),
+ )
+ )
+ .padding(start = 16.dp, top = 32.dp, end = 16.dp, bottom = 16.dp)
) {
SecondaryButton(
text = stringResource(R.string.common__cancel),
@@ -307,7 +314,6 @@ fun ProfileEditForm(
.testTag("ProfileEditSave")
)
}
- VerticalSpacer(16.dp)
}
}
diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt
index 4f46aa5b59..d1b6c8d835 100644
--- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt
+++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt
@@ -41,7 +41,7 @@ import to.bitkit.ui.sheets.hardware.HardwareRoute
import to.bitkit.ui.theme.AppShapes
import to.bitkit.ui.theme.Colors
-enum class SheetSize { LARGE, MEDIUM, SMALL, CALENDAR; }
+enum class SheetSize { LARGE, MEDIUM, COMPACT, SMALL, CALENDAR; }
val DefaultSheetContainerColor = Color(0xFF141414) // Equivalent to White08 on a Black background
@@ -71,7 +71,7 @@ sealed interface Sheet {
val isConnecting: Boolean = false,
val errorText: String? = null,
) : Sheet
- data object QrScanner : Sheet
+ data class QrScanner(val isPubkyScan: Boolean = false) : Sheet
data class PubkyAuth(val authUrl: String) : Sheet
data class TimedSheet(val type: TimedSheetType) : Sheet
diff --git a/app/src/main/java/to/bitkit/ui/components/Tag.kt b/app/src/main/java/to/bitkit/ui/components/Tag.kt
index 47c1cfa217..f69178b0fe 100644
--- a/app/src/main/java/to/bitkit/ui/components/Tag.kt
+++ b/app/src/main/java/to/bitkit/ui/components/Tag.kt
@@ -15,6 +15,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -29,17 +31,23 @@ fun TagButton(
text: String,
onClick: (() -> Unit)?,
modifier: Modifier = Modifier,
+ accessibilityLabel: String? = null,
isSelected: Boolean = false,
displayIconClose: Boolean = false,
icon: Painter = painterResource(R.drawable.ic_x),
) {
val borderColor = if (isSelected) Colors.Brand else Colors.White16
val textColor = if (isSelected) Colors.Brand else MaterialTheme.colorScheme.onSurface
+ val accessibilityModifier = accessibilityLabel?.let { label ->
+ Modifier.semantics(mergeDescendants = true) { contentDescription = label }
+ } ?: Modifier
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier
+ .testTag("Tag-$text")
+ .then(accessibilityModifier)
.wrapContentWidth()
.border(width = 1.dp, color = borderColor, shape = AppShapes.small)
.clickableAlpha(onClick = onClick)
@@ -50,7 +58,7 @@ fun TagButton(
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
- modifier = Modifier.testTag("Tag-$text")
+ modifier = Modifier
)
if (displayIconClose) {
diff --git a/app/src/main/java/to/bitkit/ui/components/Text.kt b/app/src/main/java/to/bitkit/ui/components/Text.kt
index dbfcbe5403..b5dbbd4e93 100644
--- a/app/src/main/java/to/bitkit/ui/components/Text.kt
+++ b/app/src/main/java/to/bitkit/ui/components/Text.kt
@@ -84,11 +84,13 @@ fun Headline(
text: AnnotatedString,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.primary,
+ textAlign: TextAlign = TextAlign.Start,
) {
Text(
text = text.toUpperCase(),
style = AppTextStyles.Headline.merge(
color = color,
+ textAlign = textAlign,
),
modifier = modifier
)
@@ -180,6 +182,7 @@ fun BodyM(
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
overflow: TextOverflow = if (maxLines == 1) TextOverflow.Ellipsis else TextOverflow.Clip,
+ letterSpacing: TextUnit = 0.4.sp,
) {
BodyM(
text = AnnotatedString(text),
@@ -189,6 +192,7 @@ fun BodyM(
maxLines = maxLines,
minLines = minLines,
overflow = overflow,
+ letterSpacing = letterSpacing,
)
}
@@ -201,12 +205,14 @@ fun BodyM(
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
overflow: TextOverflow = if (maxLines == 1) TextOverflow.Ellipsis else TextOverflow.Clip,
+ letterSpacing: TextUnit = 0.4.sp,
) {
Text(
text = text,
style = AppTextStyles.BodyM.merge(
color = color,
textAlign = textAlign,
+ letterSpacing = letterSpacing,
),
maxLines = maxLines,
minLines = minLines,
@@ -223,6 +229,7 @@ fun BodyMSB(
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
textAlign: TextAlign = TextAlign.Start,
+ letterSpacing: TextUnit = 0.4.sp,
) {
BodyMSB(
text = AnnotatedString(text),
@@ -231,6 +238,7 @@ fun BodyMSB(
overflow = overflow,
modifier = modifier,
textAlign = textAlign,
+ letterSpacing = letterSpacing,
)
}
@@ -242,12 +250,14 @@ fun BodyMSB(
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
textAlign: TextAlign = TextAlign.Start,
+ letterSpacing: TextUnit = 0.4.sp,
) {
Text(
text = text,
style = AppTextStyles.BodyMSB.merge(
color = color,
textAlign = textAlign,
+ letterSpacing = letterSpacing,
),
maxLines = maxLines,
overflow = overflow,
diff --git a/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt b/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt
index 343168d7bb..80023d0e27 100644
--- a/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt
+++ b/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Switch
@@ -17,6 +18,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -24,7 +27,8 @@ import to.bitkit.R
import to.bitkit.ui.components.BodyM
import to.bitkit.ui.components.BodyS
import to.bitkit.ui.components.HorizontalSpacer
-import to.bitkit.ui.shared.modifiers.clickableAlpha
+import to.bitkit.ui.shared.modifiers.alphaFeedback
+import to.bitkit.ui.shared.modifiers.rememberDebouncedClick
import to.bitkit.ui.theme.AppSwitchDefaults
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.theme.Colors
@@ -40,7 +44,7 @@ fun SettingsSwitchRow(
iconRes: Int? = null,
iconTint: Color = Color.Unspecified,
switchTestTag: String? = null,
- colors: SwitchColors = AppSwitchDefaults.colors
+ colors: SwitchColors = AppSwitchDefaults.colors,
) {
SettingsSwitchRowCore(
title = title,
@@ -77,7 +81,7 @@ fun SettingsSwitchRow(
enabled: Boolean = true,
subtitle: String? = null,
switchTestTag: String? = null,
- colors: SwitchColors = AppSwitchDefaults.colors
+ colors: SwitchColors = AppSwitchDefaults.colors,
) {
SettingsSwitchRowCore(
title = title,
@@ -105,16 +109,23 @@ private fun SettingsSwitchRowCore(
subtitle: String? = null,
icon: (@Composable () -> Unit)? = null,
switchTestTag: String? = null,
- colors: SwitchColors = AppSwitchDefaults.colors
+ colors: SwitchColors = AppSwitchDefaults.colors,
) {
+ val debouncedOnClick = rememberDebouncedClick(onClick = onClick)
Column(modifier = modifier) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
+ modifier = (switchTestTag?.let { Modifier.testTag(it) } ?: Modifier)
.fillMaxWidth()
.heightIn(min = 52.dp)
- .clickableAlpha(enabled = enabled) { onClick() }
+ .alphaFeedback(enabled = enabled)
+ .toggleable(
+ value = isChecked,
+ enabled = enabled,
+ role = Role.Switch,
+ onValueChange = { debouncedOnClick() },
+ )
) {
if (icon != null) {
icon()
@@ -137,7 +148,7 @@ private fun SettingsSwitchRowCore(
onCheckedChange = null, // handled by parent
enabled = enabled,
colors = colors,
- modifier = switchTestTag?.let { Modifier.testTag(it) } ?: Modifier
+ modifier = Modifier.clearAndSetSemantics { }
)
}
HorizontalDivider(color = Colors.White10)
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt
index 28f5b9340d..63ae007ec6 100644
--- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
@@ -52,7 +53,6 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.collections.immutable.ImmutableList
import to.bitkit.R
-import to.bitkit.ext.ellipsisMiddle
import to.bitkit.ext.getClipboardText
import to.bitkit.models.PubkyProfile
import to.bitkit.models.PubkyProfileLink
@@ -60,11 +60,13 @@ import to.bitkit.models.PubkyPublicKeyFormat
import to.bitkit.ui.components.BodyM
import to.bitkit.ui.components.BodyS
import to.bitkit.ui.components.BottomSheet
+import to.bitkit.ui.components.BottomSheetPreview
import to.bitkit.ui.components.CenteredProfileHeader
import to.bitkit.ui.components.Display
import to.bitkit.ui.components.FillHeight
import to.bitkit.ui.components.PrimaryButton
import to.bitkit.ui.components.SecondaryButton
+import to.bitkit.ui.components.SheetSize
import to.bitkit.ui.components.Text13Up
import to.bitkit.ui.components.TextInput
import to.bitkit.ui.components.VerticalSpacer
@@ -72,9 +74,10 @@ import to.bitkit.ui.scaffold.AppTopBar
import to.bitkit.ui.scaffold.DrawerNavIcon
import to.bitkit.ui.scaffold.ScreenColumn
import to.bitkit.ui.scaffold.SheetTopBar
+import to.bitkit.ui.shared.modifiers.sheetHeight
+import to.bitkit.ui.shared.util.gradientBackground
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.theme.Colors
-import to.bitkit.ui.utils.withAccent
// region AddContactSheet (bottom sheet)
@@ -144,7 +147,13 @@ private fun AddContactSheetContent(
onScanQr: () -> Unit,
onSubmit: () -> Unit,
) {
- Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ Column(
+ modifier = Modifier
+ .sheetHeight(SheetSize.SMALL, isModal = true)
+ .gradientBackground()
+ .navigationBarsPadding()
+ .padding(horizontal = 16.dp)
+ ) {
SheetTopBar(titleText = stringResource(R.string.contacts__add_sheet_title))
VerticalSpacer(16.dp)
@@ -160,7 +169,8 @@ private fun AddContactSheetContent(
value = publicKeyInput,
onValueChange = onPublicKeyChange,
placeholder = stringResource(R.string.contacts__add_pubky_placeholder),
- singleLine = true,
+ minLines = 2,
+ maxLines = 2,
isError = validationMessage != null,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
@@ -197,6 +207,13 @@ private fun AddContactSheetContent(
SecondaryButton(
text = stringResource(R.string.contacts__add_scan_qr),
onClick = onScanQr,
+ icon = {
+ Icon(
+ painter = painterResource(R.drawable.ic_scan),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp)
+ )
+ },
modifier = Modifier
.weight(1f)
.testTag("AddContactScanQR")
@@ -222,7 +239,7 @@ private fun AddContactSheetContent(
fun AddContactScreen(
viewModel: AddContactViewModel,
onBackClick: () -> Unit,
- onContactSaved: () -> Unit,
+ onContactSaved: (String) -> Unit,
onPayContact: (String, String) -> Unit,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -230,7 +247,7 @@ fun AddContactScreen(
LaunchedEffect(Unit) {
viewModel.effects.collect {
when (it) {
- AddContactEffect.ContactSaved -> onContactSaved()
+ is AddContactEffect.ContactSaved -> onContactSaved(it.publicKey)
is AddContactEffect.OpenPayment -> onPayContact(it.paymentRequest, it.publicKey)
}
}
@@ -273,14 +290,12 @@ private fun Content(
isLoading = uiState.isLoading,
hasPublicPaymentEndpoint = uiState.hasPublicPaymentEndpoint,
onPay = onPay,
- onDiscard = onBackClick,
onSave = onSave,
)
}
}
}
-private const val TRUNCATED_PK_LENGTH = 11
private const val ELLIPSE_ANIMATION_DURATION_MS = 8000
@Composable
@@ -294,7 +309,7 @@ private fun LoadingContent(publicKey: String) {
VerticalSpacer(24.dp)
Text13Up(
- text = publicKey.ellipsisMiddle(TRUNCATED_PK_LENGTH),
+ text = PubkyPublicKeyFormat.display(publicKey),
color = Colors.White64,
)
@@ -303,7 +318,7 @@ private fun LoadingContent(publicKey: String) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
- .size(80.dp)
+ .size(96.dp)
.clip(CircleShape)
.background(Colors.Gray5)
) {
@@ -313,11 +328,12 @@ private fun LoadingContent(publicKey: String) {
)
}
- VerticalSpacer(24.dp)
+ VerticalSpacer(16.dp)
Display(
- text = stringResource(R.string.contacts__add_retrieving)
- .withAccent(accentColor = Colors.PubkyGreen),
+ text = stringResource(R.string.contacts__add_retrieving),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
)
Box(
@@ -447,14 +463,13 @@ private fun LoadedContent(
isLoading: Boolean,
hasPublicPaymentEndpoint: Boolean,
onPay: () -> Unit,
- onDiscard: () -> Unit,
onSave: () -> Unit,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
- .padding(horizontal = 32.dp)
+ .padding(horizontal = 16.dp)
) {
VerticalSpacer(24.dp)
@@ -474,32 +489,32 @@ private fun LoadedContent(
VerticalSpacer(16.dp)
if (hasPublicPaymentEndpoint) {
- SecondaryButton(
- text = stringResource(R.string.wallet__send),
- onClick = onPay,
- modifier = Modifier.testTag("AddContactPay")
- )
- VerticalSpacer(16.dp)
- }
-
- Row(
- horizontalArrangement = Arrangement.spacedBy(16.dp),
- modifier = Modifier.fillMaxWidth()
- ) {
- SecondaryButton(
- text = stringResource(R.string.contacts__add_discard),
- onClick = onDiscard,
- modifier = Modifier
- .weight(1f)
- .testTag("AddContactDiscard")
- )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ SecondaryButton(
+ text = stringResource(R.string.contacts__add_pay),
+ onClick = onPay,
+ modifier = Modifier
+ .weight(1f)
+ .testTag("AddContactPay")
+ )
+ PrimaryButton(
+ text = stringResource(R.string.common__save),
+ onClick = onSave,
+ enabled = !isLoading,
+ modifier = Modifier
+ .weight(1f)
+ .testTag("AddContactSave")
+ )
+ }
+ } else {
PrimaryButton(
text = stringResource(R.string.common__save),
onClick = onSave,
enabled = !isLoading,
- modifier = Modifier
- .weight(1f)
- .testTag("AddContactSave")
+ modifier = Modifier.testTag("AddContactSave")
)
}
VerticalSpacer(16.dp)
@@ -514,15 +529,17 @@ private fun LoadedContent(
@Composable
private fun SheetPreview() {
AppThemeSurface {
- AddContactSheetContent(
- publicKeyInput = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg",
- validationMessage = null,
- isSubmitEnabled = true,
- onPublicKeyChange = {},
- onPaste = {},
- onScanQr = {},
- onSubmit = {},
- )
+ BottomSheetPreview {
+ AddContactSheetContent(
+ publicKeyInput = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg",
+ validationMessage = null,
+ isSubmitEnabled = true,
+ onPublicKeyChange = {},
+ onPaste = {},
+ onScanQr = {},
+ onSubmit = {},
+ )
+ }
}
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt
index 1d35947bd6..93fd35c11c 100644
--- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt
@@ -154,12 +154,7 @@ class AddContactViewModel @Inject constructor(
_uiState.update { it.copy(isLoading = true) }
pubkyRepo.addContact(profile.publicKey, profile)
.onSuccess {
- ToastEventBus.send(
- type = Toast.ToastType.SUCCESS,
- title = context.getString(R.string.contacts__add_contact_saved),
- testTag = "ContactSavedToast",
- )
- _effects.emit(AddContactEffect.ContactSaved)
+ _effects.emit(AddContactEffect.ContactSaved(profile.publicKey))
}
.onFailure {
Logger.error("Failed to save contact", it, context = TAG)
@@ -184,6 +179,6 @@ data class AddContactUiState(
)
sealed interface AddContactEffect {
- data object ContactSaved : AddContactEffect
+ data class ContactSaved(val publicKey: String) : AddContactEffect
data class OpenPayment(val paymentRequest: String, val publicKey: String) : AddContactEffect
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt
index 80d8c507f5..0dc27d1067 100644
--- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt
@@ -58,7 +58,7 @@ private fun Content(
) {
ScreenColumn {
AppTopBar(
- titleText = uiState.profile?.name ?: stringResource(R.string.wallet__activity),
+ titleText = stringResource(R.string.wallet__activity),
onBackClick = onBackClick,
actions = { DrawerNavIcon() },
)
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt
index 08caea71a5..7fb3da656b 100644
--- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -39,6 +40,7 @@ import to.bitkit.ui.components.SecondaryButton
import to.bitkit.ui.components.TagButton
import to.bitkit.ui.components.Text13Up
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.scaffold.ScreenColumn
@@ -52,6 +54,8 @@ fun ContactDetailScreen(
onBackClick: () -> Unit,
onPayContact: (String, String) -> Unit,
onActivityClick: (String) -> Unit,
+ showDeleteAction: Boolean = false,
+ onContactDeleted: () -> Unit = {},
onEditContact: (String) -> Unit = {},
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -61,6 +65,7 @@ fun ContactDetailScreen(
viewModel.effects.collect {
when (it) {
is ContactDetailEffect.OpenPayment -> onPayContact(it.paymentRequest, it.publicKey)
+ ContactDetailEffect.ContactDeleted -> onContactDeleted()
}
}
}
@@ -69,6 +74,8 @@ fun ContactDetailScreen(
uiState = uiState,
onBackClick = onBackClick,
onClickEdit = { uiState.profile?.publicKey?.let { onEditContact(it) } },
+ showDeleteAction = showDeleteAction,
+ onClickDelete = { viewModel.showDeleteConfirmation() },
onClickCopy = { viewModel.copyPublicKey() },
onClickPay = { viewModel.payContact() },
onClickActivity = { uiState.profile?.publicKey?.let { onActivityClick(it) } },
@@ -78,6 +85,8 @@ fun ContactDetailScreen(
onRemoveTag = { viewModel.removeTag(it) },
onDismissAddTagSheet = { viewModel.dismissAddTagSheet() },
onSaveTag = { viewModel.addTag(it) },
+ onDismissDeleteDialog = { viewModel.dismissDeleteConfirmation() },
+ onConfirmDelete = { viewModel.deleteContact() },
)
}
@@ -86,15 +95,19 @@ private fun Content(
uiState: ContactDetailUiState,
onBackClick: () -> Unit,
onClickEdit: () -> Unit,
+ showDeleteAction: Boolean,
+ onClickDelete: () -> Unit,
onClickCopy: () -> Unit,
onClickPay: () -> Unit,
onClickActivity: () -> Unit,
onClickShare: () -> Unit,
onClickRetry: () -> Unit,
onAddTag: () -> Unit,
- onRemoveTag: (Int) -> Unit,
+ onRemoveTag: (String) -> Unit,
onDismissAddTagSheet: () -> Unit,
onSaveTag: (String) -> Unit,
+ onDismissDeleteDialog: () -> Unit,
+ onConfirmDelete: () -> Unit,
) {
val currentProfile = uiState.profile
@@ -111,7 +124,9 @@ private fun Content(
profile = currentProfile,
tags = uiState.tags,
showPayButton = uiState.showPayButton,
+ showDeleteAction = showDeleteAction,
onClickEdit = onClickEdit,
+ onClickDelete = onClickDelete,
onClickCopy = onClickCopy,
onClickPay = onClickPay,
onClickActivity = onClickActivity,
@@ -129,6 +144,16 @@ private fun Content(
onSave = onSaveTag,
)
}
+
+ if (uiState.showDeleteDialog && currentProfile != null) {
+ AppAlertDialog(
+ title = stringResource(R.string.contacts__delete_confirm_title, currentProfile.name),
+ text = stringResource(R.string.contacts__delete_confirm_text, currentProfile.name),
+ confirmText = stringResource(R.string.common__delete_yes),
+ onConfirm = onConfirmDelete,
+ onDismiss = onDismissDeleteDialog,
+ )
+ }
}
@OptIn(ExperimentalLayoutApi::class)
@@ -137,20 +162,22 @@ private fun ContactBody(
profile: PubkyProfile,
tags: ImmutableList,
showPayButton: Boolean,
+ showDeleteAction: Boolean,
onClickEdit: () -> Unit,
+ onClickDelete: () -> Unit,
onClickCopy: () -> Unit,
onClickPay: () -> Unit,
onClickActivity: () -> Unit,
onClickShare: () -> Unit,
onAddTag: () -> Unit,
- onRemoveTag: (Int) -> Unit,
+ onRemoveTag: (String) -> Unit,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
- .padding(horizontal = 32.dp)
+ .padding(horizontal = 16.dp)
) {
VerticalSpacer(24.dp)
@@ -163,7 +190,13 @@ private fun ContactBody(
notesTestTag = "ContactViewNotes",
)
- VerticalSpacer(24.dp)
+ if (showDeleteAction) {
+ VerticalSpacer(16.dp)
+ HorizontalDivider(color = Colors.White10)
+ VerticalSpacer(16.dp)
+ } else {
+ VerticalSpacer(24.dp)
+ }
FlowRow(
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
@@ -192,14 +225,24 @@ private fun ContactBody(
iconRes = R.drawable.ic_share,
modifier = Modifier.testTag("ContactShare")
)
- ActionButton(
- onClick = onClickEdit,
- iconRes = R.drawable.ic_edit,
- modifier = Modifier.testTag("ContactEdit")
- )
+ if (showDeleteAction) {
+ ActionButton(
+ onClick = onClickDelete,
+ iconRes = R.drawable.ic_trash,
+ modifier = Modifier.testTag("ContactDelete")
+ )
+ } else {
+ ActionButton(
+ onClick = onClickEdit,
+ iconRes = R.drawable.ic_edit,
+ modifier = Modifier.testTag("ContactEdit")
+ )
+ }
}
- VerticalSpacer(32.dp)
+ VerticalSpacer(16.dp)
+ HorizontalDivider(color = Colors.White10)
+ VerticalSpacer(16.dp)
profile.links.forEachIndexed { index, link ->
LinkRow(label = link.label, value = link.url, linkIndex = index)
@@ -219,10 +262,11 @@ private fun ContactBody(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
- tags.forEachIndexed { index, tag ->
+ tags.forEach { tag ->
TagButton(
text = tag,
- onClick = { onRemoveTag(index) },
+ onClick = { onRemoveTag(tag) },
+ accessibilityLabel = stringResource(R.string.common__remove_tag, tag),
displayIconClose = true,
)
}
@@ -292,6 +336,8 @@ private fun Preview() {
),
onBackClick = {},
onClickEdit = {},
+ showDeleteAction = true,
+ onClickDelete = {},
onClickCopy = {},
onClickPay = {},
onClickActivity = {},
@@ -301,6 +347,8 @@ private fun Preview() {
onRemoveTag = {},
onDismissAddTagSheet = {},
onSaveTag = {},
+ onDismissDeleteDialog = {},
+ onConfirmDelete = {},
)
}
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt
index dd15c57f51..495d8bf73f 100644
--- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt
@@ -17,6 +17,8 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import to.bitkit.R
import to.bitkit.ext.setClipboardText
import to.bitkit.models.PubkyProfile
@@ -47,6 +49,7 @@ class ContactDetailViewModel @Inject constructor(
) { "publicKey not found in SavedStateHandle" }
private val redactedPublicKey = PubkyPublicKeyFormat.redacted(publicKey)
+ private val tagPersistenceMutex = Mutex()
private val _uiState = MutableStateFlow(ContactDetailUiState())
val uiState: StateFlow = _uiState.asStateFlow()
@@ -156,30 +159,80 @@ class ContactDetailViewModel @Inject constructor(
_uiState.update { it.copy(showAddTagSheet = false) }
}
+ fun showDeleteConfirmation() {
+ _uiState.update { it.copy(showDeleteDialog = true) }
+ }
+
+ fun dismissDeleteConfirmation() {
+ _uiState.update { it.copy(showDeleteDialog = false) }
+ }
+
+ fun deleteContact() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(showDeleteDialog = false, isLoading = true) }
+ pubkyRepo.removeContact(publicKey)
+ .onSuccess {
+ ToastEventBus.send(
+ type = Toast.ToastType.SUCCESS,
+ title = context.getString(R.string.contacts__delete_success),
+ testTag = "ContactDeletedToast",
+ )
+ _effects.emit(ContactDetailEffect.ContactDeleted)
+ }
+ .onFailure {
+ Logger.error("Failed to delete contact '$redactedPublicKey'", it, context = TAG)
+ _uiState.update { state -> state.copy(isLoading = false) }
+ }
+ }
+ }
+
fun addTag(tag: String) {
- val newTags = (_uiState.value.tags + tag).distinct().toImmutableList()
- _uiState.update { it.copy(tags = newTags, showAddTagSheet = false) }
- persistTags(newTags)
+ updateTags(
+ transform = { (it + tag).distinct().toImmutableList() },
+ onSuccess = { _uiState.update { it.copy(showAddTagSheet = false) } },
+ )
}
- fun removeTag(index: Int) {
- val newTags = _uiState.value.tags.filterIndexed { i, _ -> i != index }.toImmutableList()
- _uiState.update { it.copy(tags = newTags) }
- persistTags(newTags)
+ fun removeTag(tag: String) {
+ updateTags(transform = { tags -> tags.filterNot { it == tag }.toImmutableList() })
}
- private fun persistTags(tags: List) {
- val profile = _uiState.value.profile ?: return
+ private fun updateTags(
+ transform: (ImmutableList) -> ImmutableList,
+ onSuccess: () -> Unit = {},
+ ) {
viewModelScope.launch {
- pubkyRepo.updateContact(
- publicKey = publicKey,
- name = profile.name,
- bio = profile.bio,
- imageUrl = profile.imageUrl,
- links = profile.links.map { PubkyProfileLink(it.label, it.url) },
- tags = tags,
- ).onFailure {
- Logger.error("Failed to update tags for contact '$redactedPublicKey'", it, context = TAG)
+ tagPersistenceMutex.withLock {
+ val state = _uiState.value
+ val profile = state.profile ?: return@withLock
+ val tags = transform(state.tags)
+ if (tags == state.tags) {
+ onSuccess()
+ return@withLock
+ }
+ pubkyRepo.updateContact(
+ publicKey = publicKey,
+ name = profile.name,
+ bio = profile.bio,
+ imageUrl = profile.imageUrl,
+ links = profile.links.map { PubkyProfileLink(it.label, it.url) },
+ tags = tags,
+ ).onSuccess {
+ _uiState.update {
+ it.copy(
+ profile = it.profile?.copy(tags = tags),
+ tags = tags,
+ )
+ }
+ onSuccess()
+ }.onFailure {
+ Logger.error("Failed to update tags for contact '$redactedPublicKey'", it, context = TAG)
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.contacts__edit_save_error),
+ description = it.message,
+ )
+ }
}
}
}
@@ -192,8 +245,10 @@ data class ContactDetailUiState(
val isLoading: Boolean = false,
val showPayButton: Boolean = false,
val showAddTagSheet: Boolean = false,
+ val showDeleteDialog: Boolean = false,
)
sealed interface ContactDetailEffect {
data class OpenPayment(val paymentRequest: String, val publicKey: String) : ContactDetailEffect
+ data object ContactDeleted : ContactDetailEffect
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt
index de39614c23..32ff74c815 100644
--- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt
@@ -2,25 +2,34 @@ package to.bitkit.ui.screens.contacts
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
+import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -32,6 +41,7 @@ import to.bitkit.ui.components.BodyMSB
import to.bitkit.ui.components.BodySSB
import to.bitkit.ui.components.Display
import to.bitkit.ui.components.FillHeight
+import to.bitkit.ui.components.Headline
import to.bitkit.ui.components.HorizontalSpacer
import to.bitkit.ui.components.PrimaryButton
import to.bitkit.ui.components.PubkyImage
@@ -43,6 +53,19 @@ import to.bitkit.ui.scaffold.ScreenColumn
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.theme.Colors
import to.bitkit.ui.utils.withAccent
+import to.bitkit.ui.utils.withAccentBoldBright
+
+/** Figma's opaque muted fill for avatar fallbacks. */
+private val AvatarMutedColor = Color(0xFF303034)
+
+/** Figma's base background fill for the overflow avatar. */
+private val AvatarOverflowBackgroundColor = Color(0xFF05050A)
+
+/** Figma's muted foreground stroke for the overflow avatar. */
+private val AvatarOverflowBorderColor = Color(0xFF89898F)
+
+/** Figma's shadow color for avatar separation. */
+private val AvatarShadowColor = Color(0x4005050A)
@Composable
fun ContactImportOverviewScreen(
@@ -91,33 +114,38 @@ private fun Content(
titleText = stringResource(R.string.contacts__import_title),
onBackClick = onBackClick,
actions = { DrawerNavIcon() },
+ modifier = Modifier
+ .height(48.dp)
+ .offset(y = (-2).dp),
)
Column(
modifier = Modifier
.fillMaxSize()
- .padding(horizontal = 32.dp)
+ .padding(horizontal = 16.dp)
) {
- VerticalSpacer(24.dp)
+ VerticalSpacer(10.dp)
Display(
text = stringResource(R.string.contacts__import_overview_headline)
.withAccent(accentColor = Colors.PubkyGreen),
)
- VerticalSpacer(8.dp)
+ VerticalSpacer(4.dp)
val truncatedKey = uiState.profile?.truncatedPublicKey.orEmpty()
BodyM(
- text = stringResource(R.string.contacts__import_overview_subtitle, truncatedKey),
+ text = stringResource(R.string.contacts__import_overview_subtitle, truncatedKey)
+ .withAccentBoldBright(),
color = Colors.White64,
+ letterSpacing = 0.sp,
)
VerticalSpacer(32.dp)
if (uiState.profile != null) {
ProfileRow(profile = uiState.profile)
- VerticalSpacer(24.dp)
+ VerticalSpacer(31.dp)
}
if (uiState.contacts.isNotEmpty()) {
@@ -134,15 +162,17 @@ private fun Content(
text = stringResource(R.string.contacts__import_select),
onClick = onClickSelect,
modifier = Modifier.weight(1f),
+ letterSpacing = 0.sp,
)
PrimaryButton(
text = stringResource(R.string.contacts__import_all),
onClick = onClickImportAll,
isLoading = uiState.isImporting,
modifier = Modifier.weight(1f),
+ letterSpacing = 0.sp,
)
}
- VerticalSpacer(16.dp)
+ VerticalSpacer(10.dp)
}
}
}
@@ -153,8 +183,8 @@ private fun ProfileRow(profile: PubkyProfile) {
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
- Display(
- text = profile.name,
+ Headline(
+ text = AnnotatedString(profile.name),
modifier = Modifier.weight(1f),
)
@@ -188,6 +218,7 @@ private fun ContactCountRow(contacts: ImmutableList) {
) {
BodyMSB(
text = stringResource(R.string.contacts__import_friends_count, contacts.size),
+ letterSpacing = 0.sp,
)
AvatarStack(contacts = contacts)
@@ -196,26 +227,35 @@ private fun ContactCountRow(contacts: ImmutableList) {
@Composable
private fun AvatarStack(contacts: ImmutableList) {
- val visibleCount = minOf(contacts.size, 4)
+ val visibleCount = minOf(contacts.size, 5)
val overflow = contacts.size - visibleCount
+ val itemCount = visibleCount + if (overflow > 0) 1 else 0
+ val stackWidth = 32 + ((itemCount - 1).coerceAtLeast(0) * 24)
- Box {
+ Box(
+ modifier = Modifier.size(
+ width = stackWidth.dp,
+ height = 32.dp,
+ )
+ ) {
contacts.take(visibleCount).forEachIndexed { index, contact ->
Box(modifier = Modifier.offset(x = (index * 24).dp)) {
if (contact.imageUrl != null) {
- PubkyImage(uri = contact.imageUrl, size = 36.dp)
+ PubkyImage(
+ uri = contact.imageUrl,
+ size = 32.dp,
+ modifier = Modifier.avatarShadow()
+ )
} else {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
- .size(36.dp)
+ .size(32.dp)
+ .avatarShadow()
.clip(CircleShape)
- .background(Colors.White10)
+ .background(AvatarMutedColor)
) {
- BodySSB(
- text = contact.name.firstOrNull()?.uppercase().orEmpty(),
- color = Colors.White,
- )
+ AvatarLabel(text = contact.name.firstOrNull()?.uppercase().orEmpty())
}
}
}
@@ -226,16 +266,38 @@ private fun AvatarStack(contacts: ImmutableList) {
contentAlignment = Alignment.Center,
modifier = Modifier
.offset(x = (visibleCount * 24).dp)
- .size(36.dp)
+ .size(32.dp)
+ .avatarShadow()
.clip(CircleShape)
- .background(Colors.Gray4)
+ .background(AvatarOverflowBackgroundColor)
+ .border(1.dp, AvatarOverflowBorderColor, CircleShape)
) {
- BodySSB(text = "+$overflow", color = Colors.White)
+ AvatarLabel(text = "+$overflow")
}
}
}
}
+private fun Modifier.avatarShadow() = shadow(
+ elevation = 2.dp,
+ shape = CircleShape,
+ ambientColor = AvatarShadowColor,
+ spotColor = AvatarShadowColor,
+)
+
+@Composable
+private fun AvatarLabel(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ color = Colors.White,
+ fontWeight = FontWeight.Medium,
+ lineHeight = 20.sp,
+ letterSpacing = 0.sp,
+ ),
+ )
+}
+
@Preview(showSystemUi = true)
@Composable
private fun Preview() {
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt
index 4b596613e0..5d047216c5 100644
--- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -31,13 +32,13 @@ 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.BodyS
import to.bitkit.ui.components.BodySSB
import to.bitkit.ui.components.Display
import to.bitkit.ui.components.HorizontalSpacer
import to.bitkit.ui.components.PrimaryButton
import to.bitkit.ui.components.PubkyImage
import to.bitkit.ui.components.TagButton
+import to.bitkit.ui.components.Text13Up
import to.bitkit.ui.components.VerticalSpacer
import to.bitkit.ui.scaffold.AppTopBar
import to.bitkit.ui.scaffold.DrawerNavIcon
@@ -101,7 +102,7 @@ private fun Content(
Column(
modifier = Modifier
.fillMaxSize()
- .padding(horizontal = 32.dp)
+ .padding(horizontal = 16.dp)
) {
VerticalSpacer(24.dp)
@@ -125,10 +126,16 @@ private fun Content(
.fillMaxWidth()
) {
items(uiState.contacts, key = { it.profile.publicKey }) { contact ->
- SelectableContactRow(
- contact = contact,
- onToggle = { onToggleContact(contact.profile.publicKey) },
- )
+ Column {
+ HorizontalDivider(color = Colors.White10)
+ SelectableContactRow(
+ contact = contact,
+ onToggle = { onToggleContact(contact.profile.publicKey) },
+ )
+ }
+ }
+ if (uiState.contacts.isNotEmpty()) {
+ item { HorizontalDivider(color = Colors.White10) }
}
}
@@ -163,7 +170,7 @@ private fun SelectableContactRow(
modifier = Modifier
.fillMaxWidth()
.clickableAlpha(onClick = onToggle)
- .padding(vertical = 12.dp)
+ .padding(vertical = 24.dp)
) {
ContactAvatar(profile = contact.profile)
@@ -173,7 +180,7 @@ private fun SelectableContactRow(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.weight(1f)
) {
- BodyS(
+ Text13Up(
text = contact.profile.truncatedPublicKey,
color = Colors.White64,
maxLines = 1,
@@ -236,14 +243,14 @@ private fun FooterBar(
HorizontalSpacer(16.dp)
- val allSelected = selectedCount == totalCount
TagButton(
- text = if (allSelected) {
- stringResource(R.string.contacts__import_select_none)
- } else {
- stringResource(R.string.contacts__import_select_all)
- },
- onClick = if (allSelected) onSelectNone else onSelectAll,
+ text = stringResource(R.string.contacts__import_select_all),
+ onClick = onSelectAll.takeIf { selectedCount < totalCount },
+ )
+ HorizontalSpacer(8.dp)
+ TagButton(
+ text = stringResource(R.string.contacts__import_select_none),
+ onClick = onSelectNone.takeIf { selectedCount > 0 },
)
}
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt
index e8793f10ab..86b186375c 100644
--- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt
@@ -31,9 +31,7 @@ import to.bitkit.R
import to.bitkit.models.PubkyProfile
import to.bitkit.ui.components.ActionButton
import to.bitkit.ui.components.BodyM
-import to.bitkit.ui.components.BodyS
-import to.bitkit.ui.components.BodySSB
-import to.bitkit.ui.components.FillHeight
+import to.bitkit.ui.components.BodyMSB
import to.bitkit.ui.components.GradientCircularProgressIndicator
import to.bitkit.ui.components.HorizontalSpacer
import to.bitkit.ui.components.PrimaryButton
@@ -97,6 +95,7 @@ private fun Content(
)
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ VerticalSpacer(16.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
@@ -113,7 +112,7 @@ private fun Content(
modifier = Modifier.testTag("ContactsAddButton")
)
}
- VerticalSpacer(8.dp)
+ VerticalSpacer(16.dp)
}
when {
@@ -167,12 +166,12 @@ private fun ContactsList(
color = Colors.White64,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)
)
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
ContactRow(
profile = myProfile,
onClick = onClickMyProfile,
modifier = Modifier.testTag("ContactsMyProfile")
)
- HorizontalDivider()
}
}
@@ -183,7 +182,7 @@ private fun ContactsList(
color = Colors.White64,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)
)
- HorizontalDivider()
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
}
items(contacts, key = { it.publicKey }) { contact ->
@@ -192,7 +191,7 @@ private fun ContactsList(
onClick = { onClickContact(contact.publicKey) },
modifier = Modifier.testTag("Contact_${contact.publicKey}")
)
- HorizontalDivider()
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
}
}
}
@@ -210,7 +209,7 @@ private fun ContactRow(
modifier = modifier
.fillMaxWidth()
.clickableAlpha(onClick = onClick)
- .padding(horizontal = 16.dp, vertical = 12.dp)
+ .padding(horizontal = 16.dp, vertical = 24.dp)
) {
PubkyContactAvatar(profile = profile)
@@ -218,13 +217,13 @@ private fun ContactRow(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f)
) {
- BodyS(
+ Text13Up(
text = profile.truncatedPublicKey,
color = Colors.White64,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
- BodySSB(
+ BodyMSB(
text = profile.name,
color = Colors.White,
maxLines = 1,
@@ -251,45 +250,44 @@ private fun EmptyState(
onAddContact: () -> Unit,
) {
Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(horizontal = 16.dp)
+ modifier = Modifier.fillMaxSize()
) {
myProfile?.let {
- VerticalSpacer(16.dp)
Text13Up(
text = stringResource(R.string.contacts__my_profile),
color = Colors.White64,
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)
)
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
ContactRow(
profile = it,
onClick = onClickMyProfile,
modifier = Modifier.testTag("ContactsMyProfile")
)
- HorizontalDivider()
}
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(16.dp),
+ Text13Up(
+ text = stringResource(R.string.contacts__contacts_header),
+ color = Colors.White64,
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 16.dp)
- .padding(top = 48.dp)
+ .padding(horizontal = 16.dp, vertical = 16.dp)
+ )
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)
) {
+ BodyM(
+ text = stringResource(R.string.contacts__intro_description),
+ color = Colors.White64,
+ )
PrimaryButton(
text = stringResource(R.string.contacts__intro_add_contact),
onClick = onAddContact,
modifier = Modifier.testTag("ContactsEmptyAddButton")
)
- BodyM(
- text = stringResource(R.string.contacts__empty_state),
- color = Colors.White64,
- )
}
-
- FillHeight()
}
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactScreen.kt
index d47d70cbe0..70820c97d2 100644
--- a/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactScreen.kt
@@ -1,15 +1,20 @@
package to.bitkit.ui.screens.contacts
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+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
@@ -19,10 +24,10 @@ import to.bitkit.R
import to.bitkit.ui.components.AddLinkSheet
import to.bitkit.ui.components.AddTagSheet
import to.bitkit.ui.components.BodyM
-import to.bitkit.ui.components.CenteredProfileHeader
import to.bitkit.ui.components.GradientCircularProgressIndicator
import to.bitkit.ui.components.ProfileEditForm
import to.bitkit.ui.components.ProfileEditLink
+import to.bitkit.ui.components.PubkyImage
import to.bitkit.ui.components.SecondaryButton
import to.bitkit.ui.components.VerticalSpacer
import to.bitkit.ui.scaffold.AppAlertDialog
@@ -119,12 +124,7 @@ private fun Content(
onCancel = onBackClick,
isSaveEnabled = uiState.name.isNotBlank() && !uiState.isSaving,
avatarContent = {
- CenteredProfileHeader(
- publicKey = uiState.publicKey,
- name = "",
- bio = "",
- imageUrl = uiState.imageUrl,
- )
+ ContactEditAvatar(imageUrl = uiState.imageUrl)
},
publicKeyLabel = stringResource(R.string.contacts__pubky),
bioPlaceholder = stringResource(R.string.contacts__edit_bio_placeholder),
@@ -160,6 +160,29 @@ private fun Content(
}
}
+@Composable
+private fun ContactEditAvatar(imageUrl: String?) {
+ if (imageUrl != null) {
+ PubkyImage(uri = imageUrl, size = 96.dp)
+ return
+ }
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(96.dp)
+ .clip(CircleShape)
+ .background(Colors.Gray5)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_user_square),
+ contentDescription = null,
+ tint = Colors.White32,
+ modifier = Modifier.size(48.dp)
+ )
+ }
+}
+
@Composable
private fun LoadingState() {
Box(
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt
index dcfb015e64..a1721cb827 100644
--- a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt
@@ -35,7 +35,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import to.bitkit.R
import to.bitkit.ui.components.BodyM
-import to.bitkit.ui.components.BodyS
+import to.bitkit.ui.components.BodyMSB
import to.bitkit.ui.components.FillHeight
import to.bitkit.ui.components.GradientCircularProgressIndicator
import to.bitkit.ui.components.PrimaryButton
@@ -43,6 +43,7 @@ import to.bitkit.ui.components.Text13Up
import to.bitkit.ui.components.TextInput
import to.bitkit.ui.components.VerticalSpacer
import to.bitkit.ui.scaffold.AppTopBar
+import to.bitkit.ui.scaffold.DrawerNavIcon
import to.bitkit.ui.scaffold.ScreenColumn
import to.bitkit.ui.theme.AppTextFieldDefaults
import to.bitkit.ui.theme.AppTextStyles
@@ -108,6 +109,7 @@ private fun Content(
AppTopBar(
titleText = stringResource(navTitleRes),
onBackClick = onBackClick,
+ actions = { DrawerNavIcon() },
)
if (uiState.isLoading) {
@@ -128,7 +130,7 @@ private fun Content(
modifier = Modifier.testTag("CreateProfileAvatar"),
)
- VerticalSpacer(24.dp)
+ VerticalSpacer(32.dp)
TextInput(
value = uiState.name,
@@ -151,7 +153,7 @@ private fun Content(
color = Colors.White64,
)
VerticalSpacer(8.dp)
- BodyS(
+ BodyMSB(
text = uiState.derivedPublicKey ?: "...",
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
@@ -182,7 +184,7 @@ private fun AvatarPickerButton(
Box(
contentAlignment = Alignment.Center,
modifier = modifier
- .size(100.dp)
+ .size(96.dp)
.clip(CircleShape)
.background(Colors.Gray5)
.clickable(onClick = onClick),
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt
index ea4e412f40..0d0c5a5e69 100644
--- a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt
@@ -37,6 +37,7 @@ import to.bitkit.ui.components.ProfileEditLink
import to.bitkit.ui.components.PubkyImage
import to.bitkit.ui.scaffold.AppAlertDialog
import to.bitkit.ui.scaffold.AppTopBar
+import to.bitkit.ui.scaffold.DrawerNavIcon
import to.bitkit.ui.scaffold.ScreenColumn
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.theme.Colors
@@ -131,6 +132,7 @@ private fun Content(
AppTopBar(
titleText = stringResource(R.string.profile__edit_nav_title),
onBackClick = onBackClick,
+ actions = { DrawerNavIcon() },
)
if (uiState.isLoading) {
@@ -220,7 +222,7 @@ private fun AvatarSection(
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
- .size(100.dp)
+ .size(96.dp)
.clip(CircleShape)
.background(Colors.Gray5)
.testTag("EditProfileAvatar")
@@ -233,7 +235,7 @@ private fun AvatarSection(
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
- imageUrl != null -> PubkyImage(uri = imageUrl, size = 100.dp)
+ imageUrl != null -> PubkyImage(uri = imageUrl, size = 96.dp)
else -> Icon(
painter = painterResource(R.drawable.ic_user_square),
contentDescription = null,
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt
index b7bb3709f7..ee6baa0de5 100644
--- a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt
@@ -2,15 +2,11 @@ package to.bitkit.ui.screens.profile
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Switch
-import androidx.compose.material3.SwitchDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
@@ -21,7 +17,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import to.bitkit.R
import to.bitkit.ui.components.BodyM
import to.bitkit.ui.components.Display
-import to.bitkit.ui.components.HorizontalSpacer
import to.bitkit.ui.components.PrimaryButton
import to.bitkit.ui.components.VerticalSpacer
import to.bitkit.ui.scaffold.AppTopBar
@@ -49,7 +44,6 @@ fun PayContactsScreen(
Content(
uiState = uiState,
- onPaymentSharingChange = { viewModel.setPaymentSharingEnabled(it) },
onContinue = { viewModel.continueToProfile() },
onBackClick = onBackClick,
)
@@ -58,7 +52,6 @@ fun PayContactsScreen(
@Composable
private fun Content(
uiState: PayContactsUiState,
- onPaymentSharingChange: (Boolean) -> Unit,
onContinue: () -> Unit,
onBackClick: () -> Unit,
) {
@@ -90,33 +83,6 @@ private fun Content(
text = stringResource(R.string.profile__pay_contacts_description),
color = Colors.White64,
)
- VerticalSpacer(24.dp)
-
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth()
- ) {
- BodyM(
- text = stringResource(R.string.profile__pay_contacts_toggle),
- color = Colors.White,
- modifier = Modifier.weight(1f)
- )
- HorizontalSpacer(16.dp)
- Switch(
- checked = uiState.isPaymentSharingEnabled,
- onCheckedChange = if (uiState.isLoading) null else onPaymentSharingChange,
- colors = SwitchDefaults.colors(
- checkedThumbColor = Colors.White,
- checkedTrackColor = Colors.PubkyGreen,
- checkedBorderColor = Colors.PubkyGreen,
- uncheckedThumbColor = Colors.White,
- uncheckedTrackColor = Colors.Gray4,
- uncheckedBorderColor = Colors.Gray4,
- ),
- modifier = Modifier.testTag("PayContactsToggle")
- )
- }
-
VerticalSpacer(32.dp)
PrimaryButton(
text = stringResource(R.string.common__continue),
@@ -135,7 +101,6 @@ private fun Preview() {
AppThemeSurface {
Content(
uiState = PayContactsUiState(),
- onPaymentSharingChange = {},
onContinue = {},
onBackClick = {},
)
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt
index 5ed92e43cf..7b9edc0b3f 100644
--- a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt
@@ -11,243 +11,53 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import to.bitkit.R
-import to.bitkit.data.SettingsData
-import to.bitkit.data.SettingsStore
-import to.bitkit.ext.runSuspendCatching
import to.bitkit.models.Toast
-import to.bitkit.repositories.PrivatePaykitRepo
-import to.bitkit.repositories.PubkyRepo
+import to.bitkit.repositories.ContactPaymentSettingsRepo
import to.bitkit.repositories.PublicPaykitError
-import to.bitkit.repositories.PublicPaykitRepo
import to.bitkit.ui.shared.toast.ToastEventBus
+import to.bitkit.utils.Logger
import javax.inject.Inject
@HiltViewModel
class PayContactsViewModel @Inject constructor(
@ApplicationContext private val context: Context,
- private val settingsStore: SettingsStore,
- private val publicPaykitRepo: PublicPaykitRepo,
- private val privatePaykitRepo: PrivatePaykitRepo,
- private val pubkyRepo: PubkyRepo,
+ private val contactPaymentSettingsRepo: ContactPaymentSettingsRepo,
) : ViewModel() {
+ companion object {
+ private const val TAG = "PayContactsViewModel"
+ }
+
private val _uiState = MutableStateFlow(PayContactsUiState())
val uiState: StateFlow = _uiState.asStateFlow()
private val _effects = MutableSharedFlow(extraBufferCapacity = 1)
val effects = _effects.asSharedFlow()
- init {
- viewModelScope.launch {
- val settings = settingsStore.data.first()
- val hasLocalSecretKey = pubkyRepo.hasSecretKey()
- _uiState.update {
- it.copy(
- isPaymentSharingEnabled = resolvedSharingDefault(settings, hasLocalSecretKey),
- )
- }
- }
- }
-
- fun setPaymentSharingEnabled(isEnabled: Boolean) {
- _uiState.update { it.copy(isPaymentSharingEnabled = isEnabled) }
- }
-
fun continueToProfile() {
viewModelScope.launch {
- val shouldPublish = _uiState.value.isPaymentSharingEnabled
- val contacts = pubkyRepo.contacts.value.map { it.publicKey }
_uiState.update { it.copy(isLoading = true) }
-
- val result = if (shouldPublish) {
- enableContactPayments(contacts)
- } else {
- disableContactPayments(contacts)
- }
-
- result
- .onSuccess {
- _uiState.update { it.copy(isLoading = false) }
- _effects.emit(PayContactsEffect.Continue)
- }
- .onFailure {
- val settings = settingsStore.data.first()
- val persistedValue = resolvedSharingDefault(settings, pubkyRepo.hasSecretKey())
- ToastEventBus.send(
- type = Toast.ToastType.ERROR,
- title = context.getString(R.string.common__error),
- description = syncErrorMessage(it),
- )
- _uiState.update {
- it.copy(
- isLoading = false,
- isPaymentSharingEnabled = persistedValue,
+ try {
+ contactPaymentSettingsRepo.setEnabled(true)
+ .onSuccess {
+ _effects.emit(PayContactsEffect.Continue)
+ }
+ .onFailure {
+ Logger.error("Failed to enable contact payments", it, context = TAG)
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.common__error),
+ description = syncErrorMessage(it),
)
}
- }
- }
- }
-
- private suspend fun enableContactPayments(contacts: List): Result {
- val previous = settingsStore.data.first()
- publicPaykitRepo.syncPublishedEndpoints(publish = true)
- .onFailure {
- rollbackEnabledContactPayments(previous, contacts, it)
- return Result.failure(it)
- }
-
- val canUsePrivateContactPayments = pubkyRepo.hasSecretKey()
- if (canUsePrivateContactPayments) {
- privatePaykitRepo.setContactSharingCleanupPending(false)
- .onFailure {
- rollbackEnabledContactPayments(previous, contacts, it)
- return Result.failure(it)
- }
- }
-
- runSuspendCatching {
- settingsStore.update {
- it.copy(
- hasConfirmedPublicPaykitEndpoints = true,
- sharesPublicPaykitEndpoints = true,
- sharesPrivatePaykitEndpoints = canUsePrivateContactPayments,
- )
- }
- }.onFailure {
- rollbackEnabledContactPayments(previous, contacts, it)
- return Result.failure(it)
- }
-
- if (canUsePrivateContactPayments) {
- privatePaykitRepo.prepareSavedContacts(contacts)
- }
-
- return Result.success(Unit)
- }
-
- private suspend fun rollbackEnabledContactPayments(
- previous: SettingsData,
- contacts: List,
- error: Throwable,
- ) {
- runSuspendCatching {
- settingsStore.update {
- it.copy(
- hasConfirmedPublicPaykitEndpoints = previous.hasConfirmedPublicPaykitEndpoints,
- sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints,
- sharesPrivatePaykitEndpoints = previous.sharesPrivatePaykitEndpoints,
- )
+ } finally {
+ _uiState.update { it.copy(isLoading = false) }
}
- }.onFailure(error::addSuppressed)
- publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints)
- .onFailure { rollbackError ->
- error.addSuppressed(rollbackError)
- markPublicPaykitRetry(error)
- }
- if (!previous.sharesPrivatePaykitEndpoints) {
- privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts)
- .onFailure(error::addSuppressed)
}
}
- private suspend fun disableContactPayments(contacts: List): Result {
- val previous = settingsStore.data.first()
- runSuspendCatching {
- settingsStore.update {
- it.copy(
- hasConfirmedPublicPaykitEndpoints = true,
- sharesPublicPaykitEndpoints = false,
- sharesPrivatePaykitEndpoints = false,
- )
- }
- }.onFailure {
- return Result.failure(it)
- }
-
- var publicCleanupError: Throwable? = null
- var privateCleanupError: Throwable? = null
- publicPaykitRepo.syncPublishedEndpoints(publish = false)
- .onFailure { publicCleanupError = it }
-
- privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts)
- .onFailure { privateCleanupError = it }
-
- publicCleanupError?.let { error ->
- runSuspendCatching {
- settingsStore.update { settings ->
- settings.copy(sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints)
- }
- }.onFailure { rollbackError ->
- error.addSuppressed(rollbackError)
- }
- publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints)
- .onFailure { rollbackError ->
- error.addSuppressed(rollbackError)
- markPublicPaykitRetry(error)
- }
- }
- privateCleanupError?.let { error ->
- if (previous.sharesPrivatePaykitEndpoints) {
- restorePrivateContactPayments(contacts, error)
- } else {
- updatePrivateContactsPreference(isEnabled = false, error = error)
- }
- }
-
- val cleanupError = publicCleanupError ?: privateCleanupError
- publicCleanupError?.let { publicError ->
- privateCleanupError?.let { privateError -> publicError.addSuppressed(privateError) }
- }
- cleanupError?.let {
- return Result.failure(it)
- }
-
- privatePaykitRepo.setContactSharingCleanupPending(false)
- .onFailure { return Result.failure(it) }
-
- return Result.success(Unit)
- }
-
- private suspend fun restorePrivateContactPayments(
- contacts: List,
- error: Throwable,
- ) {
- val preferenceRestored = updatePrivateContactsPreference(isEnabled = true, error = error)
- if (!preferenceRestored) return
-
- privatePaykitRepo.prepareSavedContacts(
- publicKeys = contacts,
- requireImmediatePublication = true,
- ).exceptionOrNull()?.let {
- error.addSuppressed(it)
- updatePrivateContactsPreference(isEnabled = false, error = error)
- publicPaykitRepo.syncLocalReceiverMarker().onFailure(error::addSuppressed)
- return
- }
-
- privatePaykitRepo.setContactSharingCleanupPending(false).exceptionOrNull()?.let {
- error.addSuppressed(it)
- updatePrivateContactsPreference(isEnabled = false, error = error)
- }
- publicPaykitRepo.syncLocalReceiverMarker().onFailure(error::addSuppressed)
- }
-
- private suspend fun markPublicPaykitRetry(error: Throwable) {
- runSuspendCatching {
- settingsStore.update { it.copy(publicPaykitCleanupPending = true) }
- }.onFailure(error::addSuppressed)
- }
-
- private suspend fun updatePrivateContactsPreference(
- isEnabled: Boolean,
- error: Throwable,
- ): Boolean = runSuspendCatching {
- settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = isEnabled) }
- }.onFailure(error::addSuppressed).isSuccess
-
private fun syncErrorMessage(error: Throwable): String = when (error) {
PublicPaykitError.InvalidPayload -> context.getString(R.string.profile__pay_contacts_error_invalid_payload)
PublicPaykitError.NoSupportedEndpoint -> context.getString(R.string.profile__pay_contacts_error_no_endpoint)
@@ -255,16 +65,10 @@ class PayContactsViewModel @Inject constructor(
PublicPaykitError.WalletNotReady -> context.getString(R.string.profile__pay_contacts_error_wallet)
else -> context.getString(R.string.common__error_body)
}
-
- private fun resolvedSharingDefault(settings: SettingsData, hasLocalSecretKey: Boolean): Boolean =
- settings.sharesPublicPaykitEndpoints ||
- (settings.sharesPrivatePaykitEndpoints && hasLocalSecretKey) ||
- !settings.hasConfirmedPublicPaykitEndpoints
}
@Immutable
data class PayContactsUiState(
- val isPaymentSharingEnabled: Boolean = true,
val isLoading: Boolean = false,
)
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt
index 85099ff752..38e0e65c9e 100644
--- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt
@@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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
@@ -31,6 +32,7 @@ import to.bitkit.R
import to.bitkit.models.PubkyProfile
import to.bitkit.models.PubkyProfileLink
import to.bitkit.ui.components.ActionButton
+import to.bitkit.ui.components.AddTagSheet
import to.bitkit.ui.components.BodyM
import to.bitkit.ui.components.BodyS
import to.bitkit.ui.components.CenteredProfileHeader
@@ -79,6 +81,10 @@ fun ProfileScreen(
onDismissSignOutDialog = { viewModel.dismissSignOutDialog() },
onConfirmSignOut = { viewModel.signOut() },
onClickRetry = { viewModel.loadProfile() },
+ onClickAddTag = { viewModel.showAddTagSheet() },
+ onRemoveTag = { viewModel.removeTag(it) },
+ onDismissAddTagSheet = { viewModel.dismissAddTagSheet() },
+ onSaveTag = { viewModel.addTag(it) },
)
}
@@ -93,6 +99,10 @@ private fun Content(
onDismissSignOutDialog: () -> Unit,
onConfirmSignOut: () -> Unit,
onClickRetry: () -> Unit,
+ onClickAddTag: () -> Unit,
+ onRemoveTag: (String) -> Unit,
+ onDismissAddTagSheet: () -> Unit,
+ onSaveTag: (String) -> Unit,
) {
val currentProfile = uiState.profile
@@ -110,6 +120,8 @@ private fun Content(
onClickEdit = onClickEdit,
onClickCopy = onClickCopy,
onClickShare = onClickShare,
+ onClickAddTag = onClickAddTag,
+ onRemoveTag = onRemoveTag,
)
else -> EmptyState(onClickRetry = onClickRetry, onClickSignOut = onClickSignOut)
}
@@ -124,6 +136,13 @@ private fun Content(
onDismiss = onDismissSignOutDialog,
)
}
+
+ if (uiState.showAddTagSheet) {
+ AddTagSheet(
+ onDismiss = onDismissAddTagSheet,
+ onSave = onSaveTag,
+ )
+ }
}
@Composable
@@ -132,13 +151,15 @@ private fun ProfileBody(
onClickEdit: () -> Unit,
onClickCopy: () -> Unit,
onClickShare: () -> Unit,
+ onClickAddTag: () -> Unit,
+ onRemoveTag: (String) -> Unit,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
- .padding(horizontal = 32.dp)
+ .padding(horizontal = 16.dp)
) {
VerticalSpacer(24.dp)
@@ -161,7 +182,7 @@ private fun ProfileBody(
) {
QrCodeImage(
content = profile.publicKey,
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier.size(279.dp),
testTag = "ProfileQRCode",
)
if (profile.imageUrl != null) {
@@ -210,26 +231,36 @@ private fun ProfileBody(
}
}
- if (profile.tags.isNotEmpty()) {
- VerticalSpacer(16.dp)
- Text13Up(
- text = stringResource(R.string.profile__edit_tags),
- color = Colors.White64,
- modifier = Modifier
- .fillMaxWidth()
- .testTag("ProfileViewTagsHeader")
- )
- VerticalSpacer(8.dp)
- @OptIn(ExperimentalLayoutApi::class)
- FlowRow(
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.fillMaxWidth()
- ) {
- profile.tags.forEach { tag ->
- TagButton(text = tag, onClick = null)
- }
+ VerticalSpacer(16.dp)
+ Text13Up(
+ text = stringResource(R.string.profile__edit_tags),
+ color = Colors.White64,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("ProfileViewTagsHeader")
+ )
+ VerticalSpacer(8.dp)
+ @OptIn(ExperimentalLayoutApi::class)
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ profile.tags.forEach { tag ->
+ TagButton(
+ text = tag,
+ onClick = { onRemoveTag(tag) },
+ accessibilityLabel = stringResource(R.string.common__remove_tag, tag),
+ displayIconClose = true,
+ )
}
+ TagButton(
+ text = stringResource(R.string.profile__add_tag),
+ onClick = onClickAddTag,
+ icon = painterResource(R.drawable.ic_tag),
+ displayIconClose = true,
+ modifier = Modifier.testTag("ProfileAddTag")
+ )
}
VerticalSpacer(16.dp)
@@ -299,6 +330,10 @@ private fun Preview() {
onDismissSignOutDialog = {},
onConfirmSignOut = {},
onClickRetry = {},
+ onClickAddTag = {},
+ onRemoveTag = {},
+ onDismissAddTagSheet = {},
+ onSaveTag = {},
)
}
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt
index f967e9ece4..b4a6a3ebaa 100644
--- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt
@@ -15,6 +15,8 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import to.bitkit.R
import to.bitkit.ext.setClipboardText
import to.bitkit.models.PubkyProfile
@@ -37,20 +39,29 @@ class ProfileViewModel @Inject constructor(
private val _showSignOutDialog = MutableStateFlow(false)
private val _isSigningOut = MutableStateFlow(false)
+ private val _showAddTagSheet = MutableStateFlow(false)
+ private val tagUpdateMutex = Mutex()
+ private val controls = combine(
+ _showSignOutDialog,
+ _isSigningOut,
+ _showAddTagSheet,
+ ) { showSignOutDialog, isSigningOut, showAddTagSheet ->
+ ProfileControls(showSignOutDialog, isSigningOut, showAddTagSheet)
+ }
val uiState: StateFlow = combine(
pubkyRepo.profile,
pubkyRepo.publicKey,
pubkyRepo.isLoadingProfile,
- _showSignOutDialog,
- _isSigningOut,
- ) { profile, publicKey, isLoading, showSignOutDialog, isSigningOut ->
+ controls,
+ ) { profile, publicKey, isLoading, controls ->
ProfileUiState(
profile = profile,
publicKey = publicKey,
isLoading = isLoading,
- showSignOutDialog = showSignOutDialog,
- isSigningOut = isSigningOut,
+ showSignOutDialog = controls.showSignOutDialog,
+ isSigningOut = controls.isSigningOut,
+ showAddTagSheet = controls.showAddTagSheet,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProfileUiState())
@@ -73,6 +84,25 @@ class ProfileViewModel @Inject constructor(
_showSignOutDialog.update { false }
}
+ fun showAddTagSheet() {
+ _showAddTagSheet.update { true }
+ }
+
+ fun dismissAddTagSheet() {
+ _showAddTagSheet.update { false }
+ }
+
+ fun addTag(tag: String) {
+ updateTags(
+ transform = { (it + tag).distinct() },
+ onSuccess = { _showAddTagSheet.update { false } },
+ )
+ }
+
+ fun removeTag(tag: String) {
+ updateTags(transform = { tags -> tags.filterNot { it == tag } })
+ }
+
fun signOut() {
viewModelScope.launch {
_isSigningOut.update { true }
@@ -119,6 +149,39 @@ class ProfileViewModel @Inject constructor(
)
}
}
+
+ private fun updateTags(
+ transform: (List) -> List,
+ onSuccess: () -> Unit = {},
+ ) {
+ viewModelScope.launch {
+ tagUpdateMutex.withLock {
+ val profile = pubkyRepo.profile.value ?: return@withLock
+ val tags = transform(profile.tags)
+ if (tags == profile.tags) {
+ onSuccess()
+ return@withLock
+ }
+
+ pubkyRepo.saveProfile(
+ name = profile.name,
+ bio = profile.bio,
+ links = profile.links,
+ tags = tags,
+ imageUrl = profile.imageUrl,
+ ).onSuccess {
+ onSuccess()
+ }.onFailure {
+ Logger.error("Failed to update profile tags", it, context = TAG)
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__edit_save_error),
+ description = it.message,
+ )
+ }
+ }
+ }
+ }
}
@Stable
@@ -128,6 +191,13 @@ data class ProfileUiState(
val isLoading: Boolean = false,
val showSignOutDialog: Boolean = false,
val isSigningOut: Boolean = false,
+ val showAddTagSheet: Boolean = false,
+)
+
+private data class ProfileControls(
+ val showSignOutDialog: Boolean,
+ val isSigningOut: Boolean,
+ val showAddTagSheet: Boolean,
)
sealed interface ProfileEffect {
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt
index a08f71404f..97ec5eae3f 100644
--- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -22,26 +23,34 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import to.bitkit.R
+import to.bitkit.models.PubkyAuthClaim
import to.bitkit.models.PubkyAuthPermission
import to.bitkit.models.PubkyProfile
import to.bitkit.ui.appViewModel
import to.bitkit.ui.components.AuthCheckView
import to.bitkit.ui.components.BiometricsView
import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.BodyMSB
import to.bitkit.ui.components.BodySSB
import to.bitkit.ui.components.BottomSheetPreview
-import to.bitkit.ui.components.CenteredProfileHeader
+import to.bitkit.ui.components.Display
import to.bitkit.ui.components.FillHeight
+import to.bitkit.ui.components.Headline
import to.bitkit.ui.components.HorizontalSpacer
import to.bitkit.ui.components.PrimaryButton
+import to.bitkit.ui.components.PubkyImage
import to.bitkit.ui.components.SecondaryButton
import to.bitkit.ui.components.SheetSize
import to.bitkit.ui.components.Text13Up
@@ -53,6 +62,7 @@ import to.bitkit.ui.shared.util.gradientBackground
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.theme.Colors
import to.bitkit.ui.utils.rememberBiometricAuthSupported
+import to.bitkit.ui.utils.withAccent
import to.bitkit.ui.utils.withAccentBoldBright
@Composable
@@ -62,6 +72,35 @@ fun PubkyAuthApprovalSheet(
onDismiss: () -> Unit,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(authUrl) { viewModel.load(authUrl) }
+
+ Box {
+ Content(
+ uiState = uiState,
+ isCurrentRequest = uiState.authUrl == authUrl,
+ onAuthorize = {
+ if (uiState.authUrl == authUrl) viewModel.requestAuthorize(authUrl)
+ },
+ onApproveWatchOnly = {
+ if (uiState.authUrl == authUrl) viewModel.approveWatchOnlyConsent(authUrl)
+ },
+ onBackToWatchOnly = {
+ if (uiState.authUrl == authUrl) viewModel.returnToWatchOnlyConsent(authUrl)
+ },
+ onCancel = { viewModel.dismiss() },
+ onDismiss = { viewModel.dismiss() },
+ )
+
+ PubkyAuthorizationLocalAuth(viewModel = viewModel, onDismiss = onDismiss)
+ }
+}
+
+@Composable
+private fun PubkyAuthorizationLocalAuth(
+ viewModel: PubkyAuthApprovalViewModel,
+ onDismiss: () -> Unit,
+) {
var showBiometrics by remember { mutableStateOf(false) }
var showAuthCheck by remember { mutableStateOf(false) }
var pendingAuthUrl by remember { mutableStateOf(null) }
@@ -72,8 +111,6 @@ fun PubkyAuthApprovalSheet(
val isBiometricEnabled by settings.isBiometricEnabled.collectAsStateWithLifecycle()
val isBiometrySupported = rememberBiometricAuthSupported()
- LaunchedEffect(authUrl) { viewModel.load(authUrl) }
-
LaunchedEffect(Unit) {
viewModel.effects.collect {
when (it) {
@@ -107,43 +144,36 @@ fun PubkyAuthApprovalSheet(
}
}
- Box {
- Content(
- uiState = uiState,
- onAuthorize = { viewModel.requestAuthorize(authUrl) },
- onCancel = { viewModel.dismiss() },
- onDismiss = { viewModel.dismiss() },
+ if (showAuthCheck) {
+ AuthCheckView(
+ appViewModel = app,
+ settingsViewModel = settings,
+ onSuccess = {
+ showAuthCheck = false
+ pendingAuthUrl?.let { viewModel.confirmAuthorize(it) }
+ pendingAuthUrl = null
+ },
+ onBack = {
+ showAuthCheck = false
+ pendingAuthUrl?.let(viewModel::cancelLocalAuth)
+ pendingAuthUrl = null
+ },
)
+ }
- if (showAuthCheck) {
- AuthCheckView(
- appViewModel = app,
- settingsViewModel = settings,
- onSuccess = {
- showAuthCheck = false
- pendingAuthUrl?.let { viewModel.confirmAuthorize(it) }
- pendingAuthUrl = null
- },
- onBack = {
- showAuthCheck = false
- pendingAuthUrl = null
- },
- )
- }
-
- if (showBiometrics) {
- BiometricsView(
- onSuccess = {
- showBiometrics = false
- pendingAuthUrl?.let { viewModel.confirmAuthorize(it) }
- pendingAuthUrl = null
- },
- onFailure = {
- showBiometrics = false
- pendingAuthUrl = null
- },
- )
- }
+ if (showBiometrics) {
+ BiometricsView(
+ onSuccess = {
+ showBiometrics = false
+ pendingAuthUrl?.let { viewModel.confirmAuthorize(it) }
+ pendingAuthUrl = null
+ },
+ onFailure = {
+ showBiometrics = false
+ pendingAuthUrl?.let(viewModel::cancelLocalAuth)
+ pendingAuthUrl = null
+ },
+ )
}
}
@@ -166,15 +196,22 @@ internal fun resolvePubkyApprovalLocalAuthMode(
@Composable
private fun Content(
uiState: PubkyAuthApprovalUiState,
+ isCurrentRequest: Boolean,
onAuthorize: () -> Unit,
+ onApproveWatchOnly: () -> Unit,
+ onBackToWatchOnly: () -> Unit,
onCancel: () -> Unit,
onDismiss: () -> Unit,
) {
- val headerTitle = if (uiState.state == ApprovalState.Success) {
- stringResource(R.string.profile__auth_approval_success)
- } else {
- stringResource(R.string.profile__auth_approval_title)
- }
+ val approvalState = if (isCurrentRequest) uiState.state else ApprovalState.Loading
+ val headerTitle = approvalHeaderTitle(approvalState)
+ val onBack = approvalBackAction(
+ approvalState = approvalState,
+ bitkitClaim = uiState.bitkitClaim,
+ onBackToWatchOnly = onBackToWatchOnly,
+ onCancel = onCancel,
+ onDismiss = onDismiss,
+ )
Column(
modifier = Modifier
@@ -183,16 +220,20 @@ private fun Content(
.navigationBarsPadding()
.padding(horizontal = 16.dp)
) {
- SheetTopBar(titleText = headerTitle)
+ SheetTopBar(titleText = headerTitle, onBack = onBack)
- when (uiState.state) {
+ when (approvalState) {
ApprovalState.Loading -> LoadingContent()
+ ApprovalState.WatchOnlyConsent -> WatchOnlyConsentContent(
+ onApprove = onApproveWatchOnly,
+ onCancel = onCancel,
+ )
ApprovalState.Authorize -> AuthorizeContent(
uiState = uiState,
onAuthorize = onAuthorize,
onCancel = onCancel,
)
- ApprovalState.Authorizing -> AuthorizingContent(
+ ApprovalState.Authenticating, ApprovalState.Authorizing -> AuthorizingContent(
uiState = uiState,
)
ApprovalState.Success -> SuccessContent(
@@ -203,6 +244,81 @@ private fun Content(
}
}
+@Composable
+private fun approvalHeaderTitle(approvalState: ApprovalState): String = when (approvalState) {
+ ApprovalState.WatchOnlyConsent -> stringResource(R.string.profile__auth_approval_watch_only_intro_nav_title)
+ ApprovalState.Success -> stringResource(R.string.profile__auth_approval_success)
+ else -> stringResource(R.string.profile__auth_approval_title)
+}
+
+private fun approvalBackAction(
+ approvalState: ApprovalState,
+ bitkitClaim: PubkyAuthClaim?,
+ onBackToWatchOnly: () -> Unit,
+ onCancel: () -> Unit,
+ onDismiss: () -> Unit,
+): (() -> Unit)? = when (approvalState) {
+ ApprovalState.Authorize if bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1 -> onBackToWatchOnly
+ ApprovalState.Authorize, ApprovalState.Authenticating, ApprovalState.Authorizing -> onCancel
+ ApprovalState.Success -> onDismiss
+ else -> null
+}
+
+@Composable
+private fun ColumnScope.WatchOnlyConsentContent(
+ onApprove: () -> Unit,
+ onCancel: () -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(horizontal = 16.dp)
+ .testTag("PubkyAuthWatchOnlyConsent")
+ ) {
+ FillHeight(min = 26.dp)
+
+ Image(
+ painter = painterResource(R.drawable.coin_stack),
+ contentDescription = null,
+ modifier = Modifier
+ .size(256.dp)
+ .align(Alignment.CenterHorizontally),
+ )
+
+ VerticalSpacer(36.dp)
+
+ Display(
+ text = stringResource(R.string.profile__auth_approval_watch_only_intro_title)
+ .withAccent(accentColor = Colors.Blue),
+ )
+ VerticalSpacer(8.dp)
+ BodyM(
+ text = stringResource(R.string.profile__auth_approval_watch_only_intro_description),
+ color = Colors.White64,
+ )
+
+ VerticalSpacer(32.dp)
+
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ SecondaryButton(
+ text = stringResource(R.string.common__cancel),
+ onClick = onCancel,
+ modifier = Modifier
+ .weight(1f)
+ .testTag("PubkyAuthWatchOnlyCancel"),
+ )
+ PrimaryButton(
+ text = stringResource(R.string.profile__auth_approval_watch_only_intro_approve),
+ onClick = onApprove,
+ modifier = Modifier
+ .weight(1f)
+ .testTag("PubkyAuthWatchOnlyApprove"),
+ )
+ }
+ VerticalSpacer(16.dp)
+ }
+}
+
@Composable
private fun ColumnScope.LoadingContent() {
FillHeight()
@@ -221,19 +337,7 @@ private fun ColumnScope.AuthorizeContent(
onAuthorize: () -> Unit,
onCancel: () -> Unit,
) {
- DescriptionText(serviceName = uiState.serviceName)
- VerticalSpacer(32.dp)
-
- PermissionsSection(permissions = uiState.permissions)
- VerticalSpacer(16.dp)
-
- FillHeight()
-
- TrustWarning()
- VerticalSpacer(16.dp)
-
- uiState.profile?.let { ProfileCard(it) }
- VerticalSpacer(24.dp)
+ ApprovalDetails(uiState = uiState)
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
SecondaryButton(
@@ -254,27 +358,38 @@ private fun ColumnScope.AuthorizeContent(
private fun ColumnScope.AuthorizingContent(
uiState: PubkyAuthApprovalUiState,
) {
- DescriptionText(serviceName = uiState.serviceName)
- VerticalSpacer(32.dp)
+ ApprovalDetails(uiState = uiState)
- PermissionsSection(permissions = uiState.permissions)
+ BodyMSB(
+ text = stringResource(R.string.profile__auth_approval_authorizing),
+ color = Colors.White32,
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 18.dp),
+ )
VerticalSpacer(16.dp)
+}
- FillHeight()
+@Composable
+private fun ColumnScope.ApprovalDetails(
+ uiState: PubkyAuthApprovalUiState,
+) {
+ Column(modifier = Modifier.weight(1f)) {
+ VerticalSpacer(26.dp)
- TrustWarning()
- VerticalSpacer(16.dp)
+ DescriptionText(serviceName = uiState.serviceName)
+ VerticalSpacer(32.dp)
- uiState.profile?.let { ProfileCard(it) }
- VerticalSpacer(24.dp)
+ PermissionsSection(permissions = uiState.permissions)
+ FillHeight(min = 32.dp)
- PrimaryButton(
- text = stringResource(R.string.profile__auth_approval_authorizing),
- onClick = {},
- isLoading = true,
- enabled = false,
- )
- VerticalSpacer(16.dp)
+ TrustWarning()
+ VerticalSpacer(16.dp)
+
+ uiState.profile?.let { ProfileCard(it) }
+ VerticalSpacer(16.dp)
+ }
}
@Composable
@@ -282,9 +397,11 @@ private fun ColumnScope.SuccessContent(
uiState: PubkyAuthApprovalUiState,
onDismiss: () -> Unit,
) {
+ VerticalSpacer(26.dp)
+
SuccessDescriptionText(
serviceName = uiState.serviceName,
- truncatedKey = uiState.profile?.truncatedPublicKey ?: "",
+ truncatedKey = uiState.profile?.authDisplayPublicKey.orEmpty(),
)
VerticalSpacer(16.dp)
@@ -356,7 +473,7 @@ private fun PermissionRow(permission: PubkyAuthPermission) {
)
HorizontalSpacer(4.dp)
BodySSB(
- text = permission.path,
+ text = permission.displayPath,
modifier = Modifier.weight(1f),
)
Text13Up(
@@ -383,15 +500,70 @@ private fun ProfileCard(profile: PubkyProfile) {
.background(Colors.Gray6, RoundedCornerShape(16.dp))
.padding(24.dp),
) {
- CenteredProfileHeader(
- publicKey = profile.publicKey,
- name = profile.name,
- bio = "",
- imageUrl = profile.imageUrl,
+ Text13Up(
+ text = profile.authDisplayPublicKey,
+ color = Colors.White64,
+ )
+ VerticalSpacer(16.dp)
+
+ if (profile.imageUrl != null) {
+ PubkyImage(uri = profile.imageUrl, size = 96.dp)
+ } else {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(96.dp)
+ .clip(CircleShape)
+ .background(Colors.Gray5),
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_user_square),
+ contentDescription = null,
+ tint = Colors.White32,
+ modifier = Modifier.size(48.dp),
+ )
+ }
+ }
+
+ VerticalSpacer(16.dp)
+ Headline(
+ text = AnnotatedString(profile.name),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
)
}
}
+private val PubkyProfile.authDisplayPublicKey: String
+ get() = pubkyAuthDisplayPublicKey(publicKey)
+
+internal fun pubkyAuthDisplayPublicKey(publicKey: String): String {
+ val rawKey = publicKey.removePrefix("pubky")
+ return if (rawKey.length > 8) "${rawKey.take(4)}...${rawKey.takeLast(4)}" else rawKey
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun WatchOnlyConsentPreview() {
+ AppThemeSurface {
+ BottomSheetPreview {
+ Content(
+ uiState = PubkyAuthApprovalUiState(
+ state = ApprovalState.WatchOnlyConsent,
+ serviceName = "paykit",
+ bitkitClaim = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1,
+ ),
+ isCurrentRequest = true,
+ onAuthorize = {},
+ onApproveWatchOnly = {},
+ onBackToWatchOnly = {},
+ onCancel = {},
+ onDismiss = {},
+ )
+ }
+ }
+}
+
@Preview(showSystemUi = true)
@Composable
private fun AuthorizePreview() {
@@ -405,6 +577,7 @@ private fun AuthorizePreview() {
PubkyAuthPermission(path = "/pub/pubky.app/", accessLevel = "rw"),
PubkyAuthPermission(path = "/pub/paykit/v0/", accessLevel = "rw"),
),
+ bitkitClaim = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1,
profile = PubkyProfile(
publicKey = "pk8e3qm5f4kgczagxhertyuiop1gxag",
name = "Satoshi Nakamoto",
@@ -414,7 +587,10 @@ private fun AuthorizePreview() {
status = null,
),
),
+ isCurrentRequest = true,
onAuthorize = {},
+ onApproveWatchOnly = {},
+ onBackToWatchOnly = {},
onCancel = {},
onDismiss = {},
)
@@ -432,7 +608,10 @@ private fun SuccessPreview() {
state = ApprovalState.Success,
serviceName = "pubky.app",
),
+ isCurrentRequest = true,
onAuthorize = {},
+ onApproveWatchOnly = {},
+ onBackToWatchOnly = {},
onCancel = {},
onDismiss = {},
)
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt
index 0af0750034..702d8abc61 100644
--- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.synonym.paykit.PubkyAuthCompanionClaimApprovalException
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.collections.immutable.ImmutableList
@@ -16,19 +17,27 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import to.bitkit.R
+import to.bitkit.ext.runSuspendCatching
+import to.bitkit.models.PubkyAuthClaim
import to.bitkit.models.PubkyAuthPermission
import to.bitkit.models.PubkyAuthRequest
import to.bitkit.models.PubkyProfile
import to.bitkit.models.Toast
+import to.bitkit.models.WatchOnlyAccountSetupState
import to.bitkit.repositories.PubkyRepo
+import to.bitkit.repositories.WatchOnlyAccountAuthorizationStartError
+import to.bitkit.repositories.WatchOnlyAccountRepo
import to.bitkit.ui.shared.toast.ToastEventBus
+import to.bitkit.ui.utils.localizedPubkyAuthMessage
import to.bitkit.utils.Logger
+import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
@HiltViewModel
class PubkyAuthApprovalViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val pubkyRepo: PubkyRepo,
+ private val watchOnlyAccountRepo: WatchOnlyAccountRepo,
) : ViewModel() {
companion object {
private const val TAG = "PubkyAuthApprovalVM"
@@ -40,31 +49,49 @@ class PubkyAuthApprovalViewModel @Inject constructor(
private val _effects = MutableSharedFlow(extraBufferCapacity = 1)
val effects = _effects.asSharedFlow()
+ private val inFlightAuthorization = AtomicReference()
fun load(authUrl: String) {
+ inFlightAuthorization.get()?.takeIf { it.authUrl == authUrl }?.let { authorization ->
+ if (_uiState.value.authUrl != authUrl) {
+ authorization.uiState?.let { authorizingState ->
+ _uiState.update { authorizingState }
+ }
+ }
+ return
+ }
+ if (!resetForLoad(authUrl)) return
viewModelScope.launch {
- val details = pubkyRepo.parseAuthUrl(authUrl).getOrElse {
+ val request = pubkyRepo.parseAuthUrl(authUrl).getOrElse {
+ if (_uiState.value.authUrl != authUrl) return@launch
Logger.error("Failed to parse auth request", it, context = TAG)
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.profile__auth_error_title),
- description = it.message,
+ description = it.localizedPubkyAuthMessage(context),
)
_effects.emit(PubkyAuthApprovalEffect.Dismiss)
return@launch
}
- val caps = details.capabilities.orEmpty()
- val permissions = PubkyAuthRequest.parseCapabilities(caps)
- val serviceNames = permissions.mapNotNull { PubkyAuthRequest.extractServiceName(it.path) }.distinct()
+ if (_uiState.value.authUrl != authUrl) return@launch
val unknownService = context.getString(R.string.profile__auth_approval_service_unknown)
- val serviceName = serviceNames.firstOrNull() ?: unknownService
- val profile = pubkyRepo.profile.value
-
+ val serviceName = request.serviceNames.firstOrNull() ?: unknownService
+ val profile = pubkyRepo.profile.value ?: pubkyRepo.publicKey.value?.let { publicKey ->
+ PubkyProfile.forDisplay(
+ publicKey = publicKey,
+ name = pubkyRepo.displayName.value,
+ imageUrl = pubkyRepo.displayImageUri.value,
+ )
+ }
_uiState.update {
it.copy(
- state = ApprovalState.Authorize,
+ state = if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) {
+ ApprovalState.WatchOnlyConsent
+ } else {
+ ApprovalState.Authorize
+ },
serviceName = serviceName,
- requestedCapabilities = caps,
- permissions = permissions.toImmutableList(),
+ permissions = request.permissions.toImmutableList(),
+ bitkitClaim = request.bitkitClaim,
profile = profile,
)
}
@@ -72,44 +99,194 @@ class PubkyAuthApprovalViewModel @Inject constructor(
}
fun requestAuthorize(authUrl: String) {
+ val state = _uiState.value
+ if (state.authUrl != authUrl || state.state != ApprovalState.Authorize) return
+ if (!_uiState.compareAndSet(state, state.copy(state = ApprovalState.Authenticating))) return
viewModelScope.launch {
_effects.emit(PubkyAuthApprovalEffect.RequestLocalAuth(authUrl))
}
}
+ fun approveWatchOnlyConsent(authUrl: String) {
+ _uiState.update { state ->
+ if (state.authUrl == authUrl && state.state == ApprovalState.WatchOnlyConsent) {
+ state.copy(state = ApprovalState.Authorize)
+ } else {
+ state
+ }
+ }
+ }
+
+ fun returnToWatchOnlyConsent(authUrl: String) {
+ _uiState.update { state ->
+ if (
+ state.authUrl == authUrl &&
+ state.state == ApprovalState.Authorize &&
+ state.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1
+ ) {
+ state.copy(state = ApprovalState.WatchOnlyConsent)
+ } else {
+ state
+ }
+ }
+ }
+
+ fun cancelLocalAuth(authUrl: String) {
+ _uiState.update { state ->
+ if (state.authUrl == authUrl && state.state == ApprovalState.Authenticating) {
+ state.copy(state = ApprovalState.Authorize)
+ } else {
+ state
+ }
+ }
+ }
+
fun confirmAuthorize(authUrl: String) {
+ val authorization = InFlightAuthorization(authUrl)
+ if (!inFlightAuthorization.compareAndSet(null, authorization)) return
+ val authorizingState = transitionToAuthorizing(authUrl)
+ if (authorizingState == null) {
+ inFlightAuthorization.compareAndSet(authorization, null)
+ return
+ }
+ authorization.uiState = authorizingState
+
viewModelScope.launch {
- _uiState.update { it.copy(state = ApprovalState.Authorizing) }
- val capabilities = _uiState.value.requestedCapabilities.ifBlank {
- pubkyRepo.parseAuthUrl(authUrl).getOrElse {
- Logger.error("Failed to parse auth request", it, context = TAG)
- _uiState.update { state -> state.copy(state = ApprovalState.Authorize) }
- ToastEventBus.send(
- type = Toast.ToastType.ERROR,
- title = context.getString(R.string.profile__auth_error_title),
- description = it.message,
- )
- return@launch
- }.capabilities.orEmpty()
+ try {
+ authorize(authUrl)
+ } finally {
+ inFlightAuthorization.compareAndSet(authorization, null)
+ }
+ }
+ }
+
+ private suspend fun authorize(authUrl: String) {
+ val request = pubkyRepo.parseAuthUrl(authUrl).getOrElse {
+ handleApprovalFailure(it, authUrl)
+ return
+ }
+ if (_uiState.value.authUrl != authUrl) return
+ if (!approveRequest(request, authUrl)) return
+
+ Logger.info("Auth approved for '${request.serviceNames.firstOrNull().orEmpty()}'", context = TAG)
+ _uiState.update { state ->
+ if (state.authUrl == authUrl) state.copy(state = ApprovalState.Success) else state
+ }
+ }
+
+ private suspend fun approveRequest(
+ request: PubkyAuthRequest,
+ authUrl: String,
+ ): Boolean {
+ val preparedClaim = runSuspendCatching {
+ if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) {
+ watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, defaultWatchOnlyAccountName(request))
+ } else {
+ null
}
+ }.getOrElse {
+ handleApprovalFailure(it, authUrl)
+ return false
+ }
- pubkyRepo.approveAuth(authUrl, capabilities)
- .onSuccess {
- Logger.info("Auth approved for '${_uiState.value.serviceName}'", context = TAG)
- _uiState.update { it.copy(state = ApprovalState.Success) }
+ var preserveAuthorizingState = preparedClaim?.account?.setupState == WatchOnlyAccountSetupState.Authorizing
+ preparedClaim?.let { claim ->
+ runSuspendCatching { watchOnlyAccountRepo.beginAuthorization(claim.account.id) }
+ .onSuccess { preserveAuthorizingState = it }
+ .getOrElse {
+ if (it is WatchOnlyAccountAuthorizationStartError) {
+ preserveAuthorizingState = it.preserveAuthorizingState
+ }
+ cancelIncompleteSetup(claim.account.id, preserveAuthorizingState)
+ handleApprovalFailure(it, authUrl)
+ return false
}
- .onFailure {
- Logger.error("Auth approval failed", it, context = TAG)
- _uiState.update { it.copy(state = ApprovalState.Authorize) }
- ToastEventBus.send(
- type = Toast.ToastType.ERROR,
- title = context.getString(R.string.profile__auth_error_title),
- description = it.message,
+ }
+
+ val approvalResult = preparedClaim?.let {
+ pubkyRepo.approveAuthWithCompanionClaim(authUrl, it.payload)
+ } ?: pubkyRepo.approveAuth(authUrl, request.capabilities)
+ if (approvalResult.isFailure) {
+ val approvalError = checkNotNull(approvalResult.exceptionOrNull()) { "Authorization failed" }
+ preparedClaim?.let { claim ->
+ if (!approvalError.isPostDeliveryAuthorizationFailure()) {
+ cancelIncompleteSetup(
+ claim.account.id,
+ preserveAuthorizingState,
)
}
+ }
+ handleApprovalFailure(approvalError, authUrl)
+ return false
+ }
+
+ preparedClaim?.let { claim ->
+ runSuspendCatching { watchOnlyAccountRepo.markActive(claim.account.id) }.getOrElse {
+ handleApprovalFailure(it, authUrl)
+ return false
+ }
+ }
+ return true
+ }
+
+ private fun transitionToAuthorizing(authUrl: String): PubkyAuthApprovalUiState? {
+ val initialState = _uiState.value
+ if (
+ initialState.authUrl != authUrl ||
+ (initialState.state != ApprovalState.Authorize && initialState.state != ApprovalState.Authenticating)
+ ) {
+ return null
+ }
+ val authorizingState = initialState.copy(state = ApprovalState.Authorizing)
+ return authorizingState.takeIf { _uiState.compareAndSet(initialState, it) }
+ }
+
+ private fun resetForLoad(authUrl: String): Boolean {
+ while (true) {
+ val currentState = _uiState.value
+ if (
+ currentState.authUrl == authUrl &&
+ currentState.state in setOf(ApprovalState.Authenticating, ApprovalState.Authorizing)
+ ) {
+ return false
+ }
+ if (_uiState.compareAndSet(currentState, PubkyAuthApprovalUiState(authUrl = authUrl))) return true
}
}
+ private suspend fun cancelIncompleteSetup(
+ accountId: String,
+ preserveAuthorizingState: Boolean,
+ ) {
+ runSuspendCatching {
+ watchOnlyAccountRepo.cancelAuthorization(accountId, preserveAuthorizingState)
+ }
+ .onFailure {
+ Logger.error(
+ "Failed to unload incomplete watch-only account",
+ it,
+ context = TAG,
+ )
+ }
+ }
+
+ private suspend fun handleApprovalFailure(error: Throwable, authUrl: String) {
+ Logger.error("Auth approval failed", error, context = TAG)
+ if (_uiState.value.authUrl != authUrl) return
+ _uiState.update { it.copy(state = ApprovalState.Authorize) }
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__auth_error_title),
+ description = error.localizedPubkyAuthMessage(context),
+ )
+ }
+
+ private fun defaultWatchOnlyAccountName(request: PubkyAuthRequest): String {
+ val serviceName = request.serviceNames.firstOrNull()
+ ?: context.getString(R.string.profile__auth_approval_service_unknown)
+ return context.getString(R.string.profile__auth_approval_watch_only_account_default_name, serviceName)
+ }
+
fun dismiss() {
viewModelScope.launch { _effects.emit(PubkyAuthApprovalEffect.Dismiss) }
}
@@ -117,16 +294,19 @@ class PubkyAuthApprovalViewModel @Inject constructor(
@Stable
data class PubkyAuthApprovalUiState(
+ val authUrl: String = "",
val state: ApprovalState = ApprovalState.Loading,
val serviceName: String = "",
- val requestedCapabilities: String = "",
val permissions: ImmutableList = persistentListOf(),
+ val bitkitClaim: PubkyAuthClaim? = null,
val profile: PubkyProfile? = null,
)
sealed interface ApprovalState {
data object Loading : ApprovalState
+ data object WatchOnlyConsent : ApprovalState
data object Authorize : ApprovalState
+ data object Authenticating : ApprovalState
data object Authorizing : ApprovalState
data object Success : ApprovalState
}
@@ -135,3 +315,19 @@ sealed interface PubkyAuthApprovalEffect {
data class RequestLocalAuth(val authUrl: String) : PubkyAuthApprovalEffect
data object Dismiss : PubkyAuthApprovalEffect
}
+
+private class InFlightAuthorization(
+ val authUrl: String,
+) {
+ @Volatile
+ var uiState: PubkyAuthApprovalUiState? = null
+}
+
+private fun Throwable.isPostDeliveryAuthorizationFailure(): Boolean {
+ var current: Throwable? = this
+ while (current != null) {
+ if (current is PubkyAuthCompanionClaimApprovalException.AuthorizationFailure) return true
+ current = current.cause
+ }
+ return false
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt
index 854541911d..c1d9224116 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
@@ -14,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
@@ -26,23 +26,25 @@ 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.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.collections.immutable.persistentListOf
import to.bitkit.R
+import to.bitkit.data.sharing.SharedPubkyContract
+import to.bitkit.models.PubkyProfile
import to.bitkit.ui.components.BodyM
import to.bitkit.ui.components.BodyMSB
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.Text13Up
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,13 +52,13 @@ 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
-private const val KEYRING_OFFSET_X = 0.341f
-private const val KEYRING_OFFSET_Y = 0.06f
-private const val TAG_ALPHA = 0.6f
+private const val TAG_IMAGE_WIDTH_FRACTION = 0.64f
+private const val KEYRING_IMAGE_WIDTH_FRACTION = 0.83f
+private const val TAG_OFFSET_X = -0.313f
+private const val TAG_OFFSET_Y = 0.336f
+private const val KEYRING_OFFSET_X = 0.251f
+private const val KEYRING_OFFSET_Y = 0.27f
+private const val TAG_ALPHA = 1f
private const val KEYRING_ALPHA = 0.9f
@Composable
@@ -65,45 +67,24 @@ fun PubkyChoiceScreen(
onNavigateToCreateProfile: () -> Unit,
onNavigateToContactImportOverview: () -> Unit,
onNavigateToPayContacts: () -> Unit,
- 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()
}
}
}
- LaunchedEffect(uiState.navigateToProfile) {
- if (!uiState.navigateToProfile) return@LaunchedEffect
-
- viewModel.clearProfileNavigation()
- onNavigateToProfile()
- }
-
Content(
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() },
+ onSelectRingIdentity = viewModel::selectRingIdentity,
)
}
@@ -112,10 +93,7 @@ private fun Content(
uiState: PubkyChoiceUiState,
onBackClick: () -> Unit,
onCreateProfile: () -> Unit,
- onImportWithRing: () -> Unit,
- onCancelAuth: () -> Unit,
- onDownloadRing: () -> Unit,
- onDismissDialog: () -> Unit,
+ onSelectRingIdentity: (SharedPubkyChoice) -> Unit,
) {
Box(
modifier = Modifier
@@ -128,7 +106,7 @@ private fun Content(
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
- .fillMaxWidth(BG_IMAGE_WIDTH_FRACTION)
+ .fillMaxWidth(TAG_IMAGE_WIDTH_FRACTION)
.align(Alignment.Center)
.offset(x = maxWidth * TAG_OFFSET_X, y = maxHeight * TAG_OFFSET_Y)
.alpha(TAG_ALPHA)
@@ -139,7 +117,7 @@ private fun Content(
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
- .fillMaxWidth(BG_IMAGE_WIDTH_FRACTION)
+ .fillMaxWidth(KEYRING_IMAGE_WIDTH_FRACTION)
.align(Alignment.Center)
.offset(x = maxWidth * KEYRING_OFFSET_X, y = maxHeight * KEYRING_OFFSET_Y)
.alpha(KEYRING_ALPHA)
@@ -151,66 +129,141 @@ private fun Content(
titleText = stringResource(R.string.profile__nav_title),
onBackClick = onBackClick,
actions = { DrawerNavIcon() },
+ modifier = Modifier.offset(y = (-10).dp),
)
- Column(modifier = Modifier.padding(horizontal = 32.dp)) {
- VerticalSpacer(24.dp)
-
- Display(
- text = stringResource(R.string.profile__choice_title)
- .withAccent(accentColor = Colors.PubkyGreen),
- color = Colors.White,
- )
- VerticalSpacer(8.dp)
-
- BodyM(
- text = stringResource(R.string.profile__choice_description),
- color = Colors.White64,
- )
- VerticalSpacer(24.dp)
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .offset(y = (-6.5).dp)
+ ) {
+ Display(
+ text = stringResource(R.string.profile__choice_title)
+ .withAccent(accentColor = Colors.PubkyGreen),
+ color = Colors.White,
+ )
+ VerticalSpacer(4.dp)
+ BodyM(
+ text = stringResource(R.string.profile__choice_description),
+ color = Colors.White64,
+ letterSpacing = 0.sp,
+ )
+ VerticalSpacer(33.dp)
- if (uiState.isLoadingAfterAuth) {
- LoadingState(text = stringResource(R.string.profile__choice_loading_profile))
- } else if (uiState.isWaitingForRing) {
- WaitingForRingState(onCancel = onCancelAuth)
- } else {
- OptionCard(
- iconResId = R.drawable.ic_user_plus,
- text = stringResource(R.string.profile__choice_create),
+ CreateProfileCard(
+ enabled = uiState.selectedPubky == null,
onClick = onCreateProfile,
- 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")
)
+
+ uiState.identities.forEach {
+ VerticalSpacer(8.dp)
+ RingIdentityCard(
+ choice = it,
+ isLoading = uiState.selectedPubky == it.pubky,
+ enabled = uiState.selectedPubky == null,
+ onClick = { onSelectRingIdentity(it) },
+ )
+ }
+
+ if (uiState.isDiscovering) {
+ VerticalSpacer(20.dp)
+ GradientCircularProgressIndicator(
+ modifier = Modifier
+ .size(24.dp)
+ .align(Alignment.CenterHorizontally)
+ .testTag("PubkyChoiceDiscovering")
+ )
+ }
+ VerticalSpacer(32.dp)
}
}
-
- 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
+private fun CreateProfileCard(
+ enabled: Boolean,
+ onClick: () -> Unit,
+) {
+ ChoiceCard(
+ enabled = enabled,
+ onClick = onClick,
+ leading = {
+ ChoiceIcon(iconResId = R.drawable.ic_user_plus)
+ },
+ content = {
+ Text13Up(
+ text = stringResource(R.string.profile__choice_new_pubky),
+ color = Colors.White64,
+ )
+ BodyMSB(
+ text = stringResource(R.string.profile__choice_create),
+ color = Colors.White,
+ )
+ },
+ modifier = Modifier.testTag("PubkyChoiceCreate")
+ )
+}
+
+@Composable
+private fun RingIdentityCard(
+ choice: SharedPubkyChoice,
+ isLoading: Boolean,
+ enabled: Boolean,
+ onClick: () -> Unit,
+) {
+ ChoiceCard(
+ enabled = enabled,
+ onClick = onClick,
+ leading = {
+ if (isLoading) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.size(40.dp)
+ ) {
+ GradientCircularProgressIndicator(modifier = Modifier.size(20.dp))
+ }
+ } else {
+ ChoiceIcon(iconResId = R.drawable.ic_key)
+ }
+ },
+ content = {
+ Text13Up(
+ text = choice.profile.truncatedPublicKey,
+ color = Colors.White64,
+ )
+ BodyMSB(
+ text = choice.profile.name,
+ color = Colors.White,
+ )
+ },
+ trailing = {
+ PubkyContactAvatar(
+ profile = choice.profile,
+ size = 32.dp,
+ testTag = "PubkyChoiceRingAvatar",
+ )
+ },
+ modifier = Modifier.testTag("PubkyChoiceRing_${choice.pubky}")
+ )
}
@Composable
-private fun OptionCard(
- iconResId: Int,
- text: String,
+private fun ChoiceCard(
+ leading: @Composable () -> Unit,
+ content: @Composable () -> Unit,
+ enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
+ trailing: (@Composable () -> Unit)? = null,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -218,71 +271,61 @@ private fun OptionCard(
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Colors.Gray6)
- .clickable(onClick = onClick)
+ .clickable(enabled = enabled, onClick = onClick)
.padding(16.dp)
) {
- Box(
- contentAlignment = Alignment.Center,
- modifier = Modifier
- .size(40.dp)
- .background(Colors.Black, CircleShape)
- ) {
- Icon(
- painter = painterResource(iconResId),
- contentDescription = null,
- tint = Colors.PubkyGreen,
- modifier = Modifier.size(20.dp)
- )
- }
+ leading()
HorizontalSpacer(16.dp)
- BodyMSB(text = text, color = Colors.White)
+ Column(modifier = Modifier.weight(1f)) {
+ content()
+ }
+ trailing?.let {
+ HorizontalSpacer(12.dp)
+ it()
+ }
}
}
@Composable
-private fun WaitingForRingState(onCancel: () -> Unit) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth()
+private fun ChoiceIcon(iconResId: Int) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(40.dp)
+ .background(Colors.Black, CircleShape)
) {
- GradientCircularProgressIndicator(modifier = Modifier.size(20.dp))
- HorizontalSpacer(12.dp)
- BodyM(
- text = stringResource(R.string.profile__choice_waiting_ring),
- color = Colors.White64,
+ Icon(
+ painter = painterResource(iconResId),
+ contentDescription = null,
+ tint = Colors.PubkyGreen,
+ modifier = Modifier.size(20.dp)
)
}
- VerticalSpacer(16.dp)
- SecondaryButton(
- text = stringResource(R.string.common__cancel),
- onClick = onCancel,
- )
-}
-
-@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)
- }
}
@Preview(showBackground = true)
@Composable
private fun Preview() {
+ val pubky = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
AppThemeSurface {
Content(
- uiState = PubkyChoiceUiState(),
+ uiState = PubkyChoiceUiState(
+ identities = persistentListOf(
+ SharedPubkyChoice(
+ protocolVersion = SharedPubkyContract.PROTOCOL_VERSION,
+ sourcePackage = SharedPubkyContract.RING_SOURCE,
+ pubky = pubky,
+ profile = PubkyProfile.forDisplay(
+ publicKey = SharedPubkyContract.toBitkitPubky(pubky),
+ name = "Satoshi Nakamoto",
+ imageUrl = null,
+ ),
+ ),
+ ),
+ ),
onBackClick = {},
onCreateProfile = {},
- onImportWithRing = {},
- onCancelAuth = {},
- onDownloadRing = {},
- onDismissDialog = {},
+ onSelectRingIdentity = {},
)
}
}
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..00624aaaee 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,24 +1,27 @@
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.compose.runtime.Stable
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.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
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.data.sharing.SharedPubkyContract
+import to.bitkit.data.sharing.SharedPubkyIdentity
+import to.bitkit.models.PubkyProfile
import to.bitkit.models.Toast
import to.bitkit.repositories.PubkyRepo
import to.bitkit.ui.shared.toast.ToastEventBus
@@ -32,7 +35,6 @@ class PubkyChoiceViewModel @Inject constructor(
) : ViewModel() {
companion object {
private const val TAG = "PubkyChoiceViewModel"
- internal const val PUBKY_RING_PACKAGE = "to.pubky.ring"
}
private val _uiState = MutableStateFlow(PubkyChoiceUiState())
@@ -41,179 +43,102 @@ class PubkyChoiceViewModel @Inject constructor(
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) }
- }
- }
- viewModelScope.launch {
- pubkyRepo.isAuthenticated.collectLatest {
- if (it && approvalJob?.isActive != true && !_uiState.value.isLoadingAfterAuth) {
- _uiState.update { state -> state.copy(navigateToProfile = true) }
- }
- }
- }
+ refreshRingIdentities()
}
- override fun onCleared() {
- super.onCleared()
- if (_uiState.value.isWaitingForRing) {
- pubkyRepo.cancelAuthenticationSync()
- }
- }
-
- fun startRingAuth() {
+ fun refreshRingIdentities() {
viewModelScope.launch {
- if (_uiState.value.isWaitingForRing) {
- approvalJob?.cancel()
- approvalJob = null
- _uiState.update { it.copy(isWaitingForRing = false) }
- pubkyRepo.cancelAuthentication()
- }
-
- if (!isRingInstalled()) {
- showRingNotInstalledDialog()
- return@launch
- }
-
- pubkyRepo.startAuthentication()
- .onSuccess { authRequest ->
- val callbackAuthUrl = PubkyRingAuthUrlBuilder.addCallbacks(
- authUrl = authRequest.authUrl,
- nonce = authRequest.callbackNonce,
- ) ?: authRequest.authUrl
- val ringIntent = createRingAuthIntent(callbackAuthUrl)
- if (!canOpenWithRing(ringIntent)) {
- cancelAuthAndShowRingDialog()
- return@launch
+ _uiState.update { it.copy(isDiscovering = true) }
+ val choices = pubkyRepo.discoverRingIdentities()
+ .map { identities ->
+ coroutineScope {
+ identities.map { identity ->
+ async {
+ val bitkitPubky = SharedPubkyContract.toBitkitPubky(identity.pubky)
+ val profile = pubkyRepo.fetchRemoteProfile(bitkitPubky)
+ .getOrNull()
+ ?: PubkyProfile.placeholder(bitkitPubky)
+ SharedPubkyChoice(
+ protocolVersion = identity.protocolVersion,
+ sourcePackage = identity.sourcePackage,
+ pubky = identity.pubky,
+ profile = profile,
+ )
+ }
+ }.awaitAll()
}
-
- _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,
- )
+ Logger.info("Found no available Pubky Ring identities", context = TAG)
}
+ .getOrDefault(emptyList())
+ .sortedWith(compareBy({ it.profile.name.lowercase() }, { it.pubky }))
+ .toImmutableList()
+ _uiState.update { it.copy(isDiscovering = false, identities = choices) }
}
}
- fun onRingLaunchFailed() {
- viewModelScope.launch {
- cancelAuthAndShowRingDialog()
- }
- }
-
- @VisibleForTesting
- internal fun waitForApproval() {
- if (approvalJob?.isActive == true) return
+ fun selectRingIdentity(choice: SharedPubkyChoice) {
+ if (_uiState.value.selectedPubky != null) return
+ _uiState.update { it.copy(selectedPubky = choice.pubky) }
- approvalJob = viewModelScope.launch {
- pubkyRepo.completeAuthentication()
+ viewModelScope.launch {
+ pubkyRepo.adoptRingIdentity(choice.toIdentity())
.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 {
+ _uiState.update { state -> state.copy(selectedPubky = null) }
+ if (pubkyRepo.pendingImportContacts.value.isEmpty()) {
_effects.emit(PubkyChoiceEffect.NavigateToPayContacts)
+ } else {
+ _effects.emit(PubkyChoiceEffect.NavigateToContactImportOverview)
}
}
.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,
- )
+ handleSelectionFailure("Preparing shared profile failed", it)
}
}
.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,
- )
+ handleSelectionFailure("Connecting shared profile failed", it)
}
}
}
- 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 suspend fun handleSelectionFailure(message: String, error: Throwable) {
+ Logger.error(message, error, context = TAG)
+ _uiState.update { it.copy(selectedPubky = null) }
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__choice_error),
+ description = error.message,
+ )
+ refreshRingIdentities()
}
}
-@Immutable
+@Stable
data class PubkyChoiceUiState(
- val isWaitingForRing: Boolean = false,
- val isLoadingAfterAuth: Boolean = false,
- val showRingNotInstalledDialog: Boolean = false,
- val navigateToProfile: Boolean = false,
+ val isDiscovering: Boolean = false,
+ val identities: ImmutableList = persistentListOf(),
+ val selectedPubky: String? = null,
)
+@Stable
+data class SharedPubkyChoice(
+ val protocolVersion: Int,
+ val sourcePackage: String,
+ val pubky: String,
+ val profile: PubkyProfile,
+) {
+ fun toIdentity() = SharedPubkyIdentity(
+ protocolVersion = protocolVersion,
+ sourcePackage = sourcePackage,
+ pubky = pubky,
+ )
+}
+
sealed interface PubkyChoiceEffect {
- data class OpenRingAuth(val intent: Intent) : PubkyChoiceEffect
- data object NavigateToCreateProfile : PubkyChoiceEffect
data object NavigateToContactImportOverview : PubkyChoiceEffect
data object NavigateToPayContacts : PubkyChoiceEffect
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt
index f8d09a1694..fbf4b87664 100644
--- a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt
@@ -15,8 +15,10 @@ import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
@@ -66,6 +68,7 @@ import to.bitkit.models.sanitizedQrLogValue
import to.bitkit.ui.appViewModel
import to.bitkit.ui.components.PrimaryButton
import to.bitkit.ui.components.SecondaryButton
+import to.bitkit.ui.components.Text13Up
import to.bitkit.ui.components.TextInput
import to.bitkit.ui.components.VerticalSpacer
import to.bitkit.ui.scaffold.AppAlertDialog
@@ -85,6 +88,7 @@ private const val TAG = "QrScanningScreen"
fun QrScanningScreen(
onScanSuccess: (String) -> Unit,
onBack: (() -> Unit)? = null,
+ isPubkyScan: Boolean = false,
) {
val app = appViewModel ?: return
@@ -197,6 +201,7 @@ fun QrScanningScreen(
},
grantedContent = {
Content(
+ isPubkyScan = isPubkyScan,
previewView = previewView,
onClickFlashlight = {
isFlashlightOn = !isFlashlightOn
@@ -236,6 +241,7 @@ private fun handlePaste(
@Composable
private fun Content(
+ isPubkyScan: Boolean,
previewView: PreviewView,
onClickFlashlight: () -> Unit,
onClickGallery: () -> Unit,
@@ -292,6 +298,31 @@ private fun Content(
tint = Colors.White
)
}
+
+ if (isPubkyScan) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(16.dp)
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(999.dp))
+ .background(Colors.Black50)
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_broadcast),
+ contentDescription = null,
+ tint = Colors.White64,
+ modifier = Modifier.size(16.dp)
+ )
+ Text13Up(
+ text = stringResource(R.string.contacts__scanner_status),
+ color = Colors.White64,
+ )
+ }
+ }
}
VerticalSpacer(16.dp)
PrimaryButton(
@@ -301,7 +332,9 @@ private fun Content(
contentDescription = stringResource(R.string.other__qr_paste),
)
},
- text = stringResource(R.string.other__qr_paste),
+ text = stringResource(
+ if (isPubkyScan) R.string.contacts__scanner_paste else R.string.other__qr_paste
+ ),
onClick = onPasteFromClipboard,
)
diff --git a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt
index c371ed01c1..d838f61b93 100644
--- a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt
+++ b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt
@@ -14,6 +14,7 @@ import to.bitkit.models.ElectrumServer
import to.bitkit.models.addressTypeInfo
import to.bitkit.models.toAddressType
import to.bitkit.repositories.LightningRepo
+import to.bitkit.repositories.WatchOnlyAccountRepo
import javax.inject.Inject
private const val NODE_ID_PREFIX_LENGTH = 5
@@ -23,6 +24,7 @@ private const val ELECTRUM_HOST_PREFIX_LENGTH = 5
class AdvancedSettingsViewModel @Inject constructor(
private val settingsStore: SettingsStore,
private val lightningRepo: LightningRepo,
+ watchOnlyAccountRepo: WatchOnlyAccountRepo,
) : ViewModel() {
val selectedAddressTypeName = settingsStore.data
@@ -33,6 +35,9 @@ class AdvancedSettingsViewModel @Inject constructor(
.map { it.channels.filterOpen().size }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
+ val watchOnlyAccountCount = watchOnlyAccountRepo.currentWalletAccountCount
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
+
val truncatedNodeId = lightningRepo.lightningState
.map { it.nodeId.take(NODE_ID_PREFIX_LENGTH).ifEmpty { "" } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "")
diff --git a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt
index 11e708c96d..d8e88edde8 100644
--- a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt
@@ -47,7 +47,6 @@ import to.bitkit.ui.navigateToDefaultUnitSettings
import to.bitkit.ui.navigateToDevSettings
import to.bitkit.ui.navigateToLanguageSettings
import to.bitkit.ui.navigateToLocalCurrencySettings
-import to.bitkit.ui.navigateToPaymentPreferenceSettings
import to.bitkit.ui.navigateToPinManagement
import to.bitkit.ui.navigateToQuickPaySettings
import to.bitkit.ui.navigateToTagsSettings
@@ -96,6 +95,8 @@ fun SettingsScreen(
val notificationsGranted by settings.notificationsGranted.collectAsStateWithLifecycle()
val isPubkyAuthenticated by settings.isPubkyAuthenticated.collectAsStateWithLifecycle()
val isPaykitEnabled by settings.isPaykitEnabled.collectAsStateWithLifecycle()
+ val contactPaymentsEnabled by settings.contactPaymentsEnabled.collectAsStateWithLifecycle()
+ val isUpdatingContactPayments by settings.isUpdatingContactPayments.collectAsStateWithLifecycle()
val hardwareWallets by hwWalletViewModel.wallets.collectAsStateWithLifecycle()
val languageUiState by languageViewModel.uiState.collectAsStateWithLifecycle()
@@ -115,6 +116,7 @@ fun SettingsScreen(
val truncatedNodeId by advancedViewModel.truncatedNodeId.collectAsStateWithLifecycle()
val electrumHost by advancedViewModel.electrumHost.collectAsStateWithLifecycle()
val coinSelectAuto by advancedViewModel.coinSelectAuto.collectAsStateWithLifecycle()
+ val watchOnlyAccountCount by advancedViewModel.watchOnlyAccountCount.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { languageViewModel.fetchLanguageInfo() }
@@ -133,6 +135,8 @@ fun SettingsScreen(
notificationsGranted = notificationsGranted,
isPubkyAuthenticated = isPubkyAuthenticated,
isPaykitEnabled = isPaykitEnabled,
+ contactPaymentsEnabled = contactPaymentsEnabled,
+ isUpdatingContactPayments = isUpdatingContactPayments,
hardwareWalletCount = hardwareWallets.size,
),
securityState = SecurityTabState(
@@ -147,11 +151,13 @@ fun SettingsScreen(
),
advancedState = AdvancedTabState(
isDevModeEnabled = isDevModeEnabled,
+ isPaykitEnabled = isPaykitEnabled,
selectedAddressTypeName = selectedAddressTypeName,
coinSelectAuto = coinSelectAuto,
openChannelCount = openChannelCount,
truncatedNodeId = truncatedNodeId,
electrumHost = electrumHost,
+ watchOnlyAccountCount = watchOnlyAccountCount,
),
onEvent = { event ->
when (event) {
@@ -161,7 +167,9 @@ fun SettingsScreen(
SettingsEvent.WidgetsClick -> navController.navigateToWidgetsSettings()
SettingsEvent.TagsClick -> navController.navigateToTagsSettings()
SettingsEvent.TransactionSpeedClick -> navController.navigateToTransactionSpeedSettings()
- SettingsEvent.PaymentPreferenceClick -> navController.navigateToPaymentPreferenceSettings()
+ SettingsEvent.ContactPaymentsClick -> {
+ settings.setContactPaymentsEnabled(!contactPaymentsEnabled)
+ }
SettingsEvent.QuickPayClick -> navController.navigateToQuickPaySettings(quickPayIntroSeen)
SettingsEvent.BgPaymentsClick -> {
if (bgPaymentsIntroSeen || notificationsGranted) {
@@ -195,6 +203,7 @@ fun SettingsScreen(
SettingsEvent.AddressTypeClick -> navController.navigateTo(Routes.AddressTypePreference)
SettingsEvent.CoinSelectionClick -> navController.navigateTo(Routes.CoinSelectPreference)
SettingsEvent.AddressViewerClick -> navController.navigateTo(Routes.AddressViewer)
+ SettingsEvent.WatchOnlyAccountsClick -> navController.navigateTo(Routes.WatchOnlyAccounts)
SettingsEvent.LightningConnectionsClick -> navController.navigateTo(Routes.LightningConnections)
SettingsEvent.LightningNodeClick -> navController.navigateTo(Routes.NodeInfo)
SettingsEvent.ElectrumServerClick -> navController.navigateTo(Routes.ElectrumConfig)
@@ -327,6 +336,17 @@ private fun GeneralTabContent(
padding = PaddingValues(top = 16.dp),
)
+ if (state.isPaykitEnabled && state.isPubkyAuthenticated) {
+ SettingsSwitchRow(
+ title = stringResource(R.string.settings__general__enable_contact_payments),
+ isChecked = state.contactPaymentsEnabled,
+ icon = { SettingsIcon(R.drawable.ic_coins) },
+ onClick = { onEvent(SettingsEvent.ContactPaymentsClick) },
+ enabled = !state.isUpdatingContactPayments,
+ switchTestTag = "ContactPaymentsSwitch",
+ modifier = Modifier.testTag("ContactPaymentsSettings")
+ )
+ }
SettingsButtonRow(
title = stringResource(R.string.settings__general__speed),
icon = {
@@ -342,14 +362,6 @@ private fun GeneralTabContent(
onClick = { onEvent(SettingsEvent.TransactionSpeedClick) },
modifier = Modifier.testTag("TransactionSpeedSettings")
)
- if (state.isPaykitEnabled && state.isPubkyAuthenticated) {
- SettingsButtonRow(
- title = stringResource(R.string.settings__payment_pref_title),
- icon = { SettingsIcon(R.drawable.ic_coins) },
- onClick = { onEvent(SettingsEvent.PaymentPreferenceClick) },
- modifier = Modifier.testTag("PaymentPreferenceSettings")
- )
- }
SettingsButtonRow(
title = stringResource(R.string.settings__quickpay__nav_title),
icon = { SettingsIcon(R.drawable.ic_caret_double_right) },
@@ -557,6 +569,15 @@ private fun AdvancedTabContent(
onClick = { onEvent(SettingsEvent.AddressViewerClick) },
modifier = Modifier.testTag("AddressViewer")
)
+ if (state.isPaykitEnabled) {
+ SettingsButtonRow(
+ title = stringResource(R.string.watch_only_accounts__title),
+ icon = { SettingsIcon(R.drawable.ic_lock_key) },
+ value = SettingsButtonValue.StringValue(state.watchOnlyAccountCount.toString()),
+ onClick = { onEvent(SettingsEvent.WatchOnlyAccountsClick) },
+ modifier = Modifier.testTag("WatchOnlyAccounts")
+ )
+ }
SectionHeader(
title = stringResource(R.string.settings__adv__section_networks),
@@ -660,7 +681,7 @@ sealed interface SettingsEvent {
data object WidgetsClick : SettingsEvent
data object TagsClick : SettingsEvent
data object TransactionSpeedClick : SettingsEvent
- data object PaymentPreferenceClick : SettingsEvent
+ data object ContactPaymentsClick : SettingsEvent
data object QuickPayClick : SettingsEvent
data object BgPaymentsClick : SettingsEvent
data object HardwareWalletsClick : SettingsEvent
@@ -682,6 +703,7 @@ sealed interface SettingsEvent {
data object AddressTypeClick : SettingsEvent
data object CoinSelectionClick : SettingsEvent
data object AddressViewerClick : SettingsEvent
+ data object WatchOnlyAccountsClick : SettingsEvent
data object LightningConnectionsClick : SettingsEvent
data object LightningNodeClick : SettingsEvent
data object ElectrumServerClick : SettingsEvent
@@ -706,6 +728,8 @@ data class GeneralTabState(
val notificationsGranted: Boolean = false,
val isPubkyAuthenticated: Boolean = false,
val isPaykitEnabled: Boolean = false,
+ val contactPaymentsEnabled: Boolean = false,
+ val isUpdatingContactPayments: Boolean = false,
val hardwareWalletCount: Int = 0,
)
@@ -724,9 +748,11 @@ data class SecurityTabState(
@Immutable
data class AdvancedTabState(
val isDevModeEnabled: Boolean = false,
+ val isPaykitEnabled: Boolean = false,
val selectedAddressTypeName: String = "",
val coinSelectAuto: Boolean = true,
val openChannelCount: Int = 0,
val truncatedNodeId: String = "",
val electrumHost: String = "",
+ val watchOnlyAccountCount: Int = 0,
)
diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsScreen.kt
new file mode 100644
index 0000000000..247e35a2f5
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsScreen.kt
@@ -0,0 +1,286 @@
+package to.bitkit.ui.settings.advanced
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.NavController
+import kotlinx.collections.immutable.ImmutableList
+import to.bitkit.R
+import to.bitkit.models.Toast
+import to.bitkit.models.WatchOnlyAccountRecord
+import to.bitkit.models.WatchOnlyAccountSetupState
+import to.bitkit.ui.appViewModel
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.BodySSB
+import to.bitkit.ui.components.BottomSheet
+import to.bitkit.ui.components.ButtonSize
+import to.bitkit.ui.components.Caption13Up
+import to.bitkit.ui.components.SecondaryButton
+import to.bitkit.ui.components.TextInput
+import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.components.settings.SectionHeader
+import to.bitkit.ui.components.settings.SettingsButtonRow
+import to.bitkit.ui.components.settings.SettingsSwitchRow
+import to.bitkit.ui.scaffold.AppTopBar
+import to.bitkit.ui.scaffold.ScreenColumn
+import to.bitkit.ui.scaffold.SheetTopBar
+import to.bitkit.ui.theme.Colors
+import to.bitkit.ui.utils.copyToClipboard
+
+@Composable
+fun WatchOnlyAccountsScreen(
+ navController: NavController,
+ viewModel: WatchOnlyAccountsViewModel = hiltViewModel(),
+) {
+ val accounts by viewModel.accounts.collectAsStateWithLifecycle()
+ val isUpdating by viewModel.isUpdating.collectAsStateWithLifecycle()
+
+ Content(
+ accounts = accounts,
+ isUpdating = isUpdating,
+ onBack = { navController.popBackStack() },
+ onRename = viewModel::rename,
+ onTrackingChange = viewModel::setTrackingEnabled,
+ )
+}
+
+@Composable
+private fun Content(
+ accounts: ImmutableList,
+ isUpdating: Boolean,
+ onBack: () -> Unit,
+ onRename: (WatchOnlyAccountRecord, String) -> Unit,
+ onTrackingChange: (WatchOnlyAccountRecord, Boolean) -> Unit,
+) {
+ val activeAccounts = accounts.filter { it.setupState == WatchOnlyAccountSetupState.Active }
+ val pendingAccounts = accounts.filter { it.setupState != WatchOnlyAccountSetupState.Active }
+ var selectedAccount by remember { mutableStateOf(null) }
+
+ ScreenColumn(modifier = Modifier.testTag("WatchOnlyAccountsScreen")) {
+ AppTopBar(
+ titleText = stringResource(R.string.watch_only_accounts__title),
+ onBackClick = onBack,
+ )
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp),
+ ) {
+ item {
+ BodyM(
+ text = stringResource(R.string.watch_only_accounts__description),
+ color = Colors.White64,
+ )
+ VerticalSpacer(8.dp)
+ }
+ if (accounts.isEmpty()) {
+ item { EmptyState() }
+ } else {
+ if (activeAccounts.isNotEmpty()) {
+ item {
+ SectionHeader(stringResource(R.string.watch_only_accounts__active_section))
+ }
+ items(activeAccounts, key = WatchOnlyAccountRecord::id) { account ->
+ ActiveAccountRows(
+ account = account,
+ isUpdating = isUpdating,
+ onOpenDetails = { selectedAccount = account },
+ onTrackingChange = onTrackingChange,
+ )
+ }
+ }
+
+ if (pendingAccounts.isNotEmpty()) {
+ item {
+ SectionHeader(
+ title = stringResource(R.string.watch_only_accounts__pending_section),
+ color = Colors.Yellow,
+ )
+ BodyM(
+ text = stringResource(R.string.watch_only_accounts__pending_description),
+ color = Colors.White64,
+ )
+ VerticalSpacer(8.dp)
+ }
+ items(pendingAccounts, key = WatchOnlyAccountRecord::id) { account ->
+ PendingAccountRow(
+ account = account,
+ onOpenDetails = { selectedAccount = account },
+ )
+ }
+ }
+ }
+ item { VerticalSpacer(32.dp) }
+ }
+ }
+
+ selectedAccount?.let { account ->
+ AccountDetailsSheet(
+ account = account,
+ onRename = { name -> onRename(account, name) },
+ onDismiss = { selectedAccount = null },
+ )
+ }
+}
+
+@Composable
+private fun ActiveAccountRows(
+ account: WatchOnlyAccountRecord,
+ isUpdating: Boolean,
+ onOpenDetails: () -> Unit,
+ onTrackingChange: (WatchOnlyAccountRecord, Boolean) -> Unit,
+) {
+ Column(modifier = Modifier.testTag("WatchOnlyAccount_${account.accountIndex}")) {
+ SettingsButtonRow(
+ title = account.name,
+ subtitle = account.derivationPath,
+ onClick = onOpenDetails,
+ )
+ SettingsSwitchRow(
+ title = stringResource(R.string.watch_only_accounts__tracking),
+ isChecked = account.isTrackingEnabled,
+ onClick = { onTrackingChange(account, !account.isTrackingEnabled) },
+ enabled = !isUpdating,
+ switchTestTag = "WatchOnlyAccountTracking_${account.accountIndex}",
+ )
+ VerticalSpacer(8.dp)
+ }
+}
+
+@Composable
+private fun PendingAccountRow(
+ account: WatchOnlyAccountRecord,
+ onOpenDetails: () -> Unit,
+) {
+ SettingsButtonRow(
+ title = account.name,
+ subtitle = account.derivationPath,
+ description = stringResource(R.string.watch_only_accounts__setup_not_confirmed),
+ onClick = onOpenDetails,
+ modifier = Modifier.testTag("WatchOnlyAccount_${account.accountIndex}"),
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun AccountDetailsSheet(
+ account: WatchOnlyAccountRecord,
+ onRename: (String) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ var name by remember(account.id, account.name) { mutableStateOf(account.name) }
+ val context = LocalContext.current
+ val app = appViewModel
+ val copyXpub = copyToClipboard(account.xpub) {
+ app?.toast(
+ type = Toast.ToastType.SUCCESS,
+ title = context.getString(R.string.common__copied),
+ )
+ }
+
+ BottomSheet(
+ onDismissRequest = onDismiss,
+ modifier = Modifier.imePadding(),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(horizontal = 16.dp)
+ .testTag("WatchOnlyAccountDetails_${account.accountIndex}"),
+ ) {
+ SheetTopBar(titleText = stringResource(R.string.watch_only_accounts__details_title))
+
+ if (account.setupState != WatchOnlyAccountSetupState.Active) {
+ BodyM(
+ text = stringResource(R.string.watch_only_accounts__setup_not_finished),
+ color = Colors.Yellow,
+ modifier = Modifier.testTag("WatchOnlyAccountPending_${account.accountIndex}"),
+ )
+ VerticalSpacer(24.dp)
+ }
+
+ Caption13Up(stringResource(R.string.watch_only_accounts__name), color = Colors.White64)
+ VerticalSpacer(8.dp)
+ TextInput(
+ value = name,
+ onValueChange = { name = it },
+ placeholder = stringResource(R.string.watch_only_accounts__name_placeholder),
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("WatchOnlyAccountName_${account.accountIndex}"),
+ )
+
+ VerticalSpacer(24.dp)
+ Caption13Up(stringResource(R.string.watch_only_accounts__xpub), color = Colors.White64)
+ VerticalSpacer(8.dp)
+ BodyM(
+ text = account.xpub,
+ color = Colors.White64,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.testTag("WatchOnlyAccountXpub_${account.accountIndex}"),
+ )
+
+ VerticalSpacer(24.dp)
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ SecondaryButton(
+ text = stringResource(R.string.watch_only_accounts__save_name),
+ onClick = { onRename(name) },
+ size = ButtonSize.Small,
+ modifier = Modifier
+ .weight(1f)
+ .testTag("WatchOnlyAccountSaveName_${account.accountIndex}"),
+ )
+ SecondaryButton(
+ text = stringResource(R.string.watch_only_accounts__copy_xpub),
+ onClick = copyXpub,
+ size = ButtonSize.Small,
+ modifier = Modifier
+ .weight(1f)
+ .testTag("WatchOnlyAccountCopyXpub_${account.accountIndex}"),
+ )
+ }
+ VerticalSpacer(24.dp)
+ }
+ }
+}
+
+@Composable
+private fun EmptyState() {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 24.dp)
+ .testTag("WatchOnlyAccountsEmpty"),
+ ) {
+ BodySSB(stringResource(R.string.watch_only_accounts__empty_title))
+ VerticalSpacer(8.dp)
+ BodyM(
+ text = stringResource(R.string.watch_only_accounts__empty_description),
+ color = Colors.White64,
+ )
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsViewModel.kt
new file mode 100644
index 0000000000..09d8f925dc
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsViewModel.kt
@@ -0,0 +1,82 @@
+package to.bitkit.ui.settings.advanced
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import to.bitkit.R
+import to.bitkit.ext.runSuspendCatching
+import to.bitkit.models.Toast
+import to.bitkit.models.WatchOnlyAccountRecord
+import to.bitkit.repositories.WatchOnlyAccountRepo
+import to.bitkit.ui.shared.toast.ToastEventBus
+import to.bitkit.ui.utils.localizedPubkyAuthMessage
+import javax.inject.Inject
+
+@HiltViewModel
+class WatchOnlyAccountsViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val watchOnlyAccountRepo: WatchOnlyAccountRepo,
+) : ViewModel() {
+ private val _isUpdating = MutableStateFlow(false)
+ val isUpdating = _isUpdating.asStateFlow()
+
+ val accounts = watchOnlyAccountRepo.currentWalletAccounts
+ .map { it.toImmutableList() }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), persistentListOf())
+
+ fun rename(account: WatchOnlyAccountRecord, name: String) {
+ viewModelScope.launch {
+ runSuspendCatching { watchOnlyAccountRepo.rename(account.id, name) }
+ .onSuccess {
+ ToastEventBus.send(
+ type = Toast.ToastType.SUCCESS,
+ title = context.getString(R.string.watch_only_accounts__name_saved),
+ )
+ }
+ .onFailure { showError(it) }
+ }
+ }
+
+ fun setTrackingEnabled(account: WatchOnlyAccountRecord, enabled: Boolean) {
+ viewModelScope.launch {
+ _isUpdating.update { true }
+ try {
+ runSuspendCatching {
+ watchOnlyAccountRepo.setTrackingEnabled(account.id, enabled)
+ }.onSuccess {
+ ToastEventBus.send(
+ type = Toast.ToastType.SUCCESS,
+ title = context.getString(
+ if (enabled) {
+ R.string.watch_only_accounts__tracking_enabled
+ } else {
+ R.string.watch_only_accounts__tracking_disabled
+ }
+ ),
+ )
+ }.onFailure { error -> showError(error) }
+ } finally {
+ _isUpdating.update { false }
+ }
+ }
+ }
+
+ private suspend fun showError(error: Throwable) {
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.common__error),
+ description = error.localizedPubkyAuthMessage(context),
+ )
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceScreen.kt b/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceScreen.kt
deleted file mode 100644
index 520d07e4eb..0000000000
--- a/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceScreen.kt
+++ /dev/null
@@ -1,145 +0,0 @@
-package to.bitkit.ui.settings.paymentPreference
-
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import to.bitkit.R
-import to.bitkit.ui.components.BodyM
-import to.bitkit.ui.components.BodyS
-import to.bitkit.ui.components.VerticalSpacer
-import to.bitkit.ui.components.settings.SectionHeader
-import to.bitkit.ui.components.settings.SettingsSwitchRow
-import to.bitkit.ui.scaffold.AppTopBar
-import to.bitkit.ui.scaffold.DrawerNavIcon
-import to.bitkit.ui.scaffold.ScreenColumn
-import to.bitkit.ui.theme.AppThemeSurface
-import to.bitkit.ui.theme.Colors
-
-@Composable
-fun PaymentPreferenceScreen(
- onBack: () -> Unit,
- viewModel: PaymentPreferenceViewModel = hiltViewModel(),
-) {
- val uiState by viewModel.uiState.collectAsStateWithLifecycle()
-
- PaymentPreferenceContent(
- uiState = uiState,
- onBack = onBack,
- onToggleLightning = { viewModel.setLightningEnabled(!uiState.lightningEnabled) },
- onToggleOnchain = { viewModel.setOnchainEnabled(!uiState.onchainEnabled) },
- onTogglePrivateContacts = { viewModel.setPrivateContactsEnabled(!uiState.privateContactsEnabled) },
- onTogglePublicContacts = { viewModel.setPublicContactsEnabled(!uiState.publicContactsEnabled) },
- )
-}
-
-@Composable
-private fun PaymentPreferenceContent(
- uiState: PaymentPreferenceUiState,
- onBack: () -> Unit = {},
- onToggleLightning: () -> Unit = {},
- onToggleOnchain: () -> Unit = {},
- onTogglePrivateContacts: () -> Unit = {},
- onTogglePublicContacts: () -> Unit = {},
-) {
- ScreenColumn {
- AppTopBar(
- titleText = stringResource(R.string.settings__payment_pref_title),
- onBackClick = onBack,
- actions = { DrawerNavIcon() },
- )
-
- Column(
- modifier = Modifier
- .padding(horizontal = 16.dp)
- .verticalScroll(rememberScrollState())
- .testTag("PaymentPreferenceScreen")
- ) {
- BodyM(
- text = stringResource(R.string.settings__payment_pref_header),
- color = Colors.White64,
- modifier = Modifier.padding(top = 32.dp, bottom = 16.dp)
- )
-
- SectionHeader(
- title = stringResource(R.string.settings__payment_pref_options),
- padding = PaddingValues.Zero,
- )
-
- SettingsSwitchRow(
- title = stringResource(R.string.settings__payment_pref_lightning),
- isChecked = uiState.lightningEnabled,
- onClick = onToggleLightning,
- enabled = !uiState.isUpdatingPaymentOptions && (!uiState.lightningEnabled || uiState.onchainEnabled),
- modifier = Modifier.testTag("PaymentPreferenceLightning")
- )
- SettingsSwitchRow(
- title = stringResource(R.string.settings__payment_pref_onchain),
- isChecked = uiState.onchainEnabled,
- onClick = onToggleOnchain,
- enabled = !uiState.isUpdatingPaymentOptions && (!uiState.onchainEnabled || uiState.lightningEnabled),
- modifier = Modifier.testTag("PaymentPreferenceOnchain")
- )
-
- if (uiState.hasPubkyProfile) {
- SectionHeader(
- title = stringResource(R.string.settings__payment_pref_contacts),
- padding = PaddingValues(top = 16.dp),
- )
-
- if (uiState.canUsePrivateContacts) {
- SettingsSwitchRow(
- title = stringResource(R.string.settings__payment_pref_private_contacts),
- isChecked = uiState.privateContactsEnabled,
- onClick = onTogglePrivateContacts,
- enabled = !uiState.isUpdatingPrivateContacts,
- modifier = Modifier.testTag("PaymentPreferencePrivateContacts")
- )
- }
- SettingsSwitchRow(
- title = stringResource(R.string.settings__payment_pref_public_contacts),
- isChecked = uiState.publicContactsEnabled,
- onClick = onTogglePublicContacts,
- enabled = !uiState.isUpdatingPublicContacts,
- modifier = Modifier.testTag("PaymentPreferencePublicContacts")
- )
- }
-
- VerticalSpacer(220.dp)
- if (uiState.hasPubkyProfile) {
- BodyS(
- text = stringResource(R.string.settings__payment_pref_contacts_footer),
- color = Colors.White64,
- )
- }
- VerticalSpacer(32.dp)
- }
- }
-}
-
-@Preview(showBackground = true)
-@Composable
-private fun Preview() {
- AppThemeSurface {
- PaymentPreferenceContent(
- uiState = PaymentPreferenceUiState(
- lightningEnabled = true,
- onchainEnabled = true,
- privateContactsEnabled = true,
- publicContactsEnabled = true,
- hasPubkyProfile = true,
- canUsePrivateContacts = true,
- ),
- )
- }
-}
diff --git a/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModel.kt
deleted file mode 100644
index 06d703c1bc..0000000000
--- a/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModel.kt
+++ /dev/null
@@ -1,326 +0,0 @@
-package to.bitkit.ui.settings.paymentPreference
-
-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.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
-import to.bitkit.R
-import to.bitkit.data.SettingsData
-import to.bitkit.data.SettingsStore
-import to.bitkit.ext.runSuspendCatching
-import to.bitkit.models.Toast
-import to.bitkit.repositories.PrivatePaykitRepo
-import to.bitkit.repositories.PubkyRepo
-import to.bitkit.repositories.PublicPaykitError
-import to.bitkit.repositories.PublicPaykitRepo
-import to.bitkit.ui.shared.toast.ToastEventBus
-import javax.inject.Inject
-
-@HiltViewModel
-class PaymentPreferenceViewModel @Inject constructor(
- @ApplicationContext private val context: Context,
- private val settingsStore: SettingsStore,
- private val publicPaykitRepo: PublicPaykitRepo,
- private val privatePaykitRepo: PrivatePaykitRepo,
- private val pubkyRepo: PubkyRepo,
-) : ViewModel() {
- private val _uiState = MutableStateFlow(PaymentPreferenceUiState())
- val uiState: StateFlow = _uiState.asStateFlow()
- private val privateContactsPendingValue = MutableStateFlow(null)
-
- init {
- viewModelScope.launch {
- combine(
- settingsStore.data,
- pubkyRepo.isAuthenticated,
- privateContactsPendingValue,
- ) { settings, isAuthenticated, pendingPrivateContactsEnabled ->
- val canUsePrivateContacts = isAuthenticated && pubkyRepo.hasSecretKey()
- if (!canUsePrivateContacts && settings.sharesPrivatePaykitEndpoints) {
- settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) }
- }
- PaymentPreferenceStateSource(
- settings = settings,
- isAuthenticated = isAuthenticated,
- canUsePrivateContacts = canUsePrivateContacts,
- pendingPrivateContactsEnabled = pendingPrivateContactsEnabled,
- )
- }.collect { stateSource ->
- _uiState.update { it.from(stateSource) }
- }
- }
- }
-
- fun setLightningEnabled(isEnabled: Boolean) {
- updatePaymentMethod(lightningEnabled = isEnabled)
- }
-
- fun setOnchainEnabled(isEnabled: Boolean) {
- updatePaymentMethod(onchainEnabled = isEnabled)
- }
-
- fun setPrivateContactsEnabled(isEnabled: Boolean) {
- if (_uiState.value.isUpdatingPrivateContacts) return
- if (isEnabled && !_uiState.value.hasPubkyProfile) {
- viewModelScope.launch { showSyncError(PublicPaykitError.SessionNotActive) }
- return
- }
- if (isEnabled && !_uiState.value.canUsePrivateContacts) {
- viewModelScope.launch { showSyncError(PublicPaykitError.SessionNotActive) }
- return
- }
- viewModelScope.launch {
- val previous = settingsStore.data.first()
- privateContactsPendingValue.update { _uiState.value.privateContactsEnabled }
- _uiState.update { it.copy(isUpdatingPrivateContacts = true) }
- val result = runSuspendCatching {
- settingsStore.update {
- it.copy(
- hasConfirmedPublicPaykitEndpoints = true,
- sharesPrivatePaykitEndpoints = isEnabled,
- )
- }
- if (isEnabled) {
- privatePaykitRepo.enableSharingAndPrepareSavedContacts(
- publicKeys = contactPublicKeys(),
- requireImmediatePublication = true,
- ).getOrThrow()
- } else {
- privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contactPublicKeys()).getOrThrow()
- }
- if (!previous.sharesPublicPaykitEndpoints) {
- publicPaykitRepo.syncLocalReceiverMarker(privateSharingEnabled = isEnabled).getOrThrow()
- }
- }
-
- result.exceptionOrNull()?.let {
- rollbackPrivateContactsPreference(
- requestedEnabled = isEnabled,
- previous = previous,
- error = it,
- )
- showSyncError(it)
- }
- privateContactsPendingValue.update { null }
- _uiState.update { it.copy(isUpdatingPrivateContacts = false) }
- }
- }
-
- fun setPublicContactsEnabled(isEnabled: Boolean) {
- if (_uiState.value.isUpdatingPublicContacts) return
- if (isEnabled && !_uiState.value.hasPubkyProfile) {
- viewModelScope.launch { showSyncError(PublicPaykitError.SessionNotActive) }
- return
- }
- viewModelScope.launch {
- _uiState.update { it.copy(isUpdatingPublicContacts = true) }
- val previous = settingsStore.data.first()
- settingsStore.update {
- it.copy(
- hasConfirmedPublicPaykitEndpoints = true,
- sharesPublicPaykitEndpoints = isEnabled,
- )
- }
-
- publicPaykitRepo.syncPublishedEndpoints(publish = isEnabled).exceptionOrNull()?.let { error ->
- rollbackPublicContactsPreference(previous, error)
- showSyncError(error)
- }
- _uiState.update { it.copy(isUpdatingPublicContacts = false) }
- }
- }
-
- private fun updatePaymentMethod(
- lightningEnabled: Boolean = _uiState.value.lightningEnabled,
- onchainEnabled: Boolean = _uiState.value.onchainEnabled,
- ) {
- if (_uiState.value.isUpdatingPaymentOptions) return
- if (!lightningEnabled && !onchainEnabled) {
- viewModelScope.launch {
- ToastEventBus.send(
- type = Toast.ToastType.WARNING,
- title = context.getString(R.string.common__error),
- description = context.getString(R.string.settings__payment_pref_keep_one),
- )
- }
- return
- }
-
- viewModelScope.launch {
- _uiState.update { it.copy(isUpdatingPaymentOptions = true) }
- val previous = settingsStore.data.first()
- settingsStore.update {
- it.copy(
- publicPaykitLightningEnabled = lightningEnabled,
- publicPaykitOnchainEnabled = onchainEnabled,
- )
- }
-
- val result = refreshPublishedPreferences()
- result.exceptionOrNull()?.let {
- settingsStore.update { settings ->
- settings.copy(
- publicPaykitLightningEnabled = previous.publicPaykitLightningEnabled,
- publicPaykitOnchainEnabled = previous.publicPaykitOnchainEnabled,
- )
- }
- refreshPublishedPreferences()
- showSyncError(it)
- }
- _uiState.update { it.copy(isUpdatingPaymentOptions = false) }
- }
- }
-
- private suspend fun refreshPublishedPreferences(): Result = runSuspendCatching {
- val settings = settingsStore.data.first()
- if (settings.sharesPublicPaykitEndpoints) {
- publicPaykitRepo.syncCurrentPublishedEndpoints(
- forceRefreshLightning = true,
- requireEndpoint = true,
- ).getOrThrow()
- }
- if (settings.sharesPrivatePaykitEndpoints) {
- if (pubkyRepo.hasSecretKey()) {
- privatePaykitRepo.prepareSavedContacts(
- publicKeys = contactPublicKeys(),
- requireImmediatePublication = true,
- ).getOrThrow()
- } else {
- settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) }
- publicPaykitRepo.syncLocalReceiverMarker(privateSharingEnabled = false).getOrThrow()
- }
- }
- }
-
- private fun contactPublicKeys(): List =
- pubkyRepo.contacts.value.map { it.publicKey }
-
- private suspend fun rollbackPublicContactsPreference(previous: SettingsData, error: Throwable) {
- runSuspendCatching {
- settingsStore.update { settings ->
- settings.copy(sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints)
- }
- }.onFailure(error::addSuppressed)
-
- publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints)
- .onFailure { rollbackError ->
- error.addSuppressed(rollbackError)
- runSuspendCatching {
- settingsStore.update { it.copy(publicPaykitCleanupPending = true) }
- }.onFailure(error::addSuppressed)
- }
- }
-
- private suspend fun rollbackPrivateContactsPreference(
- requestedEnabled: Boolean,
- previous: SettingsData,
- error: Throwable,
- ) {
- val contacts = contactPublicKeys()
- if (!requestedEnabled && previous.sharesPrivatePaykitEndpoints) {
- restorePrivateContactsPreference(contacts, error)
- return
- }
-
- runSuspendCatching {
- settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = previous.sharesPrivatePaykitEndpoints) }
- }.onFailure(error::addSuppressed)
- if (!previous.sharesPublicPaykitEndpoints) {
- publicPaykitRepo.syncLocalReceiverMarker(privateSharingEnabled = previous.sharesPrivatePaykitEndpoints)
- .onFailure(error::addSuppressed)
- }
-
- if (requestedEnabled && !previous.sharesPrivatePaykitEndpoints) {
- privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts)
- .onFailure(error::addSuppressed)
- }
- }
-
- private suspend fun restorePrivateContactsPreference(
- contacts: List,
- error: Throwable,
- ) {
- val preferenceRestored = updatePrivateContactsPreference(isEnabled = true, error = error)
- if (!preferenceRestored) return
-
- privatePaykitRepo.prepareSavedContacts(
- publicKeys = contacts,
- requireImmediatePublication = true,
- ).exceptionOrNull()?.let {
- error.addSuppressed(it)
- updatePrivateContactsPreference(isEnabled = false, error = error)
- publicPaykitRepo.syncLocalReceiverMarker().onFailure(error::addSuppressed)
- return
- }
-
- privatePaykitRepo.setContactSharingCleanupPending(false).exceptionOrNull()?.let {
- error.addSuppressed(it)
- updatePrivateContactsPreference(isEnabled = false, error = error)
- }
- publicPaykitRepo.syncLocalReceiverMarker().onFailure(error::addSuppressed)
- }
-
- private suspend fun updatePrivateContactsPreference(
- isEnabled: Boolean,
- error: Throwable,
- ): Boolean = runSuspendCatching {
- settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = isEnabled) }
- }.onFailure(error::addSuppressed).isSuccess
-
- private suspend fun showSyncError(error: Throwable) {
- ToastEventBus.send(
- type = Toast.ToastType.ERROR,
- title = context.getString(R.string.common__error),
- description = when (error) {
- PublicPaykitError.InvalidPayload ->
- context.getString(R.string.profile__pay_contacts_error_invalid_payload)
-
- PublicPaykitError.NoSupportedEndpoint ->
- context.getString(R.string.profile__pay_contacts_error_no_endpoint)
-
- PublicPaykitError.SessionNotActive -> context.getString(R.string.profile__session_expired)
- PublicPaykitError.WalletNotReady -> context.getString(R.string.profile__pay_contacts_error_wallet)
- else -> context.getString(R.string.common__error_body)
- },
- )
- }
-}
-
-@Immutable
-data class PaymentPreferenceUiState(
- val lightningEnabled: Boolean = true,
- val onchainEnabled: Boolean = true,
- val privateContactsEnabled: Boolean = false,
- val publicContactsEnabled: Boolean = false,
- val hasPubkyProfile: Boolean = false,
- val canUsePrivateContacts: Boolean = false,
- val isUpdatingPaymentOptions: Boolean = false,
- val isUpdatingPrivateContacts: Boolean = false,
- val isUpdatingPublicContacts: Boolean = false,
-)
-
-private data class PaymentPreferenceStateSource(
- val settings: SettingsData,
- val isAuthenticated: Boolean,
- val canUsePrivateContacts: Boolean,
- val pendingPrivateContactsEnabled: Boolean?,
-)
-
-private fun PaymentPreferenceUiState.from(source: PaymentPreferenceStateSource) = copy(
- lightningEnabled = source.settings.publicPaykitLightningEnabled,
- onchainEnabled = source.settings.publicPaykitOnchainEnabled,
- privateContactsEnabled = source.pendingPrivateContactsEnabled
- ?: (source.settings.sharesPrivatePaykitEndpoints && source.canUsePrivateContacts),
- publicContactsEnabled = source.settings.sharesPublicPaykitEndpoints,
- hasPubkyProfile = source.isAuthenticated,
- canUsePrivateContacts = source.canUsePrivateContacts,
-)
diff --git a/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt b/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt
index eb37d5ad13..515f5e1ab0 100644
--- a/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt
+++ b/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt
@@ -46,6 +46,7 @@ fun Modifier.sheetHeight(
maxOf(preferred, min)
}
+ SheetSize.COMPACT -> 460.dp + Insets.Bottom
SheetSize.SMALL -> 400.dp + Insets.Bottom
}
diff --git a/app/src/main/java/to/bitkit/ui/sheets/QrScanningSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/QrScanningSheet.kt
index 0b6671fbda..d0086f37a6 100644
--- a/app/src/main/java/to/bitkit/ui/sheets/QrScanningSheet.kt
+++ b/app/src/main/java/to/bitkit/ui/sheets/QrScanningSheet.kt
@@ -4,13 +4,18 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import to.bitkit.ui.components.Sheet
import to.bitkit.ui.screens.scanner.QrScanningScreen
import to.bitkit.ui.shared.modifiers.sheetHeight
import to.bitkit.viewmodels.AppViewModel
@Composable
-fun QrScanningSheet(appViewModel: AppViewModel) {
+fun QrScanningSheet(
+ sheet: Sheet.QrScanner,
+ appViewModel: AppViewModel,
+) {
Content(
+ isPubkyScan = sheet.isPubkyScan,
onBack = { appViewModel.hideScannerSheet() },
onScanSuccess = { appViewModel.onScannerSheetResult(it) },
)
@@ -18,6 +23,7 @@ fun QrScanningSheet(appViewModel: AppViewModel) {
@Composable
private fun Content(
+ isPubkyScan: Boolean,
onBack: () -> Unit,
onScanSuccess: (String) -> Unit,
) {
@@ -27,6 +33,7 @@ private fun Content(
.sheetHeight()
) {
QrScanningScreen(
+ isPubkyScan = isPubkyScan,
onScanSuccess = onScanSuccess,
onBack = onBack,
)
diff --git a/app/src/main/java/to/bitkit/ui/theme/Colors.kt b/app/src/main/java/to/bitkit/ui/theme/Colors.kt
index 58a6d4917f..a588b28d40 100644
--- a/app/src/main/java/to/bitkit/ui/theme/Colors.kt
+++ b/app/src/main/java/to/bitkit/ui/theme/Colors.kt
@@ -10,7 +10,7 @@ object Colors {
val Purple = Color(0xFFB95CE8)
val Red = Color(0xFFE95164)
val Yellow = Color(0xFFFFD200)
- val PubkyGreen = Color(0xFFBEFF00)
+ val PubkyGreen = Color(0xFFC8FF00)
val Bitcoin = Color(0xFFF7931A)
// Base
diff --git a/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt b/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt
new file mode 100644
index 0000000000..f683c6335d
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt
@@ -0,0 +1,29 @@
+package to.bitkit.ui.utils
+
+import android.content.Context
+import to.bitkit.R
+import to.bitkit.models.PubkyAuthRequestError
+import to.bitkit.repositories.WatchOnlyAccountError
+
+fun Throwable.localizedPubkyAuthMessage(context: Context): String? {
+ var current: Throwable? = this
+ while (current != null) {
+ val messageResource = when (current) {
+ is PubkyAuthRequestError.InvalidUrl -> R.string.profile__auth_error_invalid_url
+ PubkyAuthRequestError.MissingBitkitClaim -> R.string.profile__auth_error_missing_claim
+ PubkyAuthRequestError.DuplicateBitkitClaim -> R.string.profile__auth_error_duplicate_claim
+ is PubkyAuthRequestError.UnsupportedBitkitClaim -> R.string.profile__auth_error_unsupported_claim
+ PubkyAuthRequestError.InvalidBitkitClaimCapabilities -> R.string.profile__auth_error_invalid_capabilities
+ WatchOnlyAccountError.AuthorizationAccountMissing -> R.string.watch_only_accounts__setup_not_finished
+ WatchOnlyAccountError.InvalidAccountName -> R.string.watch_only_accounts__error_invalid_name
+ WatchOnlyAccountError.InvalidExtendedPublicKey -> R.string.watch_only_accounts__error_invalid_xpub
+ WatchOnlyAccountError.NodeUnavailable -> R.string.watch_only_accounts__error_node_unavailable
+ else -> null
+ }
+ if (messageResource != null) {
+ return context.getString(messageResource)
+ }
+ current = current.cause
+ }
+ return message
+}
diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt
index 3b23c2092f..ce9e32170e 100644
--- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt
+++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt
@@ -15,6 +15,7 @@ import to.bitkit.repositories.LightningRepo
import to.bitkit.repositories.PrivatePaykitAddressReservationRepo
import to.bitkit.repositories.PrivatePaykitRepo
import to.bitkit.repositories.PubkyRepo
+import to.bitkit.repositories.WatchOnlyAccountRepo
import to.bitkit.services.CoreService
import to.bitkit.services.MigrationService
import to.bitkit.utils.Logger
@@ -31,6 +32,7 @@ class WipeWalletUseCase @Inject constructor(
private val db: AppDb,
private val settingsStore: SettingsStore,
private val cacheStore: CacheStore,
+ private val watchOnlyAccountRepo: WatchOnlyAccountRepo,
private val widgetsStore: WidgetsStore,
private val blocktankRepo: BlocktankRepo,
private val activityRepo: ActivityRepo,
@@ -66,6 +68,7 @@ class WipeWalletUseCase @Inject constructor(
settingsStore.reset()
cacheStore.reset()
+ watchOnlyAccountRepo.clear()
widgetsStore.reset()
blocktankRepo.resetState()
diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
index 3449a58acf..761c0f9df2 100644
--- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@@ -93,6 +93,7 @@ import to.bitkit.ext.minSendableSat
import to.bitkit.ext.minWithdrawableSat
import to.bitkit.ext.rawId
import to.bitkit.ext.removeSpaces
+import to.bitkit.ext.runSuspendCatching
import to.bitkit.ext.setClipboardText
import to.bitkit.ext.toHex
import to.bitkit.ext.toUserMessage
@@ -2770,9 +2771,12 @@ class AppViewModel @Inject constructor(
// region Sheets
private var scanResultHandler: ((String) -> Unit)? = null
- fun showScannerSheet(onResult: ((String) -> Unit)? = null) {
+ fun showScannerSheet(
+ isPubkyScan: Boolean = false,
+ onResult: ((String) -> Unit)? = null,
+ ) {
scanResultHandler = onResult
- showSheet(Sheet.QrScanner)
+ showSheet(Sheet.QrScanner(isPubkyScan = isPubkyScan))
}
fun onScannerSheetResult(data: String) {
@@ -3257,6 +3261,15 @@ class AppViewModel @Inject constructor(
hwWalletRepo.onAppForegrounded()
}
+ fun onAppResumed() {
+ viewModelScope.launch(bgDispatcher) {
+ runSuspendCatching { pubkyRepo.validateExternalIdentitySource() }
+ .onFailure {
+ Logger.error("Failed to clear unavailable shared Pubky identity", it, context = TAG)
+ }
+ }
+ }
+
fun onLeftHome() = timedSheetManager.onHomeScreenExited()
fun dismissTimedSheet() = timedSheetManager.dismissCurrentSheet()
diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt
index 91c2e38e19..a4a5429e17 100644
--- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt
@@ -1,36 +1,48 @@
package to.bitkit.viewmodels
+import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import to.bitkit.R
import to.bitkit.data.SettingsStore
import to.bitkit.data.WidgetsStore
import to.bitkit.data.hasPaykitState
import to.bitkit.data.hasPublicPaykitPublicationState
import to.bitkit.data.paykitDisabled
import to.bitkit.flags.PaykitFeatureFlags
+import to.bitkit.models.Toast
import to.bitkit.models.TransactionSpeed
+import to.bitkit.repositories.ContactPaymentSettingsRepo
import to.bitkit.repositories.PrivatePaykitRepo
import to.bitkit.repositories.PubkyRepo
+import to.bitkit.repositories.PublicPaykitError
import to.bitkit.repositories.PublicPaykitRepo
import to.bitkit.repositories.WidgetsRepo
+import to.bitkit.ui.shared.toast.ToastEventBus
import to.bitkit.utils.Logger
import javax.inject.Inject
-@Suppress("TooManyFunctions")
+@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class SettingsViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
private val settingsStore: SettingsStore,
private val pubkyRepo: PubkyRepo,
+ private val contactPaymentSettingsRepo: ContactPaymentSettingsRepo,
private val publicPaykitRepo: PublicPaykitRepo,
private val privatePaykitRepo: PrivatePaykitRepo,
private val widgetsStore: WidgetsStore,
@@ -188,12 +200,43 @@ class SettingsViewModel @Inject constructor(
val isPaykitStateLoaded = settingsStore.isPaykitEnabled.map { true }
.asStateFlow(initialValue = false)
+ val contactPaymentsEnabled = contactPaymentSettingsRepo.isEnabled
+ .asStateFlow(initialValue = false)
+
+ private val _isUpdatingContactPayments = MutableStateFlow(false)
+ val isUpdatingContactPayments = _isUpdatingContactPayments.asStateFlow()
+
+ fun setContactPaymentsEnabled(value: Boolean) {
+ if (_isUpdatingContactPayments.value) return
+
+ viewModelScope.launch {
+ _isUpdatingContactPayments.update { true }
+ contactPaymentSettingsRepo.setEnabled(value)
+ .onFailure {
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.common__error),
+ description = contactPaymentSyncErrorMessage(it),
+ )
+ }
+ _isUpdatingContactPayments.update { false }
+ }
+ }
+
fun setIsPaykitEnabled(value: Boolean) {
viewModelScope.launch {
updatePaykitEnabled(value)
}
}
+ private fun contactPaymentSyncErrorMessage(error: Throwable): String = when (error) {
+ PublicPaykitError.InvalidPayload -> context.getString(R.string.profile__pay_contacts_error_invalid_payload)
+ PublicPaykitError.NoSupportedEndpoint -> context.getString(R.string.profile__pay_contacts_error_no_endpoint)
+ PublicPaykitError.SessionNotActive -> context.getString(R.string.profile__pay_contacts_error_session)
+ PublicPaykitError.WalletNotReady -> context.getString(R.string.profile__pay_contacts_error_wallet)
+ else -> context.getString(R.string.common__error_body)
+ }
+
private suspend fun updatePaykitEnabled(value: Boolean) {
val shouldEnable = value && PaykitFeatureFlags.isUiAvailable
val previousSettings = settingsStore.data.first()
diff --git a/app/src/main/res/drawable/ic_key.xml b/app/src/main/res/drawable/ic_key.xml
new file mode 100644
index 0000000000..1b1fd1e6d6
--- /dev/null
+++ b/app/src/main/res/drawable/ic_key.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 98d83f888e..962c4aec61 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -86,6 +86,7 @@
Preview
Ready
Remove
+ Remove %1$s tag
Reset
Retry
â‚¿ / vbyte
@@ -103,18 +104,17 @@
Received from %1$s
Sent to %1$s
Add
- Contact saved
Add Contact
- Add a new contact by scanning their QR or pasting their pubky below.
- Discard
+ Add a new contact by scanning their QR or by pasting their pubky below.
This pubky is already in your contacts.
Could not retrieve contact info. Please check the public key and try again.
Invalid pubky key format. Please check and try again.
You can\'t add your own pubky as a contact.
Please note that you and %1$s must add each other as contacts to pay each other privately. Otherwise, the payment will be visible publicly.
+ Pay
PUBKY
Paste a pubky
- Retrieving\n<accent>contact info</accent>
+ Retrieving\ncontact info
Scan QR
Add Contact
CONTACTS
@@ -128,11 +128,11 @@
Contact updated
Edit Contact
Please note contact information is stored in public files. Changes you make to a contact in Bitkit will not update their profile.
- You don\'t have any contacts yet.
+ Failed to save contact
Import All
%1$d friends
- Found\n<accent>profile & contacts</accent>
- Bitkit found profile and contacts data connected to pubky %1$s
+ Found\n<accent>contacts</accent>
+ Bitkit found profile and contacts data connected to pubky <accent>%1$s</accent>
Select
Select all
Select\n<accent>contacts</accent>
@@ -141,11 +141,13 @@
%1$d selected
Import
Add Contact
- Get automatic updates from contacts, pay them, and follow their public profiles.
+ Pay your contacts with just a tap. Send payments directly, to anyone, anywhere.
Dynamic\n<accent>contacts</accent>
MY PROFILE
Contacts
Pubky
+ Paste QR Or Link
+ SCANNING QR & NFC
Depends on the fee
Depends on the fee
Custom
@@ -574,14 +576,23 @@
You authorized with pubky <accent>%1$s</accent> and gave the service permission to access and edit your <accent>%2$s</accent> data.
Authorize
Make sure you trust the service, browser, or device before authorizing with your pubky.
+ %1$s server
+ Approve
+ To earn, you need to share a watch-only Bitcoin account with Paykit. It can view sales activity, but cannot spend funds.
+ Earn
+ <accent>EARN BITCOIN</accent>\nFROM YOUR\nCONTENT
+ This authorization contains more than one Bitkit claim.
+ The requested access does not match this Bitkit claim.
+ This Pubky authorization link is invalid.
+ This authorization is missing its required Bitkit claim.
Authorization Failed
+ 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.
- Import with Pubky Ring
- Loading your profile…
- Join the\n<accent>pubky web</accent>
- Waiting for Pubky Ring…
+ Create a new pubky and profile in Bitkit, or use an existing pubky from Pubky Ring.
+ Couldn\'t use this pubky
+ New pubky
+ ENTER THE\n<accent>FREEDOM WEB</accent>
Failed to create profile
Create Profile
Restoring your existing profile…
@@ -594,18 +605,18 @@
Profile deleted
Deriving your keys…
Failed to disconnect profile
- NOTES
+ BIO
Short note. Tell a bit about yourself.
DELETE
YOUR NAME
Edit Profile
- Please note profile information is stored in public files. Changes you make in Bitkit will not update your pubky.app profile.
+ Please note profile information is stored in public files.
Failed to save profile
Profile saved
TAGS
Unable to load your profile.
- Set up your portable pubky profile, so your contacts can reach you or pay you anytime, anywhere in the ecosystem.
- Portable\n<accent>pubky\nprofile</accent>
+ With your portable profile your contacts can reach you and pay you anywhere.
+ PORTABLE\n<accent>PROFILE</accent>
Profile
Use Bitkit with your contacts to send payments directly, anytime, anywhere.
Payment endpoint data could not be prepared.
@@ -614,19 +625,10 @@
Wallet is still starting. Try again in a moment.
Let your\ncontacts\n<accent>pay you</accent>
Pay Contacts
- Share payment data and enable payments with contacts
Public Key
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.
@@ -874,6 +876,7 @@
Classic (â‚¿ 0.00010000)
Bitcoin denomination
Modern (â‚¿ 10 000)
+ Enable payments with contacts
Interface
Payments
Transaction Speed
@@ -896,16 +899,6 @@
Rename Hardware Wallet
System Settings
Language
- Payments from contacts
- *Public payments with contacts requires payment data to be shared publicly.
- Choose how you prefer to receive money when users send funds to your profile key.
- Keep at least one payment method enabled.
- Lightning (Bitkit)
- On-chain (Bitkit)
- Payment options
- Private payments with contacts
- Public payments with contacts*
- Payment Preference
Bitkit QuickPay makes checking out faster by automatically paying QR codes when scanned.
<accent>Frictionless</accent>\npayments
QuickPay
@@ -1245,6 +1238,28 @@
Transfer To Savings
Transfer To Spending
Your withdrawal was unsuccessful. Please scan the QR code again or contact support.
+ Active accounts
+ Copy xpub
+ Each approved service gets a separate Bitcoin account. Turn tracking off to unload an account without deleting its wallet history or revoking the service session.
+ Account details
+ Accounts you approve for Paykit servers will appear here.
+ No server accounts
+ Enter an account name between 1 and 64 characters.
+ Bitkit could not create a valid account xpub.
+ The wallet must be running before an account can be created.
+ NAME
+ Account name
+ Account name saved
+ These accounts were created locally, but setup did not finish. Retry the same authorization to reuse the account.
+ Incomplete setup
+ Save name
+ Setup not confirmed
+ Setup did not finish. Retry the same authorization to use this account.
+ Server accounts
+ Track account
+ Account tracking disabled
+ Account tracking enabled
+ EXTENDED PUBLIC KEY
Add Widget
Data (max 4)
Examine various statistics on newly mined Bitcoin Blocks. Powered by mempool.space.
diff --git a/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt b/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt
new file mode 100644
index 0000000000..b8718d83bf
--- /dev/null
+++ b/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt
@@ -0,0 +1,237 @@
+package to.bitkit.data
+
+import org.junit.Test
+import to.bitkit.di.json
+import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE
+import to.bitkit.models.WalletBackupV1
+import to.bitkit.models.WatchOnlyAccountRecord
+import to.bitkit.models.WatchOnlyAccountSetupState
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class WatchOnlyAccountStoreTest {
+ private companion object {
+ const val TEST_XPUB =
+ "tpubDCgMbrEACV32r3jqiWn685NmYnqDkAcas1GBGh7XUVhxKFagQdpd2aY5kBMFqAFRa9NWPzCHma" +
+ "BEsU7YJcyjX8M8sswT3e6wq4LKCep3YaP"
+ }
+
+ @Test
+ fun `allocation is monotonic and retries reuse their reservation`() {
+ var data = WatchOnlyAccountData()
+
+ fun reserve(requestFingerprint: String): Int {
+ val reservation = data.reserveAccountIndex(walletIndex = 0, requestFingerprint)
+ data = reservation.data
+ return reservation.accountIndex
+ }
+
+ assertEquals(1, reserve("first"))
+ assertEquals(1, reserve("first"))
+ assertEquals(2, reserve("second"))
+ assertEquals(2, data.highestAccountIndexByWallet["0"])
+ }
+
+ @Test
+ fun `persisted accounts restore the allocator high water mark`() {
+ val data = WatchOnlyAccountData(accounts = listOf(account(accountIndex = 7)))
+ val reservation = data.reserveAccountIndex(walletIndex = 0, requestFingerprint = "next")
+
+ assertEquals(8, reservation.accountIndex)
+ assertEquals(7, reservation.data.accounts.single().accountIndex)
+ }
+
+ @Test
+ fun `restoring an older backup preserves high water and clears unstored reservations`() {
+ val data = WatchOnlyAccountData(
+ highestAccountIndexByWallet = mapOf("0" to 10),
+ pendingAccountIndexByRequest = mapOf("0:pending" to 10),
+ )
+
+ val restored = data.restoreTestAccounts(listOf(account(accountIndex = 7)))
+
+ assertEquals(emptyMap(), restored.pendingAccountIndexByRequest)
+ assertEquals(11, restored.reserveAccountIndex(walletIndex = 0, requestFingerprint = "pending").accountIndex)
+ }
+
+ @Test
+ fun `allocator backup restores pending reuse and monotonic high water`() {
+ val allocationState = WatchOnlyAccountAllocationState(
+ highestAccountIndexByWallet = mapOf("0" to 9),
+ pendingAccountIndexByRequest = mapOf("0:pending" to 7),
+ )
+
+ val restored = WatchOnlyAccountData().restoreTestAccounts(
+ accounts = listOf(account(accountIndex = 5)),
+ allocationState = allocationState,
+ )
+
+ assertEquals(7, restored.reserveAccountIndex(0, "pending").accountIndex)
+ assertEquals(10, restored.reserveAccountIndex(0, "new").accountIndex)
+ }
+
+ @Test
+ fun `restore sanitizes accounts and normalizes incomplete tracking`() {
+ val pending = account(accountIndex = 1).copy(
+ requestFingerprint = "pending",
+ isTrackingEnabled = true,
+ setupState = WatchOnlyAccountSetupState.PendingDelivery,
+ )
+ val authorizing = account(accountIndex = 2).copy(
+ requestFingerprint = "authorizing",
+ isTrackingEnabled = false,
+ setupState = WatchOnlyAccountSetupState.Authorizing,
+ )
+ val activeDisabled = account(accountIndex = 3).copy(isTrackingEnabled = false)
+ val duplicateSlot = account(accountIndex = 1).copy(
+ id = "duplicate",
+ requestFingerprint = "duplicate",
+ )
+ val invalid = account(accountIndex = 4).copy(xpub = "invalid")
+
+ val restored = WatchOnlyAccountData().restoreTestAccounts(
+ listOf(pending, authorizing, activeDisabled, duplicateSlot, invalid),
+ )
+
+ assertEquals(listOf(pending.id, authorizing.id, activeDisabled.id), restored.accounts.map { it.id })
+ assertEquals(listOf(false, true, false), restored.accounts.map { it.isTrackingEnabled })
+ }
+
+ @Test
+ fun `restore drops unusable accounts and burns their indexes`() {
+ val valid = account(accountIndex = 1)
+ val invalidAddressType = account(accountIndex = 7).copy(addressType = "legacy")
+ val invalidXpub = account(accountIndex = 8).copy(xpub = "not-an-xpub")
+ val accountZero = account(accountIndex = 0)
+
+ val restored = WatchOnlyAccountData().restoreTestAccounts(
+ listOf(invalidXpub, accountZero, invalidAddressType, valid),
+ )
+
+ assertEquals(listOf(valid), restored.accounts)
+ assertEquals(9, restored.reserveAccountIndex(0, "next").accountIndex)
+ }
+
+ @Test
+ fun `restore persists replaced accounts until runtime reconciliation completes`() {
+ val replacedAccount = account(accountIndex = 1)
+ val restoredAccount = account(accountIndex = 2)
+
+ val restored = WatchOnlyAccountData(accounts = listOf(replacedAccount))
+ .restoreTestAccounts(accounts = listOf(restoredAccount))
+ val reloaded = json.decodeFromString(json.encodeToString(restored))
+ val otherWalletPendingRemoval = account(accountIndex = 3, walletIndex = 1)
+
+ assertEquals(listOf(restoredAccount), reloaded.accounts)
+ assertEquals(listOf(replacedAccount), reloaded.accountsPendingRemoval)
+ assertEquals(
+ listOf(otherWalletPendingRemoval),
+ reloaded.copy(accountsPendingRemoval = reloaded.accountsPendingRemoval + otherWalletPendingRemoval)
+ .completeReconciliation(walletIndex = 0)
+ .accountsPendingRemoval,
+ )
+ }
+
+ @Test
+ fun `wallet backup round trip retains allocator state`() {
+ val allocationState = WatchOnlyAccountAllocationState(
+ highestAccountIndexByWallet = mapOf("0" to 9),
+ pendingAccountIndexByRequest = mapOf("0:pending" to 7),
+ )
+ val payload = WalletBackupV1(
+ createdAt = 1,
+ transfers = emptyList(),
+ watchOnlyAccounts = listOf(account(accountIndex = 5)),
+ watchOnlyAccountAllocationState = allocationState,
+ )
+
+ val restored = json.decodeFromString(json.encodeToString(payload))
+
+ assertEquals(allocationState, restored.watchOnlyAccountAllocationState)
+ assertEquals(5, restored.watchOnlyAccounts?.single()?.accountIndex)
+ }
+
+ @Test
+ fun `activation updates account and completes reservation in one snapshot`() {
+ val pendingAccount = account(accountIndex = 7).copy(
+ isTrackingEnabled = false,
+ setupState = WatchOnlyAccountSetupState.Authorizing,
+ )
+ val requestKey = "0:${pendingAccount.requestFingerprint}"
+ val data = WatchOnlyAccountData(
+ accounts = listOf(pendingAccount),
+ highestAccountIndexByWallet = mapOf("0" to 7),
+ pendingAccountIndexByRequest = mapOf(requestKey to 7, "0:other" to 8),
+ )
+
+ val activated = data.markAccountActive(pendingAccount.id)
+
+ assertTrue(activated.accounts.single().isTrackingEnabled)
+ assertEquals(WatchOnlyAccountSetupState.Active, activated.accounts.single().setupState)
+ assertFalse(requestKey in activated.pendingAccountIndexByRequest)
+ assertEquals(8, activated.pendingAccountIndexByRequest["0:other"])
+ }
+
+ @Test
+ fun `restore preserves an authorizing account over backup state`() {
+ val authorizing = account(accountIndex = 4).copy(
+ isTrackingEnabled = true,
+ setupState = WatchOnlyAccountSetupState.Authorizing,
+ )
+ val requestKey = "0:${authorizing.requestFingerprint}"
+ val restoredActive = authorizing.copy(setupState = WatchOnlyAccountSetupState.Active)
+ val restored = WatchOnlyAccountData(
+ accounts = listOf(authorizing),
+ pendingAccountIndexByRequest = mapOf(requestKey to authorizing.accountIndex),
+ ).restoreTestAccounts(listOf(restoredActive))
+
+ assertEquals(listOf(authorizing), restored.accounts)
+ assertEquals(authorizing.accountIndex, restored.pendingAccountIndexByRequest[requestKey])
+ }
+
+ @Test
+ fun `restore preserves a local owner when backup reuses its slot`() {
+ val local = account(accountIndex = 5)
+ val conflictingBackup = local.copy(
+ id = "restored-owner",
+ requestFingerprint = "restored-request",
+ )
+
+ val restored = WatchOnlyAccountData(accounts = listOf(local)).restoreTestAccounts(listOf(conflictingBackup))
+
+ assertEquals(listOf(local), restored.accounts)
+ assertTrue(restored.accountsPendingRemoval.isEmpty())
+ }
+
+ @Test
+ fun `activation fails when the account is missing`() {
+ val error = assertFailsWith {
+ WatchOnlyAccountData().markAccountActive("missing")
+ }
+
+ assertEquals("Watch-only account 'missing' not found", error.message)
+ }
+
+ private fun account(accountIndex: Int, walletIndex: Int = 0) = WatchOnlyAccountRecord(
+ id = "account-$walletIndex-$accountIndex",
+ walletIndex = walletIndex,
+ accountIndex = accountIndex,
+ addressType = WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE,
+ xpub = TEST_XPUB,
+ requestFingerprint = "request-$walletIndex-$accountIndex",
+ createdAt = 1,
+ name = "Account $accountIndex",
+ isTrackingEnabled = true,
+ setupState = WatchOnlyAccountSetupState.Active,
+ )
+
+ private fun WatchOnlyAccountData.restoreTestAccounts(
+ accounts: List,
+ allocationState: WatchOnlyAccountAllocationState? = null,
+ ) = restoreAccounts(accounts, allocationState) { xpub ->
+ require(xpub == TEST_XPUB)
+ ByteArray(78)
+ }
+}
diff --git a/app/src/test/java/to/bitkit/data/serializers/SettingsSerializerTest.kt b/app/src/test/java/to/bitkit/data/serializers/SettingsSerializerTest.kt
new file mode 100644
index 0000000000..5e39ec4e0d
--- /dev/null
+++ b/app/src/test/java/to/bitkit/data/serializers/SettingsSerializerTest.kt
@@ -0,0 +1,26 @@
+package to.bitkit.data.serializers
+
+import kotlinx.serialization.encodeToString
+import org.junit.Test
+import to.bitkit.data.SettingsData
+import to.bitkit.di.json
+import to.bitkit.test.BaseUnitTest
+import java.io.ByteArrayInputStream
+import kotlin.test.assertTrue
+
+class SettingsSerializerTest : BaseUnitTest() {
+ @Test
+ fun `read resets hidden Paykit payment methods`() = test {
+ val stored = SettingsData(
+ publicPaykitLightningEnabled = false,
+ publicPaykitOnchainEnabled = false,
+ )
+
+ val result = SettingsSerializer.readFrom(
+ ByteArrayInputStream(json.encodeToString(stored).encodeToByteArray())
+ )
+
+ assertTrue(result.publicPaykitLightningEnabled)
+ assertTrue(result.publicPaykitOnchainEnabled)
+ }
+}
diff --git a/app/src/test/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializerTest.kt b/app/src/test/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializerTest.kt
new file mode 100644
index 0000000000..91ff5b6658
--- /dev/null
+++ b/app/src/test/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializerTest.kt
@@ -0,0 +1,17 @@
+package to.bitkit.data.serializers
+
+import androidx.datastore.core.CorruptionException
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import kotlin.test.assertIs
+
+class WatchOnlyAccountDataSerializerTest {
+ @Test
+ fun `malformed JSON is reported as datastore corruption`() = runTest {
+ val error = runCatching {
+ WatchOnlyAccountDataSerializer.readFrom("{not-json".byteInputStream())
+ }.exceptionOrNull()
+
+ assertIs(error)
+ }
+}
diff --git a/app/src/test/java/to/bitkit/data/sharing/SharedPubkyContractTest.kt b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyContractTest.kt
new file mode 100644
index 0000000000..d4d09f03b4
--- /dev/null
+++ b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyContractTest.kt
@@ -0,0 +1,82 @@
+package to.bitkit.data.sharing
+
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+class SharedPubkyContractTest {
+ companion object {
+ private const val WIRE_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ private const val SECRET_KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
+ }
+
+ @Test
+ fun `wire format is always bare lowercase z-base32`() {
+ assertEquals(WIRE_PUBKY, SharedPubkyContract.canonicalPubky(" PUBKY${WIRE_PUBKY.uppercase()} "))
+ assertEquals("pubky$WIRE_PUBKY", SharedPubkyContract.toBitkitPubky(WIRE_PUBKY))
+ }
+
+ @Test
+ fun `bare wire key may itself begin with pubky`() {
+ val wirePubky = "pubky" + "y".repeat(47)
+
+ assertEquals(wirePubky, SharedPubkyContract.canonicalPubky(wirePubky))
+ assertEquals("pubky$wirePubky", SharedPubkyContract.toBitkitPubky(wirePubky))
+ }
+
+ @Test
+ fun `wire format rejects wrong length and alphabet`() {
+ assertFailsWith {
+ SharedPubkyContract.canonicalPubky(WIRE_PUBKY.dropLast(1))
+ }
+ assertFailsWith {
+ SharedPubkyContract.canonicalPubky("${WIRE_PUBKY.dropLast(1)}0")
+ }
+ assertFailsWith {
+ SharedPubkyContract.requireWirePubky("pubky$WIRE_PUBKY")
+ }
+ assertFailsWith {
+ SharedPubkyContract.requireWirePubky(WIRE_PUBKY.uppercase())
+ }
+ }
+
+ @Test
+ fun `credential Uri encodes the selected bare pubky in the v1 path`() {
+ assertEquals(
+ "content://app.pubkyring.sharedpubky/v1/identities/$WIRE_PUBKY/credential",
+ SharedPubkyContract.ringCredentialUriString("pubky$WIRE_PUBKY"),
+ )
+ }
+
+ @Test
+ fun `secret key wire format requires exactly 64 lowercase hex characters`() {
+ assertEquals(SECRET_KEY, SharedPubkyContract.canonicalSecretKeyHex(SECRET_KEY))
+ assertFailsWith {
+ SharedPubkyContract.canonicalSecretKeyHex(SECRET_KEY.dropLast(1))
+ }
+ assertFailsWith {
+ SharedPubkyContract.canonicalSecretKeyHex("${SECRET_KEY.dropLast(1)}z")
+ }
+ assertFailsWith {
+ SharedPubkyContract.canonicalSecretKeyHex(SECRET_KEY.uppercase())
+ }
+ }
+
+ @Test
+ fun `external reference rejects unsupported sources and versions`() {
+ assertFailsWith {
+ ExternalPubkyIdentityRef(
+ protocolVersion = 2,
+ sourcePackage = SharedPubkyContract.RING_SOURCE,
+ pubky = WIRE_PUBKY,
+ ).validated()
+ }
+ assertFailsWith {
+ ExternalPubkyIdentityRef(
+ protocolVersion = SharedPubkyContract.PROTOCOL_VERSION,
+ sourcePackage = "other.app",
+ pubky = WIRE_PUBKY,
+ ).validated()
+ }
+ }
+}
diff --git a/app/src/test/java/to/bitkit/data/sharing/SharedPubkyManifestTest.kt b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyManifestTest.kt
new file mode 100644
index 0000000000..641cfa8112
--- /dev/null
+++ b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyManifestTest.kt
@@ -0,0 +1,41 @@
+package to.bitkit.data.sharing
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.content.pm.PermissionInfo
+import androidx.test.core.app.ApplicationProvider
+import dagger.hilt.android.testing.HiltTestApplication
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import to.bitkit.BuildConfig
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = HiltTestApplication::class, sdk = [34])
+class SharedPubkyManifestTest {
+ private val context = ApplicationProvider.getApplicationContext()
+ private val packageManager = context.packageManager
+
+ @Test
+ fun `provider authority and permissions expand for the application variant`() {
+ val applicationId = BuildConfig.APPLICATION_ID
+ val permissionName = "$applicationId.permission.READ_SHARED_PUBKY"
+ val provider = requireNotNull(
+ packageManager.resolveContentProvider("$applicationId.sharedpubky", PackageManager.MATCH_ALL)
+ )
+
+ assertEquals(applicationId, provider.packageName)
+ assertEquals(permissionName, provider.readPermission)
+ assertEquals(permissionName, provider.writePermission)
+ assertTrue(provider.exported)
+
+ val permission = packageManager.getPermissionInfo(permissionName, PackageManager.GET_META_DATA)
+ assertEquals(
+ PermissionInfo.PROTECTION_SIGNATURE,
+ permission.protectionLevel and PermissionInfo.PROTECTION_MASK_BASE,
+ )
+ }
+}
diff --git a/app/src/test/java/to/bitkit/data/sharing/SharedPubkyProviderTest.kt b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyProviderTest.kt
new file mode 100644
index 0000000000..c83e0af6f4
--- /dev/null
+++ b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyProviderTest.kt
@@ -0,0 +1,78 @@
+package to.bitkit.data.sharing
+
+import org.junit.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class SharedPubkyProviderTest {
+ companion object {
+ private const val WIRE_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ private const val SECRET_KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
+ }
+
+ @Test
+ fun `borrowed active identity without a local secret is never exported`() {
+ val identity = localSharedPubkyIdentity(
+ exportEnabled = true,
+ managedSecretQuarantined = false,
+ secretKeyHex = null,
+ publicKeyFromSecret = { error("Must not derive a borrowed identity") },
+ )
+
+ assertNull(identity)
+ }
+
+ @Test
+ fun `disabled local identity is never exported`() {
+ val identity = localSharedPubkyIdentity(
+ exportEnabled = false,
+ managedSecretQuarantined = false,
+ secretKeyHex = SECRET_KEY,
+ publicKeyFromSecret = { WIRE_PUBKY },
+ )
+
+ assertNull(identity)
+ }
+
+ @Test
+ fun `quarantined managed identity is never exported`() {
+ val identity = localSharedPubkyIdentity(
+ exportEnabled = true,
+ managedSecretQuarantined = true,
+ secretKeyHex = SECRET_KEY,
+ publicKeyFromSecret = { error("Must not derive a quarantined identity") },
+ )
+
+ assertNull(identity)
+ }
+
+ @Test
+ fun `public discovery row excludes the local secret`() {
+ val identity = localSharedPubkyIdentity(
+ exportEnabled = true,
+ managedSecretQuarantined = false,
+ secretKeyHex = SECRET_KEY,
+ publicKeyFromSecret = { "pubky$WIRE_PUBKY" },
+ )
+
+ assertEquals(WIRE_PUBKY, identity?.pubky)
+ assertContentEquals(
+ arrayOf(
+ SharedPubkyContract.PROTOCOL_VERSION,
+ SharedPubkyContract.BITKIT_SOURCE,
+ WIRE_PUBKY,
+ ),
+ identity?.publicRow(),
+ )
+ assertContentEquals(
+ arrayOf(
+ SharedPubkyContract.PROTOCOL_VERSION,
+ SharedPubkyContract.BITKIT_SOURCE,
+ WIRE_PUBKY,
+ SECRET_KEY,
+ ),
+ identity?.credentialRow(),
+ )
+ }
+}
diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt
index 6431bd2bab..db00877783 100644
--- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt
+++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt
@@ -2,11 +2,88 @@ package to.bitkit.models
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class PubkyAuthRequestTest {
+ @Test
+ fun `parse recognizes watch-only account claim`() {
+ val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES
+ val request = PubkyAuthRequest.parse(
+ rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue),
+ relay = "https://httprelay.pubky.app/inbox/",
+ capabilities = capabilities,
+ ).getOrThrow()
+
+ assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, request.bitkitClaim)
+ }
+
+ @Test
+ fun `parse preserves normal auth without Bitkit claim`() {
+ val request = PubkyAuthRequest.parse(
+ rawUrl = authUrl("/pub/bitkit.to/:rw"),
+ relay = "https://httprelay.pubky.app/inbox/",
+ capabilities = "/pub/bitkit.to/:rw",
+ ).getOrThrow()
+
+ assertNull(request.bitkitClaim)
+ }
+
+ @Test
+ fun `parse rejects watch-only capability without Bitkit claim`() {
+ val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES
+ val result = PubkyAuthRequest.parse(
+ rawUrl = authUrl(capabilities),
+ relay = "https://httprelay.pubky.app/inbox/",
+ capabilities = capabilities,
+ )
+
+ assertIs(result.exceptionOrNull())
+ }
+
+ @Test
+ fun `parse rejects duplicate Bitkit claim`() {
+ val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES
+ val result = PubkyAuthRequest.parse(
+ rawUrl = authUrl(
+ capabilities,
+ PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue,
+ PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue,
+ ),
+ relay = "https://httprelay.pubky.app/inbox/",
+ capabilities = capabilities,
+ )
+
+ assertIs(result.exceptionOrNull())
+ }
+
+ @Test
+ fun `parse rejects unknown Bitkit claim`() {
+ val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES
+ val result = PubkyAuthRequest.parse(
+ rawUrl = authUrl(capabilities, "unknown-v1"),
+ relay = "https://httprelay.pubky.app/inbox/",
+ capabilities = capabilities,
+ )
+
+ val error = assertIs(result.exceptionOrNull())
+ assertEquals("unknown-v1", error.value)
+ }
+
+ @Test
+ fun `parse rejects watch-only claim with other capabilities`() {
+ val capabilities = "/pub/paykit/v0/:rw"
+ val result = PubkyAuthRequest.parse(
+ rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue),
+ relay = "https://httprelay.pubky.app/inbox/",
+ capabilities = capabilities,
+ )
+
+ assertIs(result.exceptionOrNull())
+ }
+
@Test
fun `parseCapabilities parses single permission`() {
val permissions = PubkyAuthRequest.parseCapabilities("/pub/bitkit.to/:rw")
@@ -61,6 +138,18 @@ class PubkyAuthRequestTest {
assertEquals("READ, WRITE", perm.displayAccess)
}
+ @Test
+ fun `displayPath removes capability separator`() {
+ val perm = PubkyAuthPermission(path = "/pub/paykit/v0/bitkit/server/", accessLevel = "rw")
+ assertEquals("/pub/paykit/v0/bitkit/server", perm.displayPath)
+ }
+
+ @Test
+ fun `displayPath preserves root`() {
+ val perm = PubkyAuthPermission(path = "/", accessLevel = "r")
+ assertEquals("/", perm.displayPath)
+ }
+
@Test
fun `extractServiceName extracts from pub path`() {
assertEquals("bitkit.to", PubkyAuthRequest.extractServiceName("/pub/bitkit.to/"))
@@ -81,4 +170,11 @@ class PubkyAuthRequestTest {
PubkyAuthRequest.extractServiceName("/pub/staging.bitkit.to/profile.json"),
)
}
+
+ private fun authUrl(capabilities: String, vararg claimValues: String): String {
+ val claims = claimValues.joinToString(separator = "") {
+ "&${PubkyAuthClaim.QUERY_PARAMETER}=$it"
+ }
+ return "pubkyauth://signin?caps=$capabilities&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F$claims"
+ }
}
diff --git a/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt b/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt
index 68fbfb623c..d8424a5106 100644
--- a/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt
+++ b/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt
@@ -45,6 +45,18 @@ class PubkyPublicKeyFormatTest {
assertEquals("pubky3r…k8yw5xg", PubkyPublicKeyFormat.redacted(rawKey))
}
+ @Test
+ fun `display normalizes and shortens a prefixed key`() {
+ val key = " PUBKY3RSDUHCXPW74SNWYCT86M38C63J3PQ8X4YCQIKXG64ROIK8YW5X "
+
+ assertEquals("3rsd...yw5x", PubkyPublicKeyFormat.display(key))
+ }
+
+ @Test
+ fun `display leaves a short raw key unchanged`() {
+ assertEquals("short", PubkyPublicKeyFormat.display("pubkyshort"))
+ }
+
@Test
fun `matches compares equivalent pubky representations`() {
val rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt
index 84bafb8348..7a389f388f 100644
--- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt
+++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt
@@ -13,18 +13,24 @@ import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.doSuspendableAnswer
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
+import org.mockito.kotlin.verifyBlocking
import org.mockito.kotlin.whenever
import to.bitkit.data.AppCacheData
import to.bitkit.data.AppDb
import to.bitkit.data.CacheStore
import to.bitkit.data.SettingsData
import to.bitkit.data.SettingsStore
+import to.bitkit.data.WatchOnlyAccountAllocationState
+import to.bitkit.data.WatchOnlyAccountBackupSnapshot
+import to.bitkit.data.WatchOnlyAccountData
+import to.bitkit.data.WatchOnlyAccountStore
import to.bitkit.data.WidgetsData
import to.bitkit.data.WidgetsStore
import to.bitkit.data.backup.VssBackupClient
@@ -35,11 +41,14 @@ import to.bitkit.di.json
import to.bitkit.models.BackupCategory
import to.bitkit.models.BackupItemStatus
import to.bitkit.models.WalletBackupV1
+import to.bitkit.models.WatchOnlyAccountRecord
+import to.bitkit.models.WatchOnlyAccountSetupState
import to.bitkit.services.LightningService
import to.bitkit.services.PaykitSdkService
import to.bitkit.test.BaseUnitTest
import to.bitkit.utils.AppError
import javax.inject.Provider
+import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
@@ -53,6 +62,8 @@ class BackupRepoTest : BaseUnitTest() {
private val vssBackupClientLdk = mock()
private val settingsStore = mock()
private val widgetsStore = mock()
+ private val watchOnlyAccountStore = mock()
+ private val watchOnlyAccountRepo = mock()
private val blocktankRepo = mock()
private val activityRepo = mock()
private val pubkyRepo = mock()
@@ -81,6 +92,11 @@ class BackupRepoTest : BaseUnitTest() {
whenever(settingsStore.data).thenReturn(settingsData)
whenever { settingsStore.update(any()) }.thenReturn(Unit)
whenever(widgetsStore.data).thenReturn(widgetsData)
+ whenever(watchOnlyAccountStore.data).thenReturn(MutableStateFlow(WatchOnlyAccountData()))
+ whenever { watchOnlyAccountStore.load() }.thenReturn(emptyList())
+ whenever { watchOnlyAccountStore.backupSnapshot() }.thenReturn(
+ WatchOnlyAccountBackupSnapshot(emptyList(), WatchOnlyAccountAllocationState())
+ )
whenever { vssBackupClient.getObject(any()) }.thenReturn(Result.success(null))
whenever { vssBackupClient.putObject(any(), any()) }
.thenReturn(Result.success(VssItem(key = BackupCategory.SETTINGS.name, value = byteArrayOf(), version = 1)))
@@ -251,6 +267,56 @@ class BackupRepoTest : BaseUnitTest() {
verify(settingsStore).update(any())
}
+ @Test
+ fun `wallet backup includes watch-only accounts and allocator state`() = test {
+ val account = watchOnlyAccount()
+ val allocationState = WatchOnlyAccountAllocationState(
+ highestAccountIndexByWallet = mapOf("0" to 7),
+ pendingAccountIndexByRequest = mapOf("0:pending" to 7),
+ )
+ whenever { watchOnlyAccountStore.backupSnapshot() }.thenReturn(
+ WatchOnlyAccountBackupSnapshot(listOf(account), allocationState)
+ )
+ val dataCaptor = argumentCaptor()
+
+ sut.triggerBackup(BackupCategory.WALLET)
+
+ verifyBlocking(vssBackupClient) {
+ putObject(eq(BackupCategory.WALLET.name), dataCaptor.capture())
+ }
+ val payload = json.decodeFromString(dataCaptor.firstValue.decodeToString())
+ assertEquals(listOf(account), payload.watchOnlyAccounts)
+ assertEquals(allocationState, payload.watchOnlyAccountAllocationState)
+ }
+
+ @Test
+ fun `wallet restore restores watch-only accounts and allocator before runtime reconciliation`() = test {
+ val account = watchOnlyAccount()
+ val allocationState = WatchOnlyAccountAllocationState(
+ highestAccountIndexByWallet = mapOf("0" to 7),
+ pendingAccountIndexByRequest = mapOf("0:pending" to 7),
+ )
+ stubWalletBackup(
+ watchOnlyAccounts = listOf(account),
+ watchOnlyAccountAllocationState = allocationState,
+ )
+ var accountsWereRestored = false
+ whenever { watchOnlyAccountRepo.restore(listOf(account), allocationState) }.thenAnswer {
+ accountsWereRestored = true
+ Unit
+ }
+ whenever { lightningService.reconcileWatchOnlyAccounts() }.thenAnswer {
+ assertTrue(accountsWereRestored)
+ Unit
+ }
+
+ val result = sut.performFullRestoreFromLatestBackup()
+
+ assertTrue(result.isSuccess)
+ verifyBlocking(watchOnlyAccountRepo) { restore(listOf(account), allocationState) }
+ verifyBlocking(lightningService) { reconcileWatchOnlyAccounts() }
+ }
+
@Test
fun `full restore should fail when private Paykit reserved indexes fail to reconcile`() = test {
stubWalletBackup()
@@ -265,12 +331,16 @@ class BackupRepoTest : BaseUnitTest() {
private fun stubWalletBackup(
paykitSdkBackupState: String? = null,
+ watchOnlyAccounts: List? = null,
+ watchOnlyAccountAllocationState: WatchOnlyAccountAllocationState? = null,
) {
val walletBackup = WalletBackupV1(
createdAt = 123,
transfers = emptyList(),
privatePaykitHighestReservedReceiveIndexByAddressType = mapOf("nativeSegwit" to 5),
paykitSdkBackupState = paykitSdkBackupState,
+ watchOnlyAccounts = watchOnlyAccounts,
+ watchOnlyAccountAllocationState = watchOnlyAccountAllocationState,
)
whenever { vssBackupClient.getObject(BackupCategory.WALLET.name) }
.thenReturn(
@@ -324,6 +394,8 @@ class BackupRepoTest : BaseUnitTest() {
vssBackupClientLdk = vssBackupClientLdk,
settingsStore = settingsStore,
widgetsStore = widgetsStore,
+ watchOnlyAccountStore = watchOnlyAccountStore,
+ watchOnlyAccountRepo = watchOnlyAccountRepo,
blocktankRepo = blocktankRepo,
activityRepo = activityRepo,
pubkyRepo = pubkyRepo,
@@ -337,4 +409,17 @@ class BackupRepoTest : BaseUnitTest() {
)
private class BackupRepoTestError(message: String) : AppError(message)
+
+ private fun watchOnlyAccount() = WatchOnlyAccountRecord(
+ id = "account-7",
+ walletIndex = 0,
+ accountIndex = 7,
+ addressType = "nativeSegwit",
+ xpub = "xpub-7",
+ requestFingerprint = "pending",
+ createdAt = 1,
+ name = "Server account",
+ isTrackingEnabled = true,
+ setupState = WatchOnlyAccountSetupState.Authorizing,
+ )
}
diff --git a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt
new file mode 100644
index 0000000000..d5eba52104
--- /dev/null
+++ b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt
@@ -0,0 +1,168 @@
+package to.bitkit.repositories
+
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Before
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import to.bitkit.data.SettingsData
+import to.bitkit.data.SettingsStore
+import to.bitkit.models.PubkyProfile
+import to.bitkit.test.BaseUnitTest
+import to.bitkit.utils.AppError
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ContactPaymentSettingsRepoTest : BaseUnitTest() {
+ companion object {
+ private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ }
+
+ private val settingsStore: SettingsStore = mock()
+ private val publicPaykitRepo: PublicPaykitRepo = mock()
+ private val privatePaykitRepo: PrivatePaykitRepo = mock()
+ private val pubkyRepo: PubkyRepo = mock()
+ private val settingsFlow = MutableStateFlow(SettingsData())
+
+ @Before
+ fun setUp() {
+ settingsFlow.value = SettingsData()
+ whenever(settingsStore.data).thenReturn(settingsFlow)
+ whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact())))
+ whenever { pubkyRepo.hasSecretKey() }.thenReturn(true)
+ whenever { settingsStore.update(any()) }.thenAnswer {
+ val transform = it.getArgument<(SettingsData) -> SettingsData>(0)
+ settingsFlow.value = transform(settingsFlow.value)
+ Unit
+ }
+ whenever { publicPaykitRepo.syncPublishedEndpoints(any()) }.thenReturn(Result.success(Unit))
+ whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) }
+ .thenReturn(Result.success(Unit))
+ whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>(), any()) }
+ .thenReturn(Result.success(Unit))
+ whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) }
+ .thenReturn(Result.success(Unit))
+ }
+
+ @Test
+ fun `enabling publishes public and private contact payments`() = test {
+ settingsFlow.value = SettingsData(
+ publicPaykitLightningEnabled = false,
+ publicPaykitOnchainEnabled = false,
+ )
+
+ val result = createSut().setEnabled(true)
+
+ assertTrue(result.isSuccess)
+ assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints)
+ assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints)
+ assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints)
+ assertTrue(settingsFlow.value.publicPaykitLightningEnabled)
+ assertTrue(settingsFlow.value.publicPaykitOnchainEnabled)
+ verify(publicPaykitRepo).syncPublishedEndpoints(publish = true)
+ verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY), true)
+ }
+
+ @Test
+ fun `enabling without local key enables only public payments`() = test {
+ whenever { pubkyRepo.hasSecretKey() }.thenReturn(false)
+
+ val result = createSut().setEnabled(true)
+
+ assertTrue(result.isSuccess)
+ assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints)
+ assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints)
+ verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>(), any())
+ }
+
+ @Test
+ fun `failed publication restores disabled settings`() = test {
+ whenever { publicPaykitRepo.syncPublishedEndpoints(publish = true) }
+ .thenReturn(Result.failure(ContactPaymentSettingsTestError("publish failed")))
+
+ val result = createSut().setEnabled(true)
+
+ assertTrue(result.isFailure)
+ assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints)
+ assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints)
+ verify(publicPaykitRepo).syncPublishedEndpoints(publish = false)
+ }
+
+ @Test
+ fun `disabling removes public and private contact payments`() = test {
+ settingsFlow.value = SettingsData(
+ hasConfirmedPublicPaykitEndpoints = true,
+ sharesPublicPaykitEndpoints = true,
+ sharesPrivatePaykitEndpoints = true,
+ )
+
+ val result = createSut().setEnabled(false)
+
+ assertTrue(result.isSuccess)
+ assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints)
+ assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints)
+ verify(publicPaykitRepo).syncPublishedEndpoints(publish = false)
+ verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY))
+ }
+
+ @Test
+ fun `failed private cleanup restores private contact payments`() = test {
+ settingsFlow.value = SettingsData(
+ hasConfirmedPublicPaykitEndpoints = true,
+ sharesPrivatePaykitEndpoints = true,
+ )
+ whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) }
+ .thenReturn(Result.failure(ContactPaymentSettingsTestError("cleanup failed")))
+
+ val result = createSut().setEnabled(false)
+
+ assertTrue(result.isFailure)
+ assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints)
+ verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY), true)
+ }
+
+ @Test
+ fun `failed private restore leaves private payments disabled`() = test {
+ settingsFlow.value = SettingsData(
+ hasConfirmedPublicPaykitEndpoints = true,
+ sharesPrivatePaykitEndpoints = true,
+ )
+ whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) }
+ .thenReturn(Result.failure(ContactPaymentSettingsTestError("cleanup failed")))
+ whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>(), eq(true)) }
+ .thenReturn(Result.failure(ContactPaymentSettingsTestError("restore failed")))
+
+ val result = createSut().setEnabled(false)
+
+ assertTrue(result.isFailure)
+ assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints)
+ }
+
+ private fun createSut() = ContactPaymentSettingsRepo(
+ settingsStore = settingsStore,
+ publicPaykitRepo = publicPaykitRepo,
+ privatePaykitRepo = privatePaykitRepo,
+ pubkyRepo = pubkyRepo,
+ ioDispatcher = testDispatcher,
+ )
+
+ private fun createContact() = PubkyProfile(
+ publicKey = CONTACT_KEY,
+ name = "Alice",
+ bio = "",
+ imageUrl = null,
+ links = emptyList(),
+ tags = persistentListOf(),
+ status = null,
+ )
+}
+
+private class ContactPaymentSettingsTestError(message: String) : AppError(message)
diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt
index 5d721ce3fa..33ecceb425 100644
--- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt
+++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt
@@ -24,6 +24,7 @@ import org.lightningdevkit.ldknode.AddressTypeBalance
import org.lightningdevkit.ldknode.BalanceDetails
import org.lightningdevkit.ldknode.ChannelDetails
import org.lightningdevkit.ldknode.Event
+import org.lightningdevkit.ldknode.Node
import org.lightningdevkit.ldknode.NodeStatus
import org.lightningdevkit.ldknode.PaymentDetails
import org.lightningdevkit.ldknode.PeerDetails
@@ -189,6 +190,44 @@ class LightningRepoTest : BaseUnitTest() {
}
}
+ @Test
+ fun `start reconciles watch-only accounts when the underlying node is already running`() = test {
+ sut.setInitNodeLifecycleState()
+ val node = mock()
+ val status = mock()
+ whenever(lightningService.node).thenReturn(node)
+ whenever(lightningService.status).thenReturn(status)
+ whenever(status.isRunning).thenReturn(true)
+ whenever { lightningService.startEventListener(any()) }.thenReturn(Result.success(Unit))
+
+ val result = sut.start(shouldRetry = false)
+
+ assertTrue(result.isSuccess)
+ assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState)
+ verifyBlocking(lightningService) { reconcileWatchOnlyAccounts() }
+ verifyBlocking(lightningService, never()) { start(anyOrNull(), any()) }
+ }
+
+ @Test
+ fun `start remains running when watch-only reconciliation fails for an already running node`() = test {
+ sut.setInitNodeLifecycleState()
+ val node = mock()
+ val status = mock()
+ whenever(lightningService.node).thenReturn(node)
+ whenever(lightningService.status).thenReturn(status)
+ whenever(status.isRunning).thenReturn(true)
+ whenever { lightningService.reconcileWatchOnlyAccounts() }
+ .thenThrow(IllegalStateException("reconciliation failed"))
+ whenever { lightningService.startEventListener(any()) }.thenReturn(Result.success(Unit))
+
+ val result = sut.start(shouldRetry = false)
+
+ assertTrue(result.isSuccess)
+ assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState)
+ verifyBlocking(lightningService) { reconcileWatchOnlyAccounts() }
+ verifyBlocking(lightningService, never()) { start(anyOrNull(), any()) }
+ }
+
@Test
fun `stop should transition to stopped state`() = test {
startNodeForTesting()
diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt
index 20d62407fc..9970ce3784 100644
--- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt
+++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt
@@ -14,7 +14,6 @@ import com.synonym.paykit.PaymentEndpointSource
import com.synonym.paykit.PrivatePaymentListDeliveryReport
import com.synonym.paykit.PrivatePaymentListReservationUpdateInput
import com.synonym.paykit.PrivatePaymentListSyncChange
-import com.synonym.paykit.PubkyIdentityCapability
import com.synonym.paykit.PublicationStatus
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -118,9 +117,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) {
whenever(paykitSdkService.identityStatus()).thenReturn(
IdentityStatus(
publicKey = OWN_KEY,
- capability = PubkyIdentityCapability.PRIVATE_LINK_CAPABLE,
liveSessionAvailable = true,
- privateLinkCapable = true,
),
)
whenever(walletRepo.walletExists()).thenReturn(true)
@@ -708,14 +705,12 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) {
}
@Test
- fun `beginSavedContactPayment uses public SDK endpoint when private capability is unavailable`() = test {
+ fun `beginSavedContactPayment uses public SDK endpoint when live session is unavailable`() = test {
settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true)
whenever(paykitSdkService.identityStatus()).thenReturn(
IdentityStatus(
publicKey = OWN_KEY,
- capability = PubkyIdentityCapability.PUBLIC_ONLY,
- liveSessionAvailable = true,
- privateLinkCapable = false,
+ liveSessionAvailable = false,
),
)
sut.prepareSavedContacts(listOf(CONTACT_KEY))
diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
index fd9cfc4ce4..238a6638d6 100644
--- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
+++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
@@ -8,16 +8,26 @@ import com.synonym.paykit.ContactProfileResolution
import com.synonym.paykit.ContactProfileSource
import com.synonym.paykit.ContactRecord
import com.synonym.paykit.PaykitProfile
+import com.synonym.paykit.PubkyAuthCompanionClaim
import com.synonym.paykit.PublicationStatus
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runCurrent
import org.junit.Before
import org.junit.Test
import org.mockito.Mockito.clearInvocations
import org.mockito.kotlin.any
import org.mockito.kotlin.atLeastOnce
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doSuspendableAnswer
+import org.mockito.kotlin.doThrow
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.inOrder
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times
@@ -29,6 +39,12 @@ import to.bitkit.data.PubkyStoreData
import to.bitkit.data.SettingsData
import to.bitkit.data.SettingsStore
import to.bitkit.data.keychain.Keychain
+import to.bitkit.data.sharing.ExternalPubkyIdentityRef
+import to.bitkit.data.sharing.SharedPubkyContract
+import to.bitkit.data.sharing.SharedPubkyCredential
+import to.bitkit.data.sharing.SharedPubkyDiscovery
+import to.bitkit.data.sharing.SharedPubkyIdentity
+import to.bitkit.models.PubkyAuthClaim
import to.bitkit.models.PubkyProfile
import to.bitkit.models.PubkyRingAuthCallback
import to.bitkit.models.PubkyRingAuthCallbackHandlingResult
@@ -46,12 +62,15 @@ import kotlin.time.Duration.Companion.milliseconds
import com.synonym.paykit.PubkyProfile as SdkPubkyProfile
@Suppress("LargeClass")
+@OptIn(ExperimentalCoroutinesApi::class)
class PubkyRepoTest : BaseUnitTest() {
companion object {
// Valid 52-char z-base-32 key (+ "pubky" prefix = 57 chars)
private const val VALID_CONTACT_KEY_A = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
private const val VALID_CONTACT_KEY_B = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
private const val VALID_SELF_KEY = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ private const val SHARED_SECRET_KEY =
+ "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
}
private lateinit var sut: PubkyRepo
@@ -61,20 +80,47 @@ class PubkyRepoTest : BaseUnitTest() {
private val imageLoader = mock()
private val pubkyStore = mock()
private val settingsStore = mock()
+ private val sharedPubkyDiscovery = mock()
private val settingsFlow = MutableStateFlow(SettingsData())
+ private val pubkyDataFlow = MutableStateFlow(PubkyStoreData())
+ private var sharedExportEnabled: String? = null
@Before
fun setUp() = runBlocking {
settingsFlow.value = SettingsData()
- whenever(pubkyStore.data).thenReturn(flowOf(PubkyStoreData()))
+ pubkyDataFlow.value = PubkyStoreData()
+ sharedExportEnabled = null
+ whenever(pubkyStore.data).thenReturn(pubkyDataFlow)
whenever(settingsStore.data).thenReturn(settingsFlow)
whenever(pubkyService.contactRecords()).thenReturn(emptyList())
+ whenever(keychain.loadString(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name))
+ .thenAnswer { sharedExportEnabled }
+ whenever(keychain.upsertString(eq(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name), any()))
+ .thenAnswer {
+ sharedExportEnabled = it.getArgument(1)
+ Unit
+ }
+ whenever(keychain.delete(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name))
+ .thenAnswer {
+ sharedExportEnabled = null
+ Unit
+ }
+ whenever { pubkyStore.update(any()) }.thenAnswer {
+ val transform = it.getArgument<(PubkyStoreData) -> PubkyStoreData>(0)
+ pubkyDataFlow.value = transform(pubkyDataFlow.value)
+ Unit
+ }
+ whenever { pubkyStore.reset() }.thenAnswer {
+ pubkyDataFlow.value = PubkyStoreData()
+ Unit
+ }
whenever { settingsStore.update(any()) }.thenAnswer {
val transform = it.getArgument<(SettingsData) -> SettingsData>(0)
settingsFlow.value = transform(settingsFlow.value)
Unit
}
sut = createSut()
+ Unit
}
private fun createSut() = PubkyRepo(
@@ -85,6 +131,7 @@ class PubkyRepoTest : BaseUnitTest() {
pubkyStore = pubkyStore,
settingsStore = settingsStore,
httpClient = mock(),
+ sharedPubkyDiscovery = sharedPubkyDiscovery,
)
@Test
@@ -93,6 +140,151 @@ class PubkyRepoTest : BaseUnitTest() {
assertFalse(sut.isAuthenticated.value)
}
+ @Test
+ fun `adopt Ring identity persists source reference and never stores shared secret`() = test {
+ val identity = stubRingIdentity()
+
+ val result = sut.adoptRingIdentity(identity)
+
+ assertTrue(result.isSuccess)
+ assertEquals(VALID_SELF_KEY, sut.publicKey.value)
+ assertEquals(identity.toExternalRefForTest(), pubkyDataFlow.value.externalIdentityRef)
+ verifyBlocking(pubkyService) { signInExternal(SHARED_SECRET_KEY) }
+ verifyBlocking(keychain, never()) {
+ upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, SHARED_SECRET_KEY)
+ }
+ assertNull(sut.snapshotSessionBackupState().getOrThrow())
+ }
+
+ @Test
+ fun `adopt Ring identity rejects credential whose secret derives another pubky`() = test {
+ val identity = stubRingIdentity(derivedPublicKey = VALID_CONTACT_KEY_B)
+
+ val result = sut.adoptRingIdentity(identity)
+
+ assertTrue(result.isFailure)
+ assertNull(sut.publicKey.value)
+ assertNull(pubkyDataFlow.value.externalIdentityRef)
+ verifyBlocking(pubkyService, never()) { signInExternal(SHARED_SECRET_KEY) }
+ }
+
+ @Test
+ fun `Ring managed identity reads credential just in time for auth approval`() = test {
+ val identity = stubRingIdentity()
+ assertTrue(sut.adoptRingIdentity(identity).isSuccess)
+
+ val result = sut.approveAuth("pubkyauth://signin", "/pub/example/:rw")
+
+ assertTrue(result.isSuccess)
+ verifyBlocking(pubkyService) {
+ approveAuth("pubkyauth://signin", "/pub/example/:rw", SHARED_SECRET_KEY)
+ }
+ verifyBlocking(keychain, never()) {
+ upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, SHARED_SECRET_KEY)
+ }
+ }
+
+ @Test
+ fun `missing Ring source clears borrowed reference and local session`() = test {
+ val identity = stubRingIdentity()
+ assertTrue(sut.adoptRingIdentity(identity).isSuccess)
+ whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList()))
+ clearInvocations(pubkyService, pubkyStore)
+
+ val available = sut.validateExternalIdentitySource()
+
+ assertFalse(available)
+ assertNull(sut.publicKey.value)
+ assertNull(pubkyDataFlow.value.externalIdentityRef)
+ inOrder(pubkyService, pubkyStore) {
+ verify(pubkyService).clearExternalSessionAccess()
+ verify(pubkyStore).reset()
+ }
+ verifyBlocking(pubkyService, never()) { forceSignOut() }
+ }
+
+ @Test
+ fun `source cleanup preserves marker when external session cleanup fails`() = test {
+ val identity = stubRingIdentity()
+ assertTrue(sut.adoptRingIdentity(identity).isSuccess)
+ val cleanupError = RuntimeException("cleanup failed")
+ whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList()))
+ whenever { pubkyService.clearExternalSessionAccess() }.thenThrow(cleanupError)
+ clearInvocations(pubkyStore)
+
+ val thrown = runCatching { sut.validateExternalIdentitySource() }.exceptionOrNull()
+
+ assertEquals(cleanupError.message, thrown?.message)
+ assertEquals(identity.toExternalRefForTest(), pubkyDataFlow.value.externalIdentityRef)
+ verify(pubkyStore, never()).reset()
+ }
+
+ @Test
+ fun `source cleanup keeps marker after reset failure and succeeds on retry`() = test {
+ val identity = stubRingIdentity()
+ assertTrue(sut.adoptRingIdentity(identity).isSuccess)
+ val resetError = RuntimeException("reset failed")
+ whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList()))
+ doThrow(resetError).whenever(pubkyStore).reset()
+ clearInvocations(pubkyService, pubkyStore)
+
+ val thrown = runCatching { sut.validateExternalIdentitySource() }.exceptionOrNull()
+
+ assertEquals(resetError.message, thrown?.message)
+ assertEquals(identity.toExternalRefForTest(), pubkyDataFlow.value.externalIdentityRef)
+
+ doAnswer {
+ pubkyDataFlow.value = PubkyStoreData()
+ Unit
+ }.whenever(pubkyStore).reset()
+ assertFalse(sut.validateExternalIdentitySource())
+ assertNull(pubkyDataFlow.value.externalIdentityRef)
+ verifyBlocking(pubkyService, times(2)) { clearExternalSessionAccess() }
+ verifyBlocking(pubkyStore, times(2)) { reset() }
+ }
+
+ @Test
+ fun `source cleanup quarantines but never deletes a conflicting managed secret`() = test {
+ val identity = stubRingIdentity()
+ assertTrue(sut.adoptRingIdentity(identity).isSuccess)
+ whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList()))
+ whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("managed-secret")
+ whenever(keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name))
+ .thenReturn("1")
+
+ assertFalse(sut.validateExternalIdentitySource())
+
+ verifyBlocking(keychain) {
+ upsertString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name, "1")
+ }
+ verifyBlocking(keychain, never()) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) }
+ }
+
+ @Test
+ fun `Ring adoption cannot interleave with local identity restore`() = test {
+ val identity = stubRingIdentity()
+ val releaseExternalSignIn = CompletableDeferred()
+ whenever(pubkyService.signInExternal(SHARED_SECRET_KEY)).doSuspendableAnswer {
+ releaseExternalSignIn.await()
+ VALID_SELF_KEY
+ }
+
+ val adoption = async { sut.adoptRingIdentity(identity) }
+ runCurrent()
+ val restore = async { sut.restoreSessionBackupState(null) }
+ runCurrent()
+
+ assertFalse(restore.isCompleted)
+ verifyBlocking(pubkyService, never()) { clearSessionAccess() }
+
+ releaseExternalSignIn.complete(Unit)
+ advanceUntilIdle()
+
+ assertTrue(adoption.await().isSuccess)
+ assertTrue(restore.await().isSuccess)
+ verifyBlocking(pubkyService) { clearSessionAccess() }
+ }
+
@Test
fun `startAuthentication should return auth uri on success`() = test {
val authUri = "pubky://auth?capabilities=..."
@@ -198,7 +390,9 @@ class PubkyRepoTest : BaseUnitTest() {
val authUrl = "pubkyauth://signin?caps=/pub/bitkit.to/:rw"
val capabilities = "/pub/bitkit.to/:rw"
val secretKey = "local_secret"
+ authenticateForTesting(publicKey = VALID_SELF_KEY)
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey)
+ whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn(VALID_SELF_KEY)
val result = sut.approveAuth(authUrl, capabilities)
@@ -206,6 +400,32 @@ class PubkyRepoTest : BaseUnitTest() {
verifyBlocking(pubkyService) { approveAuth(authUrl, capabilities, secretKey) }
}
+ @Test
+ fun `approveAuthWithCompanionClaim forwards exact claim identifiers and capability`() = test {
+ val authUrl = "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1"
+ val secretKey = "local_secret"
+ val payload = ByteArray(84) { it.toByte() }
+ authenticateForTesting(publicKey = VALID_SELF_KEY)
+ whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey)
+ whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn(VALID_SELF_KEY)
+
+ val result = sut.approveAuthWithCompanionClaim(authUrl, payload)
+
+ assertTrue(result.isSuccess)
+ verifyBlocking(pubkyService) {
+ approveAuthWithCompanionClaim(
+ authUrl = authUrl,
+ expectedCapabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES,
+ secretKeyHex = secretKey,
+ claim = PubkyAuthCompanionClaim(
+ queryParameter = PubkyAuthClaim.QUERY_PARAMETER,
+ claimType = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue,
+ unsignedPayload = payload,
+ ),
+ )
+ }
+ }
+
@Test
fun `completeAuthentication should clear session when auth is canceled after completion`() = test {
whenever(pubkyService.startAuth()).thenReturn("auth_uri")
@@ -764,7 +984,7 @@ class PubkyRepoTest : BaseUnitTest() {
val pubkyProfile = createPubkyProfile(name = "Restored User")
whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session)
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null)
- whenever(pubkyService.importSession(session)).thenReturn(unprefixedPublicKey)
+ whenever(pubkyService.importExternalSession(session)).thenReturn(unprefixedPublicKey)
whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true))
.thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = pubkyProfile))
@@ -797,7 +1017,7 @@ class PubkyRepoTest : BaseUnitTest() {
val session = "stale_session"
whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session)
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null)
- whenever(pubkyService.importSession(session)).thenAnswer { throw TestAppError("Expired") }
+ whenever(pubkyService.importExternalSession(session)).thenAnswer { throw TestAppError("Expired") }
sut.initialize()
@@ -806,6 +1026,49 @@ class PubkyRepoTest : BaseUnitTest() {
verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) }
}
+ @Test
+ fun `initialize retries quarantined external cleanup before removing its marker`() = test {
+ val identity = stubRingIdentity()
+ pubkyDataFlow.value = PubkyStoreData(externalIdentityRef = identity.toExternalRefForTest())
+ whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("borrowed_session")
+ whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("managed_secret")
+ whenever(keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name)).thenReturn("1")
+ clearInvocations(pubkyService, pubkyStore)
+
+ sut.initialize()
+
+ assertNull(pubkyDataFlow.value.externalIdentityRef)
+ inOrder(pubkyService, pubkyStore) {
+ verify(pubkyService).clearExternalSessionAccess()
+ verify(pubkyStore).reset()
+ }
+ verifyBlocking(pubkyService, never()) { importExternalSession(any()) }
+ verifyBlocking(pubkyService, never()) { signInExternal(any()) }
+ verifyBlocking(keychain, never()) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) }
+ }
+
+ @Test
+ fun `initialize preserves external marker when source exists but sign in cannot recover`() = test {
+ val identity = stubRingIdentity()
+ val identityRef = identity.toExternalRefForTest()
+ pubkyDataFlow.value = PubkyStoreData(externalIdentityRef = identityRef)
+ whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("stale_session")
+ whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null)
+ whenever(pubkyService.importExternalSession("stale_session"))
+ .thenAnswer { throw TestAppError("Expired") }
+ whenever(pubkyService.signInExternal(SHARED_SECRET_KEY))
+ .thenAnswer { throw TestAppError("Offline") }
+ clearInvocations(pubkyService, pubkyStore)
+
+ sut.initialize()
+
+ assertTrue(sut.sessionRestorationFailed.value)
+ assertFalse(sut.isAuthenticated.value)
+ assertEquals(identityRef, pubkyDataFlow.value.externalIdentityRef)
+ verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() }
+ verifyBlocking(pubkyStore, never()) { reset() }
+ }
+
@Test
fun `refreshSessionIfPossible should refresh session when local secret key exists`() = test {
val secretKey = "local_secret"
@@ -834,6 +1097,7 @@ class PubkyRepoTest : BaseUnitTest() {
@Test
fun `restoreSessionBackupState should derive local secret key for local seed backups`() = test {
whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("test mnemonic")
+ whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("derived_secret")
whenever(pubkyService.deriveSecretKey("test mnemonic")).thenReturn("derived_secret")
whenever(pubkyService.signIn("derived_secret")).thenReturn(Unit)
whenever(pubkyService.publicKeyFromSecret("derived_secret")).thenReturn(VALID_SELF_KEY.removePrefix("pubky"))
@@ -1261,6 +1525,32 @@ class PubkyRepoTest : BaseUnitTest() {
assertEquals("pubky://avatar", contact.imageUrl)
}
+ private suspend fun stubRingIdentity(
+ derivedPublicKey: String = VALID_SELF_KEY,
+ ): SharedPubkyIdentity {
+ val identity = SharedPubkyIdentity(
+ protocolVersion = SharedPubkyContract.PROTOCOL_VERSION,
+ sourcePackage = SharedPubkyContract.RING_SOURCE,
+ pubky = VALID_SELF_KEY.removePrefix("pubky"),
+ )
+ whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(listOf(identity)))
+ whenever(sharedPubkyDiscovery.readRingCredential(identity.pubky)).thenReturn(
+ Result.success(
+ SharedPubkyCredential(
+ identity = identity,
+ secretKeyHex = SHARED_SECRET_KEY,
+ ),
+ ),
+ )
+ whenever(pubkyService.publicKeyFromSecret(SHARED_SECRET_KEY)).thenReturn(derivedPublicKey)
+ whenever(pubkyService.signInExternal(SHARED_SECRET_KEY)).thenReturn(VALID_SELF_KEY)
+ whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).thenReturn(
+ createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Satoshi")),
+ )
+ whenever(pubkyService.contactRecords()).thenReturn(emptyList())
+ return identity
+ }
+
private suspend fun authenticateForTesting(
publicKey: String = "test_pk_12345",
secret: String = "test_secret",
@@ -1359,3 +1649,9 @@ private class TestAppError(message: String) : AppError(message)
private fun String.ensurePubkyPrefixForTest(): String =
if (startsWith("pubky")) this else "pubky$this"
+
+private fun SharedPubkyIdentity.toExternalRefForTest() = ExternalPubkyIdentityRef(
+ protocolVersion = protocolVersion,
+ sourcePackage = sourcePackage,
+ pubky = pubky,
+)
diff --git a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt
new file mode 100644
index 0000000000..dfcee0ac5e
--- /dev/null
+++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt
@@ -0,0 +1,63 @@
+package to.bitkit.repositories
+
+import org.junit.Test
+import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE
+import to.bitkit.models.WatchOnlyAccountRecord
+import to.bitkit.models.WatchOnlyAccountSetupState
+import java.nio.ByteBuffer
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+class WatchOnlyAccountClaimCodecTest {
+ @Test
+ fun `unsigned claim contains exact account metadata`() {
+ val rawXpub = TESTNET_SERIALIZED_HEX.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
+ val account = account(accountIndex = 42, xpub = TESTNET_TPUB)
+
+ val payload = WatchOnlyAccountClaimCodec.encode(account) { xpub ->
+ require(xpub == TESTNET_TPUB)
+ rawXpub
+ }
+
+ assertEquals(84, payload.size)
+ assertEquals(WatchOnlyAccountClaimCodec.PAYLOAD_LENGTH, payload.size)
+ assertEquals(WatchOnlyAccountClaimCodec.VERSION, payload[0])
+ assertEquals(42, ByteBuffer.wrap(payload, 1, 4).int)
+ assertEquals(WatchOnlyAccountClaimCodec.NATIVE_SEGWIT_ADDRESS_TYPE, payload[5])
+ assertContentEquals(rawXpub, payload.copyOfRange(6, 84))
+ }
+
+ @Test
+ fun `unsigned claim rejects invalid Base58Check checksum`() {
+ val invalidXpub = TESTNET_TPUB.dropLast(1) + if (TESTNET_TPUB.last() == '1') '2' else '1'
+
+ assertFailsWith {
+ WatchOnlyAccountClaimCodec.encode(account(accountIndex = 1, xpub = invalidXpub)) {
+ throw IllegalArgumentException("Invalid extended public key")
+ }
+ }
+ }
+
+ private fun account(accountIndex: Int, xpub: String) = WatchOnlyAccountRecord(
+ id = "id",
+ walletIndex = 0,
+ accountIndex = accountIndex,
+ addressType = WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE,
+ xpub = xpub,
+ requestFingerprint = "request",
+ createdAt = 1,
+ name = "Test",
+ isTrackingEnabled = true,
+ setupState = WatchOnlyAccountSetupState.PendingDelivery,
+ )
+
+ private companion object {
+ const val TESTNET_TPUB =
+ "tpubDDWohsp5dx2iMJ9N7iHbgAEDhH4BJB9NWW1fEW3yA3AFNDREmpzteCXNqppMLUmKFY5q5e3" +
+ "PXtS5CuqWCQbYcGhpPqYAgQSYdwknW9J6sQv"
+ const val TESTNET_SERIALIZED_HEX =
+ "043587cf03caafd489800000004b5fcc4a5fe210d9fba6616b4db1d025237dd7f035101f11f562401bc7104699" +
+ "02e0bf22b51a6a49e0b149b995670d0ed9bb1fd99417748bacefba88fae655572d"
+ }
+}
diff --git a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountLifecycleCoordinatorTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountLifecycleCoordinatorTest.kt
new file mode 100644
index 0000000000..020474ac53
--- /dev/null
+++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountLifecycleCoordinatorTest.kt
@@ -0,0 +1,192 @@
+package to.bitkit.repositories
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runCurrent
+import org.junit.Test
+import org.lightningdevkit.ldknode.AddressType
+import org.lightningdevkit.ldknode.Node
+import org.lightningdevkit.ldknode.OnchainPayment
+import org.lightningdevkit.ldknode.OnchainWalletAccount
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import to.bitkit.data.SettingsStore
+import to.bitkit.data.WatchOnlyAccountAllocationState
+import to.bitkit.data.WatchOnlyAccountData
+import to.bitkit.data.WatchOnlyAccountReconciliationState
+import to.bitkit.data.WatchOnlyAccountStore
+import to.bitkit.data.WatchOnlyAccountXpubSerializer
+import to.bitkit.data.backup.VssStoreIdProvider
+import to.bitkit.data.keychain.Keychain
+import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX
+import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE
+import to.bitkit.models.WatchOnlyAccountRecord
+import to.bitkit.models.WatchOnlyAccountSetupState
+import to.bitkit.services.LightningService
+import to.bitkit.services.WatchOnlyAccountLifecycleCoordinator
+import to.bitkit.test.BaseUnitTest
+import to.bitkit.utils.LoggerLdk
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class WatchOnlyAccountLifecycleCoordinatorTest : BaseUnitTest() {
+ @Test
+ fun `reconciliation cannot remove an account while authorization is being persisted`() = test {
+ val account = account().copy(isTrackingEnabled = false, setupState = WatchOnlyAccountSetupState.PendingDelivery)
+ var storedAccounts = listOf(account)
+ val tracked = AtomicBoolean(false)
+ val loadCount = AtomicInteger(0)
+ val authorizationSyncStarted = CountDownLatch(1)
+ val allowAuthorizationSync = CountDownLatch(1)
+ val store = mock()
+ val node = mock()
+ val onchainPayment = mock()
+ val coordinator = WatchOnlyAccountLifecycleCoordinator()
+ whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = storedAccounts)))
+ whenever(store.load()).thenAnswer {
+ loadCount.incrementAndGet()
+ storedAccounts
+ }
+ whenever(store.loadReconciliationState()).thenAnswer {
+ loadCount.incrementAndGet()
+ WatchOnlyAccountReconciliationState(storedAccounts, emptyList())
+ }
+ whenever(store.update(any())).thenAnswer {
+ val transform = it.getArgument<(List) -> List>(0)
+ storedAccounts = transform(storedAccounts)
+ Unit
+ }
+ whenever(node.listOnchainWalletAccounts()).thenAnswer {
+ if (tracked.get()) listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) else emptyList()
+ }
+ whenever(node.onchainPayment()).thenReturn(onchainPayment)
+ doAnswer { tracked.set(true) }.whenever(node)
+ .addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub)
+ doAnswer { tracked.set(false) }.whenever(node)
+ .removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)
+ whenever(node.syncWallets()).thenAnswer {
+ authorizationSyncStarted.countDown()
+ check(allowAuthorizationSync.await(5, TimeUnit.SECONDS))
+ Unit
+ }
+ val lightningService = lightningService(store, node, coordinator)
+ val sut = repository(store, lightningService, coordinator)
+
+ val authorization = launch { sut.beginAuthorization(account.id) }
+ assertTrue(authorizationSyncStarted.await(5, TimeUnit.SECONDS))
+
+ val reconciliation = launch {
+ lightningService.reconcileWatchOnlyAccounts(syncAfterReconcile = false)
+ }
+ runCurrent()
+
+ assertEquals(1, loadCount.get())
+ assertTrue(reconciliation.isActive)
+
+ allowAuthorizationSync.countDown()
+ authorization.join()
+ reconciliation.join()
+
+ assertTrue(tracked.get())
+ assertTrue(storedAccounts.single().isTrackingEnabled)
+ assertEquals(WatchOnlyAccountSetupState.Authorizing, storedAccounts.single().setupState)
+ assertEquals(2, loadCount.get())
+ verify(node, never()).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)
+ }
+
+ @Test
+ fun `restore waits for in-flight reconciliation before replacing persisted accounts`() = test {
+ val account = account()
+ val restoredAccount = account.copy(name = "Restored account")
+ val allocationState = WatchOnlyAccountAllocationState(
+ highestAccountIndexByWallet = mapOf("0" to account.accountIndex),
+ )
+ val reconciliationStarted = CountDownLatch(1)
+ val allowReconciliation = CountDownLatch(1)
+ val store = mock()
+ val node = mock()
+ val onchainPayment = mock()
+ val coordinator = WatchOnlyAccountLifecycleCoordinator()
+ whenever(store.loadReconciliationState()).thenReturn(
+ WatchOnlyAccountReconciliationState(listOf(account), emptyList()),
+ )
+ whenever(node.listOnchainWalletAccounts()).thenReturn(
+ listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, account.accountIndex.toUInt())),
+ )
+ whenever(node.onchainPayment()).thenReturn(onchainPayment)
+ doAnswer {
+ reconciliationStarted.countDown()
+ check(allowReconciliation.await(5, TimeUnit.SECONDS))
+ }.whenever(onchainPayment).revealReceiveAddressesToAccount(
+ AddressType.NATIVE_SEGWIT,
+ account.accountIndex.toUInt(),
+ WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(),
+ )
+ val lightningService = lightningService(store, node, coordinator)
+ val sut = repository(store, lightningService, coordinator)
+
+ val reconciliation = launch {
+ lightningService.reconcileWatchOnlyAccounts(syncAfterReconcile = false)
+ }
+ assertTrue(reconciliationStarted.await(5, TimeUnit.SECONDS))
+
+ val restore = launch { sut.restore(listOf(restoredAccount), allocationState) }
+ runCurrent()
+ verify(store, never()).restore(listOf(restoredAccount), allocationState)
+
+ allowReconciliation.countDown()
+ reconciliation.join()
+ restore.join()
+
+ verify(store).restore(listOf(restoredAccount), allocationState)
+ }
+
+ private fun lightningService(
+ store: WatchOnlyAccountStore,
+ node: Node,
+ coordinator: WatchOnlyAccountLifecycleCoordinator,
+ ) = LightningService(
+ bgDispatcher = testDispatcher,
+ keychain = mock(),
+ vssStoreIdProvider = mock(),
+ settingsStore = mock(),
+ watchOnlyAccountStore = store,
+ loggerLdk = mock(),
+ watchOnlyAccountLifecycleCoordinator = coordinator,
+ ).apply { this.node = node }
+
+ private fun repository(
+ store: WatchOnlyAccountStore,
+ lightningService: LightningService,
+ coordinator: WatchOnlyAccountLifecycleCoordinator,
+ ) = WatchOnlyAccountRepo(
+ testDispatcher,
+ store,
+ lightningService,
+ coordinator,
+ mock(),
+ )
+
+ private fun account() = WatchOnlyAccountRecord(
+ id = "account-id",
+ walletIndex = 0,
+ accountIndex = 1,
+ addressType = WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE,
+ xpub = "xpub",
+ requestFingerprint = "request",
+ createdAt = 1,
+ name = "Creator account",
+ isTrackingEnabled = true,
+ setupState = WatchOnlyAccountSetupState.Active,
+ )
+}
diff --git a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt
new file mode 100644
index 0000000000..de15f212d0
--- /dev/null
+++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt
@@ -0,0 +1,565 @@
+package to.bitkit.repositories
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runCurrent
+import org.junit.Test
+import org.lightningdevkit.ldknode.AddressType
+import org.lightningdevkit.ldknode.Node
+import org.lightningdevkit.ldknode.OnchainPayment
+import org.lightningdevkit.ldknode.OnchainWalletAccount
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doThrow
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import to.bitkit.data.WatchOnlyAccountAllocationState
+import to.bitkit.data.WatchOnlyAccountData
+import to.bitkit.data.WatchOnlyAccountStore
+import to.bitkit.data.WatchOnlyAccountXpubSerializer
+import to.bitkit.data.reserveAccountIndex
+import to.bitkit.data.restoreAccounts
+import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX
+import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE
+import to.bitkit.models.WatchOnlyAccountRecord
+import to.bitkit.models.WatchOnlyAccountSetupState
+import to.bitkit.services.LightningService
+import to.bitkit.services.WatchOnlyAccountLifecycleCoordinator
+import to.bitkit.test.BaseUnitTest
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertNotEquals
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class WatchOnlyAccountRepoTest : BaseUnitTest() {
+ @Test
+ fun `current wallet accounts exclude records from other wallets`() = test {
+ val currentWalletAccount = account().copy(walletIndex = 1)
+ val otherWalletAccount = account().copy(id = "other-account", walletIndex = 0)
+ val store = mock()
+ val lightningService = mock()
+ whenever(store.data).thenReturn(
+ flowOf(WatchOnlyAccountData(accounts = listOf(otherWalletAccount, currentWalletAccount))),
+ )
+ whenever(lightningService.currentWalletIndex).thenReturn(1)
+ val sut = repository(store, lightningService)
+
+ assertEquals(listOf(currentWalletAccount), sut.currentWalletAccounts.first())
+ assertEquals(1, sut.currentWalletAccountCount.first())
+ }
+
+ @Test
+ fun `authorization fails before tracking when the prepared account is missing`() = test {
+ val store = mock()
+ val lightningService = mock()
+ val sut = repository(store, lightningService)
+ whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData()))
+ whenever(store.load()).thenReturn(emptyList())
+
+ assertFailsWith {
+ sut.beginAuthorization("missing")
+ }
+
+ verify(lightningService, never()).node
+ verify(store, never()).update(any())
+ }
+
+ @Test
+ fun `activation fails before persistence when the account is missing`() = test {
+ val store = mock()
+ val lightningService = mock()
+ whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData()))
+ whenever(store.load()).thenReturn(emptyList())
+ val sut = repository(store, lightningService)
+
+ assertFailsWith {
+ sut.markActive("missing")
+ }
+
+ verify(store, never()).markActive(any())
+ }
+
+ @Test
+ fun `activation persistence completes after caller cancellation while waiting for lifecycle lock`() = test {
+ val account = account().copy(setupState = WatchOnlyAccountSetupState.Authorizing)
+ val store = mock()
+ val lightningService = mock()
+ val coordinator = WatchOnlyAccountLifecycleCoordinator()
+ val lockAcquired = CompletableDeferred()
+ val releaseLock = CompletableDeferred()
+ whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = listOf(account))))
+ whenever(store.load()).thenReturn(listOf(account))
+ val sut = repository(store, lightningService, coordinator)
+ val lockHolder = launch {
+ coordinator.withLock {
+ lockAcquired.complete(Unit)
+ releaseLock.await()
+ }
+ }
+ lockAcquired.await()
+
+ val activation = launch { sut.markActive(account.id) }
+ runCurrent()
+ activation.cancel()
+ runCurrent()
+
+ verify(store, never()).markActive(any())
+ releaseLock.complete(Unit)
+ lockHolder.join()
+ activation.join()
+
+ verify(store).markActive(account.id)
+ }
+
+ @Test
+ fun `retry with reordered query reuses the pending account and xpub without tracking it`() = test {
+ var storedAccounts = emptyList()
+ val store = mock()
+ val lightningService = mock()
+ val node = mock()
+ whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = storedAccounts)))
+ whenever(store.load()).thenAnswer { storedAccounts }
+ whenever(store.save(any())).thenAnswer {
+ storedAccounts = it.getArgument(0)
+ Unit
+ }
+ whenever(store.reserveAccountIndex(any(), any())).thenReturn(1)
+ whenever(lightningService.node).thenReturn(node)
+ whenever(node.exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 1u)).thenReturn(TEST_XPUB)
+ val sut = repository(store, lightningService)
+
+ val first = sut.prepareUnsignedClaim(
+ "pubkyauth://signin?relay=https%3A%2F%2Frelay.example&secret=same&" +
+ "caps=%2Fpub%2Fpaykit%2Fv0%2Fbitkit%2Fserver%2F%3Arw&x-bitkit-claim=watch-only-account-v1",
+ "Creator account",
+ )
+ val retry = sut.prepareUnsignedClaim(
+ "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1&" +
+ "caps=%2Fpub%2Fpaykit%2Fv0%2Fbitkit%2Fserver%2F%3Arw&secret=same&" +
+ "relay=https%3A%2F%2Frelay.example",
+ "Renamed account",
+ )
+
+ assertEquals(first.account.id, retry.account.id)
+ assertEquals(first.account.accountIndex, retry.account.accountIndex)
+ assertEquals(first.account.xpub, retry.account.xpub)
+ assertEquals("Renamed account", retry.account.name)
+ assertFalse(retry.account.isTrackingEnabled)
+ verify(node, times(1)).exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 1u)
+ verify(node, never()).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, TEST_XPUB)
+ }
+
+ @Test
+ fun `restored colliding request gets a fresh account index and xpub`() = test {
+ val authorizingAccount = account().copy(
+ accountIndex = 5,
+ xpub = TEST_XPUB_ALTERNATE,
+ requestFingerprint = "current-authorizing-request",
+ setupState = WatchOnlyAccountSetupState.Authorizing,
+ )
+ val restoredRequestKey = "0:$RESTORED_REQUEST_FINGERPRINT"
+ var storedData = WatchOnlyAccountData(
+ accounts = listOf(authorizingAccount),
+ highestAccountIndexByWallet = mapOf("0" to 5),
+ pendingAccountIndexByRequest = mapOf(
+ "0:${authorizingAccount.requestFingerprint}" to 5,
+ ),
+ ).restoreAccounts(
+ accounts = emptyList(),
+ allocationState = WatchOnlyAccountAllocationState(
+ highestAccountIndexByWallet = mapOf("0" to 5),
+ pendingAccountIndexByRequest = mapOf(restoredRequestKey to 5),
+ ),
+ serializeXpub = { ByteArray(78) },
+ )
+ assertFalse(restoredRequestKey in storedData.pendingAccountIndexByRequest)
+ val store = mock()
+ val lightningService = mock()
+ val node = mock()
+ whenever(store.data).thenReturn(flowOf(storedData))
+ whenever(store.load()).thenAnswer { storedData.accounts }
+ whenever(store.reserveAccountIndex(any(), any())).thenAnswer {
+ val reservation = storedData.reserveAccountIndex(
+ walletIndex = it.getArgument(0),
+ requestFingerprint = it.getArgument(1),
+ )
+ storedData = reservation.data
+ reservation.accountIndex
+ }
+ whenever(store.save(any())).thenAnswer {
+ storedData = storedData.copy(accounts = it.getArgument(0))
+ Unit
+ }
+ whenever(lightningService.currentWalletIndex).thenReturn(0)
+ whenever(lightningService.node).thenReturn(node)
+ whenever(node.exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 6u)).thenReturn(TEST_XPUB)
+ val sut = repository(store, lightningService)
+
+ val prepared = sut.prepareUnsignedClaim(RESTORED_AUTH_URL, "Restored server")
+
+ assertEquals(6, prepared.account.accountIndex)
+ assertEquals(TEST_XPUB, prepared.account.xpub)
+ assertNotEquals(authorizingAccount.xpub, prepared.account.xpub)
+ assertEquals(6, storedData.pendingAccountIndexByRequest[restoredRequestKey])
+ assertEquals(listOf(5, 6), storedData.accounts.map(WatchOnlyAccountRecord::accountIndex))
+ verify(node).exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 6u)
+ verify(node, never()).exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 5u)
+ }
+
+ @Test
+ fun `older pending reservation cannot reuse a later active account index`() = test {
+ val activeAccount = account().copy(
+ accountIndex = 5,
+ xpub = TEST_XPUB_ALTERNATE,
+ requestFingerprint = RESTORED_REQUEST_FINGERPRINT,
+ setupState = WatchOnlyAccountSetupState.Active,
+ )
+ val restoredRequestKey = "0:$RESTORED_REQUEST_FINGERPRINT"
+ var storedData = WatchOnlyAccountData(
+ accounts = listOf(activeAccount),
+ highestAccountIndexByWallet = mapOf("0" to 5),
+ pendingAccountIndexByRequest = mapOf(restoredRequestKey to 5),
+ ).restoreAccounts(
+ accounts = emptyList(),
+ allocationState = WatchOnlyAccountAllocationState(
+ highestAccountIndexByWallet = mapOf("0" to 5),
+ pendingAccountIndexByRequest = mapOf(restoredRequestKey to 5),
+ ),
+ serializeXpub = { ByteArray(78) },
+ )
+ assertEquals(listOf(activeAccount), storedData.accountsPendingRemoval)
+ assertFalse(restoredRequestKey in storedData.pendingAccountIndexByRequest)
+ val store = mock()
+ val lightningService = mock()
+ val node = mock()
+ whenever(store.data).thenReturn(flowOf(storedData))
+ whenever(store.load()).thenAnswer { storedData.accounts }
+ whenever(store.reserveAccountIndex(any(), any())).thenAnswer {
+ val reservation = storedData.reserveAccountIndex(
+ walletIndex = it.getArgument(0),
+ requestFingerprint = it.getArgument(1),
+ )
+ storedData = reservation.data
+ reservation.accountIndex
+ }
+ whenever(store.save(any())).thenAnswer {
+ storedData = storedData.copy(accounts = it.getArgument(0))
+ Unit
+ }
+ whenever(lightningService.currentWalletIndex).thenReturn(0)
+ whenever(lightningService.node).thenReturn(node)
+ whenever(node.exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 6u)).thenReturn(TEST_XPUB)
+ val sut = repository(store, lightningService)
+
+ val prepared = sut.prepareUnsignedClaim(RESTORED_AUTH_URL, "Restored server")
+
+ assertEquals(6, prepared.account.accountIndex)
+ assertEquals(TEST_XPUB, prepared.account.xpub)
+ assertNotEquals(activeAccount.xpub, prepared.account.xpub)
+ assertEquals(6, storedData.pendingAccountIndexByRequest[restoredRequestKey])
+ assertEquals(listOf(6), storedData.accounts.map(WatchOnlyAccountRecord::accountIndex))
+ assertEquals(listOf(5), storedData.accountsPendingRemoval.map(WatchOnlyAccountRecord::accountIndex))
+ verify(node).exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 6u)
+ verify(node, never()).exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 5u)
+ }
+
+ @Test
+ fun `failed authorization unloads the pending account`() = test {
+ val account = account().copy(isTrackingEnabled = false, setupState = WatchOnlyAccountSetupState.PendingDelivery)
+ var storedAccounts = listOf(account)
+ var isTracked = false
+ val store = mock()
+ val lightningService = mock()
+ val node = mock()
+ val onchainPayment = mock()
+ whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = storedAccounts)))
+ whenever(store.load()).thenAnswer { storedAccounts }
+ whenever(store.save(any())).thenAnswer {
+ storedAccounts = it.getArgument(0)
+ Unit
+ }
+ whenever(lightningService.node).thenReturn(node)
+ whenever(node.onchainPayment()).thenReturn(onchainPayment)
+ whenever(node.listOnchainWalletAccounts()).thenAnswer {
+ if (isTracked) listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) else emptyList()
+ }
+ doAnswer { isTracked = true }.whenever(
+ node
+ ).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub)
+ doAnswer { isTracked = false }.whenever(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)
+ val sut = repository(store, lightningService)
+
+ sut.beginAuthorization(account.id)
+ sut.cancelAuthorization(account.id)
+
+ assertFalse(storedAccounts.single().isTrackingEnabled)
+ verify(node).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub)
+ verify(onchainPayment).revealReceiveAddressesToAccount(
+ AddressType.NATIVE_SEGWIT,
+ 1u,
+ WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(),
+ )
+ verify(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)
+ }
+
+ @Test
+ fun `retry failure keeps a delivered account authorizing and tracked`() = test {
+ val account = account().copy(
+ isTrackingEnabled = true,
+ setupState = WatchOnlyAccountSetupState.Authorizing,
+ )
+ var storedAccounts = listOf(account)
+ val store = mock()
+ val lightningService = mock()
+ val node = mock()
+ val onchainPayment = mock()
+ whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = storedAccounts)))
+ whenever(store.load()).thenAnswer { storedAccounts }
+ whenever(store.update(any())).thenAnswer {
+ val transform = it.getArgument<(List) -> List>(0)
+ storedAccounts = transform(storedAccounts)
+ Unit
+ }
+ whenever(store.save(any())).thenAnswer {
+ storedAccounts = it.getArgument(0)
+ Unit
+ }
+ whenever(lightningService.node).thenReturn(node)
+ whenever(node.onchainPayment()).thenReturn(onchainPayment)
+ whenever(node.listOnchainWalletAccounts()).thenReturn(
+ listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)),
+ )
+ val sut = repository(store, lightningService)
+
+ val preserveAuthorizingState = sut.beginAuthorization(account.id)
+ sut.cancelAuthorization(account.id, preserveAuthorizingState)
+
+ assertTrue(preserveAuthorizingState)
+ assertEquals(WatchOnlyAccountSetupState.Authorizing, storedAccounts.single().setupState)
+ assertTrue(storedAccounts.single().isTrackingEnabled)
+ verify(node, never()).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)
+ }
+
+ @Test
+ fun `failed authorization keeps persisted state when unloading fails`() = test {
+ val account = account().copy(setupState = WatchOnlyAccountSetupState.Authorizing)
+ var storedAccounts = listOf(account)
+ val unloadError = IllegalStateException("unload failed")
+ val store = mock()
+ val lightningService = mock()
+ val node = mock()
+ whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = storedAccounts)))
+ whenever(store.load()).thenAnswer { storedAccounts }
+ whenever(lightningService.node).thenReturn(node)
+ whenever(node.listOnchainWalletAccounts()).thenReturn(
+ listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)),
+ )
+ doThrow(unloadError).whenever(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)
+ val sut = repository(store, lightningService)
+
+ val error = runCatching { sut.cancelAuthorization(account.id) }.exceptionOrNull()
+
+ assertSame(unloadError, error?.cause)
+ assertEquals(account, storedAccounts.single())
+ verify(store, never()).save(any())
+ }
+
+ @Test
+ fun `failed authorization reloads the account when persistence fails`() = test {
+ val account = account().copy(setupState = WatchOnlyAccountSetupState.Authorizing)
+ var storedAccounts = listOf(account)
+ var isTracked = true
+ val persistenceError = IllegalStateException("persistence failed")
+ val store = mock