Skip to content

Commit 66d687a

Browse files
committed
Merge branch 'main' into develop for v6.11.0 release
Resolve conflicts: - pubspec.yaml: keep 6.11.0+185 - lib/main.dart: keep SyncCoordinator-based lifecycle handler, re-add 6.10.6 inactive-state log flush - CHANGELOG.md: keep 6.11.0 entry plus main's 6.10.6/6.10.1 release notes
2 parents a985297 + d40cad2 commit 66d687a

20 files changed

Lines changed: 919 additions & 300 deletions

File tree

CHANGELOG.md

Lines changed: 143 additions & 79 deletions
Large diffs are not rendered by default.

ios/Runner.xcodeproj/project.pbxproj

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -488,11 +488,11 @@
488488
buildSettings = {
489489
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
490490
CLANG_ENABLE_MODULES = YES;
491-
CURRENT_PROJECT_VERSION = 178;
491+
CURRENT_PROJECT_VERSION = 184;
492492
DEVELOPMENT_TEAM = BX99T32YGS;
493493
ENABLE_BITCODE = NO;
494-
FLUTTER_BUILD_NAME = 6.10.0;
495-
FLUTTER_BUILD_NUMBER = 178;
494+
FLUTTER_BUILD_NAME = 6.10.6;
495+
FLUTTER_BUILD_NUMBER = 184;
496496
INFOPLIST_FILE = Runner/Info.plist;
497497
INFOPLIST_KEY_CFBundleDisplayName = BULL;
498498
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
@@ -501,7 +501,7 @@
501501
"$(inherited)",
502502
"@executable_path/Frameworks",
503503
);
504-
MARKETING_VERSION = 6.10.0;
504+
MARKETING_VERSION = 6.10.6;
505505
PRODUCT_BUNDLE_IDENTIFIER = com.bullbitcoin.app;
506506
PRODUCT_NAME = "$(TARGET_NAME)";
507507
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -679,11 +679,11 @@
679679
buildSettings = {
680680
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
681681
CLANG_ENABLE_MODULES = YES;
682-
CURRENT_PROJECT_VERSION = 178;
682+
CURRENT_PROJECT_VERSION = 184;
683683
DEVELOPMENT_TEAM = BX99T32YGS;
684684
ENABLE_BITCODE = NO;
685-
FLUTTER_BUILD_NAME = 6.10.0;
686-
FLUTTER_BUILD_NUMBER = 178;
685+
FLUTTER_BUILD_NAME = 6.10.6;
686+
FLUTTER_BUILD_NUMBER = 184;
687687
INFOPLIST_FILE = Runner/Info.plist;
688688
INFOPLIST_KEY_CFBundleDisplayName = BULL;
689689
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
@@ -692,7 +692,7 @@
692692
"$(inherited)",
693693
"@executable_path/Frameworks",
694694
);
695-
MARKETING_VERSION = 6.10.0;
695+
MARKETING_VERSION = 6.10.6;
696696
PRODUCT_BUNDLE_IDENTIFIER = com.bullbitcoin.app;
697697
PRODUCT_NAME = "$(TARGET_NAME)";
698698
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -708,11 +708,11 @@
708708
buildSettings = {
709709
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
710710
CLANG_ENABLE_MODULES = YES;
711-
CURRENT_PROJECT_VERSION = 178;
711+
CURRENT_PROJECT_VERSION = 184;
712712
DEVELOPMENT_TEAM = BX99T32YGS;
713713
ENABLE_BITCODE = NO;
714-
FLUTTER_BUILD_NAME = 6.10.0;
715-
FLUTTER_BUILD_NUMBER = 178;
714+
FLUTTER_BUILD_NAME = 6.10.6;
715+
FLUTTER_BUILD_NUMBER = 184;
716716
INFOPLIST_FILE = Runner/Info.plist;
717717
INFOPLIST_KEY_CFBundleDisplayName = BULL;
718718
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
@@ -721,7 +721,7 @@
721721
"$(inherited)",
722722
"@executable_path/Frameworks",
723723
);
724-
MARKETING_VERSION = 6.10.0;
724+
MARKETING_VERSION = 6.10.6;
725725
PRODUCT_BUNDLE_IDENTIFIER = com.bullbitcoin.app;
726726
PRODUCT_NAME = "$(TARGET_NAME)";
727727
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";

