feat: monero cache debugging (#2436)

* feat: monero cache debugging
feat: better JSON explorer for network traffic

* frozen coins debugging
This commit is contained in:
cyan
2025-09-12 15:47:19 +03:00
committed by GitHub
parent e1747f41b8
commit 52e778f275
19 changed files with 584 additions and 22 deletions
+3
View File
@@ -33,6 +33,7 @@ import 'package:cake_wallet/entities/parse_address_from_domain.dart';
import 'package:cake_wallet/exchange/provider/trocador_exchange_provider.dart';
import 'package:cake_wallet/haven/cw_haven.dart';
import 'package:cake_wallet/src/screens/dev/monero_background_sync.dart';
import 'package:cake_wallet/src/screens/dev/moneroc_cache_debug.dart';
import 'package:cake_wallet/src/screens/dev/moneroc_call_profiler.dart';
import 'package:cake_wallet/src/screens/dev/network_requests.dart';
import 'package:cake_wallet/src/screens/dev/secure_preferences_page.dart';
@@ -1550,6 +1551,8 @@ Future<void> setup({
getIt.registerFactory(() => DevMoneroCallProfilerPage());
getIt.registerFactory(() => DevMoneroWalletCacheDebugPage());
getIt.registerFactory(() => DevSharedPreferencesPage(getIt.get<DevSharedPreferences>()));
getIt.registerFactory(() => DevSecurePreferencesPage(getIt.get<DevSecurePreferences>()));
+5
View File
@@ -442,4 +442,9 @@ class CWMonero extends Monero {
Map<String, List<int>> debugCallLength() {
return monero_wallet_api.debugCallLength();
}
@override
Map<String, dynamic> getWalletCacheDebug() {
return monero_wallet_api.getWalletCacheDebug();
}
}
+6
View File
@@ -36,6 +36,7 @@ import 'package:cake_wallet/src/screens/dashboard/pages/nft_details_page.dart';
import 'package:cake_wallet/src/screens/dashboard/pages/transactions_page.dart';
import 'package:cake_wallet/src/screens/dashboard/sign_page.dart';
import 'package:cake_wallet/src/screens/dev/monero_background_sync.dart';
import 'package:cake_wallet/src/screens/dev/moneroc_cache_debug.dart';
import 'package:cake_wallet/src/screens/dev/moneroc_call_profiler.dart';
import 'package:cake_wallet/src/screens/dev/network_requests.dart';
import 'package:cake_wallet/src/screens/dev/secure_preferences_page.dart';
@@ -929,6 +930,11 @@ Route<dynamic> createRoute(RouteSettings settings) {
builder: (_) => getIt.get<DevMoneroCallProfilerPage>(),
);
case Routes.devMoneroWalletCacheDebug:
return MaterialPageRoute<void>(
builder: (_) => getIt.get<DevMoneroWalletCacheDebugPage>(),
);
case Routes.devSecurePreferences:
return MaterialPageRoute<void>(
builder: (_) => getIt.get<DevSecurePreferencesPage>(),
+1
View File
@@ -116,6 +116,7 @@ class Routes {
static const devMoneroBackgroundSync = '/dev/monero_background_sync';
static const devMoneroCallProfiler = '/dev/monero_call_profiler';
static const devMoneroWalletCacheDebug = '/dev/monero_wallet_cache_debug';
static const devSharedPreferences = '/dev/shared_preferences';
static const devSecurePreferences = '/dev/secure_preferences';
@@ -0,0 +1,443 @@
import 'dart:convert';
import 'dart:io';
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/monero/monero.dart';
import 'package:cake_wallet/utils/share_util.dart';
import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:cw_core/root_dir.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as path;
class DevMoneroWalletCacheDebugPage extends StatelessWidget {
DevMoneroWalletCacheDebugPage();
@override
Widget build(BuildContext context) {
return MoneroCacheDebug();
}
}
class MoneroCacheDebug extends StatefulWidget {
const MoneroCacheDebug({super.key});
@override
State<MoneroCacheDebug> createState() => _MoneroCacheDebugState();
}
enum DebuggableWallets {
monero,
}
class _MoneroCacheDebugState extends State<MoneroCacheDebug> {
final dashboardViewModel = getIt.get<DashboardViewModel>();
late DebuggableWallets wallet = switch (dashboardViewModel.wallet.type) {
WalletType.monero => DebuggableWallets.monero,
_ => throw Exception("Unknown wallet type"),
};
late Map<String, dynamic> walletCache = switch (wallet) {
DebuggableWallets.monero => monero!.getWalletCacheDebug(),
};
@override
Widget build(BuildContext context) {
return JsonExplorerPage(
data: walletCache,
title: 'Wallet Cache',
);
}
}
class JsonExplorerPage extends StatelessWidget {
final dynamic data;
final String title;
const JsonExplorerPage({
super.key,
required this.data,
required this.title,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
actions: [
IconButton(
onPressed: () => _copyToClipboard(context),
icon: const Icon(Icons.copy),
tooltip: 'Copy all to clipboard',
),
],
),
body: JsonExplorer(
data: data,
title: title,
),
);
}
Future<void> _copyToClipboard(BuildContext context) async {
final jsonString = JsonEncoder.withIndent(' ').convert(data);
try {
await Clipboard.setData(ClipboardData(text: jsonString));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Data copied to clipboard')),
);
return;
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to copy to clipboard: $e')),
);
}
try {
final appDir = await getAppDir();
final filePath = appDir.path + '/.json_dump_temp.json';
await File(filePath).writeAsString(jsonString);
await ShareUtil.shareFile(filePath: filePath, fileName: path.basename(filePath), context: context);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to share file: $e')),
);
}
}
}
class JsonExplorer extends StatefulWidget {
final dynamic data;
final String title;
const JsonExplorer({
super.key,
required this.data,
required this.title,
});
@override
State<JsonExplorer> createState() => _JsonExplorerState();
}
class _JsonExplorerState extends State<JsonExplorer> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
List<CacheItem> _filteredItems = [];
final List<CacheItem> _allItems = [];
@override
void initState() {
super.initState();
_buildItemList();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _buildItemList() {
_allItems.clear();
if (widget.data is Map) {
final map = widget.data as Map<String, dynamic>;
final sortedKeys = map.keys.toList()..sort();
for (final key in sortedKeys) {
_allItems.add(CacheItem(
key: key,
value: map[key],
displayKey: key,
));
}
} else if (widget.data is List) {
final list = widget.data as List;
for (int i = 0; i < list.length; i++) {
_allItems.add(CacheItem(
key: i.toString(),
value: list[i],
displayKey: '[$i]',
));
}
}
_applyFilter();
}
void _applyFilter() {
if (_searchQuery.isEmpty) {
_filteredItems = _allItems;
} else {
_filteredItems = _allItems.where((item) {
return item.displayKey.toLowerCase().contains(_searchQuery.toLowerCase()) ||
_valueContainsSearch(item.value, _searchQuery.toLowerCase());
}).toList();
}
}
bool _valueContainsSearch(dynamic value, String search) {
if (value == null) return false;
return value.toString().toLowerCase().contains(search);
}
void _copyItemToClipboard(CacheItem item) {
final jsonString = JsonEncoder.withIndent(' ').convert(item.value);
Clipboard.setData(ClipboardData(text: jsonString));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${item.displayKey} copied to clipboard')),
);
}
void _navigateToItem(CacheItem item) {
if (item.value is Map || item.value is List) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => JsonExplorerPage(
data: item.value,
title: item.displayKey,
),
),
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final totalItems = widget.data is Map
? (widget.data as Map).length
: widget.data is List
? (widget.data as List).length
: 0;
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search in ${totalItems} items...',
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
isDense: true,
suffixText: _searchQuery.isNotEmpty
? '${_filteredItems.length} found'
: null,
),
onChanged: (value) {
setState(() {
_searchQuery = value;
_applyFilter();
});
},
),
),
],
),
),
Expanded(
child: _filteredItems.isEmpty && _searchQuery.isNotEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.search_off, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(
'No items found for "${_searchQuery}"',
style: theme.textTheme.titleMedium?.copyWith(
color: Colors.grey,
),
),
],
),
)
: ListView.builder(
itemCount: _filteredItems.length,
itemBuilder: (context, index) {
final item = _filteredItems[index];
return CacheItemTile(
item: item,
onTap: () => _navigateToItem(item),
onCopy: () => _copyItemToClipboard(item),
searchQuery: _searchQuery,
);
},
),
),
],
);
}
}
class CacheItem {
final String key;
final dynamic value;
final String displayKey;
CacheItem({
required this.key,
required this.value,
required this.displayKey,
});
}
class CacheItemTile extends StatelessWidget {
final CacheItem item;
final VoidCallback onTap;
final VoidCallback onCopy;
final String searchQuery;
const CacheItemTile({
super.key,
required this.item,
required this.onTap,
required this.onCopy,
this.searchQuery = '',
});
Color _getTypeColor(BuildContext context, dynamic value) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
if (value == null) {
return Colors.grey;
}
switch (value.runtimeType) {
case String:
return Colors.green;
case int:
case double:
return Colors.blue;
case bool:
return Colors.orange;
// ignore: strict_raw_type
case Map:
return Colors.purple;
// ignore: strict_raw_type
case List:
return Colors.cyan;
default:
return Colors.pink;
}
}
String _getValuePreview(dynamic value) {
if (value == null) return 'null';
if (value is Map) {
return '{${value.length} items}';
}
if (value is List) {
return '[${value.length} items]';
}
if (value is String) {
if (value.length > 100) {
return '"${value.substring(0, 97)}..."';
}
return '"$value"';
}
final str = value.toString();
if (str.length > 100) {
return '${str.substring(0, 97)}...';
}
return str;
}
Widget _buildHighlightedText(String text, String query, TextStyle style) {
if (query.isEmpty) {
return Text(text, style: style);
}
final lowerText = text.toLowerCase();
final lowerQuery = query.toLowerCase();
final matches = <TextSpan>[];
int start = 0;
while (true) {
final index = lowerText.indexOf(lowerQuery, start);
if (index == -1) {
if (start < text.length) {
matches.add(TextSpan(text: text.substring(start), style: style));
}
break;
}
if (index > start) {
matches.add(TextSpan(text: text.substring(start, index), style: style));
}
matches.add(TextSpan(
text: text.substring(index, index + query.length),
style: style.copyWith(
backgroundColor: Colors.yellow.withOpacity(0.3),
fontWeight: FontWeight.bold,
),
));
start = index + query.length;
}
return RichText(text: TextSpan(children: matches));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final canNavigate = item.value is Map || item.value is List;
final valuePreview = _getValuePreview(item.value);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: ListTile(
leading: Icon(
canNavigate
? (item.value is Map ? Icons.folder : Icons.list)
: Icons.description,
color: _getTypeColor(context, item.value),
),
title: _buildHighlightedText(
item.displayKey,
searchQuery,
theme.textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
),
subtitle: _buildHighlightedText(
valuePreview,
searchQuery,
theme.textTheme.bodySmall!.copyWith(
color: _getTypeColor(context, item.value),
fontFamily: 'monospace',
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: onCopy,
icon: const Icon(Icons.copy, size: 18),
tooltip: 'Copy ${item.displayKey}',
),
if (canNavigate)
const Icon(Icons.chevron_right),
],
),
onTap: canNavigate ? onTap : null,
enabled: canNavigate,
),
);
}
}
+27
View File
@@ -2,6 +2,8 @@ import 'dart:convert';
import 'dart:typed_data';
import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/screens/dev/moneroc_cache_debug.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/view_model/dev/network_requests_view_model.dart';
import 'package:cw_core/utils/proxy_logger/abstract.dart';
import 'package:cw_core/utils/proxy_logger/memory_proxy_logger.dart';
@@ -133,9 +135,11 @@ class DevRequestDetails extends BasePage {
_sectionTitle("Body (as UTF-8)"),
SelectableText(_tryDecodeBody(req.body)),
_buildJsonExplorer(context, _tryDecodeBody(req.body)),
_sectionTitle("Response"),
SelectableText(req.response?.body ?? "null"),
_buildJsonExplorer(context, req.response?.body ?? "{}"),
_sectionTitle("Error"),
SelectableText(req.error ?? "No error"),
@@ -146,6 +150,29 @@ class DevRequestDetails extends BasePage {
);
}
Widget _buildJsonExplorer(BuildContext context, String body) {
try {
final jsonData = json.decode(body);
return PrimaryButton(
text: "View JSON",
color: Colors.blue,
textColor: Colors.white,
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) {
return JsonExplorerPage(data: jsonData, title: "body");
},
),
);
},
);
} catch (e) {
return SelectableText("Invalid JSON: $e");
}
}
Widget _sectionTitle(String title) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
@@ -75,6 +75,12 @@ class OtherSettingsPage extends BasePage {
handler: (BuildContext context) =>
Navigator.of(context).pushNamed(Routes.devMoneroCallProfiler),
),
if (FeatureFlag.hasDevOptions && [WalletType.monero].contains(_otherSettingsViewModel.walletType))
SettingsCellWithArrow(
title: '[dev] xmr wallet cache debug',
handler: (BuildContext context) =>
Navigator.of(context).pushNamed(Routes.devMoneroWalletCacheDebug),
),
if (FeatureFlag.hasDevOptions)
SettingsCellWithArrow(
title: '[dev] shared preferences',