Harden geofence automation and home editing

This commit is contained in:
Artem Kokos
2026-05-15 11:26:23 +07:00
parent 50748c6945
commit 8ffaa14b60
21 changed files with 718 additions and 160 deletions

View File

@@ -6,7 +6,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:label="ignis_app"
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity

View File

@@ -0,0 +1,43 @@
package ru.akokos.ignis_app
import android.content.Context
import androidx.core.content.edit
class GeofenceActiveHomeCredentials(context: Context) {
private val prefs =
context.getSharedPreferences(credentialsPrefsName, Context.MODE_PRIVATE)
private val cipher = GeofenceConfigCipher()
fun saveApiKey(homeId: String, apiKey: String) {
prefs.edit { putString(prefKey(homeId), cipher.encrypt(apiKey)) }
}
fun loadApiKey(homeId: String): String? {
val stored = prefs.getString(prefKey(homeId), null) ?: return null
val decoded =
runCatching { cipher.decrypt(stored) }
.getOrElse { stored }
.trim()
if (decoded.isEmpty()) {
return null
}
if (decoded == stored) {
saveApiKey(homeId, decoded)
}
return decoded
}
fun clear(homeId: String) {
prefs.edit { remove(prefKey(homeId)) }
}
fun clearAll() {
prefs.edit { clear() }
}
private fun prefKey(homeId: String): String = "api_key_$homeId"
companion object {
private const val credentialsPrefsName = "ignis_geofence_credentials"
}
}

View File

@@ -25,7 +25,6 @@ data class StoredGeofenceConfig(
val homeId: String,
val homeName: String,
val baseUrl: String,
val apiKey: String,
val latitude: Double,
val longitude: Double,
val radiusMeters: Int,
@@ -40,35 +39,39 @@ enum class GeofencePresenceState {
class GeofenceNativeStore(context: Context) {
private val prefs = context.getSharedPreferences("ignis_geofence_native", Context.MODE_PRIVATE)
private val cipher = GeofenceConfigCipher()
fun loadConfig(): StoredGeofenceConfig? {
val raw = prefs.getString("config", null) ?: return null
return runCatching {
val json = JSONObject(raw)
StoredGeofenceConfig(
val json = JSONObject(decodeConfig(raw))
val config =
StoredGeofenceConfig(
homeId = json.getString("homeId"),
homeName = json.optString("homeName"),
baseUrl = json.getString("baseUrl"),
apiKey = json.getString("apiKey"),
latitude = json.getDouble("latitude"),
longitude = json.getDouble("longitude"),
radiusMeters = json.getInt("radiusMeters"),
)
if (json.has("apiKey")) {
saveConfig(config)
}
config
}
.getOrNull()
}
fun saveConfig(config: StoredGeofenceConfig) {
val json =
val plainJson =
JSONObject()
.put("homeId", config.homeId)
.put("homeName", config.homeName)
.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()
prefs.edit().putString("config", cipher.encrypt(plainJson.toString())).apply()
}
fun clear() {
@@ -87,6 +90,14 @@ class GeofenceNativeStore(context: Context) {
fun setState(state: GeofencePresenceState) {
prefs.edit().putString("presence_state", state.name).apply()
}
private fun decodeConfig(raw: String): String {
val trimmed = raw.trimStart()
if (trimmed.startsWith("{")) {
return raw
}
return cipher.decrypt(raw)
}
}
object GeofenceAutomationManager {
@@ -124,6 +135,7 @@ object GeofenceAutomationManager {
fun disarm(context: Context, onComplete: (() -> Unit)? = null) {
WorkManager.getInstance(context).cancelUniqueWork(exitWorkName)
GeofenceNativeStore(context).clear()
GeofenceActiveHomeCredentials(context).clearAll()
val client = LocationServices.getGeofencingClient(context)
client.removeGeofences(buildPendingIntent(context)).addOnCompleteListener {

View File

@@ -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
}
}

View File

@@ -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<String> {
val payload = requestJson(config, "/devices/groups")
private fun fetchGroupIds(
config: StoredGeofenceConfig,
apiKey: String,
): List<String> {
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
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Ignis App</string>
</resources>