mirror of
https://github.com/cake-tech/cake_wallet.git
synced 2025-06-28 12:29:51 +00:00
feat: dev screen for file manager, snapshot and fuzzing
This commit adds tooling to find potential issues with code that are difficult to notice during debugging (especially on builds from the CI which lack debugging capabilities). File manager supports basic operations like browsing files, renaming, copying and removing Snapshots allow to take a snapshot of current app data, and compare to it later on with basic hexdump view - useful to notice anomalies or modifications to files that shouldn't be accessed. File watcher checks for file modifications in real time, which is used to see what actions are happening to the fs in real time. Fuzzing can be used to load/create wallets randomly to see if any of them will get corrupted (can be used in addition with wallet_fuzzer.sh script that kills the app and restarts the process at random intervals to simulate unexpected action that can happen for example in the background). In addition I've added toString method to {bitcoin,monero,nano ,zano}_wallet_keys.dart so I can easily compare the results when fuzzing without accessing wallet-specific code.
This commit is contained in:
parent
1d6e594e04
commit
7ac732198b
17 changed files with 3651 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -227,3 +227,6 @@ flatpak-build/
|
|||
**/linux/flutter/generated_plugin_registrant.cc
|
||||
**/linux/flutter/generated_plugin_registrant.h
|
||||
**/linux/flutter/generated_plugins.cmake
|
||||
|
||||
|
||||
wallet_fuzzer_data/
|
|
@ -4,4 +4,9 @@ class BitcoinWalletKeys {
|
|||
final String wif;
|
||||
final String privateKey;
|
||||
final String publicKey;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BitcoinWalletKeys(wif: $wif, privateKey: $privateKey, publicKey: $publicKey)';
|
||||
}
|
||||
}
|
|
@ -13,4 +13,9 @@ class MoneroWalletKeys {
|
|||
final String publicSpendKey;
|
||||
final String privateSpendKey;
|
||||
final String passphrase;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'MoneroWalletKeys(primaryAddress: $primaryAddress, publicViewKey: $publicViewKey, privateViewKey: $privateViewKey, publicSpendKey: $publicSpendKey, privateSpendKey: $privateSpendKey, passphrase: $passphrase)';
|
||||
}
|
||||
}
|
|
@ -2,4 +2,9 @@ class NanoWalletKeys {
|
|||
const NanoWalletKeys({required this.seedKey});
|
||||
|
||||
final String seedKey;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'NanoWalletKeys(seedKey: $seedKey)';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,4 +9,9 @@ class ZanoWalletKeys {
|
|||
final String privateViewKey;
|
||||
final String publicSpendKey;
|
||||
final String privateSpendKey;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ZanoWalletKeys(publicViewKey: $publicViewKey, privateViewKey: $privateViewKey, publicSpendKey: $publicSpendKey, privateSpendKey: $privateSpendKey)';
|
||||
}
|
||||
}
|
|
@ -35,6 +35,10 @@ import 'package:cake_wallet/src/screens/dev/monero_background_sync.dart';
|
|||
import 'package:cake_wallet/src/screens/dev/moneroc_call_profiler.dart';
|
||||
import 'package:cake_wallet/src/screens/dev/secure_preferences_page.dart';
|
||||
import 'package:cake_wallet/src/screens/dev/shared_preferences_page.dart';
|
||||
import 'package:cake_wallet/src/screens/dev/file_explorer.dart';
|
||||
import 'package:cake_wallet/src/screens/dev/monero_background_sync.dart';
|
||||
import 'package:cake_wallet/src/screens/dev/moneroc_call_profiler.dart';
|
||||
import 'package:cake_wallet/src/screens/dev/wallet_fuzzer.dart';
|
||||
import 'package:cake_wallet/src/screens/settings/background_sync_page.dart';
|
||||
import 'package:cake_wallet/src/screens/wallet_connect/services/bottom_sheet_service.dart';
|
||||
import 'package:cake_wallet/src/screens/wallet_connect/services/key_service/wallet_connect_key_service.dart';
|
||||
|
@ -1511,5 +1515,9 @@ Future<void> setup({
|
|||
|
||||
getIt.registerFactory(() => DevBackgroundSyncLogsPage(getIt.get<BackgroundSyncLogsViewModel>()));
|
||||
|
||||
getIt.registerFactory(() => FileExplorerPage());
|
||||
|
||||
getIt.registerFactory(() => WalletFuzzerPage());
|
||||
|
||||
_isSetupFinished = true;
|
||||
}
|
||||
|
|
|
@ -28,8 +28,10 @@ import 'package:cake_wallet/store/authentication_store.dart';
|
|||
import 'package:cake_wallet/themes/utils/theme_provider.dart';
|
||||
import 'package:cake_wallet/utils/device_info.dart';
|
||||
import 'package:cake_wallet/utils/exception_handler.dart';
|
||||
import 'package:cake_wallet/utils/feature_flag.dart';
|
||||
import 'package:cake_wallet/view_model/link_view_model.dart';
|
||||
import 'package:cake_wallet/utils/responsive_layout_util.dart';
|
||||
import 'package:cake_wallet/view_model/dev/file_explorer.dart';
|
||||
import 'package:cw_core/address_info.dart';
|
||||
import 'package:cw_core/cake_hive.dart';
|
||||
import 'package:cw_core/hive_type_ids.dart';
|
||||
|
@ -65,6 +67,9 @@ Future<void> runAppWithZone({Key? topLevelKey}) async {
|
|||
|
||||
await runZonedGuarded(() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
if (FeatureFlag.hasDevOptions) {
|
||||
await checkAndStartFileMonitoring();
|
||||
}
|
||||
FlutterError.onError = ExceptionHandler.onError;
|
||||
|
||||
/// A callback that is invoked when an unhandled error occurs in the root
|
||||
|
@ -449,3 +454,15 @@ Future<void> backgroundSync() async {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> checkAndStartFileMonitoring() async {
|
||||
try {
|
||||
final shouldMonitor = await FileExplorerViewModelBase.checkDevMonitorFileExists();
|
||||
if (shouldMonitor) {
|
||||
printV('Dev file monitoring enabled, starting file system watcher...');
|
||||
await FileExplorerViewModelBase.startMonitoring();
|
||||
}
|
||||
} catch (e) {
|
||||
printV('Failed to initialize file monitoring: $e');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,11 +36,13 @@ import 'package:cake_wallet/src/screens/dashboard/pages/address_page.dart';
|
|||
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/file_explorer.dart';
|
||||
import 'package:cake_wallet/src/screens/dev/monero_background_sync.dart';
|
||||
import 'package:cake_wallet/src/screens/dev/moneroc_call_profiler.dart';
|
||||
import 'package:cake_wallet/src/screens/dev/secure_preferences_page.dart';
|
||||
import 'package:cake_wallet/src/screens/dev/shared_preferences_page.dart';
|
||||
import 'package:cake_wallet/src/screens/dev/background_sync_logs_page.dart';
|
||||
import 'package:cake_wallet/src/screens/dev/wallet_fuzzer.dart';
|
||||
import 'package:cake_wallet/src/screens/disclaimer/disclaimer_page.dart';
|
||||
import 'package:cake_wallet/src/screens/exchange/exchange_page.dart';
|
||||
import 'package:cake_wallet/src/screens/exchange/exchange_template_page.dart';
|
||||
|
@ -920,6 +922,16 @@ Route<dynamic> createRoute(RouteSettings settings) {
|
|||
builder: (_) => getIt.get<DevSecurePreferencesPage>(),
|
||||
);
|
||||
|
||||
case Routes.devFileExplorer:
|
||||
return MaterialPageRoute<void>(
|
||||
builder: (_) => getIt.get<FileExplorerPage>(),
|
||||
);
|
||||
|
||||
case Routes.devWalletFuzzer:
|
||||
return MaterialPageRoute<void>(
|
||||
builder: (_) => getIt.get<WalletFuzzerPage>(),
|
||||
);
|
||||
|
||||
default:
|
||||
return MaterialPageRoute<void>(
|
||||
builder: (_) => Scaffold(
|
||||
|
|
|
@ -119,6 +119,8 @@ class Routes {
|
|||
static const devSecurePreferences = '/dev/secure_preferences';
|
||||
static const devBackgroundSyncLogs = '/dev/background_sync_logs';
|
||||
|
||||
static const devFileExplorer = '/dev/file_explorer';
|
||||
static const devWalletFuzzer = '/dev/wallet_fuzzer';
|
||||
static const signPage = '/sign_page';
|
||||
static const connectDevices = '/device/connect';
|
||||
static const urqrAnimatedPage = '/urqr/animated_page';
|
||||
|
|
1039
lib/src/screens/dev/file_explorer.dart
Normal file
1039
lib/src/screens/dev/file_explorer.dart
Normal file
File diff suppressed because it is too large
Load diff
238
lib/src/screens/dev/wallet_fuzzer.dart
Normal file
238
lib/src/screens/dev/wallet_fuzzer.dart
Normal file
|
@ -0,0 +1,238 @@
|
|||
import 'package:cake_wallet/src/screens/base_page.dart';
|
||||
import 'package:cake_wallet/view_model/dev/wallet_fuzzer.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class WalletFuzzerPage extends BasePage {
|
||||
WalletFuzzerPage();
|
||||
|
||||
@override
|
||||
String? get title => "[dev] wallet fuzzer";
|
||||
|
||||
final viewModel = WalletFuzzerViewModel();
|
||||
|
||||
@override
|
||||
Widget body(BuildContext context) {
|
||||
return Observer(
|
||||
builder: (_) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildStatusBar(context),
|
||||
Expanded(
|
||||
child: _buildLogsList(context),
|
||||
),
|
||||
_buildControls(context),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBar(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
color: Theme.of(context).primaryColor,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
viewModel.isRunning ? Icons.warning_amber : Icons.info_outline,
|
||||
color: viewModel.isRunning ? Colors.amber : Colors.white,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Status: ${viewModel.isRunning ? "RUNNING" : "Stopped"}',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: viewModel.isRunning ? Colors.green.withOpacity(0.3) : Colors.red.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: viewModel.isRunning ? Colors.green : Colors.red,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Ops: ${viewModel.operationsCompleted}',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.red),
|
||||
),
|
||||
child: Text(
|
||||
'Errors: ${viewModel.errorsEncountered}',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Current Operation: ${viewModel.currentOperation}',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
Text(
|
||||
'Current Wallet: ${viewModel.currentWallet.isEmpty ? "none" : viewModel.currentWallet}',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogsList(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Logs:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete_sweep),
|
||||
onPressed: () => viewModel.clearLogs(),
|
||||
tooltip: 'Clear logs',
|
||||
),
|
||||
],
|
||||
),
|
||||
Divider(),
|
||||
Expanded(
|
||||
child: viewModel.logs.isEmpty
|
||||
? Center(child: Text('No logs yet. Start fuzzing to see logs.'))
|
||||
: ListView.builder(
|
||||
itemCount: viewModel.logs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final log = viewModel.logs[index];
|
||||
final formattedTime = DateFormat('HH:mm:ss').format(log.timestamp);
|
||||
|
||||
Color logColor = Colors.black;
|
||||
if (log.action.contains('Error') || log.result?.contains('Error') == true || log.result?.contains('error') == true) {
|
||||
logColor = Colors.red;
|
||||
} else if (log.action.contains('Success') || log.action.contains('successfully')) {
|
||||
logColor = Colors.green;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
formattedTime,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: log.result != null
|
||||
? RichText(
|
||||
text: TextSpan(
|
||||
style: DefaultTextStyle.of(context).style,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${log.action} - ',
|
||||
style: TextStyle(color: logColor),
|
||||
),
|
||||
TextSpan(
|
||||
text: log.result,
|
||||
style: TextStyle(
|
||||
color: logColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
log.action,
|
||||
style: TextStyle(color: logColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControls(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
spreadRadius: 1,
|
||||
blurRadius: 3,
|
||||
offset: Offset(0, -1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: viewModel.isRunning ? Colors.red : Colors.green,
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
onPressed: () {
|
||||
if (viewModel.isRunning) {
|
||||
viewModel.stopFuzzing();
|
||||
} else {
|
||||
viewModel.startFuzzing();
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
viewModel.isRunning ? 'STOP FUZZING' : 'START FUZZING',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -93,6 +93,18 @@ class OtherSettingsPage extends BasePage {
|
|||
handler: (BuildContext context) =>
|
||||
Navigator.of(context).pushNamed(Routes.devBackgroundSyncLogs),
|
||||
),
|
||||
if (FeatureFlag.hasDevOptions)
|
||||
SettingsCellWithArrow(
|
||||
title: '[dev] file explorer',
|
||||
handler: (BuildContext context) =>
|
||||
Navigator.of(context).pushNamed(Routes.devFileExplorer),
|
||||
),
|
||||
if (FeatureFlag.hasDevOptions)
|
||||
SettingsCellWithArrow(
|
||||
title: '[dev] wallet fuzzer',
|
||||
handler: (BuildContext context) =>
|
||||
Navigator.of(context).pushNamed(Routes.devWalletFuzzer),
|
||||
),
|
||||
Spacer(),
|
||||
SettingsVersionCell(
|
||||
title: S.of(context).version(_otherSettingsViewModel.currentVersion)),
|
||||
|
|
967
lib/view_model/dev/file_explorer.dart
Normal file
967
lib/view_model/dev/file_explorer.dart
Normal file
|
@ -0,0 +1,967 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cw_core/root_dir.dart';
|
||||
import 'package:cw_core/utils/print_verbose.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:watcher/watcher.dart';
|
||||
|
||||
part 'file_explorer.g.dart';
|
||||
|
||||
class FileExplorerViewModel = FileExplorerViewModelBase with _$FileExplorerViewModel;
|
||||
|
||||
class Snapshot {
|
||||
final String name;
|
||||
final DateTime timestamp;
|
||||
final Map<String, FileInfo> files;
|
||||
|
||||
Snapshot({required this.name, required this.timestamp, required this.files});
|
||||
}
|
||||
|
||||
class FileInfo {
|
||||
final String path;
|
||||
final int size;
|
||||
final DateTime modified;
|
||||
final Uint8List? content;
|
||||
|
||||
FileInfo({
|
||||
required this.path,
|
||||
required this.size,
|
||||
required this.modified,
|
||||
this.content
|
||||
});
|
||||
}
|
||||
|
||||
class FileChange {
|
||||
final String path;
|
||||
final ChangeType type;
|
||||
final FileInfo? oldFile;
|
||||
final FileInfo? newFile;
|
||||
|
||||
FileChange({required this.path, required this.type, this.oldFile, this.newFile});
|
||||
}
|
||||
|
||||
enum ChangeType { added, removed, modified, touched }
|
||||
|
||||
enum ViewMode { fileExplorer, snapshots, hexdump, comparison, detailedComparison, fileMonitor }
|
||||
|
||||
class FileEvent {
|
||||
final DateTime timestamp;
|
||||
final WatchEvent event;
|
||||
final String relativePath;
|
||||
|
||||
FileEvent({required this.timestamp, required this.event, required this.relativePath});
|
||||
}
|
||||
|
||||
abstract class FileExplorerViewModelBase with Store {
|
||||
FileExplorerViewModelBase() {
|
||||
unawaited(_initialize());
|
||||
}
|
||||
|
||||
static ObservableList<FileEvent> fileEvents = ObservableList<FileEvent>();
|
||||
|
||||
static DirectoryWatcher? _watcher;
|
||||
|
||||
static bool isMonitoringActive = false;
|
||||
|
||||
@observable
|
||||
String? path;
|
||||
|
||||
@observable
|
||||
String? rootPath;
|
||||
|
||||
@observable
|
||||
String? snapshotsPath;
|
||||
|
||||
@observable
|
||||
File? selectedFile;
|
||||
|
||||
@observable
|
||||
ObservableList<Snapshot> snapshots = ObservableList<Snapshot>();
|
||||
|
||||
@observable
|
||||
ViewMode viewMode = ViewMode.fileExplorer;
|
||||
|
||||
@observable
|
||||
Snapshot? comparisonSnapshot;
|
||||
|
||||
@observable
|
||||
ObservableList<FileChange> fileChanges = ObservableList<FileChange>();
|
||||
|
||||
@observable
|
||||
FileChange? selectedFileChange;
|
||||
|
||||
static const int MAX_FILE_SIZE = 1 * 1024 * 1024;
|
||||
|
||||
static const int CHUNK_SIZE = 16 * 1024;
|
||||
|
||||
@action
|
||||
Future<void> _initialize() async {
|
||||
rootPath = (await getAppDir()).path;
|
||||
path = rootPath;
|
||||
snapshotsPath = p.join(rootPath!, 'snapshots');
|
||||
|
||||
final dir = Directory(snapshotsPath!);
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
|
||||
await loadSnapshots();
|
||||
}
|
||||
|
||||
@computed
|
||||
List<FileSystemEntity> get files {
|
||||
if (path == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final dir = Directory(path!);
|
||||
final entities = dir.listSync();
|
||||
return entities.where((entity) => entity is File).toList();
|
||||
}
|
||||
|
||||
@computed
|
||||
List<String> get directories {
|
||||
if (path == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final dir = Directory(path!);
|
||||
final entities = dir.listSync();
|
||||
return entities.where((entity) => entity is Directory).map((entity) => p.basename(entity.path)).toList();
|
||||
}
|
||||
|
||||
@action
|
||||
void cd(String newPath) {
|
||||
try {
|
||||
if (path == null) {
|
||||
return;
|
||||
}
|
||||
final Directory directory = Directory(p.join(path!, newPath));
|
||||
final String path2 = directory.absolute.path;
|
||||
|
||||
final String normalizedPath = p.normalize(path2);
|
||||
final String normalizedRootPath = p.normalize(rootPath!);
|
||||
|
||||
final bool isWithinRoot = normalizedPath.startsWith(normalizedRootPath);
|
||||
if (!isWithinRoot) {
|
||||
path = rootPath;
|
||||
} else {
|
||||
path = path2;
|
||||
}
|
||||
} catch (e) {
|
||||
path = rootPath;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
void selectFile(File file) {
|
||||
selectedFile = file;
|
||||
viewMode = ViewMode.hexdump;
|
||||
}
|
||||
|
||||
@action
|
||||
void switchToFileExplorer() {
|
||||
viewMode = ViewMode.fileExplorer;
|
||||
}
|
||||
|
||||
@action
|
||||
void switchToSnapshots() {
|
||||
viewMode = ViewMode.snapshots;
|
||||
}
|
||||
|
||||
@action
|
||||
void switchToComparison(Snapshot snapshot) {
|
||||
comparisonSnapshot = snapshot;
|
||||
viewMode = ViewMode.comparison;
|
||||
compareSnapshot();
|
||||
}
|
||||
|
||||
@action
|
||||
void switchToDetailedComparison(FileChange fileChange) {
|
||||
selectedFileChange = fileChange;
|
||||
viewMode = ViewMode.detailedComparison;
|
||||
}
|
||||
|
||||
@action
|
||||
void switchToFileMonitor() {
|
||||
viewMode = ViewMode.fileMonitor;
|
||||
}
|
||||
|
||||
Future<String> getHexDump(File file, {int? maxBytes}) async {
|
||||
try {
|
||||
final bytes = await file.readAsBytes();
|
||||
final bytesToRead = maxBytes != null && bytes.length > maxBytes ? maxBytes : bytes.length;
|
||||
final buffer = StringBuffer();
|
||||
|
||||
for (var i = 0; i < bytesToRead; i += 16) {
|
||||
// Address
|
||||
buffer.write('${i.toRadixString(16).padLeft(8, '0')}: ');
|
||||
|
||||
// Hex values
|
||||
for (var j = 0; j < 16; j++) {
|
||||
if (i + j < bytesToRead) {
|
||||
buffer.write('${bytes[i + j].toRadixString(16).padLeft(2, '0')} ');
|
||||
} else {
|
||||
buffer.write(' ');
|
||||
}
|
||||
if (j == 7) buffer.write(' ');
|
||||
}
|
||||
|
||||
// ASCII representation
|
||||
buffer.write(' |');
|
||||
for (var j = 0; j < 16; j++) {
|
||||
if (i + j < bytesToRead) {
|
||||
final char = bytes[i + j];
|
||||
// Show printable ASCII characters
|
||||
if (char >= 32 && char <= 126) {
|
||||
buffer.write(String.fromCharCode(char));
|
||||
} else {
|
||||
buffer.write('.');
|
||||
}
|
||||
} else {
|
||||
buffer.write(' ');
|
||||
}
|
||||
}
|
||||
buffer.write('|\n');
|
||||
}
|
||||
|
||||
if (maxBytes != null && bytes.length > maxBytes) {
|
||||
buffer.write('\n... (${bytes.length - maxBytes} more bytes) ...');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
} catch (e) {
|
||||
return 'Error reading file: $e';
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List> getFileBytes(String filePath) async {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
return await file.readAsBytes();
|
||||
} catch (e) {
|
||||
printV('Error reading file bytes: $e');
|
||||
return Uint8List(0);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> getHexDiffDump(Uint8List oldBytes, Uint8List newBytes) async {
|
||||
final buffer = StringBuffer();
|
||||
final maxLength = oldBytes.length > newBytes.length ? oldBytes.length : newBytes.length;
|
||||
final minLength = oldBytes.length < newBytes.length ? oldBytes.length : newBytes.length;
|
||||
|
||||
// Standard for hexdump displays
|
||||
final chunkSize = 16;
|
||||
|
||||
int totalBlocks = (maxLength / chunkSize).ceil();
|
||||
int diffBlocks = 0;
|
||||
int sameBlocks = 0;
|
||||
|
||||
buffer.writeln('=== Binary Diff Summary ===');
|
||||
buffer.writeln('Old size: ${_formatSize(oldBytes.length)}');
|
||||
buffer.writeln('New size: ${_formatSize(newBytes.length)}');
|
||||
buffer.writeln('Size difference: ${_formatSizeDifference(newBytes.length - oldBytes.length)}');
|
||||
buffer.writeln('');
|
||||
buffer.writeln('=== Changed Blocks Only ===');
|
||||
|
||||
for (var i = 0; i < maxLength; i += chunkSize) {
|
||||
bool hasBlockDiff = false;
|
||||
final blockEnd = i + chunkSize < maxLength ? i + chunkSize : maxLength;
|
||||
|
||||
for (var j = i; j < blockEnd && j < minLength; j++) {
|
||||
if (j < oldBytes.length && j < newBytes.length && oldBytes[j] != newBytes[j]) {
|
||||
hasBlockDiff = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (i >= oldBytes.length || i >= newBytes.length) {
|
||||
hasBlockDiff = true;
|
||||
}
|
||||
|
||||
if (hasBlockDiff) {
|
||||
diffBlocks++;
|
||||
|
||||
if (i < oldBytes.length) {
|
||||
buffer.write('- ');
|
||||
buffer.write('${i.toRadixString(16).padLeft(8, '0')}: ');
|
||||
|
||||
for (var j = 0; j < chunkSize; j++) {
|
||||
if (i + j < oldBytes.length) {
|
||||
final byteValue = oldBytes[i + j].toRadixString(16).padLeft(2, '0');
|
||||
if (i + j < newBytes.length && oldBytes[i + j] != newBytes[i + j]) {
|
||||
buffer.write('[$byteValue] ');
|
||||
} else {
|
||||
buffer.write(' $byteValue ');
|
||||
}
|
||||
} else {
|
||||
buffer.write(' ');
|
||||
}
|
||||
if (j == 7) buffer.write(' ');
|
||||
}
|
||||
|
||||
buffer.write(' |');
|
||||
for (var j = 0; j < chunkSize; j++) {
|
||||
if (i + j < oldBytes.length) {
|
||||
final char = oldBytes[i + j];
|
||||
if (char >= 32 && char <= 126) {
|
||||
if (i + j < newBytes.length && oldBytes[i + j] != newBytes[i + j]) {
|
||||
buffer.write('[${String.fromCharCode(char)}]');
|
||||
} else {
|
||||
buffer.write(' ${String.fromCharCode(char)} ');
|
||||
}
|
||||
} else {
|
||||
buffer.write(' . ');
|
||||
}
|
||||
} else {
|
||||
buffer.write(' ');
|
||||
}
|
||||
}
|
||||
buffer.write('|\n');
|
||||
}
|
||||
|
||||
if (i < newBytes.length) {
|
||||
buffer.write('+ ');
|
||||
buffer.write('${i.toRadixString(16).padLeft(8, '0')}: ');
|
||||
|
||||
for (var j = 0; j < chunkSize; j++) {
|
||||
if (i + j < newBytes.length) {
|
||||
final byteValue = newBytes[i + j].toRadixString(16).padLeft(2, '0');
|
||||
if (i + j < oldBytes.length && oldBytes[i + j] != newBytes[i + j]) {
|
||||
buffer.write('[$byteValue] ');
|
||||
} else if (i + j >= oldBytes.length) {
|
||||
buffer.write('*$byteValue* ');
|
||||
} else {
|
||||
buffer.write(' $byteValue ');
|
||||
}
|
||||
} else {
|
||||
buffer.write(' ');
|
||||
}
|
||||
if (j == 7) buffer.write(' ');
|
||||
}
|
||||
|
||||
buffer.write(' |');
|
||||
for (var j = 0; j < chunkSize; j++) {
|
||||
if (i + j < newBytes.length) {
|
||||
final char = newBytes[i + j];
|
||||
if (char >= 32 && char <= 126) {
|
||||
if (i + j < oldBytes.length && oldBytes[i + j] != newBytes[i + j]) {
|
||||
buffer.write('[${String.fromCharCode(char)}]');
|
||||
} else if (i + j >= oldBytes.length) {
|
||||
buffer.write('*${String.fromCharCode(char)}*');
|
||||
} else {
|
||||
buffer.write(' ${String.fromCharCode(char)} ');
|
||||
}
|
||||
} else {
|
||||
buffer.write(' . ');
|
||||
}
|
||||
} else {
|
||||
buffer.write(' ');
|
||||
}
|
||||
}
|
||||
buffer.write('|\n');
|
||||
}
|
||||
|
||||
buffer.write('\n');
|
||||
} else {
|
||||
sameBlocks++;
|
||||
}
|
||||
}
|
||||
|
||||
buffer.writeln('=== Diff Summary ===');
|
||||
buffer.writeln('Total blocks: $totalBlocks');
|
||||
buffer.writeln('Different blocks: $diffBlocks');
|
||||
buffer.writeln('Identical blocks: $sameBlocks');
|
||||
buffer.writeln('Difference percentage: ${(diffBlocks / totalBlocks * 100).toStringAsFixed(2)}%');
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Future<String> compareFilesStreamBased(File currentFile, FileInfo oldInfo) async {
|
||||
final buffer = StringBuffer();
|
||||
final filePath = currentFile.path;
|
||||
final fileSize = await currentFile.length();
|
||||
|
||||
if (fileSize > MAX_FILE_SIZE) {
|
||||
return 'File too large to diff (${_formatSize(fileSize)}). Maximum size: ${_formatSize(MAX_FILE_SIZE)}.';
|
||||
}
|
||||
|
||||
buffer.writeln('=== Binary Diff of ${p.basename(filePath)} ===');
|
||||
buffer.writeln('Path: ${p.relative(filePath, from: rootPath!)}');
|
||||
buffer.writeln('Old size: ${_formatSize(oldInfo.size)}');
|
||||
buffer.writeln('New size: ${_formatSize(fileSize)}');
|
||||
buffer.writeln('Size difference: ${_formatSizeDifference(fileSize - oldInfo.size)}');
|
||||
buffer.writeln('');
|
||||
|
||||
if (oldInfo.content == null) {
|
||||
buffer.writeln('No content information available for old file. Cannot generate diff.');
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
final oldBytes = oldInfo.content!;
|
||||
|
||||
final file = File(filePath);
|
||||
final fileStream = file.openRead(0, oldBytes.length < fileSize ? oldBytes.length : fileSize);
|
||||
|
||||
List<int> newBytes = [];
|
||||
await for (var chunk in fileStream) {
|
||||
newBytes.addAll(chunk);
|
||||
}
|
||||
|
||||
if (newBytes.isNotEmpty) {
|
||||
final newData = Uint8List.fromList(newBytes);
|
||||
|
||||
buffer.writeln('=== Detailed Binary Diff (${_formatSize(newData.length < oldBytes.length ? newData.length : oldBytes.length)}) ===');
|
||||
buffer.write(await getHexDiffDump(oldBytes, newData));
|
||||
} else {
|
||||
buffer.writeln('Failed to read file for comparison.');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Future<Map<String, FileInfo>> _scanDirectory(String directoryPath) async {
|
||||
final result = <String, FileInfo>{};
|
||||
final dir = Directory(directoryPath);
|
||||
final entities = await dir.list(recursive: true).toList();
|
||||
|
||||
final normalizedSnapshotsPath = p.normalize(snapshotsPath!);
|
||||
final normalizedFlutterAssetsPath = p.normalize(p.join(rootPath!, 'flutter_assets'));
|
||||
|
||||
for (final entity in entities) {
|
||||
if (entity is File) {
|
||||
final normalizedPath = p.normalize(entity.path);
|
||||
if (normalizedPath.startsWith(normalizedSnapshotsPath) ||
|
||||
normalizedPath.startsWith(normalizedFlutterAssetsPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
final relativePath = p.relative(entity.path, from: rootPath!);
|
||||
final stat = await entity.stat();
|
||||
|
||||
if (stat.size > MAX_FILE_SIZE) {
|
||||
printV('Skipping large file: $relativePath (${_formatSize(stat.size)})');
|
||||
continue;
|
||||
}
|
||||
|
||||
Uint8List? fileContent;
|
||||
try {
|
||||
fileContent = await entity.readAsBytes();
|
||||
} catch (e) {
|
||||
printV('Error reading file content: $e');
|
||||
}
|
||||
|
||||
result[relativePath] = FileInfo(
|
||||
path: relativePath,
|
||||
size: stat.size,
|
||||
modified: stat.modified,
|
||||
content: fileContent,
|
||||
);
|
||||
} catch (e) {
|
||||
printV('Error processing file ${entity.path}: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> loadSnapshots() async {
|
||||
try {
|
||||
final dir = Directory(snapshotsPath!);
|
||||
if (!await dir.exists()) return;
|
||||
|
||||
final snapshotFiles = dir.listSync().whereType<File>().where(
|
||||
(file) => file.path.endsWith('.snapshot')
|
||||
).toList();
|
||||
|
||||
snapshots.clear();
|
||||
List<String> corruptedFiles = [];
|
||||
|
||||
for (final file in snapshotFiles) {
|
||||
try {
|
||||
final content = await file.readAsString();
|
||||
|
||||
Map<String, dynamic>? jsonData;
|
||||
try {
|
||||
jsonData = jsonDecode(content) as Map<String, dynamic>;
|
||||
} catch (jsonError) {
|
||||
corruptedFiles.add(file.path);
|
||||
printV('Error parsing snapshot ${file.path}: $jsonError');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!jsonData.containsKey('files') ||
|
||||
!jsonData.containsKey('name') ||
|
||||
!jsonData.containsKey('timestamp')) {
|
||||
corruptedFiles.add(file.path);
|
||||
printV('Invalid snapshot format in ${file.path}');
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
final fileInfos = Map<String, FileInfo>.from(
|
||||
(jsonData['files'] as Map).map((key, value) => MapEntry(
|
||||
key as String,
|
||||
FileInfo(
|
||||
path: value['path'] as String,
|
||||
size: value['size'] as int,
|
||||
modified: DateTime.parse(value['modified'] as String),
|
||||
content: value['content'] != null ? base64Decode(value['content'] as String) : null,
|
||||
),
|
||||
)),
|
||||
);
|
||||
|
||||
snapshots.add(Snapshot(
|
||||
name: jsonData['name'] as String,
|
||||
timestamp: DateTime.parse(jsonData['timestamp'] as String),
|
||||
files: fileInfos,
|
||||
));
|
||||
} catch (e) {
|
||||
corruptedFiles.add(file.path);
|
||||
printV('Error processing snapshot data ${file.path}: $e');
|
||||
}
|
||||
} catch (e) {
|
||||
corruptedFiles.add(file.path);
|
||||
printV('Error loading snapshot ${file.path}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (corruptedFiles.isNotEmpty) {
|
||||
await _handleCorruptedSnapshots(corruptedFiles);
|
||||
}
|
||||
|
||||
snapshots.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
} catch (e) {
|
||||
printV('Error loading snapshots: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleCorruptedSnapshots(List<String> corruptedFilePaths) async {
|
||||
printV('Found ${corruptedFilePaths.length} corrupted snapshot files:');
|
||||
for (final path in corruptedFilePaths) {
|
||||
printV(' - ${p.basename(path)}');
|
||||
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
final newPath = '$path.corrupted';
|
||||
await file.rename(newPath);
|
||||
printV(' Renamed to ${p.basename(newPath)}');
|
||||
}
|
||||
} catch (e) {
|
||||
printV(' Failed to rename corrupted file: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> createSnapshot(String name) async {
|
||||
try {
|
||||
if (rootPath == null) return;
|
||||
|
||||
final timestamp = DateTime.now();
|
||||
final files = await _scanDirectory(rootPath!);
|
||||
|
||||
final snapshot = Snapshot(
|
||||
name: name,
|
||||
timestamp: timestamp,
|
||||
files: files,
|
||||
);
|
||||
|
||||
final jsonData = {
|
||||
'name': snapshot.name,
|
||||
'timestamp': snapshot.timestamp.toIso8601String(),
|
||||
'files': snapshot.files.map((path, fileInfo) => MapEntry(
|
||||
path,
|
||||
{
|
||||
'path': fileInfo.path,
|
||||
'size': fileInfo.size,
|
||||
'modified': fileInfo.modified.toIso8601String(),
|
||||
'content': fileInfo.content != null ? base64Encode(fileInfo.content!) : null,
|
||||
},
|
||||
)),
|
||||
};
|
||||
|
||||
final snapshotFilePath = p.join(
|
||||
snapshotsPath!,
|
||||
'${name.replaceAll(RegExp(r'[^\w\s-]'), '_')}_${timestamp.millisecondsSinceEpoch}.snapshot',
|
||||
);
|
||||
|
||||
final file = File(snapshotFilePath);
|
||||
await file.writeAsString(jsonEncode(jsonData));
|
||||
|
||||
await loadSnapshots();
|
||||
} catch (e) {
|
||||
printV('Error creating snapshot: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> compareSnapshot() async {
|
||||
if (comparisonSnapshot == null || rootPath == null) return;
|
||||
|
||||
try {
|
||||
fileChanges.clear();
|
||||
|
||||
final currentFiles = await _scanDirectory(rootPath!);
|
||||
|
||||
final snapshot = comparisonSnapshot!;
|
||||
|
||||
for (final entry in currentFiles.entries) {
|
||||
final path = entry.key;
|
||||
final current = entry.value;
|
||||
|
||||
if (!snapshot.files.containsKey(path)) {
|
||||
fileChanges.add(FileChange(
|
||||
path: path,
|
||||
type: ChangeType.added,
|
||||
newFile: current,
|
||||
));
|
||||
} else {
|
||||
final old = snapshot.files[path]!;
|
||||
|
||||
if (old.modified != current.modified) {
|
||||
fileChanges.add(FileChange(
|
||||
path: path,
|
||||
type: ChangeType.touched,
|
||||
oldFile: old,
|
||||
newFile: current,
|
||||
));
|
||||
} else if (old.size != current.size ||
|
||||
!_areContentsEqual(old.content, current.content)) {
|
||||
fileChanges.add(FileChange(
|
||||
path: path,
|
||||
type: ChangeType.modified,
|
||||
oldFile: old,
|
||||
newFile: current,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final path in snapshot.files.keys) {
|
||||
if (!currentFiles.containsKey(path)) {
|
||||
fileChanges.add(FileChange(
|
||||
path: path,
|
||||
type: ChangeType.removed,
|
||||
oldFile: snapshot.files[path],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fileChanges.sort((a, b) => a.path.compareTo(b.path));
|
||||
} catch (e) {
|
||||
printV('Error comparing snapshot: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> deleteSnapshot(Snapshot snapshot) async {
|
||||
try {
|
||||
final snapshotFile = File(p.join(
|
||||
snapshotsPath!,
|
||||
'${snapshot.name.replaceAll(RegExp(r'[^\w\s-]'), '_')}_${snapshot.timestamp.millisecondsSinceEpoch}.snapshot',
|
||||
));
|
||||
|
||||
if (await snapshotFile.exists()) {
|
||||
await snapshotFile.delete();
|
||||
}
|
||||
|
||||
await loadSnapshots();
|
||||
} catch (e) {
|
||||
printV('Error deleting snapshot: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> getFileDiff(FileChange fileChange) async {
|
||||
if (fileChange.type == ChangeType.added) {
|
||||
final filePath = p.join(rootPath!, fileChange.path);
|
||||
final file = File(filePath);
|
||||
final fileSize = await file.length();
|
||||
|
||||
if (fileSize > MAX_FILE_SIZE) {
|
||||
return 'File too large to display (${_formatSize(fileSize)}). Maximum size: ${_formatSize(MAX_FILE_SIZE)}.';
|
||||
}
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('=== File Added ===');
|
||||
buffer.writeln('Path: ${fileChange.path}');
|
||||
buffer.writeln('Size: ${_formatSize(fileChange.newFile!.size)}');
|
||||
buffer.writeln('Modified: ${fileChange.newFile!.modified}');
|
||||
buffer.writeln('');
|
||||
|
||||
if (fileChange.newFile!.content != null) {
|
||||
buffer.writeln('=== Hex Dump (Added File) ===');
|
||||
final bytes = fileChange.newFile!.content!;
|
||||
final hexDump = _formatHexDump(bytes, false);
|
||||
|
||||
final hexDumpLines = hexDump.split('\n');
|
||||
for (final line in hexDumpLines) {
|
||||
if (line.isNotEmpty) {
|
||||
buffer.writeln('+ $line');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
} else if (fileChange.type == ChangeType.removed) {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('=== File Removed ===');
|
||||
buffer.writeln('Path: ${fileChange.path}');
|
||||
buffer.writeln('Size: ${_formatSize(fileChange.oldFile!.size)}');
|
||||
buffer.writeln('Modified: ${fileChange.oldFile!.modified}');
|
||||
buffer.writeln('');
|
||||
|
||||
if (fileChange.oldFile!.content != null) {
|
||||
buffer.writeln('=== Hex Dump (Removed File) ===');
|
||||
final bytes = fileChange.oldFile!.content!;
|
||||
final hexDump = _formatHexDump(bytes, false);
|
||||
|
||||
final hexDumpLines = hexDump.split('\n');
|
||||
for (final line in hexDumpLines) {
|
||||
if (line.isNotEmpty) {
|
||||
buffer.writeln('- $line');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
} else if (fileChange.type == ChangeType.modified) {
|
||||
final filePath = p.join(rootPath!, fileChange.path);
|
||||
final currentFile = File(filePath);
|
||||
final fileSize = await currentFile.length();
|
||||
|
||||
if (fileSize > MAX_FILE_SIZE) {
|
||||
return 'File too large to diff (${_formatSize(fileSize)}). Maximum size: ${_formatSize(MAX_FILE_SIZE)}.';
|
||||
}
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('=== File Modified ===');
|
||||
buffer.writeln('Path: ${fileChange.path}');
|
||||
buffer.writeln('');
|
||||
|
||||
buffer.writeln('=== Size ===');
|
||||
buffer.writeln('Old: ${_formatSize(fileChange.oldFile!.size)}');
|
||||
buffer.writeln('New: ${_formatSize(fileChange.newFile!.size)}');
|
||||
buffer.writeln('Difference: ${_formatSizeDifference(fileChange.newFile!.size - fileChange.oldFile!.size)}');
|
||||
buffer.writeln('');
|
||||
|
||||
buffer.writeln('=== Timestamps ===');
|
||||
buffer.writeln('Old modified: ${fileChange.oldFile!.modified}');
|
||||
buffer.writeln('New modified: ${fileChange.newFile!.modified}');
|
||||
buffer.writeln('');
|
||||
|
||||
if (fileChange.oldFile!.content != null && fileChange.newFile!.content != null) {
|
||||
buffer.writeln('=== Binary Diff ===');
|
||||
final diff = await getHexDiffDump(fileChange.oldFile!.content!, fileChange.newFile!.content!);
|
||||
buffer.write(diff);
|
||||
} else {
|
||||
buffer.writeln('Cannot create binary diff: missing content data.');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
return 'No detailed information available.';
|
||||
}
|
||||
|
||||
String _formatHexDump(Uint8List bytes, bool truncated) {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
for (var i = 0; i < bytes.length; i += 16) {
|
||||
// Address
|
||||
buffer.write('${i.toRadixString(16).padLeft(8, '0')}: ');
|
||||
|
||||
// Hex values
|
||||
for (var j = 0; j < 16; j++) {
|
||||
if (i + j < bytes.length) {
|
||||
buffer.write('${bytes[i + j].toRadixString(16).padLeft(2, '0')} ');
|
||||
} else {
|
||||
buffer.write(' ');
|
||||
}
|
||||
if (j == 7) buffer.write(' ');
|
||||
}
|
||||
|
||||
// ASCII representation
|
||||
buffer.write(' |');
|
||||
for (var j = 0; j < 16; j++) {
|
||||
if (i + j < bytes.length) {
|
||||
final char = bytes[i + j];
|
||||
if (char >= 32 && char <= 126) {
|
||||
buffer.write(String.fromCharCode(char));
|
||||
} else {
|
||||
buffer.write('.');
|
||||
}
|
||||
} else {
|
||||
buffer.write(' ');
|
||||
}
|
||||
}
|
||||
buffer.write('|\n');
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
buffer.write('\n... (file continues) ...');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Future<String> getTextDiff(String oldText, String newText) async {
|
||||
final oldLines = oldText.split('\n');
|
||||
final newLines = newText.split('\n');
|
||||
|
||||
final buffer = StringBuffer();
|
||||
|
||||
// Very simple diff algorithm - can be improved with a proper diff algorithm
|
||||
final maxLines = oldLines.length > newLines.length ? oldLines.length : newLines.length;
|
||||
|
||||
for (var i = 0; i < maxLines; i++) {
|
||||
if (i < oldLines.length && i < newLines.length) {
|
||||
if (oldLines[i] == newLines[i]) {
|
||||
buffer.writeln(' ${newLines[i]}');
|
||||
} else {
|
||||
buffer.writeln('- ${oldLines[i]}');
|
||||
buffer.writeln('+ ${newLines[i]}');
|
||||
}
|
||||
} else if (i < oldLines.length) {
|
||||
buffer.writeln('- ${oldLines[i]}');
|
||||
} else if (i < newLines.length) {
|
||||
buffer.writeln('+ ${newLines[i]}');
|
||||
}
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _formatSize(int sizeInBytes) {
|
||||
if (sizeInBytes < 1024) {
|
||||
return '$sizeInBytes B';
|
||||
} else if (sizeInBytes < 1024 * 1024) {
|
||||
return '${(sizeInBytes / 1024).toStringAsFixed(2)} KB';
|
||||
} else if (sizeInBytes < 1024 * 1024 * 1024) {
|
||||
return '${(sizeInBytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
||||
} else if (sizeInBytes < 1024 * 1024 * 1024 * 1024) {
|
||||
return '${(sizeInBytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||
} else {
|
||||
return '${(sizeInBytes / (1024 * 1024 * 1024 * 1024)).toStringAsFixed(2)} TB... why?';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatSizeDifference(int diffInBytes) {
|
||||
final prefix = diffInBytes >= 0 ? '+' : '';
|
||||
return '$prefix${_formatSize(diffInBytes.abs())}';
|
||||
}
|
||||
|
||||
bool _areContentsEqual(Uint8List? a, Uint8List? b) {
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
if (a.length != b.length) return false;
|
||||
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@action
|
||||
Future<bool> renameFile(String oldPath, String newPath) async {
|
||||
try {
|
||||
final oldFile = File(oldPath);
|
||||
final newFile = File(newPath);
|
||||
printV('Renaming file: $oldPath to $newPath');
|
||||
if (await newFile.exists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await oldFile.rename(newFile.path);
|
||||
printV('Renamed file: $oldPath to $newPath');
|
||||
return true;
|
||||
} catch (e) {
|
||||
printV('Error renaming file: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
Future<bool> deleteFile(String path) async {
|
||||
try {
|
||||
final file = File(path);
|
||||
await file.delete();
|
||||
return true;
|
||||
} catch (e) {
|
||||
printV('Error deleting file: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
Future<bool> copyFile(String sourcePath, String destinationPath) async {
|
||||
try {
|
||||
final sourceFile = File(sourcePath);
|
||||
final destinationFile = File(destinationPath);
|
||||
|
||||
if (await destinationFile.exists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await sourceFile.copy(destinationFile.path);
|
||||
return true;
|
||||
} catch (e) {
|
||||
printV('Error copying file: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> startMonitoring() async {
|
||||
if (_watcher != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
isMonitoringActive = true;
|
||||
final appDir = await getAppDir();
|
||||
if (Platform.isAndroid) {
|
||||
_watcher = DirectoryWatcher(appDir.parent.path); // get rid of weird app_flutter directory
|
||||
} else {
|
||||
_watcher = DirectoryWatcher(appDir.path);
|
||||
}
|
||||
|
||||
_watcher!.events.listen((event) {
|
||||
if (event.path.contains('flutter_engine')) {
|
||||
return;
|
||||
}
|
||||
final relativePath = event.path.replaceFirst(appDir.path, '~');
|
||||
fileEvents.add(FileEvent(
|
||||
timestamp: DateTime.now(),
|
||||
event: event,
|
||||
relativePath: relativePath,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
static void stopMonitoring() {
|
||||
if (_watcher != null) {
|
||||
_watcher!.events.drain();
|
||||
_watcher = null;
|
||||
isMonitoringActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
void clearEvents() {
|
||||
fileEvents.clear();
|
||||
}
|
||||
|
||||
static Future<bool> checkDevMonitorFileExists() async {
|
||||
final appDir = await getAppDir();
|
||||
final devMonitorFile = File('${appDir.path}/.dev-monitor-fs');
|
||||
return devMonitorFile.exists();
|
||||
}
|
||||
}
|
660
lib/view_model/dev/wallet_fuzzer.dart
Normal file
660
lib/view_model/dev/wallet_fuzzer.dart
Normal file
|
@ -0,0 +1,660 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:cake_wallet/core/key_service.dart';
|
||||
import 'package:cake_wallet/core/wallet_loading_service.dart';
|
||||
import 'package:cake_wallet/di.dart';
|
||||
import 'package:cake_wallet/store/settings_store.dart';
|
||||
import 'package:cake_wallet/view_model/wallet_list/wallet_list_item.dart';
|
||||
import 'package:cake_wallet/view_model/wallet_list/wallet_list_view_model.dart';
|
||||
import 'package:cw_core/sync_status.dart';
|
||||
import 'package:cw_core/utils/print_verbose.dart';
|
||||
import 'package:cw_core/wallet_base.dart';
|
||||
import 'package:cw_core/root_dir.dart';
|
||||
import 'package:cw_core/wallet_info.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:cake_wallet/core/wallet_creation_service.dart';
|
||||
import 'package:cw_core/wallet_type.dart';
|
||||
import 'package:cake_wallet/monero/monero.dart';
|
||||
import 'package:cake_wallet/bitcoin/bitcoin.dart';
|
||||
import 'package:cake_wallet/bitcoin_cash/bitcoin_cash.dart';
|
||||
import 'package:cake_wallet/ethereum/ethereum.dart';
|
||||
import 'package:cake_wallet/nano/nano.dart';
|
||||
import 'package:cake_wallet/polygon/polygon.dart';
|
||||
import 'package:cake_wallet/solana/solana.dart';
|
||||
import 'package:cake_wallet/tron/tron.dart';
|
||||
import 'package:cake_wallet/zano/zano.dart';
|
||||
import 'package:cake_wallet/decred/decred.dart';
|
||||
import 'package:cw_core/wallet_credentials.dart';
|
||||
import 'package:cw_core/pathForWallet.dart';
|
||||
import 'package:cake_wallet/entities/seed_type.dart';
|
||||
import 'package:cake_wallet/core/generate_wallet_password.dart';
|
||||
|
||||
part 'wallet_fuzzer.g.dart';
|
||||
|
||||
class WalletFuzzerViewModel = WalletFuzzerViewModelBase with _$WalletFuzzerViewModel;
|
||||
|
||||
class FuzzyLogEntry {
|
||||
final DateTime timestamp;
|
||||
final String action;
|
||||
final String? result;
|
||||
|
||||
FuzzyLogEntry({required this.timestamp, required this.action, this.result});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final formattedTime = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format(timestamp);
|
||||
if (result != null) {
|
||||
return '[$formattedTime] $action - $result';
|
||||
}
|
||||
return '[$formattedTime] $action';
|
||||
}
|
||||
}
|
||||
|
||||
abstract class WalletFuzzerViewModelBase with Store {
|
||||
WalletFuzzerViewModelBase() {
|
||||
unawaited(_initialize());
|
||||
}
|
||||
|
||||
static final DateTime appStartDate = DateTime.now();
|
||||
|
||||
List<FuzzyLogEntry> logs = [];
|
||||
|
||||
@observable
|
||||
bool isRunning = false;
|
||||
|
||||
@observable
|
||||
String currentOperation = 'Idle';
|
||||
|
||||
@observable
|
||||
String currentWallet = '';
|
||||
|
||||
@observable
|
||||
int operationsCompleted = 0;
|
||||
|
||||
@observable
|
||||
int errorsEncountered = 0;
|
||||
|
||||
@observable
|
||||
Map<String, int> operationStats = {
|
||||
'loadWallet': 0,
|
||||
'syncWallet': 0,
|
||||
'closeWallet': 0,
|
||||
'switchWallet': 0,
|
||||
'restartWallet': 0,
|
||||
'checkAndCreateWallets': 0,
|
||||
'createWalletsForType': 0,
|
||||
'performRandomOperation': 0,
|
||||
};
|
||||
|
||||
@computed
|
||||
String get formattedOperationStats {
|
||||
final buffer = StringBuffer();
|
||||
final sortedEntries = operationStats.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
for (final entry in sortedEntries) {
|
||||
final opName = entry.key;
|
||||
final opCount = entry.value;
|
||||
|
||||
String formattedName = switch (opName) {
|
||||
'loadWallet' => 'Load Wallet',
|
||||
'syncWallet' => 'Sync Wallet',
|
||||
'closeWallet' => 'Close Wallet',
|
||||
'switchWallet' => 'Switch Wallet',
|
||||
'restartWallet' => 'Restart Wallet',
|
||||
'checkAndCreateWallets' => 'Create Wallets',
|
||||
'performRandomOperation' => 'Perform Random Operation',
|
||||
_ => opName,
|
||||
};
|
||||
|
||||
buffer.writeln('$formattedName: $opCount');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
@observable
|
||||
String? logFilePath;
|
||||
|
||||
@observable
|
||||
Directory? logDir;
|
||||
|
||||
|
||||
WalletLoadingService walletLoadingService = getIt.get<WalletLoadingService>();
|
||||
WalletListViewModel walletListViewModel = getIt.get<WalletListViewModel>();
|
||||
SettingsStore settingsStore = getIt.get<SettingsStore>();
|
||||
KeyService keyService = getIt.get<KeyService>();
|
||||
|
||||
final _random = Random();
|
||||
Timer? _fuzzerTimer;
|
||||
|
||||
static const String _fuzzyFileName = '.wallet-fuzzer';
|
||||
static const String _lastFuzzFile = 'last_fuzz';
|
||||
static const String _statsFile = 'fuzzer_stats';
|
||||
|
||||
final allTypes = [
|
||||
WalletType.monero,
|
||||
WalletType.bitcoin,
|
||||
WalletType.litecoin,
|
||||
WalletType.ethereum,
|
||||
WalletType.bitcoinCash,
|
||||
WalletType.polygon,
|
||||
WalletType.solana,
|
||||
WalletType.tron,
|
||||
WalletType.zano,
|
||||
];
|
||||
|
||||
Future<void> _initialize() async {
|
||||
|
||||
final appDir = await getAppDir();
|
||||
logDir = Directory(p.join(appDir.path, 'fuzzy'));
|
||||
|
||||
if (!await logDir!.exists()) {
|
||||
await logDir!.create(recursive: true);
|
||||
}
|
||||
|
||||
final formattedDate = DateFormat('yyyyMMdd_HHmmss').format(appStartDate);
|
||||
logFilePath = p.join(logDir!.path, 'log-$formattedDate.txt');
|
||||
|
||||
await _logToFile('Wallet Fuzzer initialized');
|
||||
|
||||
await _loadOperationStats();
|
||||
}
|
||||
|
||||
Future<void> _loadOperationStats() async {
|
||||
try {
|
||||
final appDir = await getAppDir();
|
||||
final statsFilePath = p.join(appDir.path, _statsFile);
|
||||
final statsFile = File(statsFilePath);
|
||||
|
||||
if (await statsFile.exists()) {
|
||||
final statsJson = await statsFile.readAsString();
|
||||
final stats = jsonDecode(statsJson) as Map<String, dynamic>;
|
||||
|
||||
operationStats = stats.map((key, value) => MapEntry(key, value as int));
|
||||
|
||||
await _logAction('Loaded operation statistics',
|
||||
result: operationStats.entries.map((e) => '${e.key}: ${e.value}').join(', '));
|
||||
} else {
|
||||
await _logAction('No previous operation statistics found');
|
||||
}
|
||||
} catch (e) {
|
||||
await _logAction('Failed to load operation statistics', result: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveOperationStats() async {
|
||||
try {
|
||||
final appDir = await getAppDir();
|
||||
final statsFilePath = p.join(appDir.path, _statsFile);
|
||||
final statsFile = File(statsFilePath);
|
||||
|
||||
final statsJson = jsonEncode(operationStats);
|
||||
await statsFile.writeAsString(statsJson);
|
||||
} catch (e) {
|
||||
await _logAction('Failed to save operation statistics', result: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _incrementOperationStat(String operation) async {
|
||||
if (operationStats.containsKey(operation)) {
|
||||
operationStats[operation] = operationStats[operation]! + 1;
|
||||
} else {
|
||||
operationStats[operation] = 1;
|
||||
}
|
||||
await _saveOperationStats();
|
||||
}
|
||||
|
||||
Future<void> startFuzzing() async {
|
||||
if (isRunning) return;
|
||||
|
||||
isRunning = true;
|
||||
await _logAction('Starting wallet fuzzer');
|
||||
|
||||
await _clearLastFuzzFile();
|
||||
|
||||
await _ensureEnoughWallets();
|
||||
|
||||
_fuzzerTimer = Timer.periodic(Duration(seconds: 1), (_) => _performRandomOperation());
|
||||
}
|
||||
|
||||
Future<void> _createFuzzyFile() async {
|
||||
final appDir = await getAppDir();
|
||||
final fuzzyFile = File(p.join(appDir.path, _fuzzyFileName));
|
||||
if (!await fuzzyFile.exists()) {
|
||||
await fuzzyFile.create();
|
||||
await _logAction('Created fuzzy file marker for auto-restart');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeFuzzyFile() async {
|
||||
final appDir = await getAppDir();
|
||||
final fuzzyFile = File(p.join(appDir.path, _fuzzyFileName));
|
||||
if (await fuzzyFile.exists()) {
|
||||
await fuzzyFile.delete();
|
||||
await _logAction('Removed fuzzy file marker');
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> stopFuzzing() async {
|
||||
if (!isRunning) return;
|
||||
|
||||
_fuzzerTimer?.cancel();
|
||||
_fuzzerTimer = null;
|
||||
isRunning = false;
|
||||
|
||||
await _logAction('Stopping wallet fuzzer');
|
||||
}
|
||||
|
||||
Future<void> _ensureEnoughWallets() async {
|
||||
await _logAction('Checking if we need to create wallets');
|
||||
|
||||
final wallets = walletListViewModel.wallets;
|
||||
final walletsByType = <WalletType, List<WalletListItem>>{};
|
||||
|
||||
for (final wallet in wallets.where((w) => !w.isHardware)) {
|
||||
if (!walletsByType.containsKey(wallet.type)) {
|
||||
walletsByType[wallet.type] = [];
|
||||
}
|
||||
walletsByType[wallet.type]!.add(wallet);
|
||||
}
|
||||
|
||||
const MIN_WALLETS = 8;
|
||||
for (final type in allTypes) {
|
||||
final count = walletsByType[type]?.length ?? 0;
|
||||
if (count < MIN_WALLETS) {
|
||||
await _logAction('Not enough wallets of type ${type.toString()}. Have: $count, Need: $MIN_WALLETS');
|
||||
await _createWalletsForType(type, MIN_WALLETS - count);
|
||||
} else {
|
||||
await _logAction('Enough wallets of type ${type.toString()}. Have: $count');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createWalletsForType(WalletType type, int count) async {
|
||||
await _incrementOperationStat('createWalletsForType');
|
||||
await _logAction('Creating $count wallets for type ${type.toString()}');
|
||||
final walletCreationService = getIt.get<WalletCreationService>(param1: type);
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
await _logAction('Creating wallet ${i+1} of $count');
|
||||
final index = i;
|
||||
await _createSingleWallet(type, index, count, walletCreationService);
|
||||
await Future.delayed(Duration(milliseconds: 500));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createSingleWallet(
|
||||
WalletType type, int index, int totalCount, WalletCreationService service) async {
|
||||
try {
|
||||
currentOperation = 'Creating wallet of type ${type.toString()} (${index+1}/$totalCount)';
|
||||
|
||||
final timestamp = DateFormat('yyyyMMddHHmmss').format(DateTime.now());
|
||||
final randomSuffix = _random.nextInt(9999).toString().padLeft(4, '0');
|
||||
final walletName = 'fuzzy_${type.toString().split('.').last}_${timestamp}_$randomSuffix';
|
||||
|
||||
await _logAction('Creating wallet credentials', result: walletName);
|
||||
|
||||
final dirPath = await pathForWalletDir(name: walletName, type: type);
|
||||
final path = await pathForWallet(name: walletName, type: type);
|
||||
final credentials = await _prepareCredentialsForType(type, walletName);
|
||||
credentials.walletInfo = WalletInfo.external(
|
||||
id: WalletBase.idFor(walletName, type),
|
||||
name: walletName,
|
||||
type: type,
|
||||
isRecovery: false,
|
||||
restoreHeight: credentials.height ?? 0,
|
||||
date: DateTime.now(),
|
||||
path: path,
|
||||
dirPath: dirPath,
|
||||
address: '',
|
||||
showIntroCakePayCard: false,
|
||||
derivationInfo: credentials.derivationInfo,
|
||||
hardwareWalletType: credentials.hardwareWalletType,
|
||||
);
|
||||
_logAction('Creating wallet', result: walletName);
|
||||
final wallet = await service.create(credentials, isTestnet: false);
|
||||
_logAction('Wallet created', result: walletName);
|
||||
await _logWalletState(wallet);
|
||||
final node = settingsStore.getCurrentNode(wallet.type);
|
||||
_logAction('Connecting to node', result: walletName);
|
||||
await wallet.connectToNode(node: node);
|
||||
_logAction('Starting sync', result: walletName);
|
||||
await wallet.startSync();
|
||||
await Future.delayed(Duration(seconds: 5 + _random.nextInt(25)));
|
||||
_logAction('Stopping sync', result: walletName);
|
||||
await wallet.stopSync();
|
||||
_logAction('Closing wallet', result: walletName);
|
||||
await wallet.close(shouldCleanup: true);
|
||||
_logAction('Wallet closed', result: walletName);
|
||||
} catch (e) {
|
||||
errorsEncountered++;
|
||||
await _logAction('Failed to create wallet of type ${type.toString()}', result: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<WalletCredentials> _prepareCredentialsForType(WalletType type, String name) async {
|
||||
final password = generateWalletPassword();
|
||||
|
||||
switch (type) {
|
||||
case WalletType.monero:
|
||||
return monero!.createMoneroNewWalletCredentials(
|
||||
name: name,
|
||||
language: 'English',
|
||||
seedType: MoneroSeedType.legacy.raw,
|
||||
passphrase: '',
|
||||
password: password,
|
||||
);
|
||||
case WalletType.bitcoin:
|
||||
return bitcoin!.createBitcoinNewWalletCredentials(
|
||||
name: name,
|
||||
password: password,
|
||||
passphrase: '',
|
||||
);
|
||||
case WalletType.litecoin:
|
||||
return bitcoin!.createBitcoinNewWalletCredentials(
|
||||
name: name,
|
||||
password: password,
|
||||
passphrase: '',
|
||||
);
|
||||
case WalletType.bitcoinCash:
|
||||
return bitcoinCash!.createBitcoinCashNewWalletCredentials(
|
||||
name: name,
|
||||
password: password,
|
||||
passphrase: '',
|
||||
);
|
||||
case WalletType.ethereum:
|
||||
return ethereum!.createEthereumNewWalletCredentials(
|
||||
name: name,
|
||||
password: password,
|
||||
passphrase: '',
|
||||
);
|
||||
case WalletType.nano:
|
||||
case WalletType.banano:
|
||||
return nano!.createNanoNewWalletCredentials(
|
||||
name: name,
|
||||
password: password,
|
||||
passphrase: '',
|
||||
);
|
||||
case WalletType.polygon:
|
||||
return polygon!.createPolygonNewWalletCredentials(
|
||||
name: name,
|
||||
password: password,
|
||||
passphrase: '',
|
||||
);
|
||||
case WalletType.solana:
|
||||
return solana!.createSolanaNewWalletCredentials(
|
||||
name: name,
|
||||
password: password,
|
||||
passphrase: '',
|
||||
);
|
||||
case WalletType.tron:
|
||||
return tron!.createTronNewWalletCredentials(
|
||||
name: name,
|
||||
password: password,
|
||||
passphrase: '',
|
||||
);
|
||||
case WalletType.zano:
|
||||
return zano!.createZanoNewWalletCredentials(
|
||||
name: name,
|
||||
password: password,
|
||||
passphrase: '',
|
||||
);
|
||||
case WalletType.decred:
|
||||
return decred!.createDecredNewWalletCredentials(name: name);
|
||||
default:
|
||||
throw Exception('Wallet creation not yet implemented for ${type.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearLogs() async {
|
||||
logs.clear();
|
||||
await _logAction('Logs cleared from UI');
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> _logAction(String action, {String? result}) async {
|
||||
final entry = FuzzyLogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
action: action,
|
||||
result: result,
|
||||
);
|
||||
|
||||
logs.insert(0, entry);
|
||||
|
||||
if (logs.length > 500) {
|
||||
logs.removeLast();
|
||||
}
|
||||
|
||||
await _logToFile(entry.toString());
|
||||
}
|
||||
|
||||
Future<void> _logToFile(String message) async {
|
||||
try {
|
||||
final file = File(logFilePath!);
|
||||
final sink = await file.open(mode: FileMode.append);
|
||||
sink.writeStringSync('$message\n');
|
||||
await sink.close();
|
||||
} catch (e) {
|
||||
printV('Error writing to log file: $e');
|
||||
}
|
||||
}
|
||||
bool isBusy = false;
|
||||
|
||||
Future<void> _performRandomOperation() async {
|
||||
if (!isRunning) return;
|
||||
if (isBusy) return;
|
||||
isBusy = true;
|
||||
final wallets = walletListViewModel.wallets;
|
||||
if (wallets.isEmpty) {
|
||||
await _logAction('No wallets available to test');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _incrementOperationStat('performRandomOperation');
|
||||
final operations = [
|
||||
_loadRandomWallet,
|
||||
_syncRandomWallet,
|
||||
_checkAndCreateWallets,
|
||||
];
|
||||
|
||||
final operation = operations[_random.nextInt(operations.length)];
|
||||
await operation();
|
||||
operationsCompleted++;
|
||||
} catch (e, s) {
|
||||
errorsEncountered++;
|
||||
await _logAction('Error performing operation', result: e.toString());
|
||||
printV('Error: $e\nStack trace: $s');
|
||||
} finally {
|
||||
isBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadRandomWallet() async {
|
||||
await _incrementOperationStat('loadWallet');
|
||||
|
||||
final wallets = walletListViewModel.wallets
|
||||
.where((wallet) => !wallet.isHardware)
|
||||
.toList();
|
||||
|
||||
if (wallets.isEmpty) {
|
||||
await _logAction('No non-hardware wallets available to load');
|
||||
return;
|
||||
}
|
||||
|
||||
final walletItem = wallets[_random.nextInt(wallets.length)];
|
||||
currentWallet = walletItem.name;
|
||||
currentOperation = 'Loading wallet ${walletItem.name}';
|
||||
|
||||
await _logAction('Loading wallet', result: walletItem.name);
|
||||
|
||||
try {
|
||||
final wallet = await walletLoadingService.load(walletItem.type, walletItem.name);
|
||||
await _logAction('Wallet loaded successfully', result: walletItem.name);
|
||||
|
||||
await _logWalletState(wallet);
|
||||
|
||||
await _closeWallet(wallet);
|
||||
} catch (e) {
|
||||
await _logWalletStateByName(walletItem.name, e.toString().replaceAll("\n", ";"));
|
||||
|
||||
await _logAction('Failed to load wallet', result: '${walletItem.name}: $e');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncRandomWallet() async {
|
||||
await _incrementOperationStat('syncWallet');
|
||||
|
||||
final wallets = walletListViewModel.wallets
|
||||
.where((wallet) => !wallet.isHardware)
|
||||
.toList();
|
||||
|
||||
if (wallets.isEmpty) {
|
||||
await _logAction('No non-hardware wallets available to sync');
|
||||
return;
|
||||
}
|
||||
|
||||
final walletItem = wallets[_random.nextInt(wallets.length)];
|
||||
currentWallet = walletItem.name;
|
||||
|
||||
currentOperation = 'Syncing wallet ${walletItem.name}';
|
||||
|
||||
await _logAction('Starting sync for wallet', result: walletItem.name);
|
||||
|
||||
try {
|
||||
WalletBase wallet;
|
||||
wallet = await walletLoadingService.load(walletItem.type, walletItem.name);
|
||||
await _logAction("loaded wallet: ${wallet.name} as ${walletItem.name}");
|
||||
await _logWalletState(wallet);
|
||||
|
||||
final node = settingsStore.getCurrentNode(wallet.type);
|
||||
await wallet.connectToNode(node: node);
|
||||
|
||||
await wallet.startSync();
|
||||
await _logAction('Sync started for wallet', result: walletItem.name);
|
||||
|
||||
final syncDuration = Duration(seconds: 5 + _random.nextInt(5));
|
||||
await _logAction('Syncing for ${syncDuration.inSeconds} seconds', result: walletItem.name);
|
||||
await Future.delayed(syncDuration);
|
||||
|
||||
final syncStatus = wallet.syncStatus;
|
||||
await _logAction('Sync status', result: '${walletItem.name}: ${syncStatus.runtimeType}');
|
||||
|
||||
await wallet.stopSync();
|
||||
await _logAction('Sync stopped for wallet', result: walletItem.name);
|
||||
|
||||
await _logWalletState(wallet);
|
||||
|
||||
await _closeWallet(wallet);
|
||||
} catch (e) {
|
||||
await _logAction('Error during sync operation', result: '${walletItem.name}: $e');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _closeWallet(WalletBase wallet) async {
|
||||
final walletName = wallet.name;
|
||||
await _logAction('Starting wallet close procedure', result: walletName);
|
||||
|
||||
try {
|
||||
if (wallet.syncStatus is SyncingSyncStatus) {
|
||||
await wallet.stopSync();
|
||||
await _logAction('Stopped sync before closing', result: walletName);
|
||||
}
|
||||
|
||||
await wallet.close(shouldCleanup: true);
|
||||
await _logAction('Wallet closed successfully', result: walletName);
|
||||
} catch (e) {
|
||||
await _logAction('Error in wallet close procedure', result: '$walletName: $e');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkAndCreateWallets() async {
|
||||
await _incrementOperationStat('checkAndCreateWallets');
|
||||
|
||||
currentOperation = 'Checking wallet counts and creating new ones if needed';
|
||||
await _logAction('Performing wallet count check and creation');
|
||||
await _ensureEnoughWallets();
|
||||
}
|
||||
|
||||
Future<void> _logWalletStateByName(String walletName, String data) async {
|
||||
try {
|
||||
final appDir = await getAppDir();
|
||||
final lastFuzzFile = File(p.join(appDir.path, _lastFuzzFile));
|
||||
|
||||
await lastFuzzFile.writeAsString('$walletName|$data\n', mode: FileMode.append);
|
||||
await _logAction('Updated wallet state file');
|
||||
} catch (e) {
|
||||
await _logAction('Failed to update wallet state file', result: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _logWalletState(WalletBase wallet) async {
|
||||
try {
|
||||
final appDir = await getAppDir();
|
||||
final walletInfo = _getWalletStateInfo(wallet);
|
||||
final lastFuzzFile = File(p.join(appDir.path, _lastFuzzFile));
|
||||
|
||||
await lastFuzzFile.writeAsString('$walletInfo\n', mode: FileMode.append);
|
||||
await _logAction('Updated wallet state file', result: walletInfo);
|
||||
} catch (e) {
|
||||
await _logAction('Failed to update wallet state file', result: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
String _getWalletStateInfo(WalletBase wallet) {
|
||||
String seed = wallet.seed??'noseed';
|
||||
String keys = "";
|
||||
try {
|
||||
keys = wallet.keys.toString();
|
||||
} catch (e) {
|
||||
keys = 'nokeys$e';
|
||||
}
|
||||
final data = '${wallet.name}|${wallet.type}|${seed}|${keys}'.replaceAll("\n", ";");
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<void> _clearLastFuzzFile() async {
|
||||
try {
|
||||
final appDir = await getAppDir();
|
||||
final lastFuzzFile = File(p.join(appDir.path, _lastFuzzFile));
|
||||
|
||||
if (await lastFuzzFile.exists()) {
|
||||
await lastFuzzFile.writeAsString('');
|
||||
await _logAction('Cleared last_fuzz file');
|
||||
}
|
||||
} catch (e) {
|
||||
await _logAction('Failed to clear last_fuzz file', result: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> getOperationStatsWithTiming() {
|
||||
final now = DateTime.now();
|
||||
final runDuration = now.difference(appStartDate);
|
||||
final totalOps = operationStats.values.fold(0, (a, b) => a + b);
|
||||
|
||||
double opsPerHour = 0;
|
||||
if (runDuration.inMinutes >= 1) {
|
||||
final runHours = runDuration.inMilliseconds / (1000 * 60 * 60);
|
||||
opsPerHour = totalOps / runHours;
|
||||
}
|
||||
|
||||
return {
|
||||
'stats': Map.from(operationStats),
|
||||
'totalOperations': totalOps,
|
||||
'runningTimeMinutes': runDuration.inMinutes,
|
||||
'operationsPerHour': opsPerHour,
|
||||
'startTimestamp': appStartDate.toIso8601String(),
|
||||
'currentTimestamp': now.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
120
scripts/android/debug/display_fuzzer_stats.sh
Executable file
120
scripts/android/debug/display_fuzzer_stats.sh
Executable file
|
@ -0,0 +1,120 @@
|
|||
#!/bin/bash
|
||||
|
||||
FUZZER_DATA_DIR="./wallet_fuzzer_data"
|
||||
|
||||
clear_screen() {
|
||||
clear
|
||||
}
|
||||
|
||||
print_header() {
|
||||
local col_width=$1
|
||||
shift
|
||||
|
||||
printf "%-${col_width}s" "OPERATION"
|
||||
for device in "$@"; do
|
||||
printf "| %-15s " "$(basename "$device")"
|
||||
done
|
||||
echo
|
||||
|
||||
# Print separator line
|
||||
printf "%${col_width}s" | tr ' ' '-'
|
||||
for ((i=1; i<=$#; i++)); do
|
||||
printf "%s" "+-----------------"
|
||||
done
|
||||
echo
|
||||
}
|
||||
|
||||
display_stats() {
|
||||
clear_screen
|
||||
|
||||
echo "=== WALLET FUZZER STATISTICS === (refreshes every 5 seconds)"
|
||||
echo "Last update: $(date)"
|
||||
echo
|
||||
|
||||
device_dirs=($FUZZER_DATA_DIR/*)
|
||||
|
||||
if [ ${#device_dirs[@]} -eq 0 ]; then
|
||||
echo "No device data found in $FUZZER_DATA_DIR"
|
||||
return
|
||||
fi
|
||||
|
||||
operations=()
|
||||
for device in "${device_dirs[@]}"; do
|
||||
if [ -f "$device/fuzzer_stats.json" ]; then
|
||||
if command -v jq &>/dev/null; then
|
||||
device_ops=$(jq -r 'keys[]' "$device/fuzzer_stats.json" 2>/dev/null)
|
||||
for op in $device_ops; do
|
||||
if [[ ! " ${operations[@]} " =~ " ${op} " ]]; then
|
||||
operations+=("$op")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
IFS=$'\n' sorted_operations=($(sort <<<"${operations[*]}"))
|
||||
unset IFS
|
||||
|
||||
col_width=20
|
||||
for op in "${sorted_operations[@]}"; do
|
||||
if [ ${#op} -gt $col_width ]; then
|
||||
col_width=${#op}
|
||||
fi
|
||||
done
|
||||
col_width=$((col_width + 5))
|
||||
|
||||
print_header $col_width "${device_dirs[@]}"
|
||||
|
||||
for op in "${sorted_operations[@]}"; do
|
||||
printf "%-${col_width}s" "$op"
|
||||
|
||||
for device in "${device_dirs[@]}"; do
|
||||
if [ -f "$device/fuzzer_stats.json" ] && command -v jq &>/dev/null; then
|
||||
value=$(jq -r ".[\"$op\"] // 0" "$device/fuzzer_stats.json" 2>/dev/null)
|
||||
if [ -z "$value" ] || [ "$value" = "null" ]; then
|
||||
value=0
|
||||
fi
|
||||
printf "| %-15s " "$value"
|
||||
else
|
||||
printf "| %-15s " "N/A"
|
||||
fi
|
||||
done
|
||||
echo
|
||||
done
|
||||
|
||||
printf "%-${col_width}s" "TOTAL"
|
||||
grand_total=0
|
||||
for device in "${device_dirs[@]}"; do
|
||||
if [ -f "$device/fuzzer_stats.json" ] && command -v jq &>/dev/null; then
|
||||
total=$(jq -r 'to_entries | map(.value) | add' "$device/fuzzer_stats.json" 2>/dev/null)
|
||||
if [ -z "$total" ] || [ "$total" = "null" ]; then
|
||||
total=0
|
||||
fi
|
||||
printf "| %-15s " "$total"
|
||||
grand_total=$((grand_total + total))
|
||||
else
|
||||
printf "| %-15s " "N/A"
|
||||
fi
|
||||
done
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "Total operations across all devices: $grand_total"
|
||||
}
|
||||
|
||||
main() {
|
||||
trap 'echo "Exiting..."; exit 0' INT
|
||||
|
||||
while true; do
|
||||
display_stats
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo "Warning: jq is not installed. This script requires jq to parse JSON data."
|
||||
echo "Please install jq with your package manager (e.g., apt, brew, etc.)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
main
|
132
scripts/android/debug/record_tap.sh
Executable file
132
scripts/android/debug/record_tap.sh
Executable file
|
@ -0,0 +1,132 @@
|
|||
#!/bin/bash
|
||||
# This script extracts the device's screen resolution and the raw touch coordinate maximums,
|
||||
# listens for a tap event, scales the raw touch coordinates to the screen resolution,
|
||||
# and then outputs and executes an adb tap command.
|
||||
|
||||
screen_info=$(adb shell wm size | ggrep -oP "\d+x\d+")
|
||||
if [[ -z "$screen_info" ]]; then
|
||||
echo "Failed to get screen resolution."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCREEN_WIDTH=${screen_info%x*}
|
||||
SCREEN_HEIGHT=${screen_info#*x}
|
||||
echo "Screen resolution: ${SCREEN_WIDTH}x${SCREEN_HEIGHT}"
|
||||
|
||||
echo "Detecting touch input device..."
|
||||
TOUCH_DEVICE=""
|
||||
|
||||
devices_info=$(adb shell getevent -i)
|
||||
echo "Found input devices:"
|
||||
|
||||
while read -r line; do
|
||||
if [[ $line =~ ^add\ device\ ([0-9]+):\ (.+) ]]; then
|
||||
device_num="${BASH_REMATCH[1]}"
|
||||
device_path="${BASH_REMATCH[2]}"
|
||||
device_name=$(echo "$devices_info" | ggrep -A 5 "add device $device_num:" | ggrep "name:" | gawk -F'"' '{print $2}')
|
||||
echo " Device $device_num: $device_path ($device_name)"
|
||||
|
||||
# Check if device has touch capabilities
|
||||
# Look for ABS events 0035 and 0036 which correspond to X and Y coordinates
|
||||
touch_info=$(adb shell getevent -pl "$device_path")
|
||||
if echo "$touch_info" | ggrep -q "ABS.*0035" && echo "$touch_info" | ggrep -q "ABS.*0036"; then
|
||||
TOUCH_DEVICE="$device_path"
|
||||
echo "Selected touch device at $TOUCH_DEVICE"
|
||||
fi
|
||||
fi
|
||||
done < <(echo "$devices_info" | ggrep "^add device")
|
||||
|
||||
if [[ -z "$TOUCH_DEVICE" ]]; then
|
||||
echo "Failed to detect touch input device."
|
||||
echo "Trying to use the first 'multi_touch' device as fallback..."
|
||||
|
||||
while read -r line; do
|
||||
if [[ $line =~ ^add\ device\ ([0-9]+):\ (.+) ]]; then
|
||||
device_num="${BASH_REMATCH[1]}"
|
||||
device_path="${BASH_REMATCH[2]}"
|
||||
device_name=$(echo "$devices_info" | ggrep -A 5 "add device $device_num:" | ggrep "name:" | gawk -F'"' '{print $2}')
|
||||
|
||||
if [[ "$device_name" == *"multi_touch"* ]]; then
|
||||
TOUCH_DEVICE="$device_path"
|
||||
echo "Selected fallback touch device at $TOUCH_DEVICE ($device_name)"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done < <(echo "$devices_info" | ggrep "^add device")
|
||||
fi
|
||||
|
||||
if [[ -z "$TOUCH_DEVICE" ]]; then
|
||||
echo "No touch device found. Please manually specify the device node as an argument."
|
||||
echo "Available devices:"
|
||||
echo "$devices_info" | ggrep -A 5 "^add device" | ggrep -E "^add device|name:"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
raw_info=$(adb shell getevent -pl "$TOUCH_DEVICE")
|
||||
|
||||
# Extract X and Y max values - try multiple ABS codes that could represent X/Y coordinates
|
||||
extract_max() {
|
||||
local raw_info="$1"
|
||||
local abs_code="$2"
|
||||
|
||||
echo "$raw_info" | gawk -v code="$abs_code" '
|
||||
$0 ~ code {
|
||||
for(i=1; i<=NF; i++) {
|
||||
if($i == "max") {
|
||||
print $(i+1)
|
||||
exit
|
||||
}
|
||||
}
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
# Try to find X max from either ABS_MT_POSITION_X or plain X position (0035)
|
||||
RAW_MAX_X=$(extract_max "$raw_info" "ABS_MT_POSITION_X")
|
||||
if [[ -z "$RAW_MAX_X" ]]; then
|
||||
RAW_MAX_X=$(extract_max "$raw_info" "0035")
|
||||
fi
|
||||
|
||||
# Try to find Y max from either ABS_MT_POSITION_Y or plain Y position (0036)
|
||||
RAW_MAX_Y=$(extract_max "$raw_info" "ABS_MT_POSITION_Y")
|
||||
if [[ -z "$RAW_MAX_Y" ]]; then
|
||||
RAW_MAX_Y=$(extract_max "$raw_info" "0036")
|
||||
fi
|
||||
|
||||
if [[ -z "$RAW_MAX_X" || -z "$RAW_MAX_Y" ]]; then
|
||||
echo "Failed to extract raw maximum values for touch coordinates."
|
||||
echo "Using default values for emulator: 32767 x 32767"
|
||||
RAW_MAX_X=32767
|
||||
RAW_MAX_Y=32767
|
||||
fi
|
||||
|
||||
echo "Raw touch coordinate range: X max=$RAW_MAX_X, Y max=$RAW_MAX_Y"
|
||||
|
||||
echo "Waiting for a tap event. Press Ctrl+C to exit..."
|
||||
|
||||
adb shell getevent -lt "$TOUCH_DEVICE" | gawk -v sw="$SCREEN_WIDTH" -v sh="$SCREEN_HEIGHT" -v rx="$RAW_MAX_X" -v ry="$RAW_MAX_Y" '
|
||||
/ABS_MT_POSITION_X/ || /0035 / {
|
||||
# Convert hex raw x-coordinate to a number.
|
||||
raw_x = strtonum("0x" $NF)
|
||||
}
|
||||
/ABS_MT_POSITION_Y/ || /0036 / {
|
||||
# Convert hex raw y-coordinate to a number.
|
||||
raw_y = strtonum("0x" $NF)
|
||||
# Extract the timestamp from the beginning of the line (e.g., "[ 43466.939179]")
|
||||
if (match($0, /\[ *([0-9]+\.[0-9]+)\]/, arr)) {
|
||||
curr_time = arr[1] + 0 # ensure numeric conversion
|
||||
}
|
||||
# If a previous timestamp exists, compute delay and print sleep command.
|
||||
if (prev_time != "") {
|
||||
delay = curr_time - prev_time
|
||||
# Print a sleep command with the delay (formatted with microsecond precision)
|
||||
printf "sleep %.6f\n", delay
|
||||
}
|
||||
prev_time = curr_time
|
||||
# Scale raw coordinates to screen resolution coordinates.
|
||||
scaled_x = int(raw_x * sw / rx)
|
||||
scaled_y = int(raw_y * sh / ry)
|
||||
# Print the input tap command.
|
||||
printf "adb shell input tap %d %d\n", scaled_x, scaled_y
|
||||
}
|
||||
'
|
421
scripts/android/debug/wallet_fuzzer.sh
Executable file
421
scripts/android/debug/wallet_fuzzer.sh
Executable file
|
@ -0,0 +1,421 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
APP_PACKAGE="com.cakewallet.cake_wallet"
|
||||
ACTIVITY="com.cakewallet.cake_wallet.MainActivity"
|
||||
LAST_FUZZ_FILE="app_flutter/last_fuzz"
|
||||
STATS_FILE="app_flutter/fuzzer_stats"
|
||||
LOCAL_FUZZ_DIR="./wallet_fuzzer_data"
|
||||
|
||||
function get_devices() {
|
||||
adb devices | grep -v "List" | grep -v "^$" | awk '{print $1}'
|
||||
}
|
||||
|
||||
function init_device_dirs() {
|
||||
local device_id=$1
|
||||
mkdir -p "$LOCAL_FUZZ_DIR/$device_id"
|
||||
}
|
||||
|
||||
function start_app() {
|
||||
local device_id=$1
|
||||
adb -s $device_id shell am start -n $APP_PACKAGE/$ACTIVITY
|
||||
echo "[$device_id] App started at $(date)"
|
||||
# adb shell input tap <- you need to generate these commands in order to open the screen and start
|
||||
# fuzzing.
|
||||
# on your desktop run:
|
||||
# ./scripts/android/debug/record_tap.sh
|
||||
# and then copy the output into the adb shell input tap commands below.
|
||||
# make sure to tap very briefly, otherwise multiple events will be generated.
|
||||
sleep 6
|
||||
adb -s $device_id shell input tap 602 1940
|
||||
sleep 2.664044
|
||||
adb -s $device_id shell input tap 473 1923
|
||||
sleep 2.713335
|
||||
adb -s $device_id shell input tap 459 2021
|
||||
sleep 2.668894
|
||||
adb -s $device_id shell input tap 515 2027
|
||||
sleep 3.871710
|
||||
adb -s $device_id shell input tap 977 141
|
||||
sleep 4.263207
|
||||
adb -s $device_id shell input tap 635 1801
|
||||
sleep 3.804010
|
||||
adb -s $device_id shell input tap 289 757
|
||||
sleep 3.833263
|
||||
adb -s $device_id shell input tap 962 2176
|
||||
}
|
||||
|
||||
function stop_app() {
|
||||
local device_id=$1
|
||||
adb -s $device_id shell am force-stop $APP_PACKAGE
|
||||
echo "[$device_id] App stopped at $(date)"
|
||||
}
|
||||
|
||||
function get_operation_stats() {
|
||||
local device_id=$1
|
||||
local LOCAL_STATS_FILE="$LOCAL_FUZZ_DIR/$device_id/fuzzer_stats.json"
|
||||
local LOCAL_STATS_HISTORY="$LOCAL_FUZZ_DIR/$device_id/stats_history.json"
|
||||
|
||||
echo "[$device_id] Pulling operation statistics..."
|
||||
|
||||
adb -s $device_id shell run-as $APP_PACKAGE cat $STATS_FILE > $LOCAL_STATS_FILE.new 2>/dev/null
|
||||
|
||||
if [ ! -s "$LOCAL_STATS_FILE.new" ]; then
|
||||
echo "[$device_id] No operation statistics found or couldn't access file."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! jq empty "$LOCAL_STATS_FILE.new" 2>/dev/null; then
|
||||
echo "[$device_id] Invalid JSON data received from device"
|
||||
cat "$LOCAL_STATS_FILE.new"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$LOCAL_STATS_FILE" ]; then
|
||||
# First time stats collection
|
||||
mv "$LOCAL_STATS_FILE.new" "$LOCAL_STATS_FILE"
|
||||
echo "[$device_id] Initial operation statistics recorded:"
|
||||
cat "$LOCAL_STATS_FILE" | jq -r 'to_entries | .[] | "\(.key): \(.value)"' 2>/dev/null || echo "Error parsing JSON, raw content: $(cat $LOCAL_STATS_FILE)"
|
||||
|
||||
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
|
||||
STATS_CONTENT=$(cat "$LOCAL_STATS_FILE")
|
||||
echo "[{\"timestamp\": \"$TIMESTAMP\", \"stats\": $STATS_CONTENT}]" > "$LOCAL_STATS_HISTORY"
|
||||
echo "[$device_id] Created initial stats history with timestamp"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "[$device_id] Operation statistics changes since last run:"
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
if ! jq empty "$LOCAL_STATS_FILE" 2>/dev/null; then
|
||||
echo "[$device_id] Previous stats file corrupted, creating new baseline"
|
||||
mv "$LOCAL_STATS_FILE.new" "$LOCAL_STATS_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
PREV_STATS=$(cat "$LOCAL_STATS_FILE")
|
||||
NEW_STATS=$(cat "$LOCAL_STATS_FILE.new")
|
||||
|
||||
echo "[$device_id] Operation | Previous | Current | Difference"
|
||||
echo "[$device_id] ----------|----------|---------|----------"
|
||||
|
||||
ALL_OPS=$(jq -s '.[0] + .[1] | keys | .[]' "$LOCAL_STATS_FILE" "$LOCAL_STATS_FILE.new" | sort | uniq)
|
||||
|
||||
for operation in $ALL_OPS; do
|
||||
clean_op=$(echo "$operation" | sed 's/^"//;s/"$//')
|
||||
|
||||
PREV_COUNT=$(jq -r ".[\"${clean_op}\"] // 0" <<< "$PREV_STATS")
|
||||
|
||||
NEW_COUNT=$(jq -r ".[\"${clean_op}\"] // 0" <<< "$NEW_STATS")
|
||||
|
||||
DIFF=$((NEW_COUNT - PREV_COUNT))
|
||||
|
||||
printf "[$device_id] %-10s | %8d | %7d | %10d\n" "$clean_op" $PREV_COUNT $NEW_COUNT $DIFF
|
||||
done
|
||||
|
||||
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
|
||||
if [ -f "$LOCAL_STATS_HISTORY" ]; then
|
||||
if grep -q "^\[" "$LOCAL_STATS_HISTORY"; then
|
||||
TEMP_FILE=$(mktemp)
|
||||
jq --arg timestamp "$TIMESTAMP" --argjson stats "$NEW_STATS" \
|
||||
'. + [{"timestamp": $timestamp, "stats": $stats}]' "$LOCAL_STATS_HISTORY" > "$TEMP_FILE"
|
||||
mv "$TEMP_FILE" "$LOCAL_STATS_HISTORY"
|
||||
else
|
||||
TEMP_FILE=$(mktemp)
|
||||
echo "[" > "$TEMP_FILE"
|
||||
jq --arg timestamp "$TIMESTAMP" --argjson stats "$NEW_STATS" \
|
||||
'{"timestamp": $timestamp, "stats": $stats}' <<< "{}" >> "$TEMP_FILE"
|
||||
echo "]" >> "$TEMP_FILE"
|
||||
mv "$TEMP_FILE" "$LOCAL_STATS_HISTORY"
|
||||
fi
|
||||
else
|
||||
echo "[{\"timestamp\": \"$TIMESTAMP\", \"stats\": $NEW_STATS}]" > "$LOCAL_STATS_HISTORY"
|
||||
fi
|
||||
else
|
||||
echo "[$device_id] jq not found. Install jq for better statistics display."
|
||||
echo "[$device_id] Raw statistics: "
|
||||
cat "$LOCAL_STATS_FILE.new"
|
||||
fi
|
||||
|
||||
mv "$LOCAL_STATS_FILE.new" "$LOCAL_STATS_FILE"
|
||||
return 0
|
||||
}
|
||||
|
||||
function display_stats_summary() {
|
||||
local device_id=$1
|
||||
local LOCAL_STATS_HISTORY="$LOCAL_FUZZ_DIR/$device_id/stats_history.json"
|
||||
local LOCAL_FUZZ_HISTORY="$LOCAL_FUZZ_DIR/$device_id/fuzz_history.log"
|
||||
|
||||
if [ ! -f "$LOCAL_STATS_HISTORY" ]; then
|
||||
echo "[$device_id] No statistics history found."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
echo "[$device_id] === Wallet Fuzzer Statistics Summary ==="
|
||||
|
||||
if grep -q "^\[" "$LOCAL_STATS_HISTORY"; then
|
||||
echo "[$device_id] History contains $(jq 'length' "$LOCAL_STATS_HISTORY") recorded sessions"
|
||||
|
||||
FIRST_TS=$(jq -r '.[0].timestamp' "$LOCAL_STATS_HISTORY")
|
||||
LAST_TS=$(jq -r '.[-1].timestamp' "$LOCAL_STATS_HISTORY")
|
||||
echo "[$device_id] Tracking period: $FIRST_TS to $LAST_TS"
|
||||
|
||||
LATEST_STATS=$(jq -r '.[-1].stats' "$LOCAL_STATS_HISTORY")
|
||||
|
||||
TOTAL_OPS=$(jq -r 'to_entries | map(.value) | add' <<< "$LATEST_STATS" 2>/dev/null || echo 0)
|
||||
if [ "$TOTAL_OPS" = "" ] || [ "$TOTAL_OPS" = "null" ]; then
|
||||
TOTAL_OPS=0
|
||||
fi
|
||||
echo "[$device_id] Grand total operations: $TOTAL_OPS"
|
||||
|
||||
echo "[$device_id] Total operations by type:"
|
||||
jq -r 'to_entries | .[] | "\(.key): \(.value)"' <<< "$LATEST_STATS" | sort -k2 -nr | while read -r line; do
|
||||
echo "[$device_id] $line"
|
||||
done
|
||||
|
||||
ENTRIES_COUNT=$(jq 'length' "$LOCAL_STATS_HISTORY")
|
||||
if [ "$ENTRIES_COUNT" -gt 1 ]; then
|
||||
FIRST_DATE=$(date -d "$FIRST_TS" +%s 2>/dev/null || date -j -f "%Y-%m-%d %H:%M:%S" "$FIRST_TS" +%s)
|
||||
LAST_DATE=$(date -d "$LAST_TS" +%s 2>/dev/null || date -j -f "%Y-%m-%d %H:%M:%S" "$LAST_TS" +%s)
|
||||
DURATION_HOURS=$(( (LAST_DATE - FIRST_DATE) / 3600 ))
|
||||
|
||||
if [ "$DURATION_HOURS" -gt 0 ]; then
|
||||
echo "[$device_id] Operations per hour:"
|
||||
jq -r 'to_entries | .[] | "\(.key): \(.value)"' <<< "$LATEST_STATS" | while read -r line; do
|
||||
OP_NAME=$(echo $line | cut -d':' -f1)
|
||||
OP_COUNT=$(echo $line | cut -d':' -f2 | tr -d ' ')
|
||||
OPS_PER_HOUR=$(echo "scale=2; $OP_COUNT / $DURATION_HOURS" | bc)
|
||||
echo "[$device_id] $OP_NAME: $OPS_PER_HOUR"
|
||||
done
|
||||
|
||||
TOTAL_OPS_PER_HOUR=$(echo "scale=2; $TOTAL_OPS / $DURATION_HOURS" | bc)
|
||||
echo "[$device_id] Total: $TOTAL_OPS_PER_HOUR ops/hour"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "[$device_id] History contains 1 recorded session"
|
||||
echo "[$device_id] Stats from current session:"
|
||||
LATEST_STATS=$(cat "$LOCAL_STATS_HISTORY")
|
||||
|
||||
TOTAL_OPS=$(jq -r 'to_entries | map(.value) | add' <<< "$LATEST_STATS" 2>/dev/null || echo 0)
|
||||
if [ "$TOTAL_OPS" = "" ] || [ "$TOTAL_OPS" = "null" ]; then
|
||||
TOTAL_OPS=0
|
||||
fi
|
||||
echo "[$device_id] Grand total operations: $TOTAL_OPS"
|
||||
|
||||
echo "[$device_id] Total operations by type:"
|
||||
jq -r 'to_entries | .[] | "\(.key): \(.value)"' <<< "$LATEST_STATS" | sort -k2 -nr | while read -r line; do
|
||||
echo "[$device_id] $line"
|
||||
done
|
||||
fi
|
||||
|
||||
TOTAL_RUNS=$(grep -c "INITIAL" "$LOCAL_FUZZ_HISTORY" 2>/dev/null || echo 0)
|
||||
echo "[$device_id] Total runs since tracking began: $TOTAL_RUNS"
|
||||
else
|
||||
echo "[$device_id] jq not installed. Install jq for statistics summary."
|
||||
fi
|
||||
}
|
||||
|
||||
function get_app_data() {
|
||||
local device_id=$1
|
||||
local LOCAL_FUZZ_FILE="$LOCAL_FUZZ_DIR/$device_id/last_fuzz"
|
||||
local LOCAL_FUZZ_HISTORY="$LOCAL_FUZZ_DIR/$device_id/fuzz_history.log"
|
||||
|
||||
APP_DATA_DIR=$(adb -s $device_id shell run-as $APP_PACKAGE pwd)
|
||||
|
||||
if [ -z "$APP_DATA_DIR" ]; then
|
||||
echo "[$device_id] Failed to get app data directory. Make sure app is in debug mode."
|
||||
return 1
|
||||
fi
|
||||
|
||||
adb -s $device_id shell run-as $APP_PACKAGE cat $LAST_FUZZ_FILE > $LOCAL_FUZZ_FILE.new 2>/dev/null
|
||||
|
||||
if [ ! -s "$LOCAL_FUZZ_FILE.new" ]; then
|
||||
echo "[$device_id] No wallet state information found or couldn't access file."
|
||||
return 0
|
||||
fi
|
||||
|
||||
ENTRY_COUNT=$(wc -l < "$LOCAL_FUZZ_FILE.new")
|
||||
echo "[$device_id] Found $ENTRY_COUNT wallet state entries"
|
||||
|
||||
if [ ! -f "$LOCAL_FUZZ_FILE" ]; then
|
||||
mv "$LOCAL_FUZZ_FILE.new" "$LOCAL_FUZZ_FILE"
|
||||
echo "[$device_id] Initial wallet state recorded with $ENTRY_COUNT entries:"
|
||||
cat "$LOCAL_FUZZ_FILE" | while read -r line; do
|
||||
echo "[$device_id] - $line"
|
||||
done
|
||||
echo "$(date): INITIAL: $ENTRY_COUNT entries" >> $LOCAL_FUZZ_HISTORY
|
||||
return 0
|
||||
fi
|
||||
|
||||
PREV_WALLETS=$(awk -F'|' '{print $1}' "$LOCAL_FUZZ_FILE" | sort | uniq)
|
||||
NEW_WALLETS=$(awk -F'|' '{print $1}' "$LOCAL_FUZZ_FILE.new" | sort | uniq)
|
||||
|
||||
NEW_ONLY_WALLETS=$(comm -13 <(echo "$PREV_WALLETS") <(echo "$NEW_WALLETS"))
|
||||
if [ ! -z "$NEW_ONLY_WALLETS" ]; then
|
||||
echo "[$device_id] New wallets detected: $NEW_ONLY_WALLETS (normal behavior)"
|
||||
echo "[$device_id] New wallet details:"
|
||||
for wallet in $NEW_ONLY_WALLETS; do
|
||||
(grep "^$wallet|" "$LOCAL_FUZZ_FILE.new" || true) | while read -r line; do
|
||||
echo "[$device_id] - $line"
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
MISSING_WALLETS=$(comm -23 <(echo "$PREV_WALLETS") <(echo "$NEW_WALLETS"))
|
||||
if [ ! -z "$MISSING_WALLETS" ]; then
|
||||
echo "[$device_id] Wallets no longer present: $MISSING_WALLETS"
|
||||
echo "$(date): MISSING_WALLETS: $MISSING_WALLETS" >> $LOCAL_FUZZ_HISTORY
|
||||
fi
|
||||
|
||||
COMMON_WALLETS=$(comm -12 <(echo "$PREV_WALLETS") <(echo "$NEW_WALLETS"))
|
||||
|
||||
CORRUPTION_DETECTED=0
|
||||
EVIDENCE_DIR="$LOCAL_FUZZ_DIR/$device_id/corruption_$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
if [ ! -z "$COMMON_WALLETS" ]; then
|
||||
echo "[$device_id] Checking common wallets for changes: $COMMON_WALLETS"
|
||||
|
||||
for wallet in $COMMON_WALLETS; do
|
||||
PREV_ENTRIES=$(grep "^$wallet|" "$LOCAL_FUZZ_FILE" || true)
|
||||
NEW_ENTRIES=$(grep "^$wallet|" "$LOCAL_FUZZ_FILE.new" || true)
|
||||
|
||||
PREV_COUNT=$(echo "$PREV_ENTRIES" | wc -l)
|
||||
NEW_COUNT=$(echo "$NEW_ENTRIES" | wc -l)
|
||||
|
||||
echo "[$device_id] Wallet $wallet: $PREV_COUNT previous entries, $NEW_COUNT new entries"
|
||||
|
||||
if [ $NEW_COUNT -gt $PREV_COUNT ]; then
|
||||
echo "[$device_id] Wallet $wallet has $((NEW_COUNT - PREV_COUNT)) new operation(s)"
|
||||
fi
|
||||
|
||||
PREV_INFO=$(echo "$PREV_ENTRIES" | tail -n1)
|
||||
NEW_INFO=$(echo "$NEW_ENTRIES" | tail -n1)
|
||||
|
||||
if [ "$PREV_INFO" = "$NEW_INFO" ]; then
|
||||
echo "[$device_id] Wallet $wallet is unchanged"
|
||||
continue
|
||||
fi
|
||||
mkdir -p "$EVIDENCE_DIR"
|
||||
|
||||
echo "[$device_id] WALLET CHANGED - POTENTIAL CORRUPTION DETECTED"
|
||||
echo "[$device_id] Wallet: $wallet" | tee -a "$EVIDENCE_DIR/corruption_details.txt"
|
||||
echo "[$device_id] Previous: $PREV_INFO" | tee -a "$EVIDENCE_DIR/corruption_details.txt"
|
||||
echo "[$device_id] Current: $NEW_INFO" | tee -a "$EVIDENCE_DIR/corruption_details.txt"
|
||||
CORRUPTION_DETECTED=1
|
||||
done
|
||||
fi
|
||||
|
||||
if [ $CORRUPTION_DETECTED -eq 1 ]; then
|
||||
echo "$(date): CORRUPTION_DETECTED" >> $LOCAL_FUZZ_HISTORY
|
||||
|
||||
|
||||
cp "$LOCAL_FUZZ_FILE" "$EVIDENCE_DIR/last_fuzz.prev"
|
||||
cp "$LOCAL_FUZZ_FILE.new" "$EVIDENCE_DIR/last_fuzz.new"
|
||||
|
||||
echo "=== DETAILED COMPARISON $(date) ===" > "$EVIDENCE_DIR/comparison.txt"
|
||||
echo "Previous file contents:" >> "$EVIDENCE_DIR/comparison.txt"
|
||||
cat "$LOCAL_FUZZ_FILE" >> "$EVIDENCE_DIR/comparison.txt"
|
||||
echo -e "\nNew file contents:" >> "$EVIDENCE_DIR/comparison.txt"
|
||||
cat "$LOCAL_FUZZ_FILE.new" >> "$EVIDENCE_DIR/comparison.txt"
|
||||
echo -e "\nDiff output:" >> "$EVIDENCE_DIR/comparison.txt"
|
||||
diff "$LOCAL_FUZZ_FILE" "$LOCAL_FUZZ_FILE.new" >> "$EVIDENCE_DIR/comparison.txt" || true
|
||||
|
||||
echo "[$device_id] Pulling all files from app package directory..."
|
||||
APP_FILES_DIR="$EVIDENCE_DIR/app_files"
|
||||
mkdir -p "$APP_FILES_DIR" || true
|
||||
|
||||
FILE_LIST_TMP="$EVIDENCE_DIR/file_list.txt"
|
||||
adb -s $device_id shell run-as $APP_PACKAGE "find . -type f | grep -v -E 'cache|no_backup'" > "$FILE_LIST_TMP"
|
||||
|
||||
TOTAL_FILES=$(wc -l < "$FILE_LIST_TMP")
|
||||
echo "[$device_id] Found $TOTAL_FILES files to process"
|
||||
|
||||
FILE_COUNT=0
|
||||
OLDIFS="$IFS"
|
||||
IFS=$'\n'
|
||||
for file_path in $(cat "$FILE_LIST_TMP"); do
|
||||
FILE_COUNT=$((FILE_COUNT + 1))
|
||||
echo "[$device_id] Processing file $FILE_COUNT/$TOTAL_FILES: $file_path"
|
||||
|
||||
rel_dir=$(dirname "$file_path")
|
||||
mkdir -p "$APP_FILES_DIR/$rel_dir" || true
|
||||
|
||||
dest_file="$APP_FILES_DIR/$file_path"
|
||||
adb -s $device_id shell run-as $APP_PACKAGE cat "$file_path" > "$dest_file" 2>/dev/null || echo "[$device_id] Failed to copy $file_path"
|
||||
done
|
||||
IFS="$OLDIFS"
|
||||
|
||||
echo "[$device_id] Evidence saved to $EVIDENCE_DIR"
|
||||
fi
|
||||
|
||||
mv "$LOCAL_FUZZ_FILE.new" "$LOCAL_FUZZ_FILE"
|
||||
|
||||
if [ $CORRUPTION_DETECTED -eq 1 ]; then
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
function run_device_test() {
|
||||
local device_id=$1
|
||||
|
||||
stop_app $device_id
|
||||
sleep 2
|
||||
|
||||
display_stats_summary $device_id
|
||||
|
||||
local error_detected=0
|
||||
|
||||
while [ $error_detected -eq 0 ]; do
|
||||
start_app $device_id
|
||||
RUN_TIME=$((30 + RANDOM % 120))
|
||||
echo "[$device_id] Will run for $RUN_TIME seconds"
|
||||
sleep $RUN_TIME
|
||||
|
||||
stop_app $device_id
|
||||
|
||||
get_operation_stats $device_id
|
||||
|
||||
get_app_data $device_id
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[$device_id] Stopping tests due to potential wallet corruption!"
|
||||
error_detected=1
|
||||
break
|
||||
fi
|
||||
|
||||
WAIT_TIME=$((5 + RANDOM % 15))
|
||||
echo "[$device_id] Will wait for $WAIT_TIME seconds"
|
||||
sleep $WAIT_TIME
|
||||
done
|
||||
|
||||
echo "[$device_id] Device testing completed"
|
||||
}
|
||||
|
||||
mkdir -p $LOCAL_FUZZ_DIR
|
||||
|
||||
DEVICES=$(get_devices)
|
||||
DEVICE_COUNT=$(echo "$DEVICES" | wc -l)
|
||||
|
||||
if [ -z "$DEVICES" ]; then
|
||||
echo "No ADB devices connected. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found $DEVICE_COUNT connected device(s):"
|
||||
echo "$DEVICES" | while read device; do
|
||||
echo "- $device"
|
||||
init_device_dirs $device
|
||||
done
|
||||
|
||||
for device in $DEVICES; do
|
||||
echo "Starting tests on device $device"
|
||||
RANDOM_DELAY=$(awk -v min=0.05 -v max=1.00 'BEGIN{srand(); print min+rand()*(max-min)}')
|
||||
echo "[$device] Waiting for $RANDOM_DELAY seconds before starting test"
|
||||
sleep $RANDOM_DELAY
|
||||
run_device_test $device &
|
||||
done
|
||||
|
||||
wait
|
||||
|
||||
echo "All device tests completed"
|
Loading…
Add table
Add a link
Reference in a new issue