* fix(mobile): delete leftover share files when the app starts A share's downloaded file is deleted when the share sheet closes. If the app is killed with the sheet still open, that never runs and the file stays in the temp folder for good. * clean up share downloads from the downloader's records * build the share folder list with Future.wait
325 lines
12 KiB
Dart
325 lines
12 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
|
|
import 'package:auto_route/auto_route.dart';
|
|
import 'package:background_downloader/background_downloader.dart';
|
|
import 'package:device_info_plus/device_info_plus.dart';
|
|
import 'package:easy_localization/easy_localization.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_displaymode/flutter_displaymode.dart';
|
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:immich_mobile/constants/constants.dart';
|
|
import 'package:immich_mobile/constants/locales.dart';
|
|
import 'package:immich_mobile/data/store.dart';
|
|
import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
|
import 'package:immich_mobile/generated/translations.g.dart';
|
|
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
|
import 'package:immich_mobile/pages/common/splash_screen.page.dart';
|
|
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
|
import 'package:immich_mobile/platform/permission_api.g.dart';
|
|
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
|
|
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.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/locale_provider.dart';
|
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
|
import 'package:immich_mobile/providers/theme.provider.dart';
|
|
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
|
import 'package:immich_mobile/routing/app_navigation_observer.dart';
|
|
import 'package:immich_mobile/routing/router.dart';
|
|
import 'package:immich_mobile/services/deep_link.service.dart';
|
|
import 'package:immich_mobile/theme/dynamic_theme.dart';
|
|
import 'package:immich_mobile/theme/theme_data.dart';
|
|
import 'package:immich_mobile/utils/bootstrap.dart';
|
|
import 'package:immich_mobile/utils/cache/widgets_binding.dart';
|
|
import 'package:immich_mobile/utils/debug_print.dart';
|
|
import 'package:immich_mobile/utils/fallback_localizations.dart';
|
|
import 'package:immich_mobile/utils/licenses.dart';
|
|
import 'package:immich_mobile/utils/migration.dart';
|
|
import 'package:immich_mobile/wm_executor.dart';
|
|
import 'package:immich_ui/immich_ui.dart';
|
|
import 'package:intl/date_symbol_data_local.dart';
|
|
import 'package:logging/logging.dart';
|
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
|
import 'package:timezone/data/latest.dart';
|
|
|
|
void main() async {
|
|
try {
|
|
// https://github.com/flutter/flutter/issues/118384
|
|
// Android only: Render maps into a TextureView
|
|
// By default, MapLibre will embed them into platform views, using Virtual Display. For some reason Flutter has a bug
|
|
// that leaks a presentation window and SurfaceFlinger layer for every map built, and it is never discarded
|
|
MapLibreMap.useHybridComposition = true;
|
|
|
|
ImmichWidgetsBinding();
|
|
unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock());
|
|
await EasyLocalization.ensureInitialized();
|
|
final (dataController, apiService) = await Bootstrap.initDomain();
|
|
await initApp();
|
|
// Warm-up isolate pool for worker manager
|
|
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
|
|
await migrateDatabaseIfNeeded(dataController.db, NativeSyncApi(), PermissionApi());
|
|
|
|
runApp(
|
|
ProviderScope(
|
|
overrides: Store.overrideWith(dataController: dataController, apiService: apiService),
|
|
child: const MainWidget(),
|
|
),
|
|
);
|
|
} catch (error, stack) {
|
|
Logger('main').severe('Failed to start', error, stack);
|
|
runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString()));
|
|
}
|
|
}
|
|
|
|
Future<void> initApp() async {
|
|
await initializeDateFormatting();
|
|
|
|
if (Platform.isAndroid) {
|
|
try {
|
|
await FlutterDisplayMode.setHighRefreshRate();
|
|
dPrint(() => "Enabled high refresh mode");
|
|
} catch (e) {
|
|
dPrint(() => "Error setting high refresh rate: $e");
|
|
}
|
|
}
|
|
|
|
await DynamicTheme.fetchSystemPalette();
|
|
|
|
final log = Logger("ImmichErrorLogger");
|
|
|
|
FlutterError.onError = (details) {
|
|
FlutterError.presentError(details);
|
|
log.severe(
|
|
'FlutterError - Catch all',
|
|
"$details\nException: ${details.exception}\nLibrary: ${details.library}\nContext: ${details.context}",
|
|
details.stack,
|
|
);
|
|
};
|
|
|
|
PlatformDispatcher.instance.onError = (error, stack) {
|
|
log.severe('PlatformDispatcher - Catch all', error, stack);
|
|
return true;
|
|
};
|
|
|
|
initializeTimeZones();
|
|
|
|
// Initialize the file downloader
|
|
await FileDownloader().configure(
|
|
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
|
|
|
|
// On Android, if files are larger than 256MB, run in foreground service
|
|
globalConfig: [(Config.holdingQueue, (6, 6, 3)), (Config.runInForegroundIfFileLargerThan, 256)],
|
|
);
|
|
|
|
await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false);
|
|
|
|
unawaited(FileDownloader().trackTasks());
|
|
|
|
LicenseRegistry.addLicense(() async* {
|
|
for (final license in nonPubLicenses.entries) {
|
|
yield LicenseEntryWithLineBreaks([license.key], license.value);
|
|
}
|
|
});
|
|
}
|
|
|
|
class ImmichApp extends ConsumerStatefulWidget {
|
|
const ImmichApp({super.key});
|
|
|
|
@override
|
|
ImmichAppState createState() => ImmichAppState();
|
|
}
|
|
|
|
class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserver {
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
switch (state) {
|
|
case AppLifecycleState.resumed:
|
|
dPrint(() => "[APP STATE] resumed");
|
|
unawaited(ref.read(appStateProvider.notifier).handleAppResume());
|
|
unawaited(ref.read(viewIntentHandlerProvider).onAppResumed());
|
|
case AppLifecycleState.inactive:
|
|
dPrint(() => "[APP STATE] inactive");
|
|
ref.read(appStateProvider.notifier).handleAppInactivity();
|
|
case AppLifecycleState.paused:
|
|
dPrint(() => "[APP STATE] paused");
|
|
unawaited(ref.read(appStateProvider.notifier).handleAppPause());
|
|
case AppLifecycleState.detached:
|
|
dPrint(() => "[APP STATE] detached");
|
|
unawaited(ref.read(appStateProvider.notifier).handleAppDetached());
|
|
case AppLifecycleState.hidden:
|
|
dPrint(() => "[APP STATE] hidden");
|
|
ref.read(appStateProvider.notifier).handleAppHidden();
|
|
}
|
|
}
|
|
|
|
Future<void> initApp() async {
|
|
WidgetsBinding.instance.addObserver(this);
|
|
// Draw the app from edge to edge
|
|
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
|
|
await _setNavigationBarColor();
|
|
|
|
await FlutterLocalNotificationsPlugin().initialize(
|
|
const InitializationSettings(
|
|
android: AndroidInitializationSettings('@drawable/notification_icon'),
|
|
iOS: DarwinInitializationSettings(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _setNavigationBarColor() async {
|
|
SystemUiOverlayStyle overlayStyle = const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent);
|
|
if (Platform.isAndroid) {
|
|
// Android 8 does not support transparent app bars
|
|
final info = await DeviceInfoPlugin().androidInfo;
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
if (info.version.sdkInt <= 26) {
|
|
overlayStyle = context.isDarkTheme ? SystemUiOverlayStyle.dark : SystemUiOverlayStyle.light;
|
|
}
|
|
}
|
|
SystemChrome.setSystemUIOverlayStyle(overlayStyle);
|
|
}
|
|
|
|
Future<DeepLink> _deepLinkBuilder(PlatformDeepLink deepLink) async {
|
|
final deepLinkHandler = ref.read(deepLinkServiceProvider);
|
|
final currentRouteName = ref.read(currentRouteNameProvider.notifier).state;
|
|
|
|
final isColdStart = currentRouteName == null || currentRouteName == SplashScreenRoute.name;
|
|
|
|
PageRouteInfo? route;
|
|
if (deepLink.uri.scheme == "immich") {
|
|
route = await deepLinkHandler.handleScheme(deepLink, ref);
|
|
} else if (deepLink.uri.host == "my.immich.app") {
|
|
route = await deepLinkHandler.handleMyImmichApp(deepLink, ref);
|
|
} else {
|
|
return DeepLink.path(deepLink.path);
|
|
}
|
|
|
|
if (route == null) {
|
|
return isColdStart ? DeepLink.defaultPath : DeepLink.none;
|
|
}
|
|
|
|
// We need to replace the route if the destination is the current route
|
|
if (!isColdStart) {
|
|
unawaited(
|
|
ref.read(appRouterProvider).pushAndPopUntil(route, predicate: (r) => r.settings.name != route!.routeName),
|
|
);
|
|
return DeepLink.none;
|
|
}
|
|
|
|
return DeepLink([
|
|
// we need something to segue back to if the app was cold started
|
|
if (isColdStart) const TabShellRoute(children: [MainTimelineRoute()]),
|
|
route,
|
|
]);
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
Intl.defaultLocale = context.locale.toLanguageTag();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
configureFileDownloaderNotifications();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
unawaited(initApp().then((_) => dPrint(() => "App Init Completed")));
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
// needs to be delayed so that EasyLocalization is working
|
|
unawaited(ref.read(backgroundWorkerFgServiceProvider).enable());
|
|
if (Platform.isAndroid) {
|
|
unawaited(
|
|
ref
|
|
.read(backgroundWorkerFgServiceProvider)
|
|
.saveNotificationMessage(
|
|
StaticTranslations.instance.uploading_media,
|
|
StaticTranslations.instance.backup_background_service_default_notification,
|
|
),
|
|
);
|
|
}
|
|
});
|
|
|
|
ref.read(viewIntentHandlerProvider).init();
|
|
ref.read(shareIntentUploadProvider.notifier).init();
|
|
unawaited(ref.read(assetMediaRepositoryProvider).cleanupStaleShareFiles());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void reassemble() {
|
|
if (kDebugMode) {
|
|
unawaited(NetworkRepository.init());
|
|
}
|
|
super.reassemble();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final router = ref.watch(appRouterProvider);
|
|
final immichTheme = ref.watch(immichThemeProvider);
|
|
|
|
return ProviderScope(
|
|
overrides: [localeProvider.overrideWithValue(context.locale)],
|
|
child: MaterialApp.router(
|
|
title: 'Immich',
|
|
debugShowCheckedModeBanner: true,
|
|
scaffoldMessengerKey: scaffoldMessengerKey,
|
|
localizationsDelegates: [...context.localizationDelegates, ...fallbackLocalizationDelegates],
|
|
supportedLocales: context.supportedLocales,
|
|
locale: context.locale,
|
|
themeMode: ref.watch(appConfigProvider.select((config) => config.theme.mode)),
|
|
darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: context.locale),
|
|
theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale),
|
|
builder: (context, child) => ImmichTranslationProvider(
|
|
translations: ImmichTranslations(
|
|
submit: context.t.submit,
|
|
password: context.t.password,
|
|
undo: context.t.undo,
|
|
),
|
|
child: ImmichThemeProvider(colorScheme: context.colorScheme, child: child!),
|
|
),
|
|
routerConfig: router.config(
|
|
deepLinkBuilder: _deepLinkBuilder,
|
|
navigatorObservers: () => [AppNavigationObserver(ref: ref), TransitioningRouteObserver()],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class MainWidget extends StatelessWidget {
|
|
const MainWidget({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return EasyLocalization(
|
|
supportedLocales: locales.values.toList(),
|
|
startLocale: defaultAppLocale,
|
|
path: translationsPath,
|
|
useFallbackTranslations: true,
|
|
fallbackLocale: locales.values.first,
|
|
assetLoader: const CodegenLoader(),
|
|
child: const ImmichApp(),
|
|
);
|
|
}
|
|
}
|