Add configurable chunked storage limits
This commit is contained in:
@@ -16,6 +16,8 @@ import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
|
|||||||
import se.ajpanton.notificationlog.data.EncryptedImageStore
|
import se.ajpanton.notificationlog.data.EncryptedImageStore
|
||||||
import se.ajpanton.notificationlog.capture.NotificationCaptureService
|
import se.ajpanton.notificationlog.capture.NotificationCaptureService
|
||||||
import se.ajpanton.notificationlog.export.LogExporter
|
import se.ajpanton.notificationlog.export.LogExporter
|
||||||
|
import se.ajpanton.notificationlog.settings.StorageLimits
|
||||||
|
import se.ajpanton.notificationlog.settings.StorageLimitsStore
|
||||||
import java.util.zip.ZipOutputStream
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
class SettingsFragment : Fragment(R.layout.fragment_settings) {
|
class SettingsFragment : Fragment(R.layout.fragment_settings) {
|
||||||
@@ -33,6 +35,7 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
|
|||||||
val lockStore = AppLockStore(requireContext())
|
val lockStore = AppLockStore(requireContext())
|
||||||
binding!!.appLock.isChecked = lockStore.enabled
|
binding!!.appLock.isChecked = lockStore.enabled
|
||||||
binding!!.appLock.setOnCheckedChangeListener { _, enabled -> lockStore.enabled = enabled }
|
binding!!.appLock.setOnCheckedChangeListener { _, enabled -> lockStore.enabled = enabled }
|
||||||
|
bindStorageLimits()
|
||||||
}
|
}
|
||||||
override fun onDestroyView() { binding = null; super.onDestroyView() }
|
override fun onDestroyView() { binding = null; super.onDestroyView() }
|
||||||
|
|
||||||
@@ -44,6 +47,27 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
|
|||||||
private fun isListenerEnabled(): Boolean = requireContext().getSystemService(android.app.NotificationManager::class.java)
|
private fun isListenerEnabled(): Boolean = requireContext().getSystemService(android.app.NotificationManager::class.java)
|
||||||
.isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java))
|
.isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java))
|
||||||
|
|
||||||
|
private fun bindStorageLimits() {
|
||||||
|
val store = StorageLimitsStore(requireContext())
|
||||||
|
val limits = store.load()
|
||||||
|
binding!!.logLimitMib.setText((limits.logBytes / StorageLimitsStore.MEBIBYTE).toString())
|
||||||
|
binding!!.imageLimitMib.setText((limits.imageBytes / StorageLimitsStore.MEBIBYTE).toString())
|
||||||
|
binding!!.saveStorageLimits.setOnClickListener {
|
||||||
|
val logLimit = binding!!.logLimitMib.text.toString().toLongOrNull()
|
||||||
|
val imageLimit = binding!!.imageLimitMib.text.toString().toLongOrNull()
|
||||||
|
if (logLimit == null || imageLimit == null ||
|
||||||
|
logLimit !in 1..MAXIMUM_LIMIT_MIB || imageLimit !in 1..MAXIMUM_LIMIT_MIB
|
||||||
|
) {
|
||||||
|
binding!!.logLimitMib.error = "Enter 1 to $MAXIMUM_LIMIT_MIB MiB"
|
||||||
|
binding!!.imageLimitMib.error = "Enter 1 to $MAXIMUM_LIMIT_MIB MiB"
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
store.save(StorageLimits(logLimit * StorageLimitsStore.MEBIBYTE, imageLimit * StorageLimitsStore.MEBIBYTE))
|
||||||
|
val appContext = requireContext().applicationContext
|
||||||
|
Thread { EncryptedNotificationLogStore(appContext).enforceLimits() }.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun confirmClearLogs() {
|
private fun confirmClearLogs() {
|
||||||
MaterialAlertDialogBuilder(requireContext())
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
.setTitle("Clear logs?")
|
.setTitle("Clear logs?")
|
||||||
@@ -104,4 +128,8 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") }
|
private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MAXIMUM_LIMIT_MIB = 1024L * 1024L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,23 +25,32 @@ import androidx.fragment.app.Fragment
|
|||||||
class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
||||||
private var binding: FragmentViewLogsBinding? = null
|
private var binding: FragmentViewLogsBinding? = null
|
||||||
private val expandedIds = mutableSetOf<String>()
|
private val expandedIds = mutableSetOf<String>()
|
||||||
|
private val rows = mutableListOf<LogRow>()
|
||||||
|
private var loadedEntries = 0
|
||||||
|
private var loading = false
|
||||||
|
private var noMoreRows = false
|
||||||
|
private var loadGeneration = 0
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
binding = FragmentViewLogsBinding.bind(view)
|
binding = FragmentViewLogsBinding.bind(view)
|
||||||
binding!!.logsRefresh.setOnRefreshListener(::loadLogs)
|
binding!!.logsRefresh.setOnRefreshListener { loadLogs(reset = true) }
|
||||||
binding!!.logScroll.setOnScrollChangeListener { _, _, scrollY, _, _ ->
|
binding!!.logScroll.setOnScrollChangeListener { _, _, scrollY, _, _ ->
|
||||||
binding?.scrollToTop?.visibility = if (scrollY > dp(SCROLL_TO_TOP_THRESHOLD_DP)) View.VISIBLE else View.GONE
|
binding?.scrollToTop?.visibility = if (scrollY > dp(SCROLL_TO_TOP_THRESHOLD_DP)) View.VISIBLE else View.GONE
|
||||||
|
val scroll = binding?.logScroll ?: return@setOnScrollChangeListener
|
||||||
|
if (!noMoreRows && !loading && scrollY >= scroll.getChildAt(0).height - scroll.height - dp(LOAD_MORE_THRESHOLD_DP)) {
|
||||||
|
loadLogs(reset = false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
binding!!.scrollToTop.setOnClickListener {
|
binding!!.scrollToTop.setOnClickListener {
|
||||||
binding?.logScroll?.smoothScrollTo(0, 0)
|
binding?.logScroll?.smoothScrollTo(0, 0)
|
||||||
}
|
}
|
||||||
loadLogs()
|
loadLogs(reset = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
loadLogs()
|
loadLogs(reset = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
@@ -49,26 +58,40 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadLogs() {
|
private fun loadLogs(reset: Boolean) {
|
||||||
|
if (loading) return
|
||||||
|
if (!reset && noMoreRows) return
|
||||||
val context = requireContext().applicationContext
|
val context = requireContext().applicationContext
|
||||||
|
if (reset) {
|
||||||
|
rows.clear()
|
||||||
|
loadedEntries = 0
|
||||||
|
noMoreRows = false
|
||||||
|
loadGeneration++
|
||||||
|
}
|
||||||
|
val generation = loadGeneration
|
||||||
|
loading = true
|
||||||
binding?.logsRefresh?.isRefreshing = true
|
binding?.logsRefresh?.isRefreshing = true
|
||||||
Thread {
|
Thread {
|
||||||
val imageStore = EncryptedImageStore(context)
|
val entries = EncryptedNotificationLogStore(context).readNewest(loadedEntries, PAGE_SIZE)
|
||||||
val rows = EncryptedNotificationLogStore(context).readAll()
|
|
||||||
.sortedByDescending { it.recordedAtEpochMillis }
|
|
||||||
.map { entry -> LogRow(entry, entry.imageId?.let(imageStore::read)) }
|
|
||||||
activity?.runOnUiThread {
|
activity?.runOnUiThread {
|
||||||
binding?.let { render(it, rows) }
|
if (generation == loadGeneration) {
|
||||||
|
loadedEntries += entries.size
|
||||||
|
noMoreRows = entries.size < PAGE_SIZE
|
||||||
|
val addedRows = entries.map(::LogRow)
|
||||||
|
rows += addedRows
|
||||||
|
binding?.let { render(it, addedRows, reset) }
|
||||||
|
}
|
||||||
|
loading = false
|
||||||
binding?.logsRefresh?.isRefreshing = false
|
binding?.logsRefresh?.isRefreshing = false
|
||||||
}
|
}
|
||||||
}.start()
|
}.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun render(view: FragmentViewLogsBinding, rows: List<LogRow>) {
|
private fun render(view: FragmentViewLogsBinding, addedRows: List<LogRow>, reset: Boolean) {
|
||||||
view.logRows.removeAllViews()
|
if (reset) view.logRows.removeAllViews()
|
||||||
view.emptyView.visibility = if (rows.isEmpty()) View.VISIBLE else View.GONE
|
view.emptyView.visibility = if (rows.isEmpty()) View.VISIBLE else View.GONE
|
||||||
val settings = LogViewSettingsStore(requireContext()).load()
|
val settings = LogViewSettingsStore(requireContext()).load()
|
||||||
rows.filter { eventFor(it.entry.action) in settings.visibleEvents }
|
addedRows.filter { eventFor(it.entry.action) in settings.visibleEvents }
|
||||||
.forEach { row -> view.logRows.addView(logRowView(row, settings)) }
|
.forEach { row -> view.logRows.addView(logRowView(row, settings)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,18 +182,23 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
setPadding(0, dp(4), 0, 0)
|
setPadding(0, dp(4), 0, 0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (expanded) {
|
if (expanded && row.entry.imageId != null) {
|
||||||
row.imageBytes?.let { bytes ->
|
val image = ImageView(context).apply {
|
||||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { bitmap ->
|
adjustViewBounds = true
|
||||||
addView(ImageView(context).apply {
|
maxHeight = dp(240)
|
||||||
setImageBitmap(bitmap)
|
contentDescription = "Notification image"
|
||||||
adjustViewBounds = true
|
setPadding(0, dp(4), 0, 0)
|
||||||
maxHeight = dp(240)
|
|
||||||
contentDescription = "Notification image"
|
|
||||||
setPadding(0, dp(4), 0, 0)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
addView(image)
|
||||||
|
val imageId = row.entry.imageId
|
||||||
|
val appContext = requireContext().applicationContext
|
||||||
|
Thread {
|
||||||
|
EncryptedImageStore(appContext).read(imageId)?.let { bytes ->
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
|
}?.let { bitmap ->
|
||||||
|
activity?.runOnUiThread { if (image.isAttachedToWindow) image.setImageBitmap(bitmap) }
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
}
|
}
|
||||||
setOnClickListener { onToggleExpanded() }
|
setOnClickListener { onToggleExpanded() }
|
||||||
setOnLongClickListener { showDeleteDialog(row.entry.id) }
|
setOnLongClickListener { showDeleteDialog(row.entry.id) }
|
||||||
@@ -225,7 +253,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
.setPositiveButton("Delete") { _, _ ->
|
.setPositiveButton("Delete") { _, _ ->
|
||||||
Thread {
|
Thread {
|
||||||
EncryptedNotificationLogStore(requireContext().applicationContext).delete(entryId)
|
EncryptedNotificationLogStore(requireContext().applicationContext).delete(entryId)
|
||||||
activity?.runOnUiThread(::loadLogs)
|
activity?.runOnUiThread { loadLogs(reset = true) }
|
||||||
}.start()
|
}.start()
|
||||||
}
|
}
|
||||||
.show()
|
.show()
|
||||||
@@ -244,12 +272,14 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
|
|
||||||
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
|
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
|
||||||
|
|
||||||
private data class LogRow(val entry: NotificationLogEntry, val imageBytes: ByteArray?)
|
private data class LogRow(val entry: NotificationLogEntry)
|
||||||
private data class MetadataValue(val text: String, val textSize: Float, val indented: Boolean = false, val bold: Boolean = false)
|
private data class MetadataValue(val text: String, val textSize: Float, val indented: Boolean = false, val bold: Boolean = false)
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val LEFT_PILL_WEIGHT = 0.42f
|
const val LEFT_PILL_WEIGHT = 0.42f
|
||||||
const val RIGHT_PILL_WEIGHT = 0.58f
|
const val RIGHT_PILL_WEIGHT = 0.58f
|
||||||
const val SCROLL_TO_TOP_THRESHOLD_DP = 120
|
const val SCROLL_TO_TOP_THRESHOLD_DP = 120
|
||||||
|
const val LOAD_MORE_THRESHOLD_DP = 480
|
||||||
|
const val PAGE_SIZE = 80
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+167
-70
@@ -3,101 +3,152 @@ package se.ajpanton.notificationlog.data
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AtomicFile
|
import android.util.AtomicFile
|
||||||
import se.ajpanton.notificationlog.model.NotificationLogEntry
|
import se.ajpanton.notificationlog.model.NotificationLogEntry
|
||||||
|
import se.ajpanton.notificationlog.settings.StorageLimitsStore
|
||||||
import java.io.BufferedInputStream
|
import java.io.BufferedInputStream
|
||||||
import java.io.BufferedOutputStream
|
import java.io.BufferedOutputStream
|
||||||
import java.io.DataInputStream
|
import java.io.DataInputStream
|
||||||
import java.io.DataOutputStream
|
import java.io.DataOutputStream
|
||||||
import java.io.FileNotFoundException
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An atomically replaced encrypted event log. It keeps every persisted log field
|
* Independently encrypted log chunks. Reading or appending a record needs only
|
||||||
* encrypted, including timestamps and package names, while the app is at rest.
|
* one bounded chunk; older chunks stay on disk until requested by the viewer.
|
||||||
*/
|
*/
|
||||||
class EncryptedNotificationLogStore(context: Context) {
|
class EncryptedNotificationLogStore(context: Context) {
|
||||||
private val lock = Any()
|
private val appContext = context.applicationContext
|
||||||
private val file = AtomicFile(context.filesDir.resolve(FILE_NAME))
|
private val directory = File(appContext.filesDir, DIRECTORY_NAME).also(File::mkdirs)
|
||||||
private val imageDirectory = File(context.filesDir, IMAGE_DIRECTORY_NAME)
|
private val imageDirectory = File(appContext.filesDir, IMAGE_DIRECTORY_NAME)
|
||||||
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
|
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
|
||||||
|
|
||||||
|
init {
|
||||||
|
// This development build deliberately does not retain the former monolithic format.
|
||||||
|
File(appContext.filesDir, LEGACY_FILE_NAME).delete()
|
||||||
|
}
|
||||||
|
|
||||||
fun readAll(): List<NotificationLogEntry> = synchronized(lock) {
|
fun readAll(): List<NotificationLogEntry> = synchronized(lock) {
|
||||||
val payload = readPayloadOrNull() ?: return emptyList()
|
chunkFiles().flatMap(::readChunk)
|
||||||
NotificationLogEntryJson.decode(cipher.decrypt(payload))
|
}
|
||||||
|
|
||||||
|
fun readNewest(offset: Int, limit: Int): List<NotificationLogEntry> = synchronized(lock) {
|
||||||
|
require(offset >= 0)
|
||||||
|
require(limit > 0)
|
||||||
|
val result = ArrayList<NotificationLogEntry>(limit)
|
||||||
|
var skipped = 0
|
||||||
|
for (chunk in chunkFiles().asReversed()) {
|
||||||
|
readChunk(chunk).asReversed().forEach { entry ->
|
||||||
|
if (skipped < offset) {
|
||||||
|
skipped++
|
||||||
|
} else if (result.size < limit) {
|
||||||
|
result += entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.size == limit) break
|
||||||
|
}
|
||||||
|
result.sortedByDescending { it.recordedAtEpochMillis }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun append(entry: NotificationLogEntry) = synchronized(lock) {
|
fun append(entry: NotificationLogEntry) = synchronized(lock) {
|
||||||
val entries = (readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: emptyList())
|
val chunks = chunkFiles()
|
||||||
val retained = retain(entries + entry)
|
val newest = chunks.lastOrNull()
|
||||||
write(retained)
|
val current = newest?.let(::readChunk).orEmpty()
|
||||||
(entries.mapNotNull { it.imageId } - retained.mapNotNull { it.imageId }.toSet()).forEach(::deleteImage)
|
if (newest == null || encodedSize(current + entry) > MAX_CHUNK_PLAINTEXT_BYTES) {
|
||||||
|
writeChunk(nextChunkFile(chunks), listOf(entry))
|
||||||
|
} else {
|
||||||
|
writeChunk(newest, current + entry)
|
||||||
|
}
|
||||||
|
enforceLimits()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun replaceAll(entries: List<NotificationLogEntry>) = synchronized(lock) {
|
fun replaceAll(entries: List<NotificationLogEntry>) = synchronized(lock) {
|
||||||
val existing = readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: emptyList()
|
clearChunksOnly()
|
||||||
write(entries)
|
entries.chunked(MAX_ENTRIES_PER_CHUNK).forEachIndexed { index, chunk ->
|
||||||
removeUnreferencedImages(entries, existing)
|
writeChunk(chunkFile(index.toLong()), chunk)
|
||||||
|
}
|
||||||
|
enforceLimits()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() = synchronized(lock) {
|
fun clear() = synchronized(lock) {
|
||||||
file.delete()
|
clearChunksOnly()
|
||||||
imageDirectory.listFiles()?.forEach(File::delete)
|
imageDirectory.listFiles()?.forEach(File::delete)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun delete(id: String): Boolean = synchronized(lock) {
|
fun delete(id: String): Boolean = synchronized(lock) {
|
||||||
val entries = readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: return false
|
chunkFiles().forEach { chunk ->
|
||||||
val entry = entries.firstOrNull { it.id == id } ?: return false
|
val entries = readChunk(chunk)
|
||||||
write(entries.filterNot { it.id == id })
|
val entry = entries.firstOrNull { it.id == id } ?: return@forEach
|
||||||
entry.imageId?.let(::deleteImage)
|
val retained = entries.filterNot { it.id == id }
|
||||||
true
|
if (retained.isEmpty()) chunk.delete() else writeChunk(chunk, retained)
|
||||||
|
entry.imageId?.let(::deleteImage)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Removes failed-write remnants and images no remaining log entry references. */
|
/** Removes failed-write remnants and images no current log entry references. */
|
||||||
fun removeOrphanedImages() = synchronized(lock) {
|
fun removeOrphanedImages() = synchronized(lock) {
|
||||||
val entries = readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: emptyList()
|
if (imageDirectory.listFiles().isNullOrEmpty()) return
|
||||||
removeUnreferencedImages(entries)
|
val referenced = mutableSetOf<String>()
|
||||||
|
chunkFiles().forEach { chunk ->
|
||||||
|
readChunk(chunk).mapNotNullTo(referenced) { it.imageId }
|
||||||
|
}
|
||||||
|
removeUnreferencedImages(referenced)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun write(entries: List<NotificationLogEntry>) {
|
fun enforceLimits() = synchronized(lock) {
|
||||||
val payload = cipher.encrypt(NotificationLogEntryJson.encode(entries))
|
val limits = StorageLimitsStore(appContext).load()
|
||||||
val output = file.startWrite()
|
trimLogBytes(limits.logBytes)
|
||||||
try {
|
trimImageBytes(limits.imageBytes)
|
||||||
val stream = DataOutputStream(BufferedOutputStream(output))
|
}
|
||||||
stream.writeInt(FILE_VERSION)
|
|
||||||
stream.writeInt(payload.initializationVector.size)
|
private fun trimLogBytes(limit: Long) {
|
||||||
stream.write(payload.initializationVector)
|
var chunks = chunkFiles()
|
||||||
stream.writeInt(payload.cipherText.size)
|
var totalBytes = chunks.sumOf(File::length)
|
||||||
stream.write(payload.cipherText)
|
while (totalBytes > limit && chunks.isNotEmpty()) {
|
||||||
stream.flush()
|
val oldest = chunks.first()
|
||||||
file.finishWrite(output)
|
val entries = readChunk(oldest)
|
||||||
} catch (error: Exception) {
|
if (entries.isEmpty()) {
|
||||||
file.failWrite(output)
|
oldest.delete()
|
||||||
throw error
|
} else if (chunks.size > 1) {
|
||||||
|
entries.mapNotNull { it.imageId }.forEach(::deleteImage)
|
||||||
|
oldest.delete()
|
||||||
|
} else {
|
||||||
|
var retained = entries
|
||||||
|
while (totalBytes > limit && retained.isNotEmpty()) {
|
||||||
|
retained.first().imageId?.let(::deleteImage)
|
||||||
|
retained = retained.drop(1)
|
||||||
|
if (retained.isEmpty()) oldest.delete() else writeChunk(oldest, retained)
|
||||||
|
totalBytes = chunkFiles().sumOf(File::length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chunks = chunkFiles()
|
||||||
|
totalBytes = chunks.sumOf(File::length)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun retain(entries: List<NotificationLogEntry>): List<NotificationLogEntry> {
|
private fun trimImageBytes(limit: Long) {
|
||||||
var retained = entries.takeLast(MAX_ENTRIES)
|
var imageBytes = imageDirectory.listFiles()?.filter(File::isFile)?.sumOf(File::length) ?: 0L
|
||||||
while (retained.size > 1 && NotificationLogEntryJson.encode(retained).size > MAX_PLAINTEXT_BYTES) {
|
if (imageBytes <= limit) return
|
||||||
retained = retained.drop(1)
|
chunkFiles().forEach { chunk ->
|
||||||
|
if (imageBytes <= limit) return@forEach
|
||||||
|
val entries = readChunk(chunk)
|
||||||
|
var changed = false
|
||||||
|
val retained = entries.map { entry ->
|
||||||
|
if (imageBytes > limit && entry.imageId != null) {
|
||||||
|
val image = File(imageDirectory, "${entry.imageId}.bin")
|
||||||
|
imageBytes -= image.length()
|
||||||
|
image.delete()
|
||||||
|
changed = true
|
||||||
|
entry.copy(imageId = null)
|
||||||
|
} else {
|
||||||
|
entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) writeChunk(chunk, retained)
|
||||||
}
|
}
|
||||||
require(NotificationLogEntryJson.encode(retained).size <= MAX_PLAINTEXT_BYTES) {
|
|
||||||
"A notification event exceeds the encrypted log retention limit."
|
|
||||||
}
|
|
||||||
return retained
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteImage(id: String) {
|
private fun removeUnreferencedImages(referenced: Set<String>) {
|
||||||
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 ->
|
imageDirectory.listFiles()?.forEach { image ->
|
||||||
val imageId = image.name.removeSuffix(".bin")
|
val imageId = image.name.removeSuffix(".bin")
|
||||||
if (image.name.endsWith(".new") ||
|
if (image.name.endsWith(".new") ||
|
||||||
@@ -110,17 +161,48 @@ class EncryptedNotificationLogStore(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readPayloadOrNull(): EncryptedPayload? {
|
private fun chunkFiles(): List<File> = directory.listFiles()
|
||||||
|
?.filter { it.isFile && it.name.startsWith(CHUNK_PREFIX) && it.name.endsWith(CHUNK_SUFFIX) }
|
||||||
|
?.sortedBy { it.name }
|
||||||
|
?: emptyList()
|
||||||
|
|
||||||
|
private fun nextChunkFile(chunks: List<File>): File {
|
||||||
|
val next = chunks.lastOrNull()?.name?.removePrefix(CHUNK_PREFIX)?.removeSuffix(CHUNK_SUFFIX)?.toLongOrNull()?.plus(1) ?: 0L
|
||||||
|
return chunkFile(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chunkFile(sequence: Long): File = File(directory, "$CHUNK_PREFIX${sequence.toString().padStart(CHUNK_NAME_WIDTH, '0')}$CHUNK_SUFFIX")
|
||||||
|
|
||||||
|
private fun readChunk(file: File): List<NotificationLogEntry> {
|
||||||
val input = try {
|
val input = try {
|
||||||
file.openRead()
|
AtomicFile(file).openRead()
|
||||||
} catch (_: FileNotFoundException) {
|
} catch (_: FileNotFoundException) {
|
||||||
return null
|
return emptyList()
|
||||||
}
|
}
|
||||||
DataInputStream(BufferedInputStream(input)).use { stream ->
|
DataInputStream(BufferedInputStream(input)).use { stream ->
|
||||||
require(stream.readInt() == FILE_VERSION) { "Unsupported encrypted notification-log version." }
|
require(stream.readInt() == FILE_VERSION) { "Unsupported encrypted notification-log chunk." }
|
||||||
val initializationVector = readByteArray(stream, MAX_IV_BYTES)
|
val initializationVector = readByteArray(stream, MAX_IV_BYTES)
|
||||||
val cipherText = readByteArray(stream, MAX_CIPHER_TEXT_BYTES)
|
val cipherText = readByteArray(stream, MAX_CHUNK_CIPHER_TEXT_BYTES)
|
||||||
return EncryptedPayload(initializationVector, cipherText)
|
return NotificationLogEntryJson.decode(cipher.decrypt(EncryptedPayload(initializationVector, cipherText)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeChunk(file: File, entries: List<NotificationLogEntry>) {
|
||||||
|
val payload = cipher.encrypt(NotificationLogEntryJson.encode(entries))
|
||||||
|
val atomicFile = AtomicFile(file)
|
||||||
|
val output = atomicFile.startWrite()
|
||||||
|
try {
|
||||||
|
val stream = DataOutputStream(BufferedOutputStream(output))
|
||||||
|
stream.writeInt(FILE_VERSION)
|
||||||
|
stream.writeInt(payload.initializationVector.size)
|
||||||
|
stream.write(payload.initializationVector)
|
||||||
|
stream.writeInt(payload.cipherText.size)
|
||||||
|
stream.write(payload.cipherText)
|
||||||
|
stream.flush()
|
||||||
|
atomicFile.finishWrite(output)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
atomicFile.failWrite(output)
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,14 +212,29 @@ class EncryptedNotificationLogStore(context: Context) {
|
|||||||
return ByteArray(length).also(stream::readFully)
|
return ByteArray(length).also(stream::readFully)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun encodedSize(entries: List<NotificationLogEntry>) = NotificationLogEntryJson.encode(entries).size
|
||||||
|
|
||||||
|
private fun clearChunksOnly() {
|
||||||
|
chunkFiles().forEach(File::delete)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteImage(id: String) {
|
||||||
|
if (id.matches(IMAGE_ID_PATTERN)) File(imageDirectory, "$id.bin").delete()
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val FILE_NAME = "notification-log.v1"
|
val lock = Any()
|
||||||
|
const val DIRECTORY_NAME = "notification-log"
|
||||||
|
const val LEGACY_FILE_NAME = "notification-log.v1"
|
||||||
|
const val IMAGE_DIRECTORY_NAME = "notification-images"
|
||||||
|
const val CHUNK_PREFIX = "chunk-"
|
||||||
|
const val CHUNK_SUFFIX = ".bin"
|
||||||
|
const val CHUNK_NAME_WIDTH = 12
|
||||||
const val FILE_VERSION = 1
|
const val FILE_VERSION = 1
|
||||||
const val MAX_IV_BYTES = 32
|
const val MAX_IV_BYTES = 32
|
||||||
const val MAX_CIPHER_TEXT_BYTES = 50 * 1024 * 1024
|
const val MAX_CHUNK_CIPHER_TEXT_BYTES = 1024 * 1024
|
||||||
const val MAX_PLAINTEXT_BYTES = 20 * 1024 * 1024
|
const val MAX_CHUNK_PLAINTEXT_BYTES = 256 * 1024
|
||||||
const val MAX_ENTRIES = 10_000
|
const val MAX_ENTRIES_PER_CHUNK = 500
|
||||||
const val IMAGE_DIRECTORY_NAME = "notification-images"
|
|
||||||
val IMAGE_ID_PATTERN = Regex("[0-9a-f-]{36}")
|
val IMAGE_ID_PATTERN = Regex("[0-9a-f-]{36}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package se.ajpanton.notificationlog.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
|
|
||||||
|
data class StorageLimits(
|
||||||
|
val logBytes: Long = 5 * 1024L * 1024L,
|
||||||
|
val imageBytes: Long = 100 * 1024L * 1024L,
|
||||||
|
)
|
||||||
|
|
||||||
|
class StorageLimitsStore(context: Context) {
|
||||||
|
private val preferences = context.applicationContext.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun load() = StorageLimits(
|
||||||
|
logBytes = preferences.getLong(LOG_BYTES, StorageLimits().logBytes),
|
||||||
|
imageBytes = preferences.getLong(IMAGE_BYTES, StorageLimits().imageBytes),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun save(limits: StorageLimits) = preferences.edit {
|
||||||
|
putLong(LOG_BYTES, limits.logBytes)
|
||||||
|
putLong(IMAGE_BYTES, limits.imageBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MEBIBYTE = 1024L * 1024L
|
||||||
|
const val FILE_NAME = "storage-limits"
|
||||||
|
const val LOG_BYTES = "log_bytes"
|
||||||
|
const val IMAGE_BYTES = "image_bytes"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,5 +33,31 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="24dp"
|
android:layout_marginTop="24dp"
|
||||||
android:text="Lock app with phone unlock" />
|
android:text="Lock app with phone unlock" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:text="Storage limits" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/log_limit_mib"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="Log storage limit (MiB)"
|
||||||
|
android:inputType="number" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/image_limit_mib"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="Image storage limit (MiB)"
|
||||||
|
android:inputType="number" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/save_storage_limits"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Save storage limits" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
Reference in New Issue
Block a user