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() private val rows = mutableListOf() 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() 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 { 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, 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 = 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, 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() { private var rows: List = emptyList() private var settings = LogViewSettings() private var metadataWidth = 0 fun submit(rows: List, 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 } }