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() 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) { 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): 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 = 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 } }