diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/AboutSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/AboutSettingsScreen.kt index 7d6d33a7f..ecdfbbbaa 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/AboutSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/AboutSettingsScreen.kt @@ -38,8 +38,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.bitcoinppl.cove.Auth import org.bitcoinppl.cove.BuildConfig import org.bitcoinppl.cove.cloudbackup.AndroidCloudStorageAccess import org.bitcoinppl.cove.cloudbackup.clearCloudBackupDriveAccountBinding @@ -72,7 +74,18 @@ fun AboutSettingsScreen( var showWipeCloudDialog by remember { mutableStateOf(false) } var wipeCloudResult by remember { mutableStateOf(null) } var showResetLocalStateDialog by remember { mutableStateOf(false) } + var showSendDiagnostics by remember { mutableStateOf(false) } + var showSubmittedDiagnostics by remember { mutableStateOf(false) } + var sendDiagnosticsSubmitting by remember { mutableStateOf(false) } var resetLocalStateMessage by remember { mutableStateOf(null) } + val isInDecoyMode = Auth.isInDecoyMode() + var submittedDiagnosticsLoadState by remember { + mutableStateOf( + SubmittedDiagnosticsLoadState.Loaded(emptyList()), + ) + } + var submittedDiagnosticsRefreshId by remember { mutableIntStateOf(0) } + var submittedDiagnosticsRefreshJob by remember { mutableStateOf(null) } var isBetaEnabled by remember { mutableStateOf( Database().globalFlag().getBoolConfig(GlobalFlagKey.BETA_FEATURES_ENABLED) @@ -87,6 +100,40 @@ fun AboutSettingsScreen( } } + fun refreshSubmittedDiagnostics() { + submittedDiagnosticsRefreshId++ + val refreshId = submittedDiagnosticsRefreshId + submittedDiagnosticsRefreshJob?.cancel() + + if (Auth.isInDecoyMode()) { + submittedDiagnosticsLoadState = SubmittedDiagnosticsLoadState.Loaded(emptyList()) + return + } + + submittedDiagnosticsRefreshJob = coroutineScope.launch { + val loadState = loadSubmittedDiagnosticsRecords() + + if (refreshId != submittedDiagnosticsRefreshId) { + return@launch + } + + submittedDiagnosticsLoadState = + if (Auth.isInDecoyMode()) { + SubmittedDiagnosticsLoadState.Loaded(emptyList()) + } else { + loadState + } + } + } + + LaunchedEffect(isInDecoyMode) { + if (isInDecoyMode) { + showSubmittedDiagnostics = false + } + + refreshSubmittedDiagnostics() + } + AboutSettingsContent( version = BuildConfig.VERSION_NAME, buildNumber = BuildConfig.VERSION_CODE.toString(), @@ -107,6 +154,18 @@ fun AboutSettingsScreen( } context.startActivity(intent) }, + showSendDiagnostics = !isInDecoyMode, + onSendDiagnosticsClick = { + if (!isInDecoyMode) showSendDiagnostics = true + }, + showSubmittedDiagnostics = + !isInDecoyMode && submittedDiagnosticsLoadState.shouldShowAboutEntry, + submittedDiagnosticsSummary = submittedDiagnosticsSummary(submittedDiagnosticsLoadState), + onSubmittedDiagnosticsClick = { + if (!isInDecoyMode && submittedDiagnosticsLoadState.shouldShowAboutEntry) { + showSubmittedDiagnostics = true + } + }, onWipeCloudBackupClick = { showWipeCloudDialog = true }, onResetLocalBackupStateClick = { showResetLocalStateDialog = true }, modifier = modifier, @@ -253,6 +312,41 @@ fun AboutSettingsScreen( }, ) } + + val dismissSendDiagnostics = { + if (!sendDiagnosticsSubmitting) { + showSendDiagnostics = false + refreshSubmittedDiagnostics() + } + } + + if (showSendDiagnostics && !isInDecoyMode) { + FullScreenSettingsModal( + onDismiss = dismissSendDiagnostics, + dismissEnabled = !sendDiagnosticsSubmitting, + ) { + SendDiagnosticsSheet( + onDismiss = dismissSendDiagnostics, + onSubmittingChange = { sendDiagnosticsSubmitting = it }, + ) + } + } + + val dismissSubmittedDiagnostics = { + showSubmittedDiagnostics = false + refreshSubmittedDiagnostics() + } + + if (showSubmittedDiagnostics && !isInDecoyMode) { + FullScreenSettingsModal( + onDismiss = dismissSubmittedDiagnostics, + ) { + SubmittedDiagnosticsScreen( + onDismiss = dismissSubmittedDiagnostics, + onRecordsChanged = { refreshSubmittedDiagnostics() }, + ) + } + } } @OptIn(ExperimentalMaterial3Api::class) @@ -266,6 +360,11 @@ internal fun AboutSettingsContent( onBack: () -> Unit, onBuildNumberClick: () -> Unit, onFeedbackClick: () -> Unit, + showSendDiagnostics: Boolean, + onSendDiagnosticsClick: () -> Unit, + showSubmittedDiagnostics: Boolean, + submittedDiagnosticsSummary: String, + onSubmittedDiagnosticsClick: () -> Unit, onWipeCloudBackupClick: () -> Unit, onResetLocalBackupStateClick: () -> Unit, modifier: Modifier = Modifier, @@ -326,6 +425,22 @@ internal fun AboutSettingsContent( valueStyle = MaterialTheme.typography.bodySmall, onClick = onFeedbackClick, ) + if (showSendDiagnostics) { + MaterialDivider() + AboutRow( + label = "Send Diagnostics", + onClick = onSendDiagnosticsClick, + ) + } + if (showSubmittedDiagnostics) { + MaterialDivider() + AboutRow( + label = "Submitted Diagnostics", + value = submittedDiagnosticsSummary, + valueStyle = MaterialTheme.typography.bodySmall, + onClick = onSubmittedDiagnosticsClick, + ) + } } } @@ -388,12 +503,39 @@ internal fun AboutSettingsPreviewContent() { onBack = { }, onBuildNumberClick = { }, onFeedbackClick = { }, + showSendDiagnostics = true, + onSendDiagnosticsClick = { }, + showSubmittedDiagnostics = true, + submittedDiagnosticsSummary = "3 reports", + onSubmittedDiagnosticsClick = { }, onWipeCloudBackupClick = { }, onResetLocalBackupStateClick = { }, ) } } +private fun diagnosticsReportCountText(count: Int): String = + if (count == 1) { + "1 report" + } else { + "$count reports" + } + +private val SubmittedDiagnosticsLoadState.shouldShowAboutEntry: Boolean + get() = + when (this) { + SubmittedDiagnosticsLoadState.Loading -> false + is SubmittedDiagnosticsLoadState.Loaded -> records.isNotEmpty() + is SubmittedDiagnosticsLoadState.Failed -> true + } + +private fun submittedDiagnosticsSummary(state: SubmittedDiagnosticsLoadState): String = + when (state) { + SubmittedDiagnosticsLoadState.Loading -> "Loading" + is SubmittedDiagnosticsLoadState.Loaded -> diagnosticsReportCountText(state.records.size) + is SubmittedDiagnosticsLoadState.Failed -> "Unavailable" + } + @Composable private fun DebugRow( title: String, @@ -434,7 +576,7 @@ private suspend fun debugWipeCloudBackup(context: Context): WipeCloudResult { @Composable private fun AboutRow( label: String, - value: String, + value: String? = null, onClick: (() -> Unit)? = null, valueStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.bodyLarge, ) { @@ -452,10 +594,12 @@ private fun AboutRow( text = label, style = MaterialTheme.typography.bodyLarge, ) - Text( - text = value, - style = valueStyle, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + value?.let { + Text( + text = it, + style = valueStyle, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/LogcatProcessCollector.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/LogcatProcessCollector.kt new file mode 100644 index 000000000..a8eb12d44 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/LogcatProcessCollector.kt @@ -0,0 +1,200 @@ +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SettingsFlow + +import java.io.InputStream +import java.time.Duration +import java.util.concurrent.ExecutionException +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +private const val PROCESS_STREAM_COUNT = 2 +private const val PROCESS_STREAM_BUFFER_SIZE = 8 * 1024 + +internal class LogcatProcessCollector( + private val timeout: Duration, + private val terminationGracePeriod: Duration, +) { + init { + require(!timeout.isZero && !timeout.isNegative) { "timeout must be positive" } + require(!terminationGracePeriod.isNegative) { "termination grace period cannot be negative" } + } + + fun collect(process: Process): String { + var executor: ExecutorService? = null + val drainTasks = mutableListOf>() + + return try { + val stdout = ProcessStreamDrain(process.inputStream) + val stderr = ProcessStreamDrain(process.errorStream) + val processExecutor = + Executors.newFixedThreadPool(PROCESS_STREAM_COUNT) { runnable -> + Thread(runnable, "diagnostics-process-stream").apply { isDaemon = true } + } + executor = processExecutor + drainTasks.add(processExecutor.submit(stdout::drain)) + drainTasks.add(processExecutor.submit(stderr::drain)) + + closeQuietly { process.outputStream } + + if (process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS)) { + awaitDrainTasks(drainTasks) + formatCompletedProcess(process.exitValue(), stdout.snapshot(), stderr.snapshot()) + } else { + terminate(process) + formatTimedOutProcess(process.isAlive, stdout.snapshot(), stderr.snapshot()) + } + } finally { + terminateIfAlive(process) + closeQuietly { process.inputStream } + closeQuietly { process.errorStream } + drainTasks.forEach { task -> runCatching { task.cancel(true) } } + executor?.let { processExecutor -> runCatching { processExecutor.shutdownNow() } } + } + } + + private fun terminateIfAlive(process: Process) { + val wasInterrupted = Thread.interrupted() + var cleanupWasInterrupted = false + + try { + if (!isAlive(process)) return + + runCatching { process.destroy() } + + val stoppedGracefully = + try { + process.waitFor(terminationGracePeriod.toMillis(), TimeUnit.MILLISECONDS) + } catch (_: InterruptedException) { + cleanupWasInterrupted = true + false + } catch (_: Throwable) { + false + } + if (stoppedGracefully || !isAlive(process)) return + + runCatching { process.destroyForcibly() } + + try { + process.waitFor(terminationGracePeriod.toMillis(), TimeUnit.MILLISECONDS) + } catch (_: InterruptedException) { + cleanupWasInterrupted = true + } catch (_: Throwable) { + // cleanup must not mask the collection failure + } + } finally { + if (wasInterrupted || cleanupWasInterrupted) { + Thread.currentThread().interrupt() + } + } + } + + private fun isAlive(process: Process): Boolean = runCatching { process.isAlive }.getOrDefault(true) + + private fun terminate(process: Process) { + process.destroy() + + if (process.waitFor(terminationGracePeriod.toMillis(), TimeUnit.MILLISECONDS)) return + + process.destroyForcibly() + process.waitFor(terminationGracePeriod.toMillis(), TimeUnit.MILLISECONDS) + } + + private fun awaitDrainTasks(tasks: List>) { + val deadline = System.nanoTime() + terminationGracePeriod.toNanos() + + for (task in tasks) { + val remaining = deadline - System.nanoTime() + if (remaining <= 0) return + + try { + task.get(remaining, TimeUnit.NANOSECONDS) + } catch (_: ExecutionException) { + // the stream snapshot still contains everything captured before the drain failed + } catch (_: TimeoutException) { + return + } + } + } + + private fun formatTimedOutProcess( + stillRunning: Boolean, + stdout: String, + stderr: String, + ): String { + val terminationMessage = if (stillRunning) "; process could not be terminated" else "" + val message = "logcat timed out after ${timeoutDisplayText()}$terminationMessage" + + return appendProcessOutput(message, stdout, stderr) + } + + private fun formatCompletedProcess( + exitCode: Int, + stdout: String, + stderr: String, + ): String { + val output = combinedProcessOutput(stdout, stderr) + + return if (exitCode == 0) { + output.ifBlank { "logcat returned no visible app logs" } + } else { + appendProcessOutput("logcat exited with code $exitCode", stdout, stderr) + } + } + + private fun appendProcessOutput( + message: String, + stdout: String, + stderr: String, + ): String { + val output = combinedProcessOutput(stdout, stderr) + + return if (output.isBlank()) message else "$message\n$output" + } + + private fun combinedProcessOutput( + stdout: String, + stderr: String, + ): String = + buildList { + if (stdout.isNotBlank()) add(stdout.trimEnd()) + if (stderr.isNotBlank()) add("logcat stderr:\n${stderr.trimEnd()}") + }.joinToString("\n") + + private fun timeoutDisplayText(): String = + if (timeout.toMillis() % TimeUnit.SECONDS.toMillis(1) == 0L) { + "${timeout.seconds} seconds" + } else { + "${timeout.toMillis()} ms" + } + + private fun closeQuietly(closeable: () -> AutoCloseable) { + runCatching { closeable().close() } + } +} + +private class ProcessStreamDrain( + private val stream: InputStream, +) { + private val content = StringBuilder() + + fun drain() { + stream.bufferedReader().use { reader -> + val buffer = CharArray(PROCESS_STREAM_BUFFER_SIZE) + + while (true) { + val charsRead = reader.read(buffer) + if (charsRead < 0) return + + synchronized(content) { + content.append(buffer, 0, charsRead) + } + } + } + } + + fun snapshot(): String = synchronized(content) { content.toString() } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/MainSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/MainSettingsScreen.kt index d75d2c070..02e552ac4 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/MainSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/MainSettingsScreen.kt @@ -368,13 +368,24 @@ fun MainSettingsScreen( } @Composable -private fun FullScreenSettingsModal( +@Suppress("FunctionNaming") +internal fun FullScreenSettingsModal( onDismiss: () -> Unit, + dismissEnabled: Boolean = true, content: @Composable () -> Unit, ) { Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = { + if (dismissEnabled) { + onDismiss() + } + }, + properties = + DialogProperties( + dismissOnBackPress = dismissEnabled, + dismissOnClickOutside = dismissEnabled, + usePlatformDefaultWidth = false, + ), ) { Surface( modifier = Modifier.fillMaxSize(), diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SendDiagnosticsContent.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SendDiagnosticsContent.kt new file mode 100644 index 000000000..a2791dbee --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SendDiagnosticsContent.kt @@ -0,0 +1,378 @@ +@file:Suppress("FunctionNaming", "PackageNaming") + +package org.bitcoinppl.cove.flows.SettingsFlow + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.bitcoinppl.cove.ui.theme.CoveTheme + +@Composable +internal fun DiagnosticsLoading(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.padding(horizontal = 16.dp), + ) + Text( + text = "Building diagnostics...", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) + } +} + +@Composable +internal fun DiagnosticsLoadError( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(24.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "Diagnostics Unavailable", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) + Button( + onClick = onRetry, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 16.dp), + ) { + Text("Retry") + } + } +} + +@Composable +internal fun SendDiagnosticsContent( + state: SendDiagnosticsContentState, + actions: SendDiagnosticsContentActions, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + DiagnosticsDescriptionField(state, actions) + DiagnosticsPreview(state) + DiagnosticsFeedback(state.feedback, actions) + DiagnosticsActionButtons(state, actions) + } +} + +@Composable +private fun DiagnosticsDescriptionField( + state: SendDiagnosticsContentState, + actions: SendDiagnosticsContentActions, +) { + Text( + text = "Description", + style = MaterialTheme.typography.titleMedium, + ) + + OutlinedTextField( + value = state.description, + onValueChange = actions.onDescriptionChange, + placeholder = { Text("Optional") }, + minLines = 3, + maxLines = 5, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Composable +private fun ColumnScope.DiagnosticsPreview(state: SendDiagnosticsContentState) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Preview", + style = MaterialTheme.typography.titleMedium, + ) + + Text( + text = state.reportSize, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(12.dp), + ) { + items(state.previewChunks) { chunk -> + Text( + text = chunk, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun DiagnosticsFeedback( + feedback: DiagnosticsContentFeedback, + actions: SendDiagnosticsContentActions, +) { + when (feedback) { + DiagnosticsContentFeedback.None -> Unit + is DiagnosticsContentFeedback.Error -> DiagnosticsActionError(feedback.message) + is DiagnosticsContentFeedback.Sent -> { + DiagnosticsSentReport(feedback.reportId, feedback.warning, actions) + } + } +} + +@Composable +private fun DiagnosticsActionError(message: String) { + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) +} + +@Composable +private fun DiagnosticsSentReport( + reportId: String, + warning: String?, + actions: SendDiagnosticsContentActions, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Diagnostics sent", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = reportId, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + ) + warning?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton( + onClick = { + actions.onSentReportAction(reportId, SentReportAction.CopyReportId) + }, + ) { + Text("Copy ID") + } + FilledTonalButton( + onClick = { + actions.onSentReportAction(reportId, SentReportAction.Done) + }, + ) { + Text("Done") + } + } + } +} + +@Composable +private fun DiagnosticsActionButtons( + state: SendDiagnosticsContentState, + actions: SendDiagnosticsContentActions, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedButton( + onClick = actions.onShare, + enabled = !state.submitting, + modifier = Modifier.weight(1f), + ) { + Text("Share") + } + + OutlinedButton( + onClick = actions.onClear, + enabled = !state.submitting, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + modifier = Modifier.weight(1f), + ) { + Text( + text = "Clear Stored Logs", + textAlign = TextAlign.Center, + ) + } + } + + Button( + onClick = actions.onSubmit, + enabled = !state.submitting && state.feedback !is DiagnosticsContentFeedback.Sent, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 48.dp), + ) { + if (state.submitting) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + Text( + text = "Submit", + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Preview( + name = "Send Diagnostics", + showSystemUi = true, + widthDp = 393, + heightDp = 852, +) +@Composable +internal fun SendDiagnosticsContentPreview() { + SendDiagnosticsContentPreviewContent() +} + +@Composable +internal fun SendDiagnosticsContentPreviewContent() { + CoveTheme(darkTheme = false, dynamicColor = false) { + SendDiagnosticsContent( + state = + SendDiagnosticsContentState( + description = "Wallet could not sync.", + previewChunks = + listOf( + """ + App + Version: 1.3.0 + + Startup diagnostics + + + Platform logs + logcat returned no visible app logs + + Rust logs + [txid] + """.trimIndent(), + ), + reportSize = "12 KB", + feedback = DiagnosticsContentFeedback.Sent("diag_123456"), + submitting = false, + ), + actions = + SendDiagnosticsContentActions( + onDescriptionChange = { }, + onShare = { }, + onClear = { }, + onSubmit = { }, + onSentReportAction = { _, _ -> }, + ), + ) + } +} + +internal data class SendDiagnosticsContentState( + val description: String, + val previewChunks: List, + val reportSize: String, + val feedback: DiagnosticsContentFeedback, + val submitting: Boolean, +) + +internal sealed interface DiagnosticsContentFeedback { + data object None : DiagnosticsContentFeedback + + data class Error(val message: String) : DiagnosticsContentFeedback + + data class Sent( + val reportId: String, + val warning: String? = null, + ) : DiagnosticsContentFeedback +} + +internal data class SendDiagnosticsContentActions( + val onDescriptionChange: (String) -> Unit, + val onShare: () -> Unit, + val onClear: () -> Unit, + val onSubmit: () -> Unit, + val onSentReportAction: (String, SentReportAction) -> Unit, +) + +internal enum class SentReportAction { + CopyReportId, + Done, +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SendDiagnosticsPlatform.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SendDiagnosticsPlatform.kt new file mode 100644 index 000000000..54e26616b --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SendDiagnosticsPlatform.kt @@ -0,0 +1,163 @@ +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SettingsFlow + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.widget.Toast +import androidx.core.content.FileProvider +import java.io.File +import java.time.Duration +import java.time.Instant +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.bitcoinppl.cove.BuildConfig +import org.bitcoinppl.cove_core.DiagnosticsPlatformInfo + +private const val DIAGNOSTICS_FILENAME = "cove-diagnostics.txt" +private const val MAX_PLATFORM_LOG_CHARS = 256 * 1024 +private const val LOGCAT_LINE_COUNT = "1000" +private val LOGCAT_TIMEOUT: Duration = Duration.ofSeconds(5) +private val LOGCAT_TERMINATION_GRACE_PERIOD: Duration = Duration.ofMillis(250) + +internal fun androidDiagnosticsPlatformInfo(): DiagnosticsPlatformInfo = + DiagnosticsPlatformInfo( + platform = "Android", + buildNumber = BuildConfig.VERSION_CODE.toString(), + osVersion = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})", + deviceModel = + listOf(Build.MANUFACTURER, Build.MODEL) + .filter { it.isNotBlank() } + .joinToString(" "), + ) + +internal suspend fun collectAndroidPlatformLogs( + context: Context, + ioDispatcher: CoroutineDispatcher, +): String = + withContext(ioDispatcher) { + val header = + listOf( + "Generated: ${Instant.now()}", + "App version: ${BuildConfig.VERSION_NAME}", + "Build: ${BuildConfig.VERSION_CODE}", + "Package: ${context.packageName}", + "Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})", + "Device: ${Build.MANUFACTURER} ${Build.MODEL}", + "Process ID: ${android.os.Process.myPid()}", + "", + "logcat", + ).joinToString("\n") + val logcat = collectLogcat() + + "$header\n${logcat.takeLastAtRedactionBoundary(MAX_PLATFORM_LOG_CHARS)}" + } + +private fun collectLogcat(): String = + runCatching { + val process = + ProcessBuilder("logcat", "-d", "-t", LOGCAT_LINE_COUNT) + .start() + + LogcatProcessCollector( + timeout = LOGCAT_TIMEOUT, + terminationGracePeriod = LOGCAT_TERMINATION_GRACE_PERIOD, + ).collect(process) + }.getOrElse { error -> + if (error is InterruptedException) throw error + + "logcat unavailable: ${error.displayMessage()}" + } + +/** Attempts to clear logcat without failing diagnostics when Android rejects the command */ +internal suspend fun attemptToClearAndroidPlatformLogs(ioDispatcher: CoroutineDispatcher) { + withContext(ioDispatcher) { + val process = + runCatching { ProcessBuilder("logcat", "-c").start() } + .getOrNull() ?: return@withContext + + try { + if (!process.waitFor(LOGCAT_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)) { + process.destroyForcibly() + } + } catch (error: InterruptedException) { + process.destroyForcibly() + + throw error + } + } +} + +internal suspend fun shareDiagnosticsFile( + context: Context, + content: String, + ioDispatcher: CoroutineDispatcher, +) { + val uri: Uri = + withContext(ioDispatcher) { + val file = File(context.cacheDir, DIAGNOSTICS_FILENAME) + file.writeText(content) + + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file, + ) + } + + val intent = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + context.startActivity(Intent.createChooser(intent, "Share Diagnostics")) +} + +internal fun copyReportId( + context: Context, + reportId: String, +) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Cove diagnostics report ID", reportId)) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText(context, "Report ID copied", Toast.LENGTH_SHORT).show() + } +} + +internal fun Throwable.displayMessage(): String = message ?: javaClass.simpleName + +internal fun String.takeLastAtRedactionBoundary(maxChars: Int): String { + if (length <= maxChars) return this + + var start = length - maxChars + if (start > 0 && this[start].isLowSurrogate() && this[start - 1].isHighSurrogate()) { + start++ + } + + while (start < length) { + val codePoint = codePointAt(start) + if (!codePoint.isRedactionTokenCharacter()) break + + start += Character.charCount(codePoint) + } + + return substring(start) +} + +private fun Int.isRedactionTokenCharacter(): Boolean = + Character.isLetterOrDigit(this) || + when (Character.getType(this)) { + Character.NON_SPACING_MARK.toInt(), + Character.COMBINING_SPACING_MARK.toInt(), + Character.ENCLOSING_MARK.toInt(), + -> true + else -> false + } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SendDiagnosticsSheet.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SendDiagnosticsSheet.kt new file mode 100644 index 000000000..fdbba11dc --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SendDiagnosticsSheet.kt @@ -0,0 +1,486 @@ +@file:Suppress("FunctionNaming", "PackageNaming") + +package org.bitcoinppl.cove.flows.SettingsFlow + +import android.content.Context +import android.content.ActivityNotFoundException +import androidx.compose.foundation.layout.PaddingValues +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.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.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import java.io.IOException +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.bitcoinppl.cove_core.DiagnosticsReport +import org.bitcoinppl.cove_core.DiagnosticsException +import org.bitcoinppl.cove_core.buildDiagnosticsReport +import org.bitcoinppl.cove_core.clearDiagnosticsLogs + +private const val PREVIEW_CHUNK_SIZE = 4096 +private const val PREVIEW_REFRESH_DEBOUNCE_MS = 250L + +internal class DiagnosticsGenerationTracker { + private var generation = 0 + + fun advance(): Int { + generation += 1 + + return generation + } + + fun invalidate() { + generation += 1 + } + + fun isCurrent(token: Int): Boolean = token == generation +} + +@Suppress("InjectDispatcher") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SendDiagnosticsSheet( + onDismiss: () -> Unit, + onSubmittingChange: (Boolean) -> Unit = { }, + modifier: Modifier = Modifier, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val state = remember(ioDispatcher, coroutineScope) { + SendDiagnosticsSheetState(ioDispatcher, coroutineScope) + } + val currentOnSubmittingChange by rememberUpdatedState(onSubmittingChange) + + LaunchedEffect(context) { + state.rebuildReport(context, clearStoredLogs = false) + } + + LaunchedEffect(state.submitting) { + onSubmittingChange(state.submitting) + } + + DisposableEffect(Unit) { + onDispose { + currentOnSubmittingChange(false) + state.close() + } + } + + SendDiagnosticsScaffold( + state = state, + actions = + SendDiagnosticsSheetActions( + onDismiss = onDismiss, + onRetry = { + coroutineScope.launch { + state.rebuildReport(context, clearStoredLogs = false) + } + }, + onShare = { + coroutineScope.launch { + state.share(context) + } + }, + onClear = { + coroutineScope.launch { + state.rebuildReport(context, clearStoredLogs = true) + } + }, + onSubmit = { + coroutineScope.launch { + state.submitCurrent() + } + }, + ), + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SendDiagnosticsScaffold( + state: SendDiagnosticsSheetState, + actions: SendDiagnosticsSheetActions, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = + modifier + .fillMaxSize() + .padding(WindowInsets.safeDrawing.asPaddingValues()), + topBar = { + SendDiagnosticsTopBar( + submitting = state.submitting, + onDismiss = actions.onDismiss, + ) + }, + ) { paddingValues -> + SendDiagnosticsBody( + state = state, + actions = actions, + paddingValues = paddingValues, + ) + } + + ClearStoredLogsDialog( + visible = state.showClearConfirmation, + onDismiss = { state.showClearConfirmation = false }, + onConfirm = { + state.showClearConfirmation = false + actions.onClear() + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SendDiagnosticsTopBar( + submitting: Boolean, + onDismiss: () -> Unit, +) { + TopAppBar( + title = { + Text( + text = "Send Diagnostics", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + }, + navigationIcon = { + IconButton( + onClick = { + if (!submitting) { + onDismiss() + } + }, + enabled = !submitting, + ) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + contentDescription = "Back", + ) + } + }, + ) +} + +@Composable +private fun SendDiagnosticsBody( + state: SendDiagnosticsSheetState, + actions: SendDiagnosticsSheetActions, + paddingValues: PaddingValues, +) { + val context = LocalContext.current + val modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + + when { + state.loading -> DiagnosticsLoading(modifier) + state.loadError != null -> DiagnosticsLoadError( + message = state.loadError.orEmpty(), + onRetry = actions.onRetry, + modifier = modifier, + ) + else -> SendDiagnosticsContent( + state = state.contentState(), + actions = + SendDiagnosticsContentActions( + onDescriptionChange = state::updateDescription, + onShare = actions.onShare, + onClear = { state.showClearConfirmation = true }, + onSubmit = actions.onSubmit, + onSentReportAction = { reportId, action -> + when (action) { + SentReportAction.CopyReportId -> copyReportId(context, reportId) + SentReportAction.Done -> actions.onDismiss() + } + }, + ), + modifier = modifier, + ) + } +} + +@Composable +private fun ClearStoredLogsDialog( + visible: Boolean, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + if (!visible) return + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Clear Stored Logs?") }, + text = { Text("This deletes stored diagnostics logs on this device and rebuilds the preview.") }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text("Clear") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} + +private class SendDiagnosticsSheetState( + private val ioDispatcher: CoroutineDispatcher, + private val coroutineScope: CoroutineScope, +) { + private var report by mutableStateOf(null) + private val rebuildGeneration = DiagnosticsGenerationTracker() + private var previewRefreshJob: Job? = null + private var previewText by mutableStateOf("") + var previewChunks by mutableStateOf>(emptyList()) + private set + var description by mutableStateOf("") + private set + var reportSize by mutableStateOf("") + private set + var loadError by mutableStateOf(null) + private set + var actionError by mutableStateOf(null) + private set + var reportId by mutableStateOf(null) + private set + private var submissionWarning by mutableStateOf(null) + var showClearConfirmation by mutableStateOf(false) + var loading by mutableStateOf(true) + private set + var submitting by mutableStateOf(false) + private set + + suspend fun rebuildReport( + context: Context, + clearStoredLogs: Boolean, + ) { + val generation = rebuildGeneration.advance() + var builtReport: DiagnosticsReport? = null + startLoadingReport() + + try { + if (clearStoredLogs) { + withContext(ioDispatcher) { clearDiagnosticsLogs() } + attemptToClearAndroidPlatformLogs(ioDispatcher) + } + + val platformLogs = collectAndroidPlatformLogs(context, ioDispatcher) + val nextReport = + withContext(ioDispatcher) { + buildDiagnosticsReport( + platform = androidDiagnosticsPlatformInfo(), + platformLogs = platformLogs, + ) + } + builtReport = nextReport + val nextPreview = buildPreview(nextReport, description) + + if (!rebuildGeneration.isCurrent(generation)) { + return + } + + builtReport = null + replaceReport(nextReport) + applyPreview(nextPreview) + } catch (error: CancellationException) { + throw error + } catch (error: DiagnosticsException) { + if (rebuildGeneration.isCurrent(generation)) { + loadError = error.displayMessage() + } + } catch (error: InterruptedException) { + if (rebuildGeneration.isCurrent(generation)) { + loadError = error.displayMessage() + } + } finally { + builtReport?.close() + if (rebuildGeneration.isCurrent(generation)) { + loading = false + } + } + } + + fun updateDescription(nextDescription: String) { + description = nextDescription + schedulePreviewRefresh() + } + + suspend fun share(context: Context) { + actionError = null + val content = report?.previewTextForDescription(description) ?: previewText + + try { + shareDiagnosticsFile(context, content, ioDispatcher) + } catch (error: CancellationException) { + throw error + } catch (error: IOException) { + actionError = error.displayMessage() + } catch (error: IllegalArgumentException) { + actionError = error.displayMessage() + } catch (error: ActivityNotFoundException) { + actionError = error.displayMessage() + } catch (error: SecurityException) { + actionError = error.displayMessage() + } + } + + @Suppress("RedundantSuspendModifier") + suspend fun submitCurrent() { + val current = report ?: return + submitting = true + actionError = null + + try { + val submission = withContext(ioDispatcher) { current.submit(description) } + reportId = submission.reportId + submissionWarning = submission.warning + } catch (error: CancellationException) { + throw error + } catch (error: DiagnosticsException) { + actionError = error.displayMessage() + } finally { + submitting = false + } + } + + fun close() { + rebuildGeneration.invalidate() + replaceReport(null) + } + + fun contentState(): SendDiagnosticsContentState = + SendDiagnosticsContentState( + description = description, + previewChunks = previewChunks, + reportSize = reportSize, + feedback = + actionError?.let { DiagnosticsContentFeedback.Error(it) } + ?: reportId?.let { DiagnosticsContentFeedback.Sent(it, submissionWarning) } + ?: DiagnosticsContentFeedback.None, + submitting = submitting, + ) + + private fun startLoadingReport() { + loading = true + loadError = null + actionError = null + reportId = null + submissionWarning = null + replaceReport(null) + previewText = "" + previewChunks = emptyList() + reportSize = "" + } + + private fun replaceReport(nextReport: DiagnosticsReport?) { + val oldReport = report + val oldPreviewRefreshJob = previewRefreshJob + oldPreviewRefreshJob?.cancel() + previewRefreshJob = null + report = nextReport + if (oldReport == null) return + + if (oldPreviewRefreshJob == null || oldPreviewRefreshJob.isCompleted) { + oldReport.close() + return + } + + oldPreviewRefreshJob.invokeOnCompletion { + oldReport.close() + } + } + + private fun schedulePreviewRefresh() { + val currentReport = report ?: return + val currentDescription = description + previewRefreshJob?.cancel() + previewRefreshJob = + coroutineScope.launch { + try { + delay(PREVIEW_REFRESH_DEBOUNCE_MS) + val nextPreview = buildPreview(currentReport, currentDescription) + + if (report == currentReport && description == currentDescription) { + applyPreview(nextPreview) + } + } finally { + if (previewRefreshJob == this.coroutineContext[Job]) { + previewRefreshJob = null + } + } + } + } + + private suspend fun buildPreview( + nextReport: DiagnosticsReport, + nextDescription: String, + ): DiagnosticsPreviewState = + withContext(ioDispatcher) { + val nextPreviewText = nextReport.previewTextForDescription(nextDescription) + + DiagnosticsPreviewState( + text = nextPreviewText, + chunks = nextPreviewText.chunked(PREVIEW_CHUNK_SIZE), + formattedSize = nextReport.formattedSizeForDescription(nextDescription), + ) + } + + private fun applyPreview(nextPreview: DiagnosticsPreviewState) { + previewText = nextPreview.text + previewChunks = nextPreview.chunks + reportSize = nextPreview.formattedSize + } + +} + +private data class DiagnosticsPreviewState( + val text: String, + val chunks: List, + val formattedSize: String, +) + +private data class SendDiagnosticsSheetActions( + val onDismiss: () -> Unit, + val onRetry: () -> Unit, + val onShare: () -> Unit, + val onClear: () -> Unit, + val onSubmit: () -> Unit, +) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SubmittedDiagnosticsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SubmittedDiagnosticsScreen.kt new file mode 100644 index 000000000..86ad66d6e --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/SubmittedDiagnosticsScreen.kt @@ -0,0 +1,461 @@ +@file:Suppress("FunctionNaming", "PackageNaming") + +package org.bitcoinppl.cove.flows.SettingsFlow + +import android.content.Context +import android.util.Log +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.bitcoinppl.cove.views.MaterialDivider +import org.bitcoinppl.cove_core.Database +import org.bitcoinppl.cove_core.DatabaseException +import org.bitcoinppl.cove_core.DiagnosticsReportRecord + +private const val TAG = "SubmittedDiagnostics" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SubmittedDiagnosticsScreen( + onDismiss: () -> Unit, + onRecordsChanged: () -> Unit, + modifier: Modifier = Modifier, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + val coroutineScope = rememberCoroutineScope() + var loadState by remember { + mutableStateOf(SubmittedDiagnosticsLoadState.Loading) + } + var showClearConfirmation by remember { mutableStateOf(false) } + var actionError by remember { mutableStateOf(null) } + + LaunchedEffect(ioDispatcher) { + loadState = loadSubmittedDiagnosticsRecords(ioDispatcher) + } + + Scaffold( + modifier = + modifier + .fillMaxSize() + .padding(WindowInsets.safeDrawing.asPaddingValues()), + topBar = { + SubmittedDiagnosticsTopBar( + canClear = loadState.canClear, + onDismiss = onDismiss, + onClear = { showClearConfirmation = true }, + ) + }, + ) { paddingValues -> + SubmittedDiagnosticsBody( + loadState = loadState, + onRetry = { + coroutineScope.launch { + loadState = SubmittedDiagnosticsLoadState.Loading + loadState = loadSubmittedDiagnosticsRecords(ioDispatcher) + } + }, + paddingValues = paddingValues, + ) + } + + ClearSubmittedDiagnosticsDialog( + visible = showClearConfirmation, + onDismiss = { showClearConfirmation = false }, + onConfirm = { + showClearConfirmation = false + coroutineScope.launch { + try { + withContext(ioDispatcher) { + Database().diagnosticsReports().clear() + } + loadState = SubmittedDiagnosticsLoadState.Loaded(emptyList()) + onRecordsChanged() + } catch (error: CancellationException) { + throw error + } catch (error: DatabaseException) { + actionError = error.displayMessage() + } + } + }, + ) + + SubmittedDiagnosticsErrorDialog( + error = actionError, + onDismiss = { actionError = null }, + ) +} + +@Composable +private fun SubmittedDiagnosticsErrorDialog( + error: String?, + onDismiss: () -> Unit, +) { + if (error == null) return + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Something went wrong") }, + text = { Text(error) }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("OK") + } + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SubmittedDiagnosticsTopBar( + canClear: Boolean, + onDismiss: () -> Unit, + onClear: () -> Unit, +) { + TopAppBar( + title = { + Text( + text = "Submitted Diagnostics", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + TextButton( + onClick = onClear, + enabled = canClear, + ) { + Text( + text = "Clear", + color = + if (canClear) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + }, + ) +} + +@Composable +private fun SubmittedDiagnosticsBody( + loadState: SubmittedDiagnosticsLoadState, + onRetry: () -> Unit, + paddingValues: PaddingValues, +) { + when (loadState) { + SubmittedDiagnosticsLoadState.Loading -> { + SubmittedDiagnosticsLoading(paddingValues) + } + is SubmittedDiagnosticsLoadState.Failed -> { + SubmittedDiagnosticsLoadError( + message = loadState.message, + onRetry = onRetry, + paddingValues = paddingValues, + ) + } + is SubmittedDiagnosticsLoadState.Loaded -> { + SubmittedDiagnosticsRecordsBody( + records = loadState.records, + paddingValues = paddingValues, + ) + } + } +} + +@Composable +private fun SubmittedDiagnosticsLoading(paddingValues: PaddingValues) { + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Text( + text = "Loading submitted diagnostics...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 12.dp), + ) + } + } +} + +@Composable +private fun SubmittedDiagnosticsLoadError( + message: String, + onRetry: () -> Unit, + paddingValues: PaddingValues, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Submitted diagnostics unavailable", + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp), + ) + Button( + onClick = onRetry, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 16.dp), + ) { + Text("Retry") + } + } + } +} + +@Composable +private fun SubmittedDiagnosticsRecordsBody( + records: List, + paddingValues: PaddingValues, +) { + if (records.isEmpty()) { + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "No submitted diagnostics", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "Submitted report IDs will appear here.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + return + } + + val context = LocalContext.current + + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + itemsIndexed( + items = records, + key = { index, record -> "${record.reportId}-$index" }, + ) { index, record -> + SubmittedDiagnosticsRow( + context = context, + record = record, + ) + + if (index < records.lastIndex) { + MaterialDivider() + } + } + } +} + +@Composable +private fun SubmittedDiagnosticsRow( + context: Context, + record: DiagnosticsReportRecord, +) { + ListItem( + headlineContent = { + Text( + text = record.reportId, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + Column { + Text( + text = formattedSubmittedAt(record.submittedAt), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + record.description?.takeIf { it.isNotBlank() }?.let { description -> + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + trailingContent = { + IconButton(onClick = { copyReportId(context, record.reportId) }) { + Icon( + Icons.Default.ContentCopy, + contentDescription = "Copy Report ID", + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + colors = + ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.background, + ), + ) +} + +@Composable +private fun ClearSubmittedDiagnosticsDialog( + visible: Boolean, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + if (!visible) return + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Clear Submitted Diagnostics?") }, + text = { Text("This removes saved report IDs from this device.") }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text( + text = "Clear", + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} + +internal suspend fun loadSubmittedDiagnosticsRecords( + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + loadRecords: () -> Result> = { + try { + Result.success(Database().diagnosticsReports().all()) + } catch (error: DatabaseException) { + Result.failure(error) + } + }, + logFailure: (Exception) -> Unit = { error -> + Log.w(TAG, "Failed to load submitted diagnostics", error) + }, +): SubmittedDiagnosticsLoadState = + withContext(ioDispatcher) { + loadRecords().fold( + onSuccess = SubmittedDiagnosticsLoadState::Loaded, + onFailure = { error -> + if (error is CancellationException) throw error + + val exception = error as? Exception ?: RuntimeException(error) + logFailure(exception) + SubmittedDiagnosticsLoadState.Failed(error.displayMessage()) + }, + ) + } + +private fun formattedSubmittedAt(timestamp: ULong): String = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT) + .withZone(ZoneId.systemDefault()) + .format(Instant.ofEpochSecond(timestamp.toLong())) + +internal sealed interface SubmittedDiagnosticsLoadState { + data object Loading : SubmittedDiagnosticsLoadState + + data class Loaded( + val records: List, + ) : SubmittedDiagnosticsLoadState + + data class Failed( + val message: String, + ) : SubmittedDiagnosticsLoadState +} + +private val SubmittedDiagnosticsLoadState.canClear: Boolean + get() = + when (this) { + SubmittedDiagnosticsLoadState.Loading -> false + is SubmittedDiagnosticsLoadState.Loaded -> records.isNotEmpty() + is SubmittedDiagnosticsLoadState.Failed -> true + } diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt index 2a64b464c..e569170c8 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt @@ -1067,6 +1067,10 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_func_all_block_explorer_options( ): Short + external fun uniffi_cove_checksum_func_build_diagnostics_report( + ): Short + external fun uniffi_cove_checksum_func_clear_diagnostics_logs( + ): Short external fun uniffi_cove_checksum_func_all_fiat_currencies( ): Short external fun uniffi_cove_checksum_func_is_fiat_currency_symbol( @@ -1279,6 +1283,8 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_method_database_dangerous_reset_all_data( ): Short + external fun uniffi_cove_checksum_method_database_diagnostics_reports( + ): Short external fun uniffi_cove_checksum_method_database_global_config( ): Short external fun uniffi_cove_checksum_method_database_global_flag( @@ -1289,6 +1295,10 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_method_database_wallets( ): Short + external fun uniffi_cove_checksum_method_diagnosticsreportstable_all( + ): Short + external fun uniffi_cove_checksum_method_diagnosticsreportstable_clear( + ): Short external fun uniffi_cove_checksum_method_globalconfigtable_authtype( ): Short external fun uniffi_cove_checksum_method_globalconfigtable_clear_custom_block_explorer( @@ -1377,6 +1387,20 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_method_walletstable_reorder_wallets( ): Short + external fun uniffi_cove_checksum_method_diagnosticsreport_formatted_size( + ): Short + external fun uniffi_cove_checksum_method_diagnosticsreport_formatted_size_for_description( + ): Short + external fun uniffi_cove_checksum_method_diagnosticsreport_preview_text( + ): Short + external fun uniffi_cove_checksum_method_diagnosticsreport_preview_text_for_description( + ): Short + external fun uniffi_cove_checksum_method_diagnosticsreport_size_bytes( + ): Short + external fun uniffi_cove_checksum_method_diagnosticsreport_size_bytes_for_description( + ): Short + external fun uniffi_cove_checksum_method_diagnosticsreport_submit( + ): Short external fun uniffi_cove_checksum_method_priceresponse_get( ): Short external fun uniffi_cove_checksum_method_priceresponse_get_for_currency( @@ -2262,6 +2286,8 @@ internal object UniffiLib { ): Long external fun uniffi_cove_fn_method_database_dangerous_reset_all_data(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): Unit + external fun uniffi_cove_fn_method_database_diagnostics_reports(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long external fun uniffi_cove_fn_method_database_global_config(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_fn_method_database_global_flag(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -2272,6 +2298,14 @@ internal object UniffiLib { ): Long external fun uniffi_cove_fn_method_database_wallets(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long + external fun uniffi_cove_fn_clone_diagnosticsreportstable(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_fn_free_diagnosticsreportstable(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun uniffi_cove_fn_method_diagnosticsreportstable_all(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_fn_method_diagnosticsreportstable_clear(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Unit external fun uniffi_cove_fn_clone_globalconfigtable(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_fn_free_globalconfigtable(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -2396,6 +2430,24 @@ internal object UniffiLib { ): Long external fun uniffi_cove_fn_free_labelstable(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Unit + external fun uniffi_cove_fn_clone_diagnosticsreport(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_fn_free_diagnosticsreport(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun uniffi_cove_fn_method_diagnosticsreport_formatted_size(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_fn_method_diagnosticsreport_formatted_size_for_description(`ptr`: Long,`description`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_fn_method_diagnosticsreport_preview_text(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_fn_method_diagnosticsreport_preview_text_for_description(`ptr`: Long,`description`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_fn_method_diagnosticsreport_size_bytes(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_fn_method_diagnosticsreport_size_bytes_for_description(`ptr`: Long,`description`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_fn_method_diagnosticsreport_submit(`ptr`: Long,`description`: RustBuffer.ByValue, + ): Long external fun uniffi_cove_fn_clone_fiatclient(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_fn_free_fiatclient(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -3266,6 +3318,8 @@ internal object UniffiLib { ): RustBuffer.ByValue external fun uniffi_cove_fn_method_blockexploreroption_display_name(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue + external fun uniffi_cove_fn_method_diagnosticsreportstableerror_uniffi_trait_display(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_cove_fn_method_databaseerror_uniffi_trait_display(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_cove_fn_method_serdeerror_uniffi_trait_display(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -3438,6 +3492,10 @@ internal object UniffiLib { ): RustBuffer.ByValue external fun uniffi_cove_fn_func_all_block_explorer_options(uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue + external fun uniffi_cove_fn_func_build_diagnostics_report(`platform`: RustBuffer.ByValue,`platformLogs`: RustBuffer.ByValue, + ): Long + external fun uniffi_cove_fn_func_clear_diagnostics_logs(uniffi_out_err: UniffiRustCallStatus, + ): Unit external fun uniffi_cove_fn_func_all_fiat_currencies(uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_cove_fn_func_is_fiat_currency_symbol(`symbol`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -3695,6 +3753,12 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_func_all_block_explorer_options() != 40123.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_func_build_diagnostics_report() != 50442.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_func_clear_diagnostics_logs() != 12380.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_func_all_fiat_currencies() != 53482.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4013,6 +4077,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_database_dangerous_reset_all_data() != 25988.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_database_diagnostics_reports() != 32801.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_database_global_config() != 34695.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4028,6 +4095,12 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_database_wallets() != 16005.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_diagnosticsreportstable_all() != 6560.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_diagnosticsreportstable_clear() != 15672.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_globalconfigtable_authtype() != 62043.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4160,6 +4233,27 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_walletstable_reorder_wallets() != 40391.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_diagnosticsreport_formatted_size() != 23149.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_diagnosticsreport_formatted_size_for_description() != 52799.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_diagnosticsreport_preview_text() != 51487.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_diagnosticsreport_preview_text_for_description() != 51859.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_diagnosticsreport_size_bytes() != 10840.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_diagnosticsreport_size_bytes_for_description() != 25891.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_diagnosticsreport_submit() != 54230.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_priceresponse_get() != 6552.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -8678,14 +8772,547 @@ public object FfiConverterTypeBitcoinTransaction: FfiConverter + UniffiLib.uniffi_cove_fn_constructor_boxedroute_new( + + + FfiConverterTypeRoute.lower(`route`),_status) +} + ) + + protected val handle: Long + protected val cleanable: UniffiCleaner.Cleanable? + + private val wasDestroyed = AtomicBoolean(false) + private val callCounter = AtomicLong(1) + + /** + * Whether the current object has been destroyed and its reference is gone in the Rust side. + */ + val uniffiIsDestroyed: Boolean get() = wasDestroyed.get() + + override fun destroy() { + // Only allow a single call to this method. + // TODO: maybe we should log a warning if called more than once? + if (this.wasDestroyed.compareAndSet(false, true)) { + // This decrement always matches the initial count of 1 given at creation time. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + @Synchronized + override fun close() { + this.destroy() + } + + internal inline fun callWithHandle(block: (handle: Long) -> R): R { + // Check and increment the call counter, to keep the object alive. + // This needs a compare-and-set retry loop in case of concurrent updates. + do { + val c = this.callCounter.get() + if (c == 0L) { + throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") + } + if (c == Long.MAX_VALUE) { + throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") + } + } while (! this.callCounter.compareAndSet(c, c + 1L)) + // Now we can safely do the method call without the handle being freed concurrently. + try { + return block(this.uniffiCloneHandle()) + } finally { + // This decrement always matches the increment we performed above. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + // Use a static inner class instead of a closure so as not to accidentally + // capture `this` as part of the cleanable's action. + private class UniffiCleanAction(private val handle: Long) : Runnable { + override fun run() { + if (handle == 0.toLong()) { + // Fake object created with `NoHandle`, don't try to free. + return; + } + uniffiRustCall { status -> + UniffiLib.uniffi_cove_fn_free_boxedroute(handle, status) + } + } + } + + /** + * @suppress + */ + fun uniffiCloneHandle(): Long { + if (handle == 0.toLong()) { + throw InternalException("uniffiCloneHandle() called on NoHandle object"); + } + return uniffiRustCall() { status -> + UniffiLib.uniffi_cove_fn_clone_boxedroute(handle, status) + } + } + + override fun `route`(): Route { + return FfiConverterTypeRoute.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_boxedroute_route( + it, + _status) +} + } + ) + } + + + + + + + + + + /** + * @suppress + */ + companion object + +} + + +/** + * @suppress + */ +public object FfiConverterTypeBoxedRoute: FfiConverter { + override fun lower(value: BoxedRoute): Long { + return value.uniffiCloneHandle() + } + + override fun lift(value: Long): BoxedRoute { + return BoxedRoute(UniffiWithHandle, value) + } + + override fun read(buf: ByteBuffer): BoxedRoute { + return lift(buf.getLong()) + } + + override fun allocationSize(value: BoxedRoute) = 8UL + + override fun write(value: BoxedRoute, buf: ByteBuffer) { + buf.putLong(lower(value)) + } +} + + +// This template implements a class for working with a Rust struct via a handle +// to the live Rust struct on the other side of the FFI. +// +// There's some subtlety here, because we have to be careful not to operate on a Rust +// struct after it has been dropped, and because we must expose a public API for freeing +// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: +// +// * Each instance holds an opaque handle to the underlying Rust struct. +// Method calls need to read this handle from the object's state and pass it in to +// the Rust FFI. +// +// * When an instance is no longer needed, its handle should be passed to a +// special destructor function provided by the Rust FFI, which will drop the +// underlying Rust struct. +// +// * Given an instance, calling code is expected to call the special +// `destroy` method in order to free it after use, either by calling it explicitly +// or by using a higher-level helper like the `use` method. Failing to do so risks +// leaking the underlying Rust struct. +// +// * We can't assume that calling code will do the right thing, and must be prepared +// to handle Kotlin method calls executing concurrently with or even after a call to +// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. +// +// * We must never allow Rust code to operate on the underlying Rust struct after +// the destructor has been called, and must never call the destructor more than once. +// Doing so may trigger memory unsafety. +// +// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` +// is implemented to call the destructor when the Kotlin object becomes unreachable. +// This is done in a background thread. This is not a panacea, and client code should be aware that +// 1. the thread may starve if some there are objects that have poorly performing +// `drop` methods or do significant work in their `drop` methods. +// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, +// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). +// +// If we try to implement this with mutual exclusion on access to the handle, there is the +// possibility of a race between a method call and a concurrent call to `destroy`: +// +// * Thread A starts a method call, reads the value of the handle, but is interrupted +// before it can pass the handle over the FFI to Rust. +// * Thread B calls `destroy` and frees the underlying Rust struct. +// * Thread A resumes, passing the already-read handle value to Rust and triggering +// a use-after-free. +// +// One possible solution would be to use a `ReadWriteLock`, with each method call taking +// a read lock (and thus allowed to run concurrently) and the special `destroy` method +// taking a write lock (and thus blocking on live method calls). However, we aim not to +// generate methods with any hidden blocking semantics, and a `destroy` method that might +// block if called incorrectly seems to meet that bar. +// +// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track +// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` +// has been called. These are updated according to the following rules: +// +// * The initial value of the counter is 1, indicating a live object with no in-flight calls. +// The initial value for the flag is false. +// +// * At the start of each method call, we atomically check the counter. +// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. +// If it is nonzero them we atomically increment it by 1 and proceed with the method call. +// +// * At the end of each method call, we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// * When `destroy` is called, we atomically flip the flag from false to true. +// If the flag was already true we silently fail. +// Otherwise we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, +// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. +// +// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been +// called *and* all in-flight method calls have completed, avoiding violating any of the expectations +// of the underlying Rust code. +// +// This makes a cleaner a better alternative to _not_ calling `destroy()` as +// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` +// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner +// thread may be starved, and the app will leak memory. +// +// In this case, `destroy`ing manually may be a better solution. +// +// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects +// with Rust peers are reclaimed: +// +// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: +// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: +// 3. The memory is reclaimed when the process terminates. +// +// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 +// + + +public interface CoinControlManagerStateInterface { + + companion object +} + +open class CoinControlManagerState: Disposable, AutoCloseable, CoinControlManagerStateInterface +{ + + @Suppress("UNUSED_PARAMETER") + /** + * @suppress + */ + constructor(withHandle: UniffiWithHandle, handle: Long) { + this.handle = handle + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(handle)) + } + + /** + * @suppress + * + * This constructor can be used to instantiate a fake object. Only used for tests. Any + * attempt to actually use an object constructed this way will fail as there is no + * connected Rust object. + */ + @Suppress("UNUSED_PARAMETER") + constructor(noHandle: NoHandle) { + this.handle = 0 + this.cleanable = null + } + + protected val handle: Long + protected val cleanable: UniffiCleaner.Cleanable? + + private val wasDestroyed = AtomicBoolean(false) + private val callCounter = AtomicLong(1) + + /** + * Whether the current object has been destroyed and its reference is gone in the Rust side. + */ + val uniffiIsDestroyed: Boolean get() = wasDestroyed.get() + + override fun destroy() { + // Only allow a single call to this method. + // TODO: maybe we should log a warning if called more than once? + if (this.wasDestroyed.compareAndSet(false, true)) { + // This decrement always matches the initial count of 1 given at creation time. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + @Synchronized + override fun close() { + this.destroy() + } + + internal inline fun callWithHandle(block: (handle: Long) -> R): R { + // Check and increment the call counter, to keep the object alive. + // This needs a compare-and-set retry loop in case of concurrent updates. + do { + val c = this.callCounter.get() + if (c == 0L) { + throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") + } + if (c == Long.MAX_VALUE) { + throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") + } + } while (! this.callCounter.compareAndSet(c, c + 1L)) + // Now we can safely do the method call without the handle being freed concurrently. + try { + return block(this.uniffiCloneHandle()) + } finally { + // This decrement always matches the increment we performed above. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + // Use a static inner class instead of a closure so as not to accidentally + // capture `this` as part of the cleanable's action. + private class UniffiCleanAction(private val handle: Long) : Runnable { + override fun run() { + if (handle == 0.toLong()) { + // Fake object created with `NoHandle`, don't try to free. + return; + } + uniffiRustCall { status -> + UniffiLib.uniffi_cove_fn_free_coincontrolmanagerstate(handle, status) + } + } + } + + /** + * @suppress + */ + fun uniffiCloneHandle(): Long { + if (handle == 0.toLong()) { + throw InternalException("uniffiCloneHandle() called on NoHandle object"); + } + return uniffiRustCall() { status -> + UniffiLib.uniffi_cove_fn_clone_coincontrolmanagerstate(handle, status) + } + } + + + + + + + + companion object { + fun `previewNew`(`outputCount`: kotlin.UByte = 20u, `changeCount`: kotlin.UByte = 4u): CoinControlManagerState { + return FfiConverterTypeCoinControlManagerState.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_constructor_coincontrolmanagerstate_preview_new( + + + FfiConverterUByte.lower(`outputCount`), + FfiConverterUByte.lower(`changeCount`),_status) +} + ) + } + + + + } + +} + + +/** + * @suppress + */ +public object FfiConverterTypeCoinControlManagerState: FfiConverter { + override fun lower(value: CoinControlManagerState): Long { + return value.uniffiCloneHandle() + } + + override fun lift(value: Long): CoinControlManagerState { + return CoinControlManagerState(UniffiWithHandle, value) + } + + override fun read(buf: ByteBuffer): CoinControlManagerState { + return lift(buf.getLong()) + } + + override fun allocationSize(value: CoinControlManagerState) = 8UL + + override fun write(value: CoinControlManagerState, buf: ByteBuffer) { + buf.putLong(lower(value)) + } +} + + +// This template implements a class for working with a Rust struct via a handle +// to the live Rust struct on the other side of the FFI. +// +// There's some subtlety here, because we have to be careful not to operate on a Rust +// struct after it has been dropped, and because we must expose a public API for freeing +// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: +// +// * Each instance holds an opaque handle to the underlying Rust struct. +// Method calls need to read this handle from the object's state and pass it in to +// the Rust FFI. +// +// * When an instance is no longer needed, its handle should be passed to a +// special destructor function provided by the Rust FFI, which will drop the +// underlying Rust struct. +// +// * Given an instance, calling code is expected to call the special +// `destroy` method in order to free it after use, either by calling it explicitly +// or by using a higher-level helper like the `use` method. Failing to do so risks +// leaking the underlying Rust struct. +// +// * We can't assume that calling code will do the right thing, and must be prepared +// to handle Kotlin method calls executing concurrently with or even after a call to +// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. +// +// * We must never allow Rust code to operate on the underlying Rust struct after +// the destructor has been called, and must never call the destructor more than once. +// Doing so may trigger memory unsafety. +// +// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` +// is implemented to call the destructor when the Kotlin object becomes unreachable. +// This is done in a background thread. This is not a panacea, and client code should be aware that +// 1. the thread may starve if some there are objects that have poorly performing +// `drop` methods or do significant work in their `drop` methods. +// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, +// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). +// +// If we try to implement this with mutual exclusion on access to the handle, there is the +// possibility of a race between a method call and a concurrent call to `destroy`: +// +// * Thread A starts a method call, reads the value of the handle, but is interrupted +// before it can pass the handle over the FFI to Rust. +// * Thread B calls `destroy` and frees the underlying Rust struct. +// * Thread A resumes, passing the already-read handle value to Rust and triggering +// a use-after-free. +// +// One possible solution would be to use a `ReadWriteLock`, with each method call taking +// a read lock (and thus allowed to run concurrently) and the special `destroy` method +// taking a write lock (and thus blocking on live method calls). However, we aim not to +// generate methods with any hidden blocking semantics, and a `destroy` method that might +// block if called incorrectly seems to meet that bar. +// +// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track +// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` +// has been called. These are updated according to the following rules: +// +// * The initial value of the counter is 1, indicating a live object with no in-flight calls. +// The initial value for the flag is false. +// +// * At the start of each method call, we atomically check the counter. +// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. +// If it is nonzero them we atomically increment it by 1 and proceed with the method call. +// +// * At the end of each method call, we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// * When `destroy` is called, we atomically flip the flag from false to true. +// If the flag was already true we silently fail. +// Otherwise we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, +// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. +// +// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been +// called *and* all in-flight method calls have completed, avoiding violating any of the expectations +// of the underlying Rust code. +// +// This makes a cleaner a better alternative to _not_ calling `destroy()` as +// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` +// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner +// thread may be starved, and the app will leak memory. +// +// In this case, `destroy`ing manually may be a better solution. +// +// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects +// with Rust peers are reclaimed: +// +// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: +// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: +// 3. The memory is reclaimed when the process terminates. +// +// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 +// + + +public interface ConfirmedTransactionInterface { - fun `route`(): Route + fun `blockHeight`(): kotlin.UInt + + fun `blockHeightFmt`(): kotlin.String + + fun `confirmedAt`(): kotlin.ULong + + fun `confirmedAtFmt`(): kotlin.String + + fun `confirmedAtFmtWithTime`(): kotlin.String + + fun `fiatAmount`(): FiatAmount? + + fun `id`(): TxId + + fun `label`(): kotlin.String + + fun `labelOpt`(): kotlin.String? + + fun `sentAndReceived`(): SentAndReceived companion object } -open class BoxedRoute: Disposable, AutoCloseable, BoxedRouteInterface +open class ConfirmedTransaction: Disposable, AutoCloseable, ConfirmedTransactionInterface { @Suppress("UNUSED_PARAMETER") @@ -8709,15 +9336,6 @@ open class BoxedRoute: Disposable, AutoCloseable, BoxedRouteInterface this.handle = 0 this.cleanable = null } - constructor(`route`: Route) : - this(UniffiWithHandle, - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_constructor_boxedroute_new( - - - FfiConverterTypeRoute.lower(`route`),_status) -} - ) protected val handle: Long protected val cleanable: UniffiCleaner.Cleanable? @@ -8778,7 +9396,7 @@ open class BoxedRoute: Disposable, AutoCloseable, BoxedRouteInterface return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_boxedroute(handle, status) + UniffiLib.uniffi_cove_fn_free_confirmedtransaction(handle, status) } } } @@ -8791,15 +9409,132 @@ open class BoxedRoute: Disposable, AutoCloseable, BoxedRouteInterface throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_boxedroute(handle, status) + UniffiLib.uniffi_cove_fn_clone_confirmedtransaction(handle, status) } } - override fun `route`(): Route { - return FfiConverterTypeRoute.lift( + override fun `blockHeight`(): kotlin.UInt { + return FfiConverterUInt.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_boxedroute_route( + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_block_height( + it, + _status) +} + } + ) + } + + + override fun `blockHeightFmt`(): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_block_height_fmt( + it, + _status) +} + } + ) + } + + + override fun `confirmedAt`(): kotlin.ULong { + return FfiConverterULong.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_confirmed_at( + it, + _status) +} + } + ) + } + + + override fun `confirmedAtFmt`(): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_confirmed_at_fmt( + it, + _status) +} + } + ) + } + + + override fun `confirmedAtFmtWithTime`(): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_confirmed_at_fmt_with_time( + it, + _status) +} + } + ) + } + + + override fun `fiatAmount`(): FiatAmount? { + return FfiConverterOptionalTypeFiatAmount.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_fiat_amount( + it, + _status) +} + } + ) + } + + + override fun `id`(): TxId { + return FfiConverterTypeTxId.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_id( + it, + _status) +} + } + ) + } + + + override fun `label`(): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_label( + it, + _status) +} + } + ) + } + + + override fun `labelOpt`(): kotlin.String? { + return FfiConverterOptionalString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_label_opt( + it, + _status) +} + } + ) + } + + + override fun `sentAndReceived`(): SentAndReceived { + return FfiConverterTypeSentAndReceived.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_confirmedtransaction_sent_and_received( it, _status) } @@ -8826,22 +9561,22 @@ open class BoxedRoute: Disposable, AutoCloseable, BoxedRouteInterface /** * @suppress */ -public object FfiConverterTypeBoxedRoute: FfiConverter { - override fun lower(value: BoxedRoute): Long { +public object FfiConverterTypeConfirmedTransaction: FfiConverter { + override fun lower(value: ConfirmedTransaction): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): BoxedRoute { - return BoxedRoute(UniffiWithHandle, value) + override fun lift(value: Long): ConfirmedTransaction { + return ConfirmedTransaction(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): BoxedRoute { + override fun read(buf: ByteBuffer): ConfirmedTransaction { return lift(buf.getLong()) } - override fun allocationSize(value: BoxedRoute) = 8UL + override fun allocationSize(value: ConfirmedTransaction) = 8UL - override fun write(value: BoxedRoute, buf: ByteBuffer) { + override fun write(value: ConfirmedTransaction, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -8942,12 +9677,16 @@ public object FfiConverterTypeBoxedRoute: FfiConverter { // -public interface CoinControlManagerStateInterface { +public interface ConverterInterface { + + fun `parseFiatStr`(`fiatAmount`: kotlin.String): kotlin.Double + + fun `removeFiatSuffix`(`fiatAmount`: kotlin.String): kotlin.String companion object } -open class CoinControlManagerState: Disposable, AutoCloseable, CoinControlManagerStateInterface +open class Converter: Disposable, AutoCloseable, ConverterInterface { @Suppress("UNUSED_PARAMETER") @@ -8971,6 +9710,14 @@ open class CoinControlManagerState: Disposable, AutoCloseable, CoinControlManage this.handle = 0 this.cleanable = null } + constructor() : + this(UniffiWithHandle, + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_constructor_converter_new( + + _status) +} + ) protected val handle: Long protected val cleanable: UniffiCleaner.Cleanable? @@ -9031,7 +9778,7 @@ open class CoinControlManagerState: Disposable, AutoCloseable, CoinControlManage return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_coincontrolmanagerstate(handle, status) + UniffiLib.uniffi_cove_fn_free_converter(handle, status) } } } @@ -9044,32 +9791,50 @@ open class CoinControlManagerState: Disposable, AutoCloseable, CoinControlManage throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_coincontrolmanagerstate(handle, status) + UniffiLib.uniffi_cove_fn_clone_converter(handle, status) } } + @Throws(ConverterException::class)override fun `parseFiatStr`(`fiatAmount`: kotlin.String): kotlin.Double { + return FfiConverterDouble.lift( + callWithHandle { + uniffiRustCallWithError(ConverterException) { _status -> + UniffiLib.uniffi_cove_fn_method_converter_parse_fiat_str( + it, + FfiConverterString.lower(`fiatAmount`),_status) +} + } + ) + } - - - companion object { - fun `previewNew`(`outputCount`: kotlin.UByte = 20u, `changeCount`: kotlin.UByte = 4u): CoinControlManagerState { - return FfiConverterTypeCoinControlManagerState.lift( + override fun `removeFiatSuffix`(`fiatAmount`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_constructor_coincontrolmanagerstate_preview_new( - + UniffiLib.uniffi_cove_fn_method_converter_remove_fiat_suffix( + it, - FfiConverterUByte.lower(`outputCount`), - FfiConverterUByte.lower(`changeCount`),_status) + FfiConverterString.lower(`fiatAmount`),_status) } + } ) } - } + + + + + + + /** + * @suppress + */ + companion object } @@ -9077,22 +9842,22 @@ open class CoinControlManagerState: Disposable, AutoCloseable, CoinControlManage /** * @suppress */ -public object FfiConverterTypeCoinControlManagerState: FfiConverter { - override fun lower(value: CoinControlManagerState): Long { +public object FfiConverterTypeConverter: FfiConverter { + override fun lower(value: Converter): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): CoinControlManagerState { - return CoinControlManagerState(UniffiWithHandle, value) + override fun lift(value: Long): Converter { + return Converter(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): CoinControlManagerState { + override fun read(buf: ByteBuffer): Converter { return lift(buf.getLong()) } - override fun allocationSize(value: CoinControlManagerState) = 8UL + override fun allocationSize(value: Converter) = 8UL - override fun write(value: CoinControlManagerState, buf: ByteBuffer) { + override fun write(value: Converter, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -9193,32 +9958,26 @@ public object FfiConverterTypeCoinControlManagerState: FfiConverter + UniffiLib.uniffi_cove_fn_constructor_database_new( + + _status) +} + ) protected val handle: Long protected val cleanable: UniffiCleaner.Cleanable? @@ -9302,7 +10069,7 @@ open class ConfirmedTransaction: Disposable, AutoCloseable, ConfirmedTransaction return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_confirmedtransaction(handle, status) + UniffiLib.uniffi_cove_fn_free_database(handle, status) } } } @@ -9315,41 +10082,27 @@ open class ConfirmedTransaction: Disposable, AutoCloseable, ConfirmedTransaction throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_confirmedtransaction(handle, status) + UniffiLib.uniffi_cove_fn_clone_database(handle, status) } } - override fun `blockHeight`(): kotlin.UInt { - return FfiConverterUInt.lift( + override fun `dangerousResetAllData`() + = callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_block_height( + UniffiLib.uniffi_cove_fn_method_database_dangerous_reset_all_data( it, _status) } } - ) - } - override fun `blockHeightFmt`(): kotlin.String { - return FfiConverterString.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_block_height_fmt( - it, - _status) -} - } - ) - } - - override fun `confirmedAt`(): kotlin.ULong { - return FfiConverterULong.lift( + override fun `diagnosticsReports`(): DiagnosticsReportsTable { + return FfiConverterTypeDiagnosticsReportsTable.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_confirmed_at( + UniffiLib.uniffi_cove_fn_method_database_diagnostics_reports( it, _status) } @@ -9358,37 +10111,11 @@ open class ConfirmedTransaction: Disposable, AutoCloseable, ConfirmedTransaction } - override fun `confirmedAtFmt`(): kotlin.String { - return FfiConverterString.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_confirmed_at_fmt( - it, - _status) -} - } - ) - } - - - override fun `confirmedAtFmtWithTime`(): kotlin.String { - return FfiConverterString.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_confirmed_at_fmt_with_time( - it, - _status) -} - } - ) - } - - - override fun `fiatAmount`(): FiatAmount? { - return FfiConverterOptionalTypeFiatAmount.lift( + override fun `globalConfig`(): GlobalConfigTable { + return FfiConverterTypeGlobalConfigTable.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_fiat_amount( + UniffiLib.uniffi_cove_fn_method_database_global_config( it, _status) } @@ -9397,11 +10124,11 @@ open class ConfirmedTransaction: Disposable, AutoCloseable, ConfirmedTransaction } - override fun `id`(): TxId { - return FfiConverterTypeTxId.lift( + override fun `globalFlag`(): GlobalFlagTable { + return FfiConverterTypeGlobalFlagTable.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_id( + UniffiLib.uniffi_cove_fn_method_database_global_flag( it, _status) } @@ -9410,11 +10137,11 @@ open class ConfirmedTransaction: Disposable, AutoCloseable, ConfirmedTransaction } - override fun `label`(): kotlin.String { - return FfiConverterString.lift( + override fun `historicalPrices`(): HistoricalPriceTable { + return FfiConverterTypeHistoricalPriceTable.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_label( + UniffiLib.uniffi_cove_fn_method_database_historical_prices( it, _status) } @@ -9423,11 +10150,11 @@ open class ConfirmedTransaction: Disposable, AutoCloseable, ConfirmedTransaction } - override fun `labelOpt`(): kotlin.String? { - return FfiConverterOptionalString.lift( + override fun `unsignedTransactions`(): UnsignedTransactionsTable { + return FfiConverterTypeUnsignedTransactionsTable.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_label_opt( + UniffiLib.uniffi_cove_fn_method_database_unsigned_transactions( it, _status) } @@ -9436,11 +10163,11 @@ open class ConfirmedTransaction: Disposable, AutoCloseable, ConfirmedTransaction } - override fun `sentAndReceived`(): SentAndReceived { - return FfiConverterTypeSentAndReceived.lift( + override fun `wallets`(): WalletsTable { + return FfiConverterTypeWalletsTable.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_confirmedtransaction_sent_and_received( + UniffiLib.uniffi_cove_fn_method_database_wallets( it, _status) } @@ -9467,22 +10194,22 @@ open class ConfirmedTransaction: Disposable, AutoCloseable, ConfirmedTransaction /** * @suppress */ -public object FfiConverterTypeConfirmedTransaction: FfiConverter { - override fun lower(value: ConfirmedTransaction): Long { +public object FfiConverterTypeDatabase: FfiConverter { + override fun lower(value: Database): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): ConfirmedTransaction { - return ConfirmedTransaction(UniffiWithHandle, value) + override fun lift(value: Long): Database { + return Database(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): ConfirmedTransaction { + override fun read(buf: ByteBuffer): Database { return lift(buf.getLong()) } - override fun allocationSize(value: ConfirmedTransaction) = 8UL + override fun allocationSize(value: Database) = 8UL - override fun write(value: ConfirmedTransaction, buf: ByteBuffer) { + override fun write(value: Database, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -9583,16 +10310,26 @@ public object FfiConverterTypeConfirmedTransaction: FfiConverter - UniffiLib.uniffi_cove_fn_constructor_converter_new( - - _status) -} - ) protected val handle: Long protected val cleanable: UniffiCleaner.Cleanable? @@ -9684,7 +10413,7 @@ open class Converter: Disposable, AutoCloseable, ConverterInterface return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_converter(handle, status) + UniffiLib.uniffi_cove_fn_free_diagnosticsreport(handle, status) } } } @@ -9697,39 +10426,113 @@ open class Converter: Disposable, AutoCloseable, ConverterInterface throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_converter(handle, status) + UniffiLib.uniffi_cove_fn_clone_diagnosticsreport(handle, status) } } + override fun `formattedSize`(): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_diagnosticsreport_formatted_size( + it, + _status) +} + } + ) + } - @Throws(ConverterException::class)override fun `parseFiatStr`(`fiatAmount`: kotlin.String): kotlin.Double { - return FfiConverterDouble.lift( + + override fun `formattedSizeForDescription`(`description`: kotlin.String?): kotlin.String { + return FfiConverterString.lift( callWithHandle { - uniffiRustCallWithError(ConverterException) { _status -> - UniffiLib.uniffi_cove_fn_method_converter_parse_fiat_str( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_diagnosticsreport_formatted_size_for_description( it, - FfiConverterString.lower(`fiatAmount`),_status) + FfiConverterOptionalString.lower(`description`),_status) } } ) } - override fun `removeFiatSuffix`(`fiatAmount`: kotlin.String): kotlin.String { + override fun `previewText`(): kotlin.String { return FfiConverterString.lift( callWithHandle { uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_converter_remove_fiat_suffix( + UniffiLib.uniffi_cove_fn_method_diagnosticsreport_preview_text( it, + _status) +} + } + ) + } - FfiConverterString.lower(`fiatAmount`),_status) + + override fun `previewTextForDescription`(`description`: kotlin.String?): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_diagnosticsreport_preview_text_for_description( + it, + + FfiConverterOptionalString.lower(`description`),_status) } } ) } + override fun `sizeBytes`(): kotlin.ULong { + return FfiConverterULong.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_diagnosticsreport_size_bytes( + it, + _status) +} + } + ) + } + + + override fun `sizeBytesForDescription`(`description`: kotlin.String?): kotlin.ULong { + return FfiConverterULong.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_diagnosticsreport_size_bytes_for_description( + it, + + FfiConverterOptionalString.lower(`description`),_status) +} + } + ) + } + + + + @Throws(DiagnosticsException::class) + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") + override suspend fun `submit`(`description`: kotlin.String?) : DiagnosticsSubmission { + return uniffiRustCallAsync( + callWithHandle { uniffiHandle -> + UniffiLib.uniffi_cove_fn_method_diagnosticsreport_submit( + uniffiHandle, + + FfiConverterOptionalString.lower(`description`), + ) + }, + { future, callback, continuation -> UniffiLib.ffi_cove_rust_future_poll_rust_buffer(future, callback, continuation) }, + { future, continuation -> UniffiLib.ffi_cove_rust_future_complete_rust_buffer(future, continuation) }, + { future -> UniffiLib.ffi_cove_rust_future_free_rust_buffer(future) }, + // lift function + { FfiConverterTypeDiagnosticsSubmission.lift(it) }, + // Error FFI converter + DiagnosticsException.ErrorHandler, + ) + } + @@ -9748,22 +10551,22 @@ open class Converter: Disposable, AutoCloseable, ConverterInterface /** * @suppress */ -public object FfiConverterTypeConverter: FfiConverter { - override fun lower(value: Converter): Long { +public object FfiConverterTypeDiagnosticsReport: FfiConverter { + override fun lower(value: DiagnosticsReport): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): Converter { - return Converter(UniffiWithHandle, value) + override fun lift(value: Long): DiagnosticsReport { + return DiagnosticsReport(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): Converter { + override fun read(buf: ByteBuffer): DiagnosticsReport { return lift(buf.getLong()) } - override fun allocationSize(value: Converter) = 8UL + override fun allocationSize(value: DiagnosticsReport) = 8UL - override fun write(value: Converter, buf: ByteBuffer) { + override fun write(value: DiagnosticsReport, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -9864,24 +10667,16 @@ public object FfiConverterTypeConverter: FfiConverter { // -public interface DatabaseInterface { +public interface DiagnosticsReportsTableInterface { - fun `dangerousResetAllData`() + fun `all`(): List - fun `globalConfig`(): GlobalConfigTable - - fun `globalFlag`(): GlobalFlagTable - - fun `historicalPrices`(): HistoricalPriceTable - - fun `unsignedTransactions`(): UnsignedTransactionsTable - - fun `wallets`(): WalletsTable + fun `clear`() companion object } -open class Database: Disposable, AutoCloseable, DatabaseInterface +open class DiagnosticsReportsTable: Disposable, AutoCloseable, DiagnosticsReportsTableInterface { @Suppress("UNUSED_PARAMETER") @@ -9905,14 +10700,6 @@ open class Database: Disposable, AutoCloseable, DatabaseInterface this.handle = 0 this.cleanable = null } - constructor() : - this(UniffiWithHandle, - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_constructor_database_new( - - _status) -} - ) protected val handle: Long protected val cleanable: UniffiCleaner.Cleanable? @@ -9973,7 +10760,7 @@ open class Database: Disposable, AutoCloseable, DatabaseInterface return; } uniffiRustCall { status -> - UniffiLib.uniffi_cove_fn_free_database(handle, status) + UniffiLib.uniffi_cove_fn_free_diagnosticsreportstable(handle, status) } } } @@ -9986,40 +10773,16 @@ open class Database: Disposable, AutoCloseable, DatabaseInterface throw InternalException("uniffiCloneHandle() called on NoHandle object"); } return uniffiRustCall() { status -> - UniffiLib.uniffi_cove_fn_clone_database(handle, status) + UniffiLib.uniffi_cove_fn_clone_diagnosticsreportstable(handle, status) } } - override fun `dangerousResetAllData`() - = - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_database_dangerous_reset_all_data( - it, - _status) -} - } - - - override fun `globalConfig`(): GlobalConfigTable { - return FfiConverterTypeGlobalConfigTable.lift( + @Throws(DatabaseException::class)override fun `all`(): List { + return FfiConverterSequenceTypeDiagnosticsReportRecord.lift( callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_database_global_config( - it, - _status) -} - } - ) - } - - - override fun `globalFlag`(): GlobalFlagTable { - return FfiConverterTypeGlobalFlagTable.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_database_global_flag( + uniffiRustCallWithError(DatabaseException) { _status -> + UniffiLib.uniffi_cove_fn_method_diagnosticsreportstable_all( it, _status) } @@ -10028,44 +10791,18 @@ open class Database: Disposable, AutoCloseable, DatabaseInterface } - override fun `historicalPrices`(): HistoricalPriceTable { - return FfiConverterTypeHistoricalPriceTable.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_database_historical_prices( - it, - _status) -} - } - ) - } - - override fun `unsignedTransactions`(): UnsignedTransactionsTable { - return FfiConverterTypeUnsignedTransactionsTable.lift( + @Throws(DatabaseException::class)override fun `clear`() + = callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_database_unsigned_transactions( + uniffiRustCallWithError(DatabaseException) { _status -> + UniffiLib.uniffi_cove_fn_method_diagnosticsreportstable_clear( it, _status) } } - ) - } - override fun `wallets`(): WalletsTable { - return FfiConverterTypeWalletsTable.lift( - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_database_wallets( - it, - _status) -} - } - ) - } - @@ -10085,22 +10822,22 @@ open class Database: Disposable, AutoCloseable, DatabaseInterface /** * @suppress */ -public object FfiConverterTypeDatabase: FfiConverter { - override fun lower(value: Database): Long { +public object FfiConverterTypeDiagnosticsReportsTable: FfiConverter { + override fun lower(value: DiagnosticsReportsTable): Long { return value.uniffiCloneHandle() } - override fun lift(value: Long): Database { - return Database(UniffiWithHandle, value) + override fun lift(value: Long): DiagnosticsReportsTable { + return DiagnosticsReportsTable(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): Database { + override fun read(buf: ByteBuffer): DiagnosticsReportsTable { return lift(buf.getLong()) } - override fun allocationSize(value: Database) = 8UL + override fun allocationSize(value: DiagnosticsReportsTable) = 8UL - override fun write(value: Database, buf: ByteBuffer) { + override fun write(value: DiagnosticsReportsTable, buf: ByteBuffer) { buf.putLong(lower(value)) } } @@ -29527,6 +30264,140 @@ public object FfiConverterTypeDeriveInfo: FfiConverterRustBuffer { +data class DiagnosticsPlatformInfo ( + var `platform`: kotlin.String + , + var `buildNumber`: kotlin.String + , + var `osVersion`: kotlin.String + , + var `deviceModel`: kotlin.String + +){ + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeDiagnosticsPlatformInfo: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): DiagnosticsPlatformInfo { + return DiagnosticsPlatformInfo( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterString.read(buf), + ) + } + + override fun allocationSize(value: DiagnosticsPlatformInfo) = ( + FfiConverterString.allocationSize(value.`platform`) + + FfiConverterString.allocationSize(value.`buildNumber`) + + FfiConverterString.allocationSize(value.`osVersion`) + + FfiConverterString.allocationSize(value.`deviceModel`) + ) + + override fun write(value: DiagnosticsPlatformInfo, buf: ByteBuffer) { + FfiConverterString.write(value.`platform`, buf) + FfiConverterString.write(value.`buildNumber`, buf) + FfiConverterString.write(value.`osVersion`, buf) + FfiConverterString.write(value.`deviceModel`, buf) + } +} + + + +data class DiagnosticsReportRecord ( + var `reportId`: kotlin.String + , + var `submittedAt`: kotlin.ULong + , + var `description`: kotlin.String? + +){ + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeDiagnosticsReportRecord: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): DiagnosticsReportRecord { + return DiagnosticsReportRecord( + FfiConverterString.read(buf), + FfiConverterULong.read(buf), + FfiConverterOptionalString.read(buf), + ) + } + + override fun allocationSize(value: DiagnosticsReportRecord) = ( + FfiConverterString.allocationSize(value.`reportId`) + + FfiConverterULong.allocationSize(value.`submittedAt`) + + FfiConverterOptionalString.allocationSize(value.`description`) + ) + + override fun write(value: DiagnosticsReportRecord, buf: ByteBuffer) { + FfiConverterString.write(value.`reportId`, buf) + FfiConverterULong.write(value.`submittedAt`, buf) + FfiConverterOptionalString.write(value.`description`, buf) + } +} + + + +data class DiagnosticsSubmission ( + var `reportId`: kotlin.String + , + var `historySaved`: kotlin.Boolean + , + var `warning`: kotlin.String? + +){ + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeDiagnosticsSubmission: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): DiagnosticsSubmission { + return DiagnosticsSubmission( + FfiConverterString.read(buf), + FfiConverterBoolean.read(buf), + FfiConverterOptionalString.read(buf), + ) + } + + override fun allocationSize(value: DiagnosticsSubmission) = ( + FfiConverterString.allocationSize(value.`reportId`) + + FfiConverterBoolean.allocationSize(value.`historySaved`) + + FfiConverterOptionalString.allocationSize(value.`warning`) + ) + + override fun write(value: DiagnosticsSubmission, buf: ByteBuffer) { + FfiConverterString.write(value.`reportId`, buf) + FfiConverterBoolean.write(value.`historySaved`, buf) + FfiConverterOptionalString.write(value.`warning`, buf) + } +} + + + data class FeeResponse ( var `fastestFee`: kotlin.Float , @@ -39419,6 +40290,14 @@ sealed class DatabaseException: kotlin.Exception() { get() = "v1=${ v1 }" } + class DiagnosticsReports( + + val v1: DiagnosticsReportsTableException + ) : DatabaseException() { + override val message + get() = "v1=${ v1 }" + } + class Serialization( val v1: SerdeException @@ -39551,31 +40430,34 @@ public object FfiConverterTypeDatabaseError : FfiConverterRustBuffer DatabaseException.HistoricalPrice( FfiConverterTypeHistoricalPriceTableError.read(buf), ) - 9 -> DatabaseException.Serialization( + 9 -> DatabaseException.DiagnosticsReports( + FfiConverterTypeDiagnosticsReportsTableError.read(buf), + ) + 10 -> DatabaseException.Serialization( FfiConverterTypeSerdeError.read(buf), ) - 10 -> DatabaseException.WalletNotFound() - 11 -> DatabaseException.EncryptionKeyNotSet() - 12 -> DatabaseException.BootstrapFailed( + 11 -> DatabaseException.WalletNotFound() + 12 -> DatabaseException.EncryptionKeyNotSet() + 13 -> DatabaseException.BootstrapFailed( FfiConverterString.read(buf), ) - 13 -> DatabaseException.BackendOpen( + 14 -> DatabaseException.BackendOpen( FfiConverterString.read(buf), FfiConverterString.read(buf), ) - 14 -> DatabaseException.CorruptBlock( + 15 -> DatabaseException.CorruptBlock( FfiConverterString.read(buf), FfiConverterString.read(buf), ) - 15 -> DatabaseException.DatabaseAlreadyOpen() - 16 -> DatabaseException.HeaderIntegrity( + 16 -> DatabaseException.DatabaseAlreadyOpen() + 17 -> DatabaseException.HeaderIntegrity( FfiConverterString.read(buf), FfiConverterString.read(buf), ) - 17 -> DatabaseException.UnsupportedVersion( + 18 -> DatabaseException.UnsupportedVersion( FfiConverterTypeUnsupportedDbVersion.read(buf), ) - 18 -> DatabaseException.PlaintextNotAllowed( + 19 -> DatabaseException.PlaintextNotAllowed( FfiConverterString.read(buf), ) else -> throw RuntimeException("invalid error enum value, something is very wrong!!") @@ -39624,6 +40506,11 @@ public object FfiConverterTypeDatabaseError : FfiConverterRustBuffer ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterTypeDiagnosticsReportsTableError.allocationSize(value.v1) + ) is DatabaseException.Serialization -> ( // Add the size for the Int that specifies the variant plus the size needed for all fields 4UL @@ -39719,53 +40606,58 @@ public object FfiConverterTypeDatabaseError : FfiConverterRustBuffer { + is DatabaseException.DiagnosticsReports -> { buf.putInt(9) + FfiConverterTypeDiagnosticsReportsTableError.write(value.v1, buf) + Unit + } + is DatabaseException.Serialization -> { + buf.putInt(10) FfiConverterTypeSerdeError.write(value.v1, buf) Unit } is DatabaseException.WalletNotFound -> { - buf.putInt(10) + buf.putInt(11) Unit } is DatabaseException.EncryptionKeyNotSet -> { - buf.putInt(11) + buf.putInt(12) Unit } is DatabaseException.BootstrapFailed -> { - buf.putInt(12) + buf.putInt(13) FfiConverterString.write(value.v1, buf) Unit } is DatabaseException.BackendOpen -> { - buf.putInt(13) + buf.putInt(14) FfiConverterString.write(value.`path`, buf) FfiConverterString.write(value.`error`, buf) Unit } is DatabaseException.CorruptBlock -> { - buf.putInt(14) + buf.putInt(15) FfiConverterString.write(value.`path`, buf) FfiConverterString.write(value.`error`, buf) Unit } is DatabaseException.DatabaseAlreadyOpen -> { - buf.putInt(15) + buf.putInt(16) Unit } is DatabaseException.HeaderIntegrity -> { - buf.putInt(16) + buf.putInt(17) FfiConverterString.write(value.`path`, buf) FfiConverterString.write(value.`error`, buf) Unit } is DatabaseException.UnsupportedVersion -> { - buf.putInt(17) + buf.putInt(18) FfiConverterTypeUnsupportedDbVersion.write(value.v1, buf) Unit } is DatabaseException.PlaintextNotAllowed -> { - buf.putInt(18) + buf.putInt(19) FfiConverterString.write(value.`path`, buf) Unit } @@ -40467,6 +41359,154 @@ public object FfiConverterTypeDescriptorError : FfiConverterRustBuffer { + override fun lift(error_buf: RustBuffer.ByValue): DiagnosticsException = FfiConverterTypeDiagnosticsError.lift(error_buf) + } +} + +/** + * @suppress + */ +public object FfiConverterTypeDiagnosticsError : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): DiagnosticsException { + + return when(buf.getInt()) { + 1 -> DiagnosticsException.Build(FfiConverterString.read(buf)) + 2 -> DiagnosticsException.ClearLogs(FfiConverterString.read(buf)) + 3 -> DiagnosticsException.Submit(FfiConverterString.read(buf)) + else -> throw RuntimeException("invalid error enum value, something is very wrong!!") + } + + } + + override fun allocationSize(value: DiagnosticsException): ULong { + return 4UL + } + + override fun write(value: DiagnosticsException, buf: ByteBuffer) { + when(value) { + is DiagnosticsException.Build -> { + buf.putInt(1) + Unit + } + is DiagnosticsException.ClearLogs -> { + buf.putInt(2) + Unit + } + is DiagnosticsException.Submit -> { + buf.putInt(3) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } + +} + + + + + +sealed class DiagnosticsReportsTableException: kotlin.Exception() { + + class Save( + + val v1: kotlin.String + ) : DiagnosticsReportsTableException() { + override val message + get() = "v1=${ v1 }" + } + + class Read( + + val v1: kotlin.String + ) : DiagnosticsReportsTableException() { + override val message + get() = "v1=${ v1 }" + } + + + + + // The local Rust `Display`/`Debug` implementation. + override fun toString(): String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_diagnosticsreportstableerror_uniffi_trait_display(FfiConverterTypeDiagnosticsReportsTableError.lower(this), + _status) +} + ) + } + + companion object ErrorHandler : UniffiRustCallStatusErrorHandler { + override fun lift(error_buf: RustBuffer.ByValue): DiagnosticsReportsTableException = FfiConverterTypeDiagnosticsReportsTableError.lift(error_buf) + } + + +} + +/** + * @suppress + */ +public object FfiConverterTypeDiagnosticsReportsTableError : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): DiagnosticsReportsTableException { + + + return when(buf.getInt()) { + 1 -> DiagnosticsReportsTableException.Save( + FfiConverterString.read(buf), + ) + 2 -> DiagnosticsReportsTableException.Read( + FfiConverterString.read(buf), + ) + else -> throw RuntimeException("invalid error enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: DiagnosticsReportsTableException): ULong { + return when(value) { + is DiagnosticsReportsTableException.Save -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is DiagnosticsReportsTableException.Read -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + } + } + + override fun write(value: DiagnosticsReportsTableException, buf: ByteBuffer) { + when(value) { + is DiagnosticsReportsTableException.Save -> { + buf.putInt(1) + FfiConverterString.write(value.v1, buf) + Unit + } + is DiagnosticsReportsTableException.Read -> { + buf.putInt(2) + FfiConverterString.write(value.v1, buf) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } + +} + + + sealed class DiscoveryState: Disposable { object Single : DiscoveryState() @@ -58601,6 +59641,34 @@ public object FfiConverterSequenceTypeCloudBackupWalletItem: FfiConverterRustBuf +/** + * @suppress + */ +public object FfiConverterSequenceTypeDiagnosticsReportRecord: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { + val len = buf.getInt() + return List(len) { + FfiConverterTypeDiagnosticsReportRecord.read(buf) + } + } + + override fun allocationSize(value: List): ULong { + val sizeForLength = 4UL + val sizeForItems = value.map { FfiConverterTypeDiagnosticsReportRecord.allocationSize(it) }.sum() + return sizeForLength + sizeForItems + } + + override fun write(value: List, buf: ByteBuffer) { + buf.putInt(value.size) + value.iterator().forEach { + FfiConverterTypeDiagnosticsReportRecord.write(it, buf) + } + } +} + + + + /** * @suppress */ @@ -59368,6 +60436,33 @@ object UrExceptionExternalErrorHandler : UniffiRustCallStatusErrorHandler UniffiLib.ffi_cove_rust_future_poll_u64(future, callback, continuation) }, + { future, continuation -> UniffiLib.ffi_cove_rust_future_complete_u64(future, continuation) }, + { future -> UniffiLib.ffi_cove_rust_future_free_u64(future) }, + // lift function + { FfiConverterTypeDiagnosticsReport.lift(it) }, + // Error FFI converter + DiagnosticsException.ErrorHandler, + ) + } + + @Throws(DiagnosticsException::class) fun `clearDiagnosticsLogs`() + = + uniffiRustCallWithError(DiagnosticsException) { _status -> + UniffiLib.uniffi_cove_fn_func_clear_diagnostics_logs( + + _status) +} + + fun `allFiatCurrencies`(): List { return FfiConverterSequenceTypeFiatCurrency.lift( uniffiRustCall() { _status -> diff --git a/android/app/src/test/java/org/bitcoinppl/cove/flows/SettingsFlow/LogcatProcessCollectorTest.kt b/android/app/src/test/java/org/bitcoinppl/cove/flows/SettingsFlow/LogcatProcessCollectorTest.kt new file mode 100644 index 000000000..e6d7f334d --- /dev/null +++ b/android/app/src/test/java/org/bitcoinppl/cove/flows/SettingsFlow/LogcatProcessCollectorTest.kt @@ -0,0 +1,313 @@ +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SettingsFlow + +import java.io.ByteArrayOutputStream +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.time.Duration +import java.util.concurrent.CountDownLatch +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.system.measureTimeMillis +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class LogcatProcessCollectorTest { + @Test + fun drainsStdoutAndStderrConcurrently() { + val streamsDrained = CountDownLatch(2) + val readsStarted = CyclicBarrier(2) + val process = + ControllableProcess( + stdout = CoordinatedInputStream("stdout logs\n", readsStarted, streamsDrained), + stderr = CoordinatedInputStream("stderr logs\n", readsStarted, streamsDrained), + completion = streamsDrained, + ) + val collector = + LogcatProcessCollector( + timeout = Duration.ofSeconds(1), + terminationGracePeriod = Duration.ofMillis(100), + ) + + val output = collector.collect(process) + + assertEquals("stdout logs\nlogcat stderr:\nstderr logs", output) + assertFalse(process.destroyCalled) + } + + @Test + fun drainFailurePreservesPartialOutputFromCompletedProcess() { + val process = + ControllableProcess( + stdout = PartiallyFailingInputStream("partial stdout\n"), + stderr = ByteArrayInputStream("stderr logs\n".toByteArray()), + completion = CountDownLatch(0), + ) + val collector = + LogcatProcessCollector( + timeout = Duration.ofSeconds(1), + terminationGracePeriod = Duration.ofMillis(100), + ) + + val output = collector.collect(process) + + assertEquals("partial stdout\nlogcat stderr:\nstderr logs", output) + } + + @Test + fun interruptionWhileAwaitingDrainStillPropagates() { + val stdout = BlockingInputStream() + val stderr = BlockingInputStream() + val process = + ControllableProcess( + stdout = stdout, + stderr = stderr, + completion = CountDownLatch(0), + ) + val collector = + LogcatProcessCollector( + timeout = Duration.ofSeconds(1), + terminationGracePeriod = Duration.ofSeconds(5), + ) + val failure = AtomicReference() + val collectionThread = + Thread { + try { + collector.collect(process) + } catch (error: Throwable) { + failure.set(error) + } + } + + collectionThread.start() + assertTrue(stdout.awaitReadStarted()) + assertTrue(stderr.awaitReadStarted()) + collectionThread.interrupt() + collectionThread.join(1_000) + + assertFalse("collector thread is still running", collectionThread.isAlive) + assertTrue(failure.get() is InterruptedException) + assertTrue(stdout.closed) + assertTrue(stderr.closed) + } + + @Test + fun timeoutReturnsWithoutWaitingForProcessOrPipesToFinish() { + val stdout = BlockingInputStream() + val stderr = BlockingInputStream() + val process = + ControllableProcess( + stdout = stdout, + stderr = stderr, + completion = CountDownLatch(1), + ) + val collector = + LogcatProcessCollector( + timeout = Duration.ofMillis(50), + terminationGracePeriod = Duration.ofMillis(20), + ) + lateinit var output: String + + val elapsedMillis = measureTimeMillis { output = collector.collect(process) } + + assertTrue(output.contains("logcat timed out after 50 ms")) + assertTrue(output.contains("process could not be terminated")) + assertTrue(process.destroyCalled) + assertTrue(process.destroyForciblyCalled) + assertTrue(stdout.closed) + assertTrue(stderr.closed) + assertTrue("collector took ${elapsedMillis}ms", elapsedMillis < 1_000) + } + + @Test + fun interruptionTerminatesProcessWithoutMaskingOriginalException() { + val stdout = BlockingInputStream() + val stderr = BlockingInputStream() + val interruption = InterruptedException("interrupted while waiting for logcat") + val process = + ControllableProcess( + stdout = stdout, + stderr = stderr, + completion = CountDownLatch(1), + firstTimedWaitFailure = interruption, + ) + val collector = + LogcatProcessCollector( + timeout = Duration.ofSeconds(1), + terminationGracePeriod = Duration.ofMillis(20), + ) + lateinit var thrown: InterruptedException + + val elapsedMillis = + measureTimeMillis { + thrown = assertThrows(InterruptedException::class.java) { collector.collect(process) } + } + + assertSame(interruption, thrown) + assertTrue(process.destroyCalled) + assertTrue(process.destroyForciblyCalled) + assertTrue(stdout.closed) + assertTrue(stderr.closed) + assertTrue("collector took ${elapsedMillis}ms", elapsedMillis < 1_000) + } +} + +private class ControllableProcess( + private val stdout: InputStream, + private val stderr: InputStream, + private val completion: CountDownLatch, + private val exitCode: Int = 0, + private val firstTimedWaitFailure: InterruptedException? = null, +) : Process() { + private val stdin = ByteArrayOutputStream() + private var timedWaitCount = 0 + var destroyCalled = false + private set + var destroyForciblyCalled = false + private set + + override fun getOutputStream(): OutputStream = stdin + + override fun getInputStream(): InputStream = stdout + + override fun getErrorStream(): InputStream = stderr + + override fun waitFor(): Int { + completion.await() + + return exitCode + } + + override fun waitFor( + timeout: Long, + unit: TimeUnit, + ): Boolean { + timedWaitCount += 1 + if (timedWaitCount == 1 && firstTimedWaitFailure != null) throw firstTimedWaitFailure + + return completion.await(timeout, unit) + } + + override fun exitValue(): Int { + if (isAlive) throw IllegalThreadStateException("process is still running") + + return exitCode + } + + override fun destroy() { + destroyCalled = true + } + + override fun destroyForcibly(): Process { + destroyForciblyCalled = true + + return this + } + + override fun isAlive(): Boolean = completion.count > 0 +} + +private class CoordinatedInputStream( + content: String, + private val readsStarted: CyclicBarrier, + private val drained: CountDownLatch, +) : InputStream() { + private val bytes = content.toByteArray() + private var position = 0 + private var started = false + private var finished = false + + override fun read(): Int { + val buffer = ByteArray(1) + val read = read(buffer, 0, 1) + + return if (read < 0) -1 else buffer[0].toInt() and 0xff + } + + override fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { + if (!started) { + started = true + + try { + readsStarted.await(1, TimeUnit.SECONDS) + } catch (error: Exception) { + throw IOException("streams were not drained concurrently", error) + } + } + + if (position == bytes.size) { + if (!finished) { + finished = true + drained.countDown() + } + + return -1 + } + + val copied = minOf(length, bytes.size - position) + bytes.copyInto(buffer, offset, position, position + copied) + position += copied + + return copied + } +} + +private class BlockingInputStream : InputStream() { + private val closedLatch = CountDownLatch(1) + private val readStarted = CountDownLatch(1) + var closed = false + private set + + override fun read(): Int { + readStarted.countDown() + closedLatch.await() + + return -1 + } + + override fun close() { + closed = true + closedLatch.countDown() + } + + fun awaitReadStarted(): Boolean = readStarted.await(1, TimeUnit.SECONDS) +} + +private class PartiallyFailingInputStream( + content: String, +) : InputStream() { + private val bytes = content.toByteArray() + private var position = 0 + + override fun read(): Int { + if (position == bytes.size) throw IOException("drain failed") + + return bytes[position++].toInt() and 0xff + } + + override fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { + if (position == bytes.size) throw IOException("drain failed") + + val copied = minOf(length, bytes.size - position) + bytes.copyInto(buffer, offset, position, position + copied) + position += copied + + return copied + } +} diff --git a/android/app/src/test/java/org/bitcoinppl/cove/flows/SettingsFlow/SendDiagnosticsSheetHelpersTest.kt b/android/app/src/test/java/org/bitcoinppl/cove/flows/SettingsFlow/SendDiagnosticsSheetHelpersTest.kt new file mode 100644 index 000000000..a09a7a7fa --- /dev/null +++ b/android/app/src/test/java/org/bitcoinppl/cove/flows/SettingsFlow/SendDiagnosticsSheetHelpersTest.kt @@ -0,0 +1,83 @@ +@file:Suppress("PackageNaming") + +package org.bitcoinppl.cove.flows.SettingsFlow + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SendDiagnosticsSheetHelpersTest { + @Test + fun logTailDropsPartialLeadingRedactionToken() { + val tail = "prefix xprvSECRET suffix".takeLastAtRedactionBoundary(13) + + assertEquals(" suffix", tail) + } + + @Test + fun logTailDropsPartialUnicodeMnemonicWord() { + val tail = "prefix a\u0301baco suffix".takeLastAtRedactionBoundary(12) + + assertEquals(" suffix", tail) + } + + @Test + fun logTailKeepsShortValuesUnchanged() { + val value = "short log" + + assertEquals(value, value.takeLastAtRedactionBoundary(100)) + } + + @Test + fun generationTrackerInvalidatesOlderTokens() { + val tracker = DiagnosticsGenerationTracker() + + val first = tracker.advance() + val second = tracker.advance() + + assertFalse(tracker.isCurrent(first)) + assertTrue(tracker.isCurrent(second)) + } + + @Test + fun generationTrackerInvalidateClearsCurrentToken() { + val tracker = DiagnosticsGenerationTracker() + val token = tracker.advance() + + tracker.invalidate() + + assertFalse(tracker.isCurrent(token)) + } + + @Test + fun submittedDiagnosticsLoadFailureIsNotEmptyHistory() = + runTest { + val state = + loadSubmittedDiagnosticsRecords( + ioDispatcher = StandardTestDispatcher(testScheduler), + loadRecords = { + Result.failure(IllegalStateException("history corrupt")) + }, + logFailure = { _ -> }, + ) + + assertTrue(state is SubmittedDiagnosticsLoadState.Failed) + assertEquals("history corrupt", (state as SubmittedDiagnosticsLoadState.Failed).message) + } + + @Test(expected = CancellationException::class) + fun submittedDiagnosticsLoadRethrowsCancellation() = + runTest { + loadSubmittedDiagnosticsRecords( + ioDispatcher = StandardTestDispatcher(testScheduler), + loadRecords = { + Result.failure(CancellationException("cancelled")) + }, + logFailure = { _ -> }, + ) + } +} diff --git a/ios/Cove.xcodeproj/project.pbxproj b/ios/Cove.xcodeproj/project.pbxproj index f3182680a..02a3bf886 100644 --- a/ios/Cove.xcodeproj/project.pbxproj +++ b/ios/Cove.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ C0B000192F10000000000001 /* AmountFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B000182F10000000000001 /* AmountFormatterTests.swift */; }; C0B0001B2F10000000000001 /* NavigationCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B0001A2F10000000000001 /* NavigationCoordinatorTests.swift */; }; C0B0001C2F10000000000001 /* HotWalletCreateScreenLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B0001D2F10000000000001 /* HotWalletCreateScreenLayoutTests.swift */; }; + C0B0001F2F10000000000001 /* SwiftLogStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B0001E2F10000000000001 /* SwiftLogStoreTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -70,6 +71,7 @@ C0B000182F10000000000001 /* AmountFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmountFormatterTests.swift; sourceTree = ""; }; C0B0001A2F10000000000001 /* NavigationCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationCoordinatorTests.swift; sourceTree = ""; }; C0B0001D2F10000000000001 /* HotWalletCreateScreenLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotWalletCreateScreenLayoutTests.swift; sourceTree = ""; }; + C0B0001E2F10000000000001 /* SwiftLogStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftLogStoreTests.swift; sourceTree = ""; }; D0245F4C2C0F7B4E0042B447 /* Cove.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cove.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -136,6 +138,7 @@ C0B0001A2F10000000000001 /* NavigationCoordinatorTests.swift */, C0B000152F00000000000001 /* OnboardingBackupViewsTests.swift */, C0B000032F00000000000001 /* OnboardingPromptLayoutTests.swift */, + C0B0001E2F10000000000001 /* SwiftLogStoreTests.swift */, C0B000132F00000000000001 /* TransactionsScanUiTests.swift */, C0B000042F00000000000001 /* Fixtures/wallet_2_descriptors.txt */, ); @@ -326,6 +329,7 @@ C0B0001B2F10000000000001 /* NavigationCoordinatorTests.swift in Sources */, C0B000142F00000000000001 /* OnboardingBackupViewsTests.swift in Sources */, C0B000012F00000000000001 /* OnboardingPromptLayoutTests.swift in Sources */, + C0B0001F2F10000000000001 /* SwiftLogStoreTests.swift in Sources */, C0B000122F00000000000001 /* TransactionsScanUiTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/Cove/Flows/SettingsFlow/AboutScreen.swift b/ios/Cove/Flows/SettingsFlow/AboutScreen.swift index 6f6aba012..1ba2d611d 100644 --- a/ios/Cove/Flows/SettingsFlow/AboutScreen.swift +++ b/ios/Cove/Flows/SettingsFlow/AboutScreen.swift @@ -18,12 +18,16 @@ struct WipeCloudResult: Equatable { struct AboutScreen: View { @Environment(AppManager.self) private var app + @Environment(AuthManager.self) private var auth @Environment(\.dismiss) private var dismiss @State private var buildTapCount = 0 @State private var buildTapTimer: Timer? = nil @State private var isBetaEnabled = Database().globalFlag().getBoolConfig(key: .betaFeaturesEnabled) @State private var alertState: TaggedItem? = nil + @State private var isSendDiagnosticsPresented = false + @State private var isSubmittedDiagnosticsPresented = false + @State private var submittedDiagnosticsLoadState: SubmittedDiagnosticsLoadState = .loaded([]) private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" @@ -105,6 +109,32 @@ struct AboutScreen: View { .font(.footnote) } } + + if !auth.isInDecoyMode() { + Button { + isSendDiagnosticsPresented = true + } label: { + HStack { + Text("Send Diagnostics") + .foregroundStyle(.primary) + } + } + + if submittedDiagnosticsLoadState.shouldShowSubmittedDiagnostics { + Button { + isSubmittedDiagnosticsPresented = true + } label: { + HStack { + Text("Submitted Diagnostics") + .foregroundStyle(.primary) + Spacer() + Text(submittedDiagnosticsLoadState.submittedDiagnosticsSummary) + .foregroundStyle(.secondary) + .font(.footnote) + } + } + } + } } #if DEBUG @@ -124,10 +154,47 @@ struct AboutScreen: View { #endif } .navigationTitle("About") + .task { refreshSubmittedDiagnostics() } + .onChange(of: isSendDiagnosticsPresented) { _, isPresented in + guard !isPresented else { return } + + refreshSubmittedDiagnostics() + } + .onChange(of: auth.isInDecoyMode()) { _, _ in + refreshSubmittedDiagnostics() + } .onDisappear { buildTapTimer?.invalidate(); buildTapTimer = nil } + .sheet(isPresented: $isSendDiagnosticsPresented) { + SendDiagnosticsSheet() + } + .sheet(isPresented: $isSubmittedDiagnosticsPresented, onDismiss: refreshSubmittedDiagnostics) { + SubmittedDiagnosticsSheet( + loadState: submittedDiagnosticsLoadState, + onRecordsChanged: refreshSubmittedDiagnostics + ) + } .presentingAlert($alertState, context: presentationContext, defaultTitle: "Error") } + private func refreshSubmittedDiagnostics() { + guard !auth.isInDecoyMode() else { + submittedDiagnosticsLoadState = .loaded([]) + isSubmittedDiagnosticsPresented = false + return + } + + Task { + let loadState = await loadSubmittedDiagnosticsHistory() + guard !auth.isInDecoyMode() else { + submittedDiagnosticsLoadState = .loaded([]) + isSubmittedDiagnosticsPresented = false + return + } + + submittedDiagnosticsLoadState = loadState + } + } + private nonisolated static func debugWipeCloudBackup() -> WipeCloudResult { let helper = ICloudDriveHelper.shared @@ -152,5 +219,6 @@ struct AboutScreen: View { NavigationStack { AboutScreen() .environment(AppManager.shared) + .environment(AuthManager.shared) } } diff --git a/ios/Cove/Flows/SettingsFlow/BackupExportView.swift b/ios/Cove/Flows/SettingsFlow/BackupExportView.swift index a20386309..6d8e81c1b 100644 --- a/ios/Cove/Flows/SettingsFlow/BackupExportView.swift +++ b/ios/Cove/Flows/SettingsFlow/BackupExportView.swift @@ -157,7 +157,7 @@ struct BackupExportView: View { do { try FileManager.default.removeItem(at: url) } catch { - print("Warning: failed to delete temp backup file: \(error.localizedDescription)") + Log.warn("Failed to delete temp backup file: \(error.localizedDescription)") } tempFileURL = nil } @@ -363,7 +363,7 @@ struct BackupExportView: View { do { try FileManager.default.removeItem(at: fileURL) } catch { - print("Warning: failed to delete temp backup file: \(error.localizedDescription)") + Log.warn("Failed to delete temp backup file: \(error.localizedDescription)") } tempFileURL = nil diff --git a/ios/Cove/Flows/SettingsFlow/SendDiagnosticsSheet.swift b/ios/Cove/Flows/SettingsFlow/SendDiagnosticsSheet.swift new file mode 100644 index 000000000..9c921126f --- /dev/null +++ b/ios/Cove/Flows/SettingsFlow/SendDiagnosticsSheet.swift @@ -0,0 +1,415 @@ +import SwiftUI +import UIKit + +private let diagnosticsFilename = "cove-diagnostics.txt" +private let previewChunkSize = 4096 +private let previewRefreshDelayNanoseconds: UInt64 = 300_000_000 + +private struct DiagnosticsPreviewChunk: Identifiable { + let id: Int + let text: String +} + +private enum DiagnosticsLoadState: Equatable { + case loading + case ready + case failed(String) +} + +private enum SendDiagnosticsAlert: Identifiable, Equatable { + case confirmClear + case error(String) + + var id: String { + switch self { + case .confirmClear: + "confirm-clear" + case let .error(message): + "error-\(message)" + } + } +} + +struct SendDiagnosticsSheet: View { + @Environment(\.dismiss) private var dismiss + + @State private var report: DiagnosticsReport? = nil + @State private var previewText = "" + @State private var previewChunks: [DiagnosticsPreviewChunk] = [] + @State private var description = "" + @State private var reportSize = "" + @State private var reportId: String? = nil + @State private var submissionWarning: String? = nil + @State private var loadState = DiagnosticsLoadState.loading + @State private var isSubmitting = false + @State private var alertState: SendDiagnosticsAlert? = nil + @State private var previewRefreshTask: Task? = nil + + private var isReady: Bool { + switch loadState { + case .ready: + true + case .loading, .failed: + false + } + } + + private var exportText: String { + report?.previewTextForDescription(description: description) ?? previewText + } + + var body: some View { + NavigationStack { + Group { + switch loadState { + case .loading: + ProgressView("Building diagnostics...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case let .failed(message): + VStack(spacing: 16) { + Text("Diagnostics Unavailable") + .font(.headline) + + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button("Retry") { + Task { await rebuildReport(clearStoredLogs: false) } + } + .buttonStyle(.borderedProminent) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .ready: + readyContent + } + } + .navigationTitle("Send Diagnostics") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + guard !isSubmitting else { return } + + dismiss() + } + .disabled(isSubmitting) + } + } + } + .task { + if report == nil { + await rebuildReport(clearStoredLogs: false) + } + } + .onChange(of: description) { _, _ in + schedulePreviewRefresh() + } + .onDisappear { + previewRefreshTask?.cancel() + } + .alert(item: $alertState) { alert in + switch alert { + case .confirmClear: + Alert( + title: Text("Clear Stored Logs?"), + message: Text("This deletes stored diagnostics logs on this device and rebuilds the preview."), + primaryButton: .destructive(Text("Clear")) { + Task { await rebuildReport(clearStoredLogs: true) } + }, + secondaryButton: .cancel() + ) + + case let .error(message): + Alert( + title: Text("Something went wrong"), + message: Text(message), + dismissButton: .default(Text("OK")) + ) + } + } + .interactiveDismissDisabled(isSubmitting) + } + + private var readyContent: some View { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 8) { + Text("Description") + .font(.headline) + + TextEditor(text: $description) + .frame(minHeight: 84, maxHeight: 120) + .padding(8) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + HStack { + Text("Preview") + .font(.headline) + + Spacer() + + if !reportSize.isEmpty { + Text(reportSize) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(previewChunks) { chunk in + Text(chunk.text) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(12) + } + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + if let reportId { + VStack(alignment: .leading, spacing: 8) { + Text("Diagnostics sent") + .font(.headline) + + Text(reportId) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + + if let submissionWarning { + Text(submissionWarning) + .font(.footnote) + .foregroundStyle(.red) + } + + HStack { + Button("Copy ID") { + UIPasteboard.general.string = reportId + } + .buttonStyle(.bordered) + + Button("Done") { + dismiss() + } + .buttonStyle(.borderedProminent) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + HStack(spacing: 12) { + Button("Share") { + shareDiagnostics() + } + .buttonStyle(.bordered) + .disabled(!isReady || isSubmitting) + + Button("Clear Stored Logs", role: .destructive) { + alertState = .confirmClear + } + .buttonStyle(.bordered) + .disabled(isSubmitting) + } + + Button { + Task { await submitReport() } + } label: { + if isSubmitting { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text("Submit") + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(report == nil || isSubmitting || reportId != nil) + } + .padding() + } + + @MainActor + private func rebuildReport(clearStoredLogs: Bool) async { + loadState = .loading + reportId = nil + submissionWarning = nil + report = nil + previewRefreshTask?.cancel() + previewRefreshTask = nil + previewText = "" + previewChunks = [] + reportSize = "" + + do { + if clearStoredLogs { + try clearDiagnosticsLogs() + try SwiftLogStore.shared.clear() + } + + let nextReport = try await buildDiagnosticsReport( + platform: IOSDiagnostics.platformInfo(), + platformLogs: IOSDiagnostics.platformLogs() + ) + + report = nextReport + refreshPreview(report: nextReport) + loadState = .ready + } catch { + loadState = .failed(diagnosticsErrorMessage(error)) + } + } + + @MainActor + private func submitReport() async { + guard let report else { return } + + isSubmitting = true + defer { isSubmitting = false } + + do { + let submission = try await report.submit(description: description) + reportId = submission.reportId + submissionWarning = submission.warning + } catch { + alertState = .error(diagnosticsErrorMessage(error)) + } + } + + @MainActor + private func shareDiagnostics() { + ShareSheet.present(data: exportText, filename: diagnosticsFilename) { success in + if !success { Log.warn("Diagnostics share cancelled or failed") } + } + } + + @MainActor + private func refreshPreviewForCurrentDescription() { + guard let report else { return } + + refreshPreview(report: report) + } + + @MainActor + private func schedulePreviewRefresh() { + previewRefreshTask?.cancel() + previewRefreshTask = Task { + try? await Task.sleep(nanoseconds: previewRefreshDelayNanoseconds) + guard !Task.isCancelled else { return } + + await MainActor.run { + refreshPreviewForCurrentDescription() + } + } + } + + @MainActor + private func refreshPreview(report: DiagnosticsReport) { + let nextPreviewText = report.previewTextForDescription(description: description) + + previewText = nextPreviewText + previewChunks = Self.chunks(for: nextPreviewText) + reportSize = report.formattedSizeForDescription(description: description) + } + + private static func chunks(for text: String) -> [DiagnosticsPreviewChunk] { + var chunks: [DiagnosticsPreviewChunk] = [] + var start = text.startIndex + var chunkId = 0 + + while start < text.endIndex { + let end = text.index(start, offsetBy: previewChunkSize, limitedBy: text.endIndex) ?? text.endIndex + chunks.append(DiagnosticsPreviewChunk(id: chunkId, text: String(text[start ..< end]))) + start = end + chunkId += 1 + } + + return chunks + } +} + +private func diagnosticsErrorMessage(_ error: Error) -> String { + guard let diagnosticsError = error as? DiagnosticsError else { + return error.localizedDescription + } + + switch diagnosticsError { + case let .Build(message): + return message + case let .ClearLogs(message): + return message + case let .Submit(message): + return message + } +} + +private enum IOSDiagnostics { + static func platformInfo() -> DiagnosticsPlatformInfo { + DiagnosticsPlatformInfo( + platform: "iOS", + buildNumber: bundleValue("CFBundleVersion"), + osVersion: UIDevice.current.systemVersion, + deviceModel: deviceModelIdentifier() + ) + } + + static func platformLogs() -> String { + let swiftLogs = SwiftLogStore.shared.snapshot() + + return [ + "iOS system logs are unavailable to sandboxed apps; app-recorded Swift logs are below.", + "Generated: \(ISO8601DateFormatter().string(from: Date()))", + "App version: \(bundleValue("CFBundleShortVersionString"))", + "Build: \(bundleValue("CFBundleVersion"))", + "iOS: \(UIDevice.current.systemVersion)", + "Device: \(deviceModelIdentifier())", + "Low power mode: \(ProcessInfo.processInfo.isLowPowerModeEnabled)", + "Thermal state: \(thermalStateDescription(ProcessInfo.processInfo.thermalState))", + "", + "Swift app logs", + "--------------", + swiftLogs, + ].joined(separator: "\n") + } + + private static func bundleValue(_ key: String) -> String { + Bundle.main.object(forInfoDictionaryKey: key) as? String ?? "unknown" + } + + private static func deviceModelIdentifier() -> String { + var systemInfo = utsname() + uname(&systemInfo) + + let mirror = Mirror(reflecting: systemInfo.machine) + return mirror.children.reduce(into: "") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return } + identifier.append(String(UnicodeScalar(UInt8(value)))) + } + } + + private static func thermalStateDescription(_ state: ProcessInfo.ThermalState) -> String { + switch state { + case .nominal: + "nominal" + case .fair: + "fair" + case .serious: + "serious" + case .critical: + "critical" + @unknown default: + "unknown" + } + } +} diff --git a/ios/Cove/Flows/SettingsFlow/SubmittedDiagnosticsSheet.swift b/ios/Cove/Flows/SettingsFlow/SubmittedDiagnosticsSheet.swift new file mode 100644 index 000000000..964d5c4a1 --- /dev/null +++ b/ios/Cove/Flows/SettingsFlow/SubmittedDiagnosticsSheet.swift @@ -0,0 +1,253 @@ +import SwiftUI +import UIKit + +enum SubmittedDiagnosticsLoadState: Equatable { + case loading + case loaded([DiagnosticsReportRecord]) + case failed(String) + + var records: [DiagnosticsReportRecord] { + switch self { + case .loading, .failed: + [] + case let .loaded(records): + records + } + } + + var canClear: Bool { + switch self { + case .loading: + false + case let .loaded(records): + !records.isEmpty + case .failed: + true + } + } + + var shouldShowSubmittedDiagnostics: Bool { + canClear + } + + var submittedDiagnosticsSummary: String { + switch self { + case .loading: + "Loading" + case let .loaded(records): + records.count == 1 ? "1 report" : "\(records.count) reports" + case .failed: + "Unavailable" + } + } +} + +func loadSubmittedDiagnosticsHistory() async -> SubmittedDiagnosticsLoadState { + do { + let records = try await Task.detached { + try Database().diagnosticsReports().all() + }.value + + return .loaded(records) + } catch { + Log.warn("Failed to load submitted diagnostics: \(error.localizedDescription)") + + return .failed(error.localizedDescription) + } +} + +private enum SubmittedDiagnosticsAlert: Identifiable, Equatable { + case confirmClear + case error(String) + + var id: String { + switch self { + case .confirmClear: + "confirm-clear" + case let .error(message): + "error-\(message)" + } + } +} + +struct SubmittedDiagnosticsSheet: View { + @Environment(\.dismiss) private var dismiss + + @State private var loadState: SubmittedDiagnosticsLoadState + @State private var alertState: SubmittedDiagnosticsAlert? = nil + + let onRecordsChanged: () -> Void + + init(loadState: SubmittedDiagnosticsLoadState, onRecordsChanged: @escaping () -> Void) { + _loadState = State(initialValue: loadState) + self.onRecordsChanged = onRecordsChanged + } + + var body: some View { + NavigationStack { + Group { + switch loadState { + case .loading: + ProgressView("Loading submitted diagnostics...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case let .failed(message): + VStack(spacing: 12) { + Text("Submitted diagnostics unavailable") + .font(.headline) + + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button("Retry") { + reloadHistory() + } + .buttonStyle(.borderedProminent) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case let .loaded(records) where records.isEmpty: + VStack(spacing: 8) { + Text("No submitted diagnostics") + .font(.headline) + Text("Submitted report IDs will appear here.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case let .loaded(records): + List(records, id: \.reportId) { record in + SubmittedDiagnosticsRow(record: record) + } + } + } + .navigationTitle("Submitted Diagnostics") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + + ToolbarItem(placement: .primaryAction) { + Button("Clear", role: .destructive) { + alertState = .confirmClear + } + .disabled(!loadState.canClear) + } + } + } + .alert(item: $alertState) { alert in + switch alert { + case .confirmClear: + Alert( + title: Text("Clear Submitted Diagnostics?"), + message: Text("This removes saved report IDs from this device."), + primaryButton: .destructive(Text("Clear")) { + clearHistory() + }, + secondaryButton: .cancel() + ) + + case let .error(message): + Alert( + title: Text("Something went wrong"), + message: Text(message), + dismissButton: .default(Text("OK")) + ) + } + } + } + + private func clearHistory() { + Task { + do { + try await Self.clearStoredHistory() + loadState = .loaded([]) + onRecordsChanged() + } catch { + alertState = .error(error.localizedDescription) + } + } + } + + private func reloadHistory() { + loadState = .loading + + Task { + loadState = await loadSubmittedDiagnosticsHistory() + onRecordsChanged() + } + } + + private nonisolated static func clearStoredHistory() async throws { + try await Task.detached { + try Database().diagnosticsReports().clear() + }.value + } +} + +private struct SubmittedDiagnosticsRow: View { + let record: DiagnosticsReportRecord + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(record.reportId) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + + Spacer() + + Button { + UIPasteboard.general.string = record.reportId + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + .accessibilityLabel("Copy Report ID") + } + + Text(Self.formattedDate(record.submittedAt)) + .font(.footnote) + .foregroundStyle(.secondary) + + if let description = record.description, !description.isEmpty { + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .padding(.vertical, 4) + } + + private static func formattedDate(_ timestamp: UInt64) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) + + return date.formatted(date: .abbreviated, time: .shortened) + } +} + +#Preview { + SubmittedDiagnosticsSheet( + loadState: .loaded( + [ + DiagnosticsReportRecord( + reportId: "diag_01JZV1ABCDEF", + submittedAt: 1_783_529_280, + description: "App froze after scanning a QR code on the send screen." + ), + DiagnosticsReportRecord( + reportId: "diag_01JZV1XYZ123", + submittedAt: 1_783_532_400, + description: nil + ), + ] + ), + onRecordsChanged: {} + ) +} diff --git a/ios/Cove/SwiftLogStore.swift b/ios/Cove/SwiftLogStore.swift new file mode 100644 index 000000000..54c1939ea --- /dev/null +++ b/ios/Cove/SwiftLogStore.swift @@ -0,0 +1,204 @@ +import CoveCore +import Foundation + +final class SwiftLogStore { + static let shared = SwiftLogStore( + logsDirectory: URL(fileURLWithPath: rootDataDirPath(), isDirectory: true) + .appendingPathComponent("logs", isDirectory: true) + ) + + private static let logFileBytes = 256 * 1024 + private static let maxTotalFileBytes = 2 * 1024 * 1024 + private static let archiveFileCount = (maxTotalFileBytes / logFileBytes) - 1 + private static let currentLogFile = "cove-swift.log" + private static let fallbackText = "no Swift logs captured\n" + + private let logsDirectory: URL + private let fileManager: FileManager + private let queue: DispatchQueue + private var currentSize: Int? + private var lastWriteError: String? + + init( + logsDirectory: URL, + fileManager: FileManager = .default, + queueLabel: String = "org.bitcoinppl.cove.swift-log-store" + ) { + self.logsDirectory = logsDirectory + self.fileManager = fileManager + queue = DispatchQueue(label: queueLabel) + } + + func record(level: LogLevel, category: String, message: String) { + let line = Self.line(level: level, category: category, message: message) + + queue.async { + do { + try self.writeEntry(line) + } catch { + self.lastWriteError = error.localizedDescription + } + } + } + + func snapshot() -> String { + queue.sync { + var text = "" + if let lastWriteError { + text.append("failed to write Swift diagnostics log file: \(lastWriteError)\n") + } + + text += logFileURLs().reduce(into: "") { snapshot, url in + guard fileManager.fileExists(atPath: url.path) else { return } + + do { + try snapshot.append(String(contentsOf: url, encoding: .utf8)) + } catch { + snapshot.append("failed to read Swift diagnostics log file: \(error)\n") + } + } + + return text.isEmpty ? Self.fallbackText : text + } + } + + func clear() throws { + try queue.sync { + currentSize = nil + + do { + for url in allLogFileURLs() { + try removeFileIfExists(url) + } + + lastWriteError = nil + try writeEntry("swift diagnostics logs cleared at \(Self.timestamp())\n") + } catch { + lastWriteError = error.localizedDescription + + throw error + } + } + } + + private static func line(level: LogLevel, category: String, message: String) -> String { + "\(timestamp()) \(level.rawValue.uppercased()) \(category): \(sanitized(message))\n" + } + + private static func timestamp() -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + return formatter.string(from: Date()) + } + + private static func sanitized(_ message: String) -> String { + message + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + } + + private func writeEntry(_ entry: String) throws { + try fileManager.createDirectory( + at: logsDirectory, + withIntermediateDirectories: true + ) + + let entry = Self.entryForFile(entry) + let data = Data(entry.utf8) + + if currentSize == nil { + currentSize = fileSize(currentLogURL()) + } + + if let size = currentSize, size > 0, size + data.count > Self.logFileBytes { + try rotate() + } + + if !fileManager.fileExists(atPath: currentLogURL().path) { + fileManager.createFile( + atPath: currentLogURL().path, + contents: nil, + attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] + ) + currentSize = 0 + } + + let handle = try FileHandle(forWritingTo: currentLogURL()) + defer { try? handle.close() } + + try handle.seekToEnd() + try handle.write(contentsOf: data) + currentSize = (currentSize ?? 0) + data.count + } + + private static func entryForFile(_ entry: String) -> String { + guard entry.utf8.count > logFileBytes else { return entry } + + return lastBytesAtTokenBoundary(entry, maxBytes: logFileBytes) + } + + private static func lastBytesAtTokenBoundary(_ value: String, maxBytes: Int) -> String { + var bytes = Array(value.utf8.suffix(maxBytes)) + + while !bytes.isEmpty, String(bytes: bytes, encoding: .utf8) == nil { + bytes.removeFirst() + } + + guard var text = String(bytes: bytes, encoding: .utf8) else { return "" } + + while let first = text.unicodeScalars.first, first.isASCII, CharacterSet.alphanumerics.contains(first) { + text.removeFirst() + } + + return text + } + + private func rotate() throws { + try removeFileIfExists(archivedLogURL(Self.archiveFileCount)) + + for index in stride(from: Self.archiveFileCount - 1, through: 1, by: -1) { + try renameIfExists(from: archivedLogURL(index), to: archivedLogURL(index + 1)) + } + + try renameIfExists(from: currentLogURL(), to: archivedLogURL(1)) + currentSize = 0 + } + + private func renameIfExists(from source: URL, to destination: URL) throws { + guard fileManager.fileExists(atPath: source.path) else { return } + + try fileManager.moveItem(at: source, to: destination) + } + + private func removeFileIfExists(_ url: URL) throws { + guard fileManager.fileExists(atPath: url.path) else { return } + + try fileManager.removeItem(at: url) + } + + private func fileSize(_ url: URL) -> Int { + let attributes = try? fileManager.attributesOfItem(atPath: url.path) + + return (attributes?[.size] as? NSNumber)?.intValue ?? 0 + } + + private func logFileURLs() -> [URL] { + (1 ... Self.archiveFileCount) + .reversed() + .map(archivedLogURL) + + [currentLogURL()] + } + + private func allLogFileURLs() -> [URL] { + [currentLogURL()] + (1 ... Self.archiveFileCount).map(archivedLogURL) + } + + private func currentLogURL() -> URL { + logsDirectory.appendingPathComponent(Self.currentLogFile) + } + + private func archivedLogURL(_ index: Int) -> URL { + logsDirectory.appendingPathComponent("cove-swift.\(index).log") + } +} diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index 971a46a9d..861901fdb 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -2778,6 +2778,8 @@ public protocol DatabaseProtocol: AnyObject, Sendable { func dangerousResetAllData() + func diagnosticsReports() -> DiagnosticsReportsTable + func globalConfig() -> GlobalConfigTable func globalFlag() -> GlobalFlagTable @@ -2858,6 +2860,15 @@ open func dangerousResetAllData() {try! rustCall() { } } +open func diagnosticsReports() -> DiagnosticsReportsTable { + return try! FfiConverterTypeDiagnosticsReportsTable_lift(try! rustCall() { + uniffiCallStatus in + uniffi_cove_fn_method_database_diagnostics_reports( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + open func globalConfig() -> GlobalConfigTable { return try! FfiConverterTypeGlobalConfigTable_lift(try! rustCall() { uniffiCallStatus in @@ -2953,6 +2964,327 @@ public func FfiConverterTypeDatabase_lower(_ value: Database) -> UInt64 { +public protocol DiagnosticsReportProtocol: AnyObject, Sendable { + + func formattedSize() -> String + + func formattedSizeForDescription(description: String?) -> String + + func previewText() -> String + + func previewTextForDescription(description: String?) -> String + + func sizeBytes() -> UInt64 + + func sizeBytesForDescription(description: String?) -> UInt64 + + func submit(description: String?) async throws -> DiagnosticsSubmission + +} +open class DiagnosticsReport: DiagnosticsReportProtocol, @unchecked Sendable { + fileprivate let handle: UInt64 + + /// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public struct NoHandle { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromHandle handle: UInt64) { + self.handle = handle + } + + // This constructor can be used to instantiate a fake object. + // - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public init(noHandle: NoHandle) { + self.handle = 0 + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public func uniffiCloneHandle() -> UInt64 { + return try! rustCall { uniffi_cove_fn_clone_diagnosticsreport(self.handle, $0) } + } + // No primary constructor declared for this class. + + deinit { + if handle == 0 { + // Mock objects have handle=0 don't try to free them + return + } + + try! rustCall { uniffi_cove_fn_free_diagnosticsreport(handle, $0) } + } + + + + +open func formattedSize() -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffiCallStatus in + uniffi_cove_fn_method_diagnosticsreport_formatted_size( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + +open func formattedSizeForDescription(description: String?) -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffiCallStatus in + uniffi_cove_fn_method_diagnosticsreport_formatted_size_for_description( + self.uniffiCloneHandle(), + FfiConverterOptionString.lower(description),uniffiCallStatus + ) +}) +} + +open func previewText() -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffiCallStatus in + uniffi_cove_fn_method_diagnosticsreport_preview_text( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + +open func previewTextForDescription(description: String?) -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffiCallStatus in + uniffi_cove_fn_method_diagnosticsreport_preview_text_for_description( + self.uniffiCloneHandle(), + FfiConverterOptionString.lower(description),uniffiCallStatus + ) +}) +} + +open func sizeBytes() -> UInt64 { + return try! FfiConverterUInt64.lift(try! rustCall() { + uniffiCallStatus in + uniffi_cove_fn_method_diagnosticsreport_size_bytes( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + +open func sizeBytesForDescription(description: String?) -> UInt64 { + return try! FfiConverterUInt64.lift(try! rustCall() { + uniffiCallStatus in + uniffi_cove_fn_method_diagnosticsreport_size_bytes_for_description( + self.uniffiCloneHandle(), + FfiConverterOptionString.lower(description),uniffiCallStatus + ) +}) +} + +open func submit(description: String?)async throws -> DiagnosticsSubmission { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_cove_fn_method_diagnosticsreport_submit( + self.uniffiCloneHandle(), + FfiConverterOptionString.lower(description) + ) + }, + pollFunc: ffi_cove_rust_future_poll_rust_buffer, + completeFunc: ffi_cove_rust_future_complete_rust_buffer, + freeFunc: ffi_cove_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeDiagnosticsSubmission_lift, + errorHandler: FfiConverterTypeDiagnosticsError_lift + ) +} + + + +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeDiagnosticsReport: FfiConverter { + typealias FfiType = UInt64 + typealias SwiftType = DiagnosticsReport + + public static func lift(_ handle: UInt64) throws -> DiagnosticsReport { + return DiagnosticsReport(unsafeFromHandle: handle) + } + + public static func lower(_ value: DiagnosticsReport) -> UInt64 { + return value.uniffiCloneHandle() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> DiagnosticsReport { + let handle: UInt64 = try readInt(&buf) + return try lift(handle) + } + + public static func write(_ value: DiagnosticsReport, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeDiagnosticsReport_lift(_ handle: UInt64) throws -> DiagnosticsReport { + return try FfiConverterTypeDiagnosticsReport.lift(handle) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeDiagnosticsReport_lower(_ value: DiagnosticsReport) -> UInt64 { + return FfiConverterTypeDiagnosticsReport.lower(value) +} + + + + + + +public protocol DiagnosticsReportsTableProtocol: AnyObject, Sendable { + + func all() throws -> [DiagnosticsReportRecord] + + func clear() throws + +} +open class DiagnosticsReportsTable: DiagnosticsReportsTableProtocol, @unchecked Sendable { + fileprivate let handle: UInt64 + + /// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public struct NoHandle { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromHandle handle: UInt64) { + self.handle = handle + } + + // This constructor can be used to instantiate a fake object. + // - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public init(noHandle: NoHandle) { + self.handle = 0 + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public func uniffiCloneHandle() -> UInt64 { + return try! rustCall { uniffi_cove_fn_clone_diagnosticsreportstable(self.handle, $0) } + } + // No primary constructor declared for this class. + + deinit { + if handle == 0 { + // Mock objects have handle=0 don't try to free them + return + } + + try! rustCall { uniffi_cove_fn_free_diagnosticsreportstable(handle, $0) } + } + + + + +open func all()throws -> [DiagnosticsReportRecord] { + return try FfiConverterSequenceTypeDiagnosticsReportRecord.lift(try rustCallWithError(FfiConverterTypeDatabaseError_lift) { + uniffiCallStatus in + uniffi_cove_fn_method_diagnosticsreportstable_all( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + +open func clear()throws {try rustCallWithError(FfiConverterTypeDatabaseError_lift) { + uniffiCallStatus in + uniffi_cove_fn_method_diagnosticsreportstable_clear( + self.uniffiCloneHandle(),uniffiCallStatus + ) +} +} + + + +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeDiagnosticsReportsTable: FfiConverter { + typealias FfiType = UInt64 + typealias SwiftType = DiagnosticsReportsTable + + public static func lift(_ handle: UInt64) throws -> DiagnosticsReportsTable { + return DiagnosticsReportsTable(unsafeFromHandle: handle) + } + + public static func lower(_ value: DiagnosticsReportsTable) -> UInt64 { + return value.uniffiCloneHandle() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> DiagnosticsReportsTable { + let handle: UInt64 = try readInt(&buf) + return try lift(handle) + } + + public static func write(_ value: DiagnosticsReportsTable, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeDiagnosticsReportsTable_lift(_ handle: UInt64) throws -> DiagnosticsReportsTable { + return try FfiConverterTypeDiagnosticsReportsTable.lift(handle) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeDiagnosticsReportsTable_lower(_ value: DiagnosticsReportsTable) -> UInt64 { + return FfiConverterTypeDiagnosticsReportsTable.lower(value) +} + + + + + + /** * Representation of our app over FFI. Essenially a wrapper of [`App`]. */ @@ -14893,6 +15225,184 @@ public func FfiConverterTypeDeriveInfo_lower(_ value: DeriveInfo) -> RustBuffer } +public struct DiagnosticsPlatformInfo: Equatable, Hashable { + public var platform: String + public var buildNumber: String + public var osVersion: String + public var deviceModel: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(platform: String, buildNumber: String, osVersion: String, deviceModel: String) { + self.platform = platform + self.buildNumber = buildNumber + self.osVersion = osVersion + self.deviceModel = deviceModel + } + + + + +} + +#if compiler(>=6) +extension DiagnosticsPlatformInfo: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeDiagnosticsPlatformInfo: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> DiagnosticsPlatformInfo { + return + try DiagnosticsPlatformInfo( + platform: FfiConverterString.read(from: &buf), + buildNumber: FfiConverterString.read(from: &buf), + osVersion: FfiConverterString.read(from: &buf), + deviceModel: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: DiagnosticsPlatformInfo, into buf: inout [UInt8]) { + FfiConverterString.write(value.platform, into: &buf) + FfiConverterString.write(value.buildNumber, into: &buf) + FfiConverterString.write(value.osVersion, into: &buf) + FfiConverterString.write(value.deviceModel, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeDiagnosticsPlatformInfo_lift(_ buf: RustBuffer) throws -> DiagnosticsPlatformInfo { + return try FfiConverterTypeDiagnosticsPlatformInfo.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeDiagnosticsPlatformInfo_lower(_ value: DiagnosticsPlatformInfo) -> RustBuffer { + return FfiConverterTypeDiagnosticsPlatformInfo.lower(value) +} + + +public struct DiagnosticsReportRecord: Equatable, Hashable { + public var reportId: String + public var submittedAt: UInt64 + public var description: String? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(reportId: String, submittedAt: UInt64, description: String?) { + self.reportId = reportId + self.submittedAt = submittedAt + self.description = description + } + + + + +} + +#if compiler(>=6) +extension DiagnosticsReportRecord: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeDiagnosticsReportRecord: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> DiagnosticsReportRecord { + return + try DiagnosticsReportRecord( + reportId: FfiConverterString.read(from: &buf), + submittedAt: FfiConverterUInt64.read(from: &buf), + description: FfiConverterOptionString.read(from: &buf) + ) + } + + public static func write(_ value: DiagnosticsReportRecord, into buf: inout [UInt8]) { + FfiConverterString.write(value.reportId, into: &buf) + FfiConverterUInt64.write(value.submittedAt, into: &buf) + FfiConverterOptionString.write(value.description, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeDiagnosticsReportRecord_lift(_ buf: RustBuffer) throws -> DiagnosticsReportRecord { + return try FfiConverterTypeDiagnosticsReportRecord.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeDiagnosticsReportRecord_lower(_ value: DiagnosticsReportRecord) -> RustBuffer { + return FfiConverterTypeDiagnosticsReportRecord.lower(value) +} + + +public struct DiagnosticsSubmission: Equatable, Hashable { + public var reportId: String + public var historySaved: Bool + public var warning: String? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(reportId: String, historySaved: Bool, warning: String?) { + self.reportId = reportId + self.historySaved = historySaved + self.warning = warning + } + + + + +} + +#if compiler(>=6) +extension DiagnosticsSubmission: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeDiagnosticsSubmission: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> DiagnosticsSubmission { + return + try DiagnosticsSubmission( + reportId: FfiConverterString.read(from: &buf), + historySaved: FfiConverterBool.read(from: &buf), + warning: FfiConverterOptionString.read(from: &buf) + ) + } + + public static func write(_ value: DiagnosticsSubmission, into buf: inout [UInt8]) { + FfiConverterString.write(value.reportId, into: &buf) + FfiConverterBool.write(value.historySaved, into: &buf) + FfiConverterOptionString.write(value.warning, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeDiagnosticsSubmission_lift(_ buf: RustBuffer) throws -> DiagnosticsSubmission { + return try FfiConverterTypeDiagnosticsSubmission.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeDiagnosticsSubmission_lower(_ value: DiagnosticsSubmission) -> RustBuffer { + return FfiConverterTypeDiagnosticsSubmission.lower(value) +} + + public struct FeeResponse: Equatable, Hashable { public var fastestFee: Float public var halfHourFee: Float @@ -23204,6 +23714,8 @@ enum DatabaseError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError ) case HistoricalPrice(HistoricalPriceTableError ) + case DiagnosticsReports(DiagnosticsReportsTableError + ) case Serialization(SerdeError ) case WalletNotFound @@ -23285,31 +23797,34 @@ public struct FfiConverterTypeDatabaseError: FfiConverterRustBuffer { case 8: return .HistoricalPrice( try FfiConverterTypeHistoricalPriceTableError.read(from: &buf) ) - case 9: return .Serialization( + case 9: return .DiagnosticsReports( + try FfiConverterTypeDiagnosticsReportsTableError.read(from: &buf) + ) + case 10: return .Serialization( try FfiConverterTypeSerdeError.read(from: &buf) ) - case 10: return .WalletNotFound - case 11: return .EncryptionKeyNotSet - case 12: return .BootstrapFailed( + case 11: return .WalletNotFound + case 12: return .EncryptionKeyNotSet + case 13: return .BootstrapFailed( try FfiConverterString.read(from: &buf) ) - case 13: return .BackendOpen( + case 14: return .BackendOpen( path: try FfiConverterString.read(from: &buf), error: try FfiConverterString.read(from: &buf) ) - case 14: return .CorruptBlock( + case 15: return .CorruptBlock( path: try FfiConverterString.read(from: &buf), error: try FfiConverterString.read(from: &buf) ) - case 15: return .DatabaseAlreadyOpen - case 16: return .HeaderIntegrity( + case 16: return .DatabaseAlreadyOpen + case 17: return .HeaderIntegrity( path: try FfiConverterString.read(from: &buf), error: try FfiConverterString.read(from: &buf) ) - case 17: return .UnsupportedVersion( + case 18: return .UnsupportedVersion( try FfiConverterTypeUnsupportedDbVersion.read(from: &buf) ) - case 18: return .PlaintextNotAllowed( + case 19: return .PlaintextNotAllowed( path: try FfiConverterString.read(from: &buf) ) @@ -23364,53 +23879,58 @@ public struct FfiConverterTypeDatabaseError: FfiConverterRustBuffer { FfiConverterTypeHistoricalPriceTableError.write(v1, into: &buf) - case let .Serialization(v1): + case let .DiagnosticsReports(v1): writeInt(&buf, Int32(9)) + FfiConverterTypeDiagnosticsReportsTableError.write(v1, into: &buf) + + + case let .Serialization(v1): + writeInt(&buf, Int32(10)) FfiConverterTypeSerdeError.write(v1, into: &buf) case .WalletNotFound: - writeInt(&buf, Int32(10)) + writeInt(&buf, Int32(11)) case .EncryptionKeyNotSet: - writeInt(&buf, Int32(11)) + writeInt(&buf, Int32(12)) case let .BootstrapFailed(v1): - writeInt(&buf, Int32(12)) + writeInt(&buf, Int32(13)) FfiConverterString.write(v1, into: &buf) case let .BackendOpen(path,error): - writeInt(&buf, Int32(13)) + writeInt(&buf, Int32(14)) FfiConverterString.write(path, into: &buf) FfiConverterString.write(error, into: &buf) case let .CorruptBlock(path,error): - writeInt(&buf, Int32(14)) + writeInt(&buf, Int32(15)) FfiConverterString.write(path, into: &buf) FfiConverterString.write(error, into: &buf) case .DatabaseAlreadyOpen: - writeInt(&buf, Int32(15)) + writeInt(&buf, Int32(16)) case let .HeaderIntegrity(path,error): - writeInt(&buf, Int32(16)) + writeInt(&buf, Int32(17)) FfiConverterString.write(path, into: &buf) FfiConverterString.write(error, into: &buf) case let .UnsupportedVersion(v1): - writeInt(&buf, Int32(17)) + writeInt(&buf, Int32(18)) FfiConverterTypeUnsupportedDbVersion.write(v1, into: &buf) case let .PlaintextNotAllowed(path): - writeInt(&buf, Int32(18)) + writeInt(&buf, Int32(19)) FfiConverterString.write(path, into: &buf) } @@ -23868,6 +24388,193 @@ public func FfiConverterTypeDescriptorError_lower(_ value: DescriptorError) -> R } +public +enum DiagnosticsError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { + + + + case Build(message: String) + + case ClearLogs(message: String) + + case Submit(message: String) + + + + + + + + public var errorDescription: String? { + String(reflecting: self) + } + +} + +#if compiler(>=6) +extension DiagnosticsError: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeDiagnosticsError: FfiConverterRustBuffer { + typealias SwiftType = DiagnosticsError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> DiagnosticsError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .Build( + message: try FfiConverterString.read(from: &buf) + ) + + case 2: return .ClearLogs( + message: try FfiConverterString.read(from: &buf) + ) + + case 3: return .Submit( + message: try FfiConverterString.read(from: &buf) + ) + + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: DiagnosticsError, into buf: inout [UInt8]) { + switch value { + + + + + case .Build(_ /* message is ignored*/): + writeInt(&buf, Int32(1)) + case .ClearLogs(_ /* message is ignored*/): + writeInt(&buf, Int32(2)) + case .Submit(_ /* message is ignored*/): + writeInt(&buf, Int32(3)) + + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeDiagnosticsError_lift(_ buf: RustBuffer) throws -> DiagnosticsError { + return try FfiConverterTypeDiagnosticsError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeDiagnosticsError_lower(_ value: DiagnosticsError) -> RustBuffer { + return FfiConverterTypeDiagnosticsError.lower(value) +} + + +public +enum DiagnosticsReportsTableError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { + + + + case Save(String + ) + case Read(String + ) + + + + +// The local Rust `Display` implementation. +public var description: String { + return try! FfiConverterString.lift( + try! rustCall() { + uniffiCallStatus in + uniffi_cove_fn_method_diagnosticsreportstableerror_uniffi_trait_display( + FfiConverterTypeDiagnosticsReportsTableError_lower(self),uniffiCallStatus + ) +} + ) +} + + + public var errorDescription: String? { + String(reflecting: self) + } + +} + +#if compiler(>=6) +extension DiagnosticsReportsTableError: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeDiagnosticsReportsTableError: FfiConverterRustBuffer { + typealias SwiftType = DiagnosticsReportsTableError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> DiagnosticsReportsTableError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .Save( + try FfiConverterString.read(from: &buf) + ) + case 2: return .Read( + try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: DiagnosticsReportsTableError, into buf: inout [UInt8]) { + switch value { + + + + + + case let .Save(v1): + writeInt(&buf, Int32(1)) + FfiConverterString.write(v1, into: &buf) + + + case let .Read(v1): + writeInt(&buf, Int32(2)) + FfiConverterString.write(v1, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeDiagnosticsReportsTableError_lift(_ buf: RustBuffer) throws -> DiagnosticsReportsTableError { + return try FfiConverterTypeDiagnosticsReportsTableError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeDiagnosticsReportsTableError_lower(_ value: DiagnosticsReportsTableError) -> RustBuffer { + return FfiConverterTypeDiagnosticsReportsTableError.lower(value) +} + + public enum DiscoveryState: Equatable, Hashable { @@ -38737,6 +39444,31 @@ fileprivate struct FfiConverterSequenceTypeCloudBackupWalletItem: FfiConverterRu } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeDiagnosticsReportRecord: FfiConverterRustBuffer { + typealias SwiftType = [DiagnosticsReportRecord] + + public static func write(_ value: [DiagnosticsReportRecord], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeDiagnosticsReportRecord.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [DiagnosticsReportRecord] { + let len: Int32 = try readInt(&buf) + var seq = [DiagnosticsReportRecord]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeDiagnosticsReportRecord.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -39510,6 +40242,26 @@ public func allBlockExplorerOptions() -> [BlockExplorerOption] { ) }) } +public func buildDiagnosticsReport(platform: DiagnosticsPlatformInfo, platformLogs: String)async throws -> DiagnosticsReport { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_cove_fn_func_build_diagnostics_report(FfiConverterTypeDiagnosticsPlatformInfo_lower(platform),FfiConverterString.lower(platformLogs) + ) + }, + pollFunc: ffi_cove_rust_future_poll_u64, + completeFunc: ffi_cove_rust_future_complete_u64, + freeFunc: ffi_cove_rust_future_free_u64, + liftFunc: FfiConverterTypeDiagnosticsReport_lift, + errorHandler: FfiConverterTypeDiagnosticsError_lift + ) +} +public func clearDiagnosticsLogs()throws {try rustCallWithError(FfiConverterTypeDiagnosticsError_lift) { + uniffiCallStatus in + uniffi_cove_fn_func_clear_diagnostics_logs(uniffiCallStatus + ) +} +} public func allFiatCurrencies() -> [FiatCurrency] { return try! FfiConverterSequenceTypeFiatCurrency.lift(try! rustCall() { uniffiCallStatus in @@ -40019,6 +40771,12 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_func_all_block_explorer_options() != 40123) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_func_build_diagnostics_report() != 50442) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_func_clear_diagnostics_logs() != 12380) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_func_all_fiat_currencies() != 53482) { return InitializationResult.apiChecksumMismatch } @@ -40337,6 +41095,9 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_database_dangerous_reset_all_data() != 25988) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_database_diagnostics_reports() != 32801) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_database_global_config() != 34695) { return InitializationResult.apiChecksumMismatch } @@ -40352,6 +41113,12 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_database_wallets() != 16005) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_diagnosticsreportstable_all() != 6560) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_diagnosticsreportstable_clear() != 15672) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_globalconfigtable_authtype() != 62043) { return InitializationResult.apiChecksumMismatch } @@ -40484,6 +41251,27 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_walletstable_reorder_wallets() != 40391) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_diagnosticsreport_formatted_size() != 23149) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_diagnosticsreport_formatted_size_for_description() != 52799) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_diagnosticsreport_preview_text() != 51487) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_diagnosticsreport_preview_text_for_description() != 51859) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_diagnosticsreport_size_bytes() != 10840) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_diagnosticsreport_size_bytes_for_description() != 25891) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_diagnosticsreport_submit() != 54230) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_priceresponse_get() != 6552) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/CoveTests/SwiftLogStoreTests.swift b/ios/CoveTests/SwiftLogStoreTests.swift new file mode 100644 index 000000000..1cd02f16f --- /dev/null +++ b/ios/CoveTests/SwiftLogStoreTests.swift @@ -0,0 +1,175 @@ +@testable import Cove +import XCTest + +final class SwiftLogStoreTests: XCTestCase { + private var tempDirectory: URL! + + override func setUpWithError() throws { + try super.setUpWithError() + + tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: tempDirectory, + withIntermediateDirectories: true + ) + } + + override func tearDownWithError() throws { + if let tempDirectory { + try? FileManager.default.removeItem(at: tempDirectory) + } + + tempDirectory = nil + try super.tearDownWithError() + } + + func testRecordsAndSnapshotsMessagesWithLevelCategoryAndTimestamp() { + let store = makeStore() + + store.record(level: .info, category: "unit", message: "hello diagnostics") + + let snapshot = store.snapshot() + XCTAssertTrue(snapshot.split(separator: "\n").contains { line in + String(line).range( + of: #"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z INFO unit: hello diagnostics$"#, + options: .regularExpression + ) != nil + }) + } + + func testRotationKeepsTotalBytesBoundedAndDropsOldestArchive() throws { + let store = makeStore() + let payload = String(repeating: "x", count: 140_000) + + for index in 0 ..< 20 { + store.record(level: .info, category: "rotation", message: "entry-\(index) \(payload)") + } + + let snapshot = store.snapshot() + XCTAssertFalse(snapshot.contains("entry-0 ")) + XCTAssertTrue(snapshot.contains("entry-19 ")) + + let logFiles = try FileManager.default.contentsOfDirectory( + at: tempDirectory, + includingPropertiesForKeys: [.fileSizeKey] + ) + let totalBytes = try logFiles.reduce(0) { total, url in + let values = try url.resourceValues(forKeys: [.fileSizeKey]) + + return total + (values.fileSize ?? 0) + } + + XCTAssertLessThanOrEqual(logFiles.count, 8) + XCTAssertLessThanOrEqual(totalBytes, 2 * 1024 * 1024) + } + + func testClearRemovesExistingLogsAndWritesMarker() throws { + let store = makeStore() + store.record(level: .warn, category: "clear", message: "before clear") + XCTAssertTrue(store.snapshot().contains("before clear")) + + try store.clear() + + let snapshot = store.snapshot() + XCTAssertFalse(snapshot.contains("before clear")) + XCTAssertTrue(snapshot.contains("swift diagnostics logs cleared at")) + } + + func testSnapshotReturnsFallbackWhenNoLogsExist() { + XCTAssertEqual(makeStore().snapshot(), "no Swift logs captured\n") + } + + func testSnapshotPreservesMultilineMessageWhitespaceForDiagnosticsRedaction() { + let store = makeStore() + let mnemonic = """ + abandon abandon abandon abandon abandon abandon + abandon abandon abandon abandon abandon about + """ + + store.record(level: .warn, category: "redaction", message: mnemonic) + + let snapshot = store.snapshot() + XCTAssertTrue(snapshot.contains("abandon abandon\nabandon abandon")) + XCTAssertFalse(snapshot.contains("abandon abandon\\nabandon abandon")) + } + + func testSnapshotAfterReinstantiatingStoreIncludesPersistedLines() { + let store = makeStore() + store.record(level: .error, category: "restart", message: "before restart") + _ = store.snapshot() + + let restarted = makeStore() + + XCTAssertTrue(restarted.snapshot().contains("before restart")) + } + + func testLogFileUsesExplicitProtectionClass() { + let fileManager = CapturingCreateFileManager() + let store = SwiftLogStore(logsDirectory: tempDirectory, fileManager: fileManager) + store.record(level: .info, category: "protection", message: "protected log") + _ = store.snapshot() + + XCTAssertEqual( + fileManager.createdFileAttributes?[.protectionKey] as? FileProtectionType, + .completeUntilFirstUserAuthentication + ) + } + + func testSnapshotIncludesWriteFailureBreadcrumb() throws { + let fileURL = tempDirectory.appendingPathComponent("not-a-directory") + try "not a directory".write(to: fileURL, atomically: true, encoding: .utf8) + let store = SwiftLogStore(logsDirectory: fileURL) + + store.record(level: .error, category: "failure", message: "cannot write") + + let snapshot = store.snapshot() + XCTAssertTrue(snapshot.contains("failed to write Swift diagnostics log file")) + } + + func testClearWriteFailureIsThrownAndIncludedInSnapshot() throws { + let fileURL = tempDirectory.appendingPathComponent("not-a-directory") + try "not a directory".write(to: fileURL, atomically: true, encoding: .utf8) + let store = SwiftLogStore(logsDirectory: fileURL) + + XCTAssertThrowsError(try store.clear()) + + let snapshot = store.snapshot() + XCTAssertTrue(snapshot.contains("failed to write Swift diagnostics log file")) + } + + func testClearDeletionFailureIsThrown() { + let fileManager = FailingRemoveFileManager() + let store = SwiftLogStore(logsDirectory: tempDirectory, fileManager: fileManager) + + XCTAssertThrowsError(try store.clear()) + } + + private func makeStore() -> SwiftLogStore { + SwiftLogStore(logsDirectory: tempDirectory) + } +} + +private final class CapturingCreateFileManager: FileManager, @unchecked Sendable { + private(set) var createdFileAttributes: [FileAttributeKey: Any]? + + override func createFile( + atPath path: String, + contents data: Data?, + attributes attr: [FileAttributeKey: Any]? = nil + ) -> Bool { + createdFileAttributes = attr + + return super.createFile(atPath: path, contents: data, attributes: attr) + } +} + +private final class FailingRemoveFileManager: FileManager, @unchecked Sendable { + override func fileExists(atPath _: String) -> Bool { + true + } + + override func removeItem(at _: URL) throws { + throw CocoaError(.fileWriteNoPermission) + } +} diff --git a/ios/Logger.swift b/ios/Logger.swift index 56c847c65..4361b76ca 100644 --- a/ios/Logger.swift +++ b/ios/Logger.swift @@ -9,10 +9,11 @@ import Foundation import OSLog enum LogLevel: String { - case debug, info, warn, error + case debug, notice, info, warn, error } struct Log { + private let category: String private let logger: Logger static let shared = Logger(subsystem: subsystem, category: "shared") @@ -22,6 +23,7 @@ struct Log { private static let subsystem = Bundle.main.bundleIdentifier! init(id: String) { + category = id logger = Logger(subsystem: "org.bitcoinppl.cove", category: id) } @@ -29,47 +31,65 @@ struct Log { func debug(_ message: String) { #if DEBUG - logger.debug("[swift][debug]: \(message)") + Self.record(level: .debug, category: category, message: message) + logger.debug("\(Self.osLogMessage(level: .debug, message: message))") #endif } func notice(_ message: String) { - logger.notice("[swift][notice]: \(message)") + Self.record(level: .notice, category: category, message: message) + logger.notice("\(Self.osLogMessage(level: .notice, message: message))") } func info(_ message: String) { - logger.info("[swift][info]: \(message)") + Self.record(level: .info, category: category, message: message) + logger.info("\(Self.osLogMessage(level: .info, message: message))") } func warn(_ message: String) { - logger.warning("[swift][warn]: \(message)") + Self.record(level: .warn, category: category, message: message) + logger.warning("\(Self.osLogMessage(level: .warn, message: message))") } func error(_ message: String) { - logger.error("[swift][error]: \(message)") + Self.record(level: .error, category: category, message: message) + logger.error("\(Self.osLogMessage(level: .error, message: message))") } // MARK: - Shared Instance for convenience static func debug(_ message: String) { #if DEBUG - Log.shared.debug("[swift][debug]: \(message)") + record(level: .debug, category: "shared", message: message) + Log.shared.debug("\(osLogMessage(level: .debug, message: message))") #endif } static func info(_ message: String) { - Log.shared.info("[swift][info]: \(message)") + record(level: .info, category: "shared", message: message) + Log.shared.info("\(osLogMessage(level: .info, message: message))") } static func warn(_ message: String) { - Log.shared.warning("[swift][warn]: \(message)") + record(level: .warn, category: "shared", message: message) + Log.shared.warning("\(osLogMessage(level: .warn, message: message))") } static func notice(_ message: String) { - Log.shared.notice("[swift][notice]: \(message)") + record(level: .notice, category: "shared", message: message) + Log.shared.notice("\(osLogMessage(level: .notice, message: message))") } static func error(_ message: String) { - Log.shared.error("[swift][error]: \(message)") + record(level: .error, category: "shared", message: message) + Log.shared.error("\(osLogMessage(level: .error, message: message))") + } + + private static func record(level: LogLevel, category: String, message: String) { + SwiftLogStore.shared.record(level: level, category: category, message: message) + } + + private static func osLogMessage(level: LogLevel, message: String) -> String { + "[swift][\(level.rawValue)]: \(message)" } } diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 17774dbc9..90ec18e95 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1226,6 +1226,7 @@ dependencies = [ "derive_more", "dirs", "eyre", + "flate2", "flume", "foundation-ur", "futures", @@ -1267,6 +1268,7 @@ dependencies = [ "tracing", "tracing-log", "tracing-subscriber", + "unicode-normalization", "uniffi 0.31.1", "url", "winnow 1.0.2", @@ -1318,11 +1320,14 @@ dependencies = [ "bitcoin", "dirs", "eyre", + "jiff", "memchr", "once_cell", + "parking_lot", "redb", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", "tracing", "tracing-subscriber", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index dafcbb937..5234ff5eb 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -31,6 +31,7 @@ data-encoding = "2.11.0" # parsing winnow = { version = "1.0.2", features = ["simd"] } memchr = "2.8.0" +unicode-normalization = "0.1.25" url = "2.5.8" # qr @@ -51,11 +52,12 @@ rand = "0.10.1" # compression ruzstd = "0.8.3" +flate2 = "1.1.9" # bdk / bitcoin bitcoin = { version = "0.32.9" } bdk_wallet = { version = "3.0.0", features = ["keys-bip39", "file_store", "rusqlite"] } -bip39 = { version = "2.2.2", features = ["zeroize"] } +bip39 = { version = "2.2.2", features = ["all-languages", "zeroize"] } bip329 = "0.6.0" payjoin = { version = "0.25.0", default-features = false, features = ["v2"] } pubport = { version = "0.6.0" } @@ -242,6 +244,7 @@ hmac = { workspace = true } # compression ruzstd = { workspace = true } +flate2 = { workspace = true } # fast hashmap ahash = { workspace = true } @@ -255,6 +258,7 @@ rustls = { version = "0.23.40", features = ["ring"], default-features = false } # parsing winnow = { workspace = true } memchr = { workspace = true } +unicode-normalization = { workspace = true } # encoding / decoding hex = { workspace = true } diff --git a/rust/crates/cove-bip39/src/lib.rs b/rust/crates/cove-bip39/src/lib.rs index 8fb183658..5c343972a 100644 --- a/rust/crates/cove-bip39/src/lib.rs +++ b/rust/crates/cove-bip39/src/lib.rs @@ -77,7 +77,7 @@ fn split_and_encode_phrase(phrase: &str) -> (usize, BigUint) { mod test { use std::str::FromStr as _; - use bip39::Mnemonic; + use bip39::{Language, Mnemonic}; use num_bigint::BigUint; use rand::RngExt as _; @@ -99,8 +99,8 @@ mod test { words.push(word.to_string()); - let result = bip39::Mnemonic::parse_normalized(words.join(" ").as_str()); - assert!(result.is_ok()); + let result = Mnemonic::parse_in_normalized(Language::English, words.join(" ").as_str()); + assert!(result.is_ok(), "generated final word {word} must be valid: {result:?}"); } assert_eq!(split_and_encode_phrase(words), (23, BigUint::from(0_u64))); @@ -156,7 +156,7 @@ mod test { words.push(word.to_string()); - let result = bip39::Mnemonic::parse_normalized(words.join(" ").as_str()); + let result = Mnemonic::parse_in_normalized(Language::English, words.join(" ").as_str()); assert!(result.is_ok()); } diff --git a/rust/crates/cove-common/Cargo.toml b/rust/crates/cove-common/Cargo.toml index 72327f7b9..e9f704d00 100644 --- a/rust/crates/cove-common/Cargo.toml +++ b/rust/crates/cove-common/Cargo.toml @@ -18,10 +18,15 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] } memchr = { workspace = true } once_cell = { workspace = true } +parking_lot = { workspace = true } dirs = { workspace = true } +jiff = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } redb = { workspace = true } + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/rust/crates/cove-common/src/logging.rs b/rust/crates/cove-common/src/logging.rs index c70d0da06..18a2f7c04 100644 --- a/rust/crates/cove-common/src/logging.rs +++ b/rust/crates/cove-common/src/logging.rs @@ -2,6 +2,8 @@ use std::sync::Once; use tracing_subscriber::EnvFilter; +pub mod capture; + static INIT: Once = Once::new(); pub fn init() { @@ -9,11 +11,20 @@ pub fn init() { use tracing_subscriber::{fmt, prelude::*}; if std::env::var("RUST_LOG").is_err() { - unsafe { std::env::set_var("RUST_LOG", "cove=debug,info") } + #[cfg(debug_assertions)] + unsafe { + std::env::set_var("RUST_LOG", "cove=debug,info") + }; + + #[cfg(not(debug_assertions))] + unsafe { + std::env::set_var("RUST_LOG", "cove=info") + }; } tracing_subscriber::registry() .with(fmt::layer().with_writer(std::io::stdout).with_ansi(false)) + .with(capture::layer()) .with(EnvFilter::from_default_env()) .init(); }); diff --git a/rust/crates/cove-common/src/logging/capture.rs b/rust/crates/cove-common/src/logging/capture.rs new file mode 100644 index 000000000..bb438e187 --- /dev/null +++ b/rust/crates/cove-common/src/logging/capture.rs @@ -0,0 +1,991 @@ +use std::{ + cell::Cell, + collections::VecDeque, + fmt, + fs::{File, OpenOptions}, + io::Write as _, + path::{Path, PathBuf}, + sync::{ + Arc, LazyLock, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + thread::{self, JoinHandle}, +}; + +use parking_lot::Mutex; +use tracing::{Event, Subscriber, field::Visit}; +use tracing_subscriber::{Layer, layer::Context}; + +use crate::consts::ROOT_DATA_DIR; + +pub const RING_CAP_BYTES: usize = 256 * 1024; +pub const MAX_TOTAL_FILE_BYTES: usize = 2 * 1024 * 1024; + +const LOG_FILE_BYTES: usize = RING_CAP_BYTES; +const ARCHIVE_FILE_COUNT: usize = (MAX_TOTAL_FILE_BYTES / LOG_FILE_BYTES) - 1; +const WRITER_QUEUE_CAPACITY: usize = 64; +const CURRENT_LOG_FILE: &str = "cove-rust.log"; + +static CAPTURE: LazyLock = LazyLock::new(Capture::default); + +thread_local! { + static FORMATTING_EVENT: Cell = const { Cell::new(false) }; +} + +#[derive(Debug, thiserror::Error)] +pub enum CaptureError { + #[error("failed to create diagnostics log directory {path}: {source}")] + CreateDir { path: String, source: std::io::Error }, + + #[error("failed to open diagnostics log file {path}: {source}")] + OpenFile { path: String, source: std::io::Error }, + + #[error("failed to write diagnostics log file {path}: {source}")] + WriteFile { path: String, source: std::io::Error }, + + #[error("failed to rotate diagnostics log file {path}: {source}")] + RotateFile { path: String, source: std::io::Error }, + + #[error("failed to remove diagnostics log file {path}: {source}")] + RemoveFile { path: String, source: std::io::Error }, + + #[error("failed to remove diagnostics log directory {path}: {source}")] + RemoveDir { path: String, source: std::io::Error }, + + #[error("failed to start diagnostics log writer: {source}")] + StartWriter { source: std::io::Error }, + + #[error("diagnostics log writer is unavailable while trying to {action}")] + WriterUnavailable { action: &'static str }, +} + +#[derive(Debug, Clone, Copy)] +pub struct CaptureLayer; + +#[derive(Default)] +struct Capture { + state: Mutex, +} + +struct CaptureState { + ring: RingBuffer, + writer: Option, + writer_health: Arc, + replayed_on_attach: bool, +} + +#[derive(Debug)] +struct RingBuffer { + lines: VecDeque, + bytes: usize, + cap_bytes: usize, +} + +#[derive(Debug)] +struct RollingLogFile { + dir: PathBuf, + file: File, + current_size: usize, +} + +struct LogWriter { + handle: LogWriterHandle, + join_handle: Option>, +} + +#[derive(Clone)] +struct LogWriterHandle { + dir: PathBuf, + sender: mpsc::SyncSender, + health: Arc, +} + +#[derive(Default)] +struct WriterHealth { + dropped_writes: AtomicUsize, + cleared_drops_through: AtomicUsize, +} + +enum WriterCommand { + Write(String), + Flush(mpsc::Sender, CaptureError>>), + ClearAndWrite { + marker: String, + clear_drops_through: usize, + reply: mpsc::Sender>, + }, + Shutdown, +} + +pub fn layer() -> CaptureLayer { + CaptureLayer +} + +pub fn attach_to_default_logs_dir() -> Result<(), CaptureError> { + attach(ROOT_DATA_DIR.join("logs")) +} + +pub fn attach(logs_dir: PathBuf) -> Result<(), CaptureError> { + CAPTURE.state.lock().attach(logs_dir) +} + +pub fn snapshot_text() -> String { + let snapshot = CAPTURE.state.lock().snapshot_start(); + snapshot.finish() +} + +pub fn clear() -> Result<(), CaptureError> { + let clear = CAPTURE.state.lock().start_clear()?; + if let Some(clear) = clear { + clear.wait()?; + } + + Ok(()) +} + +pub fn clear_default_logs_dir() -> Result<(), CaptureError> { + let logs_dir = ROOT_DATA_DIR.join("logs"); + let (writer, writer_health) = { + let mut state = CAPTURE.state.lock(); + let writer = state.detach_writer_for_dir(&logs_dir); + + (writer, state.writer_health.clone()) + }; + let should_reattach = writer.is_some(); + drop(writer); + + let remove_result = remove_dir_all_if_exists(&logs_dir); + if remove_result.is_ok() { + writer_health.mark_all_drops_cleared(); + } + + let reattach_result = if should_reattach { attach(logs_dir) } else { Ok(()) }; + + remove_result?; + reattach_result?; + + Ok(()) +} + +impl Layer for CaptureLayer +where + S: Subscriber, +{ + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let Some(_guard) = ReentrancyGuard::enter() else { + return; + }; + + let line = format_event(event); + + CAPTURE.state.lock().record_line(line); + } +} + +struct ReentrancyGuard; + +impl ReentrancyGuard { + fn enter() -> Option { + let already_formatting = FORMATTING_EVENT.with(|formatting| { + let already_formatting = formatting.get(); + formatting.set(true); + already_formatting + }); + + if already_formatting { + return None; + } + + Some(Self) + } +} + +impl Drop for ReentrancyGuard { + fn drop(&mut self) { + FORMATTING_EVENT.with(|formatting| formatting.set(false)); + } +} + +impl Default for CaptureState { + fn default() -> Self { + Self { + ring: RingBuffer::new(RING_CAP_BYTES), + writer: None, + writer_health: Arc::new(WriterHealth::default()), + replayed_on_attach: false, + } + } +} + +impl CaptureState { + fn attach(&mut self, logs_dir: PathBuf) -> Result<(), CaptureError> { + let previous_writer = self.writer.take(); + drop(previous_writer); + + std::fs::create_dir_all(&logs_dir).map_err(|source| CaptureError::CreateDir { + path: logs_dir.display().to_string(), + source, + })?; + + let mut file = RollingLogFile::open(logs_dir)?; + if !self.replayed_on_attach { + for line in self.ring.iter() { + file.write_entry(line)?; + } + + self.replayed_on_attach = true; + } + + self.writer = Some(LogWriter::spawn(file, self.writer_health.clone())?); + Ok(()) + } + + fn record_line(&mut self, line: impl Into) { + let mut line = line.into(); + line.push('\n'); + self.ring.push(line.clone()); + + if let Some(writer) = &self.writer { + writer.write(line); + } + } + + fn snapshot_start(&self) -> SnapshotStart { + let Some(writer) = &self.writer else { + return SnapshotStart::Ring(self.ring.text()); + }; + + SnapshotStart::Writer { + dir: writer.handle.dir.clone(), + ring_text: self.ring.text(), + flush: writer.handle.start_flush(), + } + } + + #[cfg(test)] + fn snapshot_text(&self) -> String { + self.snapshot_start().finish() + } + + #[cfg(test)] + fn clear(&mut self) -> Result<(), CaptureError> { + if let Some(clear) = self.start_clear()? { + clear.wait()?; + } + + Ok(()) + } + + fn start_clear(&mut self) -> Result, CaptureError> { + let marker = format!("diagnostics logs cleared at {}", timestamp()); + self.ring.clear(); + self.ring.push(format!("{marker}\n")); + + self.writer + .as_ref() + .map(|writer| writer.handle.start_clear_and_write(format!("{marker}\n"))) + .transpose() + } + + fn detach_writer_for_dir(&mut self, logs_dir: &Path) -> Option { + self.ring.clear(); + self.replayed_on_attach = false; + + if self.writer.as_ref().is_none_or(|writer| writer.handle.dir != logs_dir) { + return None; + } + + self.writer.take() + } +} + +enum SnapshotStart { + Ring(String), + Writer { dir: PathBuf, ring_text: String, flush: Result }, +} + +impl SnapshotStart { + fn finish(self) -> String { + let text = match self { + Self::Ring(text) => text, + Self::Writer { dir, ring_text, flush } => { + match flush { + Ok(flush) => match flush.wait() { + Ok(Some(error)) => { + return disk_incomplete_snapshot( + format!("failed to write Rust diagnostics log file: {error}"), + &ring_text, + ); + } + Ok(None) => {} + Err(error) => { + return disk_incomplete_snapshot( + format!("failed to flush Rust diagnostics log file: {error}"), + &ring_text, + ); + } + }, + Err(error) => { + return disk_incomplete_snapshot( + format!("failed to flush Rust diagnostics log file: {error}"), + &ring_text, + ); + } + } + + let mut text = String::new(); + text.push_str(&RollingLogFile::snapshot_text_in_dir(&dir)); + text + } + }; + + if text.is_empty() { "no Rust logs captured\n".to_string() } else { text } + } +} + +fn disk_incomplete_snapshot(reason: String, ring_text: &str) -> String { + let mut text = String::new(); + text.push_str(&reason); + text.push('\n'); + text.push_str( + "using in-memory Rust diagnostics log fallback because disk capture may be incomplete\n", + ); + text.push_str(ring_text); + + text +} + +struct FlushReply { + receiver: mpsc::Receiver, CaptureError>>, +} + +impl FlushReply { + fn wait(self) -> Result, CaptureError> { + self.receiver + .recv() + .map_err(|_| CaptureError::WriterUnavailable { action: "flush diagnostics logs" })? + } +} + +struct WriterReply { + receiver: mpsc::Receiver>, + action: &'static str, +} + +impl WriterReply { + fn wait(self) -> Result<(), CaptureError> { + self.receiver.recv().map_err(|_| CaptureError::WriterUnavailable { action: self.action })? + } +} + +impl LogWriter { + fn spawn(file: RollingLogFile, health: Arc) -> Result { + let dir = file.dir.clone(); + let (sender, receiver) = mpsc::sync_channel(WRITER_QUEUE_CAPACITY); + let writer_health = health.clone(); + let join_handle = thread::Builder::new() + .name("cove-diagnostics-log-writer".to_string()) + .spawn(move || run_writer(file, receiver, writer_health)) + .map_err(|source| CaptureError::StartWriter { source })?; + + let handle = LogWriterHandle { dir, sender, health }; + + Ok(Self { handle, join_handle: Some(join_handle) }) + } + + fn write(&self, entry: String) { + self.handle.write(entry); + } +} + +impl LogWriterHandle { + fn write(&self, entry: String) { + let entry = if entry.len() > LOG_FILE_BYTES { + last_bytes_at_token_boundary(&entry, LOG_FILE_BYTES) + } else { + entry + }; + + if matches!( + self.sender.try_send(WriterCommand::Write(entry)), + Err(mpsc::TrySendError::Full(_)) + ) { + self.health.record_dropped_write(); + } + } + + fn start_flush(&self) -> Result { + let (reply, receiver) = mpsc::channel(); + self.sender + .send(WriterCommand::Flush(reply)) + .map_err(|_| CaptureError::WriterUnavailable { action: "flush diagnostics logs" })?; + + Ok(FlushReply { receiver }) + } + + fn start_clear_and_write(&self, marker: String) -> Result { + let (reply, receiver) = mpsc::channel(); + let clear_drops_through = self.health.total_dropped_writes(); + self.sender + .send(WriterCommand::ClearAndWrite { marker, clear_drops_through, reply }) + .map_err(|_| CaptureError::WriterUnavailable { action: "clear diagnostics logs" })?; + + Ok(WriterReply { receiver, action: "clear diagnostics logs" }) + } +} + +impl Drop for LogWriter { + fn drop(&mut self) { + let _ = self.handle.sender.send(WriterCommand::Shutdown); + if let Some(join_handle) = self.join_handle.take() { + let _ = join_handle.join(); + } + } +} + +impl WriterHealth { + fn record_dropped_write(&self) { + self.dropped_writes.fetch_add(1, Ordering::Relaxed); + } + + fn total_dropped_writes(&self) -> usize { + self.dropped_writes.load(Ordering::Relaxed) + } + + fn dropped_writes_since_clear(&self) -> usize { + self.total_dropped_writes() + .saturating_sub(self.cleared_drops_through.load(Ordering::Relaxed)) + } + + fn mark_cleared_through(&self, dropped_writes: usize) { + self.cleared_drops_through.store(dropped_writes, Ordering::Relaxed); + } + + fn mark_all_drops_cleared(&self) { + self.mark_cleared_through(self.total_dropped_writes()); + } +} + +fn run_writer( + mut file: RollingLogFile, + receiver: mpsc::Receiver, + health: Arc, +) { + let mut last_write_error = None; + + for command in receiver { + match command { + WriterCommand::Write(entry) => { + if let Err(error) = file.write_entry(&entry) { + last_write_error = Some(error.to_string()); + } + } + WriterCommand::Flush(reply) => { + let dropped_writes = health.dropped_writes_since_clear(); + let queue_error = (dropped_writes > 0).then(|| { + format!( + "dropped {dropped_writes} entries because the diagnostics writer queue was full" + ) + }); + let incomplete_reason = match (&last_write_error, queue_error) { + (Some(write_error), Some(queue_error)) => { + Some(format!("{write_error}; {queue_error}")) + } + (Some(write_error), None) => Some(write_error.clone()), + (None, queue_error) => queue_error, + }; + + let result = file.flush().map(|()| incomplete_reason); + let _ = reply.send(result); + } + WriterCommand::ClearAndWrite { marker, clear_drops_through, reply } => { + let result = file.clear_and_write(&marker); + if result.is_ok() { + last_write_error = None; + health.mark_cleared_through(clear_drops_through); + } + let _ = reply.send(result); + } + WriterCommand::Shutdown => break, + } + } +} + +impl RingBuffer { + fn new(cap_bytes: usize) -> Self { + Self { lines: VecDeque::new(), bytes: 0, cap_bytes } + } + + fn push(&mut self, mut line: String) { + if line.len() > self.cap_bytes { + line = last_bytes_at_token_boundary(&line, self.cap_bytes); + } + + self.bytes += line.len(); + self.lines.push_back(line); + + while self.bytes > self.cap_bytes { + let Some(front) = self.lines.pop_front() else { break }; + self.bytes = self.bytes.saturating_sub(front.len()); + } + } + + fn iter(&self) -> impl Iterator { + self.lines.iter().map(String::as_str) + } + + fn text(&self) -> String { + self.lines.iter().fold(String::new(), |mut text, line| { + text.push_str(line); + text + }) + } + + fn clear(&mut self) { + self.lines.clear(); + self.bytes = 0; + } +} + +impl RollingLogFile { + fn open(dir: PathBuf) -> Result { + let path = current_log_path(&dir); + let file = OpenOptions::new().create(true).append(true).open(&path).map_err(|source| { + CaptureError::OpenFile { path: path.display().to_string(), source } + })?; + let current_size = + file.metadata().map(|metadata| metadata.len() as usize).unwrap_or_default(); + + Ok(Self { dir, file, current_size }) + } + + fn write_entry(&mut self, entry: &str) -> Result<(), CaptureError> { + let entry = if entry.len() > LOG_FILE_BYTES { + last_bytes_at_token_boundary(entry, LOG_FILE_BYTES) + } else { + entry.to_string() + }; + + if self.current_size > 0 && self.current_size + entry.len() > LOG_FILE_BYTES { + self.rotate()?; + } + + self.file.write_all(entry.as_bytes()).map_err(|source| CaptureError::WriteFile { + path: current_log_path(&self.dir).display().to_string(), + source, + })?; + self.current_size += entry.len(); + + Ok(()) + } + + fn flush(&mut self) -> Result<(), CaptureError> { + self.file.flush().map_err(|source| CaptureError::WriteFile { + path: current_log_path(&self.dir).display().to_string(), + source, + }) + } + + fn rotate(&mut self) -> Result<(), CaptureError> { + self.file.flush().map_err(|source| CaptureError::WriteFile { + path: current_log_path(&self.dir).display().to_string(), + source, + })?; + + let oldest = archived_log_path(&self.dir, ARCHIVE_FILE_COUNT); + remove_file_if_exists(&oldest)?; + + for index in (1..ARCHIVE_FILE_COUNT).rev() { + let source = archived_log_path(&self.dir, index); + let destination = archived_log_path(&self.dir, index + 1); + rename_if_exists(&source, &destination)?; + } + + let current = current_log_path(&self.dir); + let first_archive = archived_log_path(&self.dir, 1); + rename_if_exists(¤t, &first_archive)?; + + *self = Self::open(self.dir.clone())?; + Ok(()) + } + + fn remove_all_logs(&self) -> Result<(), CaptureError> { + remove_file_if_exists(¤t_log_path(&self.dir))?; + for index in 1..=ARCHIVE_FILE_COUNT { + remove_file_if_exists(&archived_log_path(&self.dir, index))?; + } + + Ok(()) + } + + fn clear_and_write(&mut self, marker: &str) -> Result<(), CaptureError> { + self.remove_all_logs()?; + *self = Self::open(self.dir.clone())?; + self.write_entry(marker) + } + + fn snapshot_text_in_dir(dir: &Path) -> String { + Self::log_paths(dir).fold(String::new(), |mut text, path| { + match std::fs::read_to_string(path) { + Ok(file_text) => text.push_str(&file_text), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + text.push_str(&format!("failed to read Rust diagnostics log file: {error}\n")); + } + } + + text + }) + } + + fn log_paths(dir: &Path) -> impl Iterator + '_ { + (1..=ARCHIVE_FILE_COUNT) + .rev() + .map(|index| archived_log_path(dir, index)) + .chain(std::iter::once(current_log_path(dir))) + } +} + +fn format_event(event: &Event<'_>) -> String { + let metadata = event.metadata(); + let mut visitor = EventVisitor::default(); + event.record(&mut visitor); + + let fields = visitor.fields.join(" "); + match (visitor.message, fields.is_empty()) { + (Some(message), true) => { + format!("{} {} {}: {message}", timestamp(), metadata.level(), metadata.target()) + } + (Some(message), false) => { + format!( + "{} {} {}: {message} {fields}", + timestamp(), + metadata.level(), + metadata.target() + ) + } + (None, true) => format!("{} {} {}", timestamp(), metadata.level(), metadata.target()), + (None, false) => { + format!("{} {} {}: {fields}", timestamp(), metadata.level(), metadata.target()) + } + } +} + +#[derive(Default)] +struct EventVisitor { + message: Option, + fields: Vec, +} + +impl EventVisitor { + fn record_value(&mut self, field: &tracing::field::Field, value: String) { + if field.name() == "message" { + self.message = Some(value); + return; + } + + self.fields.push(format!("{}={value}", field.name())); + } +} + +impl Visit for EventVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) { + self.record_value(field, format!("{value:?}")); + } + + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + self.record_value(field, value.to_string()); + } + + fn record_i64(&mut self, field: &tracing::field::Field, value: i64) { + self.record_value(field, value.to_string()); + } + + fn record_u64(&mut self, field: &tracing::field::Field, value: u64) { + self.record_value(field, value.to_string()); + } + + fn record_bool(&mut self, field: &tracing::field::Field, value: bool) { + self.record_value(field, value.to_string()); + } +} + +fn current_log_path(dir: &Path) -> PathBuf { + dir.join(CURRENT_LOG_FILE) +} + +fn archived_log_path(dir: &Path, index: usize) -> PathBuf { + dir.join(format!("cove-rust.{index}.log")) +} + +fn rename_if_exists(source: &Path, destination: &Path) -> Result<(), CaptureError> { + if !source.exists() { + return Ok(()); + } + + std::fs::rename(source, destination).map_err(|source_error| CaptureError::RotateFile { + path: format!("{} -> {}", source.display(), destination.display()), + source: source_error, + }) +} + +fn remove_file_if_exists(path: &Path) -> Result<(), CaptureError> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(CaptureError::RemoveFile { path: path.display().to_string(), source }), + } +} + +fn remove_dir_all_if_exists(path: &Path) -> Result<(), CaptureError> { + match std::fs::remove_dir_all(path) { + Ok(()) => Ok(()), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(CaptureError::RemoveDir { path: path.display().to_string(), source }), + } +} + +fn last_bytes_at_token_boundary(value: &str, max_bytes: usize) -> String { + let mut start = value.len().saturating_sub(max_bytes); + while !value.is_char_boundary(start) { + start += 1; + } + + if start == 0 { + return value.to_string(); + } + + while start < value.len() { + let Some(character) = value[start..].chars().next() else { + break; + }; + if !is_redaction_token_character(character) { + break; + } + + start += character.len_utf8(); + } + + value[start..].to_string() +} + +fn is_redaction_token_character(character: char) -> bool { + character.is_ascii_alphanumeric() +} + +fn timestamp() -> String { + jiff::Timestamp::now().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn writer_that_replaces_current_file_on_shutdown(dir: &Path) -> LogWriter { + let writer_dir = dir.to_path_buf(); + let (sender, receiver) = mpsc::sync_channel(WRITER_QUEUE_CAPACITY); + let join_handle = thread::spawn(move || { + while let Ok(command) = receiver.recv() { + if matches!(command, WriterCommand::Shutdown) { + std::fs::remove_file(current_log_path(&writer_dir)).unwrap(); + std::fs::write(current_log_path(&writer_dir), "replacement\n").unwrap(); + break; + } + } + }); + + LogWriter { + handle: LogWriterHandle { + dir: dir.to_path_buf(), + sender, + health: Arc::new(WriterHealth::default()), + }, + join_handle: Some(join_handle), + } + } + + #[test] + fn full_writer_queue_drops_writes_and_reports_incomplete_disk_capture() -> eyre::Result<()> { + let dir = TempDir::new()?; + let file = RollingLogFile::open(dir.path().to_path_buf())?; + let (sender, receiver) = mpsc::sync_channel(1); + let health = Arc::new(WriterHealth::default()); + let handle = + LogWriterHandle { dir: dir.path().to_path_buf(), sender, health: health.clone() }; + + handle.write("first\n".to_string()); + handle.write("dropped\n".to_string()); + + let join_handle = thread::spawn(move || run_writer(file, receiver, health)); + let error = handle.start_flush()?.wait()?.expect("dropped write is reported"); + + assert!(error.contains("dropped 1 entries")); + assert_eq!(std::fs::read_to_string(current_log_path(dir.path()))?, "first\n"); + + handle.start_clear_and_write("cleared\n".to_string())?.wait()?; + assert_eq!(handle.start_flush()?.wait()?, None); + assert_eq!(std::fs::read_to_string(current_log_path(dir.path()))?, "cleared\n"); + + handle.sender.send(WriterCommand::Shutdown)?; + join_handle.join().expect("writer exits cleanly"); + + Ok(()) + } + + #[test] + fn ring_cap_keeps_latest_lines_in_order() { + let mut ring = RingBuffer::new(18); + + ring.push("first\n".to_string()); + ring.push("second\n".to_string()); + ring.push("third\n".to_string()); + + assert_eq!(ring.text(), "second\nthird\n"); + } + + #[test] + fn oversized_line_drops_partial_leading_token() { + let mut ring = RingBuffer::new(10); + + ring.push("prefix xprvSECRET\n".to_string()); + + assert_eq!(ring.text(), "\n"); + } + + #[test] + fn oversized_line_keeps_tail_from_token_boundary() { + let mut ring = RingBuffer::new(17); + + ring.push("prefix xprvSECRET suffix\n".to_string()); + + assert_eq!(ring.text(), " suffix\n"); + } + + #[test] + fn rolling_file_caps_total_archives() -> eyre::Result<()> { + let dir = TempDir::new()?; + let mut file = RollingLogFile::open(dir.path().to_path_buf())?; + let entry = format!("{}\n", "x".repeat(LOG_FILE_BYTES / 2)); + + for _ in 0..20 { + file.write_entry(&entry)?; + } + + let total_bytes = std::fs::read_dir(dir.path())? + .map(|entry| entry.map(|entry| entry.metadata().map(|metadata| metadata.len()))) + .collect::, _>>()? + .into_iter() + .collect::, _>>()? + .into_iter() + .sum::(); + + assert!(total_bytes <= MAX_TOTAL_FILE_BYTES as u64); + + Ok(()) + } + + #[test] + fn attach_replays_ring_once() -> eyre::Result<()> { + let dir = TempDir::new()?; + let mut state = CaptureState::default(); + state.record_line("before attach"); + + state.attach(dir.path().to_path_buf())?; + state.attach(dir.path().to_path_buf())?; + + let text = std::fs::read_to_string(current_log_path(dir.path()))?; + assert_eq!(text.matches("before attach").count(), 1); + + Ok(()) + } + + #[test] + fn reattach_stops_previous_writer_before_opening_replacement() -> eyre::Result<()> { + let dir = TempDir::new()?; + std::fs::write(current_log_path(dir.path()), "original\n")?; + + let mut state = CaptureState::default(); + state.writer = Some(writer_that_replaces_current_file_on_shutdown(dir.path())); + state.attach(dir.path().to_path_buf())?; + state.record_line("after reattach"); + + assert_eq!(state.snapshot_text(), "replacement\nafter reattach\n"); + + Ok(()) + } + + #[test] + fn snapshot_reads_persisted_current_file_after_restart() -> eyre::Result<()> { + let dir = TempDir::new()?; + { + let mut state = CaptureState::default(); + state.attach(dir.path().to_path_buf())?; + state.record_line("before restart"); + } + + let mut state = CaptureState::default(); + state.attach(dir.path().to_path_buf())?; + + assert!(state.snapshot_text().contains("before restart")); + + Ok(()) + } + + #[test] + fn snapshot_reads_archives_before_current_file() -> eyre::Result<()> { + let dir = TempDir::new()?; + std::fs::write(archived_log_path(dir.path(), 2), "oldest\n")?; + std::fs::write(archived_log_path(dir.path(), 1), "older\n")?; + std::fs::write(current_log_path(dir.path()), "current\n")?; + + let mut state = CaptureState::default(); + state.attach(dir.path().to_path_buf())?; + + assert_eq!(state.snapshot_text(), "oldest\nolder\ncurrent\n"); + + Ok(()) + } + + #[test] + fn snapshot_uses_ring_fallback_after_writer_failure() -> eyre::Result<()> { + let dir = TempDir::new()?; + let mut state = CaptureState::default(); + state.attach(dir.path().to_path_buf())?; + std::fs::remove_dir_all(dir.path())?; + + state.record_line(" ".repeat(LOG_FILE_BYTES)); + state.record_line("after disk failure"); + + let text = state.snapshot_text(); + + assert!(text.contains("failed to write Rust diagnostics log file")); + assert!(text.contains("disk capture may be incomplete")); + assert!(text.contains("after disk failure")); + + let text = state.snapshot_text(); + assert!(text.contains("failed to write Rust diagnostics log file")); + assert!(text.contains("after disk failure")); + + Ok(()) + } + + #[test] + fn clear_reopens_file_with_marker() -> eyre::Result<()> { + let dir = TempDir::new()?; + let mut state = CaptureState::default(); + state.attach(dir.path().to_path_buf())?; + state.record_line("before clear"); + + state.clear()?; + state.record_line("after clear"); + + let text = state.snapshot_text(); + assert!(!text.contains("before clear")); + assert!(text.contains("diagnostics logs cleared at")); + assert!(text.contains("after clear")); + + Ok(()) + } +} diff --git a/rust/crates/cove-http/src/lib.rs b/rust/crates/cove-http/src/lib.rs index 564880f98..870b0695a 100644 --- a/rust/crates/cove-http/src/lib.rs +++ b/rust/crates/cove-http/src/lib.rs @@ -3,6 +3,15 @@ use std::time::Duration; /// Build a reqwest Client that uses webpki-roots for TLS cert verification, /// bypassing rustls-platform-verifier (which requires Android JNI init) pub fn new_client() -> Result { + client_builder().build() +} + +/// Build a reqwest Client with the shared TLS configuration that does not follow redirects +pub fn new_client_without_redirects() -> Result { + client_builder().redirect(reqwest::redirect::Policy::none()).build() +} + +fn client_builder() -> reqwest::ClientBuilder { let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); @@ -13,5 +22,4 @@ pub fn new_client() -> Result { .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(30)) .tls_backend_preconfigured(tls_config) - .build() } diff --git a/rust/crates/cove-tokio/src/debounced_task.rs b/rust/crates/cove-tokio/src/debounced_task.rs index 79b4b5a71..a64e38605 100644 --- a/rust/crates/cove-tokio/src/debounced_task.rs +++ b/rust/crates/cove-tokio/src/debounced_task.rs @@ -8,7 +8,7 @@ use std::{ }; use arc_swap::ArcSwapOption; -use tracing::debug; +use tracing::trace; use crate::abortable_task::AbortableTask; @@ -61,14 +61,14 @@ where // drop the previous task (aborts it) let had_previous = self.task.swap(None).is_some(); if had_previous { - debug!("[{name}] debounce cancelled previous pending task (replace #{replace_num})"); + trace!("[{name}] debounce cancelled previous pending task (replace #{replace_num})"); } let task = Arc::new(AbortableTask::spawn(async move { - debug!("[{name}] debounce waiting {debounce:?} (replace #{replace_num})"); + trace!("[{name}] debounce waiting {debounce:?} (replace #{replace_num})"); tokio::time::sleep(debounce).await; let exec_num = execute_count.fetch_add(1, Ordering::Relaxed) + 1; - debug!("[{name}] debounce executing (exec #{exec_num}, after replace #{replace_num})"); + trace!("[{name}] debounce executing (exec #{exec_num}, after replace #{replace_num})"); fut.await })); diff --git a/rust/src/app.rs b/rust/src/app.rs index 41cc58523..0cb48cdc1 100644 --- a/rust/src/app.rs +++ b/rust/src/app.rs @@ -108,8 +108,6 @@ impl_default_for!(App); impl App { /// Create a new instance of the app fn new() -> Self { - set_env(); - crate::logging::init(); // storage must be bootstrapped before any database access @@ -776,23 +774,6 @@ pub fn initialize_app() { App::global(); } -fn set_env() { - //TODO: set manually in code for now - #[cfg(debug_assertions)] - { - if std::env::var("RUST_LOG").is_err() { - unsafe { std::env::set_var("RUST_LOG", "cove=debug") } - } - } - - #[cfg(not(debug_assertions))] - { - if std::env::var("RUST_LOG").is_err() { - unsafe { std::env::set_var("RUST_LOG", "cove=info") } - } - } -} - impl Dispatchable for AppAction { fn flush(actions: Vec) { let app = App::global(); diff --git a/rust/src/bootstrap.rs b/rust/src/bootstrap.rs index 28f6adcc3..23f696d98 100644 --- a/rust/src/bootstrap.rs +++ b/rust/src/bootstrap.rs @@ -266,6 +266,8 @@ fn ensure_storage_bootstrapped_internal(track_progress: bool) -> Result Result { crate::logging::init(); + attach_persistent_diagnostics_logging(); + if track_progress { set_step(BootstrapStep::DerivingEncryptionKey); } @@ -376,6 +378,12 @@ fn do_bootstrap(track_progress: bool) -> Result { Ok(bdk_count) } +fn attach_persistent_diagnostics_logging() { + if let Err(error) = cove_common::logging::capture::attach_to_default_logs_dir() { + warn!("Failed to attach persistent Rust diagnostics logging: {error}"); + } +} + /// Returns the absolute path to the root data directory /// /// Used by iOS to set isExcludedFromBackup on the data directory, diff --git a/rust/src/bootstrap/diagnostics.rs b/rust/src/bootstrap/diagnostics.rs index d3e890f03..f9a57b0f5 100644 --- a/rust/src/bootstrap/diagnostics.rs +++ b/rust/src/bootstrap/diagnostics.rs @@ -558,6 +558,7 @@ fn main_table_classification() -> TableClassification { crate::database::unsigned_transactions::MAIN_TABLE.name(), crate::database::unsigned_transactions::BY_WALLET_TABLE.name(), crate::database::historical_price::TABLE.name(), + crate::database::diagnostics_reports::TABLE.name(), ] .into_iter() .map(str::to_string) @@ -790,7 +791,7 @@ mod tests { std::fs::create_dir_all(&wallet_dir)?; create_redb_with_tables( &dir.path().join("cove.db"), - &["global_flag", "global_bool_config"], + &["global_flag", "global_bool_config", "diagnostics_reports"], )?; reset_test_state(); @@ -799,8 +800,10 @@ mod tests { assert!(report.contains("global_flag (current)")); assert!(report.contains("global_bool_config (historical)")); + assert!(report.contains("diagnostics_reports (current)")); assert!(report.contains("historical skipped tables: global_bool_config")); assert!(!report.contains("unknown tables: global_bool_config")); + assert!(!report.contains("unknown tables: diagnostics_reports")); Ok(()) } diff --git a/rust/src/database.rs b/rust/src/database.rs index b68483c88..6ab82f235 100644 --- a/rust/src/database.rs +++ b/rust/src/database.rs @@ -3,6 +3,7 @@ pub mod cbor; pub mod cloud_backup; +pub mod diagnostics_reports; pub mod encrypted_backend; pub mod error; pub mod global_cache; @@ -24,6 +25,7 @@ use arc_swap::ArcSwap; use cloud_backup::{ CloudBackupStateTable, CloudBlobSyncStateTable, ensure_table_type_compatibility, }; +use diagnostics_reports::DiagnosticsReportsTable; use global_cache::GlobalCacheTable; use global_config::{GlobalConfigKey, GlobalConfigTable}; use global_flag::GlobalFlagTable; @@ -53,6 +55,7 @@ pub struct Database { pub wallets: WalletsTable, pub unsigned_transactions: UnsignedTransactionsTable, pub historical_prices: HistoricalPriceTable, + pub diagnostics_reports: DiagnosticsReportsTable, } #[uniffi::export] @@ -82,10 +85,22 @@ impl Database { self.historical_prices.clone() } + pub fn diagnostics_reports(&self) -> DiagnosticsReportsTable { + self.diagnostics_reports.clone() + } + pub fn dangerous_reset_all_data(&self) { - if let Err(error) = std::fs::remove_file(database_location()) { - error!("unable to delete database cove_main error: {error}"); - return; + match std::fs::remove_file(database_location()) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + error!("unable to delete database cove_main error: {error}"); + return; + } + } + + if let Err(error) = cove_common::logging::capture::clear_default_logs_dir() { + error!("unable to clear diagnostics logs during data reset: {error}"); } let db = Self::init().expect("failed to reinitialize database after reset"); @@ -136,6 +151,7 @@ impl Database { let cloud_blob_sync_states = CloudBlobSyncStateTable::new(main_db_arc.clone(), &write_txn); let unsigned_transactions = UnsignedTransactionsTable::new(main_db_arc.clone(), &write_txn); let historical_prices = HistoricalPriceTable::new(main_db_arc.clone(), &write_txn); + let diagnostics_reports = DiagnosticsReportsTable::new(main_db_arc.clone(), &write_txn); write_txn.commit()?; @@ -148,6 +164,7 @@ impl Database { wallets, unsigned_transactions, historical_prices, + diagnostics_reports, }; database.backfill_onboarding_complete_from_legacy_state(); diff --git a/rust/src/database/diagnostics_reports.rs b/rust/src/database/diagnostics_reports.rs new file mode 100644 index 000000000..3cc152f7e --- /dev/null +++ b/rust/src/database/diagnostics_reports.rs @@ -0,0 +1,325 @@ +use std::sync::Arc; + +use redb::{TableDefinition, TypeName, Value}; +use serde::{Deserialize, Serialize}; +use tracing::error; + +use cove_util::result_ext::ResultExt as _; + +use super::Error; + +const HISTORY_KEY: &str = "history"; +const MAX_REPORTS: usize = 5; +const REPORTS_JSON_TYPE_NAME: &str = + "SerdeJson"; + +pub const TABLE: TableDefinition<&'static str, DiagnosticsReportsJson> = + TableDefinition::new("diagnostics_reports"); + +#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, uniffi::Record)] +pub struct DiagnosticsReportRecord { + pub report_id: String, + pub submitted_at: u64, + pub description: Option, +} + +#[derive(Debug, Clone, uniffi::Object)] +pub struct DiagnosticsReportsTable { + db: Arc, +} + +#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Error, thiserror::Error)] +#[uniffi::export(Display)] +pub enum DiagnosticsReportsTableError { + #[error("failed to save diagnostics report history: {0}")] + Save(String), + + #[error("failed to read diagnostics report history: {0}")] + Read(String), +} + +#[derive(Debug)] +pub struct DiagnosticsReportsJson; + +#[derive(Debug)] +pub struct DiagnosticsReportHistory { + records: Vec, + decode_error: Option, +} + +impl Value for DiagnosticsReportsJson { + type SelfType<'a> + = DiagnosticsReportHistory + where + Self: 'a; + + type AsBytes<'a> + = Vec + where + Self: 'a; + + fn fixed_width() -> Option { + None + } + + fn from_bytes<'a>(data: &'a [u8]) -> Self::SelfType<'a> + where + Self: 'a, + { + DiagnosticsReportHistory::from_bytes(data) + } + + fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> Self::AsBytes<'a> + where + Self: 'b, + { + serde_json::to_vec(&value.records).expect("failed to serialize diagnostics report history") + } + + fn type_name() -> TypeName { + // pin the table metadata so moving this module does not break old installs + TypeName::new(REPORTS_JSON_TYPE_NAME) + } +} + +impl DiagnosticsReportHistory { + fn from_records(records: Vec) -> Self { + Self { records, decode_error: None } + } + + fn from_bytes(data: &[u8]) -> Self { + match serde_json::from_slice(data) { + Ok(records) => Self::from_records(records), + Err(error) => { + let error = error.to_string(); + error!("Failed to decode diagnostics report history: {error}"); + + Self { records: Vec::new(), decode_error: Some(error) } + } + } + } + + fn into_records(self) -> Result, DiagnosticsReportsTableError> { + if let Some(error) = self.decode_error { + return Err(DiagnosticsReportsTableError::Read(error)); + } + + Ok(self.records) + } +} + +impl DiagnosticsReportRecord { + pub fn now(report_id: String, description: Option) -> Self { + let submitted_at = jiff::Timestamp::now().as_second().cast_unsigned(); + + Self { report_id, submitted_at, description } + } +} + +impl DiagnosticsReportsTable { + pub fn new(db: Arc, write_txn: &redb::WriteTransaction) -> Self { + write_txn.open_table(TABLE).expect("failed to create diagnostics reports table"); + + Self { db } + } + + pub fn add(&self, record: DiagnosticsReportRecord) -> Result<(), Error> { + let write_txn = self.db.begin_write().map_err_str(Error::DatabaseAccess)?; + + { + let mut table = write_txn.open_table(TABLE).map_err_str(Error::TableAccess)?; + let mut records = read_history(&table)?; + + records.insert(0, record); + records.truncate(MAX_REPORTS); + + let history = DiagnosticsReportHistory::from_records(records); + table.insert(HISTORY_KEY, &history).map_err_str(DiagnosticsReportsTableError::Save)?; + } + + write_txn.commit().map_err_str(Error::DatabaseAccess)?; + + Ok(()) + } +} + +fn read_history( + table: &impl redb::ReadableTable<&'static str, DiagnosticsReportsJson>, +) -> Result, Error> { + table + .get(HISTORY_KEY) + .map_err_str(DiagnosticsReportsTableError::Read)? + .map(|value| value.value().into_records()) + .transpose() + .map_err(Error::from) + .map(Option::unwrap_or_default) +} + +#[uniffi::export] +impl DiagnosticsReportsTable { + pub fn all(&self) -> Result, Error> { + let read_txn = self.db.begin_read().map_err_str(Error::DatabaseAccess)?; + let table = read_txn.open_table(TABLE).map_err_str(Error::TableAccess)?; + let records = read_history(&table)?; + + Ok(records) + } + + pub fn clear(&self) -> Result<(), Error> { + let write_txn = self.db.begin_write().map_err_str(Error::DatabaseAccess)?; + + { + let mut table = write_txn.open_table(TABLE).map_err_str(Error::TableAccess)?; + table.remove(HISTORY_KEY).map_err_str(DiagnosticsReportsTableError::Save)?; + } + + write_txn.commit().map_err_str(Error::DatabaseAccess)?; + + Ok(()) + } +} + +#[cfg(test)] +pub(crate) mod test_support { + use redb::{TableDefinition, TypeName, Value}; + + use super::{DiagnosticsReportsTable, HISTORY_KEY, REPORTS_JSON_TYPE_NAME}; + + #[derive(Debug)] + struct InvalidDiagnosticsReportsJson; + + const INVALID_TABLE: TableDefinition<&'static str, InvalidDiagnosticsReportsJson> = + TableDefinition::new("diagnostics_reports"); + + impl Value for InvalidDiagnosticsReportsJson { + type SelfType<'a> + = Vec + where + Self: 'a; + + type AsBytes<'a> + = Vec + where + Self: 'a; + + fn fixed_width() -> Option { + None + } + + fn from_bytes<'a>(data: &'a [u8]) -> Self::SelfType<'a> + where + Self: 'a, + { + data.to_vec() + } + + fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> Self::AsBytes<'a> + where + Self: 'b, + { + value.clone() + } + + fn type_name() -> TypeName { + TypeName::new(REPORTS_JSON_TYPE_NAME) + } + } + + pub(crate) fn write_invalid_history(table: &DiagnosticsReportsTable) { + let write_txn = table.db.begin_write().expect("failed to begin write txn"); + + { + let mut raw_table = + write_txn.open_table(INVALID_TABLE).expect("failed to open invalid table"); + raw_table + .insert(HISTORY_KEY, &b"not json".to_vec()) + .expect("failed to insert invalid history"); + } + + write_txn.commit().expect("failed to commit invalid history"); + } + + pub(crate) fn read_invalid_history(db: &redb::Database) -> Vec { + let read_txn = db.begin_read().expect("failed to begin read txn"); + let raw_table = read_txn.open_table(INVALID_TABLE).expect("failed to open invalid table"); + + raw_table + .get(HISTORY_KEY) + .expect("failed to read invalid history") + .expect("invalid history exists") + .value() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_table() -> (tempfile::TempDir, DiagnosticsReportsTable) { + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + let db_path = tmp.path().join("diagnostics_reports.redb"); + let db = Arc::new(redb::Database::create(db_path).expect("failed to create redb")); + let write_txn = db.begin_write().expect("failed to begin write txn"); + let table = DiagnosticsReportsTable::new(db, &write_txn); + write_txn.commit().expect("failed to commit table creation"); + + (tmp, table) + } + + fn record(index: u64) -> DiagnosticsReportRecord { + DiagnosticsReportRecord { + report_id: format!("report-{index}"), + submitted_at: index, + description: Some(format!("description {index}")), + } + } + + #[test] + fn value_type_name_is_pinned() { + assert_eq!(DiagnosticsReportsJson::type_name(), TypeName::new(REPORTS_JSON_TYPE_NAME)); + } + + #[test] + fn add_keeps_newest_five_reports() { + let (_tmp, table) = test_table(); + + for index in 0..7 { + table.add(record(index)).expect("failed to add report"); + } + + let reports = table.all().expect("failed to read reports"); + let ids = reports.into_iter().map(|record| record.report_id).collect::>(); + + assert_eq!(ids, ["report-6", "report-5", "report-4", "report-3", "report-2"]); + } + + #[test] + fn clear_removes_report_history() { + let (_tmp, table) = test_table(); + + table.add(record(1)).expect("failed to add report"); + assert_eq!(table.all().expect("failed to read reports").len(), 1); + + table.clear().expect("failed to clear reports"); + + assert!(table.all().expect("failed to read reports").is_empty()); + } + + #[test] + fn add_does_not_overwrite_history_after_decode_failure() { + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + let db_path = tmp.path().join("diagnostics_reports.redb"); + let db = Arc::new(redb::Database::create(db_path).expect("failed to create redb")); + let write_txn = db.begin_write().expect("failed to begin write txn"); + let table = DiagnosticsReportsTable::new(db.clone(), &write_txn); + write_txn.commit().expect("failed to commit table creation"); + test_support::write_invalid_history(&table); + + assert!(table.add(record(1)).is_err()); + assert!(table.all().is_err()); + + let raw_history = test_support::read_invalid_history(&db); + + assert_eq!(raw_history, b"not json".to_vec()); + } +} diff --git a/rust/src/database/error.rs b/rust/src/database/error.rs index 053e76210..7103fc538 100644 --- a/rust/src/database/error.rs +++ b/rust/src/database/error.rs @@ -1,6 +1,7 @@ use super::{ - global_cache::GlobalCacheTableError, global_config::GlobalConfigTableError, - global_flag::GlobalFlagTableError, historical_price::HistoricalPriceTableError, + diagnostics_reports::DiagnosticsReportsTableError, global_cache::GlobalCacheTableError, + global_config::GlobalConfigTableError, global_flag::GlobalFlagTableError, + historical_price::HistoricalPriceTableError, unsigned_transactions::UnsignedTransactionsTableError, wallet::WalletTableError, }; @@ -50,6 +51,9 @@ pub enum DatabaseError { #[error(transparent)] HistoricalPrice(#[from] HistoricalPriceTableError), + #[error(transparent)] + DiagnosticsReports(#[from] DiagnosticsReportsTableError), + #[error("unable to serialize or deserialize: {0}")] Serialization(#[from] SerdeError), diff --git a/rust/src/diagnostics.rs b/rust/src/diagnostics.rs new file mode 100644 index 000000000..7673d225d --- /dev/null +++ b/rust/src/diagnostics.rs @@ -0,0 +1,705 @@ +pub mod redact; +pub mod upload; + +use std::{fmt, sync::Arc}; + +use cove_util::result_ext::ResultExt as _; +use serde::Serialize; + +use crate::database::{Database, diagnostics_reports::DiagnosticsReportRecord}; + +const DIAGNOSTICS_REPORT_SIZE_UNIT_BYTES: u64 = 1_000; +const DIAGNOSTICS_REPORT_SIZE_UNIT_SCALE: f64 = DIAGNOSTICS_REPORT_SIZE_UNIT_BYTES as f64; +const DIAGNOSTICS_REPORT_SIZE_FRACTIONAL_LIMIT: f64 = 10.0; +const DIAGNOSTICS_REPORT_SIZE_UNITS: [&str; 6] = ["KB", "MB", "GB", "TB", "PB", "EB"]; + +#[derive(Debug, Clone, uniffi::Record)] +pub struct DiagnosticsPlatformInfo { + pub platform: String, + pub build_number: String, + pub os_version: String, + pub device_model: String, +} + +#[derive(Debug, Clone, uniffi::Error, thiserror::Error)] +#[uniffi(flat_error)] +pub enum DiagnosticsError { + #[error("Failed to build diagnostics report: {0}")] + Build(String), + + #[error("Failed to clear diagnostics logs: {0}")] + ClearLogs(String), + + #[error("Failed to submit diagnostics report: {0}")] + Submit(String), +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct DiagnosticsSubmission { + pub report_id: String, + pub history_saved: bool, + pub warning: Option, +} + +#[derive(uniffi::Object)] +pub struct DiagnosticsReport { + generated_at: String, + metadata: DiagnosticsMetadata, + sections: Vec, + preview_text: String, + redactor: redact::Redactor, +} + +impl fmt::Debug for DiagnosticsReport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DiagnosticsReport") + .field("generated_at", &self.generated_at) + .field("metadata", &self.metadata) + .field("sections_count", &self.sections.len()) + .field("preview_text", &format_args!("", self.preview_text.len())) + .field("redactor", &self.redactor) + .finish() + } +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct DiagnosticsUploadReport { + schema_version: u16, + generated_at: String, + metadata: DiagnosticsMetadata, + user_description: Option, + sections: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct DiagnosticsMetadata { + platform: String, + app_version: String, + build_number: String, + os_version: String, + device_model: String, + rust_git_hash: String, + rust_git_branch: String, + rust_build_profile: String, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct DiagnosticsSection { + title: String, + body: String, +} + +#[uniffi::export(async_runtime = "tokio")] +pub async fn build_diagnostics_report( + platform: DiagnosticsPlatformInfo, + platform_logs: String, +) -> Result, DiagnosticsError> { + cove_tokio::unblock::run_blocking(move || { + DiagnosticsReport::build(platform, platform_logs).map(Arc::new) + }) + .await +} + +#[uniffi::export] +pub fn clear_diagnostics_logs() -> Result<(), DiagnosticsError> { + cove_common::logging::capture::clear().map_err_str(DiagnosticsError::ClearLogs) +} + +#[uniffi::export(async_runtime = "tokio")] +impl DiagnosticsReport { + pub fn preview_text(&self) -> String { + self.preview_text.clone() + } + + pub fn preview_text_for_description(&self, description: Option) -> String { + self.preview_text_with_description(description) + } + + pub fn size_bytes(&self) -> u64 { + self.preview_text.len() as u64 + } + + pub fn size_bytes_for_description(&self, description: Option) -> u64 { + self.preview_text_for_description(description).len() as u64 + } + + pub fn formatted_size(&self) -> String { + format_diagnostics_report_size(self.size_bytes()) + } + + pub fn formatted_size_for_description(&self, description: Option) -> String { + format_diagnostics_report_size(self.size_bytes_for_description(description)) + } + + pub async fn submit( + &self, + description: Option, + ) -> Result { + let description = self.redacted_user_description(description); + let report = self.upload_report(description.clone()); + + let report_id = upload::submit_report(&report).await.map_err(|error| { + tracing::warn!("Failed to submit diagnostics report: {}", error.log_message()); + DiagnosticsError::Submit(error.user_message()) + })?; + + let record = DiagnosticsReportRecord::now(report_id.clone(), description); + let save_result = cove_tokio::unblock::run_blocking(move || { + Database::global().diagnostics_reports.add(record) + }) + .await; + if let Err(error) = save_result { + tracing::warn!("Failed to save diagnostics report history: {error}"); + return Ok(DiagnosticsSubmission { + report_id, + history_saved: false, + warning: Some( + "Diagnostics were uploaded, but Cove could not save the report ID on this device. Copy the report ID before closing this screen." + .to_string(), + ), + }); + } + + Ok(DiagnosticsSubmission { report_id, history_saved: true, warning: None }) + } +} + +fn user_description_for_upload(description: Option) -> Option { + description + .map(|description| description.trim().to_string()) + .filter(|description| !description.is_empty()) +} + +fn format_diagnostics_report_size(size_bytes: u64) -> String { + if size_bytes < DIAGNOSTICS_REPORT_SIZE_UNIT_BYTES { + let unit = if size_bytes == 1 { "byte" } else { "bytes" }; + + return format!("{size_bytes} {unit}"); + } + + let mut size = size_bytes as f64; + let mut unit = DIAGNOSTICS_REPORT_SIZE_UNITS[0]; + for next_unit in DIAGNOSTICS_REPORT_SIZE_UNITS { + size /= DIAGNOSTICS_REPORT_SIZE_UNIT_SCALE; + unit = next_unit; + if size < DIAGNOSTICS_REPORT_SIZE_UNIT_SCALE { + break; + } + } + + format!("{} {unit}", format_diagnostics_report_size_value(size)) +} + +fn format_diagnostics_report_size_value(size: f64) -> String { + let formatted = if size < DIAGNOSTICS_REPORT_SIZE_FRACTIONAL_LIMIT { + format!("{size:.1}") + } else { + format!("{size:.0}") + }; + + formatted.strip_suffix(".0").unwrap_or(&formatted).to_string() +} + +impl DiagnosticsReport { + fn build( + platform: DiagnosticsPlatformInfo, + platform_logs: String, + ) -> Result { + let startup = crate::bootstrap::startup_diagnostic_text_report(); + let rust_logs = cove_common::logging::capture::snapshot_text(); + + Self::build_with_sources(platform, platform_logs, startup, rust_logs) + } + + fn build_with_sources( + platform: DiagnosticsPlatformInfo, + platform_logs: String, + startup: String, + rust_logs: String, + ) -> Result { + platform.validate()?; + + let generated_at = timestamp(); + let metadata = DiagnosticsMetadata { + platform: platform.platform, + app_version: crate::build::version(), + build_number: platform.build_number, + os_version: platform.os_version, + device_model: platform.device_model, + rust_git_hash: crate::build::git_short_hash(), + rust_git_branch: crate::build::git_branch(), + rust_build_profile: crate::build::profile(), + }; + let mut redactor = redact::Redactor::with_default_paths(); + let sections = raw_sections_from(startup, platform_logs, rust_logs) + .into_iter() + .map(|section| section.redacted(&mut redactor)) + .collect::>(); + let preview_text = render_preview(&generated_at, &metadata, §ions); + + Ok(Self { generated_at, metadata, sections, preview_text, redactor }) + } + + fn upload_report(&self, user_description: Option) -> DiagnosticsUploadReport { + DiagnosticsUploadReport { + schema_version: 1, + generated_at: self.generated_at.clone(), + metadata: self.metadata.clone(), + user_description, + sections: self.sections.clone(), + } + } + + fn redacted_user_description(&self, description: Option) -> Option { + let description = user_description_for_upload(description)?; + let mut redactor = self.redactor.clone(); + + Some(redactor.redact(&description)) + } + + fn preview_text_with_description(&self, description: Option) -> String { + let Some(description) = self.redacted_user_description(description) else { + return self.preview_text.clone(); + }; + + render_preview_with_user_description(&self.preview_text, &description) + } +} + +impl DiagnosticsSection { + fn new(title: impl Into, body: impl Into) -> Self { + Self { title: title.into(), body: body.into() } + } + + fn redacted(self, redactor: &mut redact::Redactor) -> Self { + Self { title: self.title, body: redactor.redact(&self.body) } + } +} + +impl DiagnosticsPlatformInfo { + fn validate(&self) -> Result<(), DiagnosticsError> { + if self.platform.trim().is_empty() { + return Err(DiagnosticsError::Build("platform is required".to_string())); + } + + if self.build_number.trim().is_empty() { + return Err(DiagnosticsError::Build("build number is required".to_string())); + } + + if self.os_version.trim().is_empty() { + return Err(DiagnosticsError::Build("OS version is required".to_string())); + } + + if self.device_model.trim().is_empty() { + return Err(DiagnosticsError::Build("device model is required".to_string())); + } + + Ok(()) + } +} + +fn raw_sections_from( + startup: String, + platform_logs: String, + rust_logs: String, +) -> Vec { + let platform_logs = if platform_logs.trim().is_empty() { + "no platform logs provided\n".to_string() + } else { + platform_logs + }; + + vec![ + DiagnosticsSection::new( + "Privacy notice", + "Cove redacts bitcoin addresses, extended public/private keys, WIF private keys, BIP39 seed phrases, transaction IDs, and known local app data paths. Amounts remain visible. Review the report before submitting.", + ), + DiagnosticsSection::new("Startup diagnostics", startup), + DiagnosticsSection::new("Platform logs", platform_logs), + DiagnosticsSection::new("Rust logs", rust_logs), + ] +} + +fn render_preview( + generated_at: &str, + metadata: &DiagnosticsMetadata, + sections: &[DiagnosticsSection], +) -> String { + let mut text = String::new(); + text.push_str("Cove diagnostics report\n"); + text.push_str(&format!("Generated: {generated_at}\n\n")); + text.push_str("App and build\n"); + text.push_str(&format!("Platform: {}\n", metadata.platform)); + text.push_str(&format!("App version: {}\n", metadata.app_version)); + text.push_str(&format!("Build number: {}\n", metadata.build_number)); + text.push_str(&format!("OS version: {}\n", metadata.os_version)); + text.push_str(&format!("Device model: {}\n", metadata.device_model)); + text.push_str(&format!("Rust git hash: {}\n", metadata.rust_git_hash)); + text.push_str(&format!("Rust git branch: {}\n", metadata.rust_git_branch)); + text.push_str(&format!("Rust build profile: {}\n", metadata.rust_build_profile)); + + for section in sections { + text.push_str("\n## "); + text.push_str(§ion.title); + text.push('\n'); + text.push_str(§ion.body); + if !section.body.ends_with('\n') { + text.push('\n'); + } + } + + text +} + +fn render_preview_with_user_description(preview_text: &str, user_description: &str) -> String { + let mut text = preview_text.to_string(); + if !text.ends_with('\n') { + text.push('\n'); + } + + text.push_str("\n## User description\n"); + text.push_str(user_description); + if !user_description.ends_with('\n') { + text.push('\n'); + } + + text +} + +fn timestamp() -> String { + jiff::Timestamp::now().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + static DIAGNOSTICS_UPLOAD_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + + struct EnvVarGuard(&'static str); + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + // tests hold DIAGNOSTICS_UPLOAD_ENV_LOCK while mutating process environment + unsafe { std::env::set_var(key, value) }; + + Self(key) + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + // tests hold DIAGNOSTICS_UPLOAD_ENV_LOCK while mutating process environment + unsafe { std::env::remove_var(self.0) }; + } + } + + fn platform_info() -> DiagnosticsPlatformInfo { + DiagnosticsPlatformInfo { + platform: "ios".to_string(), + build_number: "123".to_string(), + os_version: "iOS 20.0".to_string(), + device_model: "iPhone17,1".to_string(), + } + } + + async fn upload_server_url(report_id: &'static str) -> String { + upload_server_response("200 OK", format!(r#"{{"id":"{report_id}"}}"#)).await + } + + async fn upload_server_response(status: &'static str, body: String) -> String { + use tokio::{ + io::{AsyncReadExt as _, AsyncWriteExt as _}, + net::TcpListener, + }; + + let listener = TcpListener::bind("127.0.0.1:0").await.expect("test server binds"); + let addr = listener.local_addr().expect("test server has local addr"); + + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("test server accepts request"); + let mut buffer = [0; 4096]; + let _ = socket.read(&mut buffer).await.expect("test server reads request"); + + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len(), + ); + + socket.write_all(response.as_bytes()).await.expect("test server writes response"); + }); + + format!("http://{addr}/reports") + } + + #[test] + fn assembly_includes_sections_and_redacts_sensitive_values() { + let report = DiagnosticsReport::build_with_sources( + platform_info(), + "platform saw bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq amount=5000 sats".to_string(), + "startup path /tmp/cove-test/.data/wallets".to_string(), + "rust log txid 4d3c2b1a4d3c2b1a4d3c2b1a4d3c2b1a4d3c2b1a4d3c2b1a4d3c2b1a4d3c2b1a" + .to_string(), + ) + .unwrap(); + let preview = report.preview_text(); + + assert!(preview.contains("App and build")); + assert!(preview.contains("Startup diagnostics")); + assert!(preview.contains("Platform logs")); + assert!(preview.contains("Rust logs")); + assert!(preview.contains("")); + assert!(preview.contains("")); + assert!(preview.contains("")); + assert!(json.contains("")); + assert!(json.contains("")); + assert!(json.contains("")); + assert!(!preview.contains("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq")); + assert!(!preview.contains(mnemonic)); + assert!(!preview.contains(wif)); + assert!(!json.contains("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq")); + assert!(!json.contains(mnemonic)); + assert!(!json.contains(wif)); + } + + #[test] + fn empty_user_description_is_omitted() { + let report = DiagnosticsReport::build_with_sources( + platform_info(), + String::new(), + String::new(), + String::new(), + ) + .unwrap(); + + let preview = report.preview_text_for_description(Some(" \n\t ".to_string())); + let upload = + report.upload_report(report.redacted_user_description(Some(" \n\t ".to_string()))); + + assert_eq!(preview, report.preview_text()); + assert!(upload.user_description.is_none()); + } + + #[tokio::test(flavor = "current_thread")] + async fn submit_persists_successful_report_history() { + let _global_guard = crate::test_support::global_state_test_lock().lock().await; + let _env_guard = DIAGNOSTICS_UPLOAD_ENV_LOCK.lock().await; + let _ = rustls::crypto::ring::default_provider().install_default(); + crate::test_support::ensure_tokio_runtime(); + crate::database::test_support::delete_database(); + Database::try_reinit().expect("database reinitializes"); + + let upload_url = upload_server_url("report_123").await; + let _upload_url = EnvVarGuard::set("COVE_DIAGNOSTICS_URL", &upload_url); + let report = DiagnosticsReport::build_with_sources( + platform_info(), + String::new(), + String::new(), + String::new(), + ) + .unwrap(); + + let submission = report.submit(Some("description".to_string())).await.unwrap(); + + let records = Database::global().diagnostics_reports.all().unwrap(); + assert_eq!(submission.report_id, "report_123"); + assert!(submission.history_saved); + assert!(submission.warning.is_none()); + assert_eq!(records.len(), 1); + assert_eq!(records[0].report_id, "report_123"); + assert_eq!(records[0].description, Some("description".to_string())); + } + + #[tokio::test(flavor = "current_thread")] + async fn submit_persists_redacted_history_description() { + let _global_guard = crate::test_support::global_state_test_lock().lock().await; + let _env_guard = DIAGNOSTICS_UPLOAD_ENV_LOCK.lock().await; + let _ = rustls::crypto::ring::default_provider().install_default(); + crate::test_support::ensure_tokio_runtime(); + crate::database::test_support::delete_database(); + Database::try_reinit().expect("database reinitializes"); + + let upload_url = upload_server_url("report_secret").await; + let _upload_url = EnvVarGuard::set("COVE_DIAGNOSTICS_URL", &upload_url); + let report = DiagnosticsReport::build_with_sources( + platform_info(), + "platform saw bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(), + String::new(), + String::new(), + ) + .unwrap(); + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let description = + format!("same address bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq seed {mnemonic}"); + + let submission = report.submit(Some(description)).await.unwrap(); + + let records = Database::global().diagnostics_reports.all().unwrap(); + let history_description = records[0].description.as_ref().expect("description saved"); + assert_eq!(submission.report_id, "report_secret"); + assert!(history_description.contains("")); + assert!(history_description.contains("")); + assert!(!history_description.contains("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq")); + assert!(!history_description.contains(mnemonic)); + } + + #[tokio::test(flavor = "current_thread")] + async fn failed_upload_does_not_persist_history_and_returns_safe_message() { + let _global_guard = crate::test_support::global_state_test_lock().lock().await; + let _env_guard = DIAGNOSTICS_UPLOAD_ENV_LOCK.lock().await; + let _ = rustls::crypto::ring::default_provider().install_default(); + crate::test_support::ensure_tokio_runtime(); + crate::database::test_support::delete_database(); + Database::try_reinit().expect("database reinitializes"); + + let upload_url = upload_server_response( + "413 Payload Too Large", + r#"{"error":"collector internal detail"}"#.to_string(), + ) + .await; + let _upload_url = EnvVarGuard::set("COVE_DIAGNOSTICS_URL", &upload_url); + let report = DiagnosticsReport::build_with_sources( + platform_info(), + String::new(), + String::new(), + String::new(), + ) + .unwrap(); + + let error = report.submit(Some("description".to_string())).await.unwrap_err(); + + let records = Database::global().diagnostics_reports.all().unwrap(); + let DiagnosticsError::Submit(message) = error else { + panic!("expected submit error"); + }; + assert!(records.is_empty()); + assert!(message.contains("too large to submit")); + assert!(!message.contains("collector internal detail")); + } + + #[tokio::test(flavor = "current_thread")] + async fn upload_success_with_history_save_failure_returns_warning() { + let _global_guard = crate::test_support::global_state_test_lock().lock().await; + let _env_guard = DIAGNOSTICS_UPLOAD_ENV_LOCK.lock().await; + let _ = rustls::crypto::ring::default_provider().install_default(); + crate::test_support::ensure_tokio_runtime(); + crate::database::test_support::delete_database(); + Database::try_reinit().expect("database reinitializes"); + crate::database::diagnostics_reports::test_support::write_invalid_history( + &Database::global().diagnostics_reports, + ); + + let upload_url = upload_server_url("report_history_failed").await; + let _upload_url = EnvVarGuard::set("COVE_DIAGNOSTICS_URL", &upload_url); + let report = DiagnosticsReport::build_with_sources( + platform_info(), + String::new(), + String::new(), + String::new(), + ) + .unwrap(); + + let submission = report.submit(Some("description".to_string())).await.unwrap(); + + assert_eq!(submission.report_id, "report_history_failed"); + assert!(!submission.history_saved); + assert!( + submission + .warning + .as_deref() + .is_some_and(|warning| warning.contains("could not save the report ID")) + ); + assert!(Database::global().diagnostics_reports.all().is_err()); + } + + #[test] + fn report_size_format_uses_bytes_below_kilobyte() { + assert_eq!(format_diagnostics_report_size(0), "0 bytes"); + assert_eq!(format_diagnostics_report_size(1), "1 byte"); + assert_eq!(format_diagnostics_report_size(999), "999 bytes"); + } + + #[test] + fn report_size_format_uses_kilobytes_at_kilobyte() { + assert_eq!(format_diagnostics_report_size(1_000), "1 KB"); + assert_eq!(format_diagnostics_report_size(1_500), "1.5 KB"); + assert_eq!(format_diagnostics_report_size(12_000), "12 KB"); + } + + #[test] + fn report_size_format_uses_larger_units() { + assert_eq!(format_diagnostics_report_size(1_000_000), "1 MB"); + assert_eq!(format_diagnostics_report_size(1_500_000), "1.5 MB"); + assert_eq!(format_diagnostics_report_size(1_000_000_000), "1 GB"); + } +} diff --git a/rust/src/diagnostics/redact.rs b/rust/src/diagnostics/redact.rs new file mode 100644 index 000000000..43e534857 --- /dev/null +++ b/rust/src/diagnostics/redact.rs @@ -0,0 +1,1034 @@ +use std::{ + borrow::Cow, cmp::Reverse, collections::BTreeMap, fmt, path::PathBuf, str::FromStr as _, +}; + +use bip39::{Language, Mnemonic}; +use bitcoin::{Address, PrivateKey}; +use rand::RngExt as _; +use sha2::{Digest as _, Sha256}; +use unicode_normalization::char::is_combining_mark; + +const BIP39_WORD_COUNTS: [usize; 5] = [24, 21, 18, 15, 12]; +const TXID_HEX_LEN: usize = 64; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum SecretKind { + BitcoinAddress, + ExtendedKey, + Mnemonic, + PrivateKey, + TransactionId, +} + +#[derive(Debug, Clone)] +struct PathPlaceholder { + path: String, + placeholder: &'static str, +} + +#[derive(Clone)] +pub(crate) struct Redactor { + paths: Vec, + fingerprint_salt: [u8; 32], + seen: BTreeMap, + counts: BTreeMap, +} + +impl fmt::Debug for Redactor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Redactor") + .field("paths_count", &self.paths.len()) + .field("seen_count", &self.seen.len()) + .field("counts", &self.counts) + .finish() + } +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct SecretFingerprint { + kind: SecretKind, + digest: [u8; 32], +} + +#[derive(Debug, Clone, Copy)] +struct TokenSpan { + start: usize, + end: usize, +} + +#[derive(Debug)] +struct MnemonicMatch { + start: usize, + end: usize, + next_token_index: usize, + normalized_phrase: String, +} + +impl Redactor { + pub(crate) fn with_default_paths() -> Self { + Self::new(default_path_placeholders()) + } + + fn new(paths: Vec) -> Self { + let mut paths = paths.into_iter().filter(|path| !path.path.is_empty()).collect::>(); + paths.sort_by_key(|path| Reverse(path.path.len())); + + let mut fingerprint_salt = [0; 32]; + rand::rng().fill(&mut fingerprint_salt); + + Self { paths, fingerprint_salt, seen: BTreeMap::new(), counts: BTreeMap::new() } + } + + pub(crate) fn redact(&mut self, text: &str) -> String { + let path_redacted = self.redact_paths(text); + self.redact_secrets(&path_redacted) + } + + fn redact_paths(&self, text: &str) -> String { + self.paths + .iter() + .fold(text.to_string(), |redacted, path| redacted.replace(&path.path, path.placeholder)) + } + + fn redact_secrets(&mut self, text: &str) -> String { + let tokens = token_spans(text); + if tokens.is_empty() { + return text.to_string(); + } + + let mut output = String::with_capacity(text.len()); + let mut cursor = 0; + let mut token_index = 0; + + while token_index < tokens.len() { + if let Some(match_) = mnemonic_match(text, &tokens, token_index) { + output.push_str(&text[cursor..match_.start]); + output.push_str( + &self.placeholder_for(SecretKind::Mnemonic, &match_.normalized_phrase), + ); + cursor = match_.end; + token_index = match_.next_token_index; + continue; + } + + let token = tokens[token_index]; + output.push_str(&text[cursor..token.start]); + output.push_str(&self.redact_token(token.text(text))); + cursor = token.end; + token_index += 1; + } + + output.push_str(&text[cursor..]); + + output + } + + fn redact_token(&mut self, token: &str) -> String { + let Some(kind) = classify_secret(token) else { + return self.redact_embedded_ascii_secrets(token); + }; + + self.placeholder_for(kind, token) + } + + fn redact_embedded_ascii_secrets(&mut self, token: &str) -> String { + let mut output = String::with_capacity(token.len()); + let mut cursor = 0; + + while cursor < token.len() { + let Some(run_start_offset) = + token[cursor..].bytes().position(|byte| byte.is_ascii_alphanumeric()) + else { + output.push_str(&token[cursor..]); + break; + }; + let run_start = cursor + run_start_offset; + output.push_str(&token[cursor..run_start]); + + let run_len = token[run_start..].bytes().take_while(u8::is_ascii_alphanumeric).count(); + let run_end = run_start + run_len; + let run = &token[run_start..run_end]; + + if let Some(kind) = classify_secret(run) { + output.push_str(&self.placeholder_for(kind, run)); + } else { + output.push_str(&self.redact_embedded_txids(run)); + } + + cursor = run_end; + } + + output + } + + fn redact_embedded_txids(&mut self, token: &str) -> String { + let mut output = String::with_capacity(token.len()); + let mut cursor = 0; + + while cursor < token.len() { + let Some(run_start_offset) = + token[cursor..].bytes().position(|byte| byte.is_ascii_hexdigit()) + else { + output.push_str(&token[cursor..]); + break; + }; + let run_start = cursor + run_start_offset; + output.push_str(&token[cursor..run_start]); + + let run_len = + token[run_start..].bytes().take_while(|byte| byte.is_ascii_hexdigit()).count(); + let run_end = run_start + run_len; + + if run_len >= TXID_HEX_LEN { + output.push_str( + &self.placeholder_for(SecretKind::TransactionId, &token[run_start..run_end]), + ); + } else { + output.push_str(&token[run_start..run_end]); + } + + cursor = run_end; + } + + output + } + + fn placeholder_for(&mut self, kind: SecretKind, secret: &str) -> String { + let fingerprint = SecretFingerprint::new(kind, secret, &self.fingerprint_salt); + if let Some(placeholder) = self.seen.get(&fingerprint) { + return placeholder.clone(); + } + + let next = self.counts.entry(kind).and_modify(|count| *count += 1).or_insert(1); + let placeholder = format!("", kind.placeholder_name()); + self.seen.insert(fingerprint, placeholder.clone()); + + placeholder + } +} + +impl SecretFingerprint { + fn new(kind: SecretKind, secret: &str, salt: &[u8; 32]) -> Self { + let mut hasher = Sha256::new(); + hasher.update(salt); + hasher.update([0]); + hasher.update(kind.placeholder_name().as_bytes()); + hasher.update([0]); + hasher.update(secret.as_bytes()); + let digest = hasher.finalize().into(); + + Self { kind, digest } + } +} + +impl SecretKind { + fn placeholder_name(self) -> &'static str { + match self { + Self::BitcoinAddress => "bitcoin-address", + Self::ExtendedKey => "extended-key", + Self::Mnemonic => "seed-phrase", + Self::PrivateKey => "wif-private-key", + Self::TransactionId => "transaction-id", + } + } +} + +fn default_path_placeholders() -> Vec { + let root = cove_common::consts::ROOT_DATA_DIR.clone(); + let wallet = cove_common::consts::WALLET_DATA_DIR.clone(); + let mut placeholders = vec![ + path_placeholder(wallet, ""), + path_placeholder(root, ""), + ]; + + if let Some(home) = dirs::home_dir() { + placeholders.push(path_placeholder(home, "")); + } + + placeholders +} + +fn path_placeholder(path: PathBuf, placeholder: &'static str) -> PathPlaceholder { + PathPlaceholder { path: path.to_string_lossy().to_string(), placeholder } +} + +fn classify_secret(token: &str) -> Option { + if is_txid(token) { + return Some(SecretKind::TransactionId); + } + + if is_extended_key(token) { + return Some(SecretKind::ExtendedKey); + } + + if is_wif_private_key(token) { + return Some(SecretKind::PrivateKey); + } + + if is_bitcoin_address(token) { + return Some(SecretKind::BitcoinAddress); + } + + None +} + +fn is_token_character(character: char) -> bool { + character.is_alphanumeric() || is_combining_mark(character) +} + +fn token_spans(text: &str) -> Vec { + let mut tokens = Vec::new(); + let mut token_start = None; + let mut characters = text.char_indices().peekable(); + + while let Some((index, character)) = characters.next() { + if let Some(escape_len) = escaped_whitespace_len(&text[index..]) { + if let Some(start) = token_start.take() { + tokens.push(TokenSpan { start, end: index }); + } + + let escape_end = index + escape_len; + while characters.peek().is_some_and(|(index, _)| *index < escape_end) { + characters.next(); + } + + continue; + } + + if is_token_character(character) { + token_start.get_or_insert(index); + continue; + } + + if let Some(start) = token_start.take() { + tokens.push(TokenSpan { start, end: index }); + } + } + + if let Some(start) = token_start { + tokens.push(TokenSpan { start, end: text.len() }); + } + + tokens +} + +impl TokenSpan { + fn text(self, text: &str) -> &str { + &text[self.start..self.end] + } +} + +fn mnemonic_match(text: &str, tokens: &[TokenSpan], token_index: usize) -> Option { + let candidate = mnemonic_candidate(text, tokens, token_index)?; + + for word_count in BIP39_WORD_COUNTS { + if word_count > candidate.word_token_indexes.len() { + continue; + } + + let word_token_indexes = &candidate.word_token_indexes[..word_count]; + let normalized_phrase = normalized_mnemonic_phrase(text, tokens, word_token_indexes); + + let is_valid = Language::ALL + .iter() + .any(|language| Mnemonic::parse_in_normalized(*language, &normalized_phrase).is_ok()); + if !is_valid { + continue; + } + + let last_word_index = word_token_indexes[word_count - 1]; + + return Some(MnemonicMatch { + start: candidate.start, + end: tokens[last_word_index].end, + next_token_index: last_word_index + 1, + normalized_phrase, + }); + } + + None +} + +struct MnemonicCandidate { + start: usize, + word_token_indexes: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum MnemonicNumbering { + ZeroBased, + OneBased, +} + +fn mnemonic_candidate( + text: &str, + tokens: &[TokenSpan], + token_index: usize, +) -> Option { + let token = tokens[token_index]; + let (start, mut word_token_index, mut numbering) = if is_mnemonic_word(token.text(text)) { + (token.start, token_index, mnemonic_numbering_before_word(text, tokens, token_index)) + } else { + let (word_token_index, numbering) = + numbered_mnemonic_word_index(text, tokens, token_index, 0, None)?; + + (tokens[word_token_index].start, word_token_index, Some(numbering)) + }; + let mut word_token_indexes = Vec::with_capacity(BIP39_WORD_COUNTS[0]); + + loop { + let word = tokens[word_token_index].text(text); + if !is_mnemonic_word(word) { + break; + } + + word_token_indexes.push(word_token_index); + if word_token_indexes.len() == BIP39_WORD_COUNTS[0] { + break; + } + + let Some((next_word_token_index, next_numbering)) = next_mnemonic_word_index( + text, + tokens, + word_token_index, + word_token_indexes.len(), + numbering, + ) else { + break; + }; + + word_token_index = next_word_token_index; + numbering = next_numbering; + } + + Some(MnemonicCandidate { start, word_token_indexes }) +} + +fn next_mnemonic_word_index( + text: &str, + tokens: &[TokenSpan], + word_token_index: usize, + next_word_index: usize, + numbering: Option, +) -> Option<(usize, Option)> { + let next_token_index = word_token_index + 1; + let next_token = *tokens.get(next_token_index)?; + let separator = &text[tokens[word_token_index].end..next_token.start]; + if !is_mnemonic_separator(separator) { + return None; + } + + if is_mnemonic_word(next_token.text(text)) { + return Some((next_token_index, numbering)); + } + + let (word_token_index, numbering) = + numbered_mnemonic_word_index(text, tokens, next_token_index, next_word_index, numbering)?; + + Some((word_token_index, Some(numbering))) +} + +fn numbered_mnemonic_word_index( + text: &str, + tokens: &[TokenSpan], + number_token_index: usize, + word_index: usize, + expected_numbering: Option, +) -> Option<(usize, MnemonicNumbering)> { + let number_token = tokens.get(number_token_index)?.text(text); + let numbering = MnemonicNumbering::from_marker(number_token.parse().ok()?, word_index)?; + if expected_numbering.is_some_and(|expected| expected != numbering) { + return None; + } + + let word_token_index = number_token_index + 1; + let word_token = *tokens.get(word_token_index)?; + let separator = &text[tokens[number_token_index].end..word_token.start]; + if !is_mnemonic_separator(separator) || !is_mnemonic_word(word_token.text(text)) { + return None; + } + + Some((word_token_index, numbering)) +} + +fn mnemonic_numbering_before_word( + text: &str, + tokens: &[TokenSpan], + word_token_index: usize, +) -> Option { + let number_token_index = word_token_index.checked_sub(1)?; + let (numbered_word_token_index, numbering) = + numbered_mnemonic_word_index(text, tokens, number_token_index, 0, None)?; + + (numbered_word_token_index == word_token_index).then_some(numbering) +} + +impl MnemonicNumbering { + fn from_marker(marker: usize, word_index: usize) -> Option { + match marker.checked_sub(word_index)? { + 0 => Some(Self::ZeroBased), + 1 => Some(Self::OneBased), + _ => None, + } + } +} + +fn is_mnemonic_word(word: &str) -> bool { + word.chars().any(char::is_alphabetic) + && word.chars().all(|character| character.is_alphabetic() || is_combining_mark(character)) +} + +fn is_mnemonic_separator(separator: &str) -> bool { + !separator.is_empty() + && mnemonic_separator_characters_are_valid(separator, is_mnemonic_format_character) +} + +fn mnemonic_separator_characters_are_valid( + separator: &str, + mut is_format_character: impl FnMut(char) -> bool, +) -> bool { + let mut cursor = 0; + + while cursor < separator.len() { + if let Some(escape_len) = escaped_whitespace_len(&separator[cursor..]) { + cursor += escape_len; + continue; + } + + let character = separator[cursor..].chars().next().expect("cursor is in bounds"); + if !character.is_whitespace() && !is_format_character(character) { + return false; + } + + cursor += character.len_utf8(); + } + + true +} + +fn escaped_whitespace_len(text: &str) -> Option { + if text.starts_with("\\n") || text.starts_with("\\r") || text.starts_with("\\t") { + return Some(2); + } + + let unicode_escape = text.strip_prefix("\\u{")?; + let closing_brace = unicode_escape.find('}')?; + let hexadecimal = &unicode_escape[..closing_brace]; + if hexadecimal.is_empty() + || hexadecimal.len() > 6 + || !hexadecimal.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return None; + } + + let value = u32::from_str_radix(hexadecimal, 16).ok()?; + let character = char::from_u32(value)?; + character.is_whitespace().then_some(closing_brace + 4) +} + +fn is_mnemonic_format_character(character: char) -> bool { + matches!( + character, + ',' | ';' + | '.' + | ':' + | '\'' + | '"' + | '`' + | '\\' + | '[' + | ']' + | '(' + | ')' + | '-' + | '–' + | '—' + | '*' + | '+' + | '|' + | '/' + | '•' + | '◦' + | '‘' + | '’' + | '“' + | '”' + ) +} + +fn normalized_mnemonic_phrase( + text: &str, + tokens: &[TokenSpan], + word_token_indexes: &[usize], +) -> String { + let mut words = Vec::with_capacity(word_token_indexes.len()); + + for token_index in word_token_indexes { + words.push(tokens[*token_index].text(text).to_lowercase()); + } + + let mut phrase = Cow::Owned(words.join(" ")); + Mnemonic::normalize_utf8_cow(&mut phrase); + + phrase.into_owned() +} + +fn is_txid(token: &str) -> bool { + token.len() == TXID_HEX_LEN && token.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn is_extended_key(token: &str) -> bool { + const PREFIXES: [&str; 12] = [ + "xpub", "ypub", "zpub", "tpub", "upub", "vpub", "xprv", "yprv", "zprv", "tprv", "uprv", + "vprv", + ]; + + token.len() >= 50 + && PREFIXES.iter().any(|prefix| token.starts_with(prefix)) + && token.bytes().all(|byte| byte.is_ascii_alphanumeric()) +} + +fn is_wif_private_key(token: &str) -> bool { + let has_known_prefix = token.starts_with('5') + || token.starts_with('K') + || token.starts_with('L') + || token.starts_with('9') + || token.starts_with('c'); + + has_known_prefix && PrivateKey::from_wif(token).is_ok() +} + +fn is_bitcoin_address(token: &str) -> bool { + let lower = token.to_ascii_lowercase(); + let has_known_prefix = lower.starts_with("bc1") + || lower.starts_with("tb1") + || lower.starts_with("bcrt1") + || token.starts_with('1') + || token.starts_with('3') + || token.starts_with('m') + || token.starts_with('n') + || token.starts_with('2'); + + has_known_prefix && Address::from_str(token).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use unicode_normalization::UnicodeNormalization as _; + + const MNEMONIC_WORDS: [&str; 12] = [ + "abandon", "abandon", "abandon", "abandon", "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "about", + ]; + + fn redactor_without_paths() -> Redactor { + Redactor::new(vec![]) + } + + #[test] + fn redacts_bitcoin_addresses_across_networks_and_scripts() { + let mut redactor = redactor_without_paths(); + let input = concat!( + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4 ", + "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0 ", + "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7 ", + "bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5 ", + "1BoatSLRHtKNngkdXEeobR76b53LETtpyT" + ); + + let output = redactor.redact(input); + + assert_eq!(output.matches("").count(), 2); + assert_eq!(output.matches("").count(), 2); + assert!(!output.contains(&xpub)); + assert!(!output.contains(&txid)); + } + + #[test] + fn redacts_ascii_secrets_adjacent_to_cjk_text() { + let mut redactor = redactor_without_paths(); + let address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; + let extended_key = format!("xpub{}", "A".repeat(106)); + let wif = "5JYkZjmN7PVMjJUfJWfRFwtuXTGB439XV6faajeHPAM9Z2PT2R3"; + let txid = "4d3c2b1a".repeat(8); + let input = + format!("地址{address}结束 密钥{extended_key}结束 私钥{wif}结束 交易{txid}结束"); + + let output = redactor.redact(&input); + + assert_eq!(output.matches("").count(), 1); + assert_eq!(output.matches("").count(), 1); + assert_eq!(output.matches("").count(), 1); + assert_eq!(output.matches("").count(), 1); + assert!(output.contains("地址结束")); + assert!(output.contains("密钥结束")); + assert!(output.contains("私钥结束")); + assert!(output.contains("交易结束")); + assert!(!output.contains(address)); + assert!(!output.contains(&extended_key)); + assert!(!output.contains(wif)); + assert!(!output.contains(&txid)); + } + + #[test] + fn redacts_txids_embedded_inside_larger_tokens() { + let mut redactor = redactor_without_paths(); + let txid = "4d3c2b1a".repeat(8); + let input = format!("payment{txid}suffix again {txid}"); + + let output = redactor.redact(&input); + + assert_eq!(output.matches("").count(), 2); + assert!(output.contains("paymentsuffix")); + assert!(!output.contains(&txid)); + } + + #[test] + fn redacts_entire_hex_run_when_txid_boundaries_are_ambiguous() { + let mut redactor = redactor_without_paths(); + let txid = "4d3c2b1a".repeat(8); + let input = format!("ref{txid}dead"); + + let output = redactor.redact(&input); + + assert_eq!(output, "r"); + assert!(!output.contains(&txid)); + } + + #[test] + fn debug_output_does_not_include_seen_plaintext_secrets() { + let mut redactor = redactor_without_paths(); + let address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + let output = redactor.redact(&format!("{address} {mnemonic}")); + let debug = format!("{redactor:?}"); + + assert!(output.contains("")); + assert!(output.contains("")); + assert!(!debug.contains(address)); + assert!(!debug.contains(mnemonic)); + assert!(debug.contains("seen_count")); + } + + #[test] + fn redacts_bip39_seed_phrases_with_stable_placeholders() { + let mut redactor = redactor_without_paths(); + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let input = format!("seed: {mnemonic}\nagain: {mnemonic}"); + + let output = redactor.redact(&input); + + assert_eq!(output.matches("").count(), 2); + assert!(!output.contains(mnemonic)); + } + + #[test] + fn redacts_seed_phrases_in_every_supported_bip39_language() { + let mut redactor = redactor_without_paths(); + + for &language in Language::ALL.iter().filter(|&&language| language != Language::English) { + let mnemonic = Mnemonic::from_entropy_in(language, &[0; 16]) + .expect("zero entropy produces a valid mnemonic") + .to_string(); + let output = redactor.redact(&mnemonic); + + assert!( + output.starts_with("(); + let input = format!("decomposed: {decomposed}\ncomposed: {composed}"); + + assert_ne!(composed, decomposed); + + let output = redactor.redact(&input); + + assert_eq!(output.matches("").count(), 2); + assert!(!output.contains(&decomposed)); + assert!(!output.contains(&composed)); + } + + #[test] + fn redacts_non_english_seed_phrase_formatting_variants() { + let mut redactor = redactor_without_paths(); + let japanese = Mnemonic::from_entropy_in(Language::Japanese, &[0; 16]) + .expect("zero entropy produces a valid mnemonic") + .to_string(); + let spanish = Mnemonic::from_entropy_in(Language::Spanish, &[0; 16]) + .expect("zero entropy produces a valid mnemonic") + .to_string(); + let ideographic_spaces = japanese.split_whitespace().collect::>().join("\u{3000}"); + let comma_separated = spanish.split_whitespace().collect::>().join(", "); + + for formatted in [ideographic_spaces, comma_separated] { + let output = redactor.redact(&formatted); + + assert!(output.starts_with(""); + } + + #[test] + fn redacts_formatted_bip39_seed_phrases() { + let mut redactor = redactor_without_paths(); + let words = MNEMONIC_WORDS; + let comma_separated = words.join(","); + let quoted = format!("{words:?}"); + let numbered = words + .iter() + .enumerate() + .map(|(index, word)| format!("{}. {word}", index + 1)) + .collect::>() + .join("\n"); + let bulleted = words.iter().map(|word| format!("- {word}")).collect::>().join("\n"); + + for formatted in [comma_separated, quoted, numbered, bulleted] { + let output = redactor.redact(&formatted); + + assert!(output.contains(""), "output: {output}"); + assert!(!output.contains("abandon"), "output: {output}"); + assert!(!output.contains("about"), "output: {output}"); + } + } + + #[test] + fn redacts_zero_and_one_based_numbered_seed_phrases() { + let mut redactor = redactor_without_paths(); + + for numbering_base in [0, 1] { + let input = MNEMONIC_WORDS + .iter() + .enumerate() + .map(|(index, word)| format!("{}. {word}", index + numbering_base)) + .collect::>() + .join("\n"); + + let output = redactor.redact(&input); + + assert_eq!(output, format!("{numbering_base}. ")); + } + } + + #[test] + fn redacts_whitespace_only_numbered_seed_phrases() { + let mut redactor = redactor_without_paths(); + + for (numbering_base, column_separator) in [(1, " "), (0, "\t")] { + let input = MNEMONIC_WORDS + .iter() + .enumerate() + .map(|(index, word)| format!("{}{column_separator}{word}", index + numbering_base)) + .collect::>() + .join("\n"); + + let output = redactor.redact(&input); + + assert_eq!( + output, + format!("{numbering_base}{column_separator}") + ); + } + } + + #[test] + fn numbered_seed_phrases_require_consistent_sequential_markers() { + let mut redactor = redactor_without_paths(); + let input = MNEMONIC_WORDS + .iter() + .enumerate() + .map(|(index, word)| { + let number = if index == 6 { index + 2 } else { index + 1 }; + format!("{number} {word}") + }) + .collect::>() + .join("\n"); + + let output = redactor.redact(&input); + + assert_eq!(output, input); + } + + #[test] + fn redacts_debug_formatted_multiline_seed_phrases() { + let mut redactor = redactor_without_paths(); + let mnemonic = + format!("{}\n{}", MNEMONIC_WORDS[..6].join(" "), MNEMONIC_WORDS[6..].join(" ")); + let input = format!("{mnemonic:?}"); + + let output = redactor.redact(&input); + + assert!(input.contains("\\n")); + assert_eq!(output, "\"\""); + } + + #[test] + fn redacts_debug_formatted_unicode_whitespace_seed_phrases() { + let mut redactor = redactor_without_paths(); + let mnemonic = MNEMONIC_WORDS.join("\u{b}"); + let input = format!("{mnemonic:?}"); + + let output = redactor.redact(&input); + + assert!(input.contains("\\u{b}")); + assert_eq!(output, "\"\""); + + let non_whitespace_escape = MNEMONIC_WORDS.join("\\u{61}"); + assert_eq!(redactor.redact(&non_whitespace_escape), non_whitespace_escape); + } + + #[test] + fn redacts_slash_separated_seed_phrases() { + let mut redactor = redactor_without_paths(); + let input = MNEMONIC_WORDS.join("/"); + + let output = redactor.redact(&input); + + assert_eq!(output, ""); + } + + #[test] + fn redacts_period_separated_seed_phrases() { + let mut redactor = redactor_without_paths(); + let input = MNEMONIC_WORDS.join(". "); + + let output = redactor.redact(&input); + + assert_eq!(output, ""); + } + + #[test] + fn numbered_quoted_seed_phrase_boundaries_remain_balanced() { + let mut redactor = redactor_without_paths(); + let input = MNEMONIC_WORDS + .iter() + .enumerate() + .map(|(index, word)| format!("{}. \"{word}\"", index + 1)) + .collect::>() + .join("\n"); + + let output = redactor.redact(&input); + + assert_eq!(output, "1. \"\""); + } + + #[test] + fn bracketed_numbered_seed_phrase_boundaries_preserve_the_first_label() { + let mut redactor = redactor_without_paths(); + let compact = MNEMONIC_WORDS + .iter() + .enumerate() + .map(|(index, word)| format!("[{}] {word}", index + 1)) + .collect::>() + .join("\n"); + let spaced = MNEMONIC_WORDS + .iter() + .enumerate() + .map(|(index, word)| format!("[ {} ] {word}", index + 1)) + .collect::>() + .join("\n"); + + let compact_output = redactor.redact(&compact); + let spaced_output = redactor.redact(&spaced); + + assert_eq!(compact_output, "[1] "); + assert_eq!(spaced_output, "[ 1 ] "); + } + + #[test] + fn formatted_words_still_require_a_valid_bip39_checksum() { + let mut redactor = redactor_without_paths(); + let invalid_words = ["abandon"; 12]; + let input = format!("{invalid_words:?}"); + + let output = redactor.redact(&input); + + assert_eq!(output, input); + } + + #[test] + fn non_english_words_still_require_a_valid_bip39_checksum() { + let mut redactor = redactor_without_paths(); + let valid = Mnemonic::from_entropy_in(Language::Spanish, &[0; 16]) + .expect("zero entropy produces a valid mnemonic") + .to_string(); + let mut words = valid.split_whitespace().collect::>(); + words[11] = words[0]; + let input = words.join(" "); + + let output = redactor.redact(&input); + + assert_eq!(output, input); + } + + #[test] + fn redacts_wif_private_keys() { + let mut redactor = redactor_without_paths(); + let mainnet_wif = "5JYkZjmN7PVMjJUfJWfRFwtuXTGB439XV6faajeHPAM9Z2PT2R3"; + let testnet_wif = "cVt4o7BGAig1UXywgGSmARhxMdzP5qvQsxKkSsc1XEkw3tDTQFpy"; + let input = format!("{mainnet_wif} {testnet_wif} {mainnet_wif}"); + + let output = redactor.redact(&input); + + assert_eq!(output.matches("").count(), 2); + assert_eq!(output.matches("").count(), 1); + assert!(!output.contains(mainnet_wif)); + assert!(!output.contains(testnet_wif)); + } + + #[test] + fn does_not_redact_amounts_or_near_misses() { + let mut redactor = redactor_without_paths(); + let output = redactor.redact("amount=12345 sats fee=1.23 BTC abc123 not_a_txid"); + + assert!(output.contains("12345 sats")); + assert!(output.contains("1.23 BTC")); + assert!(output.contains("abc123")); + } + + #[test] + fn redacts_known_paths_before_tokens() { + let path = PathPlaceholder { + path: "/var/mobile/Containers/Data/Application/abc/.data/wallets".to_string(), + placeholder: "", + }; + let mut redactor = Redactor::new(vec![path]); + let output = redactor + .redact("Wallet dir: /var/mobile/Containers/Data/Application/abc/.data/wallets"); + + assert_eq!(output, "Wallet dir: "); + } +} diff --git a/rust/src/diagnostics/upload.rs b/rust/src/diagnostics/upload.rs new file mode 100644 index 000000000..90ae3528d --- /dev/null +++ b/rust/src/diagnostics/upload.rs @@ -0,0 +1,565 @@ +use std::{io::Write as _, time::Duration}; + +use flate2::{Compression, write::GzEncoder}; +use reqwest::{StatusCode, header::CONTENT_TYPE}; +use serde::Deserialize; + +use super::DiagnosticsUploadReport; + +const PRODUCTION_UPLOAD_URL: &str = "https://diagnostics.covebitcoinwallet.com/reports"; +// this identifies public mobile clients and must not be trusted as an authorization credential +const PUBLIC_APP_TOKEN: &str = "v1.cove-mobile-2026-07"; +const GZIP_CONTENT_TYPE: &str = "application/gzip"; +const MAX_SUCCESS_RESPONSE_BYTES: usize = 16 * 1024; +const MAX_STATUS_BODY_BYTES: usize = 2 * 1024; +const MAX_UPLOAD_ATTEMPTS: usize = 2; +const UPLOAD_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(15); +const UPLOAD_RETRY_DELAY: Duration = Duration::from_millis(500); + +#[derive(Debug, thiserror::Error)] +pub(crate) enum UploadError { + #[error("failed to create HTTP client: {0}")] + Client(reqwest::Error), + + #[error("failed to encode report JSON: {0}")] + EncodeJson(serde_json::Error), + + #[error("failed to gzip report JSON: {0}")] + Gzip(std::io::Error), + + #[error("collector request failed: {0}")] + Request(reqwest::Error), + + #[error("failed to decode collector success response: {0}")] + DecodeResponse(serde_json::Error), + + #[error("collector success response was invalid: {0}")] + InvalidResponse(String), + + #[error("collector response exceeded {limit} bytes")] + ResponseTooLarge { limit: usize }, + + #[error("collector returned status {status}: {body}")] + Status { status: reqwest::StatusCode, body: String }, +} + +impl UploadError { + pub(crate) fn user_message(&self) -> String { + match self { + Self::Client(_) | Self::EncodeJson(_) | Self::Gzip(_) => { + "Unable to prepare the diagnostics report. Please try again.".to_string() + } + Self::Request(error) if error.is_connect() => { + "Unable to reach the diagnostics collector. Check your connection and try again." + .to_string() + } + Self::Request(error) if error.is_timeout() => { + "The diagnostics upload timed out after it may have reached the collector. Retrying could submit it again; you can share the report manually." + .to_string() + } + Self::Request(_) => { + "The diagnostics upload failed after it may have reached the collector. Retrying could submit it again; you can share the report manually." + .to_string() + } + Self::DecodeResponse(_) | Self::InvalidResponse(_) | Self::ResponseTooLarge { .. } => { + "The diagnostics collector returned an unexpected response after upload. The report may have been received; retrying could submit it again. You can share the report manually." + .to_string() + } + Self::Status { status, .. } if *status == StatusCode::PAYLOAD_TOO_LARGE => { + "The diagnostics report is too large to submit. You can still share it manually." + .to_string() + } + Self::Status { status, .. } if status.is_server_error() => { + format!( + "The diagnostics collector returned {status} after upload. The report may have been received; retrying could submit it again. You can share the report manually." + ) + } + Self::Status { status, .. } => { + format!("The diagnostics collector rejected the report ({status}).") + } + } + } + + pub(crate) fn log_message(&self) -> String { + match self { + Self::Status { status, .. } => format!("collector returned status {status}"), + Self::ResponseTooLarge { limit } => { + format!("collector response exceeded {limit} bytes") + } + Self::InvalidResponse(message) => { + format!("collector success response was invalid: {message}") + } + Self::Client(error) => format!("failed to create HTTP client: {error}"), + Self::EncodeJson(error) => format!("failed to encode report JSON: {error}"), + Self::Gzip(error) => format!("failed to gzip report JSON: {error}"), + Self::Request(error) => format!("collector request failed: {error}"), + Self::DecodeResponse(error) => { + format!("failed to decode collector success response: {error}") + } + } + } +} + +#[derive(Debug, Deserialize)] +struct UploadResponse { + id: String, +} + +pub(crate) async fn submit_report(report: &DiagnosticsUploadReport) -> Result { + let client = cove_http::new_client_without_redirects().map_err(UploadError::Client)?; + let body = gzipped_json(report)?; + + for attempt in 1..=MAX_UPLOAD_ATTEMPTS { + match submit_report_once(&client, &body).await { + Ok(report_id) => return Ok(report_id), + Err(error) if attempt < MAX_UPLOAD_ATTEMPTS && upload_error_is_retryable(&error) => { + tracing::warn!( + "Diagnostics upload attempt {attempt} failed, retrying: {}", + error.log_message() + ); + tokio::time::sleep(UPLOAD_RETRY_DELAY).await; + } + Err(error) => return Err(error), + } + } + + unreachable!("upload attempts loop must return") +} + +async fn submit_report_once(client: &reqwest::Client, body: &[u8]) -> Result { + let mut response = client + .post(upload_url()) + .timeout(UPLOAD_ATTEMPT_TIMEOUT) + .bearer_auth(PUBLIC_APP_TOKEN) + .header(CONTENT_TYPE, GZIP_CONTENT_TYPE) + .body(body.to_vec()) + .send() + .await + .map_err(UploadError::Request)?; + let status = response.status(); + + if !status.is_success() { + let body = read_status_body_snippet(&mut response).await?; + + return Err(UploadError::Status { status, body }); + } + + let bytes = read_success_body(&mut response).await?; + let response: UploadResponse = + serde_json::from_slice(&bytes).map_err(UploadError::DecodeResponse)?; + let report_id = response.id.trim().to_string(); + if report_id.is_empty() { + return Err(UploadError::InvalidResponse("missing report id".to_string())); + } + + Ok(report_id) +} + +pub(crate) fn gzipped_json(report: &DiagnosticsUploadReport) -> Result, UploadError> { + let json = serde_json::to_vec(report).map_err(UploadError::EncodeJson)?; + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(&json).map_err(UploadError::Gzip)?; + encoder.finish().map_err(UploadError::Gzip) +} + +fn upload_url() -> String { + #[cfg(debug_assertions)] + if let Ok(url) = std::env::var("COVE_DIAGNOSTICS_URL") + && !url.trim().is_empty() + { + return url; + } + + PRODUCTION_UPLOAD_URL.to_string() +} + +fn upload_error_is_retryable(error: &UploadError) -> bool { + match error { + // only retry pre-connection failures because POST outcomes are otherwise ambiguous + UploadError::Request(error) => error.is_connect(), + UploadError::Client(_) + | UploadError::EncodeJson(_) + | UploadError::Gzip(_) + | UploadError::Status { .. } + | UploadError::DecodeResponse(_) + | UploadError::InvalidResponse(_) + | UploadError::ResponseTooLarge { .. } => false, + } +} + +async fn read_success_body(response: &mut reqwest::Response) -> Result, UploadError> { + let mut body = Vec::new(); + + while let Some(chunk) = response.chunk().await.map_err(UploadError::Request)? { + if body.len() + chunk.len() > MAX_SUCCESS_RESPONSE_BYTES { + return Err(UploadError::ResponseTooLarge { limit: MAX_SUCCESS_RESPONSE_BYTES }); + } + + body.extend_from_slice(&chunk); + } + + Ok(body) +} + +async fn read_status_body_snippet(response: &mut reqwest::Response) -> Result { + let mut body = Vec::new(); + let mut truncated = false; + + while let Some(chunk) = response.chunk().await.map_err(UploadError::Request)? { + let remaining = MAX_STATUS_BODY_BYTES.saturating_sub(body.len()); + if remaining == 0 { + truncated = true; + break; + } + + if chunk.len() > remaining { + body.extend_from_slice(&chunk[..remaining]); + truncated = true; + break; + } + + body.extend_from_slice(&chunk); + } + + let mut snippet = String::from_utf8_lossy(&body).to_string(); + if truncated { + snippet.push_str("... [truncated]"); + } + + Ok(snippet) +} + +#[cfg(test)] +mod tests { + use std::{ + io::Read as _, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + }; + + use flate2::read::GzDecoder; + use tokio::{ + io::{AsyncReadExt as _, AsyncWriteExt as _}, + net::{TcpListener, TcpStream}, + }; + + use super::*; + use crate::diagnostics::{DiagnosticsMetadata, DiagnosticsSection}; + + struct EnvVarGuard(&'static str); + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + // tests hold global_state_test_lock while mutating process environment + unsafe { std::env::set_var(key, value) }; + + Self(key) + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + // tests hold global_state_test_lock while mutating process environment + unsafe { std::env::remove_var(self.0) }; + } + } + + struct TestResponse { + status: StatusCode, + body: String, + } + + fn report() -> DiagnosticsUploadReport { + DiagnosticsUploadReport { + schema_version: 1, + generated_at: "2026-07-06T20:00:00Z".to_string(), + metadata: DiagnosticsMetadata { + platform: "ios".to_string(), + app_version: "1.0".to_string(), + build_number: "2".to_string(), + os_version: "iOS 20".to_string(), + device_model: "iPhone".to_string(), + rust_git_hash: "abc".to_string(), + rust_git_branch: "main".to_string(), + rust_build_profile: "debug".to_string(), + }, + user_description: Some("description".to_string()), + sections: vec![DiagnosticsSection { + title: "Rust logs".to_string(), + body: "amount=5000 sats".to_string(), + }], + } + } + + async fn upload_server(responses: Vec) -> (String, Arc) { + let _ = rustls::crypto::ring::default_provider().install_default(); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("test server binds"); + let addr = listener.local_addr().expect("test server has local addr"); + let request_count = Arc::new(AtomicUsize::new(0)); + let server_request_count = request_count.clone(); + + tokio::spawn(async move { + for response in responses { + let (mut socket, _) = listener.accept().await.expect("test server accepts request"); + let mut buffer = [0; 4096]; + let _ = socket.read(&mut buffer).await.expect("test server reads request"); + server_request_count.fetch_add(1, Ordering::Relaxed); + + let status = response.status; + let reason = status.canonical_reason().unwrap_or("status"); + let body = response.body; + let http_response = format!( + "HTTP/1.1 {} {reason}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + status.as_u16(), + body.len(), + ); + + socket + .write_all(http_response.as_bytes()) + .await + .expect("test server writes response"); + } + }); + + (format!("http://{addr}/reports"), request_count) + } + + async fn read_request_head(socket: &mut TcpStream) -> Vec { + let mut request = Vec::new(); + + loop { + let mut buffer = [0; 1024]; + let bytes_read = socket.read(&mut buffer).await.expect("test server reads request"); + assert_ne!(bytes_read, 0, "request ended before its headers"); + request.extend_from_slice(&buffer[..bytes_read]); + assert!(request.len() <= MAX_SUCCESS_RESPONSE_BYTES, "request headers are too large"); + + if request.windows(4).any(|window| window == b"\r\n\r\n") { + return request; + } + } + } + + async fn redirect_server( + location: &str, + ) -> (String, Arc, tokio::task::JoinHandle<()>) { + let _ = rustls::crypto::ring::default_provider().install_default(); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("test server binds"); + let addr = listener.local_addr().expect("test server has local addr"); + let request_count = Arc::new(AtomicUsize::new(0)); + let server_request_count = request_count.clone(); + let location = location.to_string(); + + let server = tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.expect("test server accepts request"); + let request = read_request_head(&mut socket).await; + server_request_count.fetch_add(1, Ordering::Relaxed); + + assert!(request.starts_with(b"POST /reports HTTP/1.1\r\n")); + + let body = r#"{"error":"redirect"}"#; + let http_response = format!( + "HTTP/1.1 307 Temporary Redirect\r\nlocation: {location}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len(), + ); + + socket + .write_all(http_response.as_bytes()) + .await + .expect("test server writes response"); + } + }); + + (format!("http://{addr}/reports"), request_count, server) + } + + #[test] + fn gzipped_json_contains_report_and_description() { + let report = report(); + + let gzipped = gzipped_json(&report).unwrap(); + let mut decoder = GzDecoder::new(gzipped.as_slice()); + let mut json = String::new(); + decoder.read_to_string(&mut json).unwrap(); + + assert!(json.contains("\"user_description\":\"description\"")); + assert!(json.contains("amount=5000 sats")); + } + + #[test] + fn collector_responses_are_not_retried_without_idempotency_support() { + for status in [ + StatusCode::REQUEST_TIMEOUT, + StatusCode::TOO_MANY_REQUESTS, + StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::BAD_GATEWAY, + ] { + let error = UploadError::Status { status, body: String::new() }; + + assert!(!upload_error_is_retryable(&error)); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn connection_establishment_failures_are_retryable() { + let _global_guard = crate::test_support::global_state_test_lock().lock().await; + let _ = rustls::crypto::ring::default_provider().install_default(); + let addr = { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("test server binds"); + + listener.local_addr().expect("test server has local addr") + }; + let client = cove_http::new_client_without_redirects().unwrap(); + + let error = client.post(format!("http://{addr}/reports")).send().await.unwrap_err(); + + assert!(error.is_connect()); + assert!(upload_error_is_retryable(&UploadError::Request(error))); + } + + #[tokio::test(flavor = "current_thread")] + async fn timeout_after_connection_is_not_retryable() { + let _global_guard = crate::test_support::global_state_test_lock().lock().await; + let _ = rustls::crypto::ring::default_provider().install_default(); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("test server binds"); + let addr = listener.local_addr().expect("test server has local addr"); + let server = tokio::spawn(async move { + let (_socket, _) = listener.accept().await.expect("test server accepts request"); + + tokio::time::sleep(Duration::from_secs(1)).await; + }); + let client = cove_http::new_client_without_redirects().unwrap(); + + let error = client + .post(format!("http://{addr}/reports")) + .timeout(Duration::from_millis(20)) + .body("report") + .send() + .await + .unwrap_err(); + + server.abort(); + + let error = UploadError::Request(error); + + assert!(matches!(&error, UploadError::Request(error) if error.is_timeout())); + assert!(!upload_error_is_retryable(&error)); + assert!(error.user_message().contains("may have reached")); + } + + #[test] + fn status_user_message_does_not_include_collector_body() { + let error = UploadError::Status { + status: StatusCode::INTERNAL_SERVER_ERROR, + body: "{\"error\":\"internal collector error\"}".to_string(), + }; + let message = error.user_message(); + + assert!(message.contains("500 Internal Server Error")); + assert!(!message.contains("internal collector error")); + } + + #[tokio::test(flavor = "current_thread")] + async fn submit_does_not_retry_server_error_after_collector_received_request() { + let _global_guard = crate::test_support::global_state_test_lock().lock().await; + let (upload_url, request_count) = upload_server(vec![TestResponse { + status: StatusCode::INTERNAL_SERVER_ERROR, + body: r#"{"error":"temporary"}"#.to_string(), + }]) + .await; + let _upload_url = EnvVarGuard::set("COVE_DIAGNOSTICS_URL", &upload_url); + + let error = submit_report(&report()).await.unwrap_err(); + + assert!(matches!(error, UploadError::Status { status, .. } if status.is_server_error())); + assert!(error.user_message().contains("may have been received")); + assert_eq!(request_count.load(Ordering::Relaxed), 1); + } + + #[tokio::test(flavor = "current_thread")] + async fn submit_does_not_follow_or_retry_post_redirect() { + let _global_guard = crate::test_support::global_state_test_lock().lock().await; + let (upload_url, request_count, server) = + redirect_server("http://127.0.0.1:0/reports").await; + let _upload_url = EnvVarGuard::set("COVE_DIAGNOSTICS_URL", &upload_url); + + let error = submit_report(&report()).await.unwrap_err(); + + server.abort(); + + assert!(matches!( + error, + UploadError::Status { status: StatusCode::TEMPORARY_REDIRECT, .. } + )); + assert_eq!(request_count.load(Ordering::Relaxed), 1); + } + + #[tokio::test(flavor = "current_thread")] + async fn submit_rejects_bad_success_json_without_retry() { + let _global_guard = crate::test_support::global_state_test_lock().lock().await; + let (upload_url, request_count) = upload_server(vec![TestResponse { + status: StatusCode::OK, + body: "not json".to_string(), + }]) + .await; + let _upload_url = EnvVarGuard::set("COVE_DIAGNOSTICS_URL", &upload_url); + + let error = submit_report(&report()).await.unwrap_err(); + + assert!(matches!(error, UploadError::DecodeResponse(_))); + assert!(error.user_message().contains("may have been received")); + assert_eq!(request_count.load(Ordering::Relaxed), 1); + } + + #[tokio::test(flavor = "current_thread")] + async fn submit_rejects_blank_report_id() { + let _global_guard = crate::test_support::global_state_test_lock().lock().await; + let (upload_url, _request_count) = upload_server(vec![TestResponse { + status: StatusCode::OK, + body: r#"{"id":" "}"#.to_string(), + }]) + .await; + let _upload_url = EnvVarGuard::set("COVE_DIAGNOSTICS_URL", &upload_url); + + let error = submit_report(&report()).await.unwrap_err(); + + assert!(matches!(error, UploadError::InvalidResponse(_))); + assert!(error.user_message().contains("may have been received")); + } + + #[tokio::test(flavor = "current_thread")] + async fn submit_classifies_payload_too_large_without_retry() { + let _global_guard = crate::test_support::global_state_test_lock().lock().await; + let (upload_url, request_count) = upload_server(vec![TestResponse { + status: StatusCode::PAYLOAD_TOO_LARGE, + body: r#"{"error":"too large"}"#.to_string(), + }]) + .await; + let _upload_url = EnvVarGuard::set("COVE_DIAGNOSTICS_URL", &upload_url); + + let error = submit_report(&report()).await.unwrap_err(); + + assert!(matches!(error, UploadError::Status { status: StatusCode::PAYLOAD_TOO_LARGE, .. })); + assert!(error.user_message().contains("too large to submit")); + assert_eq!(request_count.load(Ordering::Relaxed), 1); + } + + #[tokio::test(flavor = "current_thread")] + async fn submit_rejects_oversized_success_response() { + let _global_guard = crate::test_support::global_state_test_lock().lock().await; + let (upload_url, _request_count) = upload_server(vec![TestResponse { + status: StatusCode::OK, + body: "x".repeat(MAX_SUCCESS_RESPONSE_BYTES + 1), + }]) + .await; + let _upload_url = EnvVarGuard::set("COVE_DIAGNOSTICS_URL", &upload_url); + + let error = submit_report(&report()).await.unwrap_err(); + + assert!(matches!(error, UploadError::ResponseTooLarge { .. })); + assert!(error.user_message().contains("may have been received")); + } +} diff --git a/rust/src/discovery_scanner.rs b/rust/src/discovery_scanner.rs index 5deaf7cc3..04e3c8469 100644 --- a/rust/src/discovery_scanner.rs +++ b/rust/src/discovery_scanner.rs @@ -20,7 +20,7 @@ use cove_util::result_ext::ResultExt as _; use eyre::Context; use flume::Sender; use pubport::formats::Json; -use tracing::{debug, error, info, warn}; +use tracing::{debug, error, info, trace, warn}; /// Default number of addresses to scan const DEFAULT_SCAN_LIMIT: u32 = 200; @@ -478,7 +478,7 @@ impl WalletDiscoveryWorker { } current_address += 1; - debug!("checked {current_address} addresses for {wallet_type}"); + trace!("checked {current_address} addresses for {wallet_type}"); // every 5 addresses, save the scan state if current_address.is_multiple_of(5) { diff --git a/rust/src/lib.rs b/rust/src/lib.rs index dd47dfce5..ebc9adf0b 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -45,6 +45,7 @@ mod bdk_store; mod build; mod converter; mod custom_block_explorer; +mod diagnostics; mod discovery_scanner; mod fee_client; mod fiat; diff --git a/rust/src/manager/deferred_sender.rs b/rust/src/manager/deferred_sender.rs index e07c63da7..af298fe9c 100644 --- a/rust/src/manager/deferred_sender.rs +++ b/rust/src/manager/deferred_sender.rs @@ -1,7 +1,7 @@ use std::fmt::Debug; use flume::{Sender, TrySendError}; -use tracing::{debug, error, warn}; +use tracing::{error, trace, warn}; use cove_tokio::task; @@ -60,7 +60,7 @@ impl MessageSender { pub fn send(&self, message: impl Into>) { let message = message.into(); - debug!("send: {message:?}"); + trace!("send: {message:?}"); match self.sender.try_send(message) { Ok(()) => {} Err(TrySendError::Full(message)) => { @@ -77,7 +77,7 @@ impl MessageSender { pub async fn send_async(&self, message: impl Into>) { let message = message.into(); - debug!("send_async: {message:?}"); + trace!("send_async: {message:?}"); if let Err(err) = self.sender.send_async(message).await { error!("unable to send message to send flow manager: {err}"); } diff --git a/rust/src/manager/send_flow_manager.rs b/rust/src/manager/send_flow_manager.rs index 9c0a1a87f..1cf73232a 100644 --- a/rust/src/manager/send_flow_manager.rs +++ b/rust/src/manager/send_flow_manager.rs @@ -482,7 +482,7 @@ impl RustSendFlowManager { /// action from the frontend to change the state of the view model #[uniffi::method] pub fn dispatch(self: Arc, action: Action) { - debug!("dispatch: {action:?}"); + trace!("dispatch: {action:?}"); match action { Action::NotifyEnteringBtcAmountChanged(string) => { diff --git a/rust/src/manager/send_flow_manager/amount_input.rs b/rust/src/manager/send_flow_manager/amount_input.rs index c7b598dbd..f343385d6 100644 --- a/rust/src/manager/send_flow_manager/amount_input.rs +++ b/rust/src/manager/send_flow_manager/amount_input.rs @@ -39,7 +39,7 @@ impl RustSendFlowManager { let handler = BtcOnChangeHandler::new(state.clone()); let changes = handler.on_change(&old, &new); - debug!("btc_on_change_handler changes: {changes:?}"); + trace!("btc_on_change_handler changes: {changes:?}"); let btc_on_change::Changeset { entering_amount_btc, max_selected, amount_btc, amount_fiat } = changes; @@ -93,7 +93,7 @@ impl RustSendFlowManager { old_value: String, new_value: String, ) -> Option<()> { - debug!("fiat_field_changed {old_value} --> {new_value}"); + trace!("fiat_field_changed {old_value} --> {new_value}"); if old_value == new_value { return None; } @@ -113,7 +113,7 @@ impl RustSendFlowManager { return None; }; - debug!("result: {result:?}, old_value: {old_value}, new_value: {new_value}"); + trace!("result: {result:?}, old_value: {old_value}, new_value: {new_value}"); let fiat_on_change::Changeset { entering_fiat_amount, fiat_value, diff --git a/rust/src/manager/send_flow_manager/fee_selection.rs b/rust/src/manager/send_flow_manager/fee_selection.rs index ca5ace1dc..dc997d821 100644 --- a/rust/src/manager/send_flow_manager/fee_selection.rs +++ b/rust/src/manager/send_flow_manager/fee_selection.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use act_zero::call; use cove_common::consts::LOW_SEND_WARNING_SATS; -use tracing::debug; +use tracing::trace; use crate::{fee_client::FEE_CLIENT, transaction::FeeRate, wallet::Address}; @@ -89,7 +89,7 @@ impl RustSendFlowManager { } pub(crate) async fn get_or_update_fee_rate_options(self: &Arc) { - debug!("get_or_update_fee_rate_options"); + trace!("get_or_update_fee_rate_options"); let mut sender = self.reconciler.deferred_sender(); @@ -100,7 +100,7 @@ impl RustSendFlowManager { (address, amount_sats) }; - debug!("get_or_update_fee_rate_options: {address:?}, {amount_sats:?}"); + trace!("get_or_update_fee_rate_options: {address:?}, {amount_sats:?}"); let wallet_actor = self.wallet_actor(); let state = self.state.clone(); diff --git a/rust/src/manager/send_flow_manager/psbt_builder.rs b/rust/src/manager/send_flow_manager/psbt_builder.rs index c3a5768ea..1f4e307de 100644 --- a/rust/src/manager/send_flow_manager/psbt_builder.rs +++ b/rust/src/manager/send_flow_manager/psbt_builder.rs @@ -1,6 +1,6 @@ use act_zero::call; use cove_types::{amount::Amount, psbt::Psbt}; -use tracing::debug; +use tracing::trace; use crate::{transaction::FeeRate, wallet::Address}; @@ -13,7 +13,7 @@ impl RustSendFlowManager { amount: Option, fee_rate: FeeRate, ) -> Result { - debug!("build_psbt"); + trace!("build_psbt"); let mode = self.state.lock().mode.clone(); match mode { @@ -30,7 +30,7 @@ impl RustSendFlowManager { amount: Option, fee_rate: FeeRate, ) -> Result { - debug!("build_psbt_for_amount"); + trace!("build_psbt_for_amount"); let (amount, address) = { let state = self.state.lock(); @@ -74,7 +74,7 @@ impl RustSendFlowManager { address: Option
, fee_rate: FeeRate, ) -> Result { - debug!("build_psbt_for_utxo_list"); + trace!("build_psbt_for_coin_control"); let (address, amount) = { let state = self.state.lock(); diff --git a/rust/src/manager/wallet_manager/actor.rs b/rust/src/manager/wallet_manager/actor.rs index 3719e73ab..7313244e0 100644 --- a/rust/src/manager/wallet_manager/actor.rs +++ b/rust/src/manager/wallet_manager/actor.rs @@ -1334,6 +1334,18 @@ mod tests { Arc::new(RwLock::new(WalletSnapshot::from_wallet(wallet))) } + fn new_test_wallet_actor( + wallet: Wallet, + sender: flume::Sender, + ) -> super::WalletActor { + crate::test_support::ensure_tokio_runtime(); + + let wallet_snapshot = test_wallet_snapshot(&wallet); + + super::WalletActor::new(wallet, sender, test_scan_status(), wallet_snapshot) + .expect("actor is created") + } + fn test_keychain() -> &'static Keychain { static INIT: Once = Once::new(); INIT.call_once(|| { @@ -1370,12 +1382,8 @@ mod tests { fn spawn_test_wallet_actor( wallet: Wallet, ) -> (Addr, flume::Receiver) { - crate::test_support::ensure_tokio_runtime(); - let (sender, receiver) = flume::bounded(100); - let wallet_snapshot = test_wallet_snapshot(&wallet); - let actor = super::WalletActor::new(wallet, sender, test_scan_status(), wallet_snapshot) - .expect("actor is created"); + let actor = new_test_wallet_actor(wallet, sender); let addr = spawn_actor(actor); (addr, receiver) @@ -1677,10 +1685,7 @@ mod tests { let unlocked = receive_output_in_latest_block(&mut wallet.bdk, Amount::from_sat(80_000)); let (sender, _receiver) = flume::bounded(10); - let wallet_snapshot = test_wallet_snapshot(&wallet); - let mut actor = - super::WalletActor::new(wallet, sender, test_scan_status(), wallet_snapshot) - .expect("actor is created"); + let mut actor = new_test_wallet_actor(wallet, sender); let (db, tmp) = new_test_wallet_data_db(actor.wallet.id.clone()); db.labels.set_output_spendability(locked, false).expect("output is locked"); actor.db = db; @@ -1785,10 +1790,7 @@ mod tests { receive_output_in_latest_block(&mut wallet.bdk, Amount::from_sat(80_000)); let (sender, _receiver) = flume::bounded(10); - let wallet_snapshot = test_wallet_snapshot(&wallet); - let mut actor = - super::WalletActor::new(wallet, sender, test_scan_status(), wallet_snapshot) - .expect("actor is created"); + let mut actor = new_test_wallet_actor(wallet, sender); let (db, _tmp) = new_test_wallet_data_db(actor.wallet.id.clone()); actor.db = db; @@ -1810,10 +1812,7 @@ mod tests { let wallet = Wallet::preview_new_wallet(); let (sender, _receiver) = flume::bounded(10); - let wallet_snapshot = test_wallet_snapshot(&wallet); - let mut actor = - super::WalletActor::new(wallet, sender, test_scan_status(), wallet_snapshot) - .expect("actor is created"); + let mut actor = new_test_wallet_actor(wallet, sender); let (db, _tmp) = wallet_data_db_with_mismatched_output_table(actor.wallet.id.clone()); actor.db = db; @@ -2002,7 +2001,6 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn actor_fee_options_include_fee_when_amount_exceeds_available_with_fee() { let _guard = crate::test_support::global_state_test_lock().lock().await; - crate::test_support::ensure_tokio_runtime(); let fixture = locked_actor_fixture(); let mut actor = fixture.actor; @@ -2090,6 +2088,7 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn actor_manual_max_send_uses_recipient_exact_dust_floor() { + let _guard = crate::test_support::global_state_test_lock().lock().await; crate::database::test_support::init_test_database(); let mut wallet = Wallet::preview_new_wallet(); @@ -2101,10 +2100,7 @@ mod tests { let spendable = receive_output_in_latest_block(&mut wallet.bdk, Amount::from_sat(4_000)); let (sender, _receiver) = flume::bounded(10); - let wallet_snapshot = test_wallet_snapshot(&wallet); - let mut actor = - super::WalletActor::new(wallet, sender, test_scan_status(), wallet_snapshot) - .expect("actor is created"); + let mut actor = new_test_wallet_actor(wallet, sender); let (db, _tmp) = new_test_wallet_data_db(actor.wallet.id.clone()); actor.db = db; @@ -2122,6 +2118,7 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn actor_manual_max_send_returns_domain_error_when_fee_shortfall_consumes_estimate() { + let _guard = crate::test_support::global_state_test_lock().lock().await; crate::database::test_support::init_test_database(); let mut wallet = Wallet::preview_new_wallet(); @@ -2133,10 +2130,7 @@ mod tests { let spendable = receive_output_in_latest_block(&mut wallet.bdk, Amount::from_sat(7_500)); let (sender, _receiver) = flume::bounded(10); - let wallet_snapshot = test_wallet_snapshot(&wallet); - let mut actor = - super::WalletActor::new(wallet, sender, test_scan_status(), wallet_snapshot) - .expect("actor is created"); + let mut actor = new_test_wallet_actor(wallet, sender); let (db, _tmp) = new_test_wallet_data_db(actor.wallet.id.clone()); actor.db = db; diff --git a/rust/src/transaction_watcher.rs b/rust/src/transaction_watcher.rs index cbf9a6b93..f982f87ed 100644 --- a/rust/src/transaction_watcher.rs +++ b/rust/src/transaction_watcher.rs @@ -3,7 +3,7 @@ use std::{sync::Arc, time::Duration}; use act_zero::*; use bitcoin::{Transaction, Txid}; use cove_types::Network; -use tracing::{debug, error, info}; +use tracing::{debug, error, info, trace}; use crate::{ manager::wallet_manager::actor::WalletActor, @@ -85,7 +85,7 @@ impl TransactionWatcher { loop { let Ok(true) = call!(addr.should_keep_watching()).await else { break }; - debug!("checking txn: {tx_id}"); + trace!("checking txn: {tx_id}"); let result = call!(addr.check_txn(client.clone())).await; match result { @@ -100,7 +100,7 @@ impl TransactionWatcher { // sleep for the normal wait time before checking again Ok(WatchResult::Continue) => { - debug!("continue watching, waiting for {}", normal_wait_time.as_secs()); + trace!("continue watching, waiting for {}", normal_wait_time.as_secs()); tokio::time::sleep(normal_wait_time).await; }