diff --git a/README.md b/README.md index 37fd7b9..a0746bf 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Android-клиент для self-hosted backend [Ignis Core](https://git.akokos. ## Гео-автоматизация -Для активного дома приложение может зарегистрировать системный Android geofence. После подтверждённого `EXIT` запускается короткая фоновая задача, которая проверяет группы и выключает только те, что реально включены. +Для активного дома приложение может зарегистрировать системный Android geofence. После подтверждённого `EXIT` запускается короткая фоновая задача, которая проверяет группы и выключает только те, что реально включены. После успешной фоновой обработки приложение может показать локальное подтверждение через Android notifications. Это не polling каждые 15 минут. Основной триггер здесь событийный: - geofence регистрируется нативно через Android geofencing API; @@ -102,6 +102,7 @@ flutter test 2. При необходимости задать координаты дома. 3. Включить "выключать свет при уходе". 4. Выдать Android-разрешения на геолокацию, включая background location. +5. Разрешить уведомления, если нужны подтверждения о срабатывании geofence. API-ключи хранятся отдельно от списка домов в `flutter_secure_storage`. Старые ключи из `SharedPreferences` мигрируются автоматически. diff --git a/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt b/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt index eaa9f4c..9c0269c 100644 --- a/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.provider.Settings +import androidx.core.app.ActivityCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import io.flutter.embedding.engine.FlutterEngine @@ -12,6 +13,8 @@ import io.flutter.embedding.android.FlutterActivity import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { + private var pendingNotificationPermissionResult: MethodChannel.Result? = null + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -67,9 +70,10 @@ class MainActivity : FlutterActivity() { } } "getNotificationPermissionStatus" -> { - result.success( - if (isNotificationPermissionEnabled()) "enabled" else "disabled", - ) + result.success(getNotificationPermissionStatusValue()) + } + "requestNotificationPermission" -> { + requestNotificationPermission(result) } "openNotificationSettings" -> { val intent = @@ -85,6 +89,70 @@ class MainActivity : FlutterActivity() { } } + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + + if (requestCode != notificationPermissionRequestCode) { + return + } + + pendingNotificationPermissionResult?.success(getNotificationPermissionStatusValue()) + pendingNotificationPermissionResult = null + } + + private fun requestNotificationPermission(result: MethodChannel.Result) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + result.success(getNotificationPermissionStatusValue()) + return + } + + if (pendingNotificationPermissionResult != null) { + result.error( + "request_in_progress", + "Notification permission request is already in progress", + null, + ) + return + } + + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + ) { + result.success(getNotificationPermissionStatusValue()) + return + } + + pendingNotificationPermissionResult = result + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + notificationPermissionRequestCode, + ) + } + + private fun getNotificationPermissionStatusValue(): String { + if (isNotificationPermissionEnabled()) { + return "enabled" + } + + val runtimePermissionGranted = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + + return if (!runtimePermissionGranted) { + "requestable" + } else { + "settings_required" + } + } + private fun isNotificationPermissionEnabled(): Boolean { val runtimePermissionGranted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || @@ -95,4 +163,8 @@ class MainActivity : FlutterActivity() { return runtimePermissionGranted && NotificationManagerCompat.from(this).areNotificationsEnabled() } + + companion object { + private const val notificationPermissionRequestCode = 4102 + } } diff --git a/lib/features/settings/models/notification_permission_status.dart b/lib/features/settings/models/notification_permission_status.dart index 86fce43..caf66a3 100644 --- a/lib/features/settings/models/notification_permission_status.dart +++ b/lib/features/settings/models/notification_permission_status.dart @@ -1,12 +1,14 @@ enum NotificationPermissionStatus { enabled, - disabled, + requestable, + settingsRequired, unsupported; static NotificationPermissionStatus fromPlatformValue(String? value) { return switch (value) { 'enabled' => NotificationPermissionStatus.enabled, - 'disabled' => NotificationPermissionStatus.disabled, + 'requestable' => NotificationPermissionStatus.requestable, + 'settings_required' => NotificationPermissionStatus.settingsRequired, _ => NotificationPermissionStatus.unsupported, }; } diff --git a/lib/features/settings/providers/settings_providers.dart b/lib/features/settings/providers/settings_providers.dart index 7a11980..0bb66e8 100644 --- a/lib/features/settings/providers/settings_providers.dart +++ b/lib/features/settings/providers/settings_providers.dart @@ -93,6 +93,8 @@ final geofenceSystemStatusProvider = FutureProvider(( abstract class NotificationPermissionStatusService { Future inspect(); + Future requestPermission(); + Future openSettings(); } @@ -112,6 +114,15 @@ class DeviceNotificationPermissionStatusService } } + @override + Future requestPermission() async { + try { + await _channel.invokeMethod('requestNotificationPermission'); + } on MissingPluginException { + return; + } + } + @override Future openSettings() async { try { diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index d214c77..42426b1 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -58,11 +58,12 @@ class _SettingsScreenState extends ConsumerState final geofenceState = geofenceStatus.asData?.value; final notificationStatus = ref.watch(notificationPermissionStatusProvider); final notificationPermission = notificationStatus.asData?.value; + final bottomInset = MediaQuery.paddingOf(context).bottom; return Scaffold( appBar: AppBar(title: const Text('НАСТРОЙКИ')), body: ListView( - padding: const EdgeInsets.all(16), + padding: EdgeInsets.fromLTRB(16, 16, 16, bottomInset + 24), children: [ _SectionTitle( title: 'Дом и подключение', @@ -321,7 +322,21 @@ class _SettingsScreenState extends ConsumerState ], }; - if (notificationPermission == NotificationPermissionStatus.disabled) { + if (notificationPermission == NotificationPermissionStatus.requestable) { + actions.add( + FilledButton.tonalIcon( + onPressed: () async { + await notificationNotifier.requestPermission(); + ref.invalidate(notificationPermissionStatusProvider); + }, + icon: const Icon(Icons.notifications_active_outlined), + label: const Text('Разрешить уведомления'), + ), + ); + } + + if (notificationPermission == + NotificationPermissionStatus.settingsRequired) { actions.add( OutlinedButton.icon( onPressed: () async { @@ -631,7 +646,10 @@ class _SettingsScreenState extends ConsumerState return switch (status) { NotificationPermissionStatus.enabled => Icons.notifications_active_outlined, - NotificationPermissionStatus.disabled => Icons.notifications_off_outlined, + NotificationPermissionStatus.requestable => + Icons.notifications_off_outlined, + NotificationPermissionStatus.settingsRequired => + Icons.notifications_paused_outlined, NotificationPermissionStatus.unsupported || null => Icons.notifications_none_outlined, }; @@ -643,7 +661,8 @@ class _SettingsScreenState extends ConsumerState ) { return switch (status) { NotificationPermissionStatus.enabled => Colors.green, - NotificationPermissionStatus.disabled => Theme.of( + NotificationPermissionStatus.requestable || + NotificationPermissionStatus.settingsRequired => Theme.of( context, ).colorScheme.error, NotificationPermissionStatus.unsupported || null => Colors.white70, @@ -654,8 +673,10 @@ class _SettingsScreenState extends ConsumerState return switch (status) { NotificationPermissionStatus.enabled => 'После срабатывания geofence приложение сможет прислать подтверждение.', - NotificationPermissionStatus.disabled => - 'Автовыключение сработает и без них, но подтверждение ты не увидишь.', + NotificationPermissionStatus.requestable => + 'Android ещё может показать системный запрос на уведомления.', + NotificationPermissionStatus.settingsRequired => + 'Автовыключение сработает и без них, но подтверждение нужно включить вручную.', NotificationPermissionStatus.unsupported => 'На этой платформе отдельный статус уведомлений не используется.', null => 'Проверяем, доступны ли уведомления для подтверждения.', diff --git a/test/notification_permission_status_provider_test.dart b/test/notification_permission_status_provider_test.dart index 2bf56a3..bb084ae 100644 --- a/test/notification_permission_status_provider_test.dart +++ b/test/notification_permission_status_provider_test.dart @@ -13,7 +13,7 @@ void main() { overrides: [ notificationPermissionStatusServiceProvider.overrideWithValue( _FakeNotificationPermissionStatusService( - NotificationPermissionStatus.disabled, + NotificationPermissionStatus.requestable, ), ), ], @@ -24,7 +24,7 @@ void main() { notificationPermissionStatusProvider.future, ); - expect(status, NotificationPermissionStatus.disabled); + expect(status, NotificationPermissionStatus.requestable); }, ); } @@ -38,6 +38,9 @@ class _FakeNotificationPermissionStatusService @override Future inspect() async => result; + @override + Future requestPermission() async {} + @override Future openSettings() async {} }