diff --git a/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt index 78553c3..da55e7f 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt @@ -74,7 +74,7 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) { } setupCopyControls() setupEventMasterToggles() - binding!!.exportLogs.setOnClickListener { chooseExportFormat() } + binding!!.exportLogs.setOnClickListener { confirmExport() } binding!!.clearLogs.setOnClickListener { confirmClearLogs() } val lockStore = AppLockStore(requireContext()) binding!!.appLock.isChecked = lockStore.enabled @@ -177,6 +177,15 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) { .show() } + private fun confirmExport() { + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Export unencrypted logs?") + .setMessage("The exported file will not be encrypted. Anyone with access to its destination can read the selected log fields.") + .setNegativeButton("Cancel", null) + .setPositiveButton("Continue") { _, _ -> chooseExportFormat() } + .show() + } + private fun writeExport(uri: Uri) { val format = pendingExport ?: return val context = requireContext().applicationContext diff --git a/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt index 7d6a3fd..935ae64 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt @@ -1,115 +1,191 @@ package se.ajpanton.notificationlog -import android.os.Bundle import android.content.ComponentName import android.content.Intent +import android.graphics.BitmapFactory import android.net.Uri -import android.text.TextUtils +import android.os.Bundle import android.provider.Settings +import android.text.TextUtils import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout import android.widget.TextView import androidx.fragment.app.Fragment import com.google.android.material.dialog.MaterialAlertDialogBuilder -import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore +import se.ajpanton.notificationlog.capture.NotificationCaptureService import se.ajpanton.notificationlog.data.EncryptedImageStore +import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore import se.ajpanton.notificationlog.databinding.FragmentViewLogsBinding +import se.ajpanton.notificationlog.export.LogExporter +import se.ajpanton.notificationlog.model.NotificationAction import se.ajpanton.notificationlog.model.NotificationLogEntry import se.ajpanton.notificationlog.settings.LogField +import se.ajpanton.notificationlog.settings.LogViewSettings import se.ajpanton.notificationlog.settings.LogViewSettingsStore import se.ajpanton.notificationlog.settings.TimestampZone -import se.ajpanton.notificationlog.capture.NotificationCaptureService -import se.ajpanton.notificationlog.export.LogExporter -import java.util.zip.ZipOutputStream import java.text.DateFormat import java.util.Date +import java.util.zip.ZipOutputStream class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { private var binding: FragmentViewLogsBinding? = null private var pendingExport: ExportFormat? = null - private val createDocument = registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> - uri?.let(::writeExport) - } + private val expandedIds = mutableSetOf() + private val createDocument = registerForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.CreateDocument("application/octet-stream"), + ) { uri -> uri?.let(::writeExport) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding = FragmentViewLogsBinding.bind(view) - binding!!.notificationAccess.setOnClickListener { - startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) - } - binding!!.exportLogs.setOnClickListener { chooseExportFormat() } - binding!!.clearLogs.setOnClickListener { - MaterialAlertDialogBuilder(requireContext()) - .setTitle("Clear logs?") - .setMessage("This permanently removes every stored notification log.") - .setNegativeButton("Cancel", null) - .setPositiveButton("Clear") { _, _ -> - EncryptedNotificationLogStore(requireContext()).clear() - loadLogs() - } - .show() - } + binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) } + binding!!.exportLogs.setOnClickListener { confirmExport() } + binding!!.clearLogs.setOnClickListener { confirmClearLogs() } loadLogs() } override fun onResume() { super.onResume() - binding?.notificationAccess?.text = if (isListenerEnabled()) { - "Notification access enabled" - } else { - "Enable notification access" - } + binding?.notificationAccess?.text = if (isListenerEnabled()) "Notification access enabled" else "Enable notification access" } - override fun onDestroyView() { binding = null; super.onDestroyView() } + override fun onDestroyView() { + binding = null + super.onDestroyView() + } private fun loadLogs() { val context = requireContext().applicationContext Thread { - val entries = EncryptedNotificationLogStore(context).readAll().sortedByDescending { it.recordedAtEpochMillis } - activity?.runOnUiThread { binding?.let { render(it, entries) } } + val imageStore = EncryptedImageStore(context) + val rows = EncryptedNotificationLogStore(context).readAll() + .sortedByDescending { it.recordedAtEpochMillis } + .map { entry -> LogRow(entry, entry.imageId?.let(imageStore::read)) } + activity?.runOnUiThread { binding?.let { render(it, rows) } } }.start() } - private fun render(view: FragmentViewLogsBinding, entries: List) { + private fun render(view: FragmentViewLogsBinding, rows: List) { view.logRows.removeAllViews() - view.emptyView.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE + view.emptyView.visibility = if (rows.isEmpty()) View.VISIBLE else View.GONE val settings = LogViewSettingsStore(requireContext()).load() - entries.forEach { entry -> - view.logRows.addView(TextView(requireContext()).apply { - text = settings.order.filter { it in settings.visibleFields }.mapNotNull { field -> when (field) { - LogField.TIMESTAMP -> timestamp(entry.recordedAtEpochMillis, settings.timestampZone, entry.eventTimeZoneId) - LogField.APP_NAME -> entry.appName - LogField.PACKAGE_NAME -> entry.packageName - LogField.ACTION -> entry.action.name.lowercase().replace('_', ' ') - LogField.CONTENTS -> entry.contents - } }.joinToString(" · ") - setPadding(0, 12, 0, 12) - maxLines = 1 - ellipsize = TextUtils.TruncateAt.END - setOnClickListener { - val expanded = maxLines != 1 - maxLines = if (expanded) 1 else Int.MAX_VALUE - ellipsize = if (expanded) TextUtils.TruncateAt.END else null + rows.forEach { row -> view.logRows.addView(logRowView(row, settings)) } + } + + private fun logRowView(row: LogRow, settings: LogViewSettings): View = LinearLayout(requireContext()).apply { + orientation = LinearLayout.VERTICAL + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { + bottomMargin = dp(12) + } + isClickable = true + isFocusable = true + + fun renderExpanded(expanded: Boolean) { + removeAllViews() + if (!expanded) { + addView(TextView(context).apply { + text = values(row.entry, settings).joinToString(" · ") + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + setPadding(0, dp(8), 0, dp(8)) + }) + return + } + settings.order.filter { it in settings.visibleFields }.forEach { field -> + val value = fieldValue(field, row.entry, settings).takeUnless { field == LogField.CONTENTS && it == "[image]" } + if (!value.isNullOrEmpty()) { + addView(TextView(context).apply { + text = value + setLineSpacing(0f, 0.92f) + setPadding(0, dp(1), 0, dp(1)) + }) } - }) + } + row.imageBytes?.let { bytes -> + BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { bitmap -> + addView(ImageView(context).apply { + setImageBitmap(bitmap) + adjustViewBounds = true + maxHeight = dp(240) + contentDescription = "Notification image" + setPadding(0, dp(4), 0, dp(4)) + }) + } + } + } + + renderExpanded(row.entry.id in expandedIds) + setOnClickListener { + if (!expandedIds.add(row.entry.id)) expandedIds.remove(row.entry.id) + renderExpanded(row.entry.id in expandedIds) + } + setOnLongClickListener { + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Delete this log?") + .setMessage("This permanently removes this log and its copied image, if any.") + .setNegativeButton("Cancel", null) + .setPositiveButton("Delete") { _, _ -> + Thread { + EncryptedNotificationLogStore(requireContext().applicationContext).delete(row.entry.id) + activity?.runOnUiThread(::loadLogs) + }.start() + } + .show() + true } } - private fun timestamp(value: Long, zone: TimestampZone, eventTimeZoneId: String?): String { - val format = DateFormat.getDateTimeInstance() - format.timeZone = when (zone) { + private fun values(entry: NotificationLogEntry, settings: LogViewSettings): List = + settings.order.filter { it in settings.visibleFields }.mapNotNull { fieldValue(it, entry, settings) } + + private fun fieldValue(field: LogField, entry: NotificationLogEntry, settings: LogViewSettings): String? = when (field) { + LogField.TIMESTAMP -> timestamp(entry.recordedAtEpochMillis, settings.timestampZone, entry.eventTimeZoneId) + LogField.APP_NAME -> entry.appName + LogField.PACKAGE_NAME -> entry.packageName + LogField.ACTION -> actionLabel(entry.action) + LogField.CONTENTS -> entry.contents + } + + private fun actionLabel(action: NotificationAction): String = when (action) { + NotificationAction.APP_CANCELLED -> "App cancelled notification" + NotificationAction.APP_CANCELLED_ALL -> "App cancelled all notifications" + NotificationAction.USER_DISMISSED -> "User dismissed notification" + NotificationAction.USER_DISMISSED_ALL -> "User dismissed all notifications" + else -> action.name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase) + } + + private fun timestamp(value: Long, zone: TimestampZone, eventTimeZoneId: String?): String = DateFormat.getDateTimeInstance().apply { + timeZone = when (zone) { TimestampZone.UTC -> java.util.TimeZone.getTimeZone("UTC") TimestampZone.LOCAL_NOW -> java.util.TimeZone.getDefault() TimestampZone.EVENT_LOCAL -> eventTimeZoneId?.let(java.util.TimeZone::getTimeZone) ?: java.util.TimeZone.getDefault() } - return format.format(Date(value)) + }.format(Date(value)) + + private fun isListenerEnabled(): Boolean = requireContext().getSystemService(android.app.NotificationManager::class.java) + .isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java)) + + private fun confirmClearLogs() { + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Clear logs?") + .setMessage("This permanently removes every stored notification log and copied image.") + .setNegativeButton("Cancel", null) + .setPositiveButton("Clear") { _, _ -> + EncryptedNotificationLogStore(requireContext()).clear() + expandedIds.clear() + loadLogs() + } + .show() } - private fun isListenerEnabled(): Boolean { - val manager = requireContext().getSystemService(android.app.NotificationManager::class.java) - return manager.isNotificationListenerAccessGranted( - ComponentName(requireContext(), NotificationCaptureService::class.java), - ) + private fun confirmExport() { + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Export unencrypted logs?") + .setMessage("The exported file will not be encrypted. Anyone with access to its destination can read the selected log fields.") + .setNegativeButton("Cancel", null) + .setPositiveButton("Continue") { _, _ -> chooseExportFormat() } + .show() } private fun chooseExportFormat() { @@ -117,7 +193,8 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { .setItems(arrayOf("CSV", "Formatted text", "HTML ZIP")) { _, which -> pendingExport = ExportFormat.entries[which] createDocument.launch("notification-log.${pendingExport!!.extension}") - }.show() + } + .show() } private fun writeExport(uri: Uri) { @@ -126,29 +203,37 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { Thread { val entries = EncryptedNotificationLogStore(context).readAll() val settings = LogViewSettingsStore(context).load() - context.contentResolver.openOutputStream(uri)?.use { output -> when (format) { - ExportFormat.CSV -> output.write(LogExporter.csv(entries, settings).encodeToByteArray()) - ExportFormat.FORMATTED -> output.write(LogExporter.formatted(entries, settings).encodeToByteArray()) - ExportFormat.HTML_ZIP -> ZipOutputStream(output).use { zip -> - zip.putNextEntry(java.util.zip.ZipEntry("notification-log.html")) - val imageStore = EncryptedImageStore(context) - val imageEntries = entries.filter { it.imageId != null && LogField.CONTENTS in settings.visibleFields } - val exportedImages = imageEntries.mapNotNull { entry -> - imageStore.read(entry.imageId!!)?.let { entry.imageId to it } - }.toMap() - zip.write(LogExporter.html(entries, settings) { entry -> - entry.imageId?.takeIf(exportedImages::containsKey)?.let { "images/$it.png" } - }.encodeToByteArray()) - zip.closeEntry() - exportedImages.forEach { (imageId, image) -> - zip.putNextEntry(java.util.zip.ZipEntry("images/$imageId.png")) - zip.write(image) - zip.closeEntry() - } + context.contentResolver.openOutputStream(uri)?.use { output -> + when (format) { + ExportFormat.CSV -> output.write(LogExporter.csv(entries, settings).encodeToByteArray()) + ExportFormat.FORMATTED -> output.write(LogExporter.formatted(entries, settings).encodeToByteArray()) + ExportFormat.HTML_ZIP -> writeHtmlExport(output, entries, settings, context) } - } } + } }.start() } + private fun writeHtmlExport(output: java.io.OutputStream, entries: List, settings: LogViewSettings, context: android.content.Context) { + ZipOutputStream(output).use { zip -> + val imageStore = EncryptedImageStore(context) + val exportedImages = entries.filter { it.imageId != null && LogField.CONTENTS in settings.visibleFields } + .mapNotNull { entry -> imageStore.read(entry.imageId!!)?.let { entry.imageId to it } } + .toMap() + zip.putNextEntry(java.util.zip.ZipEntry("notification-log.html")) + zip.write(LogExporter.html(entries, settings) { entry -> + entry.imageId?.takeIf(exportedImages::containsKey)?.let { "images/$it.png" } + }.encodeToByteArray()) + zip.closeEntry() + exportedImages.forEach { (imageId, image) -> + zip.putNextEntry(java.util.zip.ZipEntry("images/$imageId.png")) + zip.write(image) + zip.closeEntry() + } + } + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + + private data class LogRow(val entry: NotificationLogEntry, val imageBytes: ByteArray?) private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") } } diff --git a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt index d9ade2e..debe70f 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt @@ -91,10 +91,17 @@ class NotificationCaptureService : NotificationListenerService() { writeExecutor.execute { try { if (retainImage) { - imageStore.save(entry.imageId!!, snapshot.imageBytes!!) + try { + imageStore.save(entry.imageId!!, snapshot.imageBytes!!) + } catch (error: Exception) { + Log.w(TAG, "Could not retain notification image; keeping the text event", error) + logStore.append(entry.copy(imageId = null)) + return@execute + } } logStore.append(entry) } catch (error: Exception) { + entry.imageId?.let(imageStore::delete) Log.e(TAG, "Could not persist notification log entry", error) } } @@ -107,7 +114,10 @@ class NotificationCaptureService : NotificationListenerService() { val image = snapshot.hasImage && NotificationRuleEvaluator.allows( ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName, ) - return listOfNotNull(text, if (image) "[image]" else null).joinToString(" — ").ifEmpty { null } + return listOfNotNull(text, if (image) "[image]" else null) + .joinToString(" — ") + .take(MAX_CONTENT_CHARACTERS) + .ifEmpty { null } } private fun appName(packageName: String): String = try { @@ -147,5 +157,6 @@ class NotificationCaptureService : NotificationListenerService() { private companion object { const val TAG = "NotificationCapture" + const val MAX_CONTENT_CHARACTERS = 16_000 } } diff --git a/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedImageStore.kt b/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedImageStore.kt index 7254ddd..1678231 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedImageStore.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedImageStore.kt @@ -1,6 +1,7 @@ package se.ajpanton.notificationlog.data import android.content.Context +import android.util.AtomicFile import java.io.File import java.io.DataInputStream import java.io.FileNotFoundException @@ -12,11 +13,19 @@ class EncryptedImageStore(context: Context) { fun save(id: String, bytes: ByteArray) { require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." } + require(bytes.size <= MAX_IMAGE_BYTES) { "Notification image exceeds the 5 MiB retention limit." } val payload = cipher.encrypt(bytes) - File(directory, "$id.bin").outputStream().use { output -> + val file = AtomicFile(File(directory, "$id.bin")) + val output = file.startWrite() + try { output.write(payload.initializationVector.size) output.write(payload.initializationVector) output.write(payload.cipherText) + output.flush() + file.finishWrite(output) + } catch (error: Exception) { + file.failWrite(output) + throw error } } @@ -38,7 +47,13 @@ class EncryptedImageStore(context: Context) { } } + fun delete(id: String) { + require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." } + File(directory, "$id.bin").delete() + } + private companion object { val IMAGE_ID_PATTERN = Regex("[0-9a-f-]{36}") + const val MAX_IMAGE_BYTES = 5 * 1024 * 1024 } } diff --git a/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedNotificationLogStore.kt b/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedNotificationLogStore.kt index e598ca7..6aa850a 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedNotificationLogStore.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedNotificationLogStore.kt @@ -27,7 +27,9 @@ class EncryptedNotificationLogStore(context: Context) { fun append(entry: NotificationLogEntry) = synchronized(lock) { val entries = (readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: emptyList()) - write(entries + entry) + val retained = retain(entries + entry) + write(retained) + (entries.mapNotNull { it.imageId } - retained.mapNotNull { it.imageId }.toSet()).forEach(::deleteImage) } fun replaceAll(entries: List) = synchronized(lock) { @@ -39,6 +41,14 @@ class EncryptedNotificationLogStore(context: Context) { imageDirectory.listFiles()?.forEach(File::delete) } + fun delete(id: String): Boolean = synchronized(lock) { + val entries = readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: return false + val entry = entries.firstOrNull { it.id == id } ?: return false + write(entries.filterNot { it.id == id }) + entry.imageId?.let(::deleteImage) + true + } + private fun write(entries: List) { val payload = cipher.encrypt(NotificationLogEntryJson.encode(entries)) val output = file.startWrite() @@ -57,6 +67,21 @@ class EncryptedNotificationLogStore(context: Context) { } } + private fun retain(entries: List): List { + var retained = entries.takeLast(MAX_ENTRIES) + while (retained.size > 1 && NotificationLogEntryJson.encode(retained).size > MAX_PLAINTEXT_BYTES) { + retained = retained.drop(1) + } + require(NotificationLogEntryJson.encode(retained).size <= MAX_PLAINTEXT_BYTES) { + "A notification event exceeds the encrypted log retention limit." + } + return retained + } + + private fun deleteImage(id: String) { + if (id.matches(IMAGE_ID_PATTERN)) File(imageDirectory, "$id.bin").delete() + } + private fun readPayloadOrNull(): EncryptedPayload? { val input = try { file.openRead() @@ -82,6 +107,9 @@ class EncryptedNotificationLogStore(context: Context) { const val FILE_VERSION = 1 const val MAX_IV_BYTES = 32 const val MAX_CIPHER_TEXT_BYTES = 50 * 1024 * 1024 + const val MAX_PLAINTEXT_BYTES = 20 * 1024 * 1024 + const val MAX_ENTRIES = 10_000 const val IMAGE_DIRECTORY_NAME = "notification-images" + val IMAGE_ID_PATTERN = Regex("[0-9a-f-]{36}") } } diff --git a/app/src/main/res/layout/fragment_view_logs.xml b/app/src/main/res/layout/fragment_view_logs.xml index c574a2a..ea9c3a3 100644 --- a/app/src/main/res/layout/fragment_view_logs.xml +++ b/app/src/main/res/layout/fragment_view_logs.xml @@ -1,10 +1,61 @@ - -