Files
notifications-master/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt
T

241 lines
11 KiB
Kotlin

package se.ajpanton.notificationlog
import android.graphics.BitmapFactory
import android.os.Bundle
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.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.TimestampZone
import java.text.DateFormat
import java.util.Date
import androidx.fragment.app.Fragment
class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
private var binding: FragmentViewLogsBinding? = null
private val expandedIds = mutableSetOf<String>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding = FragmentViewLogsBinding.bind(view)
binding!!.logsRefresh.setOnRefreshListener(::loadLogs)
loadLogs()
}
override fun onResume() {
super.onResume()
loadLogs()
}
override fun onDestroyView() {
binding = null
super.onDestroyView()
}
private fun loadLogs() {
val context = requireContext().applicationContext
binding?.logsRefresh?.isRefreshing = true
Thread {
val imageStore = EncryptedImageStore(context)
val rows = EncryptedNotificationLogStore(context).readAll()
.sortedByDescending { it.recordedAtEpochMillis }
.map { entry -> LogRow(entry, entry.imageId?.let(imageStore::read)) }
activity?.runOnUiThread {
binding?.let { render(it, rows) }
binding?.logsRefresh?.isRefreshing = false
}
}.start()
}
private fun render(view: FragmentViewLogsBinding, rows: List<LogRow>) {
view.logRows.removeAllViews()
view.emptyView.visibility = if (rows.isEmpty()) View.VISIBLE else View.GONE
val settings = LogViewSettingsStore(requireContext()).load()
rows.forEach { row -> view.logRows.addView(logRowView(row, settings)) }
}
private fun logRowView(row: LogRow, settings: LogViewSettings): 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 = dp(6)
}
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 metadataPill = metadataPill(metadata).apply {
layoutParams = LinearLayout.LayoutParams(
if (hasContents) 0 else LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
if (hasContents) LEFT_PILL_WEIGHT else 0f,
).apply {
if (hasContents) marginEnd = dp(6)
}
}
if (metadata.isNotEmpty()) addView(metadataPill)
if (hasContents) {
val contentsPill = contentsPill(row, contents!!, metadata.size.coerceAtLeast(1), expanded) {
if (!expandedIds.add(row.entry.id)) expandedIds.remove(row.entry.id)
renderExpanded(row.entry.id in expandedIds)
}.apply {
layoutParams = LinearLayout.LayoutParams(
if (metadata.isNotEmpty()) 0 else LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
if (metadata.isNotEmpty()) RIGHT_PILL_WEIGHT else 0f,
)
}
addView(contentsPill)
if (metadata.isNotEmpty()) equalizePillHeights(metadataPill, contentsPill)
}
}
renderExpanded(row.entry.id in expandedIds)
setOnLongClickListener {
showDeleteDialog(row.entry.id)
}
}
private fun metadataPill(values: List<MetadataValue>): LinearLayout = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
background = androidx.core.content.ContextCompat.getDrawable(context, R.drawable.log_pill_background)
setPadding(dp(12), dp(8), dp(12), dp(8))
values.forEach { value ->
addView(TextView(context).apply {
text = value.text
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
textSize = value.textSize
if (value.indented) setPadding(dp(8), 0, 0, 0)
setLineSpacing(0f, 1f)
if (value.bold) setTypeface(typeface, android.graphics.Typeface.BOLD)
})
}
}
private fun contentsPill(
row: LogRow,
contents: String,
collapsedLines: Int,
expanded: Boolean,
onToggleExpanded: () -> Unit,
): LinearLayout = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
background = androidx.core.content.ContextCompat.getDrawable(context, R.drawable.log_pill_background)
setPadding(dp(12), dp(8), dp(12), dp(8))
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.imageBytes?.let { bytes ->
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { bitmap ->
addView(ImageView(context).apply {
setImageBitmap(bitmap)
adjustViewBounds = true
maxHeight = dp(240)
contentDescription = "Notification image"
setPadding(0, dp(4), 0, 0)
})
}
}
}
setOnClickListener { onToggleExpanded() }
setOnLongClickListener { showDeleteDialog(row.entry.id) }
}
private fun metadataValues(entry: NotificationLogEntry, settings: LogViewSettings): List<MetadataValue> = buildList {
if (LogField.TIMESTAMP in settings.visibleFields) add(MetadataValue(timestamp(entry.recordedAtEpochMillis, settings.timestampZone, entry.eventTimeZoneId), 12f))
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))
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.timestampZone, 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 timestamp(value: Long, zone: TimestampZone, eventTimeZoneId: String?): String = DateFormat.getDateTimeInstance().apply {
timeZone = when (zone) {
TimestampZone.UTC -> java.util.TimeZone.getTimeZone("UTC")
TimestampZone.LOCAL_NOW -> java.util.TimeZone.getDefault()
TimestampZone.EVENT_LOCAL -> eventTimeZoneId?.let(java.util.TimeZone::getTimeZone) ?: java.util.TimeZone.getDefault()
}
}.format(Date(value))
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") { _, _ ->
Thread {
EncryptedNotificationLogStore(requireContext().applicationContext).delete(entryId)
activity?.runOnUiThread(::loadLogs)
}.start()
}
.show()
return true
}
private fun equalizePillHeights(left: View, right: View) {
left.post {
val targetHeight = maxOf(left.height, right.height)
if (left.minimumHeight != targetHeight || right.minimumHeight != targetHeight) {
left.minimumHeight = targetHeight
right.minimumHeight = targetHeight
}
}
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
private data class LogRow(val entry: NotificationLogEntry, val imageBytes: ByteArray?)
private data class MetadataValue(val text: String, val textSize: Float, val indented: Boolean = false, val bold: Boolean = false)
private companion object {
const val LEFT_PILL_WEIGHT = 0.42f
const val RIGHT_PILL_WEIGHT = 0.58f
}
}