Replace geofence polling with native Android geofence
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user