Request notification permission for geofence alerts

This commit is contained in:
Artem Kokos
2026-05-15 10:43:21 +07:00
parent 8b9a25e746
commit 50748c6945
6 changed files with 124 additions and 14 deletions

View File

@@ -14,7 +14,7 @@ Android-клиент для self-hosted backend [Ignis Core](https://git.akokos.
## Гео-автоматизация ## Гео-автоматизация
Для активного дома приложение может зарегистрировать системный Android geofence. После подтверждённого `EXIT` запускается короткая фоновая задача, которая проверяет группы и выключает только те, что реально включены. Для активного дома приложение может зарегистрировать системный Android geofence. После подтверждённого `EXIT` запускается короткая фоновая задача, которая проверяет группы и выключает только те, что реально включены. После успешной фоновой обработки приложение может показать локальное подтверждение через Android notifications.
Это не polling каждые 15 минут. Основной триггер здесь событийный: Это не polling каждые 15 минут. Основной триггер здесь событийный:
- geofence регистрируется нативно через Android geofencing API; - geofence регистрируется нативно через Android geofencing API;
@@ -102,6 +102,7 @@ flutter test
2. При необходимости задать координаты дома. 2. При необходимости задать координаты дома.
3. Включить "выключать свет при уходе". 3. Включить "выключать свет при уходе".
4. Выдать Android-разрешения на геолокацию, включая background location. 4. Выдать Android-разрешения на геолокацию, включая background location.
5. Разрешить уведомления, если нужны подтверждения о срабатывании geofence.
API-ключи хранятся отдельно от списка домов в `flutter_secure_storage`. Старые ключи из `SharedPreferences` мигрируются автоматически. API-ключи хранятся отдельно от списка домов в `flutter_secure_storage`. Старые ключи из `SharedPreferences` мигрируются автоматически.

View File

@@ -5,6 +5,7 @@ import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
@@ -12,6 +13,8 @@ import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private var pendingNotificationPermissionResult: MethodChannel.Result? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
@@ -67,9 +70,10 @@ class MainActivity : FlutterActivity() {
} }
} }
"getNotificationPermissionStatus" -> { "getNotificationPermissionStatus" -> {
result.success( result.success(getNotificationPermissionStatusValue())
if (isNotificationPermissionEnabled()) "enabled" else "disabled", }
) "requestNotificationPermission" -> {
requestNotificationPermission(result)
} }
"openNotificationSettings" -> { "openNotificationSettings" -> {
val intent = val intent =
@@ -85,6 +89,70 @@ class MainActivity : FlutterActivity() {
} }
} }
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
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 { private fun isNotificationPermissionEnabled(): Boolean {
val runtimePermissionGranted = val runtimePermissionGranted =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
@@ -95,4 +163,8 @@ class MainActivity : FlutterActivity() {
return runtimePermissionGranted && return runtimePermissionGranted &&
NotificationManagerCompat.from(this).areNotificationsEnabled() NotificationManagerCompat.from(this).areNotificationsEnabled()
} }
companion object {
private const val notificationPermissionRequestCode = 4102
}
} }

View File

