taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

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:
Mwallet/src/main/java/net/taler/wallet/main/MainViewModel.kt | 21+++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt | 7+++++++
Awallet/src/main/java/net/taler/wallet/stats/PerformanceFragment.kt | 231+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/src/main/java/net/taler/wallet/stats/PerformanceStats.kt | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/src/main/res/drawable/ic_stats.xml | 10++++++++++
Mwallet/src/main/res/navigation/nav_graph.xml | 9+++++++++
Mwallet/src/main/res/values/strings.xml | 13+++++++++++++
Mwallet/src/main/res/xml/settings_main.xml | 7+++++++
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"