Persist encrypted alert configuration
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user