Complete secure log viewing and retention

This commit is contained in:
ajp_anton
2026-07-23 11:42:56 +00:00
parent 0091eb5d49
commit 8621a30225
6 changed files with 293 additions and 94 deletions
@@ -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") }
} }
@@ -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
} }
} }
@@ -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}")
} }
} }
+57 -6
View File
@@ -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>