Harden geofence automation and home editing
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
4
android/app/src/main/res/values/strings.xml
Normal file
4
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Ignis App</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user