Files
Immich/mobile/test/providers/app_life_cycle_provider_test.dart
T
Santo ShakilandCodex 59bf69f1d5 fix(mobile): hide server addresses and tokens in shared logs (#32287)
* fix(mobile): hide server addresses and tokens in shared logs

* fix(mobile): stop writing server urls and oauth values to the log

* fix(mobile): redact logs where they are read

* fix(mobile): redact logs when they are written

* fix(mobile): hide ipv6 addresses and name labels after their settings

(cherry picked from commit 89d2de1274a3e1a70a6169f4cfe1feebfb327791)
2026-10-22 05:18:40 +03:00

306 lines
11 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/services/device_permission.service.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/models/auth/auth_state.model.dart';
import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/utils/upload_speed_calculator.dart';
import 'package:mocktail/mocktail.dart';
import 'package:permission_handler/permission_handler.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
import '../service.mocks.dart';
class FakeLogMessage extends Fake implements LogMessage {}
class TestAuthNotifier extends AuthNotifier {
TestAuthNotifier(Ref ref)
: super(
MockAuthService(),
MockApiService(),
MockUserService(),
MockSecureStorageService(),
MockWidgetService(),
ref,
) {
state = const AuthState(
deviceId: 'device-1',
userId: 'user-1',
userEmail: 'user@example.com',
name: 'User',
profileImagePath: '',
isAdmin: false,
isAuthenticated: true,
);
}
@override
Future<String?> setOpenApiServiceEndpoint() async => 'http://test-server.com';
}
class TestWebsocketNotifier extends WebsocketNotifier {
TestWebsocketNotifier(super.ref);
int connectCount = 0;
int disconnectCount = 0;
bool throwOnConnect = true;
final connectCalled = Completer<void>();
@override
void connect() {
connectCount++;
if (!connectCalled.isCompleted) {
connectCalled.complete();
}
if (throwOnConnect) {
throw StateError('unexpected websocket connection');
}
}
@override
void disconnect() => disconnectCount++;
}
class TestDriftBackupNotifier extends BackupNotifier {
TestDriftBackupNotifier() : super(MockForegroundUploadService(), MockBackgroundUploadService(), UploadSpeedManager());
}
class TestNotificationPermissionNotifier extends NotificationPermissionNotifier {
@override
Future<PermissionStatus> getNotificationPermission() async => state;
}
class TestGalleryPermissionNotifier extends GalleryPermissionNotifier {
TestGalleryPermissionNotifier() : super(DevicePermissionService(MockPermissionRepository()));
@override
Future<DevicePermissionStatus> getGalleryPermissionStatus() async => state;
}
void main() {
late LogService logService;
late Completer<ServerVersion?> serverVersion;
late MockServerInfoService serverInfoService;
late MockBackgroundWorkerLockService lockService;
late MockBackgroundWorkerFgService fgService;
late MockBackgroundSyncManager backgroundSync;
late ProviderContainer container;
late TestWebsocketNotifier websocket;
late AppLifeCycleNotifier lifeCycle;
late int serverVersionCount;
late int memoryLaneBuilds;
setUpAll(() async {
final logRepository = MockLogRepository();
final settingsRepository = MockSettingsRepository();
registerFallbackValue(FakeLogMessage());
when(() => logRepository.truncate(limit: any(named: 'limit'))).thenAnswer((_) async {});
when(() => logRepository.insert(any())).thenAnswer((_) async => true);
when(() => settingsRepository.appConfig).thenReturn(const AppConfig(logLevel: LogLevel.info));
logService = await LogService.init(
logRepository: logRepository,
settingsRepository: settingsRepository,
storeService: MockStoreService(),
shouldBuffer: false,
);
});
tearDownAll(() => logService.dispose());
setUp(() {
serverVersion = Completer<ServerVersion?>();
serverInfoService = MockServerInfoService();
lockService = MockBackgroundWorkerLockService();
fgService = MockBackgroundWorkerFgService();
backgroundSync = MockBackgroundSyncManager();
serverVersionCount = 0;
memoryLaneBuilds = 0;
when(() => serverInfoService.getServerVersion()).thenAnswer((_) {
serverVersionCount++;
return serverVersionCount == 1 ? serverVersion.future : Future<ServerVersion?>.value();
});
when(() => lockService.lock()).thenAnswer((_) async {});
when(() => lockService.unlock()).thenAnswer((_) async {});
when(() => fgService.wasLaunchedInBackground()).thenAnswer((_) async => false);
when(() => backgroundSync.cancelResumeSyncs()).thenAnswer((_) async {});
when(() => backgroundSync.syncLocal(full: any(named: 'full'))).thenAnswer((_) async {});
when(() => backgroundSync.syncRemote()).thenAnswer((_) async => true);
when(() => backgroundSync.hashAssets()).thenAnswer((_) async {});
container = ProviderContainer(
overrides: [
authProvider.overrideWith(TestAuthNotifier.new),
serverInfoProvider.overrideWith((_) => ServerInfoNotifier(serverInfoService)),
websocketProvider.overrideWith((ref) {
return websocket = TestWebsocketNotifier(ref);
}),
backupProvider.overrideWith((_) => TestDriftBackupNotifier()),
backgroundWorkerLockServiceProvider.overrideWithValue(lockService),
backgroundWorkerFgServiceProvider.overrideWithValue(fgService),
backgroundSyncProvider.overrideWithValue(backgroundSync),
appConfigProvider.overrideWithValue(defaultConfig),
notificationPermissionProvider.overrideWith((_) => TestNotificationPermissionNotifier()),
galleryPermissionNotifier.overrideWith((_) => TestGalleryPermissionNotifier()),
memoryLaneProvider.overrideWith((_) {
memoryLaneBuilds++;
return const [];
}),
],
);
lifeCycle = container.read(appStateProvider.notifier);
container.read(websocketProvider);
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
});
tearDown(() {
debugDefaultTargetPlatformOverride = null;
container.dispose();
});
Future<void> startResume() async {
await lifeCycle.handleAppPause();
unawaited(lifeCycle.handleAppResume());
await untilCalled(() => serverInfoService.getServerVersion());
}
Future<void> releaseResume() async {
serverVersion.complete();
await Future<void>.delayed(Duration.zero);
}
test('pause during resume does not reconnect websocket', () async {
await startResume();
await lifeCycle.handleAppPause();
await releaseResume();
expect(lifeCycle.state, AppLifeCycleEnum.paused);
expect(serverVersionCount, 1);
expect(websocket.disconnectCount, 2);
expect(websocket.connectCount, 0);
});
test('inactive resume retries when the app resumes again', () async {
await startResume();
lifeCycle.handleAppInactivity();
await releaseResume();
unawaited(lifeCycle.handleAppResume());
await websocket.connectCalled.future;
expect(lifeCycle.state, AppLifeCycleEnum.resumed);
expect(serverVersionCount, 2);
expect(websocket.disconnectCount, 1);
expect(websocket.connectCount, 1);
});
test('pause after an inactive abort resumes once', () async {
await startResume();
lifeCycle.handleAppInactivity();
await releaseResume();
await lifeCycle.handleAppPause();
unawaited(lifeCycle.handleAppResume());
await websocket.connectCalled.future;
unawaited(lifeCycle.handleAppResume());
await Future<void>.delayed(Duration.zero);
expect(lifeCycle.state, AppLifeCycleEnum.resumed);
expect(serverVersionCount, 2);
expect(websocket.disconnectCount, 2);
expect(websocket.connectCount, 1);
});
test('resume re-queries the memory lane', () async {
container.listen(memoryLaneProvider, (_, _) {});
expect(memoryLaneBuilds, 1);
await lifeCycle.handleAppPause();
websocket.throwOnConnect = false;
serverVersion.complete();
await lifeCycle.handleAppResume();
await Future<void>.delayed(Duration.zero);
expect(memoryLaneBuilds, 2);
});
test('first resume runs when the app was launched in the background', () async {
when(() => fgService.wasLaunchedInBackground()).thenAnswer((_) async => true);
websocket.throwOnConnect = false;
serverVersion.complete();
await lifeCycle.handleAppResume();
expect(lifeCycle.state, AppLifeCycleEnum.resumed);
expect(serverVersionCount, 1);
expect(websocket.connectCount, 1);
verify(() => backgroundSync.syncLocal(full: true)).called(1);
await lifeCycle.handleAppPause();
await lifeCycle.handleAppResume();
verify(() => backgroundSync.syncLocal(full: false)).called(1);
});
test('first resume runs when the splash requested a full resume', () async {
websocket.throwOnConnect = false;
serverVersion.complete();
lifeCycle.requestFullResume();
await lifeCycle.handleAppResume();
expect(serverVersionCount, 1);
expect(websocket.connectCount, 1);
verify(() => backgroundSync.syncLocal(full: true)).called(1);
});
test('a background launch does not resume twice without a pause', () async {
when(() => fgService.wasLaunchedInBackground()).thenAnswer((_) async => true);
websocket.throwOnConnect = false;
serverVersion.complete();
await lifeCycle.handleAppResume();
await lifeCycle.handleAppResume();
expect(serverVersionCount, 1);
expect(websocket.connectCount, 1);
verify(() => backgroundSync.syncLocal(full: true)).called(1);
});
test('pause before the first sync keeps the full sync for the next resume', () async {
when(() => fgService.wasLaunchedInBackground()).thenAnswer((_) async => true);
websocket.throwOnConnect = false;
unawaited(lifeCycle.handleAppResume());
await untilCalled(() => serverInfoService.getServerVersion());
await lifeCycle.handleAppPause();
await releaseResume();
await lifeCycle.handleAppResume();
verify(() => backgroundSync.syncLocal(full: true)).called(1);
});
test('first resume is skipped on a normal launch', () async {
await lifeCycle.handleAppResume();
expect(lifeCycle.state, AppLifeCycleEnum.resumed);
expect(serverVersionCount, 0);
expect(websocket.connectCount, 0);
});
}