Skip to content
Merged
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
222 changes: 143 additions & 79 deletions CHANGELOG.md

Large diffs are not rendered by default.

24 changes: 12 additions & 12 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -488,11 +488,11 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 178;
CURRENT_PROJECT_VERSION = 184;
DEVELOPMENT_TEAM = BX99T32YGS;
ENABLE_BITCODE = NO;
FLUTTER_BUILD_NAME = 6.10.0;
FLUTTER_BUILD_NUMBER = 178;
FLUTTER_BUILD_NAME = 6.10.6;
FLUTTER_BUILD_NUMBER = 184;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = BULL;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
Expand All @@ -501,7 +501,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 6.10.0;
MARKETING_VERSION = 6.10.6;
PRODUCT_BUNDLE_IDENTIFIER = com.bullbitcoin.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
Expand Down Expand Up @@ -679,11 +679,11 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 178;
CURRENT_PROJECT_VERSION = 184;
DEVELOPMENT_TEAM = BX99T32YGS;
ENABLE_BITCODE = NO;
FLUTTER_BUILD_NAME = 6.10.0;
FLUTTER_BUILD_NUMBER = 178;
FLUTTER_BUILD_NAME = 6.10.6;
FLUTTER_BUILD_NUMBER = 184;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = BULL;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
Expand All @@ -692,7 +692,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 6.10.0;
MARKETING_VERSION = 6.10.6;
PRODUCT_BUNDLE_IDENTIFIER = com.bullbitcoin.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
Expand All @@ -708,11 +708,11 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 178;
CURRENT_PROJECT_VERSION = 184;
DEVELOPMENT_TEAM = BX99T32YGS;
ENABLE_BITCODE = NO;
FLUTTER_BUILD_NAME = 6.10.0;
FLUTTER_BUILD_NUMBER = 178;
FLUTTER_BUILD_NAME = 6.10.6;
FLUTTER_BUILD_NUMBER = 184;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = BULL;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
Expand All @@ -721,7 +721,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 6.10.0;
MARKETING_VERSION = 6.10.6;
PRODUCT_BUNDLE_IDENTIFIER = com.bullbitcoin.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
Expand Down
14 changes: 14 additions & 0 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ import workmanager_apple
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)

// 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
// engine starts with an empty plugin registry. Without this callback,
// every platform-channel call from tasksHandler (shared_preferences,
// flutter_secure_storage, drift, lwk, etc.) fails with `channel-error`
// "Unable to establish connection on channel: ...". Registering the
// generated registrant against the BG engine makes all plugins usable
// in the BG isolate.
WorkmanagerPlugin.setPluginRegistrantCallback { registry in
GeneratedPluginRegistrant.register(with: registry)
}

