Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ import workmanager_apple
) -> Bool {
GeneratedPluginRegistrant.register(with: self)

// The local SQLite databases are encrypted with a key held in the
// keychain under `first_unlock_this_device`, which is never restored
// onto a different device. An iCloud/iTunes backup that carried the
// databases but not the key would restore a database nothing can
// open, so the databases are marked "do not back up" instead. See
// `lib/core/storage/backup_exclusion.dart` for the full rationale.
//
// Registered only on the main engine: the workmanager background
// engine never needs it, and the sweep is driven from the
// foreground composition root.
if let controller = window?.rootViewController as? FlutterViewController {
BackupExclusion.register(messenger: controller.binaryMessenger)
}

// workmanager_apple spawns a separate FlutterEngine per background task
// (see BackgroundWorker.swift in workmanager_apple). Plugins registered
// against `self` above only attach to the main app's engine — the BG
Expand Down Expand Up @@ -43,3 +57,69 @@ import workmanager_apple
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

/// Sets `URLResourceValues.isExcludedFromBackup` on the paths Dart hands
/// us.
///
/// Lives in `AppDelegate.swift` rather than its own file on purpose:
/// a new `.swift` file has to be added to `project.pbxproj` to be
/// compiled, and a hand-edited pbxproj that silently drops the file
/// would leave the exclusion a no-op with no build error to catch it.
enum BackupExclusion {
static func register(messenger: FlutterBinaryMessenger) {
let channel = FlutterMethodChannel(
name: "bullbitcoin.com/backup_exclusion",
binaryMessenger: messenger
)
channel.setMethodCallHandler { call, result in
guard call.method == "excludeFromBackup" else {
result(FlutterMethodNotImplemented)
return
}
guard
let arguments = call.arguments as? [String: Any],
let paths = arguments["paths"] as? [String]
else {
result(
FlutterError(
code: "bad-arguments",
message: "excludeFromBackup expects a `paths` list of strings",
details: nil
)
)
return
}

var failures: [String] = []
for path in paths {
// Setting the flag on a file that doesn't exist throws, and Dart
// filters by existence before calling — but the two checks race
// across the channel hop, so re-check here.
guard FileManager.default.fileExists(atPath: path) else { continue }
var url = URL(fileURLWithPath: path)
var values = URLResourceValues()
values.isExcludedFromBackup = true
do {
try url.setResourceValues(values)
} catch {
// Report the failing basename only: the container path
// contains an install-scoped UUID and this string can end up
// in a log the user shares.
failures.append(url.lastPathComponent)
}
}

if failures.isEmpty {
result(nil)
} else {
result(
FlutterError(
code: "exclude-failed",
message: "Could not exclude: \(failures.joined(separator: ", "))",
details: nil
)
)
}
}
}
}
32 changes: 30 additions & 2 deletions lib/core/background_tasks/handler.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'package:bb_mobile/core/background_tasks/tasks.dart';
import 'package:bb_mobile/core/storage/sqlite_database.dart';
import 'package:bb_mobile/core/storage/data/datasources/key_value_storage/keychain_locked_exception.dart';
import 'package:bb_mobile/core/storage/database_encryption_key_store.dart';
import 'package:bb_mobile/core/swaps/domain/usecases/process_ongoing_swaps_usecase.dart';
import 'package:bb_mobile/core/utils/logger.dart' show log;
import 'package:bb_mobile/core/wallet/domain/usecases/get_wallets_usecase.dart';
Expand Down Expand Up @@ -33,7 +35,28 @@ Future<bool> tasksHandler(String task) async {
await Bull.initFlutterRustBridgeDependencies();

try {
final driftIsolate = await SqliteDatabase.createIsolateWithSpawn();
// `loadExisting` never creates a key: a background task can fire
// before the user has ever opened the app on this install, and
// minting a key here would either race the foreground boot or
// write a key that doesn't match an existing encrypted database.
final String? databaseKey;
try {
databaseKey = await DatabaseEncryptionKeyStore.loadExisting();
} on KeychainLockedException {
// iOS can fire a BGTask before the device has been unlocked since
// boot, at which point a `first_unlock_this_device` item is
// unreadable. That is "come back later", not "the key is gone" —
// return false so workmanager reschedules, and touch nothing.
log.warning('Background task skipped: keychain locked, will retry');
return false;
}
if (databaseKey == null) {
log.warning('Background task skipped before database key initialization');
return false;
}
final driftIsolate = await SqliteDatabase.createIsolateWithSpawn(
databaseKey,
);
final sqlite = SqliteDatabase(
await driftIsolate.connect(singleClientMode: true),
);
Expand All @@ -44,7 +67,12 @@ Future<bool> tasksHandler(String task) async {
// isolate, run the legacy migration and the full recovery sweep —
// concurrently with the foreground engine on the same database, inside a
// ~30s iOS background budget.
await AppLocator.setup(locator, sqlite, startPayjoinRecovery: false);
await AppLocator.setup(
locator,
sqlite,
databaseKey: databaseKey,
startPayjoinRecovery: false,
);

final syncWalletUsecase = locator<SyncWalletUsecase>();
final getWalletsUsecase = locator<GetWalletsUsecase>();
Expand Down
204 changes: 57 additions & 147 deletions lib/core/screens/app_init_error_screen.dart
Original file line number Diff line number Diff line change
@@ -1,29 +1,20 @@
import 'package:bb_mobile/core/settings/domain/settings_entity.dart';
import 'package:bb_mobile/core/screens/pre_init_scaffold.dart';
import 'package:bb_mobile/core/themes/app_theme.dart';
import 'package:bb_mobile/core/utils/constants.dart';
import 'package:bb_mobile/core/utils/logger.dart';
import 'package:bb_mobile/core/widgets/app_language_picker.dart';
import 'package:bb_mobile/core/widgets/buttons/button.dart';
import 'package:bb_mobile/core/widgets/snackbar_utils.dart';
import 'package:bb_mobile/generated/l10n/localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:bull_ui/bull_ui.dart' show Gap;
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';

class AppInitErrorScreen extends StatefulWidget {
class AppInitErrorScreen extends StatelessWidget {
const AppInitErrorScreen({super.key, required this.error});

final Object error;

@override
State<AppInitErrorScreen> createState() => _AppInitErrorScreenState();
}

class _AppInitErrorScreenState extends State<AppInitErrorScreen> {
Language _language = Language.fromKeyboard();

Future<void> _shareLogs(BuildContext context) async {
try {
final logs = await log.readLogs();
Expand Down Expand Up @@ -61,157 +52,76 @@ class _AppInitErrorScreenState extends State<AppInitErrorScreen> {

@override
Widget build(BuildContext context) {
final loc = lookupAppLocalizations(_language.locale);
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: AppTheme.themeData(AppThemeType.dark),
locale: _language.locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) {
// Seed Device.screen so AppLanguagePicker / TranslationWarningBottomSheet
// (which read it synchronously) work on this pre-init screen.
Device.init(context);
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
loc.appInitErrorTitle,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 12),
AppLanguagePicker(
value: _language,
onChanged: (lang) => setState(() => _language = lang),
),
],
),
const Gap(24),
_BackupSection(
asset: 'assets/misc/undraw_secure-usb-drive.svg',
title: loc.appInitErrorHasBackupTitle,
message: loc.appInitErrorHasBackupMessage,
),
const Gap(32),
Divider(color: context.appColors.border),
const Gap(32),
_BackupSection(
asset: 'assets/misc/undraw_forgot-password.svg',
title: loc.appInitErrorNoBackupTitle,
message: loc.appInitErrorNoBackupMessage,
),
const Gap(32),
BBButton.big(
label: loc.appInitErrorContactSupportButton,
iconData: Icons.open_in_new,
bgColor: context.appColors.primary,
textColor: context.appColors.onPrimary,
onPressed: _contactSupport,
),
const Gap(12),
BBButton.big(
label: loc.appInitErrorShareLogsButton,
iconData: Icons.share,
iconFirst: true,
bgColor: context.appColors.surface,
textColor: context.appColors.text,
borderColor: context.appColors.border,
outlined: true,
onPressed: () => _shareLogs(context),
),
const Gap(12),
BBButton.big(
label: loc.deleteLogsTitle,
iconData: Icons.delete_outline,
iconFirst: true,
bgColor: context.appColors.surface,
textColor: context.appColors.error,
borderColor: context.appColors.error,
outlined: true,
onPressed: () => _deleteLogs(context, loc),
),
const Gap(16),
_ErrorDetails(
error: widget.error,
label: loc.appInitErrorDetailsToggle,
),
],
),
),
),
);
},
),
);
}
}

