Fix notifications, use secure storage more, iOS background sync
What changed, and why it matters
This commit is a large hardening and bug-fix patch for a mobile Monero wallet. It fixes several privacy and reliability problems: it stops the app from silently falling back to clearnet when Tor is required, moves sensitive address-book and transaction data out of plain storage into encrypted storage, prevents the app from overwriting a corrupted address book with an empty one, fixes notification bugs that could spam or miss transaction alerts, and adds proper iOS background sync. The changes are defensive and reduce the chance of leaking a user's view key, IP address, or contacts.
Review and merge after normal code review and QA. The patch is defensive and fixes multiple privacy/reliability issues. Verify iOS background entitlement declarations match the registered task identifiers, and run the included unit tests for contacts and notification state. No urgent incident response is indicated by the diff itself.
Security signals we found
Fail-closed Tor behavior prevents clearnet fallback that would leak view key and IP
Sensitive data (contacts, announced tx hashes) moved from plaintext SharedPreferences to secure storage
Guard against overwriting unreadable/corrupted secure storage with empty data
Bounded Tor wait with timer cleanup and retry, replacing unbounded polling
Per-request SOCKS socket close to prevent Tor circuit accumulation
Background notification state deduplication to prevent replay/spam
iOS background task registration with Tor/clearnet and node/LWS constraints
Evidence from the diff
The patch refactors background sync/notification logic, Tor handling, and secure storage use. Key changes: (1) Tor-only connections now fail closed instead of connecting directly when Tor is unavailable, with UI state torRequirementBroken. (2) Contacts and transaction-notification state move from SharedPreferences to secure storage, with migration and corruption-safety guards (isUnreadable). (3) Transaction notifications are now based on a persisted cutoff + announced-hash set, eliminating double-announcements, backlog spam on restore, and mempool/mined re-announcements. (4) iOS gets BGAppRefreshTask and BGProcessingTask registrations, with Tor only in the longer processing window. (5) Tor waitUntilConnected is now bounded, cancels its timer, and retries a failed start. (6) SOCKS sockets are closed after each request to avoid circuit leaks. (7) Notification plugin initialization is guarded per-isolate for background execution.
Changed components
lib/models/wallet_model.dartlib/models/contact_model.dartlib/services/tor_service.dartlib/services/tor_settings_service.dartlib/services/notifications_service.dartlib/services/foreground_sync_service.dartlib/periodic_tasks.dartlib/util/contacts_store.dartlib/util/tx_notification_state.dartlib/util/tx_notifications.dartlib/util/socks_http.dartios/Runner/AppDelegate.swiftios/Runner/Info.plistInspect captured patch +1182 / −93
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index fd135a1..50a8a71 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -1,5 +1,6 @@
import Flutter
import UIKit
+import workmanager_apple
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@@ -16,6 +17,23 @@ import UIKit
// Enable background fetch for workmanager
UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum)
+ // BGTaskScheduler requires every identifier to be registered before launch
+ // finishes, and each must also appear in BGTaskSchedulerPermittedIdentifiers.
+ // Whether either task is actually scheduled is decided in Dart, from the
+ // connection the wallet is using.
+ let bundleId = Bundle.main.bundleIdentifier ?? "org.magicgrants.skylightwallet"
+
+ // Short opportunistic wake-up. iOS grants it roughly 30 seconds, so it is
+ // only ever scheduled for an LWS connection on clearnet.
+ WorkmanagerPlugin.registerPeriodicTask(
+ withIdentifier: "\(bundleId).refresh",
+ frequency: NSNumber(value: 15 * 60)
+ )
+
+ // Longer run, only while charging and idle. This is the one that can afford
+ // a Tor bootstrap before syncing.
+ WorkmanagerPlugin.registerBGProcessingTask(withIdentifier: "\(bundleId).processing")
+
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 15ccae2..882a2f3 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -5,6 +5,11 @@
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+ <!-- Short opportunistic wake-up: LWS over clearnet only. -->
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER).refresh</string>
+ <!-- Longer run while charging and idle; the only window in which Tor
+ has time to bootstrap before the sync. -->
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER).processing</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 1f61037..c33500b 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -120,6 +120,7 @@
"sendInsufficientBalanceToCoverFeeError": "Insufficient balance to cover the network fee.",
"settingsTitle": "Settings",
"settingsNotifyNewTxsLabel": "Notify New Transactions",
+ "settingsNotifyNewTxsDescriptionIos": "Notifications will be delayed for Tor LWS connections.",
"settingsAppLockLabel": "App Lock",
"settingsAppLockUnlockReason": "Unlock Wallet",
"settingsAppLockUnableToAuthError": "Unable to authenticate. Make sure you have device unlock set up.",
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index 2bda039..4d53a50 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -729,6 +729,12 @@ abstract class AppLocalizations {
/// **'Notify New Transactions'**
String get settingsNotifyNewTxsLabel;
+ /// No description provided for @settingsNotifyNewTxsDescriptionIos.
+ ///
+ /// In en, this message translates to:
+ /// **'Notifications will be delayed for Tor LWS connections.'**
+ String get settingsNotifyNewTxsDescriptionIos;
+
/// No description provided for @settingsAppLockLabel.
///
/// In en, this message translates to:
diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart
index 6c65c4b..503f317 100644
--- a/lib/l10n/app_localizations_en.dart
+++ b/lib/l10n/app_localizations_en.dart
@@ -344,6 +344,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsNotifyNewTxsLabel => 'Notify New Transactions';
+ @override
+ String get settingsNotifyNewTxsDescriptionIos =>
+ 'Notifications will be delayed for Tor LWS connections.';
+
@override
String get settingsAppLockLabel => 'App Lock';
diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart
index 635495a..31971ed 100644
--- a/lib/l10n/app_localizations_pt.dart
+++ b/lib/l10n/app_localizations_pt.dart
@@ -344,6 +344,10 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get settingsNotifyNewTxsLabel => 'Notificar Novas Transações';
+ @override
+ String get settingsNotifyNewTxsDescriptionIos =>
+ 'As notificações serão atrasadas para conexões LWS via Tor.';
+
@override
String get settingsAppLockLabel => 'Desbloqueio com PIN/Biometria';
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index 54420a6..0164cd2 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -120,6 +120,7 @@
"sendInsufficientBalanceToCoverFeeError": "Saldo insuficiente para cobrir a taxa da rede.",
"settingsTitle": "Configurações",
"settingsNotifyNewTxsLabel": "Notificar Novas Transações",
+ "settingsNotifyNewTxsDescriptionIos": "As notificações serão atrasadas para conexões LWS via Tor.",
"settingsAppLockLabel": "Desbloqueio com PIN/Biometria",
"settingsAppLockUnlockReason": "Desbloquear carteira",
"settingsAppLockUnableToAuthError": "Não foi possível autenticar. Verifique se o desbloqueio de tela está configurado.",
diff --git a/lib/main.dart b/lib/main.dart
index 4e70cb5..e27e202 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -83,6 +83,10 @@ void main() async {
if (Platform.isIOS) {
await cleanTorDirectoriesOnIOS();
+ // Background sync here is LWS-only and decided by the connection; see
+ // periodic_tasks._applyIosBackgroundTasks.
+ registerPeriodicTasks();
+ NotificationService().init();
}
cleanOldLogFiles();
diff --git a/lib/models/contact_model.dart b/lib/models/contact_model.dart
index 19346aa..2e9db91 100644
--- a/lib/models/contact_model.dart
+++ b/lib/models/contact_model.dart
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
-import 'package:shared_preferences/shared_preferences.dart';
-import 'package:skylight_wallet/services/shared_preferences_service.dart';
+import 'package:skylight_wallet/util/contacts_store.dart';
import 'package:skylight_wallet/util/logging.dart';
class Contact {
@@ -27,33 +26,54 @@ class Contact {
class ContactModel with ChangeNotifier {
List<Contact> _contacts = [];
+ /// Set when the address book could not be read. Saving is refused while it
+ /// holds: an empty in-memory list written over a store we simply failed to
+ /// open would destroy the address book.
+ bool _unreadable = false;
+
List<Contact> get contacts => List.unmodifiable(_contacts);
+ /// True when the stored address book couldn't be read, so what's in memory
+ /// isn't the whole picture and edits aren't being saved.
+ bool get isUnreadable => _unreadable;
+
ContactModel() {
- _loadContacts();
+ load();
}
- Future<void> _loadContacts() async {
+ @visibleForTesting
+ Future<void> load() async {
try {
- final prefs = await SharedPreferences.getInstance();
- final contactsJson = prefs.getStringList(SharedPreferencesKeys.contacts) ?? [];
+ final storedContacts = await readEncodedContacts();
+
+ if (storedContacts == null) {
+ _unreadable = true;
+ log(LogLevel.error, 'Address book could not be read; not saving over it.');
+ return;
+ }
- _contacts = contactsJson
+ _unreadable = false;
+ _contacts = storedContacts
.map((jsonString) => Contact.fromJson(json.decode(jsonString) as Map<String, dynamic>))
.toList();
notifyListeners();
} catch (e) {
+ _unreadable = true;
log(LogLevel.error, 'Error loading contacts: $e');
}
}
Future<void> _saveContacts() async {
- try {
- final prefs = await SharedPreferences.getInstance();
- final contactsJson = _contacts.map((contact) => json.encode(contact.toJson())).toList();
+ if (_unreadable) {
+ log(LogLevel.error, 'Refusing to save contacts over an address book that failed to load.');
+ return;
+ }
- await prefs.setStringList(SharedPreferencesKeys.contacts, contactsJson);
+ try {
+ await writeEncodedContacts(
+ _contacts.map((contact) => json.encode(contact.toJson())).toList(),
+ );
} catch (e) {
log(LogLevel.error, 'Error saving contacts: $e');
}
diff --git a/lib/models/wallet_model.dart b/lib/models/wallet_model.dart
index e5b918b..30c9132 100644
--- a/lib/models/wallet_model.dart
+++ b/lib/models/wallet_model.dart
@@ -22,10 +22,13 @@ import 'package:skylight_wallet/services/tor_settings_service.dart';
import 'package:skylight_wallet/util/amount_units.dart';
import 'package:skylight_wallet/util/bip39.dart';
import 'package:skylight_wallet/util/cacert.dart';
+import 'package:skylight_wallet/util/contacts_store.dart';
import 'package:skylight_wallet/util/formatting.dart';
import 'package:skylight_wallet/util/get_height_by_date.dart';
import 'package:skylight_wallet/util/logging.dart';
import 'package:skylight_wallet/util/socks_http.dart';
+import 'package:skylight_wallet/util/tx_notification_state.dart';
+import 'package:skylight_wallet/util/tx_notifications.dart';
import 'package:skylight_wallet/util/wallet.dart';
import 'package:skylight_wallet/util/wallet_password.dart';
import 'package:skylight_wallet/consts.dart' as consts;
@@ -163,6 +166,10 @@ class WalletModel with ChangeNotifier {
Future<void>? _connectInFlight;
DateTime? _lastConnectAttempt;
int _connectFailures = 0;
+ // Set when this connection is marked Tor-only but no Tor proxy can be had.
+ // Nothing connects while it holds — the alternative is a silent clearnet
+ // fallback that leaks the view key and the user's IP to the server.
+ bool _torRequirementBroken = false;
final _sessionStartedAt = DateTime.now().secondsSinceEpoch;
var _hasAttemptedConnection = false;
@@ -190,6 +197,10 @@ class WalletModel with ChangeNotifier {
double? get totalBalance => _totalBalance;
List<TxDetails> get txHistory => _txHistory;
bool get usingTor => _connectionUseTor;
+
+ /// True when this connection requires Tor but none is available, so the app
+ /// is deliberately not connecting at all.
+ bool get torRequirementBroken => _torRequirementBroken;
bool? get serverSupportsSubaddresses => _serverSupportsSubaddresses;
int? get unusedSubaddressIndex => _unusedSubaddressIndex;
bool? get unusedSubaddressIndexIsSupported => _unusedSubaddressIndexIsSupported;
@@ -400,7 +411,14 @@ class WalletModel with ChangeNotifier {
notifyListeners();
}
- Future<void> loadTxHistory({bool persistCount = true}) async {
+ /// Re-reads the transaction list from the wallet's cache.
+ ///
+ /// Note what this deliberately does *not* do: touch notification state. Every
+ /// isolate (UI, foreground service, background task) refreshes history on its
+ /// own timer, so anything recorded here would be consumed by whichever one
+ /// refreshed first, whether or not it announced anything. That belongs to
+ /// [notifyNewIncomingTxs].
+ Future<void> loadTxHistory() async {
final txCount = _w2TxHistory!.count();
var hasPendingTx = false;
@@ -433,10 +451,6 @@ class WalletModel with ChangeNotifier {
}
}
}
-
- if (persistCount) {
- await persistTxHistoryCount();
- }
}
if (txCount > _txHistory.length) {
@@ -488,19 +502,51 @@ class WalletModel with ChangeNotifier {
await loadPersistedConnection();
}
- Future<void> persistTxHistoryCount() async {
- if (_txHistory.isEmpty) {
+ /// Treats everything currently on chain as already seen. Called when a wallet
+ /// is created or restored and when notifications are switched on, so the user
+ /// is told about what arrives from here on rather than their whole history.
+ Future<void> markExistingTxsAsNotified() async {
+ await writeTxNotificationState(
+ TxNotificationState(cutoff: DateTime.now().secondsSinceEpoch, announcedHashes: const []),
+ );
+ }
+
+ /// Announces incoming transactions the user hasn't been told about yet.
+ ///
+ /// Safe to call from any isolate and as often as you like: what has been
+ /// announced is persisted, so the background task, the foreground service and
+ /// a future caller can't double-announce or cancel each other out.
+ Future<void> notifyNewIncomingTxs() async {
+ final state = await readTxNotificationState();
+
+ // Never seeded (fresh install, or an upgrade from the old counter): take
+ // the current chain as the starting point instead of announcing a backlog.
+ if (state.cutoff == null) {
+ await markExistingTxsAsNotified();
return;
}
- await SharedPreferencesService.set<int>(
- SharedPreferencesKeys.txHistoryCount,
- _txHistory.length,
+ final decision = decideTxNotifications(
+ txHistory: _txHistory,
+ cutoff: state.cutoff!,
+ announcedHashes: state.announcedHashes,
);
- }
- Future<int> getPersistedTxHistoryCount() async {
- return await SharedPreferencesService.get<int>(SharedPreferencesKeys.txHistoryCount) ?? 0;
+ final notificationsEnabled =
+ await SharedPreferencesService.get<bool>(SharedPreferencesKeys.notificationsEnabled) ??
+ false;
+
+ if (notificationsEnabled) {
+ for (final tx in decision.toAnnounce) {
+ await NotificationService().showIncomingTxNotification(tx.amount);
+ }
+ }
+
+ // Recorded either way: with notifications off these are still "seen", so
+ // switching the setting on later doesn't replay them.
+ await writeTxNotificationState(
+ TxNotificationState(cutoff: decision.cutoff, announcedHashes: decision.announcedHashes),
+ );
}
/// Loads balances + tx history from the just-opened monero_c wallet cache,
@@ -534,6 +580,21 @@ class WalletModel with ChangeNotifier {
_connectionUseSsl = useSsl;
_connectionType = connectionType;
_connectionLoaded = true;
+ // A reconfigured connection gets a clean slate; the next attempt decides
+ // again whether Tor is available for it.
+ _torRequirementBroken = false;
+ notifyListeners();
+ }
+
+ /// Called when Tor is switched off globally. A connection that requires Tor
+ /// is marked broken and reported disconnected straight away, rather than
+ /// looking healthy until the next refresh cycle notices.
+ void onGlobalTorDisabled() {
+ if (!_connectionUseTor || _torRequirementBroken) return;
+
+ log(LogLevel.warn, 'Tor disabled globally; a Tor-only connection can no longer be used.');
+ _torRequirementBroken = true;
+ _isConnected = false;
notifyListeners();
}
@@ -582,11 +643,25 @@ class WalletModel with ChangeNotifier {
if (_connectionUseTor) {
final proxyInfo = await TorSettingsService.sharedInstance.getProxy();
- if (proxyInfo != null) {
- torProxyPort = proxyInfo.port.toString();
+
+ if (proxyInfo == null) {
+ // Fail closed. Carrying on would leave proxyAddress empty and Wallet_init
+ // would reach the server directly — handing it the primary address, the
+ // private view key and the real IP, every refresh cycle, on a connection
+ // the user marked Tor-only. Never fall back to clearnet.
+ log(LogLevel.warn, 'Connection requires Tor but no proxy is available; not connecting.');
+ _torRequirementBroken = true;
+ _hasAttemptedConnection = true;
+ _isConnected = false;
+ notifyListeners();
+ return;
}
+
+ torProxyPort = proxyInfo.port.toString();
}
+ _torRequirementBroken = false;
+
final proxyPort = torProxyPort ?? _connectionProxyPort;
if (Platform.isAndroid) {
@@ -921,7 +996,11 @@ class WalletModel with ChangeNotifier {
for (int i = 0; i < 3; i++) {
try {
if (_connectionUseTor) {
- await TorService.sharedInstance.waitUntilConnected();
+ // This POSTs the view key. Without Tor it does not go out at all.
+ if (!await TorService.sharedInstance.waitUntilConnected()) {
+ throw Exception('Tor is required for this connection but is unavailable.');
+ }
+
final proxyInfo = TorService.sharedInstance.getProxyInfo();
final response = await makeSocksHttpRequest(
'POST',
@@ -1226,6 +1305,10 @@ class WalletModel with ChangeNotifier {
);
}
+ // Whatever this seed already has on chain is history, not news — a restore
+ // would otherwise announce every incoming transaction it scans.
+ await markExistingTxsAsNotified();
+
if (Platform.isAndroid || Platform.isIOS) {
await storeMobileWalletPassword(walletPassword);
}
@@ -1387,12 +1470,11 @@ class WalletModel with ChangeNotifier {
}
await SharedPreferencesService.remove(SharedPreferencesKeys.connectionType);
- await SharedPreferencesService.remove(SharedPreferencesKeys.txHistoryCount);
+ await clearTxNotificationState();
await SharedPreferencesService.remove(SharedPreferencesKeys.walletRestoreHeight);
await SharedPreferencesService.remove(SharedPreferencesKeys.appLockEnabled);
- await SharedPreferencesService.remove(SharedPreferencesKeys.pendingOutgoingTxs);
await SharedPreferencesService.remove(SharedPreferencesKeys.serverSupportsSubaddresses);
- await SharedPreferencesService.remove(SharedPreferencesKeys.contacts);
+ await clearContacts();
await SharedPreferencesService.remove(SharedPreferencesKeys.unusedSubaddressIndex);
await SharedPreferencesService.remove(SharedPreferencesKeys.unusedSubaddressIndexIsSupported);
}
diff --git a/lib/periodic_tasks.dart b/lib/periodic_tasks.dart
index 184dab1..a4cbe24 100644
--- a/lib/periodic_tasks.dart
+++ b/lib/periodic_tasks.dart
@@ -1,21 +1,40 @@
import 'dart:io';
import 'package:skylight_wallet/models/wallet_model.dart';
-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/util/logging.dart';
import 'package:workmanager/workmanager.dart';
-import 'package:skylight_wallet/consts.dart' as consts;
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);
@@ -28,7 +47,18 @@ const _backgroundSyncStuckPolls = 12;
/// WorkManager's minimum periodic interval.
const _minSyncIntervalMinutes = 15;
-Future<bool> runTxNotifier() async {
+/// 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 {
final wallet = WalletModel();
if (!await wallet.hasExistingWallet()) {
@@ -38,6 +68,16 @@ Future<bool> runTxNotifier() async {
// Load the connection first so the correct-mode wallet file is opened.
await wallet.loadPersistedConnection();
+ if (!allowNode && wallet.connectionType == 'node') {
+ log(LogLevel.info, '[Background sync] Node connection; not syncing in this window.');
+ return true;
+ }
+
+ if (!allowTor && wallet.usingTor) {
+ log(LogLevel.info, '[Background sync] Tor connection; needs the longer window.');
+ return true;
+ }
+
final backgroundSync =
await SharedPreferencesService.get<bool>(SharedPreferencesKeys.backgroundSyncEnabled) ??
false;
@@ -57,10 +97,14 @@ Future<bool> runTxNotifier() async {
if (wallet.usingTor) {
await TorService.sharedInstance.start();
- await TorService.sharedInstance.waitUntilConnected().timeout(
- Duration(minutes: 2),
- onTimeout: () => log(LogLevel.warn, '[Background sync] Tor connection timed out'),
+ 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) {
@@ -77,7 +121,7 @@ Future<bool> runTxNotifier() async {
// 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(_backgroundSyncBudget);
+ final deadline = DateTime.now().add(budget);
var lastSyncedHeight = wallet.syncedHeight;
var stuckPolls = 0;
@@ -108,58 +152,47 @@ Future<bool> runTxNotifier() async {
// returns, and the last partial cycle of scanning would be thrown away.
await wallet.pauseSyncAndStore();
- final notify =
- await SharedPreferencesService.get<bool>(SharedPreferencesKeys.notificationsEnabled) ?? false;
-
try {
- await wallet.loadTxHistory(persistCount: false);
+ await wallet.loadTxHistory();
} catch (e) {
log(LogLevel.warn, '[Background sync] loadTxHistory failed: $e');
}
- await _notifyNewTxs(wallet, notify: notify);
+ try {
+ await wallet.notifyNewIncomingTxs();
+ } catch (e) {
+ log(LogLevel.warn, '[Background sync] notifying new transactions failed: $e');
+ }
return true;
}
-Future<void> _notifyNewTxs(WalletModel wallet, {required bool notify}) async {
- final persistedCount = await wallet.getPersistedTxHistoryCount();
- final currentCount = wallet.txHistory.length;
- final countOfNewTxs = currentCount - persistedCount;
-
- if (countOfNewTxs > 0 && currentCount != 0) {
- // Only surface a notification when notifications are on; either way advance
- // the baseline count so we don't re-notify (or flood) next run.
- if (notify) {
- for (int i = 0; i < countOfNewTxs; i++) {
- final tx = wallet.txHistory[i];
- if (tx.direction == consts.txDirectionIncoming) {
- NotificationService().showIncomingTxNotification(tx.amount);
- }
- }
- }
-
- await wallet.persistTxHistoryCount();
- }
-}
-
@pragma('vm:entry-point')
void _callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
- switch (PeriodicTasks.txNotifier) {
+ 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();
}
-
- return true;
});
}
-/// (Re)registers the background task if background sync or notifications is on,
-/// otherwise cancels it. Notifications need the task to run to detect new txs,
-/// so either flag keeps it scheduled; the interval comes from the background-
-/// sync setting. Android only.
+/// (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 =
@@ -192,8 +225,46 @@ Future<void> applyBackgroundTaskRegistration() async {
);
}
+/// 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) {
+ if (!Platform.isAndroid && !Platform.isIOS) {
return;
}
diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart
index 494e601..6d2a031 100644
--- a/lib/screens/settings.dart
+++ b/lib/screens/settings.dart
@@ -68,6 +68,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
void _setTxNotificationsEnabled(bool value) async {
+ final wallet = Provider.of<WalletModel>(context, listen: false);
+
setState(() {
_newTxNotificationsEnabled = value;
});
@@ -76,6 +78,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
final isAllowed = await NotificationService().promptPermission();
if (isAllowed) {
+ // Start from now, so switching this on doesn't announce the backlog of
+ // everything already received.
+ await wallet.markExistingTxsAsNotified();
await SharedPreferencesService.set<bool>(SharedPreferencesKeys.notificationsEnabled, true);
await applyBackgroundTaskRegistration();
}
@@ -471,11 +476,25 @@ class _SettingsScreenState extends State<SettingsScreen> {
Switch(value: _appLockEnabled, onChanged: _setAppLockEnabled),
],
),
- if (Platform.isAndroid)
+ // On iOS this is offered for LWS only: a remote node can't be
+ // scanned inside either background window iOS grants.
+ if (Platform.isAndroid || (Platform.isIOS && !context.watch<WalletModel>().isNodeMode))
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- Text(i18n.settingsNotifyNewTxsLabel, style: TextStyle(fontSize: 18)),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(i18n.settingsNotifyNewTxsLabel, style: TextStyle(fontSize: 18)),
+ if (Platform.isIOS)
+ Text(
+ i18n.settingsNotifyNewTxsDescriptionIos,
+ style: TextStyle(fontSize: 12, color: Colors.grey[600]),
+ ),
+ ],
+ ),
+ ),
Switch(value: _newTxNotificationsEnabled, onChanged: _setTxNotificationsEnabled),
],
),
diff --git a/lib/screens/wallet_home.dart b/lib/screens/wallet_home.dart
index 67c68df..4ecfd48 100644
--- a/lib/screens/wallet_home.dart
+++ b/lib/screens/wallet_home.dart
@@ -202,6 +202,17 @@ class _WalletHomeScreenState extends State<WalletHomeScreen> {
final i18n = AppLocalizations.of(context)!;
String message;
+ // Deliberately not connected: say so, rather than reporting a Tor
+ // connection that isn't happening.
+ if (wallet.torRequirementBroken) {
+ return Tooltip(
+ message:
+ 'Not connecting: this connection requires Tor, but no Tor proxy is available. '
+ 'Re-enable Tor, or edit the connection.',
+ child: StatusIcon(status: StatusIconStatus.fail, torIsEnabled: wallet.usingTor),
+ );
+ }
+
// While syncing a full node, show the sync progress instead of the address.
final blocksRemaining = wallet.syncBlocksRemaining;
if (lwsConnectionIconStatus != StatusIconStatus.complete && blocksRemaining != null) {
diff --git a/lib/services/foreground_sync_service.dart b/lib/services/foreground_sync_service.dart
index f2dc164..0cc217e 100644
--- a/lib/services/foreground_sync_service.dart
+++ b/lib/services/foreground_sync_service.dart
@@ -1,3 +1,4 @@
+import 'dart:async';
import 'dart:io';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
@@ -35,10 +36,12 @@ class _SyncTaskHandler extends TaskHandler {
if (wallet.usingTor) {
await TorService.sharedInstance.start();
- await TorService.sharedInstance.waitUntilConnected().timeout(
- const Duration(minutes: 2),
- onTimeout: () => log(LogLevel.warn, '[FG sync] Tor connection timed out'),
- );
+ 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;
@@ -65,6 +68,17 @@ class _SyncTaskHandler extends TaskHandler {
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
diff --git a/lib/services/notifications_service.dart b/lib/services/notifications_service.dart
index 98e7ad1..e6abf9a 100644
--- a/lib/services/notifications_service.dart
+++ b/lib/services/notifications_service.dart
@@ -3,9 +3,16 @@ 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
@@ -33,6 +40,7 @@ class NotificationService {
);
await notificationsPlugin.initialize(initSettings);
+ _initialized = true;
}
Future<bool> promptPermission() async {
@@ -57,6 +65,10 @@ class NotificationService {
}
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(
diff --git a/lib/services/shared_preferences_service.dart b/lib/services/shared_preferences_service.dart
index 214542d..bd35160 100644
--- a/lib/services/shared_preferences_service.dart
+++ b/lib/services/shared_preferences_service.dart
@@ -19,8 +19,6 @@ class SharedPreferencesKeys {
static const String connectionType = 'connectionType';
static const String serverSupportsSubaddresses = 'serverSupportsSubaddresses';
static const String walletRestoreHeight = 'walletRestoreHeight';
- static const String txHistoryCount = 'txHistoryCount';
- static const String pendingOutgoingTxs = 'pendingOutgoingTxs';
static const String contacts = 'contacts';
static const String unusedSubaddressIndex = 'unusedSubaddressIndex';
static const String unusedSubaddressIndexIsSupported = 'unusedSubaddressIndexIsSupported';
diff --git a/lib/services/tor_service.dart b/lib/services/tor_service.dart
index 647817a..c82bf62 100644
--- a/lib/services/tor_service.dart
+++ b/lib/services/tor_service.dart
@@ -13,6 +13,7 @@ enum TorConnectionStatus { disconnected, connecting, connected }
class TorService {
Tor? _tor;
String? _torDataDirPath;
+ Future<void>? _startInFlight;
/// Current status. Same as that fired on the event bus.
TorConnectionStatus get status => _status;
@@ -46,6 +47,24 @@ class TorService {
///
/// Returns a Future that completes when the Tor service has started.
Future<void> start() async {
+ if (_status == TorConnectionStatus.connected) return;
+
+ // Concurrent callers join the attempt already running rather than starting
+ // a second Tor.
+ final inFlight = _startInFlight;
+ if (inFlight != null) return inFlight;
+
+ final attempt = _start();
+ _startInFlight = attempt;
+
+ try {
+ await attempt;
+ } finally {
+ if (identical(_startInFlight, attempt)) _startInFlight = null;
+ }
+ }
+
+ Future<void> _start() async {
_tor ??= Tor.instance;
_torDataDirPath ??= (await getAppDir()).path;
@@ -78,14 +97,40 @@ class TorService {
return;
}
- Future<void> waitUntilConnected() async {
- final completer = Completer<void>();
+ /// Waits for Tor to come up, returning whether it did.
+ ///
+ /// Bounded, and it always cancels its poll timer. The previous version did
+ /// neither: a Tor that never connected left a 50ms timer polling for the life
+ /// of the isolate — one per call — and the future never completed at all, so
+ /// a caller without its own timeout waited forever.
+ ///
+ /// A start attempt that failed earlier is retried here, since nothing else
+ /// retries it and the app would otherwise stay wedged until a restart.
+ Future<bool> waitUntilConnected({Duration timeout = const Duration(seconds: 60)}) async {
+ if (status == TorConnectionStatus.connected) return true;
+
+ if (_status == TorConnectionStatus.disconnected && _startInFlight == null) {
+ log(LogLevel.info, 'Tor is not running; retrying start.');
+ unawaited(start().catchError((Object e) => log(LogLevel.warn, 'Tor start retry failed: $e')));
+ }
+
+ final completer = Completer<bool>();
+ Timer? poll;
+ Timer? deadline;
+
+ void finish(bool connected) {
+ poll?.cancel();
+ deadline?.cancel();
+ if (!completer.isCompleted) completer.complete(connected);
+ }
+
+ poll = Timer.periodic(const Duration(milliseconds: 50), (_) {
+ if (status == TorConnectionStatus.connected) finish(true);
+ });
- Timer.periodic(Duration(milliseconds: 50), (timer) {
- if (status == TorConnectionStatus.connected) {
- timer.cancel();
- completer.complete();
- }
+ deadline = Timer(timeout, () {
+ log(LogLevel.warn, 'Gave up waiting for Tor after ${timeout.inSeconds}s.');
+ finish(false);
});
return completer.future;
diff --git a/lib/services/tor_settings_service.dart b/lib/services/tor_settings_service.dart
index 6d51c20..df170cf 100644
--- a/lib/services/tor_settings_service.dart
+++ b/lib/services/tor_settings_service.dart
@@ -62,7 +62,9 @@ class TorSettingsService {
Future<({InternetAddress host, int port})?> getProxy() async {
if (_torMode == TorMode.builtIn) {
- await TorService.sharedInstance.waitUntilConnected();
+ // No proxy rather than a hang: callers treat null as "Tor unavailable"
+ // and refuse to connect, instead of waiting forever.
+ if (!await TorService.sharedInstance.waitUntilConnected()) return null;
return TorService.sharedInstance.getProxyInfo();
} else if (_torMode == TorMode.external) {
return (host: InternetAddress.loopbackIPv4, port: int.parse(_socksPort));
diff --git a/lib/util/contacts_store.dart b/lib/util/contacts_store.dart
new file mode 100644
index 0000000..1307af9
--- /dev/null
+++ b/lib/util/contacts_store.dart
@@ -0,0 +1,84 @@
+import 'dart:convert';
+
+import 'package:shared_preferences/shared_preferences.dart';
+
+import 'package:skylight_wallet/services/shared_preferences_service.dart';
+import 'package:skylight_wallet/util/logging.dart';
+import 'package:skylight_wallet/util/secure_storage.dart';
+
+/// Storage for the address book.
+///
+/// Contacts are names attached to Monero addresses — the user's counterparties
+/// — so they live in secure storage rather than the plaintext preferences file.
+/// Earlier builds kept them in preferences; [readEncodedContacts] moves those
+/// across on first read and deletes the plaintext copy.
+///
+/// Entries are the JSON strings the contact model already encodes, held as one
+/// JSON array, because secure storage stores strings and not lists.
+const _storageKey = 'contacts';
+
+/// Reads the address book, or null if it could not be read.
+///
+/// Null is not the same as empty, and callers must not treat it as such: an
+/// unreadable store that reads as "no contacts" would be overwritten with an
+/// empty list by the next save, losing the address book for good.
+Future<List<String>?> readEncodedContacts() async {
+ String? stored;
+
+ try {
+ stored = await secureStorage.read(key: _storageKey);
+ } catch (e) {
+ log(LogLevel.error, 'Could not read contacts: $e');
+ return null;
+ }
+
+ if (stored == null) return _migrateFromPreferences();
+
+ if (stored.isEmpty) return [];
+
+ try {
+ return (json.decode(stored) as List<dynamic>).cast<String>();
+ } catch (e) {
+ log(LogLevel.error, 'Contacts are unreadable: $e');
+ return null;
+ }
+}
+
+Future<void> writeEncodedContacts(List<String> contacts) async {
+ await secureStorage.write(key: _storageKey, value: json.encode(contacts));
+}
+
+Future<void> clearContacts() async {
+ try {
+ await secureStorage.delete(key: _storageKey);
+ } catch (e) {
+ log(LogLevel.error, 'Could not clear contacts: $e');
+ }
+
+ // Also drop anything a build that predates the move left behind.
+ await SharedPreferencesService.remove(SharedPreferencesKeys.contacts);
+}
+
+/// Moves an address book written by an earlier build out of shared preferences.
+///
+/// The plaintext copy is only deleted once the secure copy is safely written —
+/// if that fails the contacts are still returned and still in preferences, and
+/// the next launch tries again.
+Future<List<String>?> _migrateFromPreferences() async {
+ final prefs = await SharedPreferences.getInstance();
+ final legacy = prefs.getStringList(SharedPreferencesKeys.contacts);
+
+ if (legacy == null) return [];
+
+ try {
+ await writeEncodedContacts(legacy);
+ } catch (e) {
+ log(LogLevel.error, 'Could not move contacts to secure storage: $e');
+ return legacy;
+ }
+
+ await prefs.remove(SharedPreferencesKeys.contacts);
+ log(LogLevel.info, 'Moved ${legacy.length} contacts out of shared preferences');
+
+ return legacy;
+}
diff --git a/lib/util/socks_http.dart b/lib/util/socks_http.dart
index 47cfb27..708a803 100644
--- a/lib/util/socks_http.dart
+++ b/lib/util/socks_http.dart
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:io';
+import 'package:skylight_wallet/util/logging.dart';
import 'package:skylight_wallet/util/socks_socket.dart';
class ParsedHttpResponse {
@@ -128,12 +129,23 @@ Future<ParsedHttpResponse> makeSocksHttpRequest(
sslEnabled: uri.scheme == 'https',
);
- await socket.connect();
- await socket.connectTo(uri.host, uri.port);
+ try {
+ await socket.connect();
+ await socket.connectTo(uri.host, uri.port);
- final rawRequest = getRawHttpRequestString(method, url, jsonBody: body);
- final rawResponse = await socket.send(rawRequest);
- final parsedResponse = parseHttpResponse(rawResponse);
+ final rawRequest = getRawHttpRequestString(method, url, jsonBody: body);
+ final rawResponse = await socket.send(rawRequest);
- return parsedResponse;
+ return parseHttpResponse(rawResponse);
+ } finally {
+ // Each request opens its own SOCKS connection, and with it a Tor circuit.
+ // Left open they accumulate for the life of the process — the fiat poller
+ // alone starts one every ten minutes. Closing must not mask a request
+ // error, so its own failure is only logged.
+ try {
+ await socket.close();
+ } catch (e) {
+ log(LogLevel.warn, 'Failed to close SOCKS socket: $e');
+ }
+ }
}
diff --git a/lib/util/tx_notification_state.dart b/lib/util/tx_notification_state.dart
new file mode 100644
index 0000000..97073c7
--- /dev/null
+++ b/lib/util/tx_notification_state.dart
@@ -0,0 +1,68 @@
+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/util/tx_notifications.dart b/lib/util/tx_notifications.dart
new file mode 100644
index 0000000..0e47056
--- /dev/null
+++ b/lib/util/tx_notifications.dart
@@ -0,0 +1,89 @@
+import 'package:skylight_wallet/models/wallet_model.dart';
+import 'package:skylight_wallet/consts.dart' as consts;
+
+/// How many announced transaction hashes are remembered. The cutoff covers
+/// everything older, so this only has to span transactions near the tip.
+const int maxRememberedTxHashes = 50;
+
+/// What to announce, and the state to persist afterwards.
+class TxNotificationDecision {
+ /// Incoming transactions to announce, oldest first.
+ final List<TxDetails> toAnnounce;
+
+ /// New value for the cutoff (unix seconds).
+ final int cutoff;
+
+ /// New list of remembered hashes, oldest first.
+ final List<String> announcedHashes;
+
+ const TxNotificationDecision({
+ required this.toAnnounce,
+ required this.cutoff,
+ required this.announcedHashes,
+ });
+}
+
+/// Works out which incoming transactions the user hasn't been told about.
+///
+/// Two pieces of state, because neither is enough alone:
+///
+/// [cutoff] is a coarse "everything before this is old news" line. It stops a
+/// restored wallet — or one that had notifications switched off for a while —
+/// from announcing a backlog, and it bounds how much has to be remembered.
+///
+/// [announcedHashes] catches what a timestamp cannot. A transaction's timestamp
+/// is the time it was *seen* while it sits in the mempool and the *block's*
+/// timestamp once it is mined, so it moves — usually forward, sometimes
+/// backwards — and a payment announced from the mempool would be announced
+/// again on confirmation. The same applies to a transaction that is dropped or
+/// reorged out and later reappears.
+///
+/// Only confirmed transactions advance the cutoff. An unconfirmed one carries
+/// roughly the current time, which can sit well ahead of blocks the wallet is
+/// still scanning; moving the cutoff there would silence whatever those blocks
+/// turn up.
+TxNotificationDecision decideTxNotifications({
+ required List<TxDetails> txHistory,
+ required int cutoff,
+ required List<String> announcedHashes,
+ int maxHashes = maxRememberedTxHashes,
+}) {
+ final seen = announcedHashes.toSet();
+
+ final toAnnounce =
+ txHistory
+ .where(
+ (tx) =>
+ tx.direction == consts.txDirectionIncoming &&
+ tx.timestamp > cutoff &&
+ !seen.contains(tx.hash),
+ )
+ .toList()
+ // Oldest first, so a burst is announced in the order it happened.
+ ..sort((a, b) => a.timestamp.compareTo(b.timestamp));
+
+ var newCutoff = cutoff;
+
+ for (final tx in txHistory) {
+ if (isConfirmedTx(tx) && tx.timestamp > newCutoff) {
+ newCutoff = tx.timestamp;
+ }
+ }
+
+ final hashes = [
+ ...announcedHashes.where((hash) => !toAnnounce.any((tx) => tx.hash == hash)),
+ ...toAnnounce.map((tx) => tx.hash),
+ ];
+
+ return TxNotificationDecision(
+ toAnnounce: toAnnounce,
+ cutoff: newCutoff,
+ announcedHashes: hashes.length > maxHashes ? hashes.sublist(hashes.length - maxHashes) : hashes,
+ );
+}
+
+/// True when a transaction is in a block. Deliberately strict: treating an
+/// unconfirmed transaction as confirmed would drag the cutoff forward and
+/// silence real notifications, while the reverse only costs a re-check that
+/// [decideTxNotifications] deduplicates by hash anyway.
+bool isConfirmedTx(TxDetails tx) => tx.height > 0;
diff --git a/lib/widgets/connection_settings_form.dart b/lib/widgets/connection_settings_form.dart
index 1bb1dc4..1ba0293 100644
--- a/lib/widgets/connection_settings_form.dart
+++ b/lib/widgets/connection_settings_form.dart
@@ -443,6 +443,11 @@ class _ConnectionSettingsFormState extends State<ConnectionSettingsForm> {
await _disableSync();
}
+ // What background work is possible depends on the connection that was just
+ // saved — on iOS, whether it is LWS at all and whether it uses Tor — so the
+ // schedule is rebuilt for every save, not only when a sync toggle moved.
+ await applyBackgroundTaskRegistration();
+
await widget.onBeforeSave?.call();
widget.onSaved();
diff --git a/lib/widgets/tor_settings_form.dart b/lib/widgets/tor_settings_form.dart
index 4dcca52..0faeeaa 100644
--- a/lib/widgets/tor_settings_form.dart
+++ b/lib/widgets/tor_settings_form.dart
@@ -2,7 +2,10 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
+import 'package:provider/provider.dart';
+
import 'package:skylight_wallet/l10n/app_localizations.dart';
+import 'package:skylight_wallet/models/wallet_model.dart';
import 'package:skylight_wallet/services/tor_settings_service.dart';
import 'package:skylight_wallet/util/socks_http.dart';
@@ -51,7 +54,16 @@ class _TorSettingsFormState extends State<TorSettingsForm> {
}
void _onSavePressed() async {
+ // Read before the await: a Tor-only connection has to be told immediately
+ // that its requirement can no longer be met, or it goes on presenting
+ // itself as connected over Tor until something tries to reconnect.
+ final wallet = Provider.of<WalletModel>(context, listen: false);
+ final disablingTor = _selectedMode == TorMode.disabled;
+
await _saveSettings();
+
+ if (disablingTor) wallet.onGlobalTorDisabled();
+
widget.onSaved();
}
diff --git a/test/contacts_store_test.dart b/test/contacts_store_test.dart
new file mode 100644
index 0000000..ce16d7c
--- /dev/null
+++ b/test/contacts_store_test.dart
@@ -0,0 +1,116 @@
+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/contact_model.dart';
+import 'package:skylight_wallet/util/contacts_store.dart';
+
+/// The address book holds the user's counterparties, so it belongs in secure
+/// storage — and moving it there must not lose anyone's contacts.
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ String encoded(String id, String name, String address) =>
+ json.encode({'id': id, 'name': name, 'address': address});
+
+ Future<List<String>?> secureContacts() async {
+ final raw = await const FlutterSecureStorage().read(key: 'contacts');
+ return raw == null ? null : (json.decode(raw) as List<dynamic>).cast<String>();
+ }
+
+ Future<List<String>?> plaintextContacts() async {
+ final prefs = await SharedPreferences.getInstance();
+ return prefs.getStringList('contacts');
+ }
+
+ setUp(() {
+ SharedPreferences.setMockInitialValues({});
+ FlutterSecureStorage.setMockInitialValues({});
+ });
+
+ test('a new contact is written to secure storage, not preferences', () async {
+ final model = ContactModel();
+ await model.load();
+
+ await model.addContact('Alice', '4AliceAddress');
+
+ expect(await secureContacts(), hasLength(1));
+ expect(await plaintextContacts(), isNull);
+
+ final prefs = await SharedPreferences.getInstance();
+ expect(prefs.getKeys(), isEmpty, reason: 'nothing about contacts in plaintext');
+ });
+
+ test('contacts written by an older build are migrated and the plaintext copy removed', () async {
+ SharedPreferences.setMockInitialValues({
+ 'contacts': [encoded('1', 'Alice', '4Alice'), encoded('2', 'Bob', '4Bob')],
+ });
+
+ final model = ContactModel();
+ await model.load();
+
+ expect(model.contacts.map((c) => c.name), ['Alice', 'Bob']);
+ expect(await secureContacts(), hasLength(2));
+ expect(await plaintextContacts(), isNull, reason: 'the plaintext copy must be deleted');
+ });
+
+ test('migration runs once and the secure copy wins afterwards', () async {
+ SharedPreferences.setMockInitialValues({
+ 'contacts': [encoded('1', 'Alice', '4Alice')],
+ });
+
+ await (ContactModel()..load()).load();
+
+ // A stale plaintext entry reappearing must not override what is already in
+ // secure storage.
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setStringList('contacts', [encoded('9', 'Impostor', '4Impostor')]);
+
+ final second = ContactModel();
+ await second.load();
+
+ expect(second.contacts.map((c) => c.name), ['Alice']);
+ });
+
+ test('an unreadable address book is not overwritten by an empty one', () async {
+ FlutterSecureStorage.setMockInitialValues({'contacts': 'not json'});
+
+ final model = ContactModel();
+ await model.load();
+
+ expect(model.isUnreadable, isTrue);
+ expect(model.contacts, isEmpty);
+
+ // A save triggered while in that state would otherwise replace the stored
+ // address book with the empty in-memory one.
+ await model.addContact('Alice', '4Alice');
+
+ expect(await const FlutterSecureStorage().read(key: 'contacts'), 'not json');
+ });
+
+ test('clearContacts removes both copies', () async {
+ SharedPreferences.setMockInitialValues({
+ 'contacts': [encoded('1', 'Alice', '4Alice')],
+ });
+ FlutterSecureStorage.setMockInitialValues({
+ 'contacts': json.encode([encoded('1', 'Alice', '4Alice')]),
+ });
+
+ await clearContacts();
+
+ expect(await secureContacts(), isNull);
+ expect(await plaintextContacts(), isNull);
+ });
+
+ test('an empty address book reads as empty, not as unreadable', () async {
+ FlutterSecureStorage.setMockInitialValues({'contacts': json.encode(<String>[])});
+
+ final model = ContactModel();
+ await model.load();
+
+ expect(model.isUnreadable, isFalse);
+ expect(model.contacts, isEmpty);
+ });
+}
diff --git a/test/tx_notification_state_test.dart b/test/tx_notification_state_test.dart
new file mode 100644
index 0000000..acabbd3
--- /dev/null
+++ b/test/tx_notification_state_test.dart
@@ -0,0 +1,101 @@
+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
new file mode 100644
index 0000000..505116e
--- /dev/null
+++ b/test/tx_notifications_test.dart
@@ -0,0 +1,285 @@
+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/util/tx_notifications.dart';
+
+/// A transaction as the wallet reports it. [height] of -1 means unconfirmed.
+TxDetails tx({
+ required String hash,
+ required int timestamp,
+ int direction = consts.txDirectionIncoming,
+ int height = 100,
+ double amount = 1.5,
+}) {
+ return TxDetails(
+ index: 0,
+ direction: direction,
+ hash: hash,
+ amount: amount,
+ fee: 0,
+ recipients: const [],
+ accountIndex: 0,
+ subaddrIndexList: const [0],
+ timestamp: timestamp,
+ height: height,
+ confirmations: height > 0 ? 10 : 0,
+ key: '',
+ );
+}
+
+/// The wallet hands history back newest first.
+List<TxDetails> newestFirst(List<TxDetails> txs) =>
+ [...txs]..sort((a, b) => b.timestamp.compareTo(a.timestamp));
+
+List<String> hashesOf(List<TxDetails> txs) => txs.map((t) => t.hash).toList();
+
+void main() {
+ group('decideTxNotifications', () {
+ test('announces an incoming transaction newer than the cutoff', () {
+ final decision = decideTxNotifications(
+ txHistory: newestFirst([tx(hash: 'a', timestamp: 1000)]),
+ cutoff: 500,
+ announcedHashes: const [],
+ );
+
+ expect(hashesOf(decision.toAnnounce), ['a']);
+ expect(decision.cutoff, 1000);
+ expect(decision.announcedHashes, ['a']);
+ });
+
+ test('ignores outgoing transactions', () {
+ final decision = decideTxNotifications(
+ txHistory: newestFirst([
+ tx(hash: 'out', timestamp: 1000, direction: consts.txDirectionOutgoing),
+ ]),
+ cutoff: 500,
+ announcedHashes: const [],
+ );
+
+ expect(decision.toAnnounce, isEmpty);
+ // The cutoff still moves: it tracks what has been seen, not what was said.
+ expect(decision.cutoff, 1000);
+ });
+
+ test('ignores anything at or before the cutoff', () {
+ final decision = decideTxNotifications(
+ txHistory: newestFirst([
+ tx(hash: 'old', timestamp: 400),
+ tx(hash: 'exactly-at', timestamp: 500),
+ ]),
+ cutoff: 500,
+ announcedHashes: const [],
+ );
+
+ expect(decision.toAnnounce, isEmpty);
+ expect(decision.cutoff, 500);
+ });
+
+ test('announces a burst oldest first', () {
+ final decision = decideTxNotifications(
+ txHistory: newestFirst([
+ tx(hash: 'c', timestamp: 3000),
+ tx(hash: 'a', timestamp: 1000),
+ tx(hash: 'b', timestamp: 2000),
+ ]),
+ cutoff: 500,
+ announcedHashes: const [],
+ );
+
+ expect(hashesOf(decision.toAnnounce), ['a', 'b', 'c']);
+ expect(decision.cutoff, 3000);
+ });
+
+ test('a second pass over the same history announces nothing', () {
+ final history = newestFirst([tx(hash: 'a', timestamp: 1000)]);
+
+ final first = decideTxNotifications(
+ txHistory: history,
+ cutoff: 500,
+ announcedHashes: const [],
+ );
+ final second = decideTxNotifications(
+ txHistory: history,
+ cutoff: first.cutoff,
+ announcedHashes: first.announcedHashes,
+ );
+
+ expect(second.toAnnounce, isEmpty);
+ expect(second.cutoff, first.cutoff);
+ });
+
+ test('nothing is announced when the history is empty', () {
+ final decision = decideTxNotifications(
+ txHistory: const [],
+ cutoff: 500,
+ announcedHashes: const [],
+ );
+
+ expect(decision.toAnnounce, isEmpty);
+ expect(decision.cutoff, 500);
+ });
+
+ test('the cutoff never moves backwards', () {
+ final decision = decideTxNotifications(
+ txHistory: newestFirst([tx(hash: 'old', timestamp: 100)]),
+ cutoff: 9000,
+ announcedHashes: const [],
+ );
+
+ expect(decision.cutoff, 9000);
+ });
+ });
+
+ group('mempool and reorg edge cases', () {
+ test('a mempool transaction is announced once, not again when mined', () {
+ // Seen unconfirmed at t=1000...
+ final unconfirmed = tx(hash: 'a', timestamp: 1000, height: -1);
+ final first = decideTxNotifications(
+ txHistory: [unconfirmed],
+ cutoff: 500,
+ announcedHashes: const [],
+ );
+
+ expect(hashesOf(first.toAnnounce), ['a']);
+ // An unconfirmed transaction must not drag the cutoff forward.
+ expect(first.cutoff, 500);
+
+ // ...then mined into a block stamped later than it was seen.
+ final mined = tx(hash: 'a', timestamp: 1600, height: 3000);
+ final second = decideTxNotifications(
+ txHistory: [mined],
+ cutoff: first.cutoff,
+ announcedHashes: first.announcedHashes,
+ );
+
+ expect(second.toAnnounce, isEmpty, reason: 'the hash is remembered');
+ expect(second.cutoff, 1600);
+ });
+
+ test('a block timestamp earlier than when the tx was seen is still not repeated', () {
+ final first = decideTxNotifications(
+ txHistory: [tx(hash: 'a', timestamp: 1000, height: -1)],
+ cutoff: 500,
+ announcedHashes: const [],
+ );
+ final second = decideTxNotifications(
+ // Miner clocks drift; a block can be stamped before the tx was seen.
+ txHistory: [tx(hash: 'a', timestamp: 900, height: 3000)],
+ cutoff: first.cutoff,
+ announcedHashes: first.announcedHashes,
+ );
+
+ expect(second.toAnnounce, isEmpty);
+ });
+
+ test('a dropped transaction that reappears is not announced twice', () {
+ final first = decideTxNotifications(
+ txHistory: [tx(hash: 'a', timestamp: 1000, height: -1)],
+ cutoff: 500,
+ announcedHashes: const [],
+ );
+
+ // Dropped from the mempool: gone from history entirely.
+ final whileGone = decideTxNotifications(
+ txHistory: const [],
+ cutoff: first.cutoff,
+ announcedHashes: first.announcedHashes,
+ );
+
+ // Rebroadcast and mined later.
+ final back = decideTxNotifications(
+ txHistory: [tx(hash: 'a', timestamp: 5000, height: 3100)],
+ cutoff: whileGone.cutoff,
+ announcedHashes: whileGone.announcedHashes,
+ );
+
+ expect(back.toAnnounce, isEmpty);
+ });
+
+ test('a reorged-out transaction is not announced again when it returns', () {
+ final first = decideTxNotifications(
+ txHistory: [tx(hash: 'a', timestamp: 1000, height: 3000)],
+ cutoff: 500,
+ announcedHashes: const [],
+ );
+ expect(hashesOf(first.toAnnounce), ['a']);
+
+ // Reorged out and re-mined in a different block, with a new timestamp.
+ final after = decideTxNotifications(
+ txHistory: [tx(hash: 'a', timestamp: 1200, height: 3001)],
+ cutoff: first.cutoff,
+ announcedHashes: first.announcedHashes,
+ );
+
+ expect(after.toAnnounce, isEmpty);
+ });
+
+ test('an unconfirmed tx does not silence older blocks still being scanned', () {
+ // A mempool payment is seen while the scanner is far behind the tip.
+ final first = decideTxNotifications(
+ txHistory: [tx(hash: 'mempool', timestamp: 9000, height: -1)],
+ cutoff: 500,
+ announcedHashes: const [],
+ );
+ expect(first.cutoff, 500, reason: 'unconfirmed must not move the cutoff');
+
+ // The scan then reaches an older block holding another payment.
+ final second = decideTxNotifications(
+ txHistory: newestFirst([
+ tx(hash: 'mempool', timestamp: 9000, height: -1),
+ tx(hash: 'older-block', timestamp: 4000, height: 2900),
+ ]),
+ cutoff: first.cutoff,
+ announcedHashes: first.announcedHashes,
+ );
+
+ expect(hashesOf(second.toAnnounce), ['older-block']);
+ });
+ });
+
+ group('remembered hashes', () {
+ test('are capped, keeping the most recent', () {
+ var cutoff = 0;
+ var hashes = <String>[];
+
+ // 10 unconfirmed transactions, so the cutoff never advances and the cap
+ // is the only thing keeping the list bounded.
+ for (var i = 1; i <= 10; i++) {
+ final decision = decideTxNotifications(
+ txHistory: [tx(hash: 'tx$i', timestamp: i * 100, height: -1)],
+ cutoff: cutoff,
+ announcedHashes: hashes,
+ maxHashes: 3,
+ );
+ cutoff = decision.cutoff;
+ hashes = decision.announcedHashes;
+ }
+
+ expect(hashes, ['tx8', 'tx9', 'tx10']);
+ });
+
+ test('a hash is not duplicated when the same tx is re-seen', () {
+ final first = decideTxNotifications(
+ txHistory: [tx(hash: 'a', timestamp: 1000, height: -1)],
+ cutoff: 500,
+ announcedHashes: const [],
+ );
+ final second = decideTxNotifications(
+ txHistory: [tx(hash: 'a', timestamp: 1000, height: -1)],
+ cutoff: first.cutoff,
+ announcedHashes: first.announcedHashes,
+ );
+
+ expect(second.announcedHashes, ['a']);
+ });
+ });
+
+ group('isConfirmedTx', () {
+ test('treats -1 and 0 as unconfirmed', () {
+ expect(isConfirmedTx(tx(hash: 'a', timestamp: 1, height: -1)), isFalse);
+ expect(isConfirmedTx(tx(hash: 'a', timestamp: 1, height: 0)), isFalse);
+ expect(isConfirmedTx(tx(hash: 'a', timestamp: 1, height: 1)), isTrue);
+ });
+ });
+}
Why this scored 64/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.