WorkmanagerPlugin.registerPeriodicTask(
withIdentifier: "com.bullbitcoin.mobile.bitcoin-sync-id",
frequency: NSNumber(value: 20 * 60)
Expand Down
15 changes: 14 additions & 1 deletion lib/core/background_tasks/handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ void backgroundTasksHandler() {
Future<bool> tasksHandler(String task) async {
final startTime = DateTime.now();

await Bull.initLogs();
await Bull.initLogs(background: true);
// Note: `Report.init` is intentionally NOT called here. The BG isolate
// has its own Dart-side Sentry hub but the native plugin (Sentry
// Android / iOS) is a process-level singleton. Calling
Expand Down Expand Up @@ -81,5 +81,18 @@ Future<bool> tasksHandler(String task) async {
trace: StackTrace.current,
);
return Future.value(false);
} finally {
// iOS tears down the BG `FlutterEngine` shortly after we return.
// Force the Dart-side IOSink buffer to disk now, otherwise any
// non-SEVERE writes (the success line above, every `log.fine`
// from the task body) are abandoned with the dying isolate and
// never appear in `bull_background_logs.tsv`. Foreground writes
// get flushed implicitly by `readLogs()`'s own `await flush()`;
// the BG sink has no equivalent trigger so it must flush itself.
//
// `log.flush()` swallows its own errors internally
// (see `_enqueue` in logger.dart), so it can't change the return
// value or throw past the `finally`.
await log.flush();
}
}
30 changes: 19 additions & 11 deletions lib/core/background_tasks/tasks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,26 @@ enum BackgroundTask {
final String id;
const BackgroundTask(this.name, this.id);

/// Resolves a task by EITHER its short name OR its iOS BGTaskScheduler
/// identifier. The two forms reach `executeTask` depending on platform:
///
/// - **Android** (`workmanager_android`): forwards `request.taskName`
/// (the second arg of `Workmanager().registerPeriodicTask(uniqueName,
/// taskName)`), so we receive the short name like `"logs-prune"`.
/// - **iOS** (`workmanager_apple`): forwards the BGTaskScheduler
/// identifier registered in `AppDelegate.swift`, so we receive
/// `"com.bullbitcoin.mobile.logs-prune-id"`.
///
/// The asymmetry is undocumented in the workmanager README but
/// confirmed by reading `workmanager_apple/BackgroundWorker.swift`
/// (passes `identifier` to Dart) vs `workmanager_android` (passes
/// `taskName`). Several open issues track confusion around it
/// (#396, #450, #524). Accepting either form makes the dispatch
/// platform-agnostic without per-platform glue at the call site.
static BackgroundTask fromName(String name) {
switch (name) {
case 'bitcoin-sync':
return BackgroundTask.bitcoinSync;
case 'liquid-sync':
return BackgroundTask.liquidSync;
case 'swaps-sync':
return BackgroundTask.swapsSync;
case 'logs-prune':
return BackgroundTask.logsPrune;
default:
throw Exception('Unknown Background Task: $name');
for (final task in BackgroundTask.values) {
if (task.name == name || task.id == name) return task;
}
throw Exception('Unknown Background Task: $name');
}
}
36 changes: 33 additions & 3 deletions lib/core/seed/data/datasources/seed_datasource.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:bb_mobile/core/errors/bull_exception.dart';
import 'package:bb_mobile/core/seed/data/models/seed_model.dart';
import 'package:bb_mobile/core/storage/data/datasources/key_value_storage/key_value_storage_datasource.dart';
import 'package:bb_mobile/core/storage/data/datasources/key_value_storage/keychain_locked_exception.dart';
import 'package:bb_mobile/core/utils/constants.dart';
import 'package:bb_mobile/core/utils/logger.dart';
import 'package:flutter/foundation.dart';
Expand Down Expand Up @@ -54,9 +55,38 @@ class SeedDatasource {
'Seed not found for fingerprint: $fingerprint',
);
} catch (e) {
if (e is SeedNotFoundException) {
rethrow;
}
if (e is SeedNotFoundException) rethrow;

// CRITICAL: rethrow KeychainLockedException without retrying or
// converting it.
//
// The iOS Keychain returns `errSecInteractionNotAllowed` (-25308)
// when the device has not been unlocked since boot AND the
// item's accessibility class requires post-unlock access (BULL
// uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` —
// see `lib/core/storage/storage_locator.dart`). The secure
// storage layer maps this to `KeychainLockedException` (see
// `keychain_locked_exception.dart`).
//
// Why this matters here specifically:
// - The retry loop below cannot help. The lock state clears
// only on user unlock action, not on backoff; retrying 5×
// with exponential delay just burns ~9.6s of wall clock
// while every attempt hits the same locked keychain.
// - The fallback at the end of this catch throws
// `SeedNotFoundException`. If we let `KeychainLockedException`
// flow through that path, downstream code (e.g.
// `CheckForExistingDefaultWalletsUsecase`,
// `RequiresMigrationUsecase`) interprets the result as
// "wallet seed is missing" and may trigger destructive
// recovery flows for what is actually a transient, self-
// healing state (resolves when the user unlocks).
//
// Letting the typed exception bubble up to the UI is the
// correct behavior — the UI can surface a "device just
// unlocked, please retry" prompt or simply re-call the
// operation on next state change.
if (e is KeychainLockedException) rethrow;

if (attempt < maxRetries - 1) {
final delay = Duration(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,38 +1,108 @@
import 'package:bb_mobile/core/storage/data/datasources/key_value_storage/key_value_storage_datasource.dart';
import 'package:bb_mobile/core/storage/data/datasources/key_value_storage/keychain_locked_exception.dart';
import 'package:bb_mobile/core/utils/logger.dart' show log;
import 'package:flutter/services.dart' show PlatformException;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

/// File-private operation labels for keychain refusal log lines.
/// Closed set so the impl can't drift into free-form strings; the
/// twin file `secure_storage_legacy_datasource_impl.dart` has its
/// own copy (intentionally — the two impls wrap distinct plugin
/// `PlatformException` types and share no code).
enum _Operation { read, write, delete, contains, readAll, deleteAll }

/// iOS keychain `OSStatus` for `errSecInteractionNotAllowed`. Returned
/// by `SecItemCopyMatching` / `SecItemAdd` when the item's accessibility
/// class requires the device to be unlocked (or to have been unlocked
/// since boot) and the current state doesn't satisfy that. See
/// [KeychainLockedException].
const int _errSecInteractionNotAllowed = -25308;

class SecureStorageDatasourceImpl implements KeyValueStorageDatasource<String> {
final FlutterSecureStorage _storage;

SecureStorageDatasourceImpl(this._storage);

/// Wraps a keychain call, mapping iOS `-25308` to
/// [KeychainLockedException] so callers can distinguish a
/// temporarily-locked keychain from a missing key or other failure.
/// Other [PlatformException]s rethrow unchanged.
Future<T> _wrap<T>({
required _Operation operation,
String? key,
required Future<T> Function() body,
}) async {
try {
return await body();
} on PlatformException catch (e) {
// Belt-and-suspenders: across `flutter_secure_storage` releases,
// the OSStatus has historically appeared in `details` (current
// fork), `code` (older versions, as a string), or embedded in
// `message`. Match all three so a future fork bump that shifts
// the field doesn't silently regress this whole class of
// handling without a compile error.
if (_isLocked(e)) {
final target = key != null ? ' "$key"' : '';
log.warning(
'Device not unlocked since boot (${operation.name}$target)',
);
throw const KeychainLockedException();
}
rethrow;
}
}

bool _isLocked(PlatformException e) =>
e.details == _errSecInteractionNotAllowed ||
e.code == '$_errSecInteractionNotAllowed' ||
(e.message ?? '').contains('$_errSecInteractionNotAllowed');

@override
Future<void> saveValue({required String key, required String value}) async {
await _storage.write(key: key, value: value);
Future<void> saveValue({required String key, required String value}) {
return _wrap(
operation: _Operation.write,
key: key,
body: () => _storage.write(key: key, value: value),
);
}

@override
Future<Map<String, String>> getAll() {
return _storage.readAll();
return _wrap(operation: _Operation.readAll, body: () => _storage.readAll());
}

@override
Future<String?> getValue(String key) {
return _storage.read(key: key);
return _wrap(
operation: _Operation.read,
key: key,
body: () => _storage.read(key: key),
);
}

@override
Future<bool> hasValue(String key) {
return _storage.containsKey(key: key);
return _wrap(
operation: _Operation.contains,
key: key,
body: () => _storage.containsKey(key: key),
);
}

@override
Future<void> deleteValue(String key) async {
await _storage.delete(key: key);
Future<void> deleteValue(String key) {
return _wrap(
operation: _Operation.delete,
key: key,
body: () => _storage.delete(key: key),
);
}

@override
Future<void> deleteAll() async {
await _storage.deleteAll();
Future<void> deleteAll() {
return _wrap(
operation: _Operation.deleteAll,
body: () => _storage.deleteAll(),
);
}
}
Loading