From 21a228a4f6a3d359cd8e420022ff6f6fbfeaa9f8 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Mon, 27 Jul 2026 23:01:51 +0000 Subject: [PATCH] Recycle paged log rows --- .../notificationlog/ViewLogsFragment.kt | 159 ++++++++++++------ .../main/res/layout/fragment_view_logs.xml | 42 ++--- 2 files changed, 119 insertions(+), 82 deletions(-) diff --git a/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt index 4b37d75..b82937d 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt @@ -31,8 +31,14 @@ 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.children 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 @@ -42,20 +48,25 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { private var loading = false private var noMoreRows = false private var loadGeneration = 0 + private val logAdapter = LogAdapter() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding = FragmentViewLogsBinding.bind(view) binding!!.logsRefresh.setOnRefreshListener { loadLogs(reset = true) } - binding!!.logScroll.setOnScrollChangeListener { _, _, scrollY, _, _ -> + 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 - val scroll = binding?.logScroll ?: return@setOnScrollChangeListener - if (!noMoreRows && !loading && scrollY >= scroll.getChildAt(0).height - scroll.height - dp(LOAD_MORE_THRESHOLD_DP)) { + if (!noMoreRows && !loading && !recyclerView.canScrollVertically(1)) { loadLogs(reset = false) + } } - } + }) binding!!.scrollToTop.setOnClickListener { - binding?.logScroll?.smoothScrollTo(0, 0) + binding?.logList?.smoothScrollToPosition(0) } loadLogs(reset = true) } @@ -83,9 +94,10 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { val generation = loadGeneration loading = true binding?.logsRefresh?.isRefreshing = true - Thread { - val page = EncryptedNotificationLogStore(context).readNewest(nextCursor, PAGE_SIZE) - activity?.runOnUiThread { + viewLifecycleOwner.lifecycleScope.launch { + val page = withContext(Dispatchers.IO) { + EncryptedNotificationLogStore(context).readNewest(nextCursor, PAGE_SIZE) + } if (generation == loadGeneration) { nextCursor = page.nextCursor noMoreRows = nextCursor == null @@ -95,12 +107,10 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { } loading = false binding?.logsRefresh?.isRefreshing = false - } - }.start() + } } private fun render(view: FragmentViewLogsBinding) { - view.logRows.removeAllViews() view.emptyView.visibility = if (rows.isEmpty()) View.VISIBLE else View.GONE view.emptyView.text = if (hasNotificationAccess()) { "No logs yet." @@ -109,11 +119,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { } val settings = LogViewSettingsStore(requireContext()).load() val visibleRows = rows.filter { eventFor(it.entry.action) in settings.visibleEvents } - 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()) - } + logAdapter.submit(visibleRows, settings, metadataColumnWidth(view.logList.width, visibleRows, settings)) } private fun logRowView(row: LogRow, settings: LogViewSettings, metadataColumnWidth: Int): View = LinearLayout(requireContext()).apply { @@ -228,12 +234,13 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { val imageId = row.entry.imageId val appContext = requireContext().applicationContext val contentsContainer = this - Thread { - EncryptedImageStore(appContext).read(imageId)?.let { bytes -> + viewLifecycleOwner.lifecycleScope.launch { + val bitmap = withContext(Dispatchers.IO) { + EncryptedImageStore(appContext).read(imageId)?.let { bytes -> BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - }?.let { bitmap -> - activity?.runOnUiThread { - if (contentsContainer.isAttachedToWindow) { + } + } + if (contentsContainer.isAttachedToWindow && bitmap != null) { contentsContainer.addView(ImageView(contentsContainer.context).apply { setImageBitmap(bitmap) adjustViewBounds = true @@ -246,10 +253,8 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { } } }) - } - } } - }.start() + } } setOnClickListener { onToggleExpanded() } setOnLongClickListener { showDeleteDialog(row.entry.id) } @@ -327,6 +332,48 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { } } + 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) @@ -336,10 +383,12 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { .setMessage("This permanently removes this log and its copied image, if any.") .setNegativeButton("Cancel", null) .setPositiveButton("Delete") { _, _ -> - Thread { - EncryptedNotificationLogStore(requireContext().applicationContext).delete(entryId) - activity?.runOnUiThread { loadLogs(reset = true) } - }.start() + viewLifecycleOwner.lifecycleScope.launch { + withContext(Dispatchers.IO) { + EncryptedNotificationLogStore(requireContext().applicationContext).delete(entryId) + } + loadLogs(reset = true) + } } .show() return true @@ -347,35 +396,37 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { private fun captureCollapseAnchor(collapsingRow: View): CollapseAnchor? { val currentBinding = binding ?: return null - val scroll = currentBinding.logScroll - val rowsTop = currentBinding.logRows.top - val topEdge = scroll.scrollY - rowsTop - val bottomEdge = topEdge + scroll.height - val topRow = rowAt(currentBinding.logRows, topEdge) - val bottomRow = rowAt(currentBinding.logRows, bottomEdge) + 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 { - topRow === collapsingRow && bottomRow === collapsingRow -> CollapseAnchor.CENTER_COLLAPSED_ROW(collapsingRow) - topRow === collapsingRow && bottomRow != null -> - CollapseAnchor.KEEP_BOTTOM_EDGE(bottomRow, bottomRow.bottom - bottomEdge) - bottomRow === collapsingRow && topRow != null -> - CollapseAnchor.KEEP_TOP_EDGE(topRow, topEdge - topRow.top) + 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 scroll = currentBinding.logScroll - val rows = currentBinding.logRows - rows.doOnPreDraw { + val list = currentBinding.logList + val manager = list.layoutManager as? LinearLayoutManager ?: return + list.doOnPreDraw { if (binding !== currentBinding) return@doOnPreDraw - val target = when (anchor) { - is CollapseAnchor.CENTER_COLLAPSED_ROW -> rows.top + anchor.row.top + anchor.row.height / 2 - scroll.height / 2 - is CollapseAnchor.KEEP_TOP_EDGE -> rows.top + anchor.row.top + anchor.offset - is CollapseAnchor.KEEP_BOTTOM_EDGE -> rows.top + anchor.row.bottom - anchor.offset - scroll.height + 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 } - val maximum = (scroll.getChildAt(0).height - scroll.height).coerceAtLeast(0) - scroll.scrollTo(0, target.coerceIn(0, maximum)) + list.scrollBy(0, delta) afterRestore() } } @@ -403,9 +454,6 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { } } - private fun rowAt(rows: LinearLayout, edge: Int): View? = rows.children.firstOrNull { row -> - edge >= row.top && edge <= row.bottom - } private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() private fun sp(value: Float): Float = value * resources.displayMetrics.scaledDensity @@ -421,9 +469,10 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { ) private sealed interface CollapseAnchor { - data class CENTER_COLLAPSED_ROW(val row: View) : CollapseAnchor - data class KEEP_TOP_EDGE(val row: View, val offset: Int) : CollapseAnchor - data class KEEP_BOTTOM_EDGE(val row: View, val offset: Int) : 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 { diff --git a/app/src/main/res/layout/fragment_view_logs.xml b/app/src/main/res/layout/fragment_view_logs.xml index f3b65a9..1b8560f 100644 --- a/app/src/main/res/layout/fragment_view_logs.xml +++ b/app/src/main/res/layout/fragment_view_logs.xml @@ -8,36 +8,24 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - - - - - - - - + android:layout_height="match_parent" + android:clipToPadding="false" + android:paddingStart="@dimen/log_page_horizontal_padding" + android:paddingTop="@dimen/page_padding" + android:paddingEnd="@dimen/log_page_horizontal_padding" + android:paddingBottom="@dimen/page_padding" /> + +