Recycle paged log rows

This commit is contained in:
ajp_anton
2026-07-27 23:01:51 +00:00
parent 66ab982a7e
commit 21a228a4f6
2 changed files with 119 additions and 82 deletions
@@ -31,8 +31,14 @@ import se.ajpanton.notificationlog.settings.DisplayEvent
import se.ajpanton.notificationlog.capture.NotificationCaptureService import se.ajpanton.notificationlog.capture.NotificationCaptureService
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.children
import androidx.core.view.doOnPreDraw 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) { class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
private var binding: FragmentViewLogsBinding? = null private var binding: FragmentViewLogsBinding? = null
@@ -42,20 +48,25 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
private var loading = false private var loading = false
private var noMoreRows = false private var noMoreRows = false
private var loadGeneration = 0 private var loadGeneration = 0
private val logAdapter = LogAdapter()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding = FragmentViewLogsBinding.bind(view) binding = FragmentViewLogsBinding.bind(view)
binding!!.logsRefresh.setOnRefreshListener { loadLogs(reset = true) } 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 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 && !recyclerView.canScrollVertically(1)) {
if (!noMoreRows && !loading && scrollY >= scroll.getChildAt(0).height - scroll.height - dp(LOAD_MORE_THRESHOLD_DP)) {
loadLogs(reset = false) loadLogs(reset = false)
}
} }
} })
binding!!.scrollToTop.setOnClickListener { binding!!.scrollToTop.setOnClickListener {
binding?.logScroll?.smoothScrollTo(0, 0) binding?.logList?.smoothScrollToPosition(0)
} }
loadLogs(reset = true) loadLogs(reset = true)
} }
@@ -83,9 +94,10 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
val generation = loadGeneration val generation = loadGeneration
loading = true loading = true
binding?.logsRefresh?.isRefreshing = true binding?.logsRefresh?.isRefreshing = true
Thread { viewLifecycleOwner.lifecycleScope.launch {
val page = EncryptedNotificationLogStore(context).readNewest(nextCursor, PAGE_SIZE) val page = withContext(Dispatchers.IO) {
activity?.runOnUiThread { EncryptedNotificationLogStore(context).readNewest(nextCursor, PAGE_SIZE)
}
if (generation == loadGeneration) { if (generation == loadGeneration) {
nextCursor = page.nextCursor nextCursor = page.nextCursor
noMoreRows = nextCursor == null noMoreRows = nextCursor == null
@@ -95,12 +107,10 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
} }
loading = false loading = false
binding?.logsRefresh?.isRefreshing = false binding?.logsRefresh?.isRefreshing = false
} }
}.start()
} }
private fun render(view: FragmentViewLogsBinding) { private fun render(view: FragmentViewLogsBinding) {
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."
@@ -109,11 +119,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
} }
val settings = LogViewSettingsStore(requireContext()).load() val settings = LogViewSettingsStore(requireContext()).load()
val visibleRows = rows.filter { eventFor(it.entry.action) in settings.visibleEvents } val visibleRows = rows.filter { eventFor(it.entry.action) in settings.visibleEvents }
val metadataColumnWidth = metadataColumnWidth(view.logRows.width, visibleRows, settings) logAdapter.submit(visibleRows, settings, metadataColumnWidth(view.logList.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, metadataColumnWidth: Int): View = LinearLayout(requireContext()).apply { 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 imageId = row.entry.imageId
val appContext = requireContext().applicationContext val appContext = requireContext().applicationContext
val contentsContainer = this val contentsContainer = this
Thread { viewLifecycleOwner.lifecycleScope.launch {
EncryptedImageStore(appContext).read(imageId)?.let { bytes -> val bitmap = withContext(Dispatchers.IO) {
EncryptedImageStore(appContext).read(imageId)?.let { bytes ->
BitmapFactory.decodeByteArray(bytes, 0, bytes.size) BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}?.let { bitmap -> }
activity?.runOnUiThread { }
if (contentsContainer.isAttachedToWindow) { if (contentsContainer.isAttachedToWindow && bitmap != null) {
contentsContainer.addView(ImageView(contentsContainer.context).apply { contentsContainer.addView(ImageView(contentsContainer.context).apply {
setImageBitmap(bitmap) setImageBitmap(bitmap)
adjustViewBounds = true adjustViewBounds = true
@@ -246,10 +253,8 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
} }
} }
}) })
}
}
} }
}.start() }
} }
setOnClickListener { onToggleExpanded() } setOnClickListener { onToggleExpanded() }
setOnLongClickListener { showDeleteDialog(row.entry.id) } setOnLongClickListener { showDeleteDialog(row.entry.id) }
@@ -327,6 +332,48 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
} }
} }
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 = private fun timestamp(value: Long, settings: LogViewSettings, eventTimeZoneId: String?): String =
TimestampFormatter.format(requireContext(), value, settings, eventTimeZoneId) 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.") .setMessage("This permanently removes this log and its copied image, if any.")
.setNegativeButton("Cancel", null) .setNegativeButton("Cancel", null)
.setPositiveButton("Delete") { _, _ -> .setPositiveButton("Delete") { _, _ ->
Thread { viewLifecycleOwner.lifecycleScope.launch {
EncryptedNotificationLogStore(requireContext().applicationContext).delete(entryId) withContext(Dispatchers.IO) {
activity?.runOnUiThread { loadLogs(reset = true) } EncryptedNotificationLogStore(requireContext().applicationContext).delete(entryId)
}.start() }
loadLogs(reset = true)
}
} }
.show() .show()
return true return true
@@ -347,35 +396,37 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
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 list = currentBinding.logList
val rowsTop = currentBinding.logRows.top val manager = list.layoutManager as? LinearLayoutManager ?: return null
val topEdge = scroll.scrollY - rowsTop val collapsingItem = list.findContainingItemView(collapsingRow) ?: return null
val bottomEdge = topEdge + scroll.height val collapsingPosition = list.getChildAdapterPosition(collapsingItem)
val topRow = rowAt(currentBinding.logRows, topEdge) val topPosition = manager.findFirstVisibleItemPosition()
val bottomRow = rowAt(currentBinding.logRows, bottomEdge) val bottomPosition = manager.findLastVisibleItemPosition()
val topRow = manager.findViewByPosition(topPosition)
val bottomRow = manager.findViewByPosition(bottomPosition)
return when { return when {
topRow === collapsingRow && bottomRow === collapsingRow -> CollapseAnchor.CENTER_COLLAPSED_ROW(collapsingRow) topPosition == collapsingPosition && bottomPosition == collapsingPosition -> CollapseAnchor.CENTER_COLLAPSED_ROW(collapsingPosition)
topRow === collapsingRow && bottomRow != null -> topPosition == collapsingPosition && bottomRow != null ->
CollapseAnchor.KEEP_BOTTOM_EDGE(bottomRow, bottomRow.bottom - bottomEdge) CollapseAnchor.KEEP_BOTTOM_EDGE(bottomPosition, bottomRow.bottom - list.height)
bottomRow === collapsingRow && topRow != null -> bottomPosition == collapsingPosition && topRow != null ->
CollapseAnchor.KEEP_TOP_EDGE(topRow, topEdge - topRow.top) CollapseAnchor.KEEP_TOP_EDGE(topPosition, topRow.top)
else -> null else -> null
} }
} }
private fun restoreCollapseAnchor(anchor: CollapseAnchor, afterRestore: () -> Unit) { private fun restoreCollapseAnchor(anchor: CollapseAnchor, afterRestore: () -> Unit) {
val currentBinding = binding ?: return val currentBinding = binding ?: return
val scroll = currentBinding.logScroll val list = currentBinding.logList
val rows = currentBinding.logRows val manager = list.layoutManager as? LinearLayoutManager ?: return
rows.doOnPreDraw { list.doOnPreDraw {
if (binding !== currentBinding) return@doOnPreDraw if (binding !== currentBinding) return@doOnPreDraw
val target = when (anchor) { val row = manager.findViewByPosition(anchor.position) ?: return@doOnPreDraw
is CollapseAnchor.CENTER_COLLAPSED_ROW -> rows.top + anchor.row.top + anchor.row.height / 2 - scroll.height / 2 val delta = when (anchor) {
is CollapseAnchor.KEEP_TOP_EDGE -> rows.top + anchor.row.top + anchor.offset is CollapseAnchor.CENTER_COLLAPSED_ROW -> row.top + row.height / 2 - list.height / 2
is CollapseAnchor.KEEP_BOTTOM_EDGE -> rows.top + anchor.row.bottom - anchor.offset - scroll.height 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) list.scrollBy(0, delta)
scroll.scrollTo(0, target.coerceIn(0, maximum))
afterRestore() 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 dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
private fun sp(value: Float): Float = value * resources.displayMetrics.scaledDensity 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 { private sealed interface CollapseAnchor {
data class CENTER_COLLAPSED_ROW(val row: View) : CollapseAnchor val position: Int
data class KEEP_TOP_EDGE(val row: View, val offset: Int) : CollapseAnchor data class CENTER_COLLAPSED_ROW(override val position: Int) : CollapseAnchor
data class KEEP_BOTTOM_EDGE(val row: View, val offset: 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 { private companion object {
+15 -27
View File
@@ -8,36 +8,24 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<ScrollView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/log_scroll" android:id="@+id/log_list"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"
android:clipToPadding="false"
<LinearLayout android:paddingStart="@dimen/log_page_horizontal_padding"
android:layout_width="match_parent" android:paddingTop="@dimen/page_padding"
android:layout_height="wrap_content" android:paddingEnd="@dimen/log_page_horizontal_padding"
android:orientation="vertical" android:paddingBottom="@dimen/page_padding" />
android:paddingStart="@dimen/log_page_horizontal_padding"
android:paddingTop="@dimen/page_padding"
android:paddingEnd="@dimen/log_page_horizontal_padding"
android:paddingBottom="@dimen/page_padding">
<TextView
android:id="@+id/empty_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="No logs yet." />
<LinearLayout
android:id="@+id/log_rows"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<TextView
android:id="@+id/empty_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="24dp"
android:text="No logs yet." />
<com.google.android.material.floatingactionbutton.FloatingActionButton <com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/scroll_to_top" android:id="@+id/scroll_to_top"
android:layout_width="wrap_content" android:layout_width="wrap_content"