Restyle log rows as a responsive table

This commit is contained in:
ajp_anton
2026-07-27 21:36:03 +00:00
parent 627fda6bf8
commit 94250286e6
4 changed files with 76 additions and 35 deletions
@@ -1,9 +1,11 @@
package se.ajpanton.notificationlog package se.ajpanton.notificationlog
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Paint
import android.os.Bundle import android.os.Bundle
import android.content.ComponentName import android.content.ComponentName
import android.app.NotificationManager import android.app.NotificationManager
import android.text.TextPaint
import android.text.TextUtils import android.text.TextUtils
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
@@ -84,7 +86,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
noMoreRows = entries.size < PAGE_SIZE noMoreRows = entries.size < PAGE_SIZE
val addedRows = entries.map(::LogRow) val addedRows = entries.map(::LogRow)
rows += addedRows rows += addedRows
binding?.let { render(it, addedRows, reset) } binding?.let(::render)
} }
loading = false loading = false
binding?.logsRefresh?.isRefreshing = false binding?.logsRefresh?.isRefreshing = false
@@ -92,8 +94,8 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
}.start() }.start()
} }
private fun render(view: FragmentViewLogsBinding, addedRows: List<LogRow>, reset: Boolean) { private fun render(view: FragmentViewLogsBinding) {
if (reset) view.logRows.removeAllViews() 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
view.emptyView.text = if (hasNotificationAccess()) { view.emptyView.text = if (hasNotificationAccess()) {
"No logs yet." "No logs yet."
@@ -101,15 +103,19 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
"Notification access is disabled. Enable it in Settings." "Notification access is disabled. Enable it in Settings."
} }
val settings = LogViewSettingsStore(requireContext()).load() val settings = LogViewSettingsStore(requireContext()).load()
addedRows.filter { eventFor(it.entry.action) in settings.visibleEvents } val visibleRows = rows.filter { eventFor(it.entry.action) in settings.visibleEvents }
.forEach { row -> view.logRows.addView(logRowView(row, settings)) } val metadataColumnWidth = metadataColumnWidth(view.logRows.width, visibleRows, settings)
visibleRows.forEachIndexed { index, row ->
view.logRows.addView(logRowView(row, settings, metadataColumnWidth))
if (index < visibleRows.lastIndex) view.logRows.addView(rowSeparator())
}
} }
private fun logRowView(row: LogRow, settings: LogViewSettings): View = LinearLayout(requireContext()).apply { private fun logRowView(row: LogRow, settings: LogViewSettings, metadataColumnWidth: Int): View = LinearLayout(requireContext()).apply {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
gravity = android.view.Gravity.TOP gravity = android.view.Gravity.TOP
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = dp(6) bottomMargin = 0
} }
fun renderExpanded(expanded: Boolean) { fun renderExpanded(expanded: Boolean) {
@@ -124,26 +130,24 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
collapseAnchor?.let(::restoreCollapseAnchor) collapseAnchor?.let(::restoreCollapseAnchor)
Unit Unit
} }
val metadataPill = metadataPill(metadata, expanded, toggleExpanded, row.entry.id).apply { val metadataCell = metadataCell(metadata, expanded, toggleExpanded, row.entry.id).apply {
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
if (hasContents) 0 else LinearLayout.LayoutParams.MATCH_PARENT, if (hasContents) metadataColumnWidth else LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT,
if (hasContents) LEFT_PILL_WEIGHT else 0f,
).apply { ).apply {
if (hasContents) marginEnd = dp(6) if (hasContents) marginEnd = dp(COLUMN_GAP_DP)
} }
} }
if (metadata.isNotEmpty()) addView(metadataPill) if (metadata.isNotEmpty()) addView(metadataCell)
if (hasContents) { if (hasContents) {
val contentsPill = contentsPill(row, contents!!, metadata.size.coerceAtLeast(1), expanded, toggleExpanded).apply { val contentsCell = contentsCell(row, contents!!, metadata.size.coerceAtLeast(1), expanded, toggleExpanded).apply {
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
if (metadata.isNotEmpty()) 0 else LinearLayout.LayoutParams.MATCH_PARENT, if (metadata.isNotEmpty()) 0 else LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT,
if (metadata.isNotEmpty()) RIGHT_PILL_WEIGHT else 0f, if (metadata.isNotEmpty()) 1f else 0f,
) )
} }
addView(contentsPill) addView(contentsCell)
if (metadata.isNotEmpty()) equalizePillHeights(metadataPill, contentsPill)
} }
} }
@@ -153,15 +157,14 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
} }
} }
private fun metadataPill( private fun metadataCell(
values: List<MetadataValue>, values: List<MetadataValue>,
expanded: Boolean, expanded: Boolean,
onToggleExpanded: () -> Unit, onToggleExpanded: () -> Unit,
entryId: String, entryId: String,
): LinearLayout = LinearLayout(requireContext()).apply { ): LinearLayout = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
background = androidx.core.content.ContextCompat.getDrawable(context, R.drawable.log_pill_background) setPadding(dp(CELL_HORIZONTAL_PADDING_DP), dp(CELL_VERTICAL_PADDING_DP), dp(CELL_HORIZONTAL_PADDING_DP), dp(CELL_VERTICAL_PADDING_DP))
setPadding(dp(12), dp(8), dp(12), dp(8))
isClickable = true isClickable = true
isFocusable = true isFocusable = true
values.forEach { value -> values.forEach { value ->
@@ -183,7 +186,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
setOnLongClickListener { showDeleteDialog(entryId) } setOnLongClickListener { showDeleteDialog(entryId) }
} }
private fun contentsPill( private fun contentsCell(
row: LogRow, row: LogRow,
contents: String, contents: String,
collapsedLines: Int, collapsedLines: Int,
@@ -191,8 +194,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
onToggleExpanded: () -> Unit, onToggleExpanded: () -> Unit,
): LinearLayout = LinearLayout(requireContext()).apply { ): LinearLayout = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
background = androidx.core.content.ContextCompat.getDrawable(context, R.drawable.log_pill_background) setPadding(0, dp(CELL_VERTICAL_PADDING_DP), 0, dp(CELL_VERTICAL_PADDING_DP))
setPadding(dp(12), dp(8), dp(12), dp(8))
isClickable = true isClickable = true
isFocusable = true isFocusable = true
val message = TextView(context).apply { val message = TextView(context).apply {
@@ -241,7 +243,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
} }
private fun metadataValues(entry: NotificationLogEntry, settings: LogViewSettings): List<MetadataValue> = buildList { private fun metadataValues(entry: NotificationLogEntry, settings: LogViewSettings): List<MetadataValue> = buildList {
if (LogField.TIMESTAMP in settings.visibleFields) add(MetadataValue(timestamp(entry.recordedAtEpochMillis, settings.timestampZone, entry.eventTimeZoneId), 12f)) if (LogField.TIMESTAMP in settings.visibleFields) add(MetadataValue(timestamp(entry.recordedAtEpochMillis, settings.timestampZone, entry.eventTimeZoneId), 12f, isTimestamp = true))
if (LogField.APP_NAME in settings.visibleFields) add(MetadataValue(entry.appName, 15f, indented = true, bold = true)) if (LogField.APP_NAME in settings.visibleFields) add(MetadataValue(entry.appName, 15f, indented = true, bold = true))
if (LogField.PACKAGE_NAME in settings.visibleFields) add(MetadataValue(entry.packageName, 12f, indented = true, breakBeforePeriods = true)) if (LogField.PACKAGE_NAME in settings.visibleFields) add(MetadataValue(entry.packageName, 12f, indented = true, breakBeforePeriods = true))
if (LogField.ACTION in settings.visibleFields) add(MetadataValue(actionLabel(entry.action), 14f, indented = true)) if (LogField.ACTION in settings.visibleFields) add(MetadataValue(actionLabel(entry.action), 14f, indented = true))
@@ -277,6 +279,41 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
.getSystemService(NotificationManager::class.java) .getSystemService(NotificationManager::class.java)
.isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java)) .isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java))
private fun metadataColumnWidth(availableWidth: Int, rows: List<LogRow>, settings: LogViewSettings): Int {
val values = rows.flatMap { metadataValues(it.entry, settings) }
val widestTimestamp = values.filter { it.isTimestamp }
.maxOfOrNull(::metadataTextWidth)
?: 0
val widestMetadata = values
.maxOfOrNull(::metadataTextWidth)
?: 0
val timestampMinimumWidth = maxOf(
dp(MINIMUM_METADATA_COLUMN_DP),
widestTimestamp + dp(CELL_HORIZONTAL_PADDING_DP * 2),
)
val preferredWidth = maxOf(timestampMinimumWidth, widestMetadata + dp(CELL_HORIZONTAL_PADDING_DP * 2))
if (availableWidth <= 0) return preferredWidth
val maximumWidth = maxOf(
timestampMinimumWidth,
(availableWidth * MAXIMUM_METADATA_COLUMN_FRACTION).toInt(),
)
return minOf(preferredWidth, maximumWidth)
}
private fun metadataTextWidth(value: MetadataValue): Int = TextPaint(Paint.ANTI_ALIAS_FLAG).run {
textSize = sp(value.textSize)
typeface = if (value.bold) android.graphics.Typeface.DEFAULT_BOLD else android.graphics.Typeface.DEFAULT
measureText(value.text).toInt() + if (value.indented) dp(METADATA_INDENT_DP) else 0
}
private fun rowSeparator(): View = View(requireContext()).apply {
background = androidx.core.content.ContextCompat.getDrawable(context, R.drawable.log_row_separator)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(1)).apply {
topMargin = dp(ROW_SEPARATOR_MARGIN_DP)
bottomMargin = dp(ROW_SEPARATOR_MARGIN_DP)
}
}
private fun timestamp(value: Long, zone: TimestampZone, eventTimeZoneId: String?): String = DateFormat.getDateTimeInstance().apply { private fun timestamp(value: Long, zone: TimestampZone, eventTimeZoneId: String?): String = DateFormat.getDateTimeInstance().apply {
timeZone = when (zone) { timeZone = when (zone) {
TimestampZone.UTC -> java.util.TimeZone.getTimeZone("UTC") TimestampZone.UTC -> java.util.TimeZone.getTimeZone("UTC")
@@ -300,16 +337,6 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
return true return true
} }
private fun equalizePillHeights(left: View, right: View) {
left.post {
val targetHeight = maxOf(left.height, right.height)
if (left.minimumHeight != targetHeight || right.minimumHeight != targetHeight) {
left.minimumHeight = targetHeight
right.minimumHeight = targetHeight
}
}
}
private fun captureCollapseAnchor(collapsingRow: View): CollapseAnchor? { private fun captureCollapseAnchor(collapsingRow: View): CollapseAnchor? {
val currentBinding = binding ?: return null val currentBinding = binding ?: return null
val scroll = currentBinding.logScroll val scroll = currentBinding.logScroll
@@ -349,6 +376,7 @@ 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 fun sp(value: Float): Float = value * resources.displayMetrics.scaledDensity
private data class LogRow(val entry: NotificationLogEntry) private data class LogRow(val entry: NotificationLogEntry)
private data class MetadataValue( private data class MetadataValue(
@@ -357,6 +385,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
val indented: Boolean = false, val indented: Boolean = false,
val bold: Boolean = false, val bold: Boolean = false,
val breakBeforePeriods: Boolean = false, val breakBeforePeriods: Boolean = false,
val isTimestamp: Boolean = false,
) )
private sealed interface CollapseAnchor { private sealed interface CollapseAnchor {
@@ -366,11 +395,16 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
} }
private companion object { private companion object {
const val LEFT_PILL_WEIGHT = 0.42f
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 LOAD_MORE_THRESHOLD_DP = 480
const val PAGE_SIZE = 80 const val PAGE_SIZE = 80
const val IMAGE_VIEWER_TAG = "image-viewer" const val IMAGE_VIEWER_TAG = "image-viewer"
const val MINIMUM_METADATA_COLUMN_DP = 120
const val CELL_HORIZONTAL_PADDING_DP = 8
const val CELL_VERTICAL_PADDING_DP = 6
const val COLUMN_GAP_DP = 12
const val ROW_SEPARATOR_MARGIN_DP = 4
const val METADATA_INDENT_DP = 8
const val MAXIMUM_METADATA_COLUMN_FRACTION = 0.45f
} }
} }
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/log_row_separator" />
</shape>
+1
View File
@@ -8,4 +8,5 @@
<color name="surface">#121318</color> <color name="surface">#121318</color>
<color name="on_surface">#E3E2E9</color> <color name="on_surface">#E3E2E9</color>
<color name="window_background">#121318</color> <color name="window_background">#121318</color>
<color name="log_row_separator">#8F8D96</color>
</resources> </resources>
+1
View File
@@ -8,4 +8,5 @@
<color name="surface">#FBF8FF</color> <color name="surface">#FBF8FF</color>
<color name="on_surface">#1B1B21</color> <color name="on_surface">#1B1B21</color>
<color name="window_background">#FBF8FF</color> <color name="window_background">#FBF8FF</color>
<color name="log_row_separator">#A5A2AB</color>
</resources> </resources>