Merge branch 'cake-tech:dev' into dev

This commit is contained in:
BSN ∞/21M
2026-06-15 08:46:36 +02:00
committed by GitHub
14 changed files with 330 additions and 94 deletions
+20
View File
@@ -1,8 +1,10 @@
import 'dart:io';
import 'package:cw_core/db/sqlite_debug.dart';
import 'package:cw_core/root_dir.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:path/path.dart' as p;
Database? db;
@@ -22,7 +24,25 @@ Future<void> _addColumnIfNotExists(
}
}
Future<File> sqliteDebugMarkerFile() async {
final appDir = await getAppDir();
final dbDebugMarker = p.join(appDir.path, ".sqlite_db_debug");
return File(dbDebugMarker);
}
Future<void> initDb({String? pathOverride}) async {
final dbDebugMarker = await sqliteDebugMarkerFile();
try {
if (dbDebugMarker.existsSync()) {
throw Exception("Debug marker is present");
}
await _initDb(pathOverride: pathOverride);
} catch (e, s) {
await handleSqliteError(e, s);
}
}
Future<void> _initDb({String? pathOverride}) async {
if (Platform.isLinux || Platform.isWindows) {
databaseFactory = databaseFactoryFfi;
}
+177
View File
@@ -0,0 +1,177 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:cw_core/db/sqlite.dart';
import 'package:cw_core/root_dir.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
Future<void> handleSqliteError(final dynamic error, final StackTrace stackTrace) async {
try {
final f = await sqliteDebugMarkerFile();
f.createSync();
} catch (_) {}
runApp(
SqliteErrorHandlerApp(
error: error,
stackTrace: stackTrace,
),
);
await Future.delayed(Duration(hours: 24));
}
class SqliteErrorHandlerApp extends StatelessWidget {
const SqliteErrorHandlerApp({
super.key,
required this.error,
required this.stackTrace,
});
final dynamic error;
final StackTrace? stackTrace;
@override
Widget build(final BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Cake Wallet Crash',
themeMode: ThemeMode.light,
home: SqliteErrorHandler(
error: error,
stackTrace: stackTrace,
),
);
}
}
class SqliteErrorHandler extends StatefulWidget {
SqliteErrorHandler({
super.key,
required this.error,
required this.stackTrace,
});
final dynamic error;
final StackTrace? stackTrace;
@override
State<SqliteErrorHandler> createState() => _SqliteErrorHandlerState();
}
class _SqliteErrorHandlerState extends State<SqliteErrorHandler> {
String log = "";
@override
Widget build(final BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Sqlite Recovery Screen"),
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildNotice(),
),
),
),
);
}
Widget _text(final dynamic element) {
return SelectableText(
element.toString(),
);
}
void _dbgRemoveDebugMarker() async {
final f = await sqliteDebugMarkerFile();
f.delete();
}
void _corruptTheDb() async {
final dbFile = File("${(await getAppDir()).path}/cake.db");
await writeBytesToFile(dbFile);
}
void _dumpDb() async {
try {
final dbFile = File("${(await getAppDir()).path}/cake.db");
final jsonText = JsonEncoder.withIndent(" ").convert(await dumpCustomDb(dbFile.path));
setState(() {
log = jsonText;
});
} catch (e, s) {
setState(() {
log = """
e: $e
s: $s
""";
});
}
}
void _removeDb() async {
try {
File("${(await getAppDir()).path}/cake.db.old").deleteSync();
} catch (_) {}
final dbFile = File("${(await getAppDir()).path}/cake.db");
dbFile.renameSync("${(await getAppDir()).path}/cake.db.old");
}
List<Widget> _debugOptions() {
return [
_debugButton("Remove debug marker", _dbgRemoveDebugMarker),
if (kDebugMode || kProfileMode) _debugButton("[DO NOT PRESS] Corrupt the DB", _corruptTheDb),
_debugButton("Remove DB", _removeDb),
_debugButton("Dump DB", _dumpDb),
];
}
Widget _debugButton(String text, VoidCallback callback) {
return ElevatedButton(
onPressed: callback,
child: Text(text),
);
}
List<Widget> _buildNotice() {
return [
_text("""Critical error occured in SQLite
This is very rare, usually caused by the device killing the app before it finishes writing file.
Here you can recover the database or recreate it to regain access to your wallets.
During this process some of the metadata may get lost.
Please contact support@cakewallet.com if you need any help."""),
Divider(),
..._debugOptions(),
if (log.isNotEmpty) ...[
Divider(),
SelectableText(
log,
style: TextStyle(
fontSize: 8,
),
),
],
Divider(),
_text(widget.error),
Divider(),
_text(widget.stackTrace),
];
}
}
Future<void> writeBytesToFile(File file) async {
const int seek = 5;
const int count = 50000;
final raf = await file.open(mode: FileMode.writeOnlyAppend);
try {
await raf.setPosition(seek);
final zeros = Uint8List.fromList(List.generate(count, (_) => 0x42));
await raf.writeFrom(zeros);
} finally {
await raf.close();
}
}
+9 -4
View File
@@ -7,12 +7,17 @@ const _BOLT_PREFIXES = ["lnbcrt", "lntbs", "lnbc", "lntb"];
const _LUD17_PREFIXES = ['lnurlw', 'lnurlc', 'lnurlp', 'keyauth'];
bool isBolt11ZeroInvoice(String invoice) {
final request = Bech32Codec().decode(invoice.replaceFirst("lightning:", ""), invoice.length);
try {
final request = Bech32Codec().decode(invoice.replaceFirst("lightning:", ""), invoice.length);
final prefix = _BOLT_PREFIXES
.firstWhere((prefix) => request.hrp.startsWith(prefix), orElse: () => "");
return request.hrp.length == prefix.length;
final prefix = _BOLT_PREFIXES
.firstWhere((prefix) => request.hrp.startsWith(prefix), orElse: () => "");
return request.hrp.length == prefix.length;
} catch(e) {
return false;
}
}
/// Get the amount of a Bolt 11 Invoice in
+3 -3
View File
@@ -1569,10 +1569,10 @@ Future<void> setup({
unspentCoinsListViewModel:
getIt.get<UnspentCoinsListViewModel>(param1: coinTypeToSpendFrom)));
getIt.registerFactoryParam<NewCoinControlPage, UnspentCoinType?, void>(
(coinTypeToSpendFrom, _) => NewCoinControlPage(
getIt.registerFactoryParam<NewCoinControlPage, UnspentCoinType?, bool?>(
(coinTypeToSpendFrom, canEdit) => NewCoinControlPage(
unspentCoinsListViewModel:
getIt.get<UnspentCoinsListViewModel>(param1: coinTypeToSpendFrom))
getIt.get<UnspentCoinsListViewModel>(param1: coinTypeToSpendFrom), canEdit: canEdit ?? true,)
);
+21 -7
View File
@@ -4,15 +4,25 @@ import 'package:cake_wallet/new-ui/widgets/modal_header.dart';
import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart';
import 'package:cw_core/unspent_coin_type.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import "package:cake_wallet/view_model/unspent_coins/unspent_coins_item.dart";
class CoinControlPageArgs {
final bool canEdit;
final UnspentCoinType? coinTypeToSpendFrom;
const CoinControlPageArgs({required this.canEdit, this.coinTypeToSpendFrom});
}
class NewCoinControlPage extends StatefulWidget {
const NewCoinControlPage({super.key, required this.unspentCoinsListViewModel});
const NewCoinControlPage(
{super.key, required this.unspentCoinsListViewModel, required this.canEdit});
final UnspentCoinsListViewModel unspentCoinsListViewModel;
final bool canEdit;
@override
State<NewCoinControlPage> createState() => _NewCoinControlPageState();
@@ -31,9 +41,9 @@ class _NewCoinControlPageState extends State<NewCoinControlPage> {
Widget build(BuildContext context) {
return PopScope(
canPop: !widget.unspentCoinsListViewModel.isSavingItems,
onPopInvokedWithResult: (didPop, result) async{
onPopInvokedWithResult: (didPop, result) async {
await widget.unspentCoinsListViewModel.dispose();
if(!didPop && mounted) Navigator.of(context).pop();
if (!didPop && mounted) Navigator.of(context).pop();
},
child: Material(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
@@ -94,9 +104,9 @@ class _NewCoinControlPageState extends State<NewCoinControlPage> {
child: ModalHeader(
iconPath: "assets/new-ui/settings_row_icons/coin-control.svg",
title: "Coin Control",
message: S.of(context).coin_control_desc),
message: widget.canEdit ? S.of(context).coin_control_desc : S.of(context).coin_control_desc_no_edit),
),
if (widget.unspentCoinsListViewModel.items.isNotEmpty)
if (widget.unspentCoinsListViewModel.items.isNotEmpty && widget.canEdit)
Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
@@ -124,7 +134,7 @@ class _NewCoinControlPageState extends State<NewCoinControlPage> {
)
],
),
),
) else SizedBox(height: 24,),
if (widget.unspentCoinsListViewModel.nonFrozenItems.isEmpty &&
widget.unspentCoinsListViewModel.frozenItems.isEmpty) ...[
SizedBox(height: 12),
@@ -144,6 +154,7 @@ class _NewCoinControlPageState extends State<NewCoinControlPage> {
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: CoinControlListSection(
canEdit: widget.canEdit,
items: widget.unspentCoinsListViewModel.nonFrozenItems,
unspentCoinsListViewModel:
widget.unspentCoinsListViewModel),
@@ -170,6 +181,7 @@ class _NewCoinControlPageState extends State<NewCoinControlPage> {
.onSurfaceVariant),
),
CoinControlListSection(
canEdit: widget.canEdit,
items: widget
.unspentCoinsListViewModel.frozenItems,
unspentCoinsListViewModel:
@@ -194,9 +206,10 @@ class _NewCoinControlPageState extends State<NewCoinControlPage> {
class CoinControlListSection extends StatelessWidget {
const CoinControlListSection(
{super.key, required this.items, required this.unspentCoinsListViewModel});
{super.key, required this.items, required this.unspentCoinsListViewModel, required this.canEdit});
final List<UnspentCoinsItem> items;
final bool canEdit;
final UnspentCoinsListViewModel unspentCoinsListViewModel;
@override
@@ -231,6 +244,7 @@ class CoinControlListSection extends StatelessWidget {
isLoading: item.isBeingSaved,
isFirst: index == 0,
isLast: index == items.length - 1,
hasCheckbox: canEdit,
onCheckBoxTap: item.isFrozen
? null
: () async {
+1 -1
View File
@@ -524,7 +524,7 @@ class _NewSendPageState extends State<NewSendPage> {
label: "Coin Control",
onTap: () {
showCupertinoModalBottomSheet(enableDrag: false, useRootNavigator: true, isDismissible: false, context: context, builder: (context){
return NewCoinControlPage(unspentCoinsListViewModel: widget.sendViewModel.unspentCoinsListViewModel,);
return NewCoinControlPage(unspentCoinsListViewModel: widget.sendViewModel.unspentCoinsListViewModel,canEdit: true);
});
}
),
@@ -17,7 +17,7 @@ class CoinControlListItem extends StatelessWidget {
required this.isFirst,
required this.isLast,
required this.isLoading,
this.onCheckBoxTap,
this.onCheckBoxTap, required this.hasCheckbox,
});
final String note;
@@ -31,6 +31,7 @@ class CoinControlListItem extends StatelessWidget {
final bool isFirst;
final bool isLast;
final bool isLoading;
final bool hasCheckbox;
final Function()? onCheckBoxTap;
@override
@@ -123,6 +124,10 @@ class CoinControlListItem extends StatelessWidget {
return CakeImageWidget(imageUrl:"assets/new-ui/frozen.svg");
}
if(!hasCheckbox) {
return SizedBox.shrink();
}
return NewSimpleCheckbox(value: isSending, onChanged: (value){onCheckBoxTap?.call();});
}
@@ -163,17 +163,17 @@ class CoinActionRow extends StatelessWidget {
late final PaymentRequest req;
var unspentCoinType = UnspentCoinType.any;
// if (false && SendViewModelBase.isNonZeroAmountLightningInvoice(code)) {
// unspentCoinType = UnspentCoinType.lightning;
// final amount = CryptoCurrency.btcln.formatAmount(BigInt.from(getBolt11Amount(code) ?? 0));
// req = PaymentRequest(code, amount, "", "", "");
// } else if (false && SendViewModelBase.isLnurlInvoice(code)) {
// unspentCoinType = UnspentCoinType.lightning;
// final amount = CryptoCurrency.btcln.formatAmount(BigInt.from(await LNURL.getPayRequestAmount(code) ?? 0));
// req = PaymentRequest(code, amount, "", "", "");
// } else if (false &&OpenCryptoPayService.isOpenCryptoPayQR(code)) {
// req = PaymentRequest(code, "", "", "", "");
// } else
if (SendViewModelBase.isNonZeroAmountLightningInvoice(code)) {
unspentCoinType = UnspentCoinType.lightning;
final amount = CryptoCurrency.btcln.formatAmount(BigInt.from(getBolt11Amount(code) ?? 0));
req = PaymentRequest(code, amount, "", "", "");
} else if (SendViewModelBase.isLnurlInvoice(code)) {
unspentCoinType = UnspentCoinType.lightning;
final amount = CryptoCurrency.btcln.formatAmount(BigInt.from(await LNURL.getPayRequestAmount(code) ?? 0));
req = PaymentRequest(code, amount, "", "", "");
} else if (OpenCryptoPayService.isOpenCryptoPayQR(code)) {
req = PaymentRequest(code, "", "", "", "");
} else
if (Uri.tryParse(code)?.scheme == "wc") {
if (!isWalletConnectCompatibleChain(walletType)) {
showPopUp<void>(
@@ -102,7 +102,7 @@ class SwapOptionsPage extends StatelessWidget {
label: "Coin Control",
onTap: () {
showCupertinoModalBottomSheet(enableDrag: false, useRootNavigator: true, isDismissible: false, context: context, builder: (context){
return NewCoinControlPage(unspentCoinsListViewModel: exchangeViewModel.unspentCoinsListViewModel,);
return NewCoinControlPage(unspentCoinsListViewModel: exchangeViewModel.unspentCoinsListViewModel,canEdit: true,);
});
}),
ListItemSelector(
+3 -6
View File
@@ -759,12 +759,9 @@ Route<dynamic> createRoute(RouteSettings settings) {
);
case Routes.unspentCoinsList:
final coinTypeToSpendFrom = settings.arguments as UnspentCoinType?;
return handleRouteWithPlatformAwareness(
(context) => FeatureFlag.hasNewUi
? getIt.get<NewCoinControlPage>(param1: coinTypeToSpendFrom)
: getIt.get<UnspentCoinsListPage>(param1: coinTypeToSpendFrom),
);
final args = settings.arguments as CoinControlPageArgs?;
return handleRouteWithPlatformAwareness((context) =>
getIt.get<NewCoinControlPage>(param1: args?.coinTypeToSpendFrom, param2: args?.canEdit));
case Routes.unspentCoinsDetails:
final args = settings.arguments as List;
@@ -135,69 +135,73 @@ class WalletTypeFormState extends State<WalletTypeForm> {
fontSize: 14,
),
),
Flexible(
child: Padding(
padding: const EdgeInsets.only(left: 18, right: 18, bottom: 12),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(18),
),
child: ListView.separated(
key: const ValueKey('new_wallet_type_scrollable_key'),
itemCount: filteredTypes.length,
shrinkWrap: true,
itemBuilder: (context, index) {
final item = filteredTypes[index];
return GestureDetector(
key: ValueKey('new_wallet_type_${item.name}_button_key'),
behavior: HitTestBehavior.opaque,
onTap: () {
onTypeSelected(item);
},
child: SizedBox(
height: 48,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
CakeImageWidget(
height: 24,
width: 24,
imageUrl: getCryptoCurrencyIconForWalletListItem(item),
),
const SizedBox(width: 12),
Text(walletTypeToDisplayName(item)),
const SizedBox(width: 4),
Text(
walletTypeToDisplayTicker(item),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.only(left: 18, right: 18, bottom: 12),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(18),
),
child: ListView.separated(
key: const ValueKey('new_wallet_type_scrollable_key'),
itemCount: filteredTypes.length,
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
itemBuilder: (context, index) {
final item = filteredTypes[index];
return GestureDetector(
key: ValueKey('new_wallet_type_${item.name}_button_key'),
behavior: HitTestBehavior.opaque,
onTap: () {
onTypeSelected(item);
},
child: SizedBox(
height: 48,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
CakeImageWidget(
height: 24,
width: 24,
imageUrl: getCryptoCurrencyIconForWalletListItem(item),
),
)
],
),
CakeImageWidget(
imageUrl: "assets/new-ui/arrow_forward.svg",
height: 16,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant,
BlendMode.srcIn,
const SizedBox(width: 12),
Text(walletTypeToDisplayName(item)),
const SizedBox(width: 4),
Text(
walletTypeToDisplayTicker(item),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
],
),
)
],
CakeImageWidget(
imageUrl: "assets/new-ui/arrow_forward.svg",
height: 16,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant,
BlendMode.srcIn,
),
)
],
),
),
),
),
);
},
separatorBuilder: (context, index) => Container(
height: 1,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
);
},
separatorBuilder: (context, index) => Container(
height: 1,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
),
),
),
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:cake_wallet/bitcoin/bitcoin.dart';
import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart';
import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_selector.dart';
@@ -14,11 +16,13 @@ import 'package:cake_wallet/utils/feature_flag.dart';
import 'package:cake_wallet/utils/share_util.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cake_wallet/view_model/settings/other_settings_view_model.dart';
import 'package:cw_core/root_dir.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/transaction_priority.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:cw_core/db/sqlite.dart';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
class OtherSettingsPage extends BasePage {
OtherSettingsPage(this._otherSettingsViewModel) {
@@ -211,6 +215,14 @@ class OtherSettingsPage extends BasePage {
),
);
}),
ListItemRegularRow(
keyValue: '[dev] fake corrupt sqlite db',
label: '[dev] fake corrupt sqlite db',
onTap: () async {
final dbDebugMarker = await sqliteDebugMarkerFile();
dbDebugMarker.create();
}
),
]
}),
);
+2 -1
View File
@@ -1,6 +1,7 @@
import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart';
import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_toggle.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/new-ui/pages/coin_control_page.dart';
import 'package:cake_wallet/new-ui/widgets/modal_header.dart';
import 'package:cake_wallet/new-ui/widgets/modal_page_wrapper.dart';
import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
@@ -95,7 +96,7 @@ class PrivacyPage extends BasePage {
keyValue: "coin_control",
label: "Coin Control",
onTap: () =>
Navigator.of(context).pushNamed(Routes.unspentCoinsList)),
Navigator.of(context).pushNamed(Routes.unspentCoinsList, arguments: CoinControlPageArgs(canEdit: false, coinTypeToSpendFrom: null))),
],
}
),
+1
View File
@@ -208,6 +208,7 @@
"close": "Close",
"coin_control": "Coin Control (optional)",
"coin_control_desc": "Filter outputs for this transaction. The changes you make here will only reflect on the current transaction.",
"coin_control_desc_no_edit": "Freeze outputs if needed. The changes will be persistent across all transactions. You can also filter outputs for one transaction on the Send or Swap screens.",
"coin_control_load_failed": "Failed to load unspent coins",
"cold_or_recover_wallet": "Add an air-gapped cold wallet or recover a paper wallet.",
"collection_address": "Collection Address",