Replace geofence polling with native Android geofence

This commit is contained in:
Artem Kokos
2026-05-12 11:23:44 +07:00
parent 0a5ef9af17
commit 1963488479
38 changed files with 1099 additions and 1931 deletions

View File

@@ -46,4 +46,6 @@ flutter {
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
implementation("com.google.android.gms:play-services-location:21.3.0")
implementation("androidx.work:work-runtime-ktx:2.10.2")
}

View File

@@ -3,7 +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"
android:name="${applicationName}"
@@ -35,9 +35,18 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="ignis_geofence" />
<receiver
android:name=".GeofenceBroadcastReceiver"
android:exported="false" />
<receiver
android:name=".GeofenceRestoreReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@@ -0,0 +1,250 @@
package ru.akokos.ignis_app
import android.Manifest
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.google.android.gms.location.Geofence
import com.google.android.gms.location.GeofencingRequest
import com.google.android.gms.location.LocationServices
import org.json.JSONObject
import java.util.concurrent.TimeUnit
data class StoredGeofenceConfig(
val homeId: String,
val baseUrl: String,
val apiKey: String,
val latitude: Double,
val longitude: Double,
val radiusMeters: Int,
)
enum class GeofencePresenceState {
UNKNOWN,
INSIDE,
OUTSIDE,
TRIGGERED,
}
class GeofenceNativeStore(context: Context) {
private val prefs = context.getSharedPreferences("ignis_geofence_native", Context.MODE_PRIVATE)
fun loadConfig(): StoredGeofenceConfig? {
val raw = prefs.getString("config", null) ?: return null
return runCatching {
val json = JSONObject(raw)
StoredGeofenceConfig(
homeId = json.getString("homeId"),
baseUrl = json.getString("baseUrl"),
apiKey = json.getString("apiKey"),
latitude = json.getDouble("latitude"),
longitude = json.getDouble("longitude"),
radiusMeters = json.getInt("radiusMeters"),
)
}
.getOrNull()
}
fun saveConfig(config: StoredGeofenceConfig) {
val json =
JSONObject()
.put("homeId", config.homeId)
.put("baseUrl", config.baseUrl)
.put("apiKey", config.apiKey)
.put("latitude", config.latitude)
.put("longitude", config.longitude)
.put("radiusMeters", config.radiusMeters)
prefs.edit().putString("config", json.toString()).apply()
}
fun clear() {
prefs.edit().clear().apply()
}
fun getState(): GeofencePresenceState =
runCatching {
GeofencePresenceState.valueOf(
prefs.getString("presence_state", GeofencePresenceState.UNKNOWN.name)
?: GeofencePresenceState.UNKNOWN.name,
)
}
.getOrDefault(GeofencePresenceState.UNKNOWN)
fun setState(state: GeofencePresenceState) {
prefs.edit().putString("presence_state", state.name).apply()
}
}
object GeofenceAutomationManager {
const val channelName = "ignis/geofence_automation"
private const val geofenceRequestId = "ignis_active_home"
private const val exitWorkName = "ignis_geofence_exit_worker"
private const val exitWorkHomeIdKey = "homeId"
private const val exitConfirmationDelayMinutes = 2L
fun arm(
context: Context,
config: StoredGeofenceConfig,
onComplete: ((Boolean, String?) -> Unit)? = null,
) {
val store = GeofenceNativeStore(context)
store.saveConfig(config)
store.setState(GeofencePresenceState.UNKNOWN)
if (!hasRequiredLocationPermission(context)) {
onComplete?.invoke(false, "missing_location_permission")
return
}
val client = LocationServices.getGeofencingClient(context)
val pendingIntent = buildPendingIntent(context)
client.removeGeofences(pendingIntent).addOnCompleteListener {
registerGeofence(context, client, pendingIntent, config, onComplete)
}
}
fun disarm(context: Context, onComplete: (() -> Unit)? = null) {
WorkManager.getInstance(context).cancelUniqueWork(exitWorkName)
GeofenceNativeStore(context).clear()
val client = LocationServices.getGeofencingClient(context)
client.removeGeofences(buildPendingIntent(context)).addOnCompleteListener {
onComplete?.invoke()
}
}
fun restoreIfNeeded(context: Context) {
val config = GeofenceNativeStore(context).loadConfig() ?: return
arm(context, config, onComplete = null)
}
fun handleEnter(context: Context, requestId: String) {
if (requestId != geofenceRequestId) return
WorkManager.getInstance(context).cancelUniqueWork(exitWorkName)
GeofenceNativeStore(context).setState(GeofencePresenceState.INSIDE)
}
fun handleExit(context: Context, requestId: String) {
if (requestId != geofenceRequestId) return
val store = GeofenceNativeStore(context)
if (store.getState() != GeofencePresenceState.INSIDE) {
return
}
store.setState(GeofencePresenceState.OUTSIDE)
scheduleExitWorker(context, store.loadConfig()?.homeId ?: return)
}
fun markTriggered(context: Context) {
GeofenceNativeStore(context).setState(GeofencePresenceState.TRIGGERED)
}
fun shouldProcessExitWork(context: Context, homeId: String): Boolean {
val store = GeofenceNativeStore(context)
val config = store.loadConfig() ?: return false
return config.homeId == homeId && store.getState() == GeofencePresenceState.OUTSIDE
}
private fun scheduleExitWorker(context: Context, homeId: String) {
val request =
OneTimeWorkRequestBuilder<GeofenceExitWorker>()
.setInputData(
androidx.work.workDataOf(
exitWorkHomeIdKey to homeId,
),
)
.setInitialDelay(exitConfirmationDelayMinutes, TimeUnit.MINUTES)
.setConstraints(
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(),
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
exitWorkName,
ExistingWorkPolicy.REPLACE,
request,
)
}
@SuppressLint("MissingPermission")
private fun registerGeofence(
context: Context,
client: com.google.android.gms.location.GeofencingClient,
pendingIntent: PendingIntent,
config: StoredGeofenceConfig,
onComplete: ((Boolean, String?) -> Unit)?,
) {
if (!hasRequiredLocationPermission(context)) {
onComplete?.invoke(false, "missing_location_permission")
return
}
val geofence =
Geofence.Builder()
.setRequestId(geofenceRequestId)
.setCircularRegion(
config.latitude,
config.longitude,
config.radiusMeters.toFloat(),
)
.setExpirationDuration(Geofence.NEVER_EXPIRE)
.setNotificationResponsiveness((2 * 60 * 1000))
.setTransitionTypes(
Geofence.GEOFENCE_TRANSITION_ENTER or Geofence.GEOFENCE_TRANSITION_EXIT,
)
.build()
val request =
GeofencingRequest.Builder()
.setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER)
.addGeofence(geofence)
.build()
client.addGeofences(request, pendingIntent)
.addOnSuccessListener { onComplete?.invoke(true, null) }
.addOnFailureListener { error ->
onComplete?.invoke(false, error.message ?: "failed_to_register_geofence")
}
}
private fun hasRequiredLocationPermission(context: Context): Boolean {
val fineGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val backgroundGranted =
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ||
ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_BACKGROUND_LOCATION,
) == PackageManager.PERMISSION_GRANTED
return fineGranted && backgroundGranted
}
private fun buildPendingIntent(context: Context): PendingIntent {
val intent =
Intent(context, GeofenceBroadcastReceiver::class.java).apply {
action = "ru.akokos.ignis_app.GEOFENCE_TRANSITION"
}
return PendingIntent.getBroadcast(
context,
1001,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
)
}
}

