Streamline encrypted settings and logging
This commit is contained in:
+13
-40
@@ -1,63 +1,36 @@
|
||||
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 se.ajpanton.notificationsmaster.data.EncryptedAtomicFile
|
||||
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
|
||||
import se.ajpanton.notificationsmaster.module.AlertPolicySync
|
||||
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 context = context.applicationContext
|
||||
private val file = AtomicFile(File(this.context.filesDir, FILE_NAME))
|
||||
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
|
||||
private val file = EncryptedAtomicFile(this.context, FILE_NAME)
|
||||
|
||||
@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()
|
||||
fun load(): AlertConfiguration = synchronized(lock) {
|
||||
if (!file.exists) cached = null
|
||||
cached ?: file.read()?.let(AlertConfigurationJson::decode)?.also { cached = it }
|
||||
?: 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)
|
||||
NotificationListenerComponentController.synchronize(context)
|
||||
AlertPolicySync.publish(context, configuration)
|
||||
} catch (error: Exception) {
|
||||
file.failWrite(output)
|
||||
throw error
|
||||
synchronized(lock) {
|
||||
file.write(AlertConfigurationJson.encode(configuration))
|
||||
cached = configuration
|
||||
}
|
||||
NotificationListenerComponentController.synchronize(context)
|
||||
AlertPolicySync.publish(context, configuration)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FILE_NAME = "alert-configuration.bin"
|
||||
const val MAX_CIPHER_TEXT_BYTES = 1024 * 1024
|
||||
val lock = Any()
|
||||
var cached: AlertConfiguration? = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-13
@@ -133,9 +133,13 @@ class NotificationCaptureService : NotificationListenerService() {
|
||||
imageBytes: (() -> ByteArray?)? = null,
|
||||
) {
|
||||
if (!GroupSummaryPolicy.shouldLog(snapshot, action, captureSettings.logGroupSummaries)) return
|
||||
if (!allows(loggingType, snapshot.packageName)) return
|
||||
if (!loggingEnabled(loggingType, snapshot.packageName) ||
|
||||
!NotificationRuleEvaluator.allows(appFilterStore.load(), snapshot.packageName)
|
||||
) return
|
||||
val appName = appName(snapshot.packageName)
|
||||
val retainImage = includeContents && snapshot.hasImage && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName)
|
||||
val textEnabled = includeContents && loggingEnabled(LoggingType.TEXT_CONTENT, snapshot.packageName)
|
||||
val imageEnabled = includeContents && loggingEnabled(LoggingType.IMAGE_CONTENT, snapshot.packageName)
|
||||
val retainImage = snapshot.hasImage && imageEnabled
|
||||
val retainedImageBytes = if (retainImage) imageBytes?.invoke() else null
|
||||
val entry = NotificationLogEntry(
|
||||
recordedAtEpochMillis = System.currentTimeMillis(),
|
||||
@@ -143,8 +147,8 @@ class NotificationCaptureService : NotificationListenerService() {
|
||||
packageName = snapshot.packageName,
|
||||
appName = appName,
|
||||
action = action,
|
||||
contents = if (includeContents) visibleContents(snapshot) else null,
|
||||
previousContents = previousSnapshot?.let(::visibleContents),
|
||||
contents = if (includeContents) visibleContents(snapshot, textEnabled, imageEnabled) else null,
|
||||
previousContents = previousSnapshot?.let { visibleContents(it, textEnabled, imageEnabled) },
|
||||
imageId = if (retainedImageBytes != null) java.util.UUID.randomUUID().toString() else null,
|
||||
)
|
||||
writeExecutor.execute {
|
||||
@@ -166,25 +170,23 @@ class NotificationCaptureService : NotificationListenerService() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun visibleContents(snapshot: NotificationSnapshot): String? {
|
||||
val text = snapshot.textContents?.takeIf {
|
||||
allows(LoggingType.TEXT_CONTENT, snapshot.packageName)
|
||||
}
|
||||
val image = snapshot.hasImage && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName)
|
||||
return listOfNotNull(text, if (image) "[image]" else null)
|
||||
private fun visibleContents(snapshot: NotificationSnapshot, textEnabled: Boolean, imageEnabled: Boolean): String? {
|
||||
return listOfNotNull(
|
||||
snapshot.textContents?.takeIf { textEnabled },
|
||||
if (snapshot.hasImage && imageEnabled) "[image]" else null,
|
||||
)
|
||||
.joinToString("\n")
|
||||
.take(MAX_CONTENT_CHARACTERS)
|
||||
.ifEmpty { null }
|
||||
}
|
||||
|
||||
private fun allows(type: LoggingType, packageName: String): Boolean {
|
||||
private fun loggingEnabled(type: LoggingType, packageName: String): Boolean {
|
||||
val globalEnabled = ruleStore.ruleFor(type).enabled
|
||||
val eventEnabled = if (type in LoggingType.eventTypes) {
|
||||
return if (type in LoggingType.eventTypes) {
|
||||
perAppEventSettings.isEnabled(packageName, type, globalEnabled)
|
||||
} else {
|
||||
globalEnabled
|
||||
}
|
||||
return eventEnabled && NotificationRuleEvaluator.allows(appFilterStore.load(), packageName)
|
||||
}
|
||||
|
||||
private fun dispatchAlert(snapshot: NotificationSnapshot, source: AlertSource) {
|
||||
|
||||
@@ -26,7 +26,7 @@ object SeenApps {
|
||||
private fun resetAfterBoot(preferences: android.content.SharedPreferences) {
|
||||
val bootEpoch = System.currentTimeMillis() - SystemClock.elapsedRealtime()
|
||||
if (abs(preferences.getLong(KEY_BOOT_EPOCH, -1) - bootEpoch) > 5_000) {
|
||||
preferences.edit().putLong(KEY_BOOT_EPOCH, bootEpoch).remove(KEY_PACKAGES).commit()
|
||||
preferences.edit().putLong(KEY_BOOT_EPOCH, bootEpoch).remove(KEY_PACKAGES).apply()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package se.ajpanton.notificationsmaster.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
internal class EncryptedAtomicFile(context: Context, name: String) {
|
||||
private val file = AtomicFile(File(context.filesDir, name))
|
||||
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
|
||||
|
||||
val exists get() = file.baseFile.isFile
|
||||
|
||||
fun read(): ByteArray? = try {
|
||||
DataInputStream(BufferedInputStream(file.openRead())).use { input ->
|
||||
val iv = input.readBytes(MAX_IV_BYTES)
|
||||
val encrypted = input.readBytes(MAX_CIPHER_TEXT_BYTES)
|
||||
cipher.decrypt(EncryptedPayload(iv, encrypted))
|
||||
}
|
||||
} catch (_: FileNotFoundException) {
|
||||
null
|
||||
}
|
||||
|
||||
fun write(bytes: ByteArray) {
|
||||
val payload = cipher.encrypt(bytes)
|
||||
val output = file.startWrite()
|
||||
try {
|
||||
DataOutputStream(BufferedOutputStream(output)).apply {
|
||||
writeInt(payload.initializationVector.size)
|
||||
write(payload.initializationVector)
|
||||
writeInt(payload.cipherText.size)
|
||||
write(payload.cipherText)
|
||||
flush()
|
||||
}
|
||||
file.finishWrite(output)
|
||||
} catch (error: Exception) {
|
||||
file.failWrite(output)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private fun DataInputStream.readBytes(maximum: Int): ByteArray {
|
||||
val length = readInt()
|
||||
require(length in 1..maximum) { "Invalid encrypted configuration field length." }
|
||||
return ByteArray(length).also(::readFully)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_IV_BYTES = 32
|
||||
const val MAX_CIPHER_TEXT_BYTES = 1024 * 1024
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -80,10 +80,11 @@ class EncryptedNotificationLogStore(context: Context) {
|
||||
val chunks = chunkFiles()
|
||||
val newest = chunks.lastOrNull()
|
||||
val current = newest?.let(::readChunk).orEmpty()
|
||||
if (newest == null || encodedSize(current + entry) > MAX_CHUNK_PLAINTEXT_BYTES) {
|
||||
writeChunk(nextChunkFile(chunks), listOf(entry))
|
||||
val combined = NotificationLogEntryJson.encode(current + entry)
|
||||
if (newest == null || combined.size <= MAX_CHUNK_PLAINTEXT_BYTES) {
|
||||
writeChunk(newest ?: nextChunkFile(chunks), combined)
|
||||
} else {
|
||||
writeChunk(newest, current + entry)
|
||||
writeChunk(nextChunkFile(chunks), NotificationLogEntryJson.encode(listOf(entry)))
|
||||
}
|
||||
enforceLimits()
|
||||
}
|
||||
@@ -98,7 +99,7 @@ class EncryptedNotificationLogStore(context: Context) {
|
||||
val entries = readChunk(chunk)
|
||||
val entry = entries.firstOrNull { it.id == id } ?: return@forEach
|
||||
val retained = entries.filterNot { it.id == id }
|
||||
if (retained.isEmpty()) chunk.delete() else writeChunk(chunk, retained)
|
||||
if (retained.isEmpty()) chunk.delete() else writeChunk(chunk, NotificationLogEntryJson.encode(retained))
|
||||
entry.imageId?.let(::deleteImage)
|
||||
return true
|
||||
}
|
||||
@@ -137,7 +138,8 @@ class EncryptedNotificationLogStore(context: Context) {
|
||||
while (totalBytes > limit && retained.isNotEmpty()) {
|
||||
retained.first().imageId?.let(::deleteImage)
|
||||
retained = retained.drop(1)
|
||||
if (retained.isEmpty()) oldest.delete() else writeChunk(oldest, retained)
|
||||
if (retained.isEmpty()) oldest.delete()
|
||||
else writeChunk(oldest, NotificationLogEntryJson.encode(retained))
|
||||
totalBytes = chunkFiles().sumOf(File::length)
|
||||
}
|
||||
}
|
||||
@@ -203,8 +205,8 @@ class EncryptedNotificationLogStore(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeChunk(file: File, entries: List<NotificationLogEntry>) {
|
||||
val payload = cipher.encrypt(NotificationLogEntryJson.encode(entries))
|
||||
private fun writeChunk(file: File, encoded: ByteArray) {
|
||||
val payload = cipher.encrypt(encoded)
|
||||
val atomicFile = AtomicFile(file)
|
||||
val output = atomicFile.startWrite()
|
||||
try {
|
||||
@@ -228,8 +230,6 @@ class EncryptedNotificationLogStore(context: Context) {
|
||||
return ByteArray(length).also(stream::readFully)
|
||||
}
|
||||
|
||||
private fun encodedSize(entries: List<NotificationLogEntry>) = NotificationLogEntryJson.encode(entries).size
|
||||
|
||||
private fun clearChunksOnly() {
|
||||
chunkFiles().forEach(File::delete)
|
||||
}
|
||||
|
||||
+35
-51
@@ -1,75 +1,59 @@
|
||||
package se.ajpanton.notificationsmaster.visibility
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import se.ajpanton.notificationsmaster.data.AesGcmCipher
|
||||
import se.ajpanton.notificationsmaster.data.EncryptedPayload
|
||||
import se.ajpanton.notificationsmaster.data.LogEncryptionKeyProvider
|
||||
import se.ajpanton.notificationsmaster.data.EncryptedAtomicFile
|
||||
import se.ajpanton.notificationsmaster.module.VisibilityPolicySync
|
||||
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
class VisibilityPolicyStore(context: Context) {
|
||||
private val context = context.applicationContext
|
||||
private val file = AtomicFile(File(this.context.filesDir, FILE_NAME))
|
||||
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
|
||||
private val file = EncryptedAtomicFile(this.context, FILE_NAME)
|
||||
|
||||
@Synchronized
|
||||
fun load(): VisibilityPolicy = 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)
|
||||
VisibilityPolicyJson.decode(cipher.decrypt(EncryptedPayload(iv, encrypted)))
|
||||
}
|
||||
} catch (_: FileNotFoundException) {
|
||||
VisibilityPolicy()
|
||||
fun load(): VisibilityPolicy = synchronized(lock) {
|
||||
loadLocked()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun save(policy: VisibilityPolicy): VisibilityPolicy {
|
||||
require(policy.apps.flatMap { it.exceptions }.all { it.hasValidPattern() })
|
||||
val stored = policy.copy(generation = load().generation + 1)
|
||||
val encoded = VisibilityPolicyJson.encode(stored)
|
||||
require(encoded.size <= VisibilityPolicySync.MAX_POLICY_BYTES) { "Visibility policy is too large." }
|
||||
val payload = cipher.encrypt(encoded)
|
||||
val output = file.startWrite()
|
||||
try {
|
||||
DataOutputStream(BufferedOutputStream(output)).apply {
|
||||
writeInt(payload.initializationVector.size)
|
||||
write(payload.initializationVector)
|
||||
writeInt(payload.cipherText.size)
|
||||
write(payload.cipherText)
|
||||
flush()
|
||||
}
|
||||
file.finishWrite(output)
|
||||
} catch (error: Exception) {
|
||||
file.failWrite(output)
|
||||
throw error
|
||||
fun save(policy: VisibilityPolicy) = update { policy }
|
||||
|
||||
fun update(change: (VisibilityPolicy) -> VisibilityPolicy): VisibilityPolicy {
|
||||
val stored = synchronized(lock) {
|
||||
val current = loadLocked()
|
||||
saveLocked(change(current), current.generation + 1)
|
||||
}
|
||||
NotificationListenerComponentController.synchronize(context)
|
||||
VisibilityPolicySync.publish(context, stored)
|
||||
return stored
|
||||
return publish(stored)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun update(change: (VisibilityPolicy) -> VisibilityPolicy) = save(change(load()))
|
||||
|
||||
@Synchronized
|
||||
fun removeUninstalledPackages(installedPackages: Set<String>) {
|
||||
val current = load()
|
||||
val retained = current.apps.filter { it.packageName in installedPackages }
|
||||
if (retained.size != current.apps.size) save(current.copy(apps = retained))
|
||||
}
|
||||
|
||||
private fun loadLocked(): VisibilityPolicy {
|
||||
if (!file.exists) cached = null
|
||||
return cached ?: file.read()?.let(VisibilityPolicyJson::decode)?.also { cached = it }
|
||||
?: VisibilityPolicy()
|
||||
}
|
||||
|
||||
private fun saveLocked(policy: VisibilityPolicy, generation: Long): VisibilityPolicy {
|
||||
require(policy.apps.flatMap { it.exceptions }.all { it.hasValidPattern() })
|
||||
val stored = policy.copy(generation = generation)
|
||||
val encoded = VisibilityPolicyJson.encode(stored)
|
||||
require(encoded.size <= VisibilityPolicySync.MAX_POLICY_BYTES) { "Visibility policy is too large." }
|
||||
file.write(encoded)
|
||||
cached = stored
|
||||
return stored
|
||||
}
|
||||
|
||||
private fun publish(policy: VisibilityPolicy): VisibilityPolicy {
|
||||
NotificationListenerComponentController.synchronize(context)
|
||||
VisibilityPolicySync.publish(context, policy)
|
||||
return policy
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FILE_NAME = "visibility-policy.bin"
|
||||
const val MAX_CIPHER_TEXT_BYTES = 1024 * 1024
|
||||
val lock = Any()
|
||||
var cached: VisibilityPolicy? = null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user