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 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
@@ -96,11 +108,9 @@ 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 {
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
@@ -249,8 +256,6 @@ 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<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)
@@ -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 {
viewLifecycleOwner.lifecycleScope.launch {
withContext(Dispatchers.IO) {
EncryptedNotificationLogStore(requireContext().applicationContext).delete(entryId)
activity?.runOnUiThread { loadLogs(reset = true) }
}.start()
}
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 {
+7 -19
View File
@@ -8,36 +8,24 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:id="@+id/log_scroll"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/log_list"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
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">
android:paddingBottom="@dimen/page_padding" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<TextView
android:id="@+id/empty_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_margin="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>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/scroll_to_top"
android:layout_width="wrap_content"