From d065059ca1a0a9e3ae96ceb93c3160e6457e6151 Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Sun, 1 Mar 2026 10:16:23 +0530 Subject: [PATCH] feat: show notification and battery optimization warning --- i18n/en.json | 2 + .../app/alextran/immich/MainActivity.kt | 3 + .../immich/permission/PermissionApi.g.kt | 114 ++++++++++++ .../immich/permission/PermissionApiImpl.kt | 19 ++ .../lib/pages/backup/drift_backup.page.dart | 162 +++++++++++++++++- mobile/lib/platform/permission_api.g.dart | 87 ++++++++++ .../infrastructure/platform.provider.dart | 5 +- mobile/makefile | 2 + mobile/mise.toml | 21 ++- mobile/pigeon/permission_api.dart | 17 ++ 10 files changed, 425 insertions(+), 7 deletions(-) create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApiImpl.kt create mode 100644 mobile/lib/platform/permission_api.g.dart create mode 100644 mobile/pigeon/permission_api.dart diff --git a/i18n/en.json b/i18n/en.json index 97cff2c69c..3ba13db596 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -689,6 +689,7 @@ "backup_settings_subtitle": "Manage upload settings", "backup_upload_details_page_more_details": "Tap for more details", "backward": "Backward", + "battery_optimization_backup_reliability": "Disabling battery optimizations can improve the reliability of background backup", "biometric_auth_enabled": "Biometric authentication enabled", "biometric_locked_out": "You are locked out of biometric authentication", "biometric_no_options": "No biometric options available", @@ -1623,6 +1624,7 @@ "not_selected": "Not selected", "notes": "Notes", "nothing_here_yet": "Nothing here yet", + "notification_backup_reliability": "Enable notifications to improve background backup reliability", "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", "notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_enable_button": "Enable Notifications", diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index a85929a0e9..54fda1eb81 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -16,6 +16,8 @@ import app.alextran.immich.images.LocalImageApi import app.alextran.immich.images.LocalImagesImpl import app.alextran.immich.images.RemoteImageApi import app.alextran.immich.images.RemoteImagesImpl +import app.alextran.immich.permission.PermissionApi +import app.alextran.immich.permission.PermissionApiImpl import app.alextran.immich.sync.NativeSyncApi import app.alextran.immich.sync.NativeSyncApiImpl26 import app.alextran.immich.sync.NativeSyncApiImpl30 @@ -48,6 +50,7 @@ class MainActivity : FlutterFragmentActivity() { BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx)) ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx)) + PermissionApi.setUp(messenger, PermissionApiImpl(ctx)) flutterEngine.plugins.add(BackgroundServicePlugin()) flutterEngine.plugins.add(backgroundEngineLockImpl) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt new file mode 100644 index 0000000000..4a30398f98 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt @@ -0,0 +1,114 @@ +// Autogenerated from Pigeon (v26.0.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package app.alextran.immich.permission + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object PermissionApiPigeonUtils { + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : Throwable() + +enum class PermissionStatus(val raw: Int) { + GRANTED(0), + DENIED(1), + PERMANENTLY_DENIED(2); + + companion object { + fun ofRaw(raw: Int): PermissionStatus? { + return values().firstOrNull { it.raw == raw } + } + } +} +private open class PermissionApiPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as Long?)?.let { + PermissionStatus.ofRaw(it.toInt()) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is PermissionStatus -> { + stream.write(129) + writeValue(stream, value.raw) + } + else -> super.writeValue(stream, value) + } + } +} + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface PermissionApi { + fun isIgnoringBatteryOptimizations(): PermissionStatus + + companion object { + /** The codec used by PermissionApi. */ + val codec: MessageCodec by lazy { + PermissionApiPigeonCodec() + } + /** Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.isIgnoringBatteryOptimizations()) + } catch (exception: Throwable) { + PermissionApiPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApiImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApiImpl.kt new file mode 100644 index 0000000000..822f0455fc --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApiImpl.kt @@ -0,0 +1,19 @@ +package app.alextran.immich.permission + +import android.content.Context +import android.os.PowerManager + +class PermissionApiImpl(context: Context) : PermissionApi { + private val ctx: Context = context.applicationContext + + private val powerManager = + ctx.getSystemService(Context.POWER_SERVICE) as PowerManager + + + override fun isIgnoringBatteryOptimizations(): PermissionStatus { + if (powerManager.isIgnoringBatteryOptimizations(ctx.packageName)) { + return PermissionStatus.GRANTED + } + return PermissionStatus.DENIED + } +} diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index cd6c2a62b0..2c99524a60 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -8,18 +8,25 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/platform/permission_api.g.dart'; import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; +import 'package:immich_mobile/providers/notification_permission.provider.dart'; import 'package:immich_mobile/providers/sync_status.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/backup/backup_info_card.dart'; import 'package:logging/logging.dart'; +import 'package:permission_handler/permission_handler.dart' as pm; +import 'package:url_launcher/url_launcher.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; @RoutePage() @@ -161,11 +168,7 @@ class _DriftBackupPageState extends ConsumerState { ), ), }, - TextButton.icon( - icon: const Icon(Icons.info_outline_rounded), - onPressed: () => context.pushRoute(const DriftUploadDetailRoute()), - label: Text("view_details".t(context: context)), - ), + const _BackupFooter(), ], ], ), @@ -176,6 +179,130 @@ class _DriftBackupPageState extends ConsumerState { } } +class _BackupFooter extends ConsumerStatefulWidget { + const _BackupFooter(); + + @override + ConsumerState<_BackupFooter> createState() => _BackupFooterState(); +} + +class _BackupFooterState extends ConsumerState<_BackupFooter> with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (CurrentPlatform.isAndroid && state == AppLifecycleState.resumed && mounted) { + unawaited(ref.read(notificationPermissionProvider.notifier).getNotificationPermission()); + unawaited(ref.read(_batteryOptimizationProvider.notifier).getBatteryOptimizationPermission()); + } + } + + void showPermissionsDialog() { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + content: Text(context.t.notification_permission_dialog_content), + actions: [ + TextButton(child: Text(context.t.cancel), onPressed: () => ctx.pop()), + TextButton( + onPressed: () { + context.pop(); + pm.openAppSettings(); + }, + child: Text(context.t.settings), + ), + ], + ), + ); + } + + void showBatteryOptimizationInfo() { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext ctx) { + return AlertDialog( + title: Text(context.t.backup_controller_page_background_battery_info_title), + content: SingleChildScrollView(child: Text(context.t.backup_controller_page_background_battery_info_message)), + actions: [ + ElevatedButton( + onPressed: () => launchUrl(Uri.parse('https://dontkillmyapp.com'), mode: LaunchMode.externalApplication), + child: Text( + context.t.backup_controller_page_background_battery_info_link, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + ), + ElevatedButton( + child: Text( + context.t.backup_controller_page_background_battery_info_ok, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + onPressed: () => ctx.pop(), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final isBackupEnabled = ref.watch(_backupStatusProvider).valueOrNull ?? false; + final notificationStatus = ref.watch(notificationPermissionProvider); + final batteryOptimizationStatus = ref.watch(_batteryOptimizationProvider).valueOrNull; + + return Column( + children: [ + if (CurrentPlatform.isAndroid && isBackupEnabled) ...[ + if (notificationStatus != pm.PermissionStatus.granted) + TextButton.icon( + icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary), + label: Text( + context.t.notification_backup_reliability, + textAlign: TextAlign.center, + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + onPressed: () { + ref.read(notificationPermissionProvider.notifier).requestNotificationPermission().then((p) { + if (p == pm.PermissionStatus.permanentlyDenied) { + showPermissionsDialog(); + } + }); + }, + ), + // Show only after notification permission is granted + if (notificationStatus == pm.PermissionStatus.granted && + batteryOptimizationStatus != pm.PermissionStatus.granted) + TextButton.icon( + icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary), + label: Text( + context.t.battery_optimization_backup_reliability, + textAlign: TextAlign.center, + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + onPressed: showBatteryOptimizationInfo, + ), + ], + TextButton.icon( + icon: const Icon(Icons.info_outline_rounded), + onPressed: () => context.pushRoute(const DriftUploadDetailRoute()), + label: Text(context.t.view_details), + ), + ], + ); + } +} + class _BackupAlbumSelectionCard extends ConsumerWidget { const _BackupAlbumSelectionCard(); @@ -521,3 +648,28 @@ class _PreparingStatusState extends ConsumerState { ); } } + +final _backupStatusProvider = StreamProvider.autoDispose((ref) async* { + yield* ref.watch(storeServiceProvider).watch(StoreKey.enableBackup); +}); + +final _batteryOptimizationProvider = AsyncNotifierProvider<_BatteryOptimizationNotifier, pm.PermissionStatus>( + _BatteryOptimizationNotifier.new, +); + +class _BatteryOptimizationNotifier extends AsyncNotifier { + Future getBatteryOptimizationPermission() async { + final pm.PermissionStatus status; + final isIgnoring = await ref.read(permissionApiProvider).isIgnoringBatteryOptimizations(); + if (isIgnoring == PermissionStatus.granted) { + status = pm.PermissionStatus.granted; + } else { + status = pm.PermissionStatus.denied; + } + state = AsyncValue.data(status); + return status; + } + + @override + FutureOr build() => getBatteryOptimizationPermission(); +} diff --git a/mobile/lib/platform/permission_api.g.dart b/mobile/lib/platform/permission_api.g.dart new file mode 100644 index 0000000000..23429e2b31 --- /dev/null +++ b/mobile/lib/platform/permission_api.g.dart @@ -0,0 +1,87 @@ +// Autogenerated from Pigeon (v26.0.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +enum PermissionStatus { granted, denied, permanentlyDenied } + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is PermissionStatus) { + buffer.putUint8(129); + writeValue(buffer, value.index); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + final int? value = readValue(buffer) as int?; + return value == null ? null : PermissionStatus.values[value]; + default: + return super.readValueOfType(type, buffer); + } + } +} + +class PermissionApi { + /// Constructor for [PermissionApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + PermissionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future isIgnoringBatteryOptimizations() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as PermissionStatus?)!; + } + } +} diff --git a/mobile/lib/providers/infrastructure/platform.provider.dart b/mobile/lib/providers/infrastructure/platform.provider.dart index 01d0f61d1c..a2bd1fd0d4 100644 --- a/mobile/lib/providers/infrastructure/platform.provider.dart +++ b/mobile/lib/providers/infrastructure/platform.provider.dart @@ -3,9 +3,10 @@ import 'package:immich_mobile/domain/services/background_worker.service.dart'; import 'package:immich_mobile/platform/background_worker_api.g.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/platform/connectivity_api.g.dart'; -import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/local_image_api.g.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/network_api.g.dart'; +import 'package:immich_mobile/platform/permission_api.g.dart'; import 'package:immich_mobile/platform/remote_image_api.g.dart'; final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi())); @@ -18,6 +19,8 @@ final nativeSyncApiProvider = Provider((_) => NativeSyncApi()); final connectivityApiProvider = Provider((_) => ConnectivityApi()); +final permissionApiProvider = Provider((_) => PermissionApi()); + final localImageApi = LocalImageApi(); final remoteImageApi = RemoteImageApi(); diff --git a/mobile/makefile b/mobile/makefile index 3a0a263687..f2eb12cb01 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -13,6 +13,7 @@ pigeon: dart run pigeon --input pigeon/background_worker_lock_api.dart dart run pigeon --input pigeon/connectivity_api.dart dart run pigeon --input pigeon/network_api.dart + dart run pigeon --input pigeon/permission_api.dart dart format lib/platform/native_sync_api.g.dart dart format lib/platform/local_image_api.g.dart dart format lib/platform/remote_image_api.g.dart @@ -20,6 +21,7 @@ pigeon: dart format lib/platform/background_worker_lock_api.g.dart dart format lib/platform/connectivity_api.g.dart dart format lib/platform/network_api.g.dart + dart format lib/platform/permission_api.g.dart watch: dart run build_runner watch --delete-conflicting-outputs diff --git a/mobile/mise.toml b/mobile/mise.toml index 88b8902053..b4f15a8a0a 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -40,7 +40,13 @@ depends = [ [tasks."codegen:translation"] alias = "translation" description = "Generate translations from i18n JSONs" -run = [{ task = "//:i18n:format-fix" }, { tasks = ["i18n:loader", "i18n:keys"] }] +run = [ + { task = "//:i18n:format-fix" }, + { tasks = [ + "i18n:loader", + "i18n:keys", + ] }, +] [tasks."codegen:app-icon"] description = "Generate app icons" @@ -146,6 +152,19 @@ run = [ "dart format lib/platform/connectivity_api.g.dart", ] +[tasks."pigeon:permission"] +description = "Generate permission API pigeon code" +hide = true +sources = ["pigeon/permission_api.dart"] +outputs = [ + "lib/platform/permission_api.g.dart", + "android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt", +] +run = [ + "dart run pigeon --input pigeon/permission_api.dart", + "dart format lib/platform/permission_api.g.dart", +] + [tasks."i18n:loader"] description = "Generate i18n loader" hide = true diff --git a/mobile/pigeon/permission_api.dart b/mobile/pigeon/permission_api.dart new file mode 100644 index 0000000000..873a7960d6 --- /dev/null +++ b/mobile/pigeon/permission_api.dart @@ -0,0 +1,17 @@ +import 'package:pigeon/pigeon.dart'; + +enum PermissionStatus { granted, denied, permanentlyDenied } + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/permission_api.g.dart', + kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.permission'), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) +@HostApi() +abstract class PermissionApi { + PermissionStatus isIgnoringBatteryOptimizations(); +}