@@ -1,12 +1,14 @@
enum NotificationPermissionStatus { enum NotificationPermissionStatus {
enabled, enabled,
disabled, requestable,
settingsRequired,
unsupported; unsupported;
static NotificationPermissionStatus fromPlatformValue(String? value) { static NotificationPermissionStatus fromPlatformValue(String? value) {
return switch (value) { return switch (value) {
'enabled' => NotificationPermissionStatus.enabled, 'enabled' => NotificationPermissionStatus.enabled,
'disabled' => NotificationPermissionStatus.disabled, 'requestable' => NotificationPermissionStatus.requestable,
'settings_required' => NotificationPermissionStatus.settingsRequired,
_ => NotificationPermissionStatus.unsupported, _ => NotificationPermissionStatus.unsupported,
}; };
} }

View File

@@ -93,6 +93,8 @@ final geofenceSystemStatusProvider = FutureProvider<GeofenceSystemState>((
abstract class NotificationPermissionStatusService { abstract class NotificationPermissionStatusService {
Future<NotificationPermissionStatus> inspect(); Future<NotificationPermissionStatus> inspect();
Future<void> requestPermission();
Future<void> openSettings(); Future<void> openSettings();
} }
@@ -112,6 +114,15 @@ class DeviceNotificationPermissionStatusService
} }
} }
@override
Future<void> requestPermission() async {
try {
await _channel.invokeMethod<void>('requestNotificationPermission');
} on MissingPluginException {
return;
}
}
@override @override
Future<void> openSettings() async { Future<void> openSettings() async {
try { try {

View File

@@ -58,11 +58,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen>
final geofenceState = geofenceStatus.asData?.value; final geofenceState = geofenceStatus.asData?.value;
final notificationStatus = ref.watch(notificationPermissionStatusProvider); final notificationStatus = ref.watch(notificationPermissionStatusProvider);
final notificationPermission = notificationStatus.asData?.value; final notificationPermission = notificationStatus.asData?.value;
final bottomInset = MediaQuery.paddingOf(context).bottom;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('НАСТРОЙКИ')), appBar: AppBar(title: const Text('НАСТРОЙКИ')),
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: EdgeInsets.fromLTRB(16, 16, 16, bottomInset + 24),
children: [ children: [
_SectionTitle( _SectionTitle(
title: 'Дом и подключение', title: 'Дом и подключение',
@@ -321,7 +322,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen>
], ],
}; };
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( actions.add(
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () async { onPressed: () async {
@@ -631,7 +646,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen>
return switch (status) { return switch (status) {
NotificationPermissionStatus.enabled => NotificationPermissionStatus.enabled =>
Icons.notifications_active_outlined, Icons.notifications_active_outlined,
NotificationPermissionStatus.disabled => Icons.notifications_off_outlined, NotificationPermissionStatus.requestable =>
Icons.notifications_off_outlined,
NotificationPermissionStatus.settingsRequired =>
Icons.notifications_paused_outlined,
NotificationPermissionStatus.unsupported || NotificationPermissionStatus.unsupported ||
null => Icons.notifications_none_outlined, null => Icons.notifications_none_outlined,
}; };
@@ -643,7 +661,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen>
) { ) {
return switch (status) { return switch (status) {
NotificationPermissionStatus.enabled => Colors.green, NotificationPermissionStatus.enabled => Colors.green,
NotificationPermissionStatus.disabled => Theme.of( NotificationPermissionStatus.requestable ||
NotificationPermissionStatus.settingsRequired => Theme.of(
context, context,
).colorScheme.error, ).colorScheme.error,
NotificationPermissionStatus.unsupported || null => Colors.white70, NotificationPermissionStatus.unsupported || null => Colors.white70,
@@ -654,8 +673,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen>
return switch (status) { return switch (status) {
NotificationPermissionStatus.enabled => NotificationPermissionStatus.enabled =>
'После срабатывания geofence приложение сможет прислать подтверждение.', 'После срабатывания geofence приложение сможет прислать подтверждение.',
NotificationPermissionStatus.disabled => NotificationPermissionStatus.requestable =>
'Автовыключение сработает и без них, но подтверждение ты не увидишь.', 'Android ещё может показать системный запрос на уведомления.',
NotificationPermissionStatus.settingsRequired =>
'Автовыключение сработает и без них, но подтверждение нужно включить вручную.',
NotificationPermissionStatus.unsupported => NotificationPermissionStatus.unsupported =>
'На этой платформе отдельный статус уведомлений не используется.', 'На этой платформе отдельный статус уведомлений не используется.',
null => 'Проверяем, доступны ли уведомления для подтверждения.', null => 'Проверяем, доступны ли уведомления для подтверждения.',

View File

@@ -13,7 +13,7 @@ void main() {
overrides: [ overrides: [
notificationPermissionStatusServiceProvider.overrideWithValue( notificationPermissionStatusServiceProvider.overrideWithValue(
_FakeNotificationPermissionStatusService( _FakeNotificationPermissionStatusService(
NotificationPermissionStatus.disabled, NotificationPermissionStatus.requestable,
), ),
), ),
], ],
@@ -24,7 +24,7 @@ void main() {
notificationPermissionStatusProvider.future, notificationPermissionStatusProvider.future,
); );
expect(status, NotificationPermissionStatus.disabled); expect(status, NotificationPermissionStatus.requestable);
}, },
); );
} }
@@ -38,6 +38,9 @@ class _FakeNotificationPermissionStatusService
@override @override
Future<NotificationPermissionStatus> inspect() async => result; Future<NotificationPermissionStatus> inspect() async => result;
@override
Future<void> requestPermission() async {}
@override @override
Future<void> openSettings() async {} Future<void> openSettings() async {}
} }