Persist encrypted alert configuration

This commit is contained in:
ajp_anton
2026-08-16 17:16:07 +00:00
parent ae409612b4
commit 720db4b7d3
5 changed files with 250 additions and 0 deletions
@@ -0,0 +1,134 @@
package se.ajpanton.notificationsmaster.alerts
import android.content.Context
import android.util.AtomicFile
import org.json.JSONArray
import org.json.JSONObject
import se.ajpanton.notificationsmaster.data.AesGcmCipher
import se.ajpanton.notificationsmaster.data.EncryptedPayload
import se.ajpanton.notificationsmaster.data.LogEncryptionKeyProvider
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.FileNotFoundException
/** Atomic, encrypted storage for alert settings. An unsupported format fails closed. */
class AlertConfigurationStore(context: Context) {
private val file = AtomicFile(File(context.applicationContext.filesDir, FILE_NAME))
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
@Synchronized
fun load(): AlertConfiguration = try {
DataInputStream(BufferedInputStream(file.openRead())).use { input ->
val iv = ByteArray(input.readInt().also { require(it in 1..32) })
input.readFully(iv)
val encrypted = ByteArray(input.readInt().also { require(it in 1..MAX_CIPHER_TEXT_BYTES) })
input.readFully(encrypted)
AlertConfigurationJson.decode(cipher.decrypt(EncryptedPayload(iv, encrypted)))
}
} catch (_: FileNotFoundException) {
AlertConfiguration()
}
@Synchronized
fun save(configuration: AlertConfiguration) {
val payload = cipher.encrypt(AlertConfigurationJson.encode(configuration))
val output = file.startWrite()
try {
val stream = DataOutputStream(BufferedOutputStream(output))
stream.writeInt(payload.initializationVector.size)
stream.write(payload.initializationVector)
stream.writeInt(payload.cipherText.size)
stream.write(payload.cipherText)
stream.flush()
file.finishWrite(output)
} catch (error: Exception) {
file.failWrite(output)
throw error
}
}
private companion object {
const val FILE_NAME = "alert-configuration.bin"
const val MAX_CIPHER_TEXT_BYTES = 1024 * 1024
}
}
internal object AlertConfigurationJson {
fun encode(configuration: AlertConfiguration): ByteArray = JSONObject()
.put("version", 1)
.put("profiles", JSONArray().apply { configuration.profiles.forEach { put(it.toJson()) } })
.put("apps", JSONArray().apply { configuration.appSettings.forEach { put(it.toJson()) } })
.put("rules", JSONArray().apply { configuration.rules.forEach { put(it.toJson()) } })
.put("queue", JSONObject().put("maximumLength", configuration.queueSettings.maximumLength)
.put("overflow", configuration.queueSettings.overflow.name))
.toString().encodeToByteArray()
fun decode(bytes: ByteArray): AlertConfiguration {
val root = JSONObject(bytes.decodeToString())
require(root.getInt("version") == 1) { "Unsupported alert-configuration format." }
return AlertConfiguration(
profiles = root.getJSONArray("profiles").map { (it as JSONObject).toProfile() },
appSettings = root.getJSONArray("apps").map { (it as JSONObject).toAppSettings() },
rules = root.getJSONArray("rules").map { (it as JSONObject).toRule() },
queueSettings = root.getJSONObject("queue").let {
AlertQueueSettings(it.getInt("maximumLength"), AlertQueueOverflow.valueOf(it.getString("overflow")))
},
)
}
private fun AlertProfile.toJson() = JSONObject()
.put("id", id).put("name", name).put("soundUri", soundUri)
.put("vibrationPattern", JSONArray(vibrationPattern))
private fun AlertAppSettings.toJson() = JSONObject()
.put("packageName", packageName).put("allowMultipleMatches", allowMultipleMatches)
.put("directAlertControl", directAlertControl)
private fun AlertRule.toJson() = JSONObject()
.put("id", id).put("packageName", packageName).put("order", order)
.put("sources", JSONArray(sources.map { it.name }))
.put("matcher", matcher.toJson()).put("outcome", outcome.name).put("profileId", profileId)
.put("playToCompletion", playToCompletion).put("allowDuringDnd", allowDuringDnd)
.put("enabled", enabled).put("name", name)
private fun AlertTextMatcher.toJson() = JSONObject()
.put("field", field.name).put("mode", mode.name).put("value", value)
.put("caseSensitive", caseSensitive)
private fun JSONObject.toProfile() = AlertProfile(
getString("id"), getString("name"), nullableString("soundUri"),
getJSONArray("vibrationPattern").map { (it as Number).toLong() },
)
private fun JSONObject.toAppSettings() = AlertAppSettings(
getString("packageName"), getBoolean("allowMultipleMatches"), getBoolean("directAlertControl"),
)
private fun JSONObject.toRule() = AlertRule(
id = getString("id"),
packageName = getString("packageName"),
order = getInt("order"),
sources = getJSONArray("sources").map { AlertSource.valueOf(it as String) }.toSet(),
matcher = getJSONObject("matcher").let {
AlertTextMatcher(
AlertTextField.valueOf(it.getString("field")),
AlertTextMode.valueOf(it.getString("mode")),
it.getString("value"),
it.getBoolean("caseSensitive"),
)
},
outcome = AlertOutcome.valueOf(getString("outcome")),
profileId = nullableString("profileId"),
playToCompletion = getBoolean("playToCompletion"),
allowDuringDnd = getBoolean("allowDuringDnd"),
enabled = getBoolean("enabled"),
name = nullableString("name"),
)
private fun JSONObject.nullableString(name: String) = if (isNull(name)) null else getString(name)
private fun <T> JSONArray.map(transform: (Any) -> T) = List(length()) { transform(get(it)) }
}
@@ -18,6 +18,8 @@ enum class AlertTextField { ANY_TEXT, TITLE, BODY, SENDER }
enum class AlertTextMode { ANY, CONTAINS, REGEX }
enum class AlertQueueOverflow { DROP_OLDEST, DROP_NEWEST }
data class AlertProfile(
val id: String,
val name: String,
@@ -107,6 +109,31 @@ data class AlertAppSettings(
val directAlertControl: Boolean = false,
)
data class AlertQueueSettings(
val maximumLength: Int = 3,
val overflow: AlertQueueOverflow = AlertQueueOverflow.DROP_OLDEST,
) {
init {
require(maximumLength in 1..10)
}
}
data class AlertConfiguration(
val profiles: List<AlertProfile> = emptyList(),
val appSettings: List<AlertAppSettings> = emptyList(),
val rules: List<AlertRule> = emptyList(),
val queueSettings: AlertQueueSettings = AlertQueueSettings(),
) {
init {
require(profiles.map { it.id }.distinct().size == profiles.size)
require(appSettings.map { it.packageName }.distinct().size == appSettings.size)
require(rules.map { it.id }.distinct().size == rules.size)
require(rules.filter { it.outcome == AlertOutcome.PLAY_PROFILE }.all { rule ->
rule.profileId in profiles.map { it.id }
})
}
}
data class AlertEvent(
val packageName: String,
val source: AlertSource,