ios/Runner/AppDelegate.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,20 @@ import workmanager_apple
99
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
1010
) -> Bool {
1111
GeneratedPluginRegistrant.register(with: self)
12+
13+
// workmanager_apple spawns a separate FlutterEngine per background task
14+
// (see BackgroundWorker.swift in workmanager_apple). Plugins registered
15+
// against `self` above only attach to the main app's engine — the BG
16+
// engine starts with an empty plugin registry. Without this callback,
17+
// every platform-channel call from tasksHandler (shared_preferences,
18+
// flutter_secure_storage, drift, lwk, etc.) fails with `channel-error`
19+
// "Unable to establish connection on channel: ...". Registering the
20+
// generated registrant against the BG engine makes all plugins usable
21+
// in the BG isolate.
22+
WorkmanagerPlugin.setPluginRegistrantCallback { registry in
23+
GeneratedPluginRegistrant.register(with: registry)
24+
}
25+
1226
WorkmanagerPlugin.registerPeriodicTask(
1327
withIdentifier: "com.bullbitcoin.mobile.bitcoin-sync-id",
1428
frequency: NSNumber(value: 20 * 60)

lib/core/background_tasks/handler.dart

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ void backgroundTasksHandler() {
1919
Future<bool> tasksHandler(String task) async {
2020
final startTime = DateTime.now();
2121

22-
await Bull.initLogs();
22+
await Bull.initLogs(background: true);
2323
// Note: `Report.init` is intentionally NOT called here. The BG isolate
2424
// has its own Dart-side Sentry hub but the native plugin (Sentry
2525
// Android / iOS) is a process-level singleton. Calling
@@ -80,5 +80,18 @@ Future<bool> tasksHandler(String task) async {
8080
trace: StackTrace.current,
8181
);
8282
return Future.value(false);
83+
} finally {
84+
// iOS tears down the BG `FlutterEngine` shortly after we return.
85+
// Force the Dart-side IOSink buffer to disk now, otherwise any
86+
// non-SEVERE writes (the success line above, every `log.fine`
87+
// from the task body) are abandoned with the dying isolate and
88+
// never appear in `bull_background_logs.tsv`. Foreground writes
89+
// get flushed implicitly by `readLogs()`'s own `await flush()`;
90+
// the BG sink has no equivalent trigger so it must flush itself.
91+
//
92+
// `log.flush()` swallows its own errors internally
93+
// (see `_enqueue` in logger.dart), so it can't change the return
94+
// value or throw past the `finally`.
95+
await log.flush();
8396
}
8497
}

lib/core/background_tasks/tasks.dart

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,26 @@ enum BackgroundTask {
88
final String id;
99
const BackgroundTask(this.name, this.id);
1010

11+
/// Resolves a task by EITHER its short name OR its iOS BGTaskScheduler
12+
/// identifier. The two forms reach `executeTask` depending on platform:
13+
///
14+
/// - **Android** (`workmanager_android`): forwards `request.taskName`
15+
/// (the second arg of `Workmanager().registerPeriodicTask(uniqueName,
16+
/// taskName)`), so we receive the short name like `"logs-prune"`.
17+
/// - **iOS** (`workmanager_apple`): forwards the BGTaskScheduler
18+
/// identifier registered in `AppDelegate.swift`, so we receive
19+
/// `"com.bullbitcoin.mobile.logs-prune-id"`.
20+
///
21+
/// The asymmetry is undocumented in the workmanager README but
22+
/// confirmed by reading `workmanager_apple/BackgroundWorker.swift`
23+
/// (passes `identifier` to Dart) vs `workmanager_android` (passes
24+
/// `taskName`). Several open issues track confusion around it
25+
/// (#396, #450, #524). Accepting either form makes the dispatch
26+
/// platform-agnostic without per-platform glue at the call site.
1127
static BackgroundTask fromName(String name) {
12-
switch (name) {
13-
case 'bitcoin-sync':
14-
return BackgroundTask.bitcoinSync;
15-
case 'liquid-sync':
16-
return BackgroundTask.liquidSync;
17-
case 'swaps-sync':
18-
return BackgroundTask.swapsSync;
19-
case 'logs-prune':
20-
return BackgroundTask.logsPrune;
21-
default:
22-
throw Exception('Unknown Background Task: $name');
28+
for (final task in BackgroundTask.values) {
29+
if (task.name == name || task.id == name) return task;
2330
}
31+
throw Exception('Unknown Background Task: $name');
2432
}
2533
}

lib/core/seed/data/datasources/seed_datasource.dart

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:convert';
33
import 'package:bb_mobile/core/errors/bull_exception.dart';
44
import 'package:bb_mobile/core/seed/data/models/seed_model.dart';
55
import 'package:bb_mobile/core/storage/data/datasources/key_value_storage/key_value_storage_datasource.dart';
6+
import 'package:bb_mobile/core/storage/data/datasources/key_value_storage/keychain_locked_exception.dart';
67
import 'package:bb_mobile/core/utils/constants.dart';
78
import 'package:bb_mobile/core/utils/logger.dart';
89
import 'package:flutter/foundation.dart';
@@ -54,9 +55,38 @@ class SeedDatasource {
5455
'Seed not found for fingerprint: $fingerprint',
5556
);
5657
} catch (e) {
57-
if (e is SeedNotFoundException) {
58-
rethrow;
59-
}
58+
if (e is SeedNotFoundException) rethrow;
59+
60+
// CRITICAL: rethrow KeychainLockedException without retrying or
61+
// converting it.
62+
//
63+
// The iOS Keychain returns `errSecInteractionNotAllowed` (-25308)
64+
// when the device has not been unlocked since boot AND the
65+
// item's accessibility class requires post-unlock access (BULL
66+
// uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` —
67+
// see `lib/core/storage/storage_locator.dart`). The secure
68+
// storage layer maps this to `KeychainLockedException` (see
69+
// `keychain_locked_exception.dart`).
70+
//
71+
// Why this matters here specifically:
72+
// - The retry loop below cannot help. The lock state clears
73+
// only on user unlock action, not on backoff; retrying 5×
74+
// with exponential delay just burns ~9.6s of wall clock
75+
// while every attempt hits the same locked keychain.
76+
// - The fallback at the end of this catch throws
77+
// `SeedNotFoundException`. If we let `KeychainLockedException`
78+
// flow through that path, downstream code (e.g.
79+
// `CheckForExistingDefaultWalletsUsecase`,
80+
// `RequiresMigrationUsecase`) interprets the result as
81+
// "wallet seed is missing" and may trigger destructive
82+
// recovery flows for what is actually a transient, self-
83+
// healing state (resolves when the user unlocks).
84+
//
85+
// Letting the typed exception bubble up to the UI is the
86+
// correct behavior — the UI can surface a "device just
87+
// unlocked, please retry" prompt or simply re-call the
88+
// operation on next state change.
89+
if (e is KeychainLockedException) rethrow;
6090

6191
if (attempt < maxRetries - 1) {
6292
final delay = Duration(
Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,108 @@
11
import 'package:bb_mobile/core/storage/data/datasources/key_value_storage/key_value_storage_datasource.dart';
2+
import 'package:bb_mobile/core/storage/data/datasources/key_value_storage/keychain_locked_exception.dart';
3+
import 'package:bb_mobile/core/utils/logger.dart' show log;
4+
import 'package:flutter/services.dart' show PlatformException;
25
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
36

7+
/// File-private operation labels for keychain refusal log lines.
8+
/// Closed set so the impl can't drift into free-form strings; the
9+
/// twin file `secure_storage_legacy_datasource_impl.dart` has its
10+
/// own copy (intentionally — the two impls wrap distinct plugin
11+
/// `PlatformException` types and share no code).
12+
enum _Operation { read, write, delete, contains, readAll, deleteAll }
13+
14+
/// iOS keychain `OSStatus` for `errSecInteractionNotAllowed`. Returned
15+
/// by `SecItemCopyMatching` / `SecItemAdd` when the item's accessibility
16+
/// class requires the device to be unlocked (or to have been unlocked
17+
/// since boot) and the current state doesn't satisfy that. See
18+
/// [KeychainLockedException].
19+
const int _errSecInteractionNotAllowed = -25308;
20+
421
class SecureStorageDatasourceImpl implements KeyValueStorageDatasource<String> {
522
final FlutterSecureStorage _storage;
623

724
SecureStorageDatasourceImpl(this._storage);
825

26+
/// Wraps a keychain call, mapping iOS `-25308` to
27+
/// [KeychainLockedException] so callers can distinguish a
28+
/// temporarily-locked keychain from a missing key or other failure.
29+
/// Other [PlatformException]s rethrow unchanged.
30+
Future<T> _wrap<T>({
31+
required _Operation operation,
32+
String? key,
33+
required Future<T> Function() body,
34+
}) async {
35+
try {
36+
return await body();
37+
} on PlatformException catch (e) {
38+
// Belt-and-suspenders: across `flutter_secure_storage` releases,
39+
// the OSStatus has historically appeared in `details` (current
40+
// fork), `code` (older versions, as a string), or embedded in
41+
// `message`. Match all three so a future fork bump that shifts
42+
// the field doesn't silently regress this whole class of
43+
// handling without a compile error.
44+
if (_isLocked(e)) {
45+
final target = key != null ? ' "$key"' : '';
46+
log.warning(
47+
'Device not unlocked since boot (${operation.name}$target)',
48+
);
49+
throw const KeychainLockedException();
50+
}
51+
rethrow;
52+
}
53+
}
54+
55+
bool _isLocked(PlatformException e) =>
56+
e.details == _errSecInteractionNotAllowed ||
57+
e.code == '$_errSecInteractionNotAllowed' ||
58+
(e.message ?? '').contains('$_errSecInteractionNotAllowed');
59+
960
@override
10-
Future<void> saveValue({required String key, required String value}) async {
11-
await _storage.write(key: key, value: value);
61+
Future<void> saveValue({required String key, required String value}) {
62+
return _wrap(
63+
operation: _Operation.write,
64+
key: key,
65+
body: () => _storage.write(key: key, value: value),
66+
);
1267
}
1368

1469
@override
1570
Future<Map<String, String>> getAll() {
16-
return _storage.readAll();
71+
return _wrap(operation: _Operation.readAll, body: () => _storage.readAll());
1772
}
1873

1974
@override
2075
Future<String?> getValue(String key) {
21-
return _storage.read(key: key);
76+
return _wrap(
77+
operation: _Operation.read,
78+
key: key,
79+
body: () => _storage.read(key: key),
80+
);
2281
}
2382

2483
@override
2584
Future<bool> hasValue(String key) {
26-
return _storage.containsKey(key: key);
85+
return _wrap(
86+
operation: _Operation.contains,
87+
key: key,
88+
body: () => _storage.containsKey(key: key),
89+
);
2790
}
2891

2992
@override
30-
Future<void> deleteValue(String key) async {
31-
await _storage.delete(key: key);
93+
Future<void> deleteValue(String key) {
94+
return _wrap(
95+
operation: _Operation.delete,
96+
key: key,
97+
body: () => _storage.delete(key: key),
98+
);
3299
}
33100

34101
@override
35-
Future<void> deleteAll() async {
36-
await _storage.deleteAll();
102+
Future<void> deleteAll() {
103+
return _wrap(
104+
operation: _Operation.deleteAll,
105+
body: () => _storage.deleteAll(),
106+
);
37107
}
38108
}

0 commit comments

Comments
 (0)