Extract settings and harden geofence automation

This commit is contained in:
Artem Kokos
2026-05-15 10:18:46 +07:00
parent 1963488479
commit d796537917
21 changed files with 1392 additions and 278 deletions

View File

@@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:label="ignis_app"

View File

@@ -6,6 +6,7 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.work.BackoffPolicy
@@ -22,6 +23,7 @@ import java.util.concurrent.TimeUnit
data class StoredGeofenceConfig(
val homeId: String,
val homeName: String,
val baseUrl: String,
val apiKey: String,
val latitude: Double,
@@ -45,6 +47,7 @@ class GeofenceNativeStore(context: Context) {
val json = JSONObject(raw)
StoredGeofenceConfig(
homeId = json.getString("homeId"),
homeName = json.optString("homeName"),
baseUrl = json.getString("baseUrl"),
apiKey = json.getString("apiKey"),
latitude = json.getDouble("latitude"),
@@ -59,6 +62,7 @@ class GeofenceNativeStore(context: Context) {
val json =
JSONObject()
.put("homeId", config.homeId)
.put("homeName", config.homeName)
.put("baseUrl", config.baseUrl)
.put("apiKey", config.apiKey)
.put("latitude", config.latitude)
@@ -110,8 +114,10 @@ object GeofenceAutomationManager {
val client = LocationServices.getGeofencingClient(context)
val pendingIntent = buildPendingIntent(context)
client.removeGeofences(pendingIntent).addOnCompleteListener {
registerGeofence(context, client, pendingIntent, config, onComplete)
seedPresenceState(context, config) {
client.removeGeofences(pendingIntent).addOnCompleteListener {
registerGeofence(context, client, pendingIntent, config, onComplete)
}
}
}
@@ -140,12 +146,18 @@ object GeofenceAutomationManager {
if (requestId != geofenceRequestId) return
val store = GeofenceNativeStore(context)
if (store.getState() != GeofencePresenceState.INSIDE) {
return
when (store.getState()) {
GeofencePresenceState.OUTSIDE,
GeofencePresenceState.TRIGGERED,
-> return
GeofencePresenceState.UNKNOWN,
GeofencePresenceState.INSIDE,
-> Unit
}
val config = store.loadConfig() ?: return
store.setState(GeofencePresenceState.OUTSIDE)
scheduleExitWorker(context, store.loadConfig()?.homeId ?: return)
scheduleExitWorker(context, config.homeId)
}
fun markTriggered(context: Context) {
@@ -181,6 +193,49 @@ object GeofenceAutomationManager {
)
}
@SuppressLint("MissingPermission")
private fun seedPresenceState(
context: Context,
config: StoredGeofenceConfig,
onComplete: () -> Unit,
) {
if (!hasRequiredLocationPermission(context)) {
GeofenceNativeStore(context).setState(GeofencePresenceState.UNKNOWN)
onComplete()
return
}
LocationServices.getFusedLocationProviderClient(context).lastLocation
.addOnSuccessListener { location ->
val state =
when {
location == null -> GeofencePresenceState.UNKNOWN
isInsideFence(location, config) -> GeofencePresenceState.INSIDE
else -> GeofencePresenceState.OUTSIDE
}
GeofenceNativeStore(context).setState(state)
onComplete()
}.addOnFailureListener {
GeofenceNativeStore(context).setState(GeofencePresenceState.UNKNOWN)
onComplete()
}
}
private fun isInsideFence(
location: Location,
config: StoredGeofenceConfig,
): Boolean {
val distance = FloatArray(1)
Location.distanceBetween(
location.latitude,
location.longitude,
config.latitude,
config.longitude,
distance,
)
return distance[0] <= config.radiusMeters
}
@SuppressLint("MissingPermission")
private fun registerGeofence(
context: Context,

View File

@@ -0,0 +1,111 @@
package ru.akokos.ignis_app
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
object GeofenceAutomationNotifier {
private const val channelId = "ignis_geofence_automation"
private const val channelName = "Geofence automation"
private const val notificationId = 4101
fun showExitProcessed(
context: Context,
homeName: String?,
turnedOffGroups: Int,
) {
val title = if (turnedOffGroups > 0) "Ignis выключил свет" else "Ignis проверил свет"
val body =
if (turnedOffGroups > 0) {
buildString {
append("Geofence подтвердил уход из дома")
homeName?.takeIf { it.isNotBlank() }?.let { append(" \"$it\"") }
append(" и выключил ")
append(turnedOffGroups)
append(if (turnedOffGroups == 1) " группу." else " групп(ы).")
}
} else {
buildString {
append("Geofence подтвердил уход из дома")
homeName?.takeIf { it.isNotBlank() }?.let { append(" \"$it\"") }
append(", но включённых групп не нашёл.")
}
}
show(context, title, body)
}
private fun show(
context: Context,
title: String,
body: String,
) {
if (!canNotify(context)) {
return
}
ensureChannel(context)
val launchIntent =
context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val notification =
NotificationCompat.Builder(context, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(
launchIntent?.let {
PendingIntent.getActivity(
context,
0,
it,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
},
)
.build()
NotificationManagerCompat.from(context).notify(notificationId, notification)
}
private fun canNotify(context: Context): Boolean {
val permissionGranted =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
return permissionGranted && NotificationManagerCompat.from(context).areNotificationsEnabled()
}
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel =
NotificationChannel(
channelId,
channelName,
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Уведомления о срабатывании geofence-автоматизации Ignis"
}
manager.createNotificationChannel(channel)
}
}

View File

@@ -33,6 +33,11 @@ class GeofenceExitWorker(
}
GeofenceAutomationManager.markTriggered(applicationContext)
GeofenceAutomationNotifier.showExitProcessed(
context = applicationContext,
homeName = config.homeName,
turnedOffGroups = activeGroupIds.size,
)
Result.success()
}
.getOrElse { Result.retry() }

View File

@@ -15,6 +15,7 @@ class MainActivity : FlutterActivity() {
when (call.method) {
"armGeofence" -> {
val homeId = call.argument<String>("homeId")
val homeName = call.argument<String>("homeName")
val baseUrl = call.argument<String>("baseUrl")
val apiKey = call.argument<String>("apiKey")
val latitude = call.argument<Double>("latitude")
@@ -42,6 +43,7 @@ class MainActivity : FlutterActivity() {
config =
StoredGeofenceConfig(
homeId = homeId,
homeName = homeName ?: "",
baseUrl = baseUrl,
apiKey = apiKey,
latitude = latitude,