Files
JeffGandGitHub d57a883e53 Implement iOS NSE notifications and Android push token plumbing (#673)
* Add remote push notification plumbing

* Checkpoint notification reliability cleanup

* Prepare notification reliability PR

* Fix notification PR review findings

* Address notification review follow-ups

* Clean up failed iOS data migration copies

* Address app resume and migration cleanup review

* Restore coverage for notification review fixes

* Fix App Group migration guard

* Address notification PR review feedback

* Cover notification review edge cases

* Address notification test review feedback

* Address NSE migration and startup retry review

* Use foreground catch-up for notification refresh

* Update whitenoise-rs foreground catch-up dependency

* Cover notification catch-up error paths

* Fix notification review edge cases
2026-05-20 14:47:09 +02:00

935 lines
34 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'
show AsyncData, ProviderContainer, ProviderScope;
import 'package:flutter_test/flutter_test.dart';
import 'package:logging/logging.dart';
import 'package:whitenoise/main.dart'
show
WnApp,
formatAppLogRecord,
initializeAppContainer,
initializeWhitenoiseWithRetry,
kDataVersion,
kDataVersionFile,
refreshAfterNotificationRoute,
resolveWhitenoiseBaseDirectory;
import 'package:whitenoise/providers/auth_provider.dart';
import 'package:whitenoise/providers/notification_provider.dart'
show localNotificationSuppressedUntilProvider;
import 'package:whitenoise/providers/theme_provider.dart';
import 'package:whitenoise/src/rust/api.dart' as rust_api;
import 'package:whitenoise/src/rust/api/accounts.dart' as accounts_api;
import 'package:whitenoise/src/rust/api/error.dart';
import 'package:whitenoise/src/rust/frb_generated.dart';
import 'mocks/mock_secure_storage.dart';
import 'mocks/mock_wn_api.dart';
import 'test_helpers.dart';
({Directory tempDir, void Function() reset}) _mockPathProvider() {
final tempDir = Directory.systemTemp.createTempSync('whitenoise_test');
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(call) async {
if (call.method == 'getApplicationDocumentsDirectory') {
return tempDir.path;
}
return null;
},
);
return (
tempDir: tempDir,
reset: () {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
null,
);
if (tempDir.existsSync()) {
tempDir.deleteSync(recursive: true);
}
},
);
}
({Map<String, String> values, void Function() reset}) _mockSecureStorage([
Map<String, String> initialValues = const {},
]) {
final values = Map<String, String>.from(initialValues);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
(call) async {
final args = Map<Object?, Object?>.from(call.arguments as Map? ?? const {});
final key = args['key'] as String?;
switch (call.method) {
case 'read':
return key == null ? null : values[key];
case 'readAll':
return Map<String, String>.from(values);
case 'write':
if (key != null) {
values[key] = args['value'] as String;
}
return null;
case 'delete':
if (key != null) {
values.remove(key);
}
return null;
case 'deleteAll':
values.clear();
return null;
default:
return null;
}
},
);
return (
values: values,
reset: () {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
null,
);
},
);
}
void _mockAppGroupContainerPath(String? path) {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('org.parres.whitenoise/app_group'),
(call) async {
if (call.method == 'getAppGroupContainerPath') {
return path;
}
return null;
},
);
}
void _resetAppGroupContainerPath() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('org.parres.whitenoise/app_group'),
null,
);
}
class _MockAuthNotifier extends AuthNotifier {
int ensureExternalSignersRegisteredCount = 0;
Object? ensureExternalSignersRegisteredError;
@override
Future<String?> build() async {
state = const AsyncData(testPubkeyA);
return testPubkeyA;
}
@override
Future<void> ensureExternalSignersRegistered() async {
ensureExternalSignersRegisteredCount++;
final error = ensureExternalSignersRegisteredError;
if (error != null) {
throw error;
}
}
}
class _MockThemeNotifier extends ThemeNotifier {
ThemeMode _mode = ThemeMode.system;
@override
Future<ThemeMode> build() async {
state = AsyncData(_mode);
return _mode;
}
void setMode(ThemeMode mode) {
_mode = mode;
state = AsyncData(mode);
}
}
class _MockInitApi extends MockWnApi {
String? createdConfigDataDir;
String? createdConfigLogsDir;
rust_api.WhitenoiseConfig? initializedConfig;
int initCallCount = 0;
final initializeWhitenoiseErrors = <Object>[];
@override
Future<rust_api.WhitenoiseConfig> crateApiCreateWhitenoiseConfig({
required String dataDir,
required String logsDir,
List<String>? defaultRelayUrls,
}) async {
createdConfigDataDir = dataDir;
createdConfigLogsDir = logsDir;
return rust_api.WhitenoiseConfig(
dataDir: dataDir,
logsDir: logsDir,
defaultRelayUrls: defaultRelayUrls,
);
}
@override
Future<void> crateApiInitializeWhitenoise({
required rust_api.WhitenoiseConfig config,
}) async {
initCallCount++;
initializedConfig = config;
if (initializeWhitenoiseErrors.isNotEmpty) {
throw initializeWhitenoiseErrors.removeAt(0);
}
}
@override
void reset() {
super.reset();
createdConfigDataDir = null;
createdConfigLogsDir = null;
initializedConfig = null;
initCallCount = 0;
initializeWhitenoiseErrors.clear();
}
}
void main() {
late _MockInitApi mockApi;
setUpAll(() {
mockApi = _MockInitApi();
RustLib.initMock(api: mockApi);
});
setUp(() {
mockApi.reset();
});
test('formatAppLogRecord includes error and stack details', () {
final stackTrace = StackTrace.fromString('stack trace');
final record = LogRecord(
Level.WARNING,
'Something happened',
'TestLogger',
'boom',
stackTrace,
);
expect(
formatAppLogRecord(record),
'WARNING: TestLogger: Something happened\n'
' error: boom\n'
' stackTrace: stack trace',
);
});
group('WnApp', () {
late _MockAuthNotifier mockAuth;
late _MockThemeNotifier mockTheme;
Future<void> pumpWnApp(WidgetTester tester) async {
setUpTestView(tester);
mockAuth = _MockAuthNotifier();
mockTheme = _MockThemeNotifier();
await tester.pumpWidget(
ProviderScope(
overrides: [
authProvider.overrideWith(() => mockAuth),
themeProvider.overrideWith(() => mockTheme),
secureStorageProvider.overrideWithValue(MockSecureStorage()),
],
child: const WnApp(),
),
);
await tester.pumpAndSettle();
}
testWidgets('has app title', (tester) async {
await pumpWnApp(tester);
final app = tester.widget<MaterialApp>(find.byType(MaterialApp));
expect(app.title, 'White Noise');
});
testWidgets('defaults to system theme mode', (tester) async {
await pumpWnApp(tester);
final app = tester.widget<MaterialApp>(find.byType(MaterialApp));
expect(app.themeMode, ThemeMode.system);
});
testWidgets('responds to theme mode changes', (tester) async {
await pumpWnApp(tester);
mockTheme.setMode(ThemeMode.dark);
await tester.pumpAndSettle();
final app = tester.widget<MaterialApp>(find.byType(MaterialApp));
expect(app.themeMode, ThemeMode.dark);
});
testWidgets('has routes configured', (tester) async {
await pumpWnApp(tester);
final app = tester.widget<MaterialApp>(find.byType(MaterialApp));
expect(app.routerConfig, isNotNull);
});
testWidgets('forwards lifecycle state changes without error', (tester) async {
await pumpWnApp(tester);
// ForegroundService is disabled on the test host (Platform.isAndroid
// is false), so the observer chain ultimately no-ops — we're
// verifying that didChangeAppLifecycleState wires through without
// throwing and that each branch of the switch is exercised.
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pumpAndSettle();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await tester.pumpAndSettle();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
await tester.pumpAndSettle();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpAndSettle();
});
testWidgets('does not run resume refresh for the initial resumed event', (tester) async {
await pumpWnApp(tester);
mockAuth.ensureExternalSignersRegisteredCount = 0;
mockApi.resumeAfterBackgroundCallCount = 0;
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpAndSettle();
expect(mockAuth.ensureExternalSignersRegisteredCount, 0);
expect(mockApi.resumeAfterBackgroundCallCount, 0);
});
testWidgets('reconciles external signer callbacks on foreground resume', (tester) async {
await pumpWnApp(tester);
mockAuth.ensureExternalSignersRegisteredCount = 0;
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pumpAndSettle();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpAndSettle();
expect(mockAuth.ensureExternalSignersRegisteredCount, 1);
});
testWidgets('continues resume work when external signer reconciliation fails', (tester) async {
await pumpWnApp(tester);
mockAuth.ensureExternalSignersRegisteredError = Exception('signer failed');
mockApi.resumeAfterBackgroundCallCount = 0;
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pumpAndSettle();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpAndSettle();
expect(mockAuth.ensureExternalSignersRegisteredCount, 1);
expect(mockApi.resumeAfterBackgroundCallCount, 1);
});
testWidgets('resumes Whitenoise catch-up on foreground resume', (tester) async {
await pumpWnApp(tester);
mockApi.resumeAfterBackgroundCallCount = 0;
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pumpAndSettle();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpAndSettle();
expect(mockApi.resumeAfterBackgroundCallCount, 1);
});
testWidgets('temporarily suppresses local notifications on app resume', (tester) async {
await pumpWnApp(tester);
final container = ProviderScope.containerOf(
tester.element(find.byType(WnApp)),
listen: false,
);
container.read(localNotificationSuppressedUntilProvider.notifier).clear();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pumpAndSettle();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpAndSettle();
final suppressedUntil = container.read(localNotificationSuppressedUntilProvider);
expect(suppressedUntil, isNotNull);
expect(suppressedUntil!.isAfter(DateTime.now().toUtc()), isTrue);
});
testWidgets('does not suppress local notifications on cold start without a tap', (
tester,
) async {
await pumpWnApp(tester);
final container = ProviderScope.containerOf(
tester.element(find.byType(WnApp)),
listen: false,
);
expect(container.read(localNotificationSuppressedUntilProvider), isNull);
});
testWidgets('logs Whitenoise resume failures without throwing', (tester) async {
await pumpWnApp(tester);
mockApi.shouldFailResumeAfterBackground = true;
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pumpAndSettle();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpAndSettle();
expect(mockApi.resumeAfterBackgroundCallCount, 1);
});
test('refreshAfterNotificationRoute relies on foreground catch-up only', () async {
final events = <String>[];
await refreshAfterNotificationRoute(
resumeAfterBackground: () async {
events.add('foreground-catch-up');
},
);
expect(events, ['foreground-catch-up']);
});
});
group('initializeAppContainer', () {
late ({Directory tempDir, void Function() reset}) pathProvider;
late ({Map<String, String> values, void Function() reset}) secureStorage;
setUp(() {
pathProvider = _mockPathProvider();
secureStorage = _mockSecureStorage();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/shared_preferences'),
(call) async => <String, Object>{},
);
});
tearDown(() {
pathProvider.reset();
secureStorage.reset();
_resetAppGroupContainerPath();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/shared_preferences'),
null,
);
});
test('creates data directory', () async {
await initializeAppContainer();
expect(Directory('${pathProvider.tempDir.path}/whitenoise/data').existsSync(), isTrue);
});
test('creates logs directory', () async {
await initializeAppContainer();
expect(Directory('${pathProvider.tempDir.path}/whitenoise/logs').existsSync(), isTrue);
});
test('calls createWhitenoiseConfig with data directory', () async {
await initializeAppContainer();
expect(mockApi.createdConfigDataDir, '${pathProvider.tempDir.path}/whitenoise/data');
});
test('calls createWhitenoiseConfig with logs directory', () async {
await initializeAppContainer();
expect(mockApi.createdConfigLogsDir, '${pathProvider.tempDir.path}/whitenoise/logs');
});
test('calls initializeWhitenoise with config', () async {
await initializeAppContainer();
expect(mockApi.initializedConfig, isNotNull);
});
test('retries initializeWhitenoise when the database pool is temporarily exhausted', () async {
mockApi.initializeWhitenoiseErrors.add(
const ApiError.databasePoolTimedOut(
message: 'pool timed out while waiting for an open connection',
),
);
await initializeAppContainer(initializeRetryDelay: (_) async {});
expect(mockApi.initCallCount, 2);
});
test('uses the default retry delay for transient startup errors', () async {
mockApi.initializeWhitenoiseErrors.add(
const ApiError.databasePoolTimedOut(
message: 'pool timed out while waiting for an open connection',
),
);
final config = const rust_api.WhitenoiseConfig(dataDir: 'data', logsDir: 'logs');
final stopwatch = Stopwatch()..start();
await initializeWhitenoiseWithRetry(config);
expect(mockApi.initCallCount, 2);
expect(stopwatch.elapsed, greaterThanOrEqualTo(const Duration(milliseconds: 500)));
});
test('does not retry initializeWhitenoise for non-transient startup errors', () async {
mockApi.initializeWhitenoiseErrors.add(
const ApiError.whitenoise(message: 'Database error: migration failed'),
);
await expectLater(
initializeAppContainer(initializeRetryDelay: (_) async {}),
throwsA(isA<ApiError>()),
);
expect(mockApi.initCallCount, 1);
});
test('does not retry initializeWhitenoise for text that looks like a pool timeout', () async {
mockApi.initializeWhitenoiseErrors.add(
const ApiError.whitenoise(
message:
'Database error: SQLx error: pool timed out while waiting for an open connection',
),
);
await expectLater(
initializeAppContainer(initializeRetryDelay: (_) async {}),
throwsA(isA<ApiError>()),
);
expect(mockApi.initCallCount, 1);
});
test('returns a ProviderContainer', () async {
final container = await initializeAppContainer();
expect(container, isA<ProviderContainer>());
});
test('awaits authProvider', () async {
final container = await initializeAppContainer();
expect(container.read(authProvider), isA<AsyncData>());
});
test('writes version file on fresh install', () async {
await initializeAppContainer();
final versionFile = File('${pathProvider.tempDir.path}/whitenoise/data/$kDataVersionFile');
expect(versionFile.existsSync(), isTrue);
expect(versionFile.readAsStringSync().trim(), '$kDataVersion');
});
test('skips migration when version matches', () async {
final dataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data');
await dataDir.create(recursive: true);
final versionFile = File('${dataDir.path}/$kDataVersionFile');
versionFile.writeAsStringSync('$kDataVersion');
final marker = File('${dataDir.path}/whitenoise.json');
await marker.create();
await initializeAppContainer();
expect(marker.existsSync(), isTrue);
});
test('preserves data directory when no version file exists', () async {
final dataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data');
await dataDir.create(recursive: true);
final oldSecrets = File('${dataDir.path}/whitenoise.json');
final oldUuid = File('${dataDir.path}/whitenoise_uuid');
final oldDb = File('${dataDir.path}/release/whitenoise.sqlite');
await Directory('${dataDir.path}/release').create(recursive: true);
await oldSecrets.create();
await oldUuid.create();
await oldDb.create();
await initializeAppContainer();
expect(oldSecrets.existsSync(), isTrue);
expect(oldUuid.existsSync(), isTrue);
expect(oldDb.existsSync(), isTrue);
expect(dataDir.existsSync(), isTrue);
final versionFile = File('${dataDir.path}/$kDataVersionFile');
expect(versionFile.existsSync(), isTrue);
expect(versionFile.readAsStringSync().trim(), '$kDataVersion');
});
test('preserves Flutter secure storage when no version file exists', () async {
secureStorage.reset();
secureStorage = _mockSecureStorage({'active_account_pubkey': testPubkeyA});
mockApi.accounts = [
accounts_api.Account(
pubkey: testPubkeyA,
accountType: accounts_api.AccountType.local,
createdAt: DateTime(2024),
updatedAt: DateTime(2024),
),
];
await initializeAppContainer();
expect(secureStorage.values['active_account_pubkey'], testPubkeyA);
});
test('preserves data directory when version is outdated', () async {
final dataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data');
await dataDir.create(recursive: true);
final versionFile = File('${dataDir.path}/$kDataVersionFile');
versionFile.writeAsStringSync('0');
final marker = File('${dataDir.path}/whitenoise.json');
await marker.create();
await initializeAppContainer();
expect(marker.existsSync(), isTrue);
expect(versionFile.readAsStringSync().trim(), '$kDataVersion');
});
test('uses App Group container on iOS when available', () async {
final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test');
_mockAppGroupContainerPath(appGroupDir.path);
addTearDown(() {
if (appGroupDir.existsSync()) {
appGroupDir.deleteSync(recursive: true);
}
});
final baseDir = await resolveWhitenoiseBaseDirectory(isIOS: true);
expect(baseDir.path, '${appGroupDir.path}/whitenoise');
});
test('moves existing Documents data into App Group container on iOS', () async {
final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test');
_mockAppGroupContainerPath(appGroupDir.path);
final oldDataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data');
await oldDataDir.create(recursive: true);
final marker = File('${oldDataDir.path}/marker.txt');
await marker.writeAsString('existing');
addTearDown(() {
if (appGroupDir.existsSync()) {
appGroupDir.deleteSync(recursive: true);
}
});
final baseDir = await resolveWhitenoiseBaseDirectory(isIOS: true);
expect(baseDir.path, '${appGroupDir.path}/whitenoise');
expect(File('${baseDir.path}/data/marker.txt').readAsStringSync(), 'existing');
expect(Directory('${pathProvider.tempDir.path}/whitenoise').existsSync(), isFalse);
});
test('moves Documents data when App Group container only has empty directories', () async {
final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test');
_mockAppGroupContainerPath(appGroupDir.path);
final oldDataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data');
await oldDataDir.create(recursive: true);
await File('${oldDataDir.path}/marker.txt').writeAsString('existing');
await Directory('${appGroupDir.path}/whitenoise/data').create(recursive: true);
await Directory('${appGroupDir.path}/whitenoise/logs').create(recursive: true);
addTearDown(() {
if (appGroupDir.existsSync()) {
appGroupDir.deleteSync(recursive: true);
}
});
final baseDir = await resolveWhitenoiseBaseDirectory(isIOS: true);
expect(baseDir.path, '${appGroupDir.path}/whitenoise');
expect(File('${baseDir.path}/data/marker.txt').readAsStringSync(), 'existing');
expect(Directory('${pathProvider.tempDir.path}/whitenoise').existsSync(), isFalse);
});
test('preserves unversioned App Group data when Documents has no data', () async {
final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test');
_mockAppGroupContainerPath(appGroupDir.path);
await Directory('${pathProvider.tempDir.path}/whitenoise/data').create(recursive: true);
await Directory('${pathProvider.tempDir.path}/whitenoise/logs').create(recursive: true);
final appGroupDataDir = Directory('${appGroupDir.path}/whitenoise/data');
await appGroupDataDir.create(recursive: true);
await File('${appGroupDataDir.path}/nse-created.sqlite').writeAsString('nse');
addTearDown(() {
if (appGroupDir.existsSync()) {
appGroupDir.deleteSync(recursive: true);
}
});
final baseDir = await resolveWhitenoiseBaseDirectory(isIOS: true);
expect(baseDir.path, '${appGroupDir.path}/whitenoise');
expect(File('${baseDir.path}/data/nse-created.sqlite').readAsStringSync(), 'nse');
expect(Directory('${pathProvider.tempDir.path}/whitenoise').existsSync(), isFalse);
});
test('copies Documents data when App Group migration rename fails', () async {
final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test');
_mockAppGroupContainerPath(appGroupDir.path);
final oldDataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data/nested');
await oldDataDir.create(recursive: true);
await File('${oldDataDir.path}/marker.txt').writeAsString('existing');
addTearDown(() {
if (appGroupDir.existsSync()) {
appGroupDir.deleteSync(recursive: true);
}
});
final baseDir = await resolveWhitenoiseBaseDirectory(
isIOS: true,
renameDirectory: (_, _) async {
throw const FileSystemException('rename failed');
},
);
expect(baseDir.path, '${appGroupDir.path}/whitenoise');
expect(File('${baseDir.path}/data/nested/marker.txt').readAsStringSync(), 'existing');
expect(Directory('${pathProvider.tempDir.path}/whitenoise').existsSync(), isFalse);
});
test('removes partial App Group copy when Documents migration copy fails', () async {
final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test');
_mockAppGroupContainerPath(appGroupDir.path);
final oldDataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data');
await oldDataDir.create(recursive: true);
await File('${oldDataDir.path}/marker.txt').writeAsString('existing');
addTearDown(() {
if (appGroupDir.existsSync()) {
appGroupDir.deleteSync(recursive: true);
}
});
final baseDir = await resolveWhitenoiseBaseDirectory(
isIOS: true,
renameDirectory: (_, _) async {
throw const FileSystemException('rename failed');
},
copyDirectory: (_, to) async {
await Directory('${to.path}/data').create(recursive: true);
await File('${to.path}/data/partial.txt').writeAsString('partial');
throw const FileSystemException('copy failed');
},
);
expect(baseDir.path, '${pathProvider.tempDir.path}/whitenoise');
expect(File('${oldDataDir.path}/marker.txt').readAsStringSync(), 'existing');
expect(Directory('${appGroupDir.path}/whitenoise').existsSync(), isFalse);
});
test('returns App Group path when copied Documents data cannot be deleted', () async {
final previousLogLevel = Logger.root.level;
Logger.root.level = Level.ALL;
final records = <LogRecord>[];
final logSubscription = Logger('WnApp').onRecord.listen(records.add);
addTearDown(() async {
await logSubscription.cancel();
Logger.root.level = previousLogLevel;
});
final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test');
_mockAppGroupContainerPath(appGroupDir.path);
final oldDataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data');
await oldDataDir.create(recursive: true);
await File('${oldDataDir.path}/marker.txt').writeAsString('existing');
addTearDown(() {
if (appGroupDir.existsSync()) {
appGroupDir.deleteSync(recursive: true);
}
});
final baseDir = await resolveWhitenoiseBaseDirectory(
isIOS: true,
renameDirectory: (_, _) async {
throw const FileSystemException('rename failed');
},
copyDirectory: (from, to) async {
await Directory('${to.path}/data').create(recursive: true);
await File('${to.path}/data/marker.txt').writeAsString(
File('${from.path}/data/marker.txt').readAsStringSync(),
);
},
deleteSourceDirectory: (_) async {
throw const FileSystemException('delete failed');
},
);
expect(baseDir.path, '${appGroupDir.path}/whitenoise');
expect(File('${baseDir.path}/data/marker.txt').readAsStringSync(), 'existing');
expect(File('${oldDataDir.path}/marker.txt').readAsStringSync(), 'existing');
expect(
records.any(
(record) =>
record.message == 'Failed to remove old Documents data after App Group migration' &&
record.error is FileSystemException &&
record.stackTrace != null,
),
isTrue,
);
});
test(
'moves Documents data over unversioned App Group data created before app launch',
() async {
final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test');
_mockAppGroupContainerPath(appGroupDir.path);
final oldDataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data');
await oldDataDir.create(recursive: true);
await File('${oldDataDir.path}/old-marker.txt').writeAsString('old');
final appGroupDataDir = Directory('${appGroupDir.path}/whitenoise/data');
await appGroupDataDir.create(recursive: true);
await File('${appGroupDataDir.path}/nse-scratch.sqlite').writeAsString('scratch');
addTearDown(() {
if (appGroupDir.existsSync()) {
appGroupDir.deleteSync(recursive: true);
}
});
final baseDir = await resolveWhitenoiseBaseDirectory(isIOS: true);
expect(baseDir.path, '${appGroupDir.path}/whitenoise');
expect(File('${baseDir.path}/data/old-marker.txt').readAsStringSync(), 'old');
expect(File('${baseDir.path}/data/nse-scratch.sqlite').existsSync(), isFalse);
expect(Directory('${pathProvider.tempDir.path}/whitenoise').existsSync(), isFalse);
},
);
test('recovers pending reset before moving Documents data into App Group', () async {
secureStorage.reset();
secureStorage = _mockSecureStorage({'active_account_pubkey': testPubkeyA});
final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test');
_mockAppGroupContainerPath(appGroupDir.path);
final oldDataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data');
await oldDataDir.create(recursive: true);
await File('${oldDataDir.path}/old-marker.txt').writeAsString('old');
await File('${pathProvider.tempDir.path}/whitenoise/reset_pending').writeAsString('pending');
addTearDown(() {
if (appGroupDir.existsSync()) {
appGroupDir.deleteSync(recursive: true);
}
});
await initializeAppContainer(isIOS: true);
final appGroupBase = '${appGroupDir.path}/whitenoise';
expect(mockApi.createdConfigDataDir, '$appGroupBase/data');
expect(File('$appGroupBase/data/old-marker.txt').existsSync(), isFalse);
expect(File('$appGroupBase/data/$kDataVersionFile').readAsStringSync().trim(), '1');
expect(
File('${pathProvider.tempDir.path}/whitenoise/data/old-marker.txt').existsSync(),
isFalse,
);
expect(File('${pathProvider.tempDir.path}/whitenoise/reset_pending').existsSync(), isFalse);
expect(secureStorage.values, isEmpty);
});
test('preserves App Group data when both storage locations contain data', () async {
final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test');
_mockAppGroupContainerPath(appGroupDir.path);
final oldDataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data');
await oldDataDir.create(recursive: true);
await File('${oldDataDir.path}/old-marker.txt').writeAsString('old');
final appGroupDataDir = Directory('${appGroupDir.path}/whitenoise/data');
await appGroupDataDir.create(recursive: true);
await File('${appGroupDataDir.path}/new-marker.txt').writeAsString('new');
await File('${appGroupDataDir.path}/$kDataVersionFile').writeAsString('$kDataVersion');
addTearDown(() {
if (appGroupDir.existsSync()) {
appGroupDir.deleteSync(recursive: true);
}
});
final baseDir = await resolveWhitenoiseBaseDirectory(isIOS: true);
expect(baseDir.path, '${appGroupDir.path}/whitenoise');
expect(File('${baseDir.path}/data/new-marker.txt').readAsStringSync(), 'new');
expect(
File('${pathProvider.tempDir.path}/whitenoise/data/old-marker.txt').existsSync(),
isFalse,
);
});
test('keeps legacy Documents data when accepted App Group cleanup fails', () async {
final previousLogLevel = Logger.root.level;
Logger.root.level = Level.ALL;
final records = <LogRecord>[];
final logSubscription = Logger('WnApp').onRecord.listen(records.add);
addTearDown(() async {
await logSubscription.cancel();
Logger.root.level = previousLogLevel;
});
final appGroupDir = Directory.systemTemp.createTempSync('whitenoise_app_group_test');
_mockAppGroupContainerPath(appGroupDir.path);
final oldDataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data');
await oldDataDir.create(recursive: true);
await File('${oldDataDir.path}/old-marker.txt').writeAsString('old');
final appGroupDataDir = Directory('${appGroupDir.path}/whitenoise/data');
await appGroupDataDir.create(recursive: true);
await File('${appGroupDataDir.path}/new-marker.txt').writeAsString('new');
await File('${appGroupDataDir.path}/$kDataVersionFile').writeAsString('$kDataVersion');
addTearDown(() {
if (appGroupDir.existsSync()) {
appGroupDir.deleteSync(recursive: true);
}
});
final baseDir = await resolveWhitenoiseBaseDirectory(
isIOS: true,
deleteSourceDirectory: (_) async {
throw const FileSystemException('delete failed');
},
);
expect(baseDir.path, '${appGroupDir.path}/whitenoise');
expect(File('${baseDir.path}/data/new-marker.txt').readAsStringSync(), 'new');
expect(File('${oldDataDir.path}/old-marker.txt').readAsStringSync(), 'old');
expect(
records.any(
(record) =>
record.message ==
'Failed to remove old Documents data after App Group data was already accepted' &&
record.error is FileSystemException &&
record.stackTrace != null,
),
isTrue,
);
});
test('throws on iOS when App Group container is unavailable', () async {
_mockAppGroupContainerPath(null);
await expectLater(
resolveWhitenoiseBaseDirectory(isIOS: true),
throwsA(
predicate<Object>(
(error) => error.toString().contains('App Group container unavailable'),
),
),
);
});
test('throws on iOS when App Group lookup fails', () async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('org.parres.whitenoise/app_group'),
(_) async {
throw PlatformException(code: 'unavailable');
},
);
await expectLater(
resolveWhitenoiseBaseDirectory(isIOS: true),
throwsA(
predicate<Object>(
(error) => error.toString().contains('App Group container unavailable'),
),
),
);
});
});
}