class _BackupSection extends StatelessWidget {
const _BackupSection({
required this.asset,
required this.title,
required this.message,
});

final String asset;
final String title;
final String message;

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: SvgPicture.asset(
asset,
height: 120,
fit: BoxFit.contain,
placeholderBuilder: (_) => const SizedBox(height: 120),
),
return PreInitScaffold(
title: (loc) => loc.appInitErrorTitle,
builder: (context, loc) => [
PreInitIllustration(
asset: 'assets/misc/undraw_secure-usb-drive.svg',
title: loc.appInitErrorHasBackupTitle,
message: loc.appInitErrorHasBackupMessage,
),
const Gap(16),
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
const Gap(32),
Divider(color: context.appColors.border),
const Gap(32),
PreInitIllustration(
asset: 'assets/misc/undraw_forgot-password.svg',
title: loc.appInitErrorNoBackupTitle,
message: loc.appInitErrorNoBackupMessage,
),
const Gap(8),
Text(
message,
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
const Gap(32),
BBButton.big(
label: loc.appInitErrorContactSupportButton,
iconData: Icons.open_in_new,
bgColor: context.appColors.primary,
textColor: context.appColors.onPrimary,
onPressed: _contactSupport,
),
const Gap(12),
BBButton.big(
label: loc.appInitErrorShareLogsButton,
iconData: Icons.share,
iconFirst: true,
bgColor: context.appColors.surface,
textColor: context.appColors.text,
borderColor: context.appColors.border,
outlined: true,
onPressed: () => _shareLogs(context),
),
const Gap(12),
BBButton.big(
label: loc.deleteLogsTitle,
iconData: Icons.delete_outline,
iconFirst: true,
bgColor: context.appColors.surface,
textColor: context.appColors.error,
borderColor: context.appColors.error,
outlined: true,
onPressed: () => _deleteLogs(context, loc),
),
const Gap(16),
ErrorDetailsPanel(error: error, label: loc.appInitErrorDetailsToggle),
],
);
}
}

class _ErrorDetails extends StatefulWidget {
const _ErrorDetails({required this.error, required this.label});
/// Collapsible raw-error panel. Developer detail, shown only when the
/// user opens it — the screens themselves lead with a localized message.
class ErrorDetailsPanel extends StatefulWidget {
const ErrorDetailsPanel({
super.key,
required this.error,
required this.label,
});

final Object error;
final String label;

@override
State<_ErrorDetails> createState() => _ErrorDetailsState();
State<ErrorDetailsPanel> createState() => _ErrorDetailsPanelState();
}

class _ErrorDetailsState extends State<_ErrorDetails> {
class _ErrorDetailsPanelState extends State<ErrorDetailsPanel> {
bool _expanded = false;

@override
Expand Down
Loading
Loading