diff --git a/README.md b/README.md index a0746bf..90698f5 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Android-клиент для self-hosted backend [Ignis Core](https://git.akokos. Это не polling каждые 15 минут. Основной триггер здесь событийный: - geofence регистрируется нативно через Android geofencing API; - сетевое выключение выполняется отдельным one-off worker; +- ошибки отдельных групп не должны блокировать выключение остальных; - при отсутствии координат или выключенной опции geofence не армится. ## Стек @@ -104,7 +105,9 @@ flutter test 4. Выдать Android-разрешения на геолокацию, включая background location. 5. Разрешить уведомления, если нужны подтверждения о срабатывании geofence. -API-ключи хранятся отдельно от списка домов в `flutter_secure_storage`. Старые ключи из `SharedPreferences` мигрируются автоматически. +API-ключи хранятся отдельно от списка домов в `flutter_secure_storage`. Для нативного geofence active-home config и текущий API-ключ дополнительно шифруются на Android-стороне. Старые ключи из `SharedPreferences` мигрируются автоматически. + +При редактировании существующего дома приложение не требует онлайн-проверку backend, если URL и API-ключ не менялись: локальные правки имени, координат и geofence-параметров можно сохранять отдельно. ## Ограничения diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 61b7b08..635377a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,7 +6,7 @@ Unit)? = null) { WorkManager.getInstance(context).cancelUniqueWork(exitWorkName) GeofenceNativeStore(context).clear() + GeofenceActiveHomeCredentials(context).clearAll() val client = LocationServices.getGeofencingClient(context) client.removeGeofences(buildPendingIntent(context)).addOnCompleteListener { diff --git a/android/app/src/main/kotlin/ru/akokos/ignis_app/GeofenceConfigCipher.kt b/android/app/src/main/kotlin/ru/akokos/ignis_app/GeofenceConfigCipher.kt new file mode 100644 index 0000000..005a70b --- /dev/null +++ b/android/app/src/main/kotlin/ru/akokos/ignis_app/GeofenceConfigCipher.kt @@ -0,0 +1,81 @@ +package ru.akokos.ignis_app + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.nio.ByteBuffer +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +class GeofenceConfigCipher { + fun encrypt(plainText: String): String { + val cipher = Cipher.getInstance(transformation) + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey()) + val iv = cipher.iv + val encrypted = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8)) + + val payload = + ByteBuffer.allocate(Int.SIZE_BYTES + iv.size + encrypted.size) + .putInt(iv.size) + .put(iv) + .put(encrypted) + .array() + + return Base64.encodeToString(payload, Base64.NO_WRAP) + } + + fun decrypt(payload: String): String { + val bytes = Base64.decode(payload, Base64.NO_WRAP) + val buffer = ByteBuffer.wrap(bytes) + val ivSize = buffer.int + require(ivSize in 12..32) { "Invalid IV size: $ivSize" } + + val iv = ByteArray(ivSize) + buffer.get(iv) + val encrypted = ByteArray(buffer.remaining()) + buffer.get(encrypted) + + val cipher = Cipher.getInstance(transformation) + cipher.init( + Cipher.DECRYPT_MODE, + getOrCreateSecretKey(), + GCMParameterSpec(gcmTagLengthBits, iv), + ) + val decrypted = cipher.doFinal(encrypted) + return decrypted.toString(Charsets.UTF_8) + } + + private fun getOrCreateSecretKey(): SecretKey { + val keyStore = KeyStore.getInstance(androidKeyStore).apply { load(null) } + val existing = keyStore.getKey(keyAlias, null) as? SecretKey + if (existing != null) { + return existing + } + + val keyGenerator = + KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + androidKeyStore, + ) + keyGenerator.init( + KeyGenParameterSpec.Builder( + keyAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ).setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + return keyGenerator.generateKey() + } + + companion object { + private const val androidKeyStore = "AndroidKeyStore" + private const val keyAlias = "ignis_geofence_config_key" + private const val transformation = "AES/GCM/NoPadding" + private const val gcmTagLengthBits = 128 + } +} diff --git a/android/app/src/main/kotlin/ru/akokos/ignis_app/GeofenceExitWorker.kt b/android/app/src/main/kotlin/ru/akokos/ignis_app/GeofenceExitWorker.kt index 173ca78..1ec5790 100644 --- a/android/app/src/main/kotlin/ru/akokos/ignis_app/GeofenceExitWorker.kt +++ b/android/app/src/main/kotlin/ru/akokos/ignis_app/GeofenceExitWorker.kt @@ -23,28 +23,51 @@ class GeofenceExitWorker( } val config = GeofenceNativeStore(applicationContext).loadConfig() ?: return Result.success() + val apiKey = GeofenceActiveHomeCredentials(applicationContext).loadApiKey(homeId) + ?: return Result.retry() return runCatching { - val groupIds = fetchGroupIds(config) - val activeGroupIds = groupIds.filter { isGroupOn(config, it) } + val groupIds = fetchGroupIds(config, apiKey) + var turnedOffGroups = 0 + var hadFailures = false - if (activeGroupIds.isNotEmpty()) { - activeGroupIds.forEach { turnOffGroup(config, it) } + for (groupId in groupIds) { + val isOn = + runCatching { isGroupOn(config, apiKey, groupId) } + .getOrElse { + hadFailures = true + false + } + + if (!isOn) { + continue + } + + runCatching { turnOffGroup(config, apiKey, groupId) } + .onSuccess { turnedOffGroups += 1 } + .onFailure { hadFailures = true } } - GeofenceAutomationManager.markTriggered(applicationContext) - GeofenceAutomationNotifier.showExitProcessed( - context = applicationContext, - homeName = config.homeName, - turnedOffGroups = activeGroupIds.size, - ) - Result.success() + if (hadFailures) { + Result.retry() + } else { + GeofenceAutomationManager.markTriggered(applicationContext) + GeofenceAutomationNotifier.showExitProcessed( + context = applicationContext, + homeName = config.homeName, + turnedOffGroups = turnedOffGroups, + ) + Result.success() + } } .getOrElse { Result.retry() } } - private fun fetchGroupIds(config: StoredGeofenceConfig): List { - val payload = requestJson(config, "/devices/groups") + private fun fetchGroupIds( + config: StoredGeofenceConfig, + apiKey: String, + ): List { + val payload = requestJson(config, apiKey, "/devices/groups") return when (payload) { is JSONArray -> buildList { @@ -78,15 +101,23 @@ class GeofenceExitWorker( } } - private fun isGroupOn(config: StoredGeofenceConfig, groupId: String): Boolean { + private fun isGroupOn( + config: StoredGeofenceConfig, + apiKey: String, + groupId: String, + ): Boolean { val encodedId = URLEncoder.encode(groupId, Charsets.UTF_8.name()) - val payload = requestJson(config, "/control/group/$encodedId/status") + val payload = requestJson(config, apiKey, "/control/group/$encodedId/status") return extractState(payload) ?: false } - private fun turnOffGroup(config: StoredGeofenceConfig, groupId: String) { + private fun turnOffGroup( + config: StoredGeofenceConfig, + apiKey: String, + groupId: String, + ) { val encodedId = URLEncoder.encode(groupId, Charsets.UTF_8.name()) - performRequest(config, "/control/group/$encodedId?state=false", method = "POST") + performRequest(config, apiKey, "/control/group/$encodedId?state=false", method = "POST") } private fun extractState(payload: Any?): Boolean? = @@ -113,13 +144,18 @@ class GeofenceExitWorker( else -> null } - private fun requestJson(config: StoredGeofenceConfig, path: String): Any? { - val body = performRequest(config, path, method = "GET") + private fun requestJson( + config: StoredGeofenceConfig, + apiKey: String, + path: String, + ): Any? { + val body = performRequest(config, apiKey, path, method = "GET") return JSONTokener(body).nextValue() } private fun performRequest( config: StoredGeofenceConfig, + apiKey: String, path: String, method: String, ): String { @@ -128,7 +164,7 @@ class GeofenceExitWorker( requestMethod = method connectTimeout = 15_000 readTimeout = 15_000 - setRequestProperty("X-API-Key", config.apiKey) + setRequestProperty("X-API-Key", apiKey) setRequestProperty("Accept", "application/json") doInput = true } 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 9c0269c..f2edf8d 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 @@ -14,6 +14,9 @@ import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { private var pendingNotificationPermissionResult: MethodChannel.Result? = null + private val notificationPrefs by lazy { + getSharedPreferences(notificationPrefsName, MODE_PRIVATE) + } override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -48,6 +51,11 @@ class MainActivity : FlutterActivity() { return@setMethodCallHandler } + GeofenceActiveHomeCredentials(applicationContext).clearAll() + GeofenceActiveHomeCredentials(applicationContext).saveApiKey( + homeId, + apiKey, + ) GeofenceAutomationManager.arm( context = applicationContext, config = @@ -55,7 +63,6 @@ class MainActivity : FlutterActivity() { homeId = homeId, homeName = homeName ?: "", baseUrl = baseUrl, - apiKey = apiKey, latitude = latitude, longitude = longitude, radiusMeters = radiusMeters, @@ -127,6 +134,7 @@ class MainActivity : FlutterActivity() { } pendingNotificationPermissionResult = result + notificationPrefs.edit().putBoolean(notificationPermissionRequestedKey, true).apply() ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), @@ -146,10 +154,20 @@ class MainActivity : FlutterActivity() { Manifest.permission.POST_NOTIFICATIONS, ) == PackageManager.PERMISSION_GRANTED - return if (!runtimePermissionGranted) { - "requestable" + if (!runtimePermissionGranted) { + val wasRequestedBefore = + notificationPrefs.getBoolean(notificationPermissionRequestedKey, false) + val canShowPromptAgain = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) + + return if (!wasRequestedBefore || canShowPromptAgain) { + "requestable" + } else { + "settings_required" + } } else { - "settings_required" + return "settings_required" } } @@ -166,5 +184,8 @@ class MainActivity : FlutterActivity() { companion object { private const val notificationPermissionRequestCode = 4102 + private const val notificationPrefsName = "ignis_notification_permissions" + private const val notificationPermissionRequestedKey = + "post_notifications_requested" } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..009de79 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Ignis App + diff --git a/lib/features/homes/providers/location_providers.dart b/lib/features/homes/providers/location_providers.dart index 912f5a1..f8e0138 100644 --- a/lib/features/homes/providers/location_providers.dart +++ b/lib/features/homes/providers/location_providers.dart @@ -5,6 +5,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; +import '../services/location_platform_service.dart'; + enum UserLocationIssue { servicesDisabled, permissionDenied, @@ -46,6 +48,10 @@ final userLocationProvider = () => UserLocationNotifier(), ); +final locationPlatformServiceProvider = Provider( + (ref) => DeviceLocationPlatformService(), +); + class UserLocationNotifier extends Notifier { StreamSubscription? _sub; int _watchers = 0; @@ -71,9 +77,21 @@ class UserLocationNotifier extends Notifier { /// стрим остановится только когда все вызовут stopWatching. Future startWatching() async { _watchers++; + await _startWatchingIfPossible(); + } + + Future ensureWatchingStarted() async { + if (_watchers == 0 || _sub != null) { + return; + } + await _startWatchingIfPossible(); + } + + Future _startWatchingIfPossible() async { + final locationService = ref.read(locationPlatformServiceProvider); if (_sub != null) return; - final permissionState = await _ensurePermission(); + final permissionState = await _ensurePermission(requestIfDenied: false); if (!permissionState.isGranted) { state = permissionState.toLocation(); return; @@ -81,7 +99,7 @@ class UserLocationNotifier extends Notifier { if (!state.hasPosition) { try { - final last = await Geolocator.getLastKnownPosition(); + final last = await locationService.getLastKnownPosition(); if (last != null) { state = _fromPosition(last); } @@ -93,17 +111,19 @@ class UserLocationNotifier extends Notifier { distanceFilter: 20, ); - _sub = Geolocator.getPositionStream(locationSettings: settings).listen( - (pos) => state = _fromPosition(pos), - onError: (e) { - debugPrint('Ошибка стрима геолокации: $e'); - state = UserLocation( - error: 'Не удалось отслеживать позицию: $e', - issue: UserLocationIssue.unavailable, - updatedAt: state.updatedAt, + _sub = locationService + .getPositionStream(locationSettings: settings) + .listen( + (pos) => state = _fromPosition(pos), + onError: (e) { + debugPrint('Ошибка стрима геолокации: $e'); + state = UserLocation( + error: 'Не удалось отслеживать позицию: $e', + issue: UserLocationIssue.unavailable, + updatedAt: state.updatedAt, + ); + }, ); - }, - ); } /// Остановить отслеживание. Вызывать из dispose экрана. @@ -116,20 +136,21 @@ class UserLocationNotifier extends Notifier { } Future refresh() async { - final permissionState = await _ensurePermission(); + final locationService = ref.read(locationPlatformServiceProvider); + final permissionState = await _ensurePermission(requestIfDenied: false); if (!permissionState.isGranted) { state = permissionState.toLocation(); return; } try { - final last = await Geolocator.getLastKnownPosition(); + final last = await locationService.getLastKnownPosition(); if (last != null) { state = _fromPosition(last); return; } - final pos = await Geolocator.getCurrentPosition( + final pos = await locationService.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.low, timeLimit: Duration(seconds: 10), @@ -146,35 +167,40 @@ class UserLocationNotifier extends Notifier { } Future requestPermission() async { - await Geolocator.requestPermission(); + final locationService = ref.read(locationPlatformServiceProvider); + await locationService.requestPermission(); if (_watchers > 0 && _sub == null) { - await startWatching(); + await _startWatchingIfPossible(); return; } await refresh(); } Future openAppSettings() async { - await Geolocator.openAppSettings(); + await ref.read(locationPlatformServiceProvider).openAppSettings(); } Future openLocationSettings() async { - await Geolocator.openLocationSettings(); + await ref.read(locationPlatformServiceProvider).openLocationSettings(); } /// Проверить сервис и пермишены. Возвращает null если всё ок, /// иначе строку с причиной ошибки. - Future<_LocationPermissionState> _ensurePermission() async { - if (!await Geolocator.isLocationServiceEnabled()) { + Future<_LocationPermissionState> _ensurePermission({ + required bool requestIfDenied, + }) async { + final locationService = ref.read(locationPlatformServiceProvider); + + if (!await locationService.isLocationServiceEnabled()) { return const _LocationPermissionState( issue: UserLocationIssue.servicesDisabled, message: 'Геолокация выключена', ); } - var perm = await Geolocator.checkPermission(); - if (perm == LocationPermission.denied) { - perm = await Geolocator.requestPermission(); + var perm = await locationService.checkPermission(); + if (perm == LocationPermission.denied && requestIfDenied) { + perm = await locationService.requestPermission(); } if (perm == LocationPermission.denied) { return const _LocationPermissionState( diff --git a/lib/features/homes/services/home_connection_change.dart b/lib/features/homes/services/home_connection_change.dart new file mode 100644 index 0000000..d402130 --- /dev/null +++ b/lib/features/homes/services/home_connection_change.dart @@ -0,0 +1,14 @@ +import '../../../models/home_config.dart'; + +bool hasHomeConnectionChanges({ + required HomeConfig? originalHome, + required String normalizedUrl, + required String apiKey, + required String originalApiKey, +}) { + if (originalHome == null) { + return true; + } + + return normalizedUrl != originalHome.url || apiKey != originalApiKey; +} diff --git a/lib/features/homes/services/location_platform_service.dart b/lib/features/homes/services/location_platform_service.dart new file mode 100644 index 0000000..d6f55ec --- /dev/null +++ b/lib/features/homes/services/location_platform_service.dart @@ -0,0 +1,69 @@ +import 'package:geolocator/geolocator.dart'; + +abstract class LocationPlatformService { + Future isLocationServiceEnabled(); + + Future checkPermission(); + + Future requestPermission(); + + Future getLastKnownPosition(); + + Future getCurrentPosition({ + required LocationSettings locationSettings, + }); + + Stream getPositionStream({ + required LocationSettings locationSettings, + }); + + Future openAppSettings(); + + Future openLocationSettings(); +} + +class DeviceLocationPlatformService implements LocationPlatformService { + @override + Future isLocationServiceEnabled() { + return Geolocator.isLocationServiceEnabled(); + } + + @override + Future checkPermission() { + return Geolocator.checkPermission(); + } + + @override + Future requestPermission() { + return Geolocator.requestPermission(); + } + + @override + Future getLastKnownPosition() { + return Geolocator.getLastKnownPosition(); + } + + @override + Future getCurrentPosition({ + required LocationSettings locationSettings, + }) { + return Geolocator.getCurrentPosition(locationSettings: locationSettings); + } + + @override + Stream getPositionStream({ + required LocationSettings locationSettings, + }) { + return Geolocator.getPositionStream(locationSettings: locationSettings); + } + + @override + Future openAppSettings() { + return Geolocator.openAppSettings(); + } + + @override + Future openLocationSettings() { + return Geolocator.openLocationSettings(); + } +} diff --git a/lib/features/settings/providers/settings_providers.dart b/lib/features/settings/providers/settings_providers.dart index 0bb66e8..ba1533a 100644 --- a/lib/features/settings/providers/settings_providers.dart +++ b/lib/features/settings/providers/settings_providers.dart @@ -1,12 +1,13 @@ -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:geolocator/geolocator.dart'; import '../../homes/providers/homes_providers.dart'; +import '../../homes/providers/location_providers.dart'; import '../../shared/providers/core_providers.dart'; import '../models/app_theme_preset.dart'; import '../models/geofence_system_state.dart'; import '../models/notification_permission_status.dart'; +import '../services/geofence_system_status_service.dart'; +import '../services/notification_permission_status_service.dart'; final initialAppThemePresetProvider = Provider( (ref) => AppThemePreset.fallback, @@ -29,53 +30,11 @@ class AppThemeNotifier extends Notifier { } } -abstract class GeofenceSystemStatusService { - Future inspect({ - required bool hasActiveHome, - required bool hasCoordinates, - }); -} - -class DeviceGeofenceSystemStatusService implements GeofenceSystemStatusService { - @override - Future inspect({ - required bool hasActiveHome, - required bool hasCoordinates, - }) async { - if (!hasActiveHome) { - return const GeofenceSystemState(GeofenceSystemIssue.noActiveHome); - } - if (!hasCoordinates) { - return const GeofenceSystemState(GeofenceSystemIssue.missingCoordinates); - } - if (!await Geolocator.isLocationServiceEnabled()) { - return const GeofenceSystemState( - GeofenceSystemIssue.locationServicesDisabled, - ); - } - - final permission = await Geolocator.checkPermission(); - return switch (permission) { - LocationPermission.denied => const GeofenceSystemState( - GeofenceSystemIssue.permissionDenied, - ), - LocationPermission.deniedForever => const GeofenceSystemState( - GeofenceSystemIssue.permissionDeniedForever, - ), - LocationPermission.whileInUse => const GeofenceSystemState( - GeofenceSystemIssue.backgroundPermissionRequired, - ), - LocationPermission.always => const GeofenceSystemState( - GeofenceSystemIssue.ready, - ), - _ => const GeofenceSystemState(GeofenceSystemIssue.permissionDenied), - }; - } -} - final geofenceSystemStatusServiceProvider = Provider( - (ref) => DeviceGeofenceSystemStatusService(), + (ref) => DeviceGeofenceSystemStatusService( + locationPlatformService: ref.read(locationPlatformServiceProvider), + ), ); final geofenceSystemStatusProvider = FutureProvider(( @@ -90,49 +49,6 @@ final geofenceSystemStatusProvider = FutureProvider(( ); }); -abstract class NotificationPermissionStatusService { - Future inspect(); - - Future requestPermission(); - - Future openSettings(); -} - -class DeviceNotificationPermissionStatusService - implements NotificationPermissionStatusService { - static const _channel = MethodChannel('ignis/geofence_automation'); - - @override - Future inspect() async { - try { - final value = await _channel.invokeMethod( - 'getNotificationPermissionStatus', - ); - return NotificationPermissionStatus.fromPlatformValue(value); - } on MissingPluginException { - return NotificationPermissionStatus.unsupported; - } - } - - @override - Future requestPermission() async { - try { - await _channel.invokeMethod('requestNotificationPermission'); - } on MissingPluginException { - return; - } - } - - @override - Future openSettings() async { - try { - await _channel.invokeMethod('openNotificationSettings'); - } on MissingPluginException { - return; - } - } -} - final notificationPermissionStatusServiceProvider = Provider( (ref) => DeviceNotificationPermissionStatusService(), diff --git a/lib/features/settings/services/geofence_system_status_service.dart b/lib/features/settings/services/geofence_system_status_service.dart new file mode 100644 index 0000000..0770482 --- /dev/null +++ b/lib/features/settings/services/geofence_system_status_service.dart @@ -0,0 +1,52 @@ +import 'package:geolocator/geolocator.dart'; + +import '../../homes/services/location_platform_service.dart'; +import '../models/geofence_system_state.dart'; + +abstract class GeofenceSystemStatusService { + Future inspect({ + required bool hasActiveHome, + required bool hasCoordinates, + }); +} + +class DeviceGeofenceSystemStatusService implements GeofenceSystemStatusService { + final LocationPlatformService locationPlatformService; + + DeviceGeofenceSystemStatusService({required this.locationPlatformService}); + + @override + Future inspect({ + required bool hasActiveHome, + required bool hasCoordinates, + }) async { + if (!hasActiveHome) { + return const GeofenceSystemState(GeofenceSystemIssue.noActiveHome); + } + if (!hasCoordinates) { + return const GeofenceSystemState(GeofenceSystemIssue.missingCoordinates); + } + if (!await locationPlatformService.isLocationServiceEnabled()) { + return const GeofenceSystemState( + GeofenceSystemIssue.locationServicesDisabled, + ); + } + + final permission = await locationPlatformService.checkPermission(); + return switch (permission) { + LocationPermission.denied => const GeofenceSystemState( + GeofenceSystemIssue.permissionDenied, + ), + LocationPermission.deniedForever => const GeofenceSystemState( + GeofenceSystemIssue.permissionDeniedForever, + ), + LocationPermission.whileInUse => const GeofenceSystemState( + GeofenceSystemIssue.backgroundPermissionRequired, + ), + LocationPermission.always => const GeofenceSystemState( + GeofenceSystemIssue.ready, + ), + _ => const GeofenceSystemState(GeofenceSystemIssue.permissionDenied), + }; + } +} diff --git a/lib/features/settings/services/notification_permission_status_service.dart b/lib/features/settings/services/notification_permission_status_service.dart new file mode 100644 index 0000000..74fd347 --- /dev/null +++ b/lib/features/settings/services/notification_permission_status_service.dart @@ -0,0 +1,46 @@ +import 'package:flutter/services.dart'; + +import '../models/notification_permission_status.dart'; + +abstract class NotificationPermissionStatusService { + Future inspect(); + + Future requestPermission(); + + Future openSettings(); +} + +class DeviceNotificationPermissionStatusService + implements NotificationPermissionStatusService { + static const _channel = MethodChannel('ignis/geofence_automation'); + + @override + Future inspect() async { + try { + final value = await _channel.invokeMethod( + 'getNotificationPermissionStatus', + ); + return NotificationPermissionStatus.fromPlatformValue(value); + } on MissingPluginException { + return NotificationPermissionStatus.unsupported; + } + } + + @override + Future requestPermission() async { + try { + await _channel.invokeMethod('requestNotificationPermission'); + } on MissingPluginException { + return; + } + } + + @override + Future openSettings() async { + try { + await _channel.invokeMethod('openNotificationSettings'); + } on MissingPluginException { + return; + } + } +} diff --git a/lib/screens/home_edit_screen.dart b/lib/screens/home_edit_screen.dart index 83a9c78..32cdd91 100644 --- a/lib/screens/home_edit_screen.dart +++ b/lib/screens/home_edit_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../app/error_message.dart'; +import '../features/homes/services/home_connection_change.dart'; import '../models/home_config.dart'; import '../providers/providers.dart'; import '../services/api_client.dart'; @@ -23,6 +24,8 @@ class _HomeEditScreenState extends ConsumerState { final _latCtrl = TextEditingController(); final _lonCtrl = TextEditingController(); bool _saving = false; + bool _loadingApiKey = false; + String _originalApiKey = ''; bool get _isEdit => widget.home != null; @@ -42,6 +45,7 @@ class _HomeEditScreenState extends ConsumerState { if (widget.home!.longitude != null) { _lonCtrl.text = widget.home!.longitude.toString(); } + _loadingApiKey = true; _loadApiKey(); } @@ -51,11 +55,19 @@ class _HomeEditScreenState extends ConsumerState { } Future _loadApiKey() async { - final apiKey = await ref - .read(settingsServiceProvider) - .getHomeApiKey(widget.home!.id); - if (mounted && apiKey != null) { - _keyCtrl.text = apiKey; + try { + final apiKey = await ref + .read(settingsServiceProvider) + .getHomeApiKey(widget.home!.id); + _originalApiKey = apiKey ?? ''; + if (!mounted) { + return; + } + _keyCtrl.text = _originalApiKey; + } finally { + if (mounted) { + setState(() => _loadingApiKey = false); + } } } @@ -130,10 +142,12 @@ class _HomeEditScreenState extends ConsumerState { const SizedBox(height: 12), TextFormField( controller: _keyCtrl, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'API Key', - helperText: 'Ключ проверяется перед сохранением дома', - prefixIcon: Icon(Icons.key), + helperText: _loadingApiKey + ? 'Загружаем сохранённый ключ...' + : 'Ключ проверяется только при изменении подключения', + prefixIcon: const Icon(Icons.key), ), obscureText: true, validator: (value) => @@ -236,7 +250,7 @@ class _HomeEditScreenState extends ConsumerState { backgroundColor: Colors.deepOrange, foregroundColor: Colors.white, ), - onPressed: _saving ? null : _save, + onPressed: (_saving || _loadingApiKey) ? null : _save, child: _saving ? const SizedBox( width: 20, @@ -259,6 +273,12 @@ class _HomeEditScreenState extends ConsumerState { Future _save() async { FocusScope.of(context).unfocus(); + if (_loadingApiKey) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Подождите, API key ещё загружается')), + ); + return; + } if (!_formKey.currentState!.validate()) { return; } @@ -319,6 +339,12 @@ class _HomeEditScreenState extends ConsumerState { setState(() => _saving = true); final clearCoords = latText.isEmpty && lonText.isEmpty; + final credentialsChanged = hasHomeConnectionChanges( + originalHome: widget.home, + normalizedUrl: url, + apiKey: key, + originalApiKey: _originalApiKey, + ); final home = _isEdit ? widget.home!.copyWith( @@ -337,17 +363,25 @@ class _HomeEditScreenState extends ConsumerState { ); try { - await ref.read(apiProvider).validateCredentials(url, key); + if (credentialsChanged) { + await ref.read(apiProvider).validateCredentials(url, key); + } if (_isEdit) { - await ref.read(homesProvider.notifier).update(home, apiKey: key); + await ref + .read(homesProvider.notifier) + .update(home, apiKey: credentialsChanged ? key : null); } else { await ref.read(homesProvider.notifier).add(home, apiKey: key); } final currentHome = ref.read(currentHomeProvider); if (currentHome?.id == home.id) { - await ref.read(currentHomeProvider.notifier).select(home); + if (credentialsChanged) { + await ref.read(currentHomeProvider.notifier).select(home); + } else { + await ref.read(currentHomeProvider.notifier).switchTo(home); + } } if (mounted) Navigator.of(context).pop(); diff --git a/lib/screens/homes_screen.dart b/lib/screens/homes_screen.dart index 996d56c..2d07c7f 100644 --- a/lib/screens/homes_screen.dart +++ b/lib/screens/homes_screen.dart @@ -277,6 +277,11 @@ class _HomesScreenState extends ConsumerState final shouldWatch = ref .read(homesProvider) .any((home) => home.hasCoordinates); + if (shouldWatch && _isWatchingLocation) { + await _userLocationNotifier.ensureWatchingStarted(); + return; + } + if (shouldWatch == _isWatchingLocation) { return; } diff --git a/test/forms_widget_test.dart b/test/forms_widget_test.dart index bfcf398..d2b3b05 100644 --- a/test/forms_widget_test.dart +++ b/test/forms_widget_test.dart @@ -189,7 +189,10 @@ void main() { expect(savedHome.latitude, 55.75); expect(savedHome.longitude, 37.61); expect(savedHome.geofenceEnabled, isFalse); - expect(savedHome.geofenceRadiusMeters, HomeConfig.defaultGeofenceRadiusMeters); + expect( + savedHome.geofenceRadiusMeters, + HomeConfig.defaultGeofenceRadiusMeters, + ); expect(savedApiKey, 'secret-key'); }); } diff --git a/test/geofence_system_status_provider_test.dart b/test/geofence_system_status_provider_test.dart index 2d02c8e..6af8cda 100644 --- a/test/geofence_system_status_provider_test.dart +++ b/test/geofence_system_status_provider_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:ignis_app/features/settings/models/geofence_system_state.dart'; import 'package:ignis_app/features/settings/providers/settings_providers.dart'; +import 'package:ignis_app/features/settings/services/geofence_system_status_service.dart'; import 'package:ignis_app/providers/providers.dart'; import 'package:ignis_app/services/settings_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; diff --git a/test/home_connection_change_test.dart b/test/home_connection_change_test.dart new file mode 100644 index 0000000..336b073 --- /dev/null +++ b/test/home_connection_change_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ignis_app/features/homes/services/home_connection_change.dart'; +import 'package:ignis_app/models/home_config.dart'; + +void main() { + test('new home always requires connection validation', () { + expect( + hasHomeConnectionChanges( + originalHome: null, + normalizedUrl: 'https://ignis.akokos.ru', + apiKey: 'secret-key', + originalApiKey: '', + ), + isTrue, + ); + }); + + test('local-only home edits do not require connection validation', () { + final originalHome = HomeConfig( + id: 'home-1', + name: 'Квартира', + url: 'https://ignis.akokos.ru', + latitude: 55.75, + longitude: 37.61, + ); + + expect( + hasHomeConnectionChanges( + originalHome: originalHome, + normalizedUrl: originalHome.url, + apiKey: 'saved-key', + originalApiKey: 'saved-key', + ), + isFalse, + ); + }); + + test('url or api key changes still require connection validation', () { + final originalHome = HomeConfig( + id: 'home-1', + name: 'Квартира', + url: 'https://ignis.akokos.ru', + ); + + expect( + hasHomeConnectionChanges( + originalHome: originalHome, + normalizedUrl: 'https://new.ignis.akokos.ru', + apiKey: 'saved-key', + originalApiKey: 'saved-key', + ), + isTrue, + ); + expect( + hasHomeConnectionChanges( + originalHome: originalHome, + normalizedUrl: originalHome.url, + apiKey: 'new-key', + originalApiKey: 'saved-key', + ), + isTrue, + ); + }); +} diff --git a/test/notification_permission_status_provider_test.dart b/test/notification_permission_status_provider_test.dart index bb084ae..419bcb8 100644 --- a/test/notification_permission_status_provider_test.dart +++ b/test/notification_permission_status_provider_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ignis_app/features/settings/models/notification_permission_status.dart'; import 'package:ignis_app/features/settings/providers/settings_providers.dart'; +import 'package:ignis_app/features/settings/services/notification_permission_status_service.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); diff --git a/test/user_location_provider_test.dart b/test/user_location_provider_test.dart new file mode 100644 index 0000000..d6ce56e --- /dev/null +++ b/test/user_location_provider_test.dart @@ -0,0 +1,127 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:ignis_app/features/homes/providers/location_providers.dart'; +import 'package:ignis_app/features/homes/services/location_platform_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'location watcher does not auto-request permission when denied', + () async { + final service = _FakeLocationPlatformService(); + final container = ProviderContainer( + overrides: [locationPlatformServiceProvider.overrideWithValue(service)], + ); + addTearDown(() async { + await service.dispose(); + container.dispose(); + }); + + final notifier = container.read(userLocationProvider.notifier); + await notifier.startWatching(); + await notifier.refresh(); + + expect(service.requestPermissionCalls, 0); + expect(service.getPositionStreamCalls, 0); + expect( + container.read(userLocationProvider).issue, + UserLocationIssue.permissionDenied, + ); + }, + ); + + test('location watcher resumes after permission is granted later', () async { + final service = _FakeLocationPlatformService(); + final container = ProviderContainer( + overrides: [locationPlatformServiceProvider.overrideWithValue(service)], + ); + addTearDown(() async { + await service.dispose(); + container.dispose(); + }); + + final notifier = container.read(userLocationProvider.notifier); + await notifier.startWatching(); + + service.permission = LocationPermission.always; + await notifier.ensureWatchingStarted(); + + expect(service.requestPermissionCalls, 0); + expect(service.getPositionStreamCalls, 1); + }); + + test('explicit location request asks Android and starts watching', () async { + final service = _FakeLocationPlatformService(); + service.requestPermissionResult = LocationPermission.always; + final container = ProviderContainer( + overrides: [locationPlatformServiceProvider.overrideWithValue(service)], + ); + addTearDown(() async { + await service.dispose(); + container.dispose(); + }); + + final notifier = container.read(userLocationProvider.notifier); + await notifier.startWatching(); + await notifier.requestPermission(); + + expect(service.requestPermissionCalls, 1); + expect(service.getPositionStreamCalls, 1); + }); +} + +class _FakeLocationPlatformService implements LocationPlatformService { + final StreamController _positionController = + StreamController.broadcast(); + + bool locationServiceEnabled = true; + LocationPermission permission = LocationPermission.denied; + LocationPermission requestPermissionResult = LocationPermission.denied; + int requestPermissionCalls = 0; + int getPositionStreamCalls = 0; + + Future dispose() async { + await _positionController.close(); + } + + @override + Future checkPermission() async => permission; + + @override + Stream getPositionStream({ + required LocationSettings locationSettings, + }) { + getPositionStreamCalls += 1; + return _positionController.stream; + } + + @override + Future getCurrentPosition({ + required LocationSettings locationSettings, + }) { + throw UnimplementedError(); + } + + @override + Future getLastKnownPosition() async => null; + + @override + Future isLocationServiceEnabled() async => locationServiceEnabled; + + @override + Future openAppSettings() async => true; + + @override + Future openLocationSettings() async => true; + + @override + Future requestPermission() async { + requestPermissionCalls += 1; + permission = requestPermissionResult; + return permission; + } +}