View File

@@ -0,0 +1,24 @@
package ru.akokos.ignis_app
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.google.android.gms.location.Geofence
import com.google.android.gms.location.GeofencingEvent
class GeofenceBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val event = GeofencingEvent.fromIntent(intent) ?: return
if (event.hasError()) {
return
}
val geofenceId = event.triggeringGeofences?.firstOrNull()?.requestId ?: return
when (event.geofenceTransition) {
Geofence.GEOFENCE_TRANSITION_ENTER ->
GeofenceAutomationManager.handleEnter(context, geofenceId)
Geofence.GEOFENCE_TRANSITION_EXIT ->
GeofenceAutomationManager.handleExit(context, geofenceId)
}
}
}

View File

@@ -0,0 +1,158 @@
package ru.akokos.ignis_app
import android.content.Context
import androidx.work.Worker
import androidx.work.WorkerParameters
import org.json.JSONArray
import org.json.JSONObject
import org.json.JSONTokener
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URLEncoder
import java.net.URL
class GeofenceExitWorker(
appContext: Context,
workerParams: WorkerParameters,
) : Worker(appContext, workerParams) {
override fun doWork(): Result {
val homeId = inputData.getString("homeId") ?: return Result.success()
if (!GeofenceAutomationManager.shouldProcessExitWork(applicationContext, homeId)) {
return Result.success()
}
val config = GeofenceNativeStore(applicationContext).loadConfig() ?: return Result.success()
return runCatching {
val groupIds = fetchGroupIds(config)
val activeGroupIds = groupIds.filter { isGroupOn(config, it) }
if (activeGroupIds.isNotEmpty()) {
activeGroupIds.forEach { turnOffGroup(config, it) }
}
GeofenceAutomationManager.markTriggered(applicationContext)
Result.success()
}
.getOrElse { Result.retry() }
}
private fun fetchGroupIds(config: StoredGeofenceConfig): List<String> {
val payload = requestJson(config, "/devices/groups")
return when (payload) {
is JSONArray ->
buildList {
for (index in 0 until payload.length()) {
val item = payload.opt(index)
when (item) {
is JSONObject -> item.optString("id").takeIf { it.isNotBlank() }?.let(::add)
is String -> if (item.isNotBlank()) add(item)
}
}
}
is JSONObject -> {
if (payload.has("groups") && payload.opt("groups") is JSONArray) {
fetchIdsFromArray(payload.optJSONArray("groups") ?: JSONArray())
} else {
payload.keys().asSequence().map { it.trim() }.filter { it.isNotEmpty() }.toList()
}
}
else -> emptyList()
}
}
private fun fetchIdsFromArray(array: JSONArray): List<String> =
buildList {
for (index in 0 until array.length()) {
val item = array.opt(index)
when (item) {
is JSONObject -> item.optString("id").takeIf { it.isNotBlank() }?.let(::add)
is String -> if (item.isNotBlank()) add(item)
}
}
}
private fun isGroupOn(config: StoredGeofenceConfig, groupId: String): Boolean {
val encodedId = URLEncoder.encode(groupId, Charsets.UTF_8.name())
val payload = requestJson(config, "/control/group/$encodedId/status")
return extractState(payload) ?: false
}
private fun turnOffGroup(config: StoredGeofenceConfig, groupId: String) {
val encodedId = URLEncoder.encode(groupId, Charsets.UTF_8.name())
performRequest(config, "/control/group/$encodedId?state=false", method = "POST")
}
private fun extractState(payload: Any?): Boolean? =
when (payload) {
is JSONObject -> when {
payload.has("results") -> extractState(payload.optJSONArray("results"))
payload.has("status") -> extractState(payload.opt("status"))
payload.has("state") -> coerceBoolean(payload.opt("state"))
else -> null
}
is JSONArray -> if (payload.length() > 0) extractState(payload.opt(0)) else null
else -> null
}
private fun coerceBoolean(value: Any?): Boolean? =
when (value) {
is Boolean -> value
is Number -> value.toInt() != 0
is String -> when (value.lowercase()) {
"true", "1", "on" -> true
"false", "0", "off" -> false
else -> null
}
else -> null
}
private fun requestJson(config: StoredGeofenceConfig, path: String): Any? {
val body = performRequest(config, path, method = "GET")
return JSONTokener(body).nextValue()
}
private fun performRequest(
config: StoredGeofenceConfig,
path: String,
method: String,
): String {
val connection =
(URL(config.baseUrl.trimEnd('/') + path).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = 15_000
readTimeout = 15_000
setRequestProperty("X-API-Key", config.apiKey)
setRequestProperty("Accept", "application/json")
doInput = true
}
return try {
val statusCode = connection.responseCode
val stream =
if (statusCode in 200..299) {
connection.inputStream
} else {
connection.errorStream ?: connection.inputStream
}
val body =
BufferedReader(InputStreamReader(stream)).use { reader ->
buildString {
while (true) {
val line = reader.readLine() ?: break
append(line)
}
}
}
if (statusCode !in 200..299) {
throw IllegalStateException("HTTP $statusCode: $body")
}
body
} finally {
connection.disconnect()
}
}
}

