Adopt wallet-core packages for fiat, background sync, notifications, and preferences
What changed, and why it matters
This commit is a large refactoring change that moves several wallet features—fiat exchange rates, background syncing, notifications, and preference storage—out of the Skylight Wallet app and into shared 'wallet-core' packages. The app now imports and configures those packages instead of containing its own implementations. The change itself is architectural: it deletes hundreds of lines of local code and replaces them with thin wrappers and configuration calls. There is no obvious new security bug in the diff, but because the actual logic now lives in external packages that are not shown, the full security effect cannot be judged from this commit alone.
Review the corresponding wallet-core commit at ref f674cb907ab971126a88dcc78c33b549b630f58f to verify that the moved fiat, background-sync, notification, and preferences implementations preserve the previous security properties (Tor-only/clearnet fiat routing, secure storage of tx notification state, background node/Tor gating, and iOS/Android scheduling constraints). Run integration tests covering background sync, fiat fetch over Tor, and notification privacy. Treat this as a supply-chain/architecture change requiring verification of the dependency, not just the app diff.
Security signals we found
Large code relocation to external packages without visible implementation
Shared preference keys consolidated with wallet-core SettingsKeys, reducing key-string drift risk
Notification state handling moved out of app; secure-storage rationale no longer visible
Background sync and foreground service logic moved out of app; Tor/node gating no longer visible
Fiat rate fetching moved out of app; clearnet/Tor/disabled mode handling no longer visible
Isolate entry points now call installWalletCore() before shared handlers
Evidence from the diff
The diff removes local implementations of FiatRateModel, PeriodicTasks/runTxNotifier, ForegroundSyncService, NotificationService, SharedPreferencesService, and TxNotificationState, and re-exports or configures equivalents from wallet-core packages (wallet_fiat, wallet_background, wallet_infra). It updates pubspec.yaml/lock to add wallet_background and wallet_fiat and bump wallet-core to ref f674cb9. Wallet-core is installed in background/foreground isolate entry points via installWalletCore(), and coin-specific seams (MoneroWallet, Tor proxy, notification text) are injected. The visible code changes are mostly glue and API adaptation (e.g., fiatRate.rate becomes fiatRate.rateFor(‘XMR’)).
Changed components
lib/models/fiat_rate_model.dartlib/periodic_tasks.dartlib/services/foreground_sync_service.dartlib/services/notifications_service.dartlib/services/shared_preferences_service.dartlib/wallet_core_glue.dartpubspec.yaml / wallet-core dependency refswallet-core packages: wallet_fiat, wallet_background, wallet_infraInspect captured patch +148 / −975
diff --git a/lib/main.dart b/lib/main.dart
index 28335ac..2991871 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -219,6 +219,9 @@ class _AppRootState extends State<_AppRoot> with WidgetsBindingObserver {
TorSettingsService.sharedInstance.loadSettings();
TorService.sharedInstance.start();
+ // Attach the manager once so the (multicoin) fiat model knows to
+ // fetch XMR; every startService() afterwards can stay argless.
+ attachFiatWalletManager(context);
if (walletExists) {
fiatRate.startService();
}
diff --git a/lib/models/fiat_rate_model.dart b/lib/models/fiat_rate_model.dart
index acca4ad..694fe0a 100644
--- a/lib/models/fiat_rate_model.dart
+++ b/lib/models/fiat_rate_model.dart
@@ -1,201 +1,6 @@
-import 'dart:async';
-import 'dart:convert';
-import 'dart:io';
-
-import 'package:flutter/material.dart';
-
-import 'package:skylight_wallet/consts.dart';
-import 'package:skylight_wallet/services/tor_settings_service.dart';
-import 'package:skylight_wallet/services/shared_preferences_service.dart';
-import 'package:skylight_wallet/util/logging.dart';
-import 'package:skylight_wallet/util/socks_http.dart';
-
-enum FiatApiMode { torOnly, clearnet, disabled }
-
-class FiatRateModel with ChangeNotifier {
- static Future<FiatApiMode> loadFiatApiMode() async {
- final s = await SharedPreferencesService.get<String>(SharedPreferencesKeys.fiatApiMode);
- if (s == null) {
- return FiatApiMode.torOnly;
- }
- for (final m in FiatApiMode.values) {
- if (m.name == s) return m;
- }
- return FiatApiMode.torOnly;
- }
-
- static Future<void> saveFiatApiMode(FiatApiMode mode) async {
- await SharedPreferencesService.set<String>(SharedPreferencesKeys.fiatApiMode, mode.name);
- }
-
- double? _rate;
- bool _isLoading = false;
- bool _hasFailed = false;
- bool _isDisabled = false;
- String _fiatCode = 'USD';
- FiatApiMode _fiatApiMode = FiatApiMode.torOnly;
- Timer? _rateFetchTimer;
-
- double? get rate => _rate;
- bool get isLoading => _isLoading;
- bool get hasFailed => _hasFailed;
- bool get isDisabled => _isDisabled;
- String get fiatCode => _fiatCode;
-
- void _startRateFetchTimer() {
- if (_fiatApiMode == FiatApiMode.disabled) {
- _isDisabled = true;
- _hasFailed = false; // a disabled API is not a failed one
- log(LogLevel.info, 'Fiat API is disabled. Not starting rate fetch timer.');
- notifyListeners();
- return;
- } else {
- _isDisabled = false;
- }
-
- _rateFetchTimer?.cancel();
-
- _loadRate();
-
- _rateFetchTimer = Timer.periodic(Duration(minutes: 10), (_) {
- _loadRate();
- });
- }
-
- Future<void> _loadPersisted() async {
- _fiatCode =
- await SharedPreferencesService.get<String>(SharedPreferencesKeys.fiatCurrency) ?? 'USD';
- _rate = await SharedPreferencesService.get<double>(SharedPreferencesKeys.fiatRate);
- _fiatApiMode = await FiatRateModel.loadFiatApiMode();
- }
-
- Future<void> _persist(double rate) async {
- await SharedPreferencesService.set<double>(SharedPreferencesKeys.fiatRate, rate);
- }
-
- Future<double> _requestPairRateClearnet(String pair) async {
- final url = 'https://api.kraken.com/0/public/Ticker?pair=$pair';
- log(LogLevel.info, 'Fetching rate from fiat api (clearnet): $url');
-
- final client = HttpClient();
- try {
- final request = await client.getUrl(Uri.parse(url));
- final response = await request.close().timeout(Duration(seconds: 20));
- final body = await response.transform(utf8.decoder).join();
- if (response.statusCode != 200) {
- throw Exception('Status code: ${response.statusCode}');
- }
- final jsonBody = jsonDecode(body) as Map<String, dynamic>;
- // Kraken reports rate limits etc. as a 200 with a populated error array
- // and empty result; surface that rather than "could not find rate".
- final errors = jsonBody['error'];
- if (errors is List && errors.isNotEmpty) {
- throw Exception('Kraken error: ${errors.join(', ')}');
- }
- final rate = jsonBody['result']?[pair]?['o'];
- if (rate is! String) {
- throw Exception('Could not find rate for $pair');
- }
- return double.parse(rate);
- } finally {
- client.close(force: true);
- }
- }
-
- Future<double> _requestPairRateTor(String pair) async {
- final url = 'https://api.kraken.com/0/public/Ticker?pair=$pair';
- final proxyInfo = await TorSettingsService.sharedInstance.getProxy();
-
- log(LogLevel.info, 'Fetching rate from fiat api (Tor):');
- log(LogLevel.info, ' url: $url');
- log(LogLevel.info, ' proxyInfo: $proxyInfo');
-
- if (proxyInfo == null) {
- throw Exception('Not fetching rate from fiat API because no Tor proxy is available');
- }
-
- try {
- final response = await makeSocksHttpRequest(
- 'GET',
- url,
- proxyInfo,
- ).timeout(const Duration(seconds: 20));
- if (response.statusCode == 200) {
- final errors = response.jsonBody?['error'];
- if (errors is List && errors.isNotEmpty) {
- throw Exception('Kraken error: ${errors.join(', ')}');
- }
- final rate = response.jsonBody?['result']?[pair]?['o'];
- if (rate is! String) {
- throw Exception('Could not find rate for $pair');
- }
- return double.parse(rate);
- } else {
- throw Exception('Status code: ${response.statusCode}');
- }
- } catch (error) {
- log(LogLevel.error, 'Failed to get fiat rate. ${error.toString()}');
- throw Exception('Failed to get fiat rate. ${error.toString()}');
- }
- }
-
- Future<double> _requestPairRate(String pair) async {
- if (_fiatApiMode == FiatApiMode.clearnet) {
- try {
- return await _requestPairRateClearnet(pair);
- } catch (error) {
- log(LogLevel.error, 'Failed to get fiat rate. ${error.toString()}');
- throw Exception('Failed to get fiat rate. ${error.toString()}');
- }
- }
- return _requestPairRateTor(pair);
- }
-
- Future<void> _loadRate() async {
- _fiatApiMode = await FiatRateModel.loadFiatApiMode();
- if (_fiatApiMode == FiatApiMode.disabled) {
- _rateFetchTimer?.cancel();
- _rateFetchTimer = null;
- _isDisabled = true;
- _hasFailed = false; // a disabled API is not a failed one
- notifyListeners();
- return;
- }
- _isDisabled = false;
-
- _fiatCode =
- await SharedPreferencesService.get<String>(SharedPreferencesKeys.fiatCurrency) ?? 'USD';
-
- final pair1 = indirectPairCurrencies.contains(_fiatCode) ? 'XXMRZUSD' : 'XXMRZ$_fiatCode';
- final pair2 = indirectPairCurrencies.contains(fiatCode) ? 'USDT$fiatCode' : null;
-
- try {
- _isLoading = true;
- notifyListeners();
-
- final rates = await Future.wait([
- _requestPairRate(pair1),
- pair2 != null ? _requestPairRate(pair2) : Future.value(null),
- ]);
-
- final finalRate = rates[0]! * (rates[1] ?? 1);
- _rate = finalRate;
- _persist(finalRate);
- _hasFailed = false;
- log(LogLevel.info, '$_fiatCode rate: $finalRate');
- } catch (error) {
- log(LogLevel.error, 'Failed to get fiat rate. ${error.toString()}');
- _hasFailed = true;
- } finally {
- _isLoading = false;
- notifyListeners();
- }
- }
-
- Future<void> startService() async {
- _rateFetchTimer?.cancel();
- _rateFetchTimer = null;
- await _loadPersisted();
- _startRateFetchTimer();
- }
-}
+// FiatRateModel lives in wallet-core (`wallet_fiat`, multicoin — skylight uses
+// the XMR result via rateFor('XMR')). Kept under the same import path so call
+// sites are unchanged; the app supplies the Tor proxy via FiatRates.install and
+// attaches the WalletManager via attachFiatWalletManager in wallet_core_glue.dart.
+// The fiatAutoDisabledByTor auto-disable/restore stays app-side (tor_settings_form).
+export 'package:wallet_fiat/wallet_fiat.dart' show FiatRateModel, FiatApiMode;
diff --git a/lib/periodic_tasks.dart b/lib/periodic_tasks.dart
index 5c300f0..25f188a 100644
--- a/lib/periodic_tasks.dart
+++ b/lib/periodic_tasks.dart
@@ -1,247 +1,21 @@
-import 'dart:io';
-
-import 'package:skylight_wallet/services/shared_preferences_service.dart';
-import 'package:skylight_wallet/services/tor_service.dart';
-import 'package:skylight_wallet/util/logging.dart';
-import 'package:skylight_wallet/wallet_core_glue.dart' show openBackgroundWallet;
import 'package:workmanager/workmanager.dart';
-class PeriodicTasks {
- static const txNotifier = 'txNotifier';
-
- /// iOS BGAppRefreshTask. Opportunistic and short — iOS decides when, and
- /// grants roughly 30 seconds. Only ever scheduled for an LWS connection on
- /// clearnet: a node scan can't finish in that window, and a Tor bootstrap
- /// alone can outlast it.
- static const iosRefresh = 'refresh';
-
- /// iOS BGProcessingTask. Runs while the device is charging and idle, for
- /// minutes rather than seconds, so Tor has time to come up first. LWS only —
- /// a remote node is never background-synced on iOS.
- static const iosProcessing = 'processing';
-}
-
-/// Identifiers must match `BGTaskSchedulerPermittedIdentifiers` in Info.plist
-/// and the registrations in AppDelegate.
-const _iosBundleId = 'org.magicgrants.skylightwallet';
-const _iosRefreshTaskId = '$_iosBundleId.${PeriodicTasks.iosRefresh}';
-const _iosProcessingTaskId = '$_iosBundleId.${PeriodicTasks.iosProcessing}';
-
-/// Max wall-clock we let a background run scan before returning, leaving margin
-/// under Android's ~10-minute WorkManager budget to persist + notify.
-const _backgroundSyncBudget = Duration(minutes: 9);
-
-/// What a BGAppRefreshTask gets on iOS is short and not negotiable; overrunning
-/// it means iOS kills the task and schedules the next one less willingly.
-const _iosRefreshBudget = Duration(seconds: 25);
-
-/// How often a background run checks on the scan it is waiting for.
-const _backgroundSyncPollInterval = Duration(seconds: 5);
-
-/// Consecutive polls without the synced height moving before a run gives up on
-/// the rest of its budget. The window has to outlast a refresh cycle: in LWS
-/// mode the height only moves when the 20s cycle reloads stats, so a shorter
-/// one would read a healthy run as stuck.
-const _backgroundSyncStuckPolls = 12;
-
-/// WorkManager's minimum periodic interval.
-const _minSyncIntervalMinutes = 15;
-
-/// One background sync pass.
-///
-/// [budget] is the wall-clock this run may use. [allowTor] and [allowNode] say
-/// what the scheduling window can actually accommodate — a 30-second iOS
-/// refresh can carry neither a Tor bootstrap nor a node scan. They're checked
-/// again here rather than trusted from the scheduler, because iOS can deliver a
-/// task that was scheduled under a connection the user has since changed.
-Future<bool> runTxNotifier({
- Duration budget = _backgroundSyncBudget,
- bool allowTor = true,
- bool allowNode = true,
-}) async {
- // Loads the connection, applies the node/Tor/background-sync gates, and opens
- // the correct-mode wallet — null when there's no wallet or this window won't
- // sync it. A node scan is heavy, so it only runs with Background Sync on.
- final wallet = await openBackgroundWallet(
- allowTor: allowTor,
- allowNode: allowNode,
- requireBackgroundSyncForNode: true,
- );
- if (wallet == null) return true;
-
- if (wallet.usingTor) {
- await TorService.sharedInstance.start();
- final torIsUp = await TorService.sharedInstance.waitUntilConnected(
- timeout: const Duration(minutes: 2),
- );
-
- if (!torIsUp) {
- log(LogLevel.warn, '[Background sync] Tor did not come up; ending run.');
- return true;
- }
- }
-
- if (wallet.connectionAddress.isEmpty) {
- return true;
- }
-
- // Kick the daemon connection (starts the scan thread + the wallet's timers).
- try {
- await wallet.connectToDaemon();
- } catch (e) {
- log(LogLevel.warn, '[Background sync] connect failed: $e');
- }
-
- // Keep the isolate alive so the on-device scan keeps advancing, up to the OS
- // budget. The wallet's own timers drive the refresh + checkpoint; we just
- // wait, and stop early once it's synced or once it stops getting anywhere.
- final deadline = DateTime.now().add(budget);
- var lastSyncedHeight = wallet.syncedHeight;
- var stuckPolls = 0;
-
- while (DateTime.now().isBefore(deadline)) {
- if (wallet.isConnected && wallet.isSynced) break;
-
- final syncedHeight = wallet.syncedHeight;
-
- if (syncedHeight != lastSyncedHeight) {
- lastSyncedHeight = syncedHeight;
- stuckPolls = 0;
- } else if (++stuckPolls >= _backgroundSyncStuckPolls) {
- // Unreachable server, dead Tor circuit, stalled scan: holding the wake-up
- // open for the rest of the budget just spends battery to learn nothing.
- log(
- LogLevel.warn,
- '[Background sync] No progress in '
- '${_backgroundSyncStuckPolls * _backgroundSyncPollInterval.inSeconds}s; ending run early.',
- );
- break;
- }
-
- await Future.delayed(_backgroundSyncPollInterval);
- }
-
- // Stop the scan and checkpoint it before the isolate goes: nothing closes
- // this wallet, so a refresh left running keeps pulling blocks after the task
- // returns, and the last partial cycle of scanning would be thrown away.
- await wallet.pauseSyncAndStore();
+import 'package:skylight_wallet/wallet_core_glue.dart';
+import 'package:wallet_background/wallet_background.dart';
- try {
- await wallet.loadTxHistory();
- } catch (e) {
- log(LogLevel.warn, '[Background sync] loadTxHistory failed: $e');
- }
-
- try {
- await wallet.notifyNewIncomingTxs();
- } catch (e) {
- log(LogLevel.warn, '[Background sync] notifying new transactions failed: $e');
- }
-
- return true;
-}
+// The orchestration lives in wallet-core (`wallet_background`); the app keeps
+// only the isolate entry point, because a fresh background isolate has none of
+// the app's wallet-core statics and the package can't reach the app's bootstrap.
+export 'package:wallet_background/wallet_background.dart'
+ show PeriodicTasks, registerPeriodicTasks, applyBackgroundTaskRegistration;
+/// WorkManager isolate entry: bootstrap this isolate (`installWalletCore` also
+/// installs the `BackgroundSync` config), then run the shared orchestration.
+/// Top-level `@pragma` so it survives tree-shaking.
@pragma('vm:entry-point')
-void _callbackDispatcher() {
+void backgroundDispatcher() {
Workmanager().executeTask((task, inputData) async {
- switch (task) {
- // Roughly 30 seconds, whenever iOS feels like it. Enough for an LWS
- // server to report what it has already scanned, and nothing more.
- case PeriodicTasks.iosRefresh:
- return runTxNotifier(budget: _iosRefreshBudget, allowTor: false, allowNode: false);
-
- // Charging and idle, so there is room for Tor to bootstrap first.
- case PeriodicTasks.iosProcessing:
- return runTxNotifier(allowNode: false);
-
- case PeriodicTasks.txNotifier:
- default:
- return runTxNotifier();
- }
+ installWalletCore();
+ return dispatchBackgroundTask(task);
});
}
-
-/// (Re)registers background work to match the current settings, or cancels it.
-///
-/// Call after anything that changes the answer: the notifications toggle, the
-/// background-sync toggle, or the connection itself.
-Future<void> applyBackgroundTaskRegistration() async {
- if (Platform.isIOS) return _applyIosBackgroundTasks();
- if (!Platform.isAndroid) return;
-
- final backgroundSync =
- await SharedPreferencesService.get<bool>(SharedPreferencesKeys.backgroundSyncEnabled) ??
- false;
- final notifications =
- await SharedPreferencesService.get<bool>(SharedPreferencesKeys.notificationsEnabled) ?? false;
-
- await Workmanager().cancelByUniqueName(PeriodicTasks.txNotifier);
- if (!backgroundSync && !notifications) return;
-
- final minutes =
- await SharedPreferencesService.get<int>(
- SharedPreferencesKeys.backgroundSyncIntervalMinutes,
- ) ??
- _minSyncIntervalMinutes;
-
- // A node sync is heavy, so gate it on charging + WiFi; notifications are light.
- final constraints = backgroundSync
- ? Constraints(networkType: NetworkType.unmetered, requiresCharging: true)
- : Constraints(networkType: NetworkType.connected, requiresBatteryNotLow: true);
-
- await Workmanager().registerPeriodicTask(
- PeriodicTasks.txNotifier,
- "Background sync",
- frequency: Duration(
- minutes: minutes < _minSyncIntervalMinutes ? _minSyncIntervalMinutes : minutes,
- ),
- constraints: constraints,
- );
-}
-
-/// iOS scheduling, which turns on what the connection can actually support.
-///
-/// A remote node is never background-synced here: neither window is long
-/// enough for an on-device scan to be worth the wake-up. For LWS the server has
-/// already done the scanning, so a short visit is enough to collect the result.
-///
-/// - clearnet LWS gets both: the opportunistic refresh for timeliness, and
-/// processing as a backstop for when refresh doesn't fire.
-/// - Tor LWS gets processing only. Bootstrapping Tor can eat a whole refresh
-/// window on its own, so notifications wait for a charging, idle moment.
-Future<void> _applyIosBackgroundTasks() async {
- final notifications =
- await SharedPreferencesService.get<bool>(SharedPreferencesKeys.notificationsEnabled) ?? false;
- final connectionType =
- await SharedPreferencesService.get<String>(SharedPreferencesKeys.connectionType) ?? 'lws';
- final useTor =
- await SharedPreferencesService.get<bool>(SharedPreferencesKeys.connectionUseTor) ?? false;
-
- await Workmanager().cancelByUniqueName(_iosRefreshTaskId);
- await Workmanager().cancelByUniqueName(_iosProcessingTaskId);
-
- if (!notifications || connectionType == 'node') return;
-
- if (!useTor) {
- await Workmanager().registerPeriodicTask(
- _iosRefreshTaskId,
- PeriodicTasks.iosRefresh,
- frequency: Duration(minutes: _minSyncIntervalMinutes),
- );
- }
-
- await Workmanager().registerProcessingTask(
- _iosProcessingTaskId,
- PeriodicTasks.iosProcessing,
- constraints: Constraints(networkType: NetworkType.connected, requiresCharging: true),
- );
-}
-
-Future<void> registerPeriodicTasks() async {
- if (!Platform.isAndroid && !Platform.isIOS) {
- return;
- }
-
- Workmanager().initialize(_callbackDispatcher);
- await applyBackgroundTaskRegistration();
-}
diff --git a/lib/screens/confirm_send.dart b/lib/screens/confirm_send.dart
index 37122d2..829cc25 100644
--- a/lib/screens/confirm_send.dart
+++ b/lib/screens/confirm_send.dart
@@ -133,8 +133,8 @@ class _ConfirmSendScreenState extends State<ConfirmSendScreen> {
final i18n = AppLocalizations.of(context)!;
final fiatRate = context.watch<FiatRateModel>();
final fiatSymbol = fiatRate.fiatCode == 'EUR' ? '€' : '\$';
- final amountFiat = fiatRate.rate is double ? _amount * fiatRate.rate! : null;
- final networkFeeFiat = fiatRate.rate is double ? _fee * fiatRate.rate! : null;
+ final amountFiat = fiatRate.rateFor('XMR') is double ? _amount * fiatRate.rateFor('XMR')! : null;
+ final networkFeeFiat = fiatRate.rateFor('XMR') is double ? _fee * fiatRate.rateFor('XMR')! : null;
return Scaffold(
appBar: AppBar(),
diff --git a/lib/screens/send.dart b/lib/screens/send.dart
index b0c0279..e7389c0 100644
--- a/lib/screens/send.dart
+++ b/lib/screens/send.dart
@@ -472,7 +472,7 @@ class _SendScreenState extends State<SendScreen> {
priority: 0,
fees: _fees,
fiatSymbol: fiatSymbol,
- fiatRate: fiatRate.rate,
+ fiatRate: fiatRate.rateFor('XMR'),
isSelected: _selectedPriority == 0,
onTap: () {
setState(() {
@@ -487,7 +487,7 @@ class _SendScreenState extends State<SendScreen> {
priority: 1,
fees: _fees,
fiatSymbol: fiatSymbol,
- fiatRate: fiatRate.rate,
+ fiatRate: fiatRate.rateFor('XMR'),
isSelected: _selectedPriority == 1,
onTap: () {
setState(() {
@@ -502,7 +502,7 @@ class _SendScreenState extends State<SendScreen> {
priority: 2,
fees: _fees,
fiatSymbol: fiatSymbol,
- fiatRate: fiatRate.rate,
+ fiatRate: fiatRate.rateFor('XMR'),
isSelected: _selectedPriority == 2,
onTap: () {
setState(() {
diff --git a/lib/screens/wallet_home.dart b/lib/screens/wallet_home.dart
index 24285ec..c100607 100644
--- a/lib/screens/wallet_home.dart
+++ b/lib/screens/wallet_home.dart
@@ -48,8 +48,8 @@ class _TransactionListItemState extends State<_TransactionListItem> {
@override
Widget build(BuildContext context) {
- final amountFiat = widget.fiatRate.rate is double
- ? widget.tx.amount * widget.fiatRate.rate!
+ final amountFiat = widget.fiatRate.rateFor('XMR') is double
+ ? widget.tx.amount * widget.fiatRate.rateFor('XMR')!
: null;
return MouseRegion(
@@ -361,8 +361,8 @@ class _WalletHomeScreenState extends State<WalletHomeScreen> {
final wallet = appWalletOf(context, listen: true);
final fiatRate = context.watch<FiatRateModel>();
final deviceType = _getDeviceType(context);
- final unlockedBalanceFiat = fiatRate.rate is double && wallet.unlockedBalance is double
- ? wallet.unlockedBalance! * fiatRate.rate!
+ final unlockedBalanceFiat = fiatRate.rateFor('XMR') is double && wallet.unlockedBalance is double
+ ? wallet.unlockedBalance! * fiatRate.rateFor('XMR')!
: null;
final lockedBalance = (wallet.totalBalance ?? 0) - (wallet.unlockedBalance ?? 0);
final fiatSymbol = consts.currencySymbols[fiatRate.fiatCode] ?? '\$';
@@ -379,7 +379,7 @@ class _WalletHomeScreenState extends State<WalletHomeScreen> {
lwsConnectionIconStatus = StatusIconStatus.loading;
}
- if (fiatRate.rate is double &&
+ if (fiatRate.rateFor('XMR') is double &&
!fiatRate.hasFailed &&
TorService.sharedInstance.status == TorConnectionStatus.connected) {
fiatApiIconStatus = StatusIconStatus.complete;
diff --git a/lib/services/foreground_sync_service.dart b/lib/services/foreground_sync_service.dart
index 63dade6..bf2ca3a 100644
--- a/lib/services/foreground_sync_service.dart
+++ b/lib/services/foreground_sync_service.dart
@@ -1,149 +1,17 @@
-import 'dart:async';
-import 'dart:io';
-
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
-import 'package:skylight_wallet/models/app_wallet.dart';
-import 'package:skylight_wallet/services/shared_preferences_service.dart';
-import 'package:skylight_wallet/services/tor_service.dart';
-import 'package:skylight_wallet/util/logging.dart';
-import 'package:skylight_wallet/wallet_core_glue.dart' show openBackgroundWallet;
-
-/// Android foreground service that keeps the Monero wallet syncing while the
-/// app is backgrounded — a persistent-notification alternative to the
-/// budget-limited WorkManager task. Dies on force-quit (OS limitation).
+import 'package:skylight_wallet/wallet_core_glue.dart';
+import 'package:wallet_background/wallet_background.dart';
-const _channelId = 'skylight_background_sync';
-const _channelName = 'Background sync';
+// The handler + service control live in wallet-core (`wallet_background`); the
+// app keeps only the isolate entry point (see periodic_tasks.dart).
+export 'package:wallet_background/wallet_background.dart'
+ show startForegroundSync, stopForegroundSync, startForegroundSyncIfEnabled, isWalletFullySynced;
-/// Entry point run inside the foreground-service isolate. Must be top-level.
+/// Foreground-service isolate entry: bootstrap this isolate, then hand off to
+/// the shared handler. Top-level `@pragma` so it survives tree-shaking.
@pragma('vm:entry-point')
void foregroundSyncCallback() {
- FlutterForegroundTask.setTaskHandler(_SyncTaskHandler());
-}
-
-class _SyncTaskHandler extends TaskHandler {
- AppWallet? _wallet;
-
- @override
- Future<void> onStart(DateTime timestamp, TaskStarter starter) async {
- try {
- // Opens the correct-mode wallet (or null if there's none). The foreground
- // service syncs whatever the user connected to, so no node/Tor gating.
- final wallet = await openBackgroundWallet();
- _wallet = wallet;
- if (wallet == null) return;
-
- if (wallet.usingTor) {
- await TorService.sharedInstance.start();
- if (!await TorService.sharedInstance.waitUntilConnected(
- timeout: const Duration(minutes: 2),
- )) {
- log(LogLevel.warn, '[FG sync] Tor did not come up; not connecting.');
- return;
- }
- }
-
- if (wallet.connectionAddress.isEmpty) return;
- // Connect; the wallet's own timers then drive the scan + checkpoints for
- // as long as this service keeps the isolate alive.
- try {
- await wallet.connectToDaemon();
- } catch (e) {
- log(LogLevel.warn, '[FG sync] connect failed: $e');
- }
- } catch (e) {
- log(LogLevel.warn, '[FG sync] start failed: $e');
- }
- }
-
- @override
- void onRepeatEvent(DateTime timestamp) {
- final wallet = _wallet;
- final syncing =
- wallet != null && wallet.connectionAddress.isNotEmpty && !wallet.isFullySynced;
- FlutterForegroundTask.updateService(
- notificationTitle: 'Skylight Wallet',
- notificationText: syncing ? 'Syncing…' : 'Wallet up to date',
- );
-
- // While this service is running it is the thing watching the chain, so it
- // is the thing that has to announce what it finds. The background task runs
- // on its own schedule and would otherwise never see these.
- if (wallet != null) {
- unawaited(
- wallet.notifyNewIncomingTxs().catchError((Object e) {
- log(LogLevel.warn, '[FG sync] notifying new transactions failed: $e');
- }),
- );
- }
- }
-
- @override
- Future<void> onDestroy(DateTime timestamp) async {
- final wallet = _wallet;
- _wallet = null;
-
- // Stop the scan and checkpoint it: the service is going away but nothing
- // closes the wallet, and the scanning done since the last checkpoint would
- // otherwise be lost.
- if (wallet != null) {
- try {
- await wallet.pauseSyncAndStore();
- } catch (e) {
- log(LogLevel.warn, '[FG sync] Failed to store on shutdown: $e');
- }
- }
- }
-}
-
-/// Configures the service. Safe to call more than once.
-void initForegroundSync() {
- FlutterForegroundTask.init(
- androidNotificationOptions: AndroidNotificationOptions(
- channelId: _channelId,
- channelName: _channelName,
- channelImportance: NotificationChannelImportance.LOW,
- priority: NotificationPriority.LOW,
- ),
- iosNotificationOptions: const IOSNotificationOptions(),
- foregroundTaskOptions: ForegroundTaskOptions(
- eventAction: ForegroundTaskEventAction.repeat(30000),
- autoRunOnBoot: false,
- allowWakeLock: true,
- allowWifiLock: true,
- ),
- );
-}
-
-/// [synced] seeds the initial notification from the caller's live wallet state,
-/// so toggling this on while already caught up shows "up to date" immediately
-/// instead of a stale "Syncing…" until the isolate's first 30s tick.
-Future<void> startForegroundSync({bool synced = false}) async {
- if (!Platform.isAndroid) return;
- initForegroundSync();
- await FlutterForegroundTask.requestNotificationPermission();
- if (await FlutterForegroundTask.isRunningService) return;
- await FlutterForegroundTask.startService(
- notificationTitle: 'Skylight Wallet',
- notificationText: synced ? 'Wallet up to date' : 'Syncing…',
- callback: foregroundSyncCallback,
- );
-}
-
-Future<void> stopForegroundSync() async {
- if (!Platform.isAndroid) return;
- if (await FlutterForegroundTask.isRunningService) {
- await FlutterForegroundTask.stopService();
- }
-}
-
-/// Starts the service on launch if the user enabled it, so backgrounding keeps
-/// syncing.
-Future<void> startForegroundSyncIfEnabled() async {
- if (!Platform.isAndroid) return;
- final enabled =
- await SharedPreferencesService.get<bool>(SharedPreferencesKeys.foregroundSyncEnabled) ??
- false;
- if (enabled) await startForegroundSync();
+ installWalletCore();
+ FlutterForegroundTask.setTaskHandler(BackgroundSyncTaskHandler());
}
diff --git a/lib/services/notifications_service.dart b/lib/services/notifications_service.dart
index e6abf9a..dc73196 100644
--- a/lib/services/notifications_service.dart
+++ b/lib/services/notifications_service.dart
@@ -1,89 +1,6 @@
-import 'dart:io';
-import 'package:flutter_local_notifications/flutter_local_notifications.dart';
-import 'package:path/path.dart' as p;
-
-class NotificationService {
- // Per-isolate: the background task and the foreground service each get their
- // own engine, and the plugin has to be initialized in whichever one is about
- // to show something.
- static bool _initialized = false;
-
- final notificationsPlugin = FlutterLocalNotificationsPlugin();
-
- Future<void> init() async {
- if (_initialized) return;
-
- const initSettingsAndroid = AndroidInitializationSettings('@mipmap/ic_launcher');
- const initSettingsIOS = DarwinInitializationSettings(
- // We'll request permissions manually
- requestAlertPermission: false,
- requestBadgePermission: false,
- requestSoundPermission: false,
- );
- const initSettingsLinux = LinuxInitializationSettings(defaultActionName: 'Open wallet');
-
- // Windows requires an absolute path to an .ico file
- final initSettingsWindows = WindowsInitializationSettings(
- appName: 'Skylight Wallet',
- appUserModelId: 'org.magicgrants.skylight',
- guid: '6dcf17a9-fb5f-4f47-b0b9-6d655e90adbf',
- iconPath: Platform.isWindows
- ? p.join(p.dirname(Platform.resolvedExecutable), 'data', 'flutter_assets', 'assets', 'app_icon.ico')
- : null,
- );
-
- final initSettings = InitializationSettings(
- android: initSettingsAndroid,
- iOS: initSettingsIOS,
- linux: initSettingsLinux,
- windows: initSettingsWindows,
- );
-
- await notificationsPlugin.initialize(initSettings);
- _initialized = true;
- }
-
- Future<bool> promptPermission() async {
- if (Platform.isIOS) {
- // Use flutter_local_notifications' iOS-specific permission request
- final iosPlugin = notificationsPlugin
- .resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
-
- final granted = await iosPlugin?.requestPermissions(alert: true, badge: true, sound: true);
-
- return granted ?? false;
- } else if (Platform.isAndroid) {
- // Android 13+ needs runtime permission
- final androidPlugin = notificationsPlugin
- .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
-
- final granted = await androidPlugin?.requestNotificationsPermission();
- return granted ?? true; // Older Android versions don't need permission
- }
-
- return true; // Other platforms (Linux, etc.)
- }
-
- Future<void> showIncomingTxNotification(double amountReceived) async {
- // Cheap when already done, and the only way this works from a background
- // isolate, which never ran the init in main().
- await init();
-
- const notificationChannelId = 'incoming_transactions';
-
- await notificationsPlugin.show(
- 0,
- 'Incoming transaction',
- 'You received $amountReceived XMR',
- const NotificationDetails(
- android: AndroidNotificationDetails(
- notificationChannelId,
- 'Transactions',
- importance: Importance.max,
- priority: Priority.high,
- ),
- iOS: DarwinNotificationDetails(threadIdentifier: notificationChannelId),
- ),
- );
- }
-}
+// NotificationService lives in wallet-core (shared, with its plumbing + the
+// background-isolate init guard skylight pioneered). Kept under the same import
+// path so call sites are unchanged; the notification text is built by the caller
+// (see wallet_core_glue.dart) so it stays coin-correct. Windows branding statics
+// are set in installWalletCore().
+export 'package:wallet_infra/wallet_infra.dart' show NotificationService;
diff --git a/lib/services/shared_preferences_service.dart b/lib/services/shared_preferences_service.dart
index d11a246..df2a2f1 100644
--- a/lib/services/shared_preferences_service.dart
+++ b/lib/services/shared_preferences_service.dart
@@ -1,21 +1,31 @@
-import 'package:shared_preferences/shared_preferences.dart';
+import 'package:wallet_infra/wallet_infra.dart' show SettingsKeys;
+// The prefs service itself lives in wallet-core (injectable, test-seamed). Kept
+// under the same import path so call sites are unchanged.
+export 'package:wallet_infra/wallet_infra.dart' show SharedPreferencesService;
+
+/// Skylight's preference keys. The common ones reference the shared [SettingsKeys]
+/// so the key strings can't drift from spice; only skylight-specific keys (the
+/// Monero connection + wallet-state keys) are literals here.
class SharedPreferencesKeys {
- static const String language = 'language';
- static const String fiatCurrency = 'fiatCurrency';
- static const String fiatApiMode = 'fiatApiMode';
- // Set when the fiat API was auto-disabled because global Tor was turned off,
- // so re-enabling Tor can restore it — but only when the user didn't disable
- // it themselves.
- static const String fiatAutoDisabledByTor = 'fiatAutoDisabledByTor';
- static const String fiatRate = 'fiatRate';
- static const String theme = 'theme';
- static const String notificationsEnabled = 'notificationsEnabled';
- static const String backgroundSyncEnabled = 'backgroundSyncEnabled';
- static const String foregroundSyncEnabled = 'foregroundSyncEnabled';
- static const String backgroundSyncIntervalMinutes = 'backgroundSyncIntervalMinutes';
- static const String appLockEnabled = 'appLockEnabled';
- static const String verboseLoggingEnabled = 'verboseLoggingEnabled';
+ static const String language = SettingsKeys.language;
+ static const String fiatCurrency = SettingsKeys.fiatCurrency;
+ static const String fiatApiMode = SettingsKeys.fiatApiMode;
+ static const String fiatAutoDisabledByTor = SettingsKeys.fiatAutoDisabledByTor;
+ static const String fiatRate = SettingsKeys.fiatRate;
+ static const String theme = SettingsKeys.theme;
+ static const String notificationsEnabled = SettingsKeys.notificationsEnabled;
+ static const String backgroundSyncEnabled = SettingsKeys.backgroundSyncEnabled;
+ static const String foregroundSyncEnabled = SettingsKeys.foregroundSyncEnabled;
+ static const String backgroundSyncIntervalMinutes = SettingsKeys.backgroundSyncIntervalMinutes;
+ static const String appLockEnabled = SettingsKeys.appLockEnabled;
+ static const String verboseLoggingEnabled = SettingsKeys.verboseLoggingEnabled;
+ static const String contacts = SettingsKeys.contacts;
+ static const String torMode = SettingsKeys.torMode;
+ static const String torSocksPort = SettingsKeys.torSocksPort;
+ static const String torUseOrbot = SettingsKeys.torUseOrbot;
+
+ // Skylight-only (Monero connection + wallet state):
static const String connectionAddress = 'connectionAddress';
static const String connectionProxyPort = 'connectionProxyPort';
static const String connectionUseTor = 'connectionUseTor';
@@ -23,56 +33,6 @@ class SharedPreferencesKeys {
static const String connectionType = 'connectionType';
static const String serverSupportsSubaddresses = 'serverSupportsSubaddresses';
static const String walletRestoreHeight = 'walletRestoreHeight';
- static const String contacts = 'contacts';
static const String unusedSubaddressIndex = 'unusedSubaddressIndex';
static const String unusedSubaddressIndexIsSupported = 'unusedSubaddressIndexIsSupported';
- static const String torMode = 'torMode';
- static const String torSocksPort = 'torSocksPort';
- static const String torUseOrbot = 'torUseOrbot';
-}
-
-class SharedPreferencesService {
- SharedPreferencesService._();
-
- static Future<T?> get<T>(String key) async {
- final prefs = await SharedPreferences.getInstance();
- final String keyString = key.toString();
-
- switch (T) {
- // ignore: type_literal_in_constant_pattern
- case bool:
- return prefs.getBool(keyString) as T?;
- // ignore: type_literal_in_constant_pattern
- case String:
- return prefs.getString(keyString) as T?;
- // ignore: type_literal_in_constant_pattern
- case int:
- return prefs.getInt(keyString) as T?;
- // ignore: type_literal_in_constant_pattern
- case double:
- return prefs.getDouble(keyString) as T?;
- default:
- return null;
- }
- }
-
- static Future<void> set<T>(String key, T value) async {
- final prefs = await SharedPreferences.getInstance();
- final String keyString = key.toString();
-
- if (value is bool) {
- await prefs.setBool(keyString, value);
- } else if (value is String) {
- await prefs.setString(keyString, value);
- } else if (value is int) {
- await prefs.setInt(keyString, value);
- } else if (value is double) {
- await prefs.setDouble(keyString, value);
- }
- }
-
- static Future<void> remove(String key) async {
- final prefs = await SharedPreferences.getInstance();
- await prefs.remove(key);
- }
}
diff --git a/lib/util/tx_notification_state.dart b/lib/util/tx_notification_state.dart
deleted file mode 100644
index 97073c7..0000000
--- a/lib/util/tx_notification_state.dart
+++ /dev/null
@@ -1,68 +0,0 @@
-import 'dart:convert';
-
-import 'package:skylight_wallet/util/logging.dart';
-import 'package:skylight_wallet/util/secure_storage.dart';
-
-/// Record of which incoming transactions the user has already been told about.
-///
-/// Held in secure storage rather than shared preferences. Shared preferences is
-/// a plaintext file in the app's private directory, and these are real on-chain
-/// identifiers — a readable list of them ties this device to those exact
-/// transactions for anyone who gets at the app's files.
-class TxNotificationState {
- const TxNotificationState({required this.cutoff, required this.announcedHashes});
-
- /// Unix seconds of the newest transaction already accounted for. Null when
- /// nothing has been recorded yet: a fresh install, or an upgrade from a build
- /// that tracked a plain transaction count.
- final int? cutoff;
-
- /// Recently announced transaction hashes, oldest first.
- final List<String> announcedHashes;
-
- static const empty = TxNotificationState(cutoff: null, announcedHashes: []);
-}
-
-const _storageKey = 'txNotificationState';
-
-/// Reads the stored state, or [TxNotificationState.empty] if there is none.
-///
-/// A failure here reads as "nothing recorded", which makes the caller reseed
-/// from the current chain. That direction is deliberate: the alternative to
-/// staying quiet is announcing a whole history at once.
-Future<TxNotificationState> readTxNotificationState() async {
- try {
- final stored = await secureStorage.read(key: _storageKey);
-
- if (stored == null || stored.isEmpty) return TxNotificationState.empty;
-
- final decoded = json.decode(stored) as Map<String, dynamic>;
-
- return TxNotificationState(
- cutoff: decoded['cutoff'] as int?,
- announcedHashes: (decoded['announcedHashes'] as List<dynamic>? ?? const []).cast<String>(),
- );
- } catch (e) {
- log(LogLevel.warn, 'Could not read transaction notification state: $e');
- return TxNotificationState.empty;
- }
-}
-
-Future<void> writeTxNotificationState(TxNotificationState state) async {
- try {
- await secureStorage.write(
- key: _storageKey,
- value: json.encode({'cutoff': state.cutoff, 'announcedHashes': state.announcedHashes}),
- );
- } catch (e) {
- log(LogLevel.warn, 'Could not save transaction notification state: $e');
- }
-}
-
-Future<void> clearTxNotificationState() async {
- try {
- await secureStorage.delete(key: _storageKey);
- } catch (e) {
- log(LogLevel.warn, 'Could not clear transaction notification state: $e');
- }
-}
diff --git a/lib/wallet_core_glue.dart b/lib/wallet_core_glue.dart
index b1e8692..6cb568d 100644
--- a/lib/wallet_core_glue.dart
+++ b/lib/wallet_core_glue.dart
@@ -5,12 +5,19 @@ import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
import 'package:skylight_wallet/models/app_wallet.dart';
+import 'package:skylight_wallet/models/fiat_rate_model.dart';
import 'package:skylight_wallet/models/monero_wallet_adapter.dart';
+import 'package:skylight_wallet/periodic_tasks.dart' show backgroundDispatcher;
+import 'package:skylight_wallet/services/foreground_sync_service.dart' show foregroundSyncCallback;
import 'package:skylight_wallet/services/notifications_service.dart';
import 'package:skylight_wallet/services/shared_preferences_service.dart';
+import 'package:skylight_wallet/services/tor_service.dart';
+import 'package:skylight_wallet/services/tor_settings_service.dart';
import 'package:skylight_wallet/util/logging.dart';
import 'package:wallet_infra/wallet_infra.dart' as wcore;
+import 'package:wallet_background/wallet_background.dart' show BackgroundSync;
+import 'package:wallet_fiat/wallet_fiat.dart' show FiatRates;
import 'package:wallet_domain/wallet_domain.dart'
show
WalletAppConfig,
@@ -38,6 +45,21 @@ void installWalletCore() {
WalletAppConfig.install(WalletAppConfig.skylight);
CryptoWallet.aliasResolver = resolveOpenAlias;
+ wcore.NotificationService.windowsAppName = 'Skylight Wallet';
+ wcore.NotificationService.windowsAppUserModelId = 'org.magicgrants.skylight';
+ wcore.NotificationService.windowsGuid = '6dcf17a9-fb5f-4f47-b0b9-6d655e90adbf';
+
+ BackgroundSync.install(
+ coins: () => [MoneroWallet()],
+ workmanagerCallback: backgroundDispatcher,
+ foregroundCallback: foregroundSyncCallback,
+ ensureTorConnected: _ensureTorConnected,
+ iosBundleId: 'org.magicgrants.skylightwallet',
+ foregroundTitle: 'Skylight Wallet',
+ );
+
+ FiatRates.install(getTorProxy: TorSettingsService.sharedInstance.getProxy);
+
wcore.WalletLog.sink = const _SkylightLogSink();
wcore.WalletLog.isVerbose = () async =>
await SharedPreferencesService.get<bool>(SharedPreferencesKeys.verboseLoggingEnabled) ??
@@ -46,79 +68,48 @@ void installWalletCore() {
CryptoWallet.incomingTxNotifier = (tx, _) {
final amount =
double.tryParse(baseUnitsToDecimalString(tx.amountBaseUnits, _moneroDecimals)) ?? 0;
+ void show() => NotificationService().showIncomingTxNotification(
+ title: 'Incoming transaction',
+ body: 'You received $amount XMR',
+ );
if (!_isMobile) {
// Desktop has no notifications toggle (it's Android/iOS-only), so it always
// shows an incoming-tx notification.
- NotificationService().showIncomingTxNotification(amount);
+ show();
return;
}
// Mobile respects the toggle. notifyNewIncomingTxs still records the tx as
// seen whether or not this fires, so turning it on later does not replay a
// backlog.
SharedPreferencesService.get<bool>(SharedPreferencesKeys.notificationsEnabled).then((on) {
- if (on ?? false) NotificationService().showIncomingTxNotification(amount);
+ if (on ?? false) show();
});
};
}
-/// Opens the XMR wallet inside a background isolate (WorkManager / foreground
-/// service), or returns null when there is no wallet or this window should not
-/// sync it. The connection is loaded first so the correct-mode file opens and
-/// the node/Tor gates can be checked before the expensive open.
-///
-/// [allowTor]/[allowNode] describe what the scheduling window can accommodate;
-/// [requireBackgroundSyncForNode] additionally skips a node wallet unless the
-/// user turned Background Sync on (a node scan is heavy — the periodic task
-/// sets it, the foreground service does not). The manager is kept alive for the
-/// isolate's lifetime by the wallet's listener back to it.
-Future<AppWallet?> openBackgroundWallet({
- bool allowTor = true,
- bool allowNode = true,
- bool requireBackgroundSyncForNode = false,
-}) async {
- installWalletCore();
- final manager = WalletManager(coins: () => [MoneroWallet()]);
- if (!await manager.hasAnyExistingWallet()) return null;
-
- // Loads the persisted connection for each coin without opening files.
- await manager.loadCachedDisplayState();
- final wallet = manager.getWallet('XMR') as MoneroWallet;
- if (!await _shouldBackgroundSync(
- connectionType: wallet.connectionType,
- usingTor: wallet.usingTor,
- allowTor: allowTor,
- allowNode: allowNode,
- requireBackgroundSyncForNode: requireBackgroundSyncForNode,
- )) {
- return null;
- }
-
- await manager.openAll();
- return MoneroWalletAdapter(wallet);
-}
-
-Future<bool> _shouldBackgroundSync({
- required String connectionType,
- required bool usingTor,
- required bool allowTor,
- required bool allowNode,
- required bool requireBackgroundSyncForNode,
-}) async {
- if (!allowNode && connectionType == 'node') return false;
- if (!allowTor && usingTor) return false;
- if (requireBackgroundSyncForNode && connectionType == 'node') {
- final on =
- await SharedPreferencesService.get<bool>(SharedPreferencesKeys.backgroundSyncEnabled) ??
- false;
- if (!on) return false;
- }
- return true;
+/// Brings skylight's Tor up and reports whether it connected — the seam
+/// `wallet_background` uses so a background isolate starts the *same* Tor the
+/// wallet connects through. (Background open + the node/Tor gate now live inside
+/// `wallet_background`.)
+Future<bool> _ensureTorConnected() async {
+ await TorService.sharedInstance.start();
+ return TorService.sharedInstance.waitUntilConnected(timeout: const Duration(minutes: 2));
}
/// The wallet-core [WalletManager] provider.
ChangeNotifierProvider<WalletManager> walletManagerProvider() =>
ChangeNotifierProvider(create: (_) => WalletManager(coins: () => [MoneroWallet()]));
+/// Attaches the [WalletManager] to the fiat model so it fetches rates for the
+/// active coins (XMR). Call once at startup; the model stays attached, so every
+/// `FiatRateModel.startService()` afterwards needs no manager argument.
+void attachFiatWalletManager(BuildContext context) {
+ Provider.of<FiatRateModel>(
+ context,
+ listen: false,
+ ).attachWalletManager(Provider.of<WalletManager>(context, listen: false));
+}
+
/// Startup for the wallet-core stack: whether a wallet exists (for the initial
/// route) and, on mobile, opening + starting its sync.
Future<bool> startupWalletManager(BuildContext context) =>
diff --git a/pubspec.lock b/pubspec.lock
index 697fec1..3977f95 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -1229,6 +1229,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.0.2"
+ wallet_background:
+ dependency: "direct main"
+ description:
+ path: "../wallet-core/packages/wallet_background"
+ relative: true
+ source: path
+ version: "0.1.0"
wallet_domain:
dependency: "direct main"
description:
@@ -1236,6 +1243,13 @@ packages:
relative: true
source: path
version: "0.1.0"
+ wallet_fiat:
+ dependency: "direct main"
+ description:
+ path: "../wallet-core/packages/wallet_fiat"
+ relative: true
+ source: path
+ version: "0.1.0"
wallet_infra:
dependency: "direct main"
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index d0533f7..99fa181 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -65,23 +65,33 @@ dependencies:
wallet_infra:
git:
url: https://github.com/MAGICGrants/wallet-core
- ref: 6bbf5059257f18e65dc7fcdf089e355181a2d578
+ ref: f674cb907ab971126a88dcc78c33b549b630f58f
path: packages/wallet_infra
wallet_domain:
git:
url: https://github.com/MAGICGrants/wallet-core
- ref: 6bbf5059257f18e65dc7fcdf089e355181a2d578
+ ref: f674cb907ab971126a88dcc78c33b549b630f58f
path: packages/wallet_domain
wallet_monero:
git:
url: https://github.com/MAGICGrants/wallet-core
- ref: 6bbf5059257f18e65dc7fcdf089e355181a2d578
+ ref: f674cb907ab971126a88dcc78c33b549b630f58f
path: packages/wallet_monero
wallet_openalias:
git:
url: https://github.com/MAGICGrants/wallet-core
- ref: 6bbf5059257f18e65dc7fcdf089e355181a2d578
+ ref: f674cb907ab971126a88dcc78c33b549b630f58f
path: packages/wallet_openalias
+ wallet_background:
+ git:
+ url: https://github.com/MAGICGrants/wallet-core
+ ref: f674cb907ab971126a88dcc78c33b549b630f58f
+ path: packages/wallet_background
+ wallet_fiat:
+ git:
+ url: https://github.com/MAGICGrants/wallet-core
+ ref: f674cb907ab971126a88dcc78c33b549b630f58f
+ path: packages/wallet_fiat
cupertino_icons: 1.0.8
path: 1.9.1
diff --git a/test/tx_notification_state_test.dart b/test/tx_notification_state_test.dart
deleted file mode 100644
index acabbd3..0000000
--- a/test/tx_notification_state_test.dart
+++ /dev/null
@@ -1,101 +0,0 @@
-import 'dart:convert';
-
-import 'package:flutter_secure_storage/flutter_secure_storage.dart';
-import 'package:flutter_test/flutter_test.dart';
-import 'package:shared_preferences/shared_preferences.dart';
-
-import 'package:skylight_wallet/models/wallet_model.dart';
-import 'package:skylight_wallet/util/tx_notification_state.dart';
-
-/// Covers the persisted side of the notification marker: that it lives in
-/// secure storage, that it seeds rather than announcing a backlog, and that a
-/// build upgrading from the old plaintext counter starts clean.
-void main() {
- TestWidgetsFlutterBinding.ensureInitialized();
-
- setUp(() {
- SharedPreferences.setMockInitialValues({'notificationsEnabled': true});
- FlutterSecureStorage.setMockInitialValues({});
- });
-
- Future<Map<String, dynamic>?> storedState() async {
- final raw = await const FlutterSecureStorage().read(key: 'txNotificationState');
- return raw == null ? null : json.decode(raw) as Map<String, dynamic>;
- }
-
- test('nothing about announced transactions is written to shared preferences', () async {
- await WalletModel().markExistingTxsAsNotified();
-
- // These are on-chain identifiers; they must not land in the plaintext
- // preferences file alongside the theme and language settings.
- final prefs = await SharedPreferences.getInstance();
- expect(prefs.getKeys(), ['notificationsEnabled']);
- expect(await storedState(), isNotNull);
- });
-
- test('markExistingTxsAsNotified seeds the cutoff to now and clears the hashes', () async {
- FlutterSecureStorage.setMockInitialValues({
- 'txNotificationState': json.encode({
- 'cutoff': 10,
- 'announcedHashes': ['stale'],
- }),
- });
-
- final before = DateTime.now().millisecondsSinceEpoch ~/ 1000;
- await WalletModel().markExistingTxsAsNotified();
- final after = DateTime.now().millisecondsSinceEpoch ~/ 1000;
-
- final state = await storedState();
- expect(state!['cutoff'], greaterThanOrEqualTo(before));
- expect(state['cutoff'], lessThanOrEqualTo(after));
- expect(state['announcedHashes'], isEmpty);
- });
-
- test('the first run with no marker seeds instead of announcing a backlog', () async {
- // What an upgrade from the old count-based marker looks like: notifications
- // switched on, nothing recorded about what has been announced.
- expect(await readTxNotificationState().then((s) => s.cutoff), isNull);
-
- await WalletModel().notifyNewIncomingTxs();
-
- // Seeded, so the next run compares against now rather than the epoch. If
- // this regressed, a wallet with history would announce all of it at once.
- expect((await storedState())!['cutoff'], isNotNull);
- });
-
- test('a seeded marker is left alone when there is nothing new', () async {
- FlutterSecureStorage.setMockInitialValues({
- 'txNotificationState': json.encode({
- 'cutoff': 1234,
- 'announcedHashes': ['a'],
- }),
- });
-
- await WalletModel().notifyNewIncomingTxs();
-
- final state = await storedState();
- expect(state!['cutoff'], 1234);
- expect(state['announcedHashes'], ['a']);
- });
-
- test('an unreadable entry reseeds rather than throwing or announcing', () async {
- FlutterSecureStorage.setMockInitialValues({'txNotificationState': 'not json'});
-
- expect((await readTxNotificationState()).cutoff, isNull);
- await expectLater(WalletModel().notifyNewIncomingTxs(), completes);
- expect((await storedState())!['cutoff'], isNotNull);
- });
-
- test('state survives a round trip', () async {
- await writeTxNotificationState(
- const TxNotificationState(cutoff: 42, announcedHashes: ['a', 'b']),
- );
-
- final read = await readTxNotificationState();
- expect(read.cutoff, 42);
- expect(read.announcedHashes, ['a', 'b']);
-
- await clearTxNotificationState();
- expect((await readTxNotificationState()).cutoff, isNull);
- });
-}
diff --git a/test/tx_notifications_test.dart b/test/tx_notifications_test.dart
index 505116e..c5bb6ba 100644
--- a/test/tx_notifications_test.dart
+++ b/test/tx_notifications_test.dart
@@ -1,7 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:skylight_wallet/consts.dart' as consts;
-import 'package:skylight_wallet/models/wallet_model.dart';
+import 'package:skylight_wallet/models/wallet_types.dart';
import 'package:skylight_wallet/util/tx_notifications.dart';
/// A transaction as the wallet reports it. [height] of -1 means unconfirmed.
Why this scored 31/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.