555 lines
26 KiB
Kotlin
555 lines
26 KiB
Kotlin
package se.ajpanton.notificationlog
|
|
|
|
import android.animation.Animator
|
|
import android.animation.AnimatorListenerAdapter
|
|
import android.animation.ArgbEvaluator
|
|
import android.animation.ValueAnimator
|
|
import android.graphics.Color
|
|
import android.graphics.BitmapFactory
|
|
import android.graphics.Paint
|
|
import android.os.Bundle
|
|
import android.content.ComponentName
|
|
import android.app.NotificationManager
|
|
import android.text.TextPaint
|
|
import android.text.TextUtils
|
|
import android.view.View
|
|
import android.widget.ImageView
|
|
import android.widget.LinearLayout
|
|
import android.widget.TextView
|
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
import se.ajpanton.notificationlog.data.EncryptedImageStore
|
|
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
|
|
import se.ajpanton.notificationlog.data.NewestLogCursor
|
|
import se.ajpanton.notificationlog.databinding.FragmentViewLogsBinding
|
|
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.LogAppFilterStore
|
|
import se.ajpanton.notificationlog.settings.TimestampFormatter
|
|
import se.ajpanton.notificationlog.settings.DisplayEvent
|
|
import se.ajpanton.notificationlog.capture.NotificationCaptureService
|
|
import androidx.fragment.app.Fragment
|
|
import androidx.core.content.ContextCompat
|
|
import androidx.core.view.doOnPreDraw
|
|
import androidx.recyclerview.widget.LinearLayoutManager
|
|
import androidx.recyclerview.widget.RecyclerView
|
|
import androidx.recyclerview.widget.DiffUtil
|
|
import androidx.lifecycle.lifecycleScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
|
|
class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|
private var binding: FragmentViewLogsBinding? = null
|
|
private val expandedIds = mutableSetOf<String>()
|
|
private val rows = mutableListOf<LogRow>()
|
|
private var nextCursor: NewestLogCursor? = null
|
|
private var loading = false
|
|
private var noMoreRows = false
|
|
private var loadGeneration = 0
|
|
private val logAdapter = LogAdapter()
|
|
private lateinit var appFilterStore: LogAppFilterStore
|
|
|
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
super.onViewCreated(view, savedInstanceState)
|
|
binding = FragmentViewLogsBinding.bind(view)
|
|
appFilterStore = LogAppFilterStore(requireContext())
|
|
binding!!.logsRefresh.setOnRefreshListener { loadLogs(reset = true) }
|
|
binding!!.logList.layoutManager = LinearLayoutManager(requireContext())
|
|
binding!!.logList.adapter = logAdapter
|
|
binding!!.logList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
|
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
|
val scrollY = recyclerView.computeVerticalScrollOffset()
|
|
binding?.scrollToTop?.visibility = if (scrollY > dp(SCROLL_TO_TOP_THRESHOLD_DP)) View.VISIBLE else View.GONE
|
|
if (!noMoreRows && !loading && !recyclerView.canScrollVertically(1)) {
|
|
loadLogs(reset = false)
|
|
}
|
|
}
|
|
})
|
|
binding!!.scrollToTop.setOnClickListener {
|
|
binding?.logList?.smoothScrollToPosition(0)
|
|
}
|
|
loadLogs(reset = true)
|
|
}
|
|
|
|
override fun onResume() {
|
|
super.onResume()
|
|
loadLogs(reset = true)
|
|
}
|
|
|
|
override fun onDestroyView() {
|
|
binding = null
|
|
super.onDestroyView()
|
|
}
|
|
|
|
private fun loadLogs(reset: Boolean) {
|
|
if (loading && !reset) return
|
|
if (!reset && noMoreRows) return
|
|
val context = requireContext().applicationContext
|
|
if (reset) {
|
|
rows.clear()
|
|
nextCursor = null
|
|
noMoreRows = false
|
|
loadGeneration++
|
|
}
|
|
val generation = loadGeneration
|
|
loading = true
|
|
binding?.logsRefresh?.isRefreshing = true
|
|
viewLifecycleOwner.lifecycleScope.launch {
|
|
val page = withContext(Dispatchers.IO) {
|
|
EncryptedNotificationLogStore(context).readNewest(nextCursor, PAGE_SIZE)
|
|
}
|
|
if (generation != loadGeneration) return@launch
|
|
nextCursor = page.nextCursor
|
|
noMoreRows = nextCursor == null
|
|
val visibleBefore = visibleRows(LogViewSettingsStore(requireContext()).load()).size
|
|
rows += page.entries.map(::LogRow)
|
|
binding?.let(::render)
|
|
val visibleAfter = visibleRows(LogViewSettingsStore(requireContext()).load()).size
|
|
val skipFilteredPage = appFilterStore.selectedPackages().isNotEmpty() &&
|
|
visibleAfter == visibleBefore && !noMoreRows
|
|
loading = false
|
|
binding?.logsRefresh?.isRefreshing = false
|
|
if (skipFilteredPage) loadLogs(reset = false)
|
|
}
|
|
}
|
|
|
|
private fun render(view: FragmentViewLogsBinding) {
|
|
val settings = LogViewSettingsStore(requireContext()).load()
|
|
val visibleRows = visibleRows(settings)
|
|
view.emptyView.visibility = if (visibleRows.isEmpty()) View.VISIBLE else View.GONE
|
|
view.emptyView.text = if (hasNotificationAccess()) {
|
|
if (appFilterStore.selectedPackages().isEmpty()) "No logs yet." else "No logs match the app filter."
|
|
} else {
|
|
"Notification access is disabled. Enable it in Settings."
|
|
}
|
|
logAdapter.submit(visibleRows, settings, metadataColumnWidth(view.logList.width, visibleRows, settings))
|
|
}
|
|
|
|
/** Called by MainActivity's header action. Selections persist until Reset is chosen. */
|
|
fun showAppFilter() {
|
|
val appContext = requireContext().applicationContext
|
|
viewLifecycleOwner.lifecycleScope.launch {
|
|
val apps = withContext(Dispatchers.IO) {
|
|
val byPackage = linkedMapOf<String, LoggedApp>()
|
|
EncryptedNotificationLogStore(appContext).forEachNewest { entry ->
|
|
byPackage.putIfAbsent(entry.packageName, LoggedApp(entry.appName, entry.packageName))
|
|
}
|
|
byPackage.values.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
|
|
}
|
|
if (!isAdded) return@launch
|
|
val selected = appFilterStore.selectedPackages().toMutableSet()
|
|
var changed = false
|
|
val dialog = MaterialAlertDialogBuilder(requireContext())
|
|
.setTitle("Filter logs by app")
|
|
.setMultiChoiceItems(
|
|
apps.map { "${it.name}\n${it.packageName}" }.toTypedArray(),
|
|
apps.map { it.packageName in selected }.toBooleanArray(),
|
|
) { _, which, checked ->
|
|
if (checked) selected += apps[which].packageName else selected -= apps[which].packageName
|
|
appFilterStore.save(selected)
|
|
changed = true
|
|
}
|
|
.setNeutralButton("Reset") { _, _ ->
|
|
appFilterStore.clear()
|
|
changed = true
|
|
}
|
|
.setPositiveButton("Close", null)
|
|
.create()
|
|
dialog.setOnDismissListener {
|
|
if (changed) refreshAppFilter()
|
|
}
|
|
dialog.show()
|
|
}
|
|
}
|
|
|
|
private fun refreshAppFilter() {
|
|
(activity as? MainActivity)?.updateLogFilterAction()
|
|
loadLogs(reset = true)
|
|
}
|
|
|
|
private fun visibleRows(settings: LogViewSettings): List<LogRow> {
|
|
val selectedPackages = appFilterStore.selectedPackages()
|
|
return rows.filter { row ->
|
|
eventFor(row.entry.action) in settings.visibleEvents &&
|
|
(selectedPackages.isEmpty() || row.entry.packageName in selectedPackages)
|
|
}
|
|
}
|
|
|
|
private fun logRowView(row: LogRow, settings: LogViewSettings, metadataColumnWidth: Int): View = LinearLayout(requireContext()).apply {
|
|
orientation = LinearLayout.HORIZONTAL
|
|
gravity = android.view.Gravity.TOP
|
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
|
bottomMargin = 0
|
|
}
|
|
|
|
fun renderExpanded(expanded: Boolean) {
|
|
removeAllViews()
|
|
val metadata = metadataValues(row.entry, settings)
|
|
val contents = fieldValue(LogField.CONTENTS, row.entry, settings)
|
|
val hasContents = LogField.CONTENTS in settings.visibleFields && !contents.isNullOrEmpty()
|
|
val toggleExpanded = {
|
|
val collapsing = expanded
|
|
val collapseAnchor = if (collapsing) captureCollapseAnchor(this@apply) else null
|
|
if (!expandedIds.add(row.entry.id)) expandedIds.remove(row.entry.id)
|
|
renderExpanded(row.entry.id in expandedIds)
|
|
if (collapsing) {
|
|
val highlight = { highlightCollapsedRow(this@apply) }
|
|
if (collapseAnchor != null) {
|
|
restoreCollapseAnchor(collapseAnchor, highlight)
|
|
} else {
|
|
this@apply.doOnPreDraw { highlight() }
|
|
}
|
|
}
|
|
Unit
|
|
}
|
|
val metadataCell = metadataCell(metadata, expanded, toggleExpanded, row.entry.id).apply {
|
|
layoutParams = LinearLayout.LayoutParams(
|
|
if (hasContents) metadataColumnWidth else LinearLayout.LayoutParams.MATCH_PARENT,
|
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
).apply {
|
|
if (hasContents) marginEnd = dp(COLUMN_GAP_DP)
|
|
}
|
|
}
|
|
if (metadata.isNotEmpty()) addView(metadataCell)
|
|
if (hasContents) {
|
|
val contentsCell = contentsCell(row, contents!!, metadata.size.coerceAtLeast(1), expanded, toggleExpanded).apply {
|
|
layoutParams = LinearLayout.LayoutParams(
|
|
if (metadata.isNotEmpty()) 0 else LinearLayout.LayoutParams.MATCH_PARENT,
|
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
if (metadata.isNotEmpty()) 1f else 0f,
|
|
)
|
|
}
|
|
addView(contentsCell)
|
|
}
|
|
}
|
|
|
|
renderExpanded(row.entry.id in expandedIds)
|
|
setOnLongClickListener {
|
|
showDeleteDialog(row.entry.id)
|
|
}
|
|
}
|
|
|
|
private fun metadataCell(
|
|
values: List<MetadataValue>,
|
|
expanded: Boolean,
|
|
onToggleExpanded: () -> Unit,
|
|
entryId: String,
|
|
): LinearLayout = LinearLayout(requireContext()).apply {
|
|
orientation = LinearLayout.VERTICAL
|
|
setPadding(dp(CELL_HORIZONTAL_PADDING_DP), dp(CELL_VERTICAL_PADDING_DP), dp(CELL_HORIZONTAL_PADDING_DP), dp(CELL_VERTICAL_PADDING_DP))
|
|
isClickable = true
|
|
isFocusable = true
|
|
values.forEach { value ->
|
|
addView(TextView(context).apply {
|
|
text = if (expanded && value.breakBeforePeriods) value.text.replace(".", "\u200B.") else value.text
|
|
maxLines = if (expanded) Int.MAX_VALUE else 1
|
|
ellipsize = if (expanded) null else TextUtils.TruncateAt.END
|
|
if (expanded) {
|
|
breakStrategy = android.graphics.text.LineBreaker.BREAK_STRATEGY_HIGH_QUALITY
|
|
hyphenationFrequency = android.text.Layout.HYPHENATION_FREQUENCY_NONE
|
|
}
|
|
textSize = value.textSize
|
|
if (value.indented) setPadding(dp(8), 0, 0, 0)
|
|
setLineSpacing(0f, 1f)
|
|
if (value.bold) setTypeface(typeface, android.graphics.Typeface.BOLD)
|
|
})
|
|
}
|
|
setOnClickListener { onToggleExpanded() }
|
|
setOnLongClickListener { showDeleteDialog(entryId) }
|
|
}
|
|
|
|
private fun contentsCell(
|
|
row: LogRow,
|
|
contents: String,
|
|
collapsedLines: Int,
|
|
expanded: Boolean,
|
|
onToggleExpanded: () -> Unit,
|
|
): LinearLayout = LinearLayout(requireContext()).apply {
|
|
orientation = LinearLayout.VERTICAL
|
|
setPadding(0, dp(CELL_VERTICAL_PADDING_DP), 0, dp(CELL_VERTICAL_PADDING_DP))
|
|
isClickable = true
|
|
isFocusable = true
|
|
val message = TextView(context).apply {
|
|
text = contents
|
|
maxLines = if (expanded) Int.MAX_VALUE else collapsedLines
|
|
ellipsize = if (expanded) null else TextUtils.TruncateAt.END
|
|
setLineSpacing(0f, 1f)
|
|
}
|
|
addView(message)
|
|
if (expanded && row.entry.action == NotificationAction.EDITED && !row.entry.previousContents.isNullOrEmpty()) {
|
|
addView(TextView(context).apply {
|
|
text = "Previous: ${row.entry.previousContents}"
|
|
setLineSpacing(0f, 1f)
|
|
setPadding(0, dp(4), 0, 0)
|
|
})
|
|
}
|
|
if (expanded && row.entry.imageId != null) {
|
|
val imageId = row.entry.imageId
|
|
val appContext = requireContext().applicationContext
|
|
val contentsContainer = this
|
|
viewLifecycleOwner.lifecycleScope.launch {
|
|
val bitmap = withContext(Dispatchers.IO) {
|
|
EncryptedImageStore(appContext).read(imageId)?.let { bytes ->
|
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
|
}
|
|
}
|
|
if (contentsContainer.isAttachedToWindow && bitmap != null) {
|
|
contentsContainer.addView(ImageView(contentsContainer.context).apply {
|
|
setImageBitmap(bitmap)
|
|
adjustViewBounds = true
|
|
maxHeight = dp(240)
|
|
contentDescription = "Notification image"
|
|
setPadding(0, dp(4), 0, 0)
|
|
setOnClickListener {
|
|
if (isAdded) {
|
|
ImageViewerDialogFragment.newInstance(imageId).show(parentFragmentManager, IMAGE_VIEWER_TAG)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
setOnClickListener { onToggleExpanded() }
|
|
setOnLongClickListener { showDeleteDialog(row.entry.id) }
|
|
}
|
|
|
|
private fun metadataValues(entry: NotificationLogEntry, settings: LogViewSettings): List<MetadataValue> = buildList {
|
|
if (LogField.TIMESTAMP in settings.visibleFields) add(MetadataValue(timestamp(entry.recordedAtEpochMillis, settings, entry.eventTimeZoneId), 12f, isTimestamp = 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.ACTION in settings.visibleFields) add(MetadataValue(actionLabel(entry.action), 14f, indented = true))
|
|
}
|
|
|
|
private fun fieldValue(field: LogField, entry: NotificationLogEntry, settings: LogViewSettings): String? = when (field) {
|
|
LogField.TIMESTAMP -> timestamp(entry.recordedAtEpochMillis, settings, 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.APPEARED -> "Appeared"
|
|
NotificationAction.ALREADY_ACTIVE -> "Already active"
|
|
NotificationAction.EDITED -> "Edited"
|
|
NotificationAction.APP_CANCELLED -> "App cancelled"
|
|
NotificationAction.APP_CANCELLED_ALL -> "App cancelled all"
|
|
NotificationAction.USER_DISMISSED -> "User dismissed"
|
|
NotificationAction.USER_DISMISSED_ALL -> "User dismissed all"
|
|
NotificationAction.USER_CLICKED -> "User opened"
|
|
else -> action.name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase)
|
|
}
|
|
|
|
private fun eventFor(action: NotificationAction): DisplayEvent = when (action) {
|
|
NotificationAction.APPEARED, NotificationAction.ALREADY_ACTIVE -> DisplayEvent.APPEARING
|
|
NotificationAction.EDITED -> DisplayEvent.EDITS
|
|
else -> DisplayEvent.DISAPPEARING
|
|
}
|
|
|
|
private fun hasNotificationAccess(): Boolean = requireContext()
|
|
.getSystemService(NotificationManager::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 inner class LogAdapter : RecyclerView.Adapter<LogAdapter.Holder>() {
|
|
private var rows: List<LogRow> = emptyList()
|
|
private var settings = LogViewSettings()
|
|
private var metadataWidth = 0
|
|
|
|
fun submit(rows: List<LogRow>, settings: LogViewSettings, metadataWidth: Int) {
|
|
val previous = this.rows
|
|
this.rows = rows
|
|
this.settings = settings
|
|
this.metadataWidth = metadataWidth
|
|
DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
|
override fun getOldListSize() = previous.size
|
|
override fun getNewListSize() = rows.size
|
|
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
|
previous[oldItemPosition].entry.id == rows[newItemPosition].entry.id
|
|
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
|
previous[oldItemPosition] == rows[newItemPosition]
|
|
}).dispatchUpdatesTo(this)
|
|
notifyItemRangeChanged(0, itemCount)
|
|
}
|
|
|
|
override fun onCreateViewHolder(parent: android.view.ViewGroup, viewType: Int): Holder {
|
|
val root = LinearLayout(parent.context).apply { orientation = LinearLayout.VERTICAL }
|
|
val content = LinearLayout(parent.context)
|
|
root.addView(content)
|
|
root.addView(rowSeparator())
|
|
return Holder(root, content)
|
|
}
|
|
|
|
override fun onBindViewHolder(holder: Holder, position: Int) {
|
|
holder.content.removeAllViews()
|
|
holder.content.addView(logRowView(rows[position], settings, metadataWidth))
|
|
holder.separator.visibility = if (position == itemCount - 1) View.GONE else View.VISIBLE
|
|
}
|
|
|
|
override fun getItemCount(): Int = rows.size
|
|
|
|
inner class Holder(val root: LinearLayout, val content: LinearLayout) : RecyclerView.ViewHolder(root) {
|
|
val separator: View get() = root.getChildAt(1)
|
|
}
|
|
}
|
|
|
|
private fun timestamp(value: Long, settings: LogViewSettings, eventTimeZoneId: String?): String =
|
|
TimestampFormatter.format(requireContext(), value, settings, eventTimeZoneId)
|
|
|
|
private fun showDeleteDialog(entryId: String): Boolean {
|
|
MaterialAlertDialogBuilder(requireContext())
|
|
.setTitle("Delete this log?")
|
|
.setMessage("This permanently removes this log and its copied image, if any.")
|
|
.setNegativeButton("Cancel", null)
|
|
.setPositiveButton("Delete") { _, _ ->
|
|
viewLifecycleOwner.lifecycleScope.launch {
|
|
withContext(Dispatchers.IO) {
|
|
EncryptedNotificationLogStore(requireContext().applicationContext).delete(entryId)
|
|
}
|
|
loadLogs(reset = true)
|
|
}
|
|
}
|
|
.show()
|
|
return true
|
|
}
|
|
|
|
private fun captureCollapseAnchor(collapsingRow: View): CollapseAnchor? {
|
|
val currentBinding = binding ?: return null
|
|
val list = currentBinding.logList
|
|
val manager = list.layoutManager as? LinearLayoutManager ?: return null
|
|
val collapsingItem = list.findContainingItemView(collapsingRow) ?: return null
|
|
val collapsingPosition = list.getChildAdapterPosition(collapsingItem)
|
|
val topPosition = manager.findFirstVisibleItemPosition()
|
|
val bottomPosition = manager.findLastVisibleItemPosition()
|
|
val topRow = manager.findViewByPosition(topPosition)
|
|
val bottomRow = manager.findViewByPosition(bottomPosition)
|
|
return when {
|
|
topPosition == collapsingPosition && bottomPosition == collapsingPosition -> CollapseAnchor.CENTER_COLLAPSED_ROW(collapsingPosition)
|
|
topPosition == collapsingPosition && bottomRow != null ->
|
|
CollapseAnchor.KEEP_BOTTOM_EDGE(bottomPosition, bottomRow.bottom - list.height)
|
|
bottomPosition == collapsingPosition && topRow != null ->
|
|
CollapseAnchor.KEEP_TOP_EDGE(topPosition, topRow.top)
|
|
else -> null
|
|
}
|
|
}
|
|
|
|
private fun restoreCollapseAnchor(anchor: CollapseAnchor, afterRestore: () -> Unit) {
|
|
val currentBinding = binding ?: return
|
|
val list = currentBinding.logList
|
|
val manager = list.layoutManager as? LinearLayoutManager ?: return
|
|
list.doOnPreDraw {
|
|
if (binding !== currentBinding) return@doOnPreDraw
|
|
val row = manager.findViewByPosition(anchor.position) ?: return@doOnPreDraw
|
|
val delta = when (anchor) {
|
|
is CollapseAnchor.CENTER_COLLAPSED_ROW -> row.top + row.height / 2 - list.height / 2
|
|
is CollapseAnchor.KEEP_TOP_EDGE -> row.top - anchor.offset
|
|
is CollapseAnchor.KEEP_BOTTOM_EDGE -> row.bottom - list.height - anchor.offset
|
|
}
|
|
list.scrollBy(0, delta)
|
|
afterRestore()
|
|
}
|
|
}
|
|
|
|
private fun highlightCollapsedRow(row: View) {
|
|
if (!row.isAttachedToWindow) return
|
|
val baseColor = ContextCompat.getColor(row.context, R.color.primary)
|
|
val startColor = Color.argb(
|
|
COLLAPSE_HIGHLIGHT_ALPHA,
|
|
Color.red(baseColor),
|
|
Color.green(baseColor),
|
|
Color.blue(baseColor),
|
|
)
|
|
val endColor = Color.argb(0, Color.red(baseColor), Color.green(baseColor), Color.blue(baseColor))
|
|
row.setBackgroundColor(startColor)
|
|
ValueAnimator.ofObject(ArgbEvaluator(), startColor, endColor).apply {
|
|
duration = COLLAPSE_HIGHLIGHT_DURATION_MILLIS
|
|
addUpdateListener { row.setBackgroundColor(it.animatedValue as Int) }
|
|
addListener(object : AnimatorListenerAdapter() {
|
|
override fun onAnimationEnd(animation: Animator) {
|
|
row.background = null
|
|
}
|
|
})
|
|
start()
|
|
}
|
|
}
|
|
|
|
|
|
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
|
|
private fun sp(value: Float): Float = android.util.TypedValue.applyDimension(
|
|
android.util.TypedValue.COMPLEX_UNIT_SP,
|
|
value,
|
|
resources.displayMetrics,
|
|
)
|
|
|
|
private data class LogRow(val entry: NotificationLogEntry)
|
|
private data class LoggedApp(val name: String, val packageName: String)
|
|
private data class MetadataValue(
|
|
val text: String,
|
|
val textSize: Float,
|
|
val indented: Boolean = false,
|
|
val bold: Boolean = false,
|
|
val breakBeforePeriods: Boolean = false,
|
|
val isTimestamp: Boolean = false,
|
|
)
|
|
|
|
private sealed interface CollapseAnchor {
|
|
val position: Int
|
|
data class CENTER_COLLAPSED_ROW(override val position: Int) : CollapseAnchor
|
|
data class KEEP_TOP_EDGE(override val position: Int, val offset: Int) : CollapseAnchor
|
|
data class KEEP_BOTTOM_EDGE(override val position: Int, val offset: Int) : CollapseAnchor
|
|
}
|
|
|
|
private companion object {
|
|
const val SCROLL_TO_TOP_THRESHOLD_DP = 120
|
|
const val LOAD_MORE_THRESHOLD_DP = 480
|
|
const val PAGE_SIZE = 80
|
|
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 = 6
|
|
const val ROW_SEPARATOR_MARGIN_DP = 4
|
|
const val METADATA_INDENT_DP = 8
|
|
const val MAXIMUM_METADATA_COLUMN_FRACTION = 0.45f
|
|
const val COLLAPSE_HIGHLIGHT_ALPHA = 52
|
|
const val COLLAPSE_HIGHLIGHT_DURATION_MILLIS = 450L
|
|
}
|
|
}
|