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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions dsm_client/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@
android:exported="false"
android:initOrder="1000" />

<!-- The debug report file the share sheet hands to the app the user picks
(DiagnosticsReport); only the report's cache directory is exposed. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.diagnostics"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/diagnostics_paths" />
</provider>

<!-- MAIN ACTIVITY (WebView container + deterministic JS bridge) -->
<activity
android:name="com.dsm.wallet.ui.MainActivity"
Expand Down
3 changes: 3 additions & 0 deletions dsm_client/android/app/src/main/java/com/dsm/wallet/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ class App : Application() {
Log.d("DSM-App", "App.onCreate()")
// BLE events arrive with no context of their own; the relay persists through this one.
com.dsm.wallet.bridge.BleEventRelay.attach(this)
// The debug report's app log is kept on disk from process start: logcat's
// own buffer holds only minutes of it.
com.dsm.wallet.bridge.DiagnosticsReport.startRollingLog(this)
// The library load is DsmInitProvider's and SDK initialisation is
// MainActivity.initDsmAndSignalReady's; this class holds no native work.
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// SPDX-License-Identifier: MIT OR Apache-2.0

package com.dsm.wallet.bridge

import android.app.Activity
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.content.FileProvider
import androidx.core.content.pm.PackageInfoCompat
import java.io.File
import java.io.IOException
import java.io.Writer

/**
* The beta debug report: what the app measured, the app's own log and the
* bridge log, in one text file the user sends through the Android share
* sheet. A WebView cannot save a file itself, so the report is written and
* shared here.
*
* logcat's buffer holds minutes of this app's log, and a report is often
* asked for long after the failure it describes. [startRollingLog] keeps the
* app's log on disk from process start, rotated at [ROTATE_KB] KB across
* [ROTATE_FILES] files.
*/
internal object DiagnosticsReport {
private const val TAG = "DiagnosticsReport"
private const val DIR = "diagnostics"
private const val ROLLING_NAME = "app.log"
private const val REPORT_NAME = "dsm-diagnostics.txt"
private const val ROTATE_KB = "1024"
private const val ROTATE_FILES = "8"

/** The FileProvider authority the manifest declares for the report. */
fun authority(context: Context): String = "${context.packageName}.diagnostics"

// The WebView's compositor writes these under the app's pid about ten times
// a second; they say nothing about the wallet and would bury it.
private val NOISE_TAGS = listOf("View", "GPUAUX", "BufferQueueProducer")
private val NOISE_TEXT = listOf("setRequestedFrameRate", "GuiExtAuxCheckAuxPath", "BufferQueueProducer")

@Volatile private var rolling: Process? = null
@Volatile private var rollingFailure: String? = null

/**
* Starts the on-disk log once per process: a `logcat` child reading this
* app's own lines (an app reads only its own) into `files/diagnostics/`.
* The wallet's Rust log at Info and every other tag at Info, the
* compositor's tags silenced. A start that fails is kept and stated in
* the report.
*/
fun startRollingLog(context: Context) {
synchronized(this) {
if (rolling != null) return
val dir = File(context.filesDir, DIR)
dir.mkdirs()
val spec = mutableListOf(
"logcat", "-v", "threadtime", "-T", "1",
"-f", File(dir, ROLLING_NAME).absolutePath,
"-r", ROTATE_KB, "-n", ROTATE_FILES,
"DSM_RUST:I",
)
NOISE_TAGS.forEach { spec.add("$it:S") }
spec.add("*:I")
try {
rolling = Runtime.getRuntime().exec(spec.toTypedArray())
rollingFailure = null
} catch (e: IOException) {
rollingFailure = e.message ?: e.javaClass.simpleName
Log.w(TAG, "the on-disk app log did not start", e)
}
}
}

/** Writes the report into the cache and answers its file. */
fun write(context: Context, summary: String): File {
val dir = File(context.cacheDir, DIR)
dir.mkdirs()
val out = File(dir, REPORT_NAME)
out.bufferedWriter().use { w ->
w.appendLine("=== DSM debug report ===")
writeHeader(context, w)
w.appendLine()
w.appendLine("=== What the app measured ===")
w.appendLine(summary)
w.appendLine()
w.appendLine("=== App log kept on disk, oldest first ===")
writeRollingLog(context, w)
w.appendLine()
w.appendLine("=== App log in logcat's buffer now ===")
writeLiveBuffer(w)
w.appendLine()
w.appendLine("=== Bridge log ===")
w.append(String(BridgeLogger.readLogBytes(), Charsets.UTF_8))
}
return out
}

/** Opens the share sheet with the report; the user picks where it goes. */
fun share(activity: Activity, report: File) {
val uri = FileProvider.getUriForFile(activity, authority(activity), report)
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "DSM debug report")
clipData = ClipData.newRawUri(REPORT_NAME, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(send, "Send the DSM debug report").apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
activity.runOnUiThread {
try {
activity.startActivity(chooser)
} catch (t: Throwable) {
// The launch runs after this answer went out; the UI thread can only log it.
Log.w(TAG, "the share sheet did not open", t)
}
}
}

private fun writeHeader(context: Context, w: Writer) {
val info = context.packageManager.getPackageInfo(context.packageName, 0)
w.appendLine(
"app=${context.packageName} version=${info.versionName} code=${PackageInfoCompat.getLongVersionCode(info)} " +
"installed=${info.firstInstallTime} updated=${info.lastUpdateTime}"
)
w.appendLine(
"device=${Build.MANUFACTURER} ${Build.MODEL} android=${Build.VERSION.RELEASE} " +
"sdk=${Build.VERSION.SDK_INT} abis=${Build.SUPPORTED_ABIS.joinToString(",")}"
)
val proc = rolling
val state = when {
proc == null -> "not running: ${rollingFailure ?: "never started in this process"}"
proc.isAlive -> "running"
else -> "exited with ${proc.exitValue()}"
}
w.appendLine("on-disk log=$state")
}

private fun writeRollingLog(context: Context, w: Writer) {
val dir = File(context.filesDir, DIR)
val files = dir.listFiles { f -> f.name == ROLLING_NAME || f.name.startsWith("$ROLLING_NAME.") }
if (files == null || files.isEmpty()) {
w.appendLine("(no file in ${dir.absolutePath})")
return
}
// logcat rotates app.log to app.log.1, app.log.1 to app.log.2 and so on:
// the highest number is the oldest, app.log the newest.
files.sortedByDescending { rotationIndex(it.name) }.forEach { f ->
f.forEachLine { line -> w.appendLine(line) }
}
}

private fun rotationIndex(name: String): Int =
if (name == ROLLING_NAME) 0 else name.substringAfterLast('.').toInt()

private fun writeLiveBuffer(w: Writer) {
val proc = Runtime.getRuntime().exec(arrayOf("logcat", "-d", "-v", "threadtime"))
proc.inputStream.bufferedReader().useLines { lines ->
lines.filter { line -> NOISE_TEXT.none { line.contains(it) } }
.forEach { w.appendLine(it) }
}
val code = proc.waitFor()
if (code != 0) {
w.appendLine("(logcat -d exited with $code: ${proc.errorStream.bufferedReader().readText().trim()})")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,17 @@ class SinglePathWebViewBridge(private val context: Context) {
BridgeLogger.readLogBytes()
}

// Diagnostics: write the debug report (the summary in the payload, the
// app log, the bridge log) and open the share sheet with it. Answers the
// report's size in bytes, as decimal UTF-8.
"shareDiagnosticsReport" -> {
val act = com.dsm.wallet.ui.MainActivity.getActiveInstance()
?: throw IllegalStateException("shareDiagnosticsReport: no active activity")
val report = DiagnosticsReport.write(act, String(payload, Charsets.UTF_8))
DiagnosticsReport.share(act, report)
report.length().toString().toByteArray(Charsets.UTF_8)
}

// Diagnostics: Architecture Info
"getArchitectureInfo" -> {
BridgeDiagnosticsHandler.getArchitectureInfo(::escapeForString)
Expand Down
6 changes: 6 additions & 0 deletions dsm_client/android/app/src/main/res/xml/diagnostics_paths.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: MIT OR Apache-2.0 -->
<!-- The one directory the diagnostics FileProvider shares: the debug report. -->
<paths>
<cache-path name="diagnostics" path="diagnostics/" />
</paths>
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ use crate::sdk::core_sdk::CoreSDK;
use dsm::types::state_types::DeviceInfo;
use std::collections::HashMap;
use std::sync::Arc;

/// How long `wallet.send` keeps syncing for the recipient's finality
/// certificate before the send-ready authority refuses: the certificate
/// normally follows this device's acceptance within seconds.
const SEND_CERT_WAIT: std::time::Duration = std::time::Duration::from_secs(20);

/// The pause between those syncs.
const SEND_CERT_POLL: std::time::Duration = std::time::Duration::from_millis(1_500);
use tokio::sync::Mutex;

use super::response_helpers::{pack_envelope_ok, err};
Expand Down Expand Up @@ -563,6 +571,55 @@ impl AppRouterImpl {
));
}
}
// The finality barrier a send meets most often is seconds from clearing:
// this device accepted a transfer from the recipient, and the
// recipient's certificate for it is on its way back. Rather than refuse
// at once, keep syncing for the certificate a bounded while; the
// send-ready authority below still decides.
let cert_wait_started = std::time::Instant::now();
loop {
match crate::storage::client_db::counterparty_awaits_peer_finalization(&to_device_id) {
Ok(awaiting) if awaiting => {}
Ok(_) => break,
Err(e) => {
return err(format!(
"wallet.send: the acceptance finality state is unreadable: {e}"
))
}
}
let waited = cert_wait_started.elapsed();
if waited >= SEND_CERT_WAIT {
log::info!(
"[wallet.send] the recipient's finality certificate did not arrive within {}s",
SEND_CERT_WAIT.as_secs()
);
break;
}
log::info!(
"[wallet.send] waiting for the recipient's finality certificate ({} ms so far)",
waited.as_millis()
);
tokio::time::sleep(SEND_CERT_POLL).await;
match self
.run_storage_sync_request(crate::sdk::inbox_poller::poll_sync_request())
.await
{
Ok(resp) => {
if !resp.errors.is_empty() {
log::warn!(
"wallet.send: certificate sync completed with {} error(s): {:?}",
resp.errors.len(),
resp.errors
);
}
}
Err(e) => {
return err(format!(
"wallet.send: failed to sync for the recipient's finality certificate: {e}"
));
}
}
}
let contact_record =
match crate::storage::client_db::get_contact_by_device_id(&to_device_id) {
Ok(Some(contact)) => contact,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,21 @@ const DEFAULT_POLL_INTERVAL_MS: u64 = 60_000;
/// so follow-up messages (e.g. ACKs or rapid exchanges) are picked up faster.
const EAGER_POLL_INTERVAL_MS: u64 = 8_000;

/// Poll interval while sender-side pending online catch-up gates exist.
/// Poll interval while settlement work is outstanding (see
/// [`has_pending_settlement_work`]): a sender awaiting the acceptance, a
/// recipient owing its reply or awaiting the sender's certificate, a
/// certificate not yet at quorum.
///
/// This keeps ACK/finalization hot until the relationship actually converges,
/// instead of falling back to the idle 60s cadence after one early wake-up.
const PENDING_GATE_POLL_INTERVAL_MS: u64 = 5_000;
/// A transfer settles in three syncs — the recipient takes it and replies,
/// the sender finalizes and posts its certificate, the recipient takes the
/// certificate — and each side waits this long at most for the other's step.
/// At 5 s a two-phone round trip took up to half a minute after the money had
/// already moved, with both phones open.
const PENDING_GATE_POLL_INTERVAL_MS: u64 = 2_000;

/// Poll interval while the app is on screen and nothing is settling: an
/// incoming transfer shows within this, instead of within the idle minute.
const FOREGROUND_POLL_INTERVAL_MS: u64 = 5_000;

/// Number of consecutive eager-interval polls before reverting to default.
const EAGER_POLL_CYCLES: u32 = 5;
Expand Down Expand Up @@ -181,6 +191,8 @@ pub fn start_poller() {

let interval_ms = if pending_gate_active {
PENDING_GATE_POLL_INTERVAL_MS
} else if crate::sdk::session_manager::app_in_foreground() {
FOREGROUND_POLL_INTERVAL_MS
} else if eager_remaining > 0 {
EAGER_POLL_INTERVAL_MS
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,16 @@ fn envelope_wrap_snapshot(snapshot: generated::AppSessionStateProto) -> Vec<u8>

/// Acquire the global session manager lock and return envelope-wrapped snapshot bytes.
/// Returns `[0x03][Envelope(SessionStateResponse)]` — Kotlin relays untouched to WebView.
/// Whether the app is on screen, as the last session facts from the host said.
/// The inbox poller polls faster while it is.
pub fn app_in_foreground() -> bool {
let mgr = match SESSION_MANAGER.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
mgr.hardware.app_foreground
}

pub fn get_session_snapshot_bytes() -> Result<Vec<u8>, String> {
let mut mgr = SESSION_MANAGER.lock().unwrap_or_else(|p| p.into_inner());
mgr.sync_lock_config_from_app_state()
Expand Down
2 changes: 2 additions & 0 deletions dsm_client/frontend/src/bridge/practiceGate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const READ_METHODS = new Set([
'getPreference',
'getArchitectureInfo',
'getDiagnosticsLog',
// Writes only the report file in the app's cache, then opens the share sheet.
'shareDiagnosticsReport',
]);

/** The preferences the tour's shell lessons change: how the app looks and sounds. */
Expand Down
7 changes: 4 additions & 3 deletions dsm_client/frontend/src/components/DiagnosticsOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ export default function DiagnosticsOverlay() {
clearBridgeError,
gatherDiagnostics,
copyDiagnostics,
downloadDiagnostics,
shareDiagnostics,
sharesInFlight,
sendDiagnosticsTelemetry,
openGitHubIssue,
openGitHubFeedback,
Expand Down Expand Up @@ -141,7 +142,7 @@ export default function DiagnosticsOverlay() {
<div style={{ ...sectionCardStyle, marginBottom: 10, color: 'var(--text-dark)', fontSize: '11px' }}>
<div style={{ marginBottom: 8, fontWeight: 700 }}>Suggested actions</div>
<ul style={{ marginTop: 0, marginBottom: 8, paddingLeft: 18, fontSize: '10px', lineHeight: 1.35 }}>
<li>Copy or download the diagnostics and attach them to an issue.</li>
<li>Share report sends the full report, the app log of this device included, to the app you pick.</li>
<li>Collect device logs (adb logcat) for a full trace.</li>
<li>If you consent, save diagnostics into the local native log before filing a report.</li>
</ul>
Expand Down Expand Up @@ -171,7 +172,7 @@ export default function DiagnosticsOverlay() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 8 }}>
<button data-testid="send-diagnostics" disabled={!telemetryConsent || !diagnostics} onClick={() => void sendDiagnosticsTelemetry()} style={{ ...actionButtonStyle, opacity: (!telemetryConsent || !diagnostics) ? 0.5 : 1, cursor: (!telemetryConsent || !diagnostics) ? 'not-allowed' : 'pointer' }}>Save to local log</button>
<button data-testid="copy-diagnostics" onClick={() => void copyDiagnostics()} style={actionButtonStyle}>Copy</button>
<button data-testid="download-diagnostics" onClick={() => downloadDiagnostics()} style={actionButtonStyle}>Download</button>
<button data-testid="share-diagnostics" disabled={sharesInFlight > 0} onClick={() => { shareDiagnostics(); }} style={actionButtonStyle}>{sharesInFlight > 0 ? 'Preparing…' : 'Share report'}</button>
<button data-testid="open-issue" onClick={() => openGitHubIssue()} style={actionButtonStyle}>Open beta bug report</button>
<button data-testid="open-feedback" onClick={() => openGitHubFeedback()} style={actionButtonStyle}>Send feedback</button>
</div>
Expand Down
Loading
Loading