diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 92fe892..cf01dfc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -97,4 +97,7 @@ dependencies { compileOnly(libs.libxposed.api) testImplementation(libs.junit) + testImplementation("org.json:json:20240303") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test:runner:1.6.2") } diff --git a/app/src/androidTest/java/se/ajpanton/notificationsmaster/alerts/AlertConfigurationStoreTest.kt b/app/src/androidTest/java/se/ajpanton/notificationsmaster/alerts/AlertConfigurationStoreTest.kt new file mode 100644 index 0000000..cefcd4a --- /dev/null +++ b/app/src/androidTest/java/se/ajpanton/notificationsmaster/alerts/AlertConfigurationStoreTest.kt @@ -0,0 +1,43 @@ +package se.ajpanton.notificationsmaster.alerts + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +@RunWith(AndroidJUnit4::class) +class AlertConfigurationStoreTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val configurationFile = File(context.filesDir, "alert-configuration.bin") + + @After + fun removeStoredConfiguration() { + configurationFile.delete() + File(configurationFile.path + ".bak").delete() + File(configurationFile.path + ".new").delete() + } + + @Test + fun encryptsAndReloadsConfiguration() { + val profile = AlertProfile("profile", "Sensitive profile", vibrationPattern = listOf(0, 120)) + val configuration = AlertConfiguration( + profiles = listOf(profile), + appSettings = listOf(AlertAppSettings("chat.app", directAlertControl = true)), + rules = listOf( + AlertRule( + "rule", "chat.app", 0, setOf(AlertSource.NOTIFICATION_POST), + outcome = AlertOutcome.PLAY_PROFILE, profileId = profile.id, + ), + ), + ) + + AlertConfigurationStore(context).save(configuration) + + assertEquals(configuration, AlertConfigurationStore(context).load()) + assertFalse(configurationFile.readBytes().decodeToString().contains(profile.name)) + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertConfigurationStore.kt b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertConfigurationStore.kt new file mode 100644 index 0000000..7ab47db --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertConfigurationStore.kt @@ -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 JSONArray.map(transform: (Any) -> T) = List(length()) { transform(get(it)) } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertModels.kt b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertModels.kt index 7aca802..df1c7d2 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertModels.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertModels.kt @@ -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 = emptyList(), + val appSettings: List = emptyList(), + val rules: List = 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, diff --git a/app/src/test/java/se/ajpanton/notificationsmaster/alerts/AlertConfigurationJsonTest.kt b/app/src/test/java/se/ajpanton/notificationsmaster/alerts/AlertConfigurationJsonTest.kt new file mode 100644 index 0000000..6e4e9c6 --- /dev/null +++ b/app/src/test/java/se/ajpanton/notificationsmaster/alerts/AlertConfigurationJsonTest.kt @@ -0,0 +1,43 @@ +package se.ajpanton.notificationsmaster.alerts + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AlertConfigurationJsonTest { + @Test + fun roundTripsProfilesAppSettingsRulesAndQueue() { + val profile = AlertProfile("urgent", "Urgent", "content://sound", listOf(0, 100, 50, 200)) + val rule = AlertRule( + id = "rule", + packageName = "chat.app", + order = 2, + sources = setOf(AlertSource.NOTIFICATION_POST, AlertSource.NOTIFICATION_UPDATE), + matcher = AlertTextMatcher(AlertTextField.BODY, AlertTextMode.REGEX, "invoice.+", true), + outcome = AlertOutcome.PLAY_PROFILE, + profileId = profile.id, + playToCompletion = true, + allowDuringDnd = true, + name = "Invoices", + ) + val original = AlertConfiguration( + profiles = listOf(profile), + appSettings = listOf(AlertAppSettings("chat.app", true, true)), + rules = listOf(rule), + queueSettings = AlertQueueSettings(7, AlertQueueOverflow.DROP_NEWEST), + ) + + assertEquals(original, AlertConfigurationJson.decode(AlertConfigurationJson.encode(original))) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsRuleReferencingMissingProfile() { + AlertConfiguration( + rules = listOf( + AlertRule( + "rule", "chat.app", 0, setOf(AlertSource.NOTIFICATION_POST), + outcome = AlertOutcome.PLAY_PROFILE, profileId = "missing", + ), + ), + ) + } +}