View File

@@ -0,0 +1,15 @@
package ru.akokos.ignis_app
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class GeofenceRestoreReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
-> GeofenceAutomationManager.restoreIfNeeded(context)
}
}
}

View File

@@ -1,5 +1,64 @@
package ru.akokos.ignis_app
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
GeofenceAutomationManager.channelName,
).setMethodCallHandler { call, result ->
when (call.method) {
"armGeofence" -> {
val homeId = call.argument<String>("homeId")
val baseUrl = call.argument<String>("baseUrl")
val apiKey = call.argument<String>("apiKey")
val latitude = call.argument<Double>("latitude")
val longitude = call.argument<Double>("longitude")
val radiusMeters = call.argument<Int>("radiusMeters")
if (
homeId.isNullOrBlank() ||
baseUrl.isNullOrBlank() ||
apiKey.isNullOrBlank() ||
latitude == null ||
longitude == null ||
radiusMeters == null
) {
result.error(
"invalid_args",
"armGeofence requires a complete home config",
null,
)
return@setMethodCallHandler
}
GeofenceAutomationManager.arm(
context = applicationContext,
config =
StoredGeofenceConfig(
homeId = homeId,
baseUrl = baseUrl,
apiKey = apiKey,
latitude = latitude,
longitude = longitude,
radiusMeters = radiusMeters,
),
) { armed, error ->
result.success(mapOf("armed" to armed, "error" to error))
}
}
"disarmGeofence" -> {
GeofenceAutomationManager.disarm(applicationContext) {
result.success(null)
}
}
else -> result.notImplemented()
}
}
}
}