Recycle paged log rows
This commit is contained in:
@@ -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
|
||||||
@@ -96,11 +108,9 @@ 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 {
|
||||||
|
val bitmap = withContext(Dispatchers.IO) {
|
||||||
EncryptedImageStore(appContext).read(imageId)?.let { bytes ->
|
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
|
||||||
@@ -249,8 +256,6 @@ 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 {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
EncryptedNotificationLogStore(requireContext().applicationContext).delete(entryId)
|
EncryptedNotificationLogStore(requireContext().applicationContext).delete(entryId)
|
||||||
activity?.runOnUiThread { loadLogs(reset = true) }
|
}
|
||||||
}.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 {
|
||||||
|
|||||||
@@ -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:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:paddingStart="@dimen/log_page_horizontal_padding"
|
android:paddingStart="@dimen/log_page_horizontal_padding"
|
||||||
android:paddingTop="@dimen/page_padding"
|
android:paddingTop="@dimen/page_padding"
|
||||||
android:paddingEnd="@dimen/log_page_horizontal_padding"
|
android:paddingEnd="@dimen/log_page_horizontal_padding"
|
||||||
android:paddingBottom="@dimen/page_padding">
|
android:paddingBottom="@dimen/page_padding" />
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/empty_view"
|
android:id="@+id/empty_view"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="24dp"
|
android:layout_margin="24dp"
|
||||||
android:text="No logs yet." />
|
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
|
<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"
|
||||||
|
|||||||
Reference in New Issue
Block a user