Files
notifications-master/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt
T

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
}
}