Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions app/src/androidTest/java/to/bitkit/ui/components/TagButtonTest.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
10 changes: 9 additions & 1 deletion app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,7 +14,8 @@ object SettingsSerializer : Serializer<SettingsData> {

override suspend fun readFrom(input: InputStream): SettingsData {
return try {
json.decodeFromString(input.readBytes().decodeToString())
json.decodeFromString<SettingsData>(input.readBytes().decodeToString())
.withDefaultPaykitPaymentMethods()
} catch (e: SerializationException) {
Logger.error("Failed to deserialize: $e")
defaultValue
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/to/bitkit/models/PubkyProfile.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
170 changes: 170 additions & 0 deletions app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt
Original file line number Diff line number Diff line change
@@ -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<Boolean> = settingsStore.data.map { it.areContactPaymentsEnabled() }

suspend fun setEnabled(isEnabled: Boolean): Result<Unit> = withContext(ioDispatcher) {
val contacts = pubkyRepo.contacts.value.map { it.publicKey }
if (isEnabled) enable(contacts) else disable(contacts)
}

private suspend fun enable(contacts: List<String>): Result<Unit> {
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<String>,
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<String>): Result<Unit> {
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<String>,
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
}
Loading
Loading