mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-17 05:41:29 +02:00
feat: monero cache debugging (#2436)
* feat: monero cache debugging feat: better JSON explorer for network traffic * frozen coins debugging
This commit is contained in:
@@ -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>()));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>(),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user