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/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..00d4f31f52 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -232,6 +232,7 @@ class Keychain @Inject constructor( PIN, PIN_ATTEMPTS_REMAINING, PAYKIT_SESSION, + PAYKIT_RECEIVER_NOISE_SECRET_KEY, PAYKIT_SDK_STATE, 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/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 9f92ef4fc9..719821704c 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 @@ -91,6 +92,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, @@ -263,6 +266,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 @@ -546,11 +560,14 @@ class BackupRepo @Inject constructor( val privateReservations = privatePaykitAddressReservationRepo.get().backupSnapshot().getOrThrow() val paykitSdkBackupState = privatePaykitRepo.get().backupSnapshot().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() @@ -632,6 +649,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 74dd9b49a0..d813d4e0a9 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -343,8 +343,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..be6db40cd4 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 @@ -40,6 +40,8 @@ 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 @@ -897,9 +899,14 @@ class PubkyRepo @Inject constructor( managedSecretKeyFor(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() } } @@ -912,6 +919,27 @@ class PubkyRepo @Inject constructor( } } + suspend fun approveAuthWithCompanionClaim( + authUrl: String, + unsignedPayload: ByteArray, + ): Result = runSuspendCatching { + withContext(ioDispatcher) { + val secretKeyHex = requireNotNull(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) { + "No secret key available — use Ring to manage authorizations" + } + 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 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..951c96571c --- /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() { + class AuthorizationAccountMissing : WatchOnlyAccountError() + class InvalidAccountName : WatchOnlyAccountError() + class InvalidExtendedPublicKey : WatchOnlyAccountError() + class 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.name()) + +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 75419bda3a..591ed0956e 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..25ea826aa3 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 @@ -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(), @@ -204,8 +217,10 @@ class PaykitSdkService @Inject constructor( suspend fun signIn(secretKeyHex: String): 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 { @@ -237,6 +252,7 @@ class PaykitSdkService @Inject constructor( try { request.complete( localSecretKey = null, + receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), requiredCapabilities = requiredCapabilities(), ).also { activateBootstrapResult( @@ -270,6 +286,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 +458,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)) } } } @@ -644,6 +667,7 @@ class PaykitSdkService @Inject constructor( shouldStoreLocalSecret: Boolean, ) { keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, access.exportSessionSecret()) + sessionProvider.persistReceiverNoiseSecretKey(access.exportReceiverNoiseSecretKey()) val localSecret = access.exportLocalSecretKey() if (shouldStoreLocalSecret && localSecret != null) { keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex(localSecret)) @@ -663,7 +687,30 @@ class PaykitSdkService @Inject constructor( 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 +765,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()) @@ -790,6 +839,7 @@ private class PaykitSdkSessionProvider( private val keychain: Keychain, ) : SdkPubkySessionProvider { private val lock = Any() + private val receiverNoiseKeyStore = PaykitReceiverNoiseKeyStore(keychain) private var liveSessionAccess: PubkySessionAccess? = null fun setLiveSessionAccess(access: PubkySessionAccess) = synchronized(lock) { @@ -814,6 +864,7 @@ private class PaykitSdkSessionProvider( return PubkySessionAccess( sessionSecret = sessionSecret, localSecretKey = loadLocalSecretKey(), + receiverNoiseSecretKey = loadOrDeriveReceiverNoiseSecretKey(), ) } @@ -833,6 +884,127 @@ private class PaykitSdkSessionProvider( ?: return null return PaykitSdkService.localSecretKey(secretKeyHex) } + + fun loadOrDeriveReceiverNoiseSecretKey(): ReceiverNoiseSecretKey = + receiverNoiseKeyStore.loadOrDerive() + + fun persistReceiverNoiseSecretKey(key: ReceiverNoiseSecretKey) { + receiverNoiseKeyStore.persist(key) + } +} + +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..cc6cd7360b 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 @@ -117,6 +118,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..9ee9646df1 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 @@ -196,7 +196,6 @@ import to.bitkit.ui.sheets.LnurlAuthSheet import to.bitkit.ui.sheets.PinSheet import to.bitkit.ui.sheets.QrScanningSheet import to.bitkit.ui.sheets.QuickPayIntroSheet -import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.SendSheet import to.bitkit.ui.sheets.UpdateSheet import to.bitkit.ui.sheets.WidgetsSheet @@ -498,7 +497,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(), @@ -598,7 +597,7 @@ fun ContentView( isVisible = !hideTabBarForCalculator, onSendClick = { appViewModel.showSheet(Sheet.Send()) }, onReceiveClick = { appViewModel.showSheet(Sheet.Receive()) }, - onScanClick = { appViewModel.showSheet(Sheet.Send(SendRoute.QrScanner)) }, + onScanClick = { appViewModel.showScannerSheet() }, ) } } @@ -1166,7 +1165,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 +1193,9 @@ private fun NavGraphBuilder.contacts( ) } } - composableWithDefaultTransitions { + composableWithDefaultTransitions { backStackEntry -> PaykitRouteGuard(settingsViewModel, navController) { + val route = backStackEntry.toRoute() val viewModel: ContactDetailViewModel = hiltViewModel() ContactDetailScreen( viewModel = viewModel, @@ -1204,6 +1204,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 +1228,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) @@ -1430,14 +1440,6 @@ private fun NavGraphBuilder.generalSettingsSubScreens( onBack = { navController.popBackStack() }, ) } - composableWithDefaultTransitions { - PaykitRouteGuard(settingsViewModel, navController) { - PaymentPreferenceScreen( - onBack = { navController.popBackStack() }, - ) - } - } - composableWithDefaultTransitions { val notificationPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() @@ -1473,6 +1475,9 @@ private fun NavGraphBuilder.advancedSettingsSubScreens(navController: NavHostCon composableWithDefaultTransitions { AddressViewerScreen(navController) } + composableWithDefaultTransitions { + WatchOnlyAccountsScreen(navController) + } composableWithDefaultTransitions { NodeInfoScreen(navController) } @@ -1869,8 +1874,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 +1910,6 @@ sealed interface Routes { @Serializable data object TransactionSpeedSettings : Routes - @Serializable - data object PaymentPreferenceSettings : Routes - @Serializable data object WidgetsSettings : Routes @@ -1934,6 +1934,9 @@ sealed interface Routes { @Serializable data object AddressViewer : Routes + @Serializable + data object WatchOnlyAccounts : Routes + @Serializable data object CustomFeeSettings : Routes @@ -2123,7 +2126,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/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..cda8c1eaf4 100644 --- a/app/src/main/java/to/bitkit/ui/components/Button.kt +++ b/app/src/main/java/to/bitkit/ui/components/Button.kt @@ -101,6 +101,7 @@ fun PrimaryButton( fullWidth: Boolean = true, color: Color? = null, enableGradient: Boolean = true, + contentColor: Color = Colors.White, ) { val contentPadding = PaddingValues(horizontal = size.primaryHorizontalPadding.takeIf { text != null } ?: 0.dp) val buttonShape = MaterialTheme.shapes.extraLarge @@ -110,7 +111,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, 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..e8a089ecf1 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 ) 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..35aebff72c 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,6 +2,7 @@ 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 @@ -12,15 +13,22 @@ 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 +40,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 +52,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( @@ -96,7 +118,7 @@ private fun Content( Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = 32.dp) + .padding(horizontal = 16.dp) ) { VerticalSpacer(24.dp) @@ -109,7 +131,8 @@ private fun Content( 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, ) @@ -153,8 +176,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), ) @@ -196,26 +219,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 +258,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/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..0bd1de7ab9 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsScreen.kt @@ -0,0 +1,288 @@ +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.DrawerNavIcon +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, + actions = { DrawerNavIcon() }, + ) + 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/utils/PubkyAuthErrorMessage.kt b/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt new file mode 100644 index 0000000000..e558c189d7 --- /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 + is WatchOnlyAccountError.AuthorizationAccountMissing -> R.string.watch_only_accounts__setup_not_finished + is WatchOnlyAccountError.InvalidAccountName -> R.string.watch_only_accounts__error_invalid_name + is WatchOnlyAccountError.InvalidExtendedPublicKey -> R.string.watch_only_accounts__error_invalid_xpub + is 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 abe1a655f5..2eed6be1f4 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1230,6 +1230,8 @@ class AppViewModel @Inject constructor( // Skip validation for empty input if (valueWithoutSpaces.isEmpty()) return + if (valueWithoutSpaces.startsWith("$PUBKYAUTH_SCHEME://", ignoreCase = true)) return + if (PubkyPublicKeyFormat.normalized(valueWithoutSpaces) != null) { if (isPaykitEnabled.value) { _sendUiState.update { it.copy(isAddressInputValid = true) } @@ -1727,9 +1729,16 @@ class AppViewModel @Inject constructor( return@withContext } - if (input.startsWith("$PUBKYAUTH_SCHEME://")) { + if (input.startsWith("$PUBKYAUTH_SCHEME://", ignoreCase = true)) { clearActiveContactPaymentContext() - if (isPaykitEnabled.value) { + if (!fromMainScanner) { + hideSheet() + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__qr_error_header), + description = context.getString(R.string.other__qr_error_text), + ) + } else if (isPaykitEnabled.value) { handlePubkyAuth(input) } else { hideSheet() @@ -2820,9 +2829,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) { @@ -3279,6 +3291,15 @@ class AppViewModel @Inject constructor( } private suspend fun handlePubkyAuth(authUrl: String) { + if (pubkyRepo.publicKey.value == null) { + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.pubky_auth__no_identity), + description = context.getString(R.string.pubky_auth__no_identity_desc), + ) + return + } + if (!pubkyRepo.hasSecretKey()) { ToastEventBus.send( type = Toast.ToastType.WARNING, 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/values/strings.xml b/app/src/main/res/values/strings.xml index 17c003c372..eed968ded2 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 + 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,7 +576,17 @@ 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. @@ -594,18 +606,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,7 +626,6 @@ 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 @@ -635,6 +646,8 @@ Suggestions To Add Your Name Your Pubky + Pubky Identity Required + Create a Pubky identity in your profile to approve auth requests. Back Up Now that you have some funds in your wallet, it is time to back up your money! There are no funds in your wallet yet, but you can create a backup if you wish. @@ -874,6 +887,7 @@ Classic (₿ 0.00010000) Bitcoin denomination Modern (₿ 10 000) + Enable payments with contacts Interface Payments Transaction Speed @@ -896,16 +910,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 +1249,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 Paykit server gets a separate Bitcoin account. Turn tracking off to unload an account without deleting its wallet history. + Account details + Accounts appear here after you approve a Paykit server setup request. + 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/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 0eaecc6cb2..1ea56a51c8 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.assertFalse import kotlin.test.assertTrue import kotlin.time.Clock @@ -54,6 +63,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() @@ -82,6 +93,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))) @@ -292,6 +308,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() @@ -306,12 +372,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( @@ -365,6 +435,8 @@ class BackupRepoTest : BaseUnitTest() { vssBackupClientLdk = vssBackupClientLdk, settingsStore = settingsStore, widgetsStore = widgetsStore, + watchOnlyAccountStore = watchOnlyAccountStore, + watchOnlyAccountRepo = watchOnlyAccountRepo, blocktankRepo = blocktankRepo, activityRepo = activityRepo, pubkyRepo = pubkyRepo, @@ -378,4 +450,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 920974cf81..82d8aa6225 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -26,6 +26,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 @@ -197,6 +198,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 `PaymentReceived invalidates the cached invoice without decoding during event handling`() = test { val bolt11 = "lnbc1" 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..7972effba9 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -8,6 +8,7 @@ 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.async import kotlinx.coroutines.flow.MutableStateFlow @@ -29,6 +30,7 @@ import to.bitkit.data.PubkyStoreData import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain +import to.bitkit.models.PubkyAuthClaim import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyRingAuthCallback import to.bitkit.models.PubkyRingAuthCallbackHandlingResult @@ -206,6 +208,30 @@ 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() } + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) + + 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") 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() + 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())).thenThrow(persistenceError) + 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 = false }.whenever(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + doAnswer { isTracked = true }.whenever(node) + .addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub) + val sut = repository(store, lightningService) + + val error = runCatching { sut.cancelAuthorization(account.id) }.exceptionOrNull() + + assertEquals(persistenceError.message, error?.message) + assertTrue(isTracked) + assertEquals(account, storedAccounts.single()) + verify(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + 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).syncWallets() + } + + @Test + fun `already tracked account still pre-reveals addresses without syncing`() = test { + val account = account().copy(isTrackingEnabled = false, setupState = WatchOnlyAccountSetupState.PendingDelivery) + 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(lightningService.node).thenReturn(node) + whenever(node.listOnchainWalletAccounts()).thenReturn( + listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) + ) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + val sut = repository(store, lightningService) + + sut.beginAuthorization(account.id) + + assertTrue(storedAccounts.single().isTrackingEnabled) + assertEquals(WatchOnlyAccountSetupState.Authorizing, storedAccounts.single().setupState) + verify(node, never()).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, never()).syncWallets() + } + + @Test + fun `new account is removed when initial sync fails and original error is preserved`() = test { + val account = account().copy(isTrackingEnabled = false, setupState = WatchOnlyAccountSetupState.PendingDelivery) + var storedAccounts = listOf(account) + var isTracked = false + val syncError = IllegalStateException("initial sync failed") + 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(lightningService.node).thenReturn(node) + whenever(node.listOnchainWalletAccounts()).thenAnswer { + if (isTracked) listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) else emptyList() + } + whenever(node.onchainPayment()).thenReturn(onchainPayment) + doAnswer { isTracked = true }.whenever(node) + .addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub) + doAnswer { isTracked = false }.whenever(node) + .removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + whenever(node.syncWallets()).thenThrow(syncError) + val sut = repository(store, lightningService) + + val error = runCatching { sut.beginAuthorization(account.id) }.exceptionOrNull() + + assertFalse(isTracked) + assertFalse(storedAccounts.single().isTrackingEnabled) + assertEquals(WatchOnlyAccountSetupState.PendingDelivery, storedAccounts.single().setupState) + assertSame(syncError, error?.cause) + 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 `disable and re-enable unloads and restores the same account`() = test { + val account = account() + var storedAccounts = listOf(account) + var isTracked = true + 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 = false }.whenever(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + doAnswer { isTracked = true }.whenever( + node + ).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub) + val sut = repository(store, lightningService) + + sut.setTrackingEnabled(account.id, enabled = false) + + assertFalse(storedAccounts.single().isTrackingEnabled) + verify(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + + sut.setTrackingEnabled(account.id, enabled = true) + + assertTrue(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, times(1)).syncWallets() + } + + private fun repository( + store: WatchOnlyAccountStore, + lightningService: LightningService, + coordinator: WatchOnlyAccountLifecycleCoordinator = WatchOnlyAccountLifecycleCoordinator(), + ): WatchOnlyAccountRepo { + val xpubSerializer = mock() + whenever(xpubSerializer.serialize(any())).thenReturn(ByteArray(78)) + return WatchOnlyAccountRepo(testDispatcher, store, lightningService, coordinator, xpubSerializer) + } + + 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, + ) + + private companion object { + const val RESTORED_AUTH_URL = + "pubkyauth://signin?relay=https%3A%2F%2Frelay.example&secret=backup&" + + "caps=%2Fpub%2Fpaykit%2Fv0%2Fbitkit%2Fserver%2F%3Arw&x-bitkit-claim=watch-only-account-v1" + const val RESTORED_REQUEST_FINGERPRINT = "vuq8iJuzlfl/Jp58+w7KMgk1BNhEA5PJumkZfUrZjoY=" + const val TEST_XPUB = + "tpubDCgMbrEACV32r3jqiWn685NmYnqDkAcas1GBGh7XUVhxKFagQdpd2aY5kBMFqAFRa9NWPzCHma" + + "BEsU7YJcyjX8M8sswT3e6wq4LKCep3YaP" + const val TEST_XPUB_ALTERNATE = + "tpubDCgMbrEACV32r3jqiWn685Ni6Z8vkPtVn73Pv5ZvyXkwh4iFbrpcrXXPvtJhiCvHvRvZY6dXU" + + "gKt8aZEPpo4tRpHkNC7jR9B7JVZo8kFxCz" + } +} diff --git a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRestoreTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRestoreTest.kt new file mode 100644 index 0000000000..c3c748c8e2 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRestoreTest.kt @@ -0,0 +1,113 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.flow.flowOf +import org.junit.Test +import org.lightningdevkit.ldknode.AddressType +import org.lightningdevkit.ldknode.Node +import org.lightningdevkit.ldknode.OnchainPayment +import org.mockito.kotlin.any +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.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 to.bitkit.utils.LoggerLdk +import kotlin.test.assertEquals + +class WatchOnlyAccountRestoreTest : BaseUnitTest() { + @Test + fun `retry and reconciliation use the restored incomplete account`() = test { + val restoredAccount = account(id = "restored-account", accountIndex = 5, createdAt = 1) + val requestKey = "0:$REQUEST_FINGERPRINT" + val storedData = WatchOnlyAccountData().restoreAccounts( + accounts = listOf(restoredAccount), + allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = mapOf("0" to 5), + pendingAccountIndexByRequest = mapOf(requestKey to restoredAccount.accountIndex), + ), + serializeXpub = { ByteArray(78) }, + ) + val store = mock() + val node = mock() + val onchainPayment = mock() + val coordinator = WatchOnlyAccountLifecycleCoordinator() + whenever(store.data).thenReturn(flowOf(storedData)) + whenever(store.load()).thenReturn(storedData.accounts) + whenever(store.loadReconciliationState()).thenReturn( + WatchOnlyAccountReconciliationState(storedData.accounts, storedData.accountsPendingRemoval), + ) + whenever(node.listOnchainWalletAccounts()).thenReturn(emptyList()) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + val lightningService = lightningService(store, node, coordinator) + val xpubSerializer = mock() + whenever(xpubSerializer.serialize(TEST_XPUB)).thenReturn(ByteArray(78)) + val sut = WatchOnlyAccountRepo(testDispatcher, store, lightningService, coordinator, xpubSerializer) + + val prepared = sut.prepareUnsignedClaim(AUTH_URL, restoredAccount.name) + lightningService.reconcileWatchOnlyAccounts(syncAfterReconcile = false) + + assertEquals(listOf(restoredAccount), storedData.accounts) + assertEquals(restoredAccount.id, prepared.account.id) + assertEquals(restoredAccount.accountIndex, prepared.account.accountIndex) + verify(store, never()).reserveAccountIndex(any(), any()) + verify(node, never()).exportOnchainWalletAccountXpub(any(), any()) + verify(node).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 5u, TEST_XPUB) + verify(onchainPayment).revealReceiveAddressesToAccount( + AddressType.NATIVE_SEGWIT, + 5u, + WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(), + ) + } + + 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 account(id: String, accountIndex: Int, createdAt: Long) = WatchOnlyAccountRecord( + id = id, + walletIndex = 0, + accountIndex = accountIndex, + addressType = WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE, + xpub = TEST_XPUB, + requestFingerprint = REQUEST_FINGERPRINT, + createdAt = createdAt, + name = "Restored server", + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.Authorizing, + ) + + private companion object { + const val AUTH_URL = + "pubkyauth://signin?relay=https%3A%2F%2Frelay.example&secret=backup&" + + "caps=%2Fpub%2Fpaykit%2Fv0%2Fbitkit%2Fserver%2F%3Arw&x-bitkit-claim=watch-only-account-v1" + const val REQUEST_FINGERPRINT = "vuq8iJuzlfl/Jp58+w7KMgk1BNhEA5PJumkZfUrZjoY=" + const val TEST_XPUB = + "tpubDCgMbrEACV32r3jqiWn685NmYnqDkAcas1GBGh7XUVhxKFagQdpd2aY5kBMFqAFRa9NWPzCHma" + + "BEsU7YJcyjX8M8sswT3e6wq4LKCep3YaP" + } +} diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index e77bf24ae2..66c7feea66 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -2,15 +2,29 @@ package to.bitkit.services import org.junit.Before import org.junit.Test +import org.lightningdevkit.ldknode.AddressType import org.lightningdevkit.ldknode.Node +import org.lightningdevkit.ldknode.NodeStatus +import org.lightningdevkit.ldknode.OnchainPayment +import org.lightningdevkit.ldknode.OnchainWalletAccount +import org.mockito.kotlin.doAnswer 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.SettingsStore +import to.bitkit.data.WatchOnlyAccountReconciliationState +import to.bitkit.data.WatchOnlyAccountStore import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.keychain.Keychain import to.bitkit.ext.createChannelDetails +import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.test.BaseUnitTest import to.bitkit.utils.LoggerLdk +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -18,8 +32,10 @@ class LightningServiceTest : BaseUnitTest() { private val keychain = mock() private val vssStoreIdProvider = mock() private val settingsStore = mock() + private val watchOnlyAccountStore = mock() private val loggerLdk = mock() private val node = mock() + private val watchOnlyAccountLifecycleCoordinator = WatchOnlyAccountLifecycleCoordinator() private lateinit var sut: LightningService @@ -30,7 +46,9 @@ class LightningServiceTest : BaseUnitTest() { keychain = keychain, vssStoreIdProvider = vssStoreIdProvider, settingsStore = settingsStore, + watchOnlyAccountStore = watchOnlyAccountStore, loggerLdk = loggerLdk, + watchOnlyAccountLifecycleCoordinator = watchOnlyAccountLifecycleCoordinator, ) sut.node = node } @@ -56,4 +74,152 @@ class LightningServiceTest : BaseUnitTest() { assertTrue(sut.canReceive()) } + + @Test + fun `startup config includes enabled active and authorizing accounts for current wallet`() { + val enabled = watchOnlyAccount(accountIndex = 1, walletIndex = 0, enabled = true) + val disabled = watchOnlyAccount(accountIndex = 2, walletIndex = 0, enabled = false) + val otherWallet = watchOnlyAccount(accountIndex = 3, walletIndex = 1, enabled = true) + val pending = watchOnlyAccount(accountIndex = 4, walletIndex = 0, enabled = true) + .copy(setupState = WatchOnlyAccountSetupState.PendingDelivery) + val authorizing = watchOnlyAccount(accountIndex = 5, walletIndex = 0, enabled = true) + .copy(setupState = WatchOnlyAccountSetupState.Authorizing) + + val configs = enabledOnchainWalletAccountConfigs( + records = listOf(enabled, disabled, otherWallet, pending, authorizing), + walletIndex = 0, + ) + + assertEquals(listOf(1u, 5u), configs.map { it.accountIndex }) + assertEquals(listOf(enabled.xpub, authorizing.xpub), configs.map { it.xpub }) + } + + @Test + fun `reconciliation pre-reveals index 999 for an already tracked authorizing account`() = test { + val account = watchOnlyAccount(accountIndex = 5, walletIndex = 0, enabled = true) + .copy(setupState = WatchOnlyAccountSetupState.Authorizing) + val onchainPayment = mock() + val status = mock() + whenever(node.listOnchainWalletAccounts()).thenReturn( + listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 5u)) + ) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(node.status()).thenReturn(status) + whenever(status.isRunning).thenReturn(true) + + whenever(watchOnlyAccountStore.loadReconciliationState()).thenReturn( + WatchOnlyAccountReconciliationState(listOf(account), emptyList()), + ) + + sut.reconcileWatchOnlyAccounts() + + verify(node, never()).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 5u, account.xpub) + verify(onchainPayment).revealReceiveAddressesToAccount( + AddressType.NATIVE_SEGWIT, + 5u, + WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(), + ) + verify(node).syncWallets() + } + + @Test + fun `wallet sync retries watch-only reconciliation and syncs once`() = test { + val account = watchOnlyAccount(accountIndex = 5, walletIndex = 0, enabled = true) + .copy(setupState = WatchOnlyAccountSetupState.Authorizing) + val onchainPayment = mock() + whenever(watchOnlyAccountStore.loadReconciliationState()).thenReturn( + WatchOnlyAccountReconciliationState(listOf(account), emptyList()), + ) + whenever(node.listOnchainWalletAccounts()).thenReturn( + listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 5u)) + ) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + + sut.sync() + + verify(onchainPayment).revealReceiveAddressesToAccount( + AddressType.NATIVE_SEGWIT, + 5u, + WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(), + ) + verify(node, times(1)).syncWallets() + } + + @Test + fun `restore reconciliation unloads replaced account and loads restored account`() = test { + val replacedAccount = watchOnlyAccount(accountIndex = 1, walletIndex = 0, enabled = true) + val restoredAccount = watchOnlyAccount(accountIndex = 2, walletIndex = 0, enabled = true) + val trackedAccounts = mutableListOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) + val onchainPayment = mock() + whenever(watchOnlyAccountStore.loadReconciliationState()).thenReturn( + WatchOnlyAccountReconciliationState( + accounts = listOf(restoredAccount), + accountsPendingRemoval = listOf(replacedAccount), + ), + ) + whenever(node.listOnchainWalletAccounts()).thenAnswer { trackedAccounts.toList() } + whenever(node.onchainPayment()).thenReturn(onchainPayment) + doAnswer { + trackedAccounts.remove(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) + Unit + } + .whenever(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + doAnswer { trackedAccounts += OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 2u) } + .whenever(node).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 2u, restoredAccount.xpub) + + sut.reconcileWatchOnlyAccounts(syncAfterReconcile = false) + + assertEquals(listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 2u)), trackedAccounts) + verify(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + verify(node).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 2u, restoredAccount.xpub) + verify(watchOnlyAccountStore).completeReconciliation(walletIndex = 0) + } + + @Test + fun `failed restore reconciliation retains removals for the next retry`() = test { + val replacedAccount = watchOnlyAccount(accountIndex = 1, walletIndex = 0, enabled = true) + val restoredAccount = watchOnlyAccount(accountIndex = 2, walletIndex = 0, enabled = true) + val trackedAccounts = mutableListOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) + var failAdd = true + val onchainPayment = mock() + whenever(watchOnlyAccountStore.loadReconciliationState()).thenReturn( + WatchOnlyAccountReconciliationState( + accounts = listOf(restoredAccount), + accountsPendingRemoval = listOf(replacedAccount), + ), + ) + whenever(node.listOnchainWalletAccounts()).thenAnswer { trackedAccounts.toList() } + whenever(node.onchainPayment()).thenReturn(onchainPayment) + doAnswer { + trackedAccounts.remove(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) + Unit + } + .whenever(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + doAnswer { + check(!failAdd) { "add failed" } + trackedAccounts += OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 2u) + }.whenever(node).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 2u, restoredAccount.xpub) + + assertTrue(runCatching { sut.reconcileWatchOnlyAccounts(syncAfterReconcile = false) }.isFailure) + verify(watchOnlyAccountStore, never()).completeReconciliation(walletIndex = 0) + + failAdd = false + sut.reconcileWatchOnlyAccounts(syncAfterReconcile = false) + + assertEquals(listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 2u)), trackedAccounts) + verify(watchOnlyAccountStore).completeReconciliation(walletIndex = 0) + } + + private fun watchOnlyAccount(accountIndex: Int, walletIndex: Int, enabled: Boolean) = WatchOnlyAccountRecord( + id = "account-$accountIndex", + walletIndex = walletIndex, + accountIndex = accountIndex, + addressType = "nativeSegwit", + xpub = "xpub-$accountIndex", + requestFingerprint = "request-$accountIndex", + createdAt = 1, + name = "Account $accountIndex", + isTrackingEnabled = enabled, + setupState = WatchOnlyAccountSetupState.Active, + ) } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index c809c7d7e5..4e4e8aa325 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -4,7 +4,13 @@ import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy import com.synonym.paykit.EndpointManagementScope import com.synonym.paykit.PublicContactSharingPolicy import org.junit.Test +import to.bitkit.data.keychain.Keychain +import to.bitkit.ext.fromHex +import to.bitkit.ext.toHex +import to.bitkit.utils.AppError +import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class PaykitSdkServiceTest { @Test @@ -13,4 +19,84 @@ class PaykitSdkServiceTest { assertEquals(PublicContactSharingPolicy.LOCAL_ONLY, BitkitPaykitSdkConfig.publicContactSharing) assertEquals(EncryptedLinkRecoveryMarkerPolicy.ENABLED, BitkitPaykitSdkConfig.encryptedLinkRecoveryMarkers) } + + @Test + fun `receiver noise derivation matches versioned cross platform vector`() { + val seed = ( + "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e534955" + + "31f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" + ).fromHex() + + val key = PaykitReceiverNoiseKeyDerivation.derive( + seed = seed, + network = "bitcoin", + receiverPath = "bitkit/wallet", + ) + + assertEquals("500f4799bbb2d02103e3b74b365ddb478a3187333c053fa9eb62f4052ba6a327", key.toHex()) + } + + @Test + fun `receiver noise key is derived persisted and reused`() { + var persistedBytes: ByteArray? = null + val derivedBytes = ByteArray(32) { 7 } + val store = keyStore( + loadBytes = { persistedBytes }, + upsertBytes = { persistedBytes = it.copyOf() }, + deriveBytes = { derivedBytes }, + ) + + val first = store.loadOrDeriveBytes() + val second = store.loadOrDeriveBytes() + val restored = keyStore( + loadBytes = { persistedBytes }, + deriveBytes = { derivedBytes }, + ).loadOrDeriveBytes() + + assertEquals(32, first.size) + assertContentEquals(first, persistedBytes) + assertContentEquals(first, second) + assertContentEquals(first, restored) + assertEquals("PAYKIT_RECEIVER_NOISE_SECRET_KEY", Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name) + } + + @Test + fun `receiver noise key cannot be replaced`() { + val persistedBytes = ByteArray(32) { 1 } + val store = keyStore( + loadBytes = { persistedBytes }, + deriveBytes = { ByteArray(32) { 1 } }, + ) + + assertFailsWith { + store.persistBytes(ByteArray(32) { 2 }) + } + assertContentEquals(ByteArray(32) { 1 }, persistedBytes) + } + + @Test + fun `invalid persisted receiver noise key fails closed`() { + val store = keyStore( + loadBytes = { ByteArray(31) }, + deriveBytes = { ByteArray(32) }, + ) + + assertFailsWith { store.loadOrDeriveBytes() } + } + + @Test + fun `cached receiver noise key from another wallet seed fails closed`() { + val store = keyStore( + loadBytes = { ByteArray(32) { 1 } }, + deriveBytes = { ByteArray(32) { 2 } }, + ) + + assertFailsWith { store.loadOrDeriveBytes() } + } + + private fun keyStore( + loadBytes: () -> ByteArray?, + upsertBytes: (ByteArray) -> Unit = {}, + deriveBytes: () -> ByteArray, + ) = PaykitReceiverNoiseKeyStore(loadBytes, upsertBytes, deriveBytes) } diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt index 15041d7082..6ad1eb9c06 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt @@ -2,6 +2,7 @@ package to.bitkit.ui.screens.contacts import android.content.Context import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test @@ -80,6 +81,23 @@ class AddContactViewModelTest : BaseUnitTest() { assertNull(sut.uiState.value.error) } + @Test + fun `saving emits saved contact key`() = test { + val profile = PubkyProfile.placeholder(TEST_PUBLIC_KEY) + whenever(pubkyRepo.fetchContactProfile(TEST_PUBLIC_KEY)).thenReturn(Result.success(profile)) + whenever(publicPaykitRepo.hasPayablePublicEndpoint(TEST_PUBLIC_KEY)).thenReturn(Result.success(false)) + whenever { pubkyRepo.addContact(TEST_PUBLIC_KEY, profile) }.thenReturn(Result.success(Unit)) + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.saveContact() + advanceUntilIdle() + + assertEquals(AddContactEffect.ContactSaved(TEST_PUBLIC_KEY), awaitItem()) + } + } + private fun createSut(publicKey: String = TEST_PUBLIC_KEY): AddContactViewModel { return AddContactViewModel( context = context, diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt new file mode 100644 index 0000000000..8f9d3467ad --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt @@ -0,0 +1,214 @@ +package to.bitkit.ui.screens.contacts + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class ContactDetailViewModelTest : BaseUnitTest() { + companion object { + private const val TEST_PUBLIC_KEY = "pubkytest-contact" + } + + private val context: Context = mock() + private val pubkyRepo: PubkyRepo = mock() + private val privatePaykitRepo: PrivatePaykitRepo = mock() + + @Test + fun `deleting contact emits deleted effect`() = test { + whenever(context.getString(any())).thenReturn("") + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(pubkyRepo.removeContact(TEST_PUBLIC_KEY)).thenReturn(Result.success(Unit)) + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.showDeleteConfirmation() + assertTrue(sut.uiState.value.showDeleteDialog) + + sut.deleteContact() + advanceUntilIdle() + + verify(pubkyRepo).removeContact(TEST_PUBLIC_KEY) + assertFalse(sut.uiState.value.showDeleteDialog) + assertEquals(ContactDetailEffect.ContactDeleted, awaitItem()) + } + } + + @Test + fun `failed contact deletion restores loading state`() = test { + whenever(context.getString(any())).thenReturn("") + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(pubkyRepo.removeContact(TEST_PUBLIC_KEY)) + .thenReturn(Result.failure(ContactDetailTestError("delete failed"))) + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.showDeleteConfirmation() + sut.deleteContact() + advanceUntilIdle() + + verify(pubkyRepo).removeContact(TEST_PUBLIC_KEY) + assertFalse(sut.uiState.value.showDeleteDialog) + assertFalse(sut.uiState.value.isLoading) + expectNoEvents() + } + } + + @Test + fun `adding a tag persists the updated contact and closes the sheet`() = test { + whenever(context.getString(any())).thenReturn("") + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact(tags = listOf("Friend"))))) + whenever(pubkyRepo.updateContact(any(), any(), any(), anyOrNull(), any(), any())) + .thenReturn(Result.success(Unit)) + val sut = createSut() + advanceUntilIdle() + + sut.showAddTagSheet() + sut.addTag("Bitcoin") + advanceUntilIdle() + + assertEquals(listOf("Friend", "Bitcoin"), sut.uiState.value.tags) + assertFalse(sut.uiState.value.showAddTagSheet) + verify(pubkyRepo).updateContact( + publicKey = eq(TEST_PUBLIC_KEY), + name = any(), + bio = any(), + imageUrl = anyOrNull(), + links = any(), + tags = eq(listOf("Friend", "Bitcoin")), + ) + } + + @Test + fun `removing a tag persists the remaining contact tags`() = test { + whenever(context.getString(any())).thenReturn("") + whenever(pubkyRepo.contacts).thenReturn( + MutableStateFlow(listOf(createContact(tags = listOf("Friend", "Bitcoin")))), + ) + whenever(pubkyRepo.updateContact(any(), any(), any(), anyOrNull(), any(), any())) + .thenReturn(Result.success(Unit)) + val sut = createSut() + advanceUntilIdle() + + sut.removeTag("Friend") + advanceUntilIdle() + + assertEquals(listOf("Bitcoin"), sut.uiState.value.tags) + verify(pubkyRepo).updateContact( + publicKey = eq(TEST_PUBLIC_KEY), + name = any(), + bio = any(), + imageUrl = anyOrNull(), + links = any(), + tags = eq(listOf("Bitcoin")), + ) + } + + @Test + fun `rapid tag removals use stable tag identity`() = test { + whenever(context.getString(any())).thenReturn("") + whenever(pubkyRepo.contacts).thenReturn( + MutableStateFlow(listOf(createContact(tags = listOf("Friend", "Bitcoin")))), + ) + whenever(pubkyRepo.updateContact(any(), any(), any(), anyOrNull(), any(), any())) + .thenReturn(Result.success(Unit)) + val sut = createSut() + advanceUntilIdle() + + sut.removeTag("Friend") + sut.removeTag("Bitcoin") + advanceUntilIdle() + + inOrder(pubkyRepo).apply { + verify(pubkyRepo).updateContact(any(), any(), any(), anyOrNull(), any(), eq(listOf("Bitcoin"))) + verify(pubkyRepo).updateContact(any(), any(), any(), anyOrNull(), any(), eq(emptyList())) + } + assertEquals(emptyList(), sut.uiState.value.tags) + } + + @Test + fun `double tag removal does not remove a neighboring tag`() = test { + whenever(context.getString(any())).thenReturn("") + whenever(pubkyRepo.contacts).thenReturn( + MutableStateFlow(listOf(createContact(tags = listOf("Friend", "Bitcoin")))), + ) + whenever(pubkyRepo.updateContact(any(), any(), any(), anyOrNull(), any(), any())) + .thenReturn(Result.success(Unit)) + val sut = createSut() + advanceUntilIdle() + + sut.removeTag("Friend") + sut.removeTag("Friend") + advanceUntilIdle() + + verify(pubkyRepo, times(1)).updateContact( + any(), + any(), + any(), + anyOrNull(), + any(), + eq(listOf("Bitcoin")), + ) + assertEquals(listOf("Bitcoin"), sut.uiState.value.tags) + } + + @Test + fun `failed tag addition stays open and can be retried`() = test { + whenever(context.getString(any())).thenReturn("") + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact(tags = listOf("Friend"))))) + whenever(pubkyRepo.updateContact(any(), any(), any(), anyOrNull(), any(), any())).thenReturn( + Result.failure(ContactDetailTestError("save failed")), + Result.success(Unit), + ) + val sut = createSut() + advanceUntilIdle() + sut.showAddTagSheet() + + sut.addTag("Bitcoin") + advanceUntilIdle() + + assertTrue(sut.uiState.value.showAddTagSheet) + assertEquals(listOf("Friend"), sut.uiState.value.tags) + + sut.addTag("Bitcoin") + advanceUntilIdle() + + assertFalse(sut.uiState.value.showAddTagSheet) + assertEquals(listOf("Friend", "Bitcoin"), sut.uiState.value.tags) + verify(pubkyRepo, times(2)).updateContact(any(), any(), any(), anyOrNull(), any(), any()) + } + + private fun createSut() = ContactDetailViewModel( + context = context, + pubkyRepo = pubkyRepo, + privatePaykitRepo = privatePaykitRepo, + savedStateHandle = SavedStateHandle(mapOf("publicKey" to TEST_PUBLIC_KEY)), + ) + + private fun createContact(tags: List = emptyList()) = + PubkyProfile.placeholder(TEST_PUBLIC_KEY).copy(tags = tags) +} + +private class ContactDetailTestError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt index 0cdd04d145..e0aa67f34c 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt @@ -2,337 +2,66 @@ package to.bitkit.ui.screens.profile import android.content.Context import app.cash.turbine.test -import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.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.repositories.PrivatePaykitRepo -import to.bitkit.repositories.PubkyRepo -import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.repositories.ContactPaymentSettingsRepo import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class PayContactsViewModelTest : BaseUnitTest() { - companion object { - private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - } - private val context: Context = mock() - 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()) - private val contactsFlow = MutableStateFlow(listOf(createContact(CONTACT_KEY))) + private val contactPaymentSettingsRepo: ContactPaymentSettingsRepo = mock() @Before fun setUp() { - settingsFlow.value = SettingsData() - contactsFlow.value = listOf(createContact(CONTACT_KEY)) - whenever(context.getString(any())).thenReturn("") - whenever(settingsStore.data).thenReturn(settingsFlow) - whenever(pubkyRepo.contacts).thenReturn(contactsFlow) - 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.setContactSharingCleanupPending(any()) }.thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } - .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } - .thenReturn(Result.success(Unit)) - } - - @Test - fun `continueToProfile enables sharing and prepares private contacts`() = test { - val sut = createSut() - advanceUntilIdle() - - sut.effects.test { - sut.setPaymentSharingEnabled(true) - sut.continueToProfile() - advanceUntilIdle() - - assertEquals(PayContactsEffect.Continue, awaitItem()) - } - - assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) - assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(publicPaykitRepo, never()).syncLocalReceiverMarker(anyOrNull(), anyOrNull()) - verify(privatePaykitRepo).setContactSharingCleanupPending(false) - verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), false) - verify(privatePaykitRepo, never()).disableSharingAndPruneUnsavedContactState(any>()) - } - - @Test - fun `continueToProfile enables only public sharing without local secret key`() = test { - whenever { pubkyRepo.hasSecretKey() }.thenReturn(false) - val sut = createSut() - advanceUntilIdle() - - sut.effects.test { - sut.setPaymentSharingEnabled(true) - sut.continueToProfile() - advanceUntilIdle() - - assertEquals(PayContactsEffect.Continue, awaitItem()) - } - - assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(publicPaykitRepo, never()).syncLocalReceiverMarker(anyOrNull(), anyOrNull()) - verify(privatePaykitRepo, never()).setContactSharingCleanupPending(false) - verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) - } - - @Test - fun `continueToProfile cleans up when initial public publish fails`() = test { - whenever { publicPaykitRepo.syncPublishedEndpoints(publish = true) } - .thenReturn(Result.failure(PayContactsTestAppError("publish failed"))) - val sut = createSut() - advanceUntilIdle() - - sut.effects.test { - sut.setPaymentSharingEnabled(true) - sut.continueToProfile() - advanceUntilIdle() - - expectNoEvents() - } - - assertFalse(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) - } - - @Test - fun `continueToProfile keeps sharing disabled when cleanup marker clear fails`() = test { - whenever { privatePaykitRepo.setContactSharingCleanupPending(false) } - .thenReturn(Result.failure(PayContactsTestAppError("marker failed"))) - val sut = createSut() - advanceUntilIdle() - - sut.effects.test { - sut.setPaymentSharingEnabled(true) - sut.continueToProfile() - advanceUntilIdle() - - expectNoEvents() - } - - assertFalse(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) - assertFalse(sut.uiState.value.isLoading) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) - verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) - } - - @Test - fun `continueToProfile proceeds when private contact preparation fails`() = test { - whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } - .thenReturn(Result.failure(PayContactsTestAppError("private setup failed"))) - val sut = createSut() - advanceUntilIdle() - - sut.effects.test { - sut.setPaymentSharingEnabled(true) - sut.continueToProfile() - advanceUntilIdle() - - assertEquals(PayContactsEffect.Continue, awaitItem()) - } - - assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), false) + whenever { contactPaymentSettingsRepo.setEnabled(true) }.thenReturn(Result.success(Unit)) } @Test - fun `continueToProfile clears cleanup marker after disabling succeeds`() = test { - settingsFlow.value = SettingsData( - hasConfirmedPublicPaykitEndpoints = true, - sharesPublicPaykitEndpoints = true, - ) + fun `continue enables contact payments and continues`() = test { val sut = createSut() - advanceUntilIdle() sut.effects.test { - sut.setPaymentSharingEnabled(false) sut.continueToProfile() advanceUntilIdle() assertEquals(PayContactsEffect.Continue, awaitItem()) } - assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) - verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - verify(privatePaykitRepo).setContactSharingCleanupPending(false) + verify(contactPaymentSettingsRepo).setEnabled(true) assertFalse(sut.uiState.value.isLoading) } @Test - fun `continueToProfile marks cleanup pending when disabling fails`() = test { - settingsFlow.value = SettingsData( - hasConfirmedPublicPaykitEndpoints = true, - sharesPublicPaykitEndpoints = true, - ) - whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } - .thenReturn(Result.failure(PayContactsTestAppError("cleanup failed"))) + fun `continue stays on screen when enabling fails`() = test { + whenever(contactPaymentSettingsRepo.setEnabled(true)) + .thenReturn(Result.failure(PayContactsTestAppError("sync failed"))) val sut = createSut() - advanceUntilIdle() sut.effects.test { - sut.setPaymentSharingEnabled(false) sut.continueToProfile() advanceUntilIdle() expectNoEvents() } - assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) assertFalse(sut.uiState.value.isLoading) - assertFalse(sut.uiState.value.isPaymentSharingEnabled) - verify(privatePaykitRepo, never()).setContactSharingCleanupPending(true) - } - - @Test - fun `continueToProfile restores private sharing when private cleanup fails`() = test { - settingsFlow.value = SettingsData( - hasConfirmedPublicPaykitEndpoints = true, - sharesPrivatePaykitEndpoints = true, - ) - whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } - .thenReturn(Result.failure(PayContactsTestAppError("cleanup failed"))) - val sut = createSut() - advanceUntilIdle() - - sut.effects.test { - sut.setPaymentSharingEnabled(false) - sut.continueToProfile() - advanceUntilIdle() - - expectNoEvents() - } - - assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.isLoading) - assertTrue(sut.uiState.value.isPaymentSharingEnabled) - verify(privatePaykitRepo).setContactSharingCleanupPending(false) - verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), true) - verify(publicPaykitRepo).syncLocalReceiverMarker() - } - - @Test - fun `continueToProfile keeps private sharing disabled when private restore fails`() = test { - settingsFlow.value = SettingsData( - hasConfirmedPublicPaykitEndpoints = true, - sharesPrivatePaykitEndpoints = true, - ) - whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } - .thenReturn(Result.failure(PayContactsTestAppError("cleanup failed"))) - whenever { privatePaykitRepo.prepareSavedContacts(any>(), eq(true)) } - .thenReturn(Result.failure(PayContactsTestAppError("restore failed"))) - val sut = createSut() - advanceUntilIdle() - - sut.effects.test { - sut.setPaymentSharingEnabled(false) - sut.continueToProfile() - advanceUntilIdle() - - expectNoEvents() - } - - assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.isLoading) - assertFalse(sut.uiState.value.isPaymentSharingEnabled) - verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), true) - verify(privatePaykitRepo, never()).setContactSharingCleanupPending(false) - } - - @Test - fun `continueToProfile restores public sharing when public cleanup fails`() = test { - settingsFlow.value = SettingsData( - hasConfirmedPublicPaykitEndpoints = true, - sharesPublicPaykitEndpoints = true, - sharesPrivatePaykitEndpoints = true, - ) - whenever { publicPaykitRepo.syncPublishedEndpoints(publish = false) } - .thenReturn(Result.failure(PayContactsTestAppError("public cleanup failed"))) - val sut = createSut() - advanceUntilIdle() - - sut.effects.test { - sut.setPaymentSharingEnabled(false) - sut.continueToProfile() - advanceUntilIdle() - - expectNoEvents() - } - - assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.isLoading) - assertTrue(sut.uiState.value.isPaymentSharingEnabled) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - verify(privatePaykitRepo, never()).setContactSharingCleanupPending(true) } private fun createSut() = PayContactsViewModel( context = context, - settingsStore = settingsStore, - publicPaykitRepo = publicPaykitRepo, - privatePaykitRepo = privatePaykitRepo, - pubkyRepo = pubkyRepo, + contactPaymentSettingsRepo = contactPaymentSettingsRepo, ) } -private fun createContact(publicKey: String) = PubkyProfile( - publicKey = publicKey, - name = "Alice", - bio = "", - imageUrl = null, - links = emptyList(), - tags = persistentListOf(), - status = null, -) - private class PayContactsTestAppError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt index 1059215cba..7473f30ce7 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt @@ -7,17 +7,23 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer +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 import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyProfileLink import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class ProfileViewModelTest : BaseUnitTest() { @@ -62,7 +68,7 @@ class ProfileViewModelTest : BaseUnitTest() { @Test fun `signOut clears local Paykit state when Pubky sign out fails`() = test { val sut = createSut() - whenever { pubkyRepo.signOut() }.thenReturn(Result.failure(ProfileTestAppError("sign out failed"))) + whenever(pubkyRepo.signOut()).thenReturn(Result.failure(ProfileTestAppError("sign out failed"))) advanceUntilIdle() sut.signOut() @@ -71,13 +77,140 @@ class ProfileViewModelTest : BaseUnitTest() { verify(privatePaykitRepo).closeAndClear() } - private fun createSut(): ProfileViewModel { + @Test + fun `addTag saves the updated profile and closes the sheet`() = test { + val sut = createSut(createProfile()) + advanceUntilIdle() + + sut.uiState.test { + var state = awaitItem() + while (state.profile == null) state = awaitItem() + sut.showAddTagSheet() + assertTrue(awaitItem().showAddTagSheet) + + sut.addTag("Bitcoin") + assertFalse(awaitItem().showAddTagSheet) + } + advanceUntilIdle() + verify(pubkyRepo).saveProfile( + name = "Alice", + bio = "Builder", + links = listOf(PubkyProfileLink("Website", "https://example.com")), + tags = listOf("Founder", "Bitcoin"), + imageUrl = "https://example.com/avatar.png", + ) + } + + @Test + fun `adding an existing tag closes the sheet without saving`() = test { + val sut = createSut(createProfile()) + advanceUntilIdle() + + sut.uiState.test { + var state = awaitItem() + while (state.profile == null) state = awaitItem() + sut.showAddTagSheet() + assertTrue(awaitItem().showAddTagSheet) + + sut.addTag("Founder") + assertFalse(awaitItem().showAddTagSheet) + } + verify(pubkyRepo, never()).saveProfile(any(), any(), any(), any(), any()) + } + + @Test + fun `removeTag saves the profile without the selected tag`() = test { + val sut = createSut(createProfile(tags = listOf("Founder", "Bitcoin"))) + advanceUntilIdle() + + sut.removeTag("Founder") + advanceUntilIdle() + + verify(pubkyRepo).saveProfile( + name = eq("Alice"), + bio = eq("Builder"), + links = any(), + tags = eq(listOf("Bitcoin")), + imageUrl = eq("https://example.com/avatar.png"), + ) + } + + @Test + fun `rapid tag removals are serialized against the latest profile`() = test { + val profileFlow = MutableStateFlow( + createProfile(tags = listOf("Founder", "Bitcoin")), + ) + val sut = createSut(profileFlow = profileFlow) + whenever(pubkyRepo.saveProfile(any(), any(), any(), any(), any())).doSuspendableAnswer { + val tags = it.getArgument>(3) + profileFlow.value = requireNotNull(profileFlow.value).copy(tags = tags) + Result.success(Unit) + } + advanceUntilIdle() + + sut.removeTag("Founder") + sut.removeTag("Bitcoin") + advanceUntilIdle() + + inOrder(pubkyRepo).apply { + verify(pubkyRepo).saveProfile(any(), any(), any(), eq(listOf("Bitcoin")), any()) + verify(pubkyRepo).saveProfile(any(), any(), any(), eq(emptyList()), any()) + } + assertEquals(emptyList(), profileFlow.value?.tags) + } + + @Test + fun `double tap removal does not remove a neighboring tag`() = test { + val profileFlow = MutableStateFlow( + createProfile(tags = listOf("Founder", "Bitcoin")), + ) + val sut = createSut(profileFlow = profileFlow) + whenever(pubkyRepo.saveProfile(any(), any(), any(), any(), any())).doSuspendableAnswer { + val tags = it.getArgument>(3) + profileFlow.value = requireNotNull(profileFlow.value).copy(tags = tags) + Result.success(Unit) + } + advanceUntilIdle() + + sut.removeTag("Founder") + sut.removeTag("Founder") + advanceUntilIdle() + + verify(pubkyRepo, times(1)).saveProfile(any(), any(), any(), eq(listOf("Bitcoin")), any()) + assertEquals(listOf("Bitcoin"), profileFlow.value?.tags) + } + + @Test + fun `failed tag save keeps the add sheet open for retry`() = test { + val sut = createSut(createProfile()) + whenever(pubkyRepo.saveProfile(any(), any(), any(), any(), any())) + .thenReturn(Result.failure(ProfileTestAppError("save failed"))) + advanceUntilIdle() + + sut.uiState.test { + var state = awaitItem() + while (state.profile == null) state = awaitItem() + sut.showAddTagSheet() + assertTrue(awaitItem().showAddTagSheet) + + sut.addTag("Bitcoin") + advanceUntilIdle() + + assertTrue(sut.uiState.value.showAddTagSheet) + } + } + + private fun createSut( + profile: PubkyProfile? = null, + profileFlow: MutableStateFlow = MutableStateFlow(profile), + ): ProfileViewModel { whenever(context.getString(any())).thenReturn("") - whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) + whenever(pubkyRepo.profile).thenReturn(profileFlow) whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyalice")) whenever(pubkyRepo.isLoadingProfile).thenReturn(MutableStateFlow(false)) whenever { pubkyRepo.loadProfile() }.thenReturn(Unit) whenever { pubkyRepo.signOut() }.thenReturn(Result.success(Unit)) + whenever { pubkyRepo.saveProfile(any(), any(), any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit)) @@ -88,6 +221,16 @@ class ProfileViewModelTest : BaseUnitTest() { privatePaykitRepo = privatePaykitRepo, ) } + + private fun createProfile(tags: List = listOf("Founder")) = PubkyProfile( + publicKey = "pubkyalice", + name = "Alice", + bio = "Builder", + imageUrl = "https://example.com/avatar.png", + links = listOf(PubkyProfileLink("Website", "https://example.com")), + tags = tags, + status = null, + ) } private class ProfileTestAppError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt index c28de23cab..e5229d4bb9 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt @@ -1,22 +1,60 @@ package to.bitkit.ui.screens.profile import android.content.Context -import com.synonym.paykit.PubkyAuthDetails -import com.synonym.paykit.PubkyAuthRequestKind +import com.synonym.paykit.PubkyAuthCompanionClaimApprovalException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever +import to.bitkit.R +import to.bitkit.models.PreparedWatchOnlyAccountClaim +import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthPermission +import to.bitkit.models.PubkyAuthRequest +import to.bitkit.models.PubkyProfile +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.WatchOnlyAccountAuthorizationStartError +import to.bitkit.repositories.WatchOnlyAccountRepo import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError import kotlin.test.assertEquals @OptIn(ExperimentalCoroutinesApi::class) class PubkyAuthApprovalViewModelTest : BaseUnitTest() { private val context: Context = mock() - private val pubkyRepo: PubkyRepo = mock() + private val profileFlow = MutableStateFlow(null) + private val publicKeyFlow = MutableStateFlow(null) + private val displayNameFlow = MutableStateFlow(null) + private val displayImageUriFlow = MutableStateFlow(null) + private val pubkyRepo: PubkyRepo = mock { + on { profile } doReturn profileFlow + on { publicKey } doReturn publicKeyFlow + on { displayName } doReturn displayNameFlow + on { displayImageUri } doReturn displayImageUriFlow + } + private val watchOnlyAccountRepo: WatchOnlyAccountRepo = mock() + + @Before + fun setUp() { + whenever(context.getString(R.string.profile__auth_approval_service_unknown)).thenReturn("Unknown service") + whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization failed") + whenever( + context.getString(R.string.profile__auth_approval_watch_only_account_default_name, "paykit") + ).thenReturn("paykit server") + } @Test fun `initial state is loading`() { @@ -26,32 +64,495 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { } @Test - fun `confirmAuthorize reparses capabilities when load has not completed`() = test { + fun `auth display public key omits pubky prefix`() { + assertEquals("3rsd...w5xg", pubkyAuthDisplayPublicKey("pubky3rsd123456789w5xg")) + assertEquals("3rsd...w5xg", pubkyAuthDisplayPublicKey("3rsd123456789w5xg")) + } + + @Test + fun `confirmAuthorize is ignored when load has not completed`() = test { val authUrl = "pubkyauth://signin?caps=/pub/bitkit.to/:rw" val capabilities = "/pub/bitkit.to/:rw" + val sut = createSut() + + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Loading, sut.uiState.value.state) + verifyBlocking(pubkyRepo, never()) { parseAuthUrl(authUrl) } + verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities) } + } + + @Test + fun `confirmAuthorize ignores a stale auth URL after another request loads`() = test { + val staleAuthUrl = "pubkyauth://signin?caps=/pub/stale/:rw" + val currentAuthUrl = "pubkyauth://signin?caps=/pub/current/:rw" + whenever { pubkyRepo.parseAuthUrl(currentAuthUrl) }.thenReturn( + Result.success(authRequest(currentAuthUrl, "/pub/current/:rw")), + ) + val sut = createSut() + + sut.load(currentAuthUrl) + advanceUntilIdle() + sut.confirmAuthorize(staleAuthUrl) + advanceUntilIdle() + + assertEquals(currentAuthUrl, sut.uiState.value.authUrl) + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(pubkyRepo, never()) { parseAuthUrl(staleAuthUrl) } + verifyBlocking(pubkyRepo, never()) { approveAuth(staleAuthUrl, "/pub/current/:rw") } + } + + @Test + fun `confirmAuthorize reparses the current URL and fails closed when it changes`() = test { + val authUrl = "pubkyauth://signin?caps=/pub/current/:rw" + val capabilities = "/pub/current/:rw" + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities)), + Result.failure(IllegalArgumentException("request changed")), + ) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(pubkyRepo, times(2)) { parseAuthUrl(authUrl) } + verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities) } + } + + @Test + fun `ordinary authorization uses the requested capabilities`() = test { + val authUrl = "pubkyauth://signin?caps=/pub/example/:rw" + val capabilities = "/pub/example/:rw" + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities)), + ) + whenever { pubkyRepo.approveAuth(authUrl, capabilities) }.thenReturn(Result.success(Unit)) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Success, sut.uiState.value.state) + verifyBlocking(pubkyRepo) { approveAuth(authUrl, capabilities) } + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(any(), any()) } + verifyBlocking(watchOnlyAccountRepo, never()) { prepareUnsignedClaim(any(), any()) } + } + + @Test + fun `load exposes watch-only account claim for approval`() = test { + val authUrl = "pubkyauth://signin?caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success( - PubkyAuthDetails( - kind = PubkyAuthRequestKind.SIGN_IN, - capabilities = capabilities, - relayUrl = "https://httprelay.pubky.app/inbox/", - homeserverPublicKey = null, + authRequest( + authUrl = authUrl, + capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, + bitkitClaim = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, ), ), ) - whenever { pubkyRepo.approveAuth(authUrl, capabilities) }.thenReturn(Result.success(Unit)) val sut = createSut() + sut.load(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.WatchOnlyConsent, sut.uiState.value.state) + assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, sut.uiState.value.bitkitClaim) + + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + assertEquals(ApprovalState.WatchOnlyConsent, sut.uiState.value.state) + + sut.approveWatchOnlyConsent(authUrl) + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + + sut.requestAuthorize(authUrl) + runCurrent() + assertEquals(ApprovalState.Authenticating, sut.uiState.value.state) + + sut.cancelLocalAuth(authUrl) + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + + sut.returnToWatchOnlyConsent(authUrl) + assertEquals(ApprovalState.WatchOnlyConsent, sut.uiState.value.state) + } + + @Test + fun `watch-only authorization uses combined companion approval`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount(), + payload = ByteArray(84), + ) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) }.thenReturn(Result.success(Unit)) + whenever { watchOnlyAccountRepo.markActive(prepared.account.id) }.thenReturn(Unit) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) sut.confirmAuthorize(authUrl) advanceUntilIdle() assertEquals(ApprovalState.Success, sut.uiState.value.state) - verifyBlocking(pubkyRepo) { parseAuthUrl(authUrl) } - verifyBlocking(pubkyRepo) { approveAuth(authUrl, capabilities) } + verifyBlocking(watchOnlyAccountRepo) { prepareUnsignedClaim(authUrl, "paykit server") } + verifyBlocking(pubkyRepo) { approveAuthWithCompanionClaim(authUrl, prepared.payload) } + verifyBlocking(pubkyRepo, times(2)) { parseAuthUrl(authUrl) } + verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities) } + verifyBlocking(watchOnlyAccountRepo) { beginAuthorization(prepared.account.id) } + verifyBlocking(watchOnlyAccountRepo) { markActive(prepared.account.id) } + } + + @Test + fun `duplicate confirmations start one companion authorization`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount(), + payload = ByteArray(84), + ) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) }.thenReturn(Result.success(Unit)) + whenever { watchOnlyAccountRepo.markActive(prepared.account.id) }.thenReturn(Unit) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Success, sut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo, times(1)) { prepareUnsignedClaim(authUrl, "paykit server") } + verifyBlocking(watchOnlyAccountRepo, times(1)) { beginAuthorization(prepared.account.id) } + verifyBlocking(pubkyRepo, times(1)) { approveAuthWithCompanionClaim(authUrl, prepared.payload) } + verifyBlocking(watchOnlyAccountRepo, times(1)) { markActive(prepared.account.id) } + } + + @Test + fun `switching requests and reopening during companion approval does not start another authorization`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val secondAuthUrl = "pubkyauth://signin?caps=/pub/second/:rw" + val secondCapabilities = "/pub/second/:rw" + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount(), + payload = ByteArray(84), + ) + val approvalResult = CompletableDeferred>() + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + ) + whenever { pubkyRepo.parseAuthUrl(secondAuthUrl) }.thenReturn( + Result.success(authRequest(secondAuthUrl, secondCapabilities)), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) } + .doSuspendableAnswer { approvalResult.await() } + whenever { watchOnlyAccountRepo.markActive(prepared.account.id) }.thenReturn(Unit) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + runCurrent() + assertEquals(ApprovalState.Authorizing, sut.uiState.value.state) + + sut.load(secondAuthUrl) + advanceUntilIdle() + assertEquals(secondAuthUrl, sut.uiState.value.authUrl) + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + sut.confirmAuthorize(secondAuthUrl) + runCurrent() + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + + sut.load(authUrl) + sut.confirmAuthorize(authUrl) + runCurrent() + assertEquals(authUrl, sut.uiState.value.authUrl) + assertEquals(ApprovalState.Authorizing, sut.uiState.value.state) + approvalResult.complete(Result.success(Unit)) + advanceUntilIdle() + + assertEquals(ApprovalState.Success, sut.uiState.value.state) + verifyBlocking(pubkyRepo, times(2)) { parseAuthUrl(authUrl) } + verifyBlocking(pubkyRepo, times(1)) { parseAuthUrl(secondAuthUrl) } + verifyBlocking(watchOnlyAccountRepo, times(1)) { prepareUnsignedClaim(authUrl, "paykit server") } + verifyBlocking(watchOnlyAccountRepo, times(1)) { beginAuthorization(prepared.account.id) } + verifyBlocking(pubkyRepo, times(1)) { approveAuthWithCompanionClaim(authUrl, prepared.payload) } + verifyBlocking(pubkyRepo, never()) { approveAuth(secondAuthUrl, secondCapabilities) } + verifyBlocking(watchOnlyAccountRepo, times(1)) { markActive(prepared.account.id) } + } + + @Test + fun `wrapped post-delivery authorization failure keeps account authorizing for retry`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount(), + payload = ByteArray(84), + ) + val authorizationError = PubkyAuthCompanionClaimApprovalException.AuthorizationFailure( + "AuthToken delivery failed" + ) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) } + .thenReturn(Result.failure(AppError(authorizationError))) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo) { beginAuthorization(prepared.account.id) } + verifyBlocking(watchOnlyAccountRepo, never()) { cancelAuthorization(prepared.account.id) } + verifyBlocking(watchOnlyAccountRepo, never()) { markActive(prepared.account.id) } + } + + @Test + fun `companion delivery failure does not approve normal auth or activate account`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount(), + payload = ByteArray(84), + ) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) } + .thenReturn(Result.failure(IllegalStateException("Relay delivery failed"))) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities) } + verifyBlocking(watchOnlyAccountRepo) { beginAuthorization(prepared.account.id) } + verifyBlocking(watchOnlyAccountRepo) { cancelAuthorization(prepared.account.id) } + verifyBlocking(watchOnlyAccountRepo, never()) { markActive(prepared.account.id) } + } + + @Test + fun `retry delivery failure keeps a previously delivered account authorizing`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount().copy( + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.Authorizing, + ), + payload = ByteArray(84), + ) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(true) + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) } + .thenReturn(Result.failure(IllegalStateException("Relay delivery failed"))) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo) { beginAuthorization(prepared.account.id) } + verifyBlocking(watchOnlyAccountRepo) { + cancelAuthorization(prepared.account.id, preserveAuthorizingState = true) + } + verifyBlocking(watchOnlyAccountRepo, never()) { markActive(prepared.account.id) } + } + + @Test + fun `tracking preparation failure unloads account without attempting approval`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount(), + payload = ByteArray(84), + ) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success( + authRequest( + authUrl, + PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, + PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + ), + ), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) } + .thenThrow(IllegalStateException("Wallet sync failed")) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo) { cancelAuthorization(prepared.account.id) } + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(authUrl, prepared.payload) } + verifyBlocking(watchOnlyAccountRepo, never()) { markActive(prepared.account.id) } + } + + @Test + fun `tracking failure uses the current authorizing disposition`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount(), + payload = ByteArray(84), + ) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success( + authRequest( + authUrl, + PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, + PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + ), + ), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.doSuspendableAnswer { + throw WatchOnlyAccountAuthorizationStartError( + preserveAuthorizingState = true, + cause = IllegalStateException("Wallet sync failed"), + ) + } + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo) { + cancelAuthorization(prepared.account.id, preserveAuthorizingState = true) + } + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(authUrl, prepared.payload) } + } + + @Test + fun `retry after process restart reuses account and repeats authorization`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount(), + payload = ByteArray(84), + ) + val retryPrepared = prepared.copy( + account = prepared.account.copy( + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.Authorizing, + ), + ) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success( + authRequest( + authUrl, + PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, + PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + ), + ), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") } + .thenReturn(prepared, retryPrepared) + whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false, true) + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) }.thenReturn(Result.success(Unit)) + whenever { watchOnlyAccountRepo.markActive(prepared.account.id) } + .thenThrow(IllegalStateException("Persistence failed")) + .thenReturn(Unit) + val initialSut = createSut() + + initialSut.load(authUrl) + advanceUntilIdle() + initialSut.approveWatchOnlyConsent(authUrl) + initialSut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Authorize, initialSut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo) { beginAuthorization(prepared.account.id) } + verifyBlocking(watchOnlyAccountRepo, never()) { cancelAuthorization(prepared.account.id) } + + val restartedSut = createSut() + restartedSut.load(authUrl) + advanceUntilIdle() + restartedSut.approveWatchOnlyConsent(authUrl) + restartedSut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Success, restartedSut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo, times(2)) { prepareUnsignedClaim(authUrl, "paykit server") } + verifyBlocking(watchOnlyAccountRepo, times(2)) { beginAuthorization(prepared.account.id) } + verifyBlocking(pubkyRepo, times(2)) { approveAuthWithCompanionClaim(authUrl, prepared.payload) } + verifyBlocking(watchOnlyAccountRepo, times(2)) { markActive(prepared.account.id) } } private fun createSut() = PubkyAuthApprovalViewModel( context = context, pubkyRepo = pubkyRepo, + watchOnlyAccountRepo = watchOnlyAccountRepo, + ) + + private fun authRequest( + authUrl: String, + capabilities: String, + bitkitClaim: PubkyAuthClaim? = null, + ) = PubkyAuthRequest( + rawUrl = authUrl, + relay = "https://httprelay.pubky.app/inbox/", + capabilities = capabilities, + permissions = listOf(PubkyAuthPermission(path = "/pub/paykit/v0/bitkit/server/", accessLevel = "rw")), + serviceNames = listOf("paykit"), + bitkitClaim = bitkitClaim, + ) + + private fun watchOnlyAccount() = WatchOnlyAccountRecord( + id = "account-id", + walletIndex = 0, + accountIndex = 1, + addressType = "nativeSegwit", + xpub = "xpub", + requestFingerprint = "request", + createdAt = 1, + name = "paykit server", + isTrackingEnabled = false, + setupState = WatchOnlyAccountSetupState.PendingDelivery, ) } diff --git a/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt deleted file mode 100644 index f12d7cc09f..0000000000 --- a/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt +++ /dev/null @@ -1,337 +0,0 @@ -package to.bitkit.ui.settings.paymentPreference - -import android.content.Context -import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.advanceUntilIdle -import org.junit.Before -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.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.whenever -import to.bitkit.data.SettingsData -import to.bitkit.data.SettingsStore -import to.bitkit.models.PubkyProfile -import to.bitkit.repositories.PrivatePaykitRepo -import to.bitkit.repositories.PubkyRepo -import to.bitkit.repositories.PublicPaykitError -import to.bitkit.repositories.PublicPaykitRepo -import to.bitkit.test.BaseUnitTest -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -@OptIn(ExperimentalCoroutinesApi::class) -class PaymentPreferenceViewModelTest : BaseUnitTest() { - companion object { - private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - } - - private val context: Context = mock() - 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()) - private val contactsFlow = MutableStateFlow(listOf(createPaymentPreferenceContact(CONTACT_KEY))) - private val isAuthenticatedFlow = MutableStateFlow(true) - - @Before - fun setUp() { - settingsFlow.value = SettingsData() - contactsFlow.value = listOf(createPaymentPreferenceContact(CONTACT_KEY)) - isAuthenticatedFlow.value = true - - whenever(context.getString(any())).thenReturn("") - whenever(settingsStore.data).thenReturn(settingsFlow) - whenever(pubkyRepo.contacts).thenReturn(contactsFlow) - whenever(pubkyRepo.isAuthenticated).thenReturn(isAuthenticatedFlow) - 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.syncCurrentPublishedEndpoints(any(), any()) } - .thenReturn(Result.success(Unit)) - whenever { publicPaykitRepo.syncPublishedEndpoints(any()) } - .thenReturn(Result.success(Unit)) - whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.setContactSharingCleanupPending(any()) } - .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>(), any()) } - .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } - .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } - .thenReturn(Result.success(Unit)) - } - - @Test - fun `setPrivateContactsEnabled prepares contacts with immediate publication`() = test { - val sut = createSut() - advanceUntilIdle() - - sut.setPrivateContactsEnabled(true) - advanceUntilIdle() - - assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY), true) - verify(publicPaykitRepo).syncLocalReceiverMarker(privateSharingEnabled = true) - } - - @Test - fun `setPrivateContactsEnabled does not republish marker while public sharing is enabled`() = test { - settingsFlow.value = SettingsData(sharesPublicPaykitEndpoints = true) - val sut = createSut() - advanceUntilIdle() - - sut.setPrivateContactsEnabled(true) - advanceUntilIdle() - - assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY), true) - verify(publicPaykitRepo, never()).syncLocalReceiverMarker(anyOrNull(), anyOrNull()) - } - - @Test - fun `setPublicContactsEnabled republishes previous state when disabling fails`() = test { - settingsFlow.value = SettingsData(sharesPublicPaykitEndpoints = true) - whenever { publicPaykitRepo.syncPublishedEndpoints(publish = false) } - .thenReturn(Result.failure(PublicPaykitError.PublicationFailed)) - val sut = createSut() - advanceUntilIdle() - - sut.setPublicContactsEnabled(false) - advanceUntilIdle() - - assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) - assertFalse(settingsFlow.value.publicPaykitCleanupPending) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - } - - @Test - fun `setPrivateContactsEnabled rolls back when receiver marker sync fails`() = test { - whenever { publicPaykitRepo.syncLocalReceiverMarker(privateSharingEnabled = true) } - .thenReturn(Result.failure(PublicPaykitError.WalletNotReady)) - val sut = createSut() - advanceUntilIdle() - - sut.setPrivateContactsEnabled(true) - advanceUntilIdle() - - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.privateContactsEnabled) - verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY), true) - verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - } - - @Test - fun `setPrivateContactsEnabled does not enable without profile`() = test { - isAuthenticatedFlow.value = false - val sut = createSut() - advanceUntilIdle() - - sut.setPrivateContactsEnabled(true) - advanceUntilIdle() - - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.hasPubkyProfile) - verify(settingsStore, never()).update(any()) - verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>(), any()) - } - - @Test - fun `setPrivateContactsEnabled does not enable without local secret key`() = test { - whenever { pubkyRepo.hasSecretKey() }.thenReturn(false) - val sut = createSut() - advanceUntilIdle() - - sut.setPrivateContactsEnabled(true) - advanceUntilIdle() - - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.canUsePrivateContacts) - verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>(), any()) - } - - @Test - fun `init clears hidden private sharing when local secret key is unavailable`() = test { - settingsFlow.value = SettingsData(sharesPrivatePaykitEndpoints = true) - whenever { pubkyRepo.hasSecretKey() }.thenReturn(false) - val sut = createSut() - advanceUntilIdle() - - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.privateContactsEnabled) - assertFalse(sut.uiState.value.canUsePrivateContacts) - } - - @Test - fun `setPrivateContactsEnabled rolls back when private enable fails`() = test { - whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>(), any()) } - .thenReturn(Result.failure(PublicPaykitError.WalletNotReady)) - val sut = createSut() - advanceUntilIdle() - - sut.setPrivateContactsEnabled(true) - advanceUntilIdle() - - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY), true) - verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - } - - @Test - fun `setPrivateContactsEnabled restores enabled state when private disable fails`() = test { - settingsFlow.value = SettingsData(sharesPrivatePaykitEndpoints = true) - whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } - .thenReturn(Result.failure(PublicPaykitError.WalletNotReady)) - val sut = createSut() - advanceUntilIdle() - - sut.setPrivateContactsEnabled(false) - advanceUntilIdle() - - assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertTrue(sut.uiState.value.privateContactsEnabled) - assertFalse(sut.uiState.value.isUpdatingPrivateContacts) - verify(privatePaykitRepo).setContactSharingCleanupPending(false) - verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), true) - } - - @Test - fun `setPrivateContactsEnabled keeps disabled when private disable rollback publish fails`() = test { - settingsFlow.value = SettingsData(sharesPrivatePaykitEndpoints = true) - whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } - .thenReturn(Result.failure(PublicPaykitError.WalletNotReady)) - whenever { privatePaykitRepo.prepareSavedContacts(any>(), eq(true)) } - .thenReturn(Result.failure(PublicPaykitError.WalletNotReady)) - val sut = createSut() - advanceUntilIdle() - - sut.setPrivateContactsEnabled(false) - advanceUntilIdle() - - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.privateContactsEnabled) - assertFalse(sut.uiState.value.isUpdatingPrivateContacts) - verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), true) - verify(privatePaykitRepo, never()).setContactSharingCleanupPending(false) - } - - @Test - fun `setOnchainEnabled rolls back when public sync has no supported endpoint`() = test { - settingsFlow.value = SettingsData( - sharesPublicPaykitEndpoints = true, - publicPaykitLightningEnabled = true, - publicPaykitOnchainEnabled = true, - ) - whenever { - publicPaykitRepo.syncCurrentPublishedEndpoints( - forceRefreshLightning = true, - requireEndpoint = true, - ) - }.thenReturn(Result.failure(PublicPaykitError.NoSupportedEndpoint)) - val sut = createSut() - advanceUntilIdle() - - sut.setOnchainEnabled(false) - advanceUntilIdle() - - assertTrue(settingsFlow.value.publicPaykitOnchainEnabled) - assertTrue(settingsFlow.value.publicPaykitLightningEnabled) - assertFalse(sut.uiState.value.isUpdatingPaymentOptions) - verify(publicPaykitRepo, times(2)).syncCurrentPublishedEndpoints( - forceRefreshLightning = true, - requireEndpoint = true, - ) - } - - @Test - fun `setOnchainEnabled rollback preserves concurrent sharing changes`() = test { - settingsFlow.value = SettingsData( - sharesPublicPaykitEndpoints = true, - sharesPrivatePaykitEndpoints = false, - publicPaykitLightningEnabled = true, - publicPaykitOnchainEnabled = true, - ) - var updateCount = 0 - whenever { settingsStore.update(any()) }.thenAnswer { - updateCount += 1 - val transform = it.getArgument<(SettingsData) -> SettingsData>(0) - if (updateCount == 2) { - settingsFlow.value = settingsFlow.value.copy( - sharesPublicPaykitEndpoints = false, - sharesPrivatePaykitEndpoints = true, - ) - } - settingsFlow.value = transform(settingsFlow.value) - Unit - } - whenever { - publicPaykitRepo.syncCurrentPublishedEndpoints(true, true) - }.thenReturn(Result.failure(PublicPaykitError.NoSupportedEndpoint)) - val sut = createSut() - advanceUntilIdle() - - sut.setOnchainEnabled(false) - advanceUntilIdle() - - assertTrue(settingsFlow.value.publicPaykitOnchainEnabled) - assertTrue(settingsFlow.value.publicPaykitLightningEnabled) - assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) - assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.isUpdatingPaymentOptions) - verify(publicPaykitRepo).syncCurrentPublishedEndpoints(true, true) - verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), true) - } - - @Test - fun `setOnchainEnabled rolls back when private immediate publication fails`() = test { - settingsFlow.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = true, - publicPaykitOnchainEnabled = true, - ) - whenever { privatePaykitRepo.prepareSavedContacts(any>(), eq(true)) } - .thenReturn(Result.failure(PublicPaykitError.WalletNotReady)) - val sut = createSut() - advanceUntilIdle() - - sut.setOnchainEnabled(false) - advanceUntilIdle() - - assertTrue(settingsFlow.value.publicPaykitOnchainEnabled) - assertTrue(settingsFlow.value.publicPaykitLightningEnabled) - assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.isUpdatingPaymentOptions) - verify(privatePaykitRepo, times(2)).prepareSavedContacts(listOf(CONTACT_KEY), true) - } - - private fun createSut() = PaymentPreferenceViewModel( - context = context, - settingsStore = settingsStore, - publicPaykitRepo = publicPaykitRepo, - privatePaykitRepo = privatePaykitRepo, - pubkyRepo = pubkyRepo, - ) -} - -private fun createPaymentPreferenceContact(publicKey: String) = PubkyProfile( - publicKey = publicKey, - name = "Alice", - bio = "", - imageUrl = null, - links = emptyList(), - tags = persistentListOf(), - status = null, -) diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 47adac7781..54ab73af9b 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -23,6 +23,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.test.BaseUnitTest @@ -38,6 +39,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { private val db = mock() private val settingsStore = mock() private val cacheStore = mock() + private val watchOnlyAccountRepo = mock() private val widgetsStore = mock() private val blocktankRepo = mock() private val activityRepo = mock() @@ -72,6 +74,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { db = db, settingsStore = settingsStore, cacheStore = cacheStore, + watchOnlyAccountRepo = watchOnlyAccountRepo, widgetsStore = widgetsStore, blocktankRepo = blocktankRepo, activityRepo = activityRepo, @@ -100,6 +103,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { db, settingsStore, cacheStore, + watchOnlyAccountRepo, widgetsStore, blocktankRepo, activityRepo, @@ -121,6 +125,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { inOrder.verify(db).clearAllTables() inOrder.verify(settingsStore).reset() inOrder.verify(cacheStore).reset() + inOrder.verify(watchOnlyAccountRepo).clear() inOrder.verify(widgetsStore).reset() inOrder.verify(blocktankRepo).resetState() inOrder.verify(activityRepo).resetState() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 53d2352c01..878c418137 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -40,6 +40,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import to.bitkit.App import to.bitkit.CurrentActivity +import to.bitkit.R import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData @@ -838,6 +839,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `pubky auth deeplink shows approval sheet when Paykit UI is enabled`() = test { enablePaykitUi() + pubkyPublicKey.value = testPublicKey whenever(pubkyRepo.hasSecretKey()).thenReturn(true) val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw" @@ -847,6 +849,112 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) } + @Test + fun `pubky auth deeplink shows identity required toast without a Pubky identity`() = test { + enablePaykitUi() + whenever(context.getString(R.string.pubky_auth__no_identity)).thenReturn("Pubky Identity Required") + whenever(context.getString(R.string.pubky_auth__no_identity_desc)).thenReturn("Create a Pubky identity") + advanceUntilIdle() + + val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw" + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri())) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(pubkyRepo, never()).hasSecretKey() + verify(toastManager).enqueue( + check { + assertEquals("Pubky Identity Required", it.title) + assertEquals("Create a Pubky identity", it.description) + } + ) + } + + @Test + fun `pubky auth deeplink keeps Ring-only guidance for an imported identity`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever(pubkyRepo.hasSecretKey()).thenReturn(false) + whenever(context.getString(R.string.profile__auth_approval_ring_only)).thenReturn("Use Ring") + advanceUntilIdle() + + val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw" + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri())) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(toastManager).enqueue( + check { + assertEquals("Use Ring", it.title) + assertNull(it.description) + } + ) + } + + @Test + fun `global scanner sheet accepts pubky auth when Paykit UI is enabled`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw" + + sut.showScannerSheet() + advanceUntilIdle() + assertEquals(Sheet.QrScanner, sut.currentSheet.value) + + sut.onScannerSheetResult(authUrl) + advanceUntilIdle() + + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + } + + @Test + fun `send paste rejects pubky auth`() = test { + val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw" + val clipData = mock() + val item = mock() + whenever(item.text).thenReturn(authUrl) + whenever(clipData.getItemAt(0)).thenReturn(item) + whenever(clipboardManager.primaryClip).thenReturn(clipData) + sut.showSheet(Sheet.Send()) + advanceUntilIdle() + + sut.setSendEvent(SendEvent.Paste) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(pubkyRepo, never()).hasSecretKey() + verify(coreService, never()).decode(any()) + verify(toastManager).enqueue(any()) + } + + @Test + fun `send scanner rejects pubky auth`() = test { + val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw" + sut.showSheet(Sheet.Send()) + advanceUntilIdle() + + sut.onScanResult(authUrl) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(pubkyRepo, never()).hasSecretKey() + verify(coreService, never()).decode(any()) + verify(toastManager).enqueue(any()) + } + + @Test + fun `manual address input rejects pubky auth without decoding`() = test { + val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw" + + sut.setSendEvent(SendEvent.AddressChange(authUrl)) + advanceUntilIdle() + + assertEquals(authUrl, sut.sendUiState.value.addressInput) + assertFalse(sut.sendUiState.value.isAddressInputValid) + verify(coreService, never()).decode(any()) + } + @Test fun `pubky routing dismisses send sheet before navigation`() = test { enablePaykitUi() diff --git a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt index fc6fc4d2c0..8dfd31cab0 100644 --- a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt @@ -1,5 +1,6 @@ package to.bitkit.viewmodels +import android.content.Context import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle @@ -16,6 +17,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsStore import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.ContactPaymentSettingsRepo import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitRepo @@ -30,15 +32,18 @@ import kotlin.test.assertTrue class SettingsViewModelTest : BaseUnitTest() { private lateinit var sut: SettingsViewModel + private val context = mock() private val settingsStore = mock() private val pubkyRepo = mock() private val publicPaykitRepo = mock() private val privatePaykitRepo = mock() private val widgetsStore = mock() private val widgetsRepo = mock() + private val contactPaymentSettingsRepo = mock() private val settingsData = MutableStateFlow(SettingsData()) private val isPaykitEnabled = MutableStateFlow(false) + private val contactPaymentsEnabled = MutableStateFlow(false) private val contacts = MutableStateFlow( listOf( PubkyProfile( @@ -56,6 +61,8 @@ class SettingsViewModelTest : BaseUnitTest() { fun setUp() { whenever(settingsStore.data).thenReturn(settingsData) whenever(settingsStore.isPaykitEnabled).thenReturn(isPaykitEnabled) + whenever(contactPaymentSettingsRepo.isEnabled).thenReturn(contactPaymentsEnabled) + whenever { contactPaymentSettingsRepo.setEnabled(any()) }.thenReturn(Result.success(Unit)) whenever { settingsStore.update(any()) }.thenAnswer { val transform = it.getArgument<(SettingsData) -> SettingsData>(0) settingsData.value = transform(settingsData.value) @@ -204,9 +211,20 @@ class SettingsViewModelTest : BaseUnitTest() { assertFalse(settingsData.value.keepBitkitActiveInBackground) } + @Test + fun `contact payment switch delegates to shared settings repo`() = test { + sut.setContactPaymentsEnabled(true) + advanceUntilIdle() + + verify(contactPaymentSettingsRepo).setEnabled(true) + assertFalse(sut.isUpdatingContactPayments.value) + } + private fun createViewModel() = SettingsViewModel( + context = context, settingsStore = settingsStore, pubkyRepo = pubkyRepo, + contactPaymentSettingsRepo = contactPaymentSettingsRepo, publicPaykitRepo = publicPaykitRepo, privatePaykitRepo = privatePaykitRepo, widgetsStore = widgetsStore, diff --git a/changelog.d/next/1084.added.md b/changelog.d/next/1084.added.md new file mode 100644 index 0000000000..6f68318587 --- /dev/null +++ b/changelog.d/next/1084.added.md @@ -0,0 +1 @@ +Bitkit now creates, names, backs up, and manages separate watch-only Bitcoin accounts and securely delivers signed setup claims to Paykit servers. diff --git a/changelog.d/next/1097.changed.md b/changelog.d/next/1097.changed.md new file mode 100644 index 0000000000..4b493b63a8 --- /dev/null +++ b/changelog.d/next/1097.changed.md @@ -0,0 +1 @@ +Updated Pubky profiles, contacts, and Paykit settings to match the latest designs and simplified contact payments. diff --git a/docs/watch-only-account-claim-v1.md b/docs/watch-only-account-claim-v1.md new file mode 100644 index 0000000000..bc043e9169 --- /dev/null +++ b/docs/watch-only-account-claim-v1.md @@ -0,0 +1,51 @@ +# Bitkit watch-only account claim v1 + +This document records the client contract implemented by Bitkit iOS and Android for Paykit Server setup requests. + +## Request + +- The Pubky Auth URL includes `x-bitkit-claim=watch-only-account-v1`. +- The exact capability is `/pub/paykit/v0/bitkit/server/:rw`. +- Missing, unknown, mismatched, or duplicate companion-claim parameters are rejected. +- Every distinct auth request creates a fresh native-SegWit account, beginning at BIP84 account index `1`. Account indexes increase monotonically and are never reused. Retrying the same logical auth request reuses its incomplete account even if query parameters are reordered. +- Bitkit automatically names the account from the requesting service. The user can rename it later. The local name is not disclosed in the claim. + +## Claim payload + +Bitkit serializes this exact 84-byte unsigned payload: + +| Offset | Size | Value | +| --- | ---: | --- | +| 0 | 1 | Claim version, `0x01` | +| 1 | 4 | BIP account index, unsigned big-endian | +| 5 | 1 | Address type, `0x00` for native SegWit | +| 6 | 78 | Base58Check-decoded extended public key, including its 4-byte version | + +Bitkit passes the payload to Paykit's `approveAuthWithCompanionClaim` API. Paykit appends a 64-byte Ed25519 signature, encrypts the resulting 148-byte claim, delivers it on the companion relay channel, and only then approves normal Pubky Auth. + +The signature input is the byte concatenation: + +```text +UTF8("x-bitkit-claim|watch-only-account-v1|") +|| SHA256(decoded_auth_request_secret) +|| claim_bytes[0..<84] +``` + +`decoded_auth_request_secret` is the raw 32-byte value produced by base64url-no-pad decoding the URL's `secret` parameter, not UTF-8 text. + +The server verifies the signature with the creator's Pubky Ed25519 public key from the authenticated session. Binding the signature to the request secret prevents a valid signed claim from being moved to a different request; possession of the relay secret alone is insufficient to substitute an attacker's xpub. + +## Delivery and lifecycle + +- The normal AuthToken channel is `base_relay/{base64url_no_pad(BLAKE3(secret))}`. +- The companion channel is `base_relay/{base64url_no_pad(BLAKE3(ASCII("watch-only-account-v1|") || secret))}`. +- Paykit encrypts the complete 148-byte signed claim on the companion channel with the auth request secret using XSalsa20-Poly1305. +- Paykit delivers the claim before approving the normal Pubky Auth token, avoiding a session that was authorized without its required account claim. +- Bitkit persists the account before delivery and reuses the same account index and unsigned xpub payload when retrying an incomplete setup. Each attempt may create new encrypted relay messages; delivery is not guaranteed exactly once. +- Bitkit durably marks and loads an incomplete account as authorizing before calling Paykit. Successful combined approval marks it active and leaves tracking enabled. An initial preparation or companion-delivery failure returns it to pending and unloads it again. +- If Paykit reports that companion delivery succeeded but normal AuthToken delivery failed, Bitkit leaves the account authorizing and tracked. The same conservative state is retained if local activation persistence fails after Paykit returns success. Retrying reruns the combined Paykit approval with the same account and xpub payload; retry failures keep the account tracked so Bitkit does not lose visibility into addresses the server may already have derived. +- Disabling tracking unloads the account from LDK Node at runtime. It does not delete persisted wallet state, the xpub, or the server session. +- Enabled active or authorizing accounts are configured before LDK Node starts. Electrum full scans use a batch size of `100` and stop gap of `1000`. +- Bitkit pre-reveals external receive indexes `0...999` for each tracked account. LDK then maintains a rolling stop-gap window: the first address with transaction history must be at or below index `999`, and after activity at index `n`, the next active index must be at or below `n + 1000` so there are never `1000` consecutive inactive addresses. +- On startup and before app-driven sync, Bitkit reconciles persisted account state with LDK and restores the pre-revealed range. Accounts removed by a backup remain scheduled for unload until reconciliation succeeds, allowing transient failures to retry safely. +- Account metadata and monotonic allocation state are included in the existing encrypted wallet backup and use the same JSON field names on iOS and Android. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 278d17fee6..036f25963d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,8 +21,8 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.4.1" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc33" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.4.2" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc37" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } @@ -64,7 +64,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.52" } +ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.57" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }