Clean stale per-app storage on startup

This commit is contained in:
ajp_anton
2026-07-27 13:51:10 +00:00
parent 1e4315d3dd
commit dca86ae393
5 changed files with 70 additions and 17 deletions
@@ -0,0 +1,23 @@
package se.ajpanton.notificationlog
import android.app.Application
import android.content.pm.PackageManager
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.settings.AppFilterSettingsStore
import se.ajpanton.notificationlog.settings.PerAppEventSettingsStore
class NotificationLogApplication : Application() {
override fun onCreate() {
super.onCreate()
Thread(::removeStaleAppStorage, "notification-log-storage-cleanup").start()
}
private fun removeStaleAppStorage() {
val installedPackages = packageManager.getInstalledApplications(
PackageManager.ApplicationInfoFlags.of(0),
).mapTo(mutableSetOf()) { it.packageName }
AppFilterSettingsStore(this).removeUninstalledPackages(installedPackages)
PerAppEventSettingsStore(this).removeUninstalledPackages(installedPackages)
EncryptedNotificationLogStore(this).removeOrphanedImages()
}
}
@@ -33,7 +33,9 @@ class EncryptedNotificationLogStore(context: Context) {
}
fun replaceAll(entries: List<NotificationLogEntry>) = synchronized(lock) {
val existing = readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: emptyList()
write(entries)
removeUnreferencedImages(entries, existing)
}
fun clear() = synchronized(lock) {
@@ -49,6 +51,12 @@ class EncryptedNotificationLogStore(context: Context) {
true
}
/** Removes failed-write remnants and images no remaining log entry references. */
fun removeOrphanedImages() = synchronized(lock) {
val entries = readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: emptyList()
removeUnreferencedImages(entries)
}
private fun write(entries: List<NotificationLogEntry>) {
val payload = cipher.encrypt(NotificationLogEntryJson.encode(entries))
val output = file.startWrite()
@@ -82,6 +90,26 @@ class EncryptedNotificationLogStore(context: Context) {
if (id.matches(IMAGE_ID_PATTERN)) File(imageDirectory, "$id.bin").delete()
}
private fun removeUnreferencedImages(
entries: List<NotificationLogEntry>,
previousEntries: List<NotificationLogEntry> = emptyList(),
) {
val referenced = entries.mapNotNull { it.imageId }.toSet()
previousEntries.mapNotNull { it.imageId }
.filterNot(referenced::contains)
.forEach(::deleteImage)
imageDirectory.listFiles()?.forEach { image ->
val imageId = image.name.removeSuffix(".bin")
if (image.name.endsWith(".new") ||
!image.name.endsWith(".bin") ||
!imageId.matches(IMAGE_ID_PATTERN) ||
imageId !in referenced
) {
image.delete()
}
}
}
private fun readPayloadOrNull(): EncryptedPayload? {
val input = try {
file.openRead()
@@ -11,18 +11,14 @@ data class AppFilterSettings(
)
class AppFilterSettingsStore(context: Context) {
private val appContext = context.applicationContext
private val preferences = appContext.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
private val preferences = context.applicationContext.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
fun load(): AppFilterSettings {
migrateLegacyFiltersIfNeeded()
return AppFilterSettings(
fun load(): AppFilterSettings = AppFilterSettings(
mode = AppRuleMode.valueOf(preferences.getString(MODE, AppRuleMode.BLACKLIST.name)!!),
selectedPackages = preferences.getStringSet(SELECTED_PACKAGES, emptySet())?.toSet() ?: emptySet(),
onlySeenApps = preferences.getBoolean(ONLY_SEEN_APPS, false),
seenAppsFirst = preferences.getBoolean(SEEN_APPS_FIRST, true),
)
}
fun save(settings: AppFilterSettings) = preferences.edit {
putString(MODE, settings.mode.name)
@@ -31,21 +27,16 @@ class AppFilterSettingsStore(context: Context) {
putBoolean(SEEN_APPS_FIRST, settings.seenAppsFirst)
}
private fun migrateLegacyFiltersIfNeeded() {
if (preferences.getBoolean(MIGRATED, false)) return
val legacy = appContext.getSharedPreferences("logging-rules", Context.MODE_PRIVATE)
val blacklistedPackages = LoggingType.entries.flatMap { type ->
legacy.getStringSet("${type.name.lowercase()}.selected_packages", emptySet()).orEmpty()
.takeIf { legacy.getString("${type.name.lowercase()}.app_rule_mode", AppRuleMode.BLACKLIST.name) == AppRuleMode.BLACKLIST.name }
.orEmpty()
}.toSet()
save(AppFilterSettings(selectedPackages = blacklistedPackages))
preferences.edit { putBoolean(MIGRATED, true) }
fun removeUninstalledPackages(installedPackages: Set<String>) {
val settings = load()
val retainedPackages = settings.selectedPackages.intersect(installedPackages)
if (retainedPackages != settings.selectedPackages) {
save(settings.copy(selectedPackages = retainedPackages))
}
}
private companion object {
const val FILE_NAME = "app-filter-settings"
const val MIGRATED = "migrated_from_per_event_filters"
const val MODE = "mode"
const val SELECTED_PACKAGES = "selected_packages"
const val ONLY_SEEN_APPS = "only_seen_apps"
@@ -36,6 +36,16 @@ class PerAppEventSettingsStore(context: Context) {
.values
.any { value -> (value as? Set<*>)?.isNotEmpty() == true }
fun removeUninstalledPackages(installedPackages: Set<String>) {
val removed = preferences.all.keys
.filter { it.startsWith(KEY_PREFIX) }
.filter { it.removePrefix(KEY_PREFIX) !in installedPackages }
if (removed.isNotEmpty()) {
preferences.edit { removed.forEach(::remove) }
updateListenerComponent()
}
}
private fun saveEvents(packageName: String, enabledEvents: Set<LoggingType>) {
preferences.edit { putStringSet(key(packageName), enabledEvents.mapTo(mutableSetOf()) { it.name }) }
updateListenerComponent()