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;
+ }
+}