Skip to content
Merged
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
6 changes: 5 additions & 1 deletion android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ android {
applicationId = "org.bitcoinppl.cove"
minSdk = 33
targetSdk = 36
versionCode = 15
versionCode = 16
versionName = "1.2.1"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
Expand Down Expand Up @@ -109,7 +109,11 @@ dependencies {
// lifecycle process for app-level lifecycle observation
implementation("androidx.lifecycle:lifecycle-process:2.10.0")

implementation("androidx.credentials:credentials:1.5.0")
implementation("androidx.credentials:credentials-play-services-auth:1.5.0")

implementation("androidx.biometric:biometric-ktx:1.4.0-alpha02")
implementation("androidx.documentfile:documentfile:1.1.0")
implementation("androidx.security:security-crypto:1.1.0")

// Camera and QR scanning
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import org.bitcoinppl.cove.flows.SendFlow.SendFlowManager
import org.bitcoinppl.cove.flows.SendFlow.SendFlowPresenter
import org.bitcoinppl.cove_core.*
import org.bitcoinppl.cove_core.AppAlertState
import org.bitcoinppl.cove_core.device.KeychainException
import org.bitcoinppl.cove_core.tapcard.*
import org.bitcoinppl.cove_core.types.*
import java.util.UUID
Expand Down Expand Up @@ -180,6 +181,7 @@ class AppManager private constructor() : FfiReconcile {

fun findTapSignerWallet(ts: TapSigner): WalletMetadata? = rust.findTapSignerWallet(ts)

@Throws(KeychainException::class)
fun getTapSignerBackup(ts: TapSigner): ByteArray? = rust.getTapSignerBackup(ts)

fun saveTapSignerBackup(ts: TapSigner, backup: ByteArray): Boolean =
Expand Down
22 changes: 22 additions & 0 deletions android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,28 @@ private fun GlobalAlertDialog(
)
}

is AppAlertState.WalletDatabaseCorrupted -> {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(state.title()) },
text = { Text(state.message()) },
confirmButton = {
Column(horizontalAlignment = Alignment.End) {
TextButton(onClick = {
onDismiss()
app.rust.deleteCorruptedWallet(state.walletId)
}) {
Text("Delete Wallet", color = MaterialTheme.colorScheme.error)
}
TextButton(onClick = {
onDismiss()
app.rust.selectLatestOrNewWallet()
}) { Text("Cancel") }
}
},
)
}

else -> {
AlertDialog(
onDismissRequest = onDismiss,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import org.bitcoinppl.cove_core.Route
import org.bitcoinppl.cove_core.RouteFactory
import org.bitcoinppl.cove_core.SendRoute
import org.bitcoinppl.cove_core.WalletManagerAction
import org.bitcoinppl.cove_core.WalletManagerException
import org.bitcoinppl.cove_core.WalletType
import org.bitcoinppl.cove_core.types.WalletId

Expand Down Expand Up @@ -76,6 +77,11 @@ fun SelectedWalletContainer(
wm.close()
android.util.Log.d(tag, "discarding stale wallet load for $requestedId, now loading $id")
}
} catch (e: WalletManagerException.DatabaseCorruption) {
android.util.Log.e(tag, "wallet database corrupted for ${e.`id`}: ${e.`error`}", e)
app.alertState = TaggedItem(
AppAlertState.WalletDatabaseCorrupted(walletId = e.`id`, error = e.`error`)
)
} catch (e: Exception) {
android.util.Log.e(tag, "something went very wrong", e)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import org.bitcoinppl.cove.AppSheetState
import org.bitcoinppl.cove.TaggedItem
import org.bitcoinppl.cove.WalletManager
import org.bitcoinppl.cove.flows.TapSignerFlow.rememberBackupExportLauncher
import org.bitcoinppl.cove_core.AppAlertState
import org.bitcoinppl.cove_core.AfterPinAction
import org.bitcoinppl.cove_core.CoinControlRoute
import org.bitcoinppl.cove_core.HardwareWalletMetadata
Expand Down Expand Up @@ -168,7 +169,7 @@ fun WalletMoreOptionsSheet(
// launcher for creating backup file
val createBackupLauncher =
rememberBackupExportLauncher(app) {
app.getTapSignerBackup(tapSigner)
app.getTapSignerBackup(tapSigner) // throws KeychainException
?: throw IllegalStateException("Backup not available")
}

Expand All @@ -195,19 +196,27 @@ fun WalletMoreOptionsSheet(
label = "Download Backup",
onClick = {
onDismiss()
// check if backup already exists in cache
val backup = app.getTapSignerBackup(tapSigner)
if (backup != null) {
val fileName = "${tapSigner.identFileNamePrefix()}_backup.txt"
createBackupLauncher.launch(fileName)
} else {
// open TapSigner flow with Backup action
val route =
TapSignerRoute.EnterPin(
tapSigner = tapSigner,
action = AfterPinAction.Backup,
try {
val backup = app.getTapSignerBackup(tapSigner)
if (backup != null) {
val fileName = "${tapSigner.identFileNamePrefix()}_backup.txt"
createBackupLauncher.launch(fileName)
} else {
val route =
TapSignerRoute.EnterPin(
tapSigner = tapSigner,
action = AfterPinAction.Backup,
)
app.sheetState = TaggedItem(AppSheetState.TapSigner(route))
}
} catch (e: Exception) {
android.util.Log.e("WalletMoreOptions", "Failed to retrieve tap signer backup", e)
app.alertState = TaggedItem(
AppAlertState.General(
title = "Error",
message = "Failed to retrieve backup: ${e.message ?: "Unknown error"}",
)
app.sheetState = TaggedItem(AppSheetState.TapSigner(route))
)
}
},
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
package org.bitcoinppl.cove.flows.SettingsFlow

import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.bitcoinppl.cove.BuildConfig
import org.bitcoinppl.cove.views.MaterialDivider
import org.bitcoinppl.cove.views.MaterialSection
import org.bitcoinppl.cove.views.SectionHeader
import org.bitcoinppl.cove_core.Database
import org.bitcoinppl.cove_core.GlobalFlagKey

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutSettingsScreen(
app: org.bitcoinppl.cove.AppManager,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var buildTapCount by remember { mutableIntStateOf(0) }
var showBetaDialog by remember { mutableStateOf(false) }
var showBetaEnabledDialog by remember { mutableStateOf(false) }
var isBetaEnabled by remember {
mutableStateOf(
Database().globalFlag().getBoolConfig(GlobalFlagKey.BETA_FEATURES_ENABLED)
)
}
var betaError by remember { mutableStateOf<String?>(null) }

LaunchedEffect(buildTapCount) {
if (buildTapCount > 0) {
delay(2000)
buildTapCount = 0
}
}

Scaffold(
modifier =
modifier
.fillMaxSize()
.padding(WindowInsets.safeDrawing.asPaddingValues()),
topBar = @Composable {
TopAppBar(
title = {
Text(
style = MaterialTheme.typography.bodyLarge,
text = "About",
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
},
navigationIcon = {
IconButton(onClick = { app.popRoute() }) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
actions = { },
)
},
content = { paddingValues ->
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(paddingValues),
) {
SectionHeader("App Info", showDivider = false)
MaterialSection {
Column {
AboutRow(
label = "Version",
value = BuildConfig.VERSION_NAME,
)
MaterialDivider()
AboutRow(
label = "Build Number",
value = BuildConfig.VERSION_CODE.toString(),
onClick = {
buildTapCount++
if (buildTapCount >= 5) {
buildTapCount = 0
showBetaDialog = true
}
},
)
MaterialDivider()
AboutRow(
label = "Git Commit",
value = app.rust.gitShortHash(),
)
}
}

SectionHeader("Support")
MaterialSection {
Column {
AboutRow(
label = "Feedback",
value = "feedback@covebitcoinwallet.com",
valueStyle = MaterialTheme.typography.bodySmall,
onClick = {
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = Uri.parse("mailto:feedback@covebitcoinwallet.com")
}
context.startActivity(intent)
},
)
}
}
}
},
)

if (showBetaDialog) {
val currentlyEnabled = isBetaEnabled
AlertDialog(
onDismissRequest = { showBetaDialog = false },
title = { Text(if (currentlyEnabled) "Disable Beta Features?" else "Enable Beta Features?") },
text = { Text(if (currentlyEnabled) "This will hide experimental features" else "This will enable experimental features") },
confirmButton = {
TextButton(onClick = {
val newValue = !currentlyEnabled
try {
Database().globalFlag().set(GlobalFlagKey.BETA_FEATURES_ENABLED, newValue)
isBetaEnabled = newValue
} catch (e: Exception) {
betaError = "Failed to update beta features: ${e.message}"
}
showBetaDialog = false
if (newValue) showBetaEnabledDialog = true
}) {
Text(if (currentlyEnabled) "Disable" else "Enable")
}
},
dismissButton = {
TextButton(onClick = { showBetaDialog = false }) {
Text("Cancel")
}
},
)
}

betaError?.let { error ->
AlertDialog(
onDismissRequest = { betaError = null },
title = { Text("Something went wrong!") },
text = { Text(error) },
confirmButton = {
TextButton(onClick = { betaError = null }) {
Text("OK")
}
},
)
}

if (showBetaEnabledDialog) {
AlertDialog(
onDismissRequest = {
showBetaEnabledDialog = false
app.popRoute()
},
title = { Text("Beta Features Enabled") },
text = { Text("Beta features have been enabled") },
confirmButton = {
TextButton(onClick = {
showBetaEnabledDialog = false
app.popRoute()
}) {
Text("OK")
}
},
)
}
}

@Composable
private fun AboutRow(
label: String,
value: String,
onClick: (() -> Unit)? = null,
valueStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.bodyMedium,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.then(
if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier,
)
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = value,
style = valueStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Loading
Loading