Complete secure log viewing and retention
This commit is contained in:
@@ -74,7 +74,7 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
|
|||||||
}
|
}
|
||||||
setupCopyControls()
|
setupCopyControls()
|
||||||
setupEventMasterToggles()
|
setupEventMasterToggles()
|
||||||
binding!!.exportLogs.setOnClickListener { chooseExportFormat() }
|
binding!!.exportLogs.setOnClickListener { confirmExport() }
|
||||||
binding!!.clearLogs.setOnClickListener { confirmClearLogs() }
|
binding!!.clearLogs.setOnClickListener { confirmClearLogs() }
|
||||||
val lockStore = AppLockStore(requireContext())
|
val lockStore = AppLockStore(requireContext())
|
||||||
binding!!.appLock.isChecked = lockStore.enabled
|
binding!!.appLock.isChecked = lockStore.enabled
|
||||||
@@ -177,6 +177,15 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
|
|||||||
.show()
|
.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) {
|
private fun writeExport(uri: Uri) {
|
||||||
val format = pendingExport ?: return
|
val format = pendingExport ?: return
|
||||||
val context = requireContext().applicationContext
|
val context = requireContext().applicationContext
|
||||||
|
|||||||
@@ -1,115 +1,191 @@
|
|||||||
package se.ajpanton.notificationlog
|
package se.ajpanton.notificationlog
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.text.TextUtils
|
import android.os.Bundle
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import android.text.TextUtils
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
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.EncryptedImageStore
|
||||||
|
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
|
||||||
import se.ajpanton.notificationlog.databinding.FragmentViewLogsBinding
|
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.model.NotificationLogEntry
|
||||||
import se.ajpanton.notificationlog.settings.LogField
|
import se.ajpanton.notificationlog.settings.LogField
|
||||||
|
import se.ajpanton.notificationlog.settings.LogViewSettings
|
||||||
import se.ajpanton.notificationlog.settings.LogViewSettingsStore
|
import se.ajpanton.notificationlog.settings.LogViewSettingsStore
|
||||||
import se.ajpanton.notificationlog.settings.TimestampZone
|
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.text.DateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
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 var pendingExport: ExportFormat? = null
|
private var pendingExport: ExportFormat? = null
|
||||||
private val createDocument = registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
|
private val expandedIds = mutableSetOf<String>()
|
||||||
uri?.let(::writeExport)
|
private val createDocument = registerForActivityResult(
|
||||||
}
|
androidx.activity.result.contract.ActivityResultContracts.CreateDocument("application/octet-stream"),
|
||||||
|
) { uri -> uri?.let(::writeExport) }
|
||||||
|
|
||||||
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!!.notificationAccess.setOnClickListener {
|
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) }
|
||||||
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
|
binding!!.exportLogs.setOnClickListener { confirmExport() }
|
||||||
}
|
binding!!.clearLogs.setOnClickListener { confirmClearLogs() }
|
||||||
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()
|
|
||||||
}
|
|
||||||
loadLogs()
|
loadLogs()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
binding?.notificationAccess?.text = if (isListenerEnabled()) {
|
binding?.notificationAccess?.text = if (isListenerEnabled()) "Notification access enabled" else "Enable notification access"
|
||||||
"Notification access enabled"
|
|
||||||
} else {
|
|
||||||
"Enable notification access"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() { binding = null; super.onDestroyView() }
|
override fun onDestroyView() {
|
||||||
|
binding = null
|
||||||
|
super.onDestroyView()
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadLogs() {
|
private fun loadLogs() {
|
||||||
val context = requireContext().applicationContext
|
val context = requireContext().applicationContext
|
||||||
Thread {
|
Thread {
|
||||||
val entries = EncryptedNotificationLogStore(context).readAll().sortedByDescending { it.recordedAtEpochMillis }
|
val imageStore = EncryptedImageStore(context)
|
||||||
activity?.runOnUiThread { binding?.let { render(it, entries) } }
|
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()
|
}.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun render(view: FragmentViewLogsBinding, entries: List<NotificationLogEntry>) {
|
private fun render(view: FragmentViewLogsBinding, rows: List<LogRow>) {
|
||||||
view.logRows.removeAllViews()
|
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()
|
val settings = LogViewSettingsStore(requireContext()).load()
|
||||||
entries.forEach { entry ->
|
rows.forEach { row -> view.logRows.addView(logRowView(row, settings)) }
|
||||||
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)
|
private fun logRowView(row: LogRow, settings: LogViewSettings): View = LinearLayout(requireContext()).apply {
|
||||||
LogField.APP_NAME -> entry.appName
|
orientation = LinearLayout.VERTICAL
|
||||||
LogField.PACKAGE_NAME -> entry.packageName
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||||
LogField.ACTION -> entry.action.name.lowercase().replace('_', ' ')
|
bottomMargin = dp(12)
|
||||||
LogField.CONTENTS -> entry.contents
|
}
|
||||||
} }.joinToString(" · ")
|
isClickable = true
|
||||||
setPadding(0, 12, 0, 12)
|
isFocusable = true
|
||||||
|
|
||||||
|
fun renderExpanded(expanded: Boolean) {
|
||||||
|
removeAllViews()
|
||||||
|
if (!expanded) {
|
||||||
|
addView(TextView(context).apply {
|
||||||
|
text = values(row.entry, settings).joinToString(" · ")
|
||||||
maxLines = 1
|
maxLines = 1
|
||||||
ellipsize = TextUtils.TruncateAt.END
|
ellipsize = TextUtils.TruncateAt.END
|
||||||
setOnClickListener {
|
setPadding(0, dp(8), 0, dp(8))
|
||||||
val expanded = maxLines != 1
|
})
|
||||||
maxLines = if (expanded) 1 else Int.MAX_VALUE
|
return
|
||||||
ellipsize = if (expanded) TextUtils.TruncateAt.END else null
|
|
||||||
}
|
}
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun timestamp(value: Long, zone: TimestampZone, eventTimeZoneId: String?): String {
|
renderExpanded(row.entry.id in expandedIds)
|
||||||
val format = DateFormat.getDateTimeInstance()
|
setOnClickListener {
|
||||||
format.timeZone = when (zone) {
|
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 values(entry: NotificationLogEntry, settings: LogViewSettings): List<String> =
|
||||||
|
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.UTC -> java.util.TimeZone.getTimeZone("UTC")
|
||||||
TimestampZone.LOCAL_NOW -> java.util.TimeZone.getDefault()
|
TimestampZone.LOCAL_NOW -> java.util.TimeZone.getDefault()
|
||||||
TimestampZone.EVENT_LOCAL -> eventTimeZoneId?.let(java.util.TimeZone::getTimeZone) ?: 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 {
|
private fun confirmExport() {
|
||||||
val manager = requireContext().getSystemService(android.app.NotificationManager::class.java)
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
return manager.isNotificationListenerAccessGranted(
|
.setTitle("Export unencrypted logs?")
|
||||||
ComponentName(requireContext(), NotificationCaptureService::class.java),
|
.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() {
|
private fun chooseExportFormat() {
|
||||||
@@ -117,7 +193,8 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
.setItems(arrayOf("CSV", "Formatted text", "HTML ZIP")) { _, which ->
|
.setItems(arrayOf("CSV", "Formatted text", "HTML ZIP")) { _, which ->
|
||||||
pendingExport = ExportFormat.entries[which]
|
pendingExport = ExportFormat.entries[which]
|
||||||
createDocument.launch("notification-log.${pendingExport!!.extension}")
|
createDocument.launch("notification-log.${pendingExport!!.extension}")
|
||||||
}.show()
|
}
|
||||||
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun writeExport(uri: Uri) {
|
private fun writeExport(uri: Uri) {
|
||||||
@@ -126,16 +203,23 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
Thread {
|
Thread {
|
||||||
val entries = EncryptedNotificationLogStore(context).readAll()
|
val entries = EncryptedNotificationLogStore(context).readAll()
|
||||||
val settings = LogViewSettingsStore(context).load()
|
val settings = LogViewSettingsStore(context).load()
|
||||||
context.contentResolver.openOutputStream(uri)?.use { output -> when (format) {
|
context.contentResolver.openOutputStream(uri)?.use { output ->
|
||||||
|
when (format) {
|
||||||
ExportFormat.CSV -> output.write(LogExporter.csv(entries, settings).encodeToByteArray())
|
ExportFormat.CSV -> output.write(LogExporter.csv(entries, settings).encodeToByteArray())
|
||||||
ExportFormat.FORMATTED -> output.write(LogExporter.formatted(entries, settings).encodeToByteArray())
|
ExportFormat.FORMATTED -> output.write(LogExporter.formatted(entries, settings).encodeToByteArray())
|
||||||
ExportFormat.HTML_ZIP -> ZipOutputStream(output).use { zip ->
|
ExportFormat.HTML_ZIP -> writeHtmlExport(output, entries, settings, context)
|
||||||
zip.putNextEntry(java.util.zip.ZipEntry("notification-log.html"))
|
}
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeHtmlExport(output: java.io.OutputStream, entries: List<NotificationLogEntry>, settings: LogViewSettings, context: android.content.Context) {
|
||||||
|
ZipOutputStream(output).use { zip ->
|
||||||
val imageStore = EncryptedImageStore(context)
|
val imageStore = EncryptedImageStore(context)
|
||||||
val imageEntries = entries.filter { it.imageId != null && LogField.CONTENTS in settings.visibleFields }
|
val exportedImages = entries.filter { it.imageId != null && LogField.CONTENTS in settings.visibleFields }
|
||||||
val exportedImages = imageEntries.mapNotNull { entry ->
|
.mapNotNull { entry -> imageStore.read(entry.imageId!!)?.let { entry.imageId to it } }
|
||||||
imageStore.read(entry.imageId!!)?.let { entry.imageId to it }
|
.toMap()
|
||||||
}.toMap()
|
zip.putNextEntry(java.util.zip.ZipEntry("notification-log.html"))
|
||||||
zip.write(LogExporter.html(entries, settings) { entry ->
|
zip.write(LogExporter.html(entries, settings) { entry ->
|
||||||
entry.imageId?.takeIf(exportedImages::containsKey)?.let { "images/$it.png" }
|
entry.imageId?.takeIf(exportedImages::containsKey)?.let { "images/$it.png" }
|
||||||
}.encodeToByteArray())
|
}.encodeToByteArray())
|
||||||
@@ -146,9 +230,10 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
zip.closeEntry()
|
zip.closeEntry()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} }
|
|
||||||
}.start()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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") }
|
private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") }
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -91,10 +91,17 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
writeExecutor.execute {
|
writeExecutor.execute {
|
||||||
try {
|
try {
|
||||||
if (retainImage) {
|
if (retainImage) {
|
||||||
|
try {
|
||||||
imageStore.save(entry.imageId!!, snapshot.imageBytes!!)
|
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)
|
logStore.append(entry)
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
|
entry.imageId?.let(imageStore::delete)
|
||||||
Log.e(TAG, "Could not persist notification log entry", error)
|
Log.e(TAG, "Could not persist notification log entry", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,7 +114,10 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
val image = snapshot.hasImage && NotificationRuleEvaluator.allows(
|
val image = snapshot.hasImage && NotificationRuleEvaluator.allows(
|
||||||
ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName,
|
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 {
|
private fun appName(packageName: String): String = try {
|
||||||
@@ -147,5 +157,6 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "NotificationCapture"
|
const val TAG = "NotificationCapture"
|
||||||
|
const val MAX_CONTENT_CHARACTERS = 16_000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package se.ajpanton.notificationlog.data
|
package se.ajpanton.notificationlog.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.AtomicFile
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.DataInputStream
|
import java.io.DataInputStream
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
@@ -12,11 +13,19 @@ class EncryptedImageStore(context: Context) {
|
|||||||
|
|
||||||
fun save(id: String, bytes: ByteArray) {
|
fun save(id: String, bytes: ByteArray) {
|
||||||
require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." }
|
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)
|
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.size)
|
||||||
output.write(payload.initializationVector)
|
output.write(payload.initializationVector)
|
||||||
output.write(payload.cipherText)
|
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 {
|
private companion object {
|
||||||
val IMAGE_ID_PATTERN = Regex("[0-9a-f-]{36}")
|
val IMAGE_ID_PATTERN = Regex("[0-9a-f-]{36}")
|
||||||
|
const val MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-1
@@ -27,7 +27,9 @@ class EncryptedNotificationLogStore(context: Context) {
|
|||||||
|
|
||||||
fun append(entry: NotificationLogEntry) = synchronized(lock) {
|
fun append(entry: NotificationLogEntry) = synchronized(lock) {
|
||||||
val entries = (readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: emptyList())
|
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<NotificationLogEntry>) = synchronized(lock) {
|
fun replaceAll(entries: List<NotificationLogEntry>) = synchronized(lock) {
|
||||||
@@ -39,6 +41,14 @@ class EncryptedNotificationLogStore(context: Context) {
|
|||||||
imageDirectory.listFiles()?.forEach(File::delete)
|
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<NotificationLogEntry>) {
|
private fun write(entries: List<NotificationLogEntry>) {
|
||||||
val payload = cipher.encrypt(NotificationLogEntryJson.encode(entries))
|
val payload = cipher.encrypt(NotificationLogEntryJson.encode(entries))
|
||||||
val output = file.startWrite()
|
val output = file.startWrite()
|
||||||
@@ -57,6 +67,21 @@ class EncryptedNotificationLogStore(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun retain(entries: List<NotificationLogEntry>): List<NotificationLogEntry> {
|
||||||
|
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? {
|
private fun readPayloadOrNull(): EncryptedPayload? {
|
||||||
val input = try {
|
val input = try {
|
||||||
file.openRead()
|
file.openRead()
|
||||||
@@ -82,6 +107,9 @@ class EncryptedNotificationLogStore(context: Context) {
|
|||||||
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_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"
|
const val IMAGE_DIRECTORY_NAME = "notification-images"
|
||||||
|
val IMAGE_ID_PATTERN = Regex("[0-9a-f-]{36}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,61 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:padding="@dimen/page_padding">
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"><Button android:id="@+id/notification_access" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Enable notification access"/><Button android:id="@+id/export_logs" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Export logs"/><Button android:id="@+id/clear_logs" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Clear logs" /></LinearLayout>
|
android:layout_width="match_parent"
|
||||||
<ScrollView android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1">
|
android:layout_height="match_parent"
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical">
|
android:orientation="vertical"
|
||||||
<TextView android:id="@+id/empty_view" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:text="No logs yet." />
|
android:padding="@dimen/page_padding">
|
||||||
<LinearLayout android:id="@+id/log_rows" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" />
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/notification_access"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Enable notification access" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/export_logs"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Export logs" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/clear_logs"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Clear logs" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Tap a log to expand it. Long-press a log to delete it." />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/empty_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:text="No logs yet." />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/log_rows"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
Reference in New Issue
Block a user