Persist encrypted alert configuration
This commit is contained in:
@@ -97,4 +97,7 @@ dependencies {
|
|||||||
compileOnly(libs.libxposed.api)
|
compileOnly(libs.libxposed.api)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation("org.json:json:20240303")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||||
|
androidTestImplementation("androidx.test:runner:1.6.2")
|
||||||
}
|
}
|
||||||
|
|||||||
+43
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 AlertTextMode { ANY, CONTAINS, REGEX }
|
||||||
|
|
||||||
|
enum class AlertQueueOverflow { DROP_OLDEST, DROP_NEWEST }
|
||||||
|
|
||||||
data class AlertProfile(
|
data class AlertProfile(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
@@ -107,6 +109,31 @@ data class AlertAppSettings(
|
|||||||
val directAlertControl: Boolean = false,
|
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(
|
data class AlertEvent(
|
||||||
val packageName: String,
|
val packageName: String,
|
||||||
val source: AlertSource,
|
val source: AlertSource,
|
||||||
|
|||||||
+43
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user