commit faa3415125190256095ce783fe8b65753936127e
parent 1c5e59a54e6c44abf8ec3c6c0c6c849452d88541
Author: Iván Ávalos <avalos@disroot.org>
Date: Thu, 29 Jan 2026 18:28:59 +0100
[wallet] implement UI for performance stats
Diffstat:
8 files changed, 383 insertions(+), 0 deletions(-)
diff --git a/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt
@@ -59,6 +59,8 @@ import androidx.core.net.toUri
import net.taler.wallet.BuildConfig
import net.taler.wallet.NetworkManager
import net.taler.wallet.donau.DonauManager
+import net.taler.wallet.stats.PerformanceTable
+import net.taler.wallet.stats.TestingGetPerformanceStatsResponse
const val TAG = "taler-wallet"
const val OBSERVABILITY_LIMIT = 100
@@ -138,6 +140,9 @@ class MainViewModel(
private val mObservabilityLog = MutableStateFlow<List<ObservabilityEvent>>(emptyList())
val observabilityLog: StateFlow<List<ObservabilityEvent>> = mObservabilityLog
+ private val mPerformanceTable = MutableStateFlow<PerformanceTable?>(null)
+ val performanceTable: StateFlow<PerformanceTable?> = mPerformanceTable
+
private val mScanCodeEvent = MutableLiveData<Event<Boolean>>()
val scanCodeEvent: LiveData<Event<Boolean>> = mScanCodeEvent
@@ -324,6 +329,22 @@ class MainViewModel(
}.onError(onError)
}
}
+
+ fun loadPerformanceStats(limit: Int? = 10) {
+ viewModelScope.launch {
+ api.request(
+ "testingGetPerformanceStats",
+ TestingGetPerformanceStatsResponse.serializer(),
+ ) {
+ limit?.let { put("limit", limit) }
+ this
+ }.onError { error ->
+ Log.e(TAG, "got testingGetPerformanceStats error result $error")
+ }.onSuccess { res ->
+ mPerformanceTable.value = res.stats
+ }
+ }
+ }
}
enum class ScanQrContext {
diff --git a/wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt b/wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt
@@ -71,6 +71,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
private lateinit var prefBiometricLock: SwitchPreference
private lateinit var prefWithdrawTest: Preference
private lateinit var prefLogcat: Preference
+ private lateinit var prefStats: Preference
private lateinit var prefExportDb: Preference
private lateinit var prefImportDb: Preference
private lateinit var prefVersionApp: Preference
@@ -84,6 +85,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
prefVersionCore,
prefWithdrawTest,
prefLogcat,
+ prefStats,
prefExportDb,
prefImportDb,
prefVersionExchange,
@@ -122,6 +124,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
prefBiometricLock = findPreference("pref_biometric_lock")!!
prefWithdrawTest = findPreference("pref_testkudos")!!
prefLogcat = findPreference("pref_logcat")!!
+ prefStats = findPreference("pref_stats")!!
prefExportDb = findPreference("pref_export_db")!!
prefImportDb = findPreference("pref_import_db")!!
prefVersionApp = findPreference("pref_version_app")!!
@@ -199,6 +202,10 @@ class SettingsFragment : PreferenceFragmentCompat() {
logLauncher.launch("taler-wallet-log-${currentTimeMillis()}.txt")
true
}
+ prefStats.setOnPreferenceClickListener {
+ findNavController().navigate(R.id.nav_performance_stats)
+ true
+ }
prefExportDb.setOnPreferenceClickListener {
dbExportLauncher.launch("taler-wallet-db-${currentTimeMillis()}.json")
true
diff --git a/wallet/src/main/java/net/taler/wallet/stats/PerformanceFragment.kt b/wallet/src/main/java/net/taler/wallet/stats/PerformanceFragment.kt
@@ -0,0 +1,230 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.stats
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import kotlinx.serialization.json.Json
+import net.taler.wallet.BottomInsetsSpacer
+import net.taler.wallet.R
+import net.taler.wallet.balances.SectionHeader
+import net.taler.wallet.compose.EmptyComposable
+import net.taler.wallet.compose.ShareButton
+import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.compose.collectAsStateLifecycleAware
+import net.taler.wallet.main.MainViewModel
+
+class PerformanceFragment: Fragment() {
+ private val model: MainViewModel by activityViewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ) = ComposeView(requireContext()).apply {
+ setContent {
+ TalerSurface {
+ val stats by model.performanceTable.collectAsStateLifecycleAware()
+
+ if (stats == null) {
+ EmptyComposable()
+ return@TalerSurface
+ }
+
+ stats?.let {
+ PerformanceTableComposable(it,
+ onReload = { model.loadPerformanceStats() },
+ )
+ }
+ }
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ model.loadPerformanceStats()
+ }
+}
+
+@Composable
+fun PerformanceTableComposable(
+ stats: PerformanceTable,
+ onReload: () -> Unit,
+) {
+ val json = remember { Json {
+ prettyPrint = true
+ ignoreUnknownKeys = true
+ coerceInputValues = true
+ } }
+
+ LazyColumn {
+ item {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceAround,
+ ) {
+ ShareButton(json.encodeToString(stats))
+
+ Button(onClick = onReload) {
+ Icon(
+ Icons.Default.Refresh,
+ contentDescription = null,
+ modifier = Modifier.size(ButtonDefaults.IconSize),
+ )
+ Spacer(Modifier.size(ButtonDefaults.IconSpacing))
+ Text(stringResource(R.string.reload))
+ }
+ }
+ }
+
+ if (stats.httpFetch.isNotEmpty()) {
+ stickyHeader {
+ SectionHeader { Text(stringResource(R.string.performance_stats_http_fetch)) }
+ }
+
+ itemsIndexed(stats.httpFetch) { i, stat ->
+ PerformanceStatItem(i + 1, stat)
+ }
+ }
+
+ if (stats.dbQuery.isNotEmpty()) {
+ stickyHeader {
+ SectionHeader { Text(stringResource(R.string.performance_stats_db_query)) }
+ }
+
+ itemsIndexed(stats.dbQuery) { i, stat ->
+ PerformanceStatItem(i + 1, stat)
+ }
+ }
+
+
+ if (stats.crypto.isNotEmpty()) {
+ stickyHeader {
+ SectionHeader { Text(stringResource(R.string.performance_stats_crypto)) }
+ }
+
+ itemsIndexed(stats.crypto) { i, stat ->
+ PerformanceStatItem(i + 1, stat)
+ }
+ }
+
+ if (stats.walletRequest.isNotEmpty()) {
+ stickyHeader {
+ SectionHeader { Text(stringResource(R.string.performance_stats_wallet_request)) }
+ }
+
+ itemsIndexed(stats.walletRequest) { i, stat ->
+ PerformanceStatItem(i + 1, stat)
+ }
+ }
+
+ if (stats.walletTask.isNotEmpty()) {
+ stickyHeader {
+ SectionHeader { Text(stringResource(R.string.performance_stats_wallet_task)) }
+ }
+
+ itemsIndexed(stats.walletTask) { i, stat ->
+ PerformanceStatItem(i + 1, stat)
+ }
+ }
+
+ item {
+ BottomInsetsSpacer()
+ }
+ }
+}
+
+@Composable
+fun PerformanceStatItem(
+ ranking: Int,
+ stat: PerformanceStat,
+) {
+ ListItem(
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.ranking, ranking),
+ fontWeight = FontWeight.ExtraBold,
+ style = MaterialTheme.typography.titleLarge,
+ )
+ },
+
+ headlineContent = {
+ when(stat) {
+ is PerformanceStat.HttpFetch -> Text(
+ text = stat.url,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ is PerformanceStat.DbQuery -> Text(stat.name)
+ is PerformanceStat.Crypto -> Text(stat.operation)
+ is PerformanceStat.WalletRequest -> Text(stat.operation)
+ is PerformanceStat.WalletTask -> Text(
+ text = stat.taskId,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ )
+ }
+ },
+
+ supportingContent = {
+ when(stat) {
+ is PerformanceStat.DbQuery -> Text(
+ text = stat.location,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ )
+ else -> {}
+ }
+ },
+
+ trailingContent = {
+ Text(stringResource(R.string.millisecond, stat.durationMs))
+ },
+ )
+}
+\ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/stats/PerformanceStats.kt b/wallet/src/main/java/net/taler/wallet/stats/PerformanceStats.kt
@@ -0,0 +1,84 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.stats
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed class PerformanceStat {
+ abstract val durationMs: Int
+
+ @Serializable
+ @SerialName("http-fetch")
+ data class HttpFetch(
+ val url: String,
+ override val durationMs: Int,
+ ): PerformanceStat()
+
+ @Serializable
+ @SerialName("db-query")
+ data class DbQuery(
+ val name: String,
+ val location: String,
+ override val durationMs: Int,
+ ): PerformanceStat()
+
+ @Serializable
+ @SerialName("crypto")
+ data class Crypto(
+ val operation: String,
+ override val durationMs: Int,
+ ): PerformanceStat()
+
+ @Serializable
+ @SerialName("wallet-request")
+ data class WalletRequest(
+ val operation: String,
+ override val durationMs: Int,
+ ): PerformanceStat()
+
+ @Serializable
+ @SerialName("wallet-task")
+ data class WalletTask(
+ val taskId: String,
+ override val durationMs: Int,
+ ): PerformanceStat()
+}
+
+@Serializable
+data class PerformanceTable(
+ @SerialName("http-fetch")
+ val httpFetch: List<PerformanceStat.HttpFetch> = emptyList(),
+
+ @SerialName("db-query")
+ val dbQuery: List<PerformanceStat.DbQuery> = emptyList(),
+
+ @SerialName("crypto")
+ val crypto: List<PerformanceStat.Crypto> = emptyList(),
+
+ @SerialName("wallet-request")
+ val walletRequest: List<PerformanceStat.WalletRequest> = emptyList(),
+
+ @SerialName("wallet-task")
+ val walletTask: List<PerformanceStat.WalletTask> = emptyList(),
+)
+
+@Serializable
+data class TestingGetPerformanceStatsResponse(
+ val stats: PerformanceTable,
+)
+\ No newline at end of file
diff --git a/wallet/src/main/res/drawable/ic_stats.xml b/wallet/src/main/res/drawable/ic_stats.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960"
+ android:tint="?attr/colorControlNormal">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M105,561L40,514L240,194L360,334L520,74L640,254L775,40L840,87L642,401L523,222L371,469L250,328L105,561ZM580,720Q622,720 651,691Q680,662 680,620Q680,578 651,549Q622,520 580,520Q538,520 509,549Q480,578 480,620Q480,662 509,691Q538,720 580,720ZM784,880L676,772Q655,786 630.5,793Q606,800 580,800Q505,800 452.5,747.5Q400,695 400,620Q400,545 452.5,492.5Q505,440 580,440Q655,440 707.5,492.5Q760,545 760,620Q760,646 753,670.5Q746,695 732,716L840,824L784,880Z"/>
+</vector>
diff --git a/wallet/src/main/res/navigation/nav_graph.xml b/wallet/src/main/res/navigation/nav_graph.xml
@@ -352,6 +352,11 @@
android:name="net.taler.wallet.exchanges.ExchangeShoppingFragment"
android:label="@string/exchange_shopping_title" />
+ <fragment
+ android:id="@+id/performanceStats"
+ android:name="net.taler.wallet.stats.PerformanceFragment"
+ android:label="@string/performance_stats_title" />
+
<action
android:id="@+id/action_global_handle_uri"
app:destination="@id/handleUri" />
@@ -397,4 +402,8 @@
android:id="@+id/nav_shopping"
app:destination="@id/exchangeShopping" />
+ <action
+ android:id="@+id/nav_performance_stats"
+ app:destination="@id/performanceStats" />
+
</navigation>
diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml
@@ -69,12 +69,15 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish
<string name="import_db">Import</string>
<string name="loading">Loading</string>
<string name="menu">Menu</string>
+ <string name="millisecond">%1$d ms</string>
<string name="offline">Operation requires internet access. Please ensure your internet connection works and try again.</string>
<string name="offline_banner">No internet access</string>
<string name="ok">OK</string>
<string name="open">Open</string>
<string name="paste">Paste</string>
<string name="paste_invalid">Clipboard contains an invalid data type</string>
+ <string name="ranking">#%1$d</string>
+ <string name="reload">Reload</string>
<string name="reset">Reset</string>
<string name="save">Save</string>
<string name="share_payment">Share payment link</string>
@@ -447,6 +450,14 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish
<string name="observability_title">Internal event log</string>
<string name="show_logs">Show logs</string>
+ <!-- Performance stats -->
+ <string name="performance_stats_title">Performance stats</string>
+ <string name="performance_stats_http_fetch">HTTP requests</string>
+ <string name="performance_stats_db_query">Database queries</string>
+ <string name="performance_stats_crypto">Cryptographic operations</string>
+ <string name="performance_stats_wallet_request">Wallet-Core requests</string>
+ <string name="performance_stats_wallet_task">Wallet-Core tasks</string>
+
<!-- Settings -->
<string name="menu_settings">Settings</string>
@@ -478,6 +489,8 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish
<string name="settings_logcat_error">Error exporting log</string>
<string name="settings_logcat_success">Log exported to file</string>
<string name="settings_logcat_summary">Save internal log</string>
+ <string name="settings_stats">Performance stats</string>
+ <string name="settings_stats_summary">View top time-consuming operations</string>
<string name="settings_reset">Reset Wallet (dangerous!)</string>
<string name="settings_reset_summary">Throws away your money</string>
<string name="settings_test">Run integration test</string>
diff --git a/wallet/src/main/res/xml/settings_main.xml b/wallet/src/main/res/xml/settings_main.xml
@@ -68,6 +68,13 @@
tools:isPreferenceVisible="true" />
<Preference
+ app:icon="@drawable/ic_stats"
+ app:isPreferenceVisible="false"
+ android:key="pref_stats"
+ android:summary="@string/settings_stats_summary"
+ android:title="@string/settings_stats" />
+
+ <Preference
app:icon="@drawable/ic_unarchive"
app:isPreferenceVisible="false"
app:key="pref_export_db"