backup_options_page.dart 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. import 'dart:io';
  2. import 'package:connectivity_plus/connectivity_plus.dart';
  3. import 'package:easy_localization/easy_localization.dart';
  4. import 'package:flutter/material.dart';
  5. import 'package:flutter_hooks/flutter_hooks.dart';
  6. import 'package:hooks_riverpod/hooks_riverpod.dart';
  7. import 'package:immich_mobile/extensions/build_context_extensions.dart';
  8. import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
  9. import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
  10. import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
  11. import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
  12. import 'package:immich_mobile/modules/backup/services/backup_verification.service.dart';
  13. import 'package:immich_mobile/modules/backup/ui/ios_debug_info_tile.dart';
  14. import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
  15. import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
  16. import 'package:immich_mobile/shared/models/asset.dart';
  17. import 'package:immich_mobile/shared/providers/asset.provider.dart';
  18. import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
  19. import 'package:immich_mobile/shared/ui/immich_toast.dart';
  20. import 'package:permission_handler/permission_handler.dart';
  21. import 'package:url_launcher/url_launcher.dart';
  22. import 'package:wakelock_plus/wakelock_plus.dart';
  23. class BackupOptionsPage extends HookConsumerWidget {
  24. const BackupOptionsPage({Key? key}) : super(key: key);
  25. @override
  26. Widget build(BuildContext context, WidgetRef ref) {
  27. BackUpState backupState = ref.watch(backupProvider);
  28. final settings = ref.watch(iOSBackgroundSettingsProvider.notifier).settings;
  29. final settingsService = ref.watch(appSettingsServiceProvider);
  30. final showBackupFix = Platform.isAndroid &&
  31. settingsService.getSetting(AppSettingsEnum.advancedTroubleshooting);
  32. final appRefreshDisabled =
  33. Platform.isIOS && settings?.appRefreshEnabled != true;
  34. final checkInProgress = useState(false);
  35. Future<void> performDeletion(List<Asset> assets) async {
  36. try {
  37. checkInProgress.value = true;
  38. ImmichToast.show(
  39. context: context,
  40. msg: "Deleting ${assets.length} assets on the server...",
  41. );
  42. await ref
  43. .read(assetProvider.notifier)
  44. .deleteAssets(assets, force: true);
  45. ImmichToast.show(
  46. context: context,
  47. msg: "Deleted ${assets.length} assets on the server. "
  48. "You can now start a manual backup",
  49. toastType: ToastType.success,
  50. );
  51. } finally {
  52. checkInProgress.value = false;
  53. }
  54. }
  55. void performBackupCheck() async {
  56. try {
  57. checkInProgress.value = true;
  58. if (backupState.allUniqueAssets.length >
  59. backupState.selectedAlbumsBackupAssetsIds.length) {
  60. ImmichToast.show(
  61. context: context,
  62. msg: "Backup all assets before starting this check!",
  63. toastType: ToastType.error,
  64. );
  65. return;
  66. }
  67. final connection = await Connectivity().checkConnectivity();
  68. if (connection != ConnectivityResult.wifi) {
  69. ImmichToast.show(
  70. context: context,
  71. msg: "Make sure to be connected to unmetered Wi-Fi",
  72. toastType: ToastType.error,
  73. );
  74. return;
  75. }
  76. WakelockPlus.enable();
  77. const limit = 100;
  78. final toDelete = await ref
  79. .read(backupVerificationServiceProvider)
  80. .findWronglyBackedUpAssets(limit: limit);
  81. if (toDelete.isEmpty) {
  82. ImmichToast.show(
  83. context: context,
  84. msg: "Did not find any corrupt asset backups!",
  85. toastType: ToastType.success,
  86. );
  87. } else {
  88. await showDialog(
  89. context: context,
  90. builder: (context) => ConfirmDialog(
  91. onOk: () => performDeletion(toDelete),
  92. title: "Corrupt backups!",
  93. ok: "Delete",
  94. content:
  95. "Found ${toDelete.length} (max $limit at once) corrupt asset backups. "
  96. "Run the check again to find more.\n"
  97. "Do you want to delete the corrupt asset backups now?",
  98. ),
  99. );
  100. }
  101. } finally {
  102. WakelockPlus.disable();
  103. checkInProgress.value = false;
  104. }
  105. }
  106. Widget buildCheckCorruptBackups() {
  107. return ListTile(
  108. leading: Icon(
  109. Icons.warning_rounded,
  110. color: context.primaryColor,
  111. ),
  112. title: const Text(
  113. "Check for corrupt asset backups",
  114. style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
  115. ),
  116. isThreeLine: true,
  117. subtitle: Column(
  118. crossAxisAlignment: CrossAxisAlignment.start,
  119. children: [
  120. const Text("Run this check only over Wi-Fi and once all assets "
  121. "have been backed-up. The procedure might take a few minutes."),
  122. ElevatedButton(
  123. onPressed: checkInProgress.value ? null : performBackupCheck,
  124. child: checkInProgress.value
  125. ? const CircularProgressIndicator()
  126. : const Text("Perform check"),
  127. ),
  128. ],
  129. ),
  130. );
  131. }
  132. void showErrorToUser(String msg) {
  133. final snackBar = SnackBar(
  134. content: Text(
  135. msg.tr(),
  136. style: context.textTheme.bodyLarge?.copyWith(
  137. color: context.primaryColor,
  138. ),
  139. ),
  140. backgroundColor: Colors.red,
  141. );
  142. ScaffoldMessenger.of(context).showSnackBar(snackBar);
  143. }
  144. void showBatteryOptimizationInfoToUser() {
  145. showDialog<void>(
  146. context: context,
  147. barrierDismissible: false,
  148. builder: (BuildContext context) {
  149. return AlertDialog(
  150. title: const Text(
  151. 'backup_controller_page_background_battery_info_title',
  152. ).tr(),
  153. content: SingleChildScrollView(
  154. child: const Text(
  155. 'backup_controller_page_background_battery_info_message',
  156. ).tr(),
  157. ),
  158. actions: [
  159. ElevatedButton(
  160. onPressed: () => launchUrl(
  161. Uri.parse('https://dontkillmyapp.com'),
  162. mode: LaunchMode.externalApplication,
  163. ),
  164. child: const Text(
  165. "backup_controller_page_background_battery_info_link",
  166. style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
  167. ).tr(),
  168. ),
  169. ElevatedButton(
  170. child: const Text(
  171. 'backup_controller_page_background_battery_info_ok',
  172. style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
  173. ).tr(),
  174. onPressed: () {
  175. context.pop();
  176. },
  177. ),
  178. ],
  179. );
  180. },
  181. );
  182. }
  183. Widget buildBackgroundBackupController() {
  184. final bool isBackgroundEnabled = backupState.backgroundBackup;
  185. final bool isWifiRequired = backupState.backupRequireWifi;
  186. final bool isChargingRequired = backupState.backupRequireCharging;
  187. final Color activeColor = context.primaryColor;
  188. String formatBackupDelaySliderValue(double v) {
  189. if (v == 0.0) {
  190. return 'setting_notifications_notify_seconds'.tr(args: const ['5']);
  191. } else if (v == 1.0) {
  192. return 'setting_notifications_notify_seconds'.tr(args: const ['30']);
  193. } else if (v == 2.0) {
  194. return 'setting_notifications_notify_minutes'.tr(args: const ['2']);
  195. } else {
  196. return 'setting_notifications_notify_minutes'.tr(args: const ['10']);
  197. }
  198. }
  199. int backupDelayToMilliseconds(double v) {
  200. if (v == 0.0) {
  201. return 5000;
  202. } else if (v == 1.0) {
  203. return 30000;
  204. } else if (v == 2.0) {
  205. return 120000;
  206. } else {
  207. return 600000;
  208. }
  209. }
  210. double backupDelayToSliderValue(int ms) {
  211. if (ms == 5000) {
  212. return 0.0;
  213. } else if (ms == 30000) {
  214. return 1.0;
  215. } else if (ms == 120000) {
  216. return 2.0;
  217. } else {
  218. return 3.0;
  219. }
  220. }
  221. final triggerDelay =
  222. useState(backupDelayToSliderValue(backupState.backupTriggerDelay));
  223. return Column(
  224. children: [
  225. ListTile(
  226. isThreeLine: true,
  227. leading: isBackgroundEnabled
  228. ? Icon(
  229. Icons.cloud_sync_rounded,
  230. color: activeColor,
  231. )
  232. : const Icon(Icons.cloud_sync_rounded),
  233. title: Text(
  234. isBackgroundEnabled
  235. ? "backup_controller_page_background_is_on"
  236. : "backup_controller_page_background_is_off",
  237. style: context.textTheme.titleSmall,
  238. ).tr(),
  239. subtitle: Column(
  240. crossAxisAlignment: CrossAxisAlignment.start,
  241. children: [
  242. if (!isBackgroundEnabled)
  243. Padding(
  244. padding: const EdgeInsets.symmetric(vertical: 8.0),
  245. child: const Text(
  246. "backup_controller_page_background_description",
  247. ).tr(),
  248. ),
  249. if (isBackgroundEnabled && Platform.isAndroid)
  250. SwitchListTile.adaptive(
  251. title: const Text("backup_controller_page_background_wifi")
  252. .tr(),
  253. secondary: Icon(
  254. Icons.wifi,
  255. color: isWifiRequired ? activeColor : null,
  256. ),
  257. dense: true,
  258. activeColor: activeColor,
  259. value: isWifiRequired,
  260. onChanged: (isChecked) => ref
  261. .read(backupProvider.notifier)
  262. .configureBackgroundBackup(
  263. requireWifi: isChecked,
  264. onError: showErrorToUser,
  265. onBatteryInfo: showBatteryOptimizationInfoToUser,
  266. ),
  267. ),
  268. if (isBackgroundEnabled)
  269. SwitchListTile.adaptive(
  270. title:
  271. const Text("backup_controller_page_background_charging")
  272. .tr(),
  273. secondary: Icon(
  274. Icons.charging_station,
  275. color: isChargingRequired ? activeColor : null,
  276. ),
  277. dense: true,
  278. activeColor: activeColor,
  279. value: isChargingRequired,
  280. onChanged: (isChecked) => ref
  281. .read(backupProvider.notifier)
  282. .configureBackgroundBackup(
  283. requireCharging: isChecked,
  284. onError: showErrorToUser,
  285. onBatteryInfo: showBatteryOptimizationInfoToUser,
  286. ),
  287. ),
  288. if (isBackgroundEnabled && Platform.isAndroid)
  289. ListTile(
  290. isThreeLine: false,
  291. dense: true,
  292. title: const Text(
  293. 'backup_controller_page_background_delay',
  294. style: TextStyle(
  295. fontWeight: FontWeight.bold,
  296. ),
  297. ).tr(
  298. args: [formatBackupDelaySliderValue(triggerDelay.value)],
  299. ),
  300. subtitle: Slider(
  301. value: triggerDelay.value,
  302. onChanged: (double v) => triggerDelay.value = v,
  303. onChangeEnd: (double v) => ref
  304. .read(backupProvider.notifier)
  305. .configureBackgroundBackup(
  306. triggerDelay: backupDelayToMilliseconds(v),
  307. onError: showErrorToUser,
  308. onBatteryInfo: showBatteryOptimizationInfoToUser,
  309. ),
  310. max: 3.0,
  311. divisions: 3,
  312. label: formatBackupDelaySliderValue(triggerDelay.value),
  313. activeColor: context.primaryColor,
  314. ),
  315. ),
  316. ElevatedButton(
  317. onPressed: () => ref
  318. .read(backupProvider.notifier)
  319. .configureBackgroundBackup(
  320. enabled: !isBackgroundEnabled,
  321. onError: showErrorToUser,
  322. onBatteryInfo: showBatteryOptimizationInfoToUser,
  323. ),
  324. child: Text(
  325. isBackgroundEnabled
  326. ? "backup_controller_page_background_turn_off"
  327. : "backup_controller_page_background_turn_on",
  328. style: context.textTheme.labelLarge?.copyWith(
  329. color: context.isDarkTheme ? Colors.black : Colors.white,
  330. ),
  331. ).tr(),
  332. ),
  333. ],
  334. ),
  335. ),
  336. if (isBackgroundEnabled && Platform.isIOS)
  337. FutureBuilder(
  338. future: ref
  339. .read(backgroundServiceProvider)
  340. .getIOSBackgroundAppRefreshEnabled(),
  341. builder: (context, snapshot) {
  342. final enabled = snapshot.data;
  343. // If it's not enabled, show them some kind of alert that says
  344. // background refresh is not enabled
  345. if (enabled != null && !enabled) {}
  346. // If it's enabled, no need to bother them
  347. return Container();
  348. },
  349. ),
  350. if (Platform.isIOS && isBackgroundEnabled && settings != null)
  351. IosDebugInfoTile(
  352. settings: settings,
  353. ),
  354. ],
  355. );
  356. }
  357. Widget buildBackgroundAppRefreshWarning() {
  358. return ListTile(
  359. isThreeLine: true,
  360. leading: const Icon(
  361. Icons.task_outlined,
  362. ),
  363. title: const Text(
  364. 'backup_controller_page_background_app_refresh_disabled_title',
  365. style: TextStyle(
  366. fontWeight: FontWeight.bold,
  367. fontSize: 14,
  368. ),
  369. ).tr(),
  370. subtitle: Column(
  371. crossAxisAlignment: CrossAxisAlignment.start,
  372. children: [
  373. Padding(
  374. padding: const EdgeInsets.symmetric(vertical: 8.0),
  375. child: const Text(
  376. 'backup_controller_page_background_app_refresh_disabled_content',
  377. ).tr(),
  378. ),
  379. ElevatedButton(
  380. onPressed: () => openAppSettings(),
  381. child: const Text(
  382. 'backup_controller_page_background_app_refresh_enable_button_text',
  383. style: TextStyle(
  384. fontWeight: FontWeight.bold,
  385. fontSize: 12,
  386. ),
  387. ).tr(),
  388. ),
  389. ],
  390. ),
  391. );
  392. }
  393. ListTile buildAutoBackupController() {
  394. final isAutoBackup = backupState.autoBackup;
  395. final backUpOption = isAutoBackup
  396. ? "backup_controller_page_status_on".tr()
  397. : "backup_controller_page_status_off".tr();
  398. final backupBtnText = isAutoBackup
  399. ? "backup_controller_page_turn_off".tr()
  400. : "backup_controller_page_turn_on".tr();
  401. return ListTile(
  402. isThreeLine: true,
  403. leading: isAutoBackup
  404. ? Icon(
  405. Icons.cloud_done_rounded,
  406. color: context.primaryColor,
  407. )
  408. : const Icon(Icons.cloud_off_rounded),
  409. title: Text(
  410. backUpOption,
  411. style: context.textTheme.titleSmall,
  412. ),
  413. subtitle: Padding(
  414. padding: const EdgeInsets.symmetric(vertical: 8.0),
  415. child: Column(
  416. crossAxisAlignment: CrossAxisAlignment.start,
  417. children: [
  418. if (!isAutoBackup)
  419. const Text(
  420. "backup_controller_page_desc_backup",
  421. style: TextStyle(fontSize: 14),
  422. ).tr(),
  423. Padding(
  424. padding: const EdgeInsets.only(top: 8.0),
  425. child: ElevatedButton(
  426. onPressed: () => ref
  427. .read(backupProvider.notifier)
  428. .setAutoBackup(!isAutoBackup),
  429. child: Text(
  430. backupBtnText,
  431. style: context.textTheme.labelLarge?.copyWith(
  432. color: context.isDarkTheme ? Colors.black : Colors.white,
  433. ),
  434. ),
  435. ),
  436. ),
  437. ],
  438. ),
  439. ),
  440. );
  441. }
  442. return Scaffold(
  443. appBar: AppBar(
  444. elevation: 0,
  445. title: const Text(
  446. "Backup options",
  447. ),
  448. leading: IconButton(
  449. onPressed: () {
  450. context.autoPop(true);
  451. },
  452. splashRadius: 24,
  453. icon: const Icon(
  454. Icons.arrow_back_ios_rounded,
  455. ),
  456. ),
  457. ),
  458. body: Padding(
  459. padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 32.0),
  460. child: ListView(
  461. children: [
  462. buildAutoBackupController(),
  463. const Divider(),
  464. AnimatedSwitcher(
  465. duration: const Duration(milliseconds: 500),
  466. child: Platform.isIOS
  467. ? (appRefreshDisabled
  468. ? buildBackgroundAppRefreshWarning()
  469. : buildBackgroundBackupController())
  470. : buildBackgroundBackupController(),
  471. ),
  472. if (showBackupFix) const Divider(),
  473. if (showBackupFix) buildCheckCorruptBackups(),
  474. ],
  475. ),
  476. ),
  477. );
  478. }
  479. }