Add full-screen notification image viewer

This commit is contained in:
ajp_anton
2026-07-27 17:43:42 +00:00
parent d09fb4fa09
commit 83a71378fb
3 changed files with 259 additions and 0 deletions
@@ -0,0 +1,121 @@
package se.ajpanton.notificationlog
import android.app.Dialog
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.widget.FrameLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.os.bundleOf
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.fragment.app.DialogFragment
import se.ajpanton.notificationlog.data.EncryptedImageStore
/** Full-screen image viewer kept within MainActivity so opening it does not re-lock the app. */
class ImageViewerDialogFragment : DialogFragment() {
private lateinit var root: FrameLayout
private lateinit var image: ZoomImageView
private lateinit var progress: ProgressBar
private var controlsVisible = false
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
root = FrameLayout(requireContext()).apply { setBackgroundColor(Color.BLACK) }
image = ZoomImageView(requireContext()).apply {
visibility = View.GONE
onSingleTap = ::toggleSystemControls
}
progress = ProgressBar(requireContext()).apply { isIndeterminate = true }
root.addView(image, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
root.addView(progress, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
return Dialog(requireContext()).apply {
requestWindowFeature(Window.FEATURE_NO_TITLE)
setContentView(root)
}
}
override fun onStart() {
super.onStart()
dialog?.window?.apply {
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
setBackgroundDrawable(ColorDrawable(Color.BLACK))
WindowCompat.setDecorFitsSystemWindows(this, false)
addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
}
hideSystemControls()
loadImage(requireArguments().getString(ARGUMENT_IMAGE_ID)!!)
}
private fun loadImage(imageId: String) {
val targetWidth = resources.displayMetrics.widthPixels
val targetHeight = resources.displayMetrics.heightPixels
val appContext = requireContext().applicationContext
Thread {
val bitmap = runCatching {
EncryptedImageStore(appContext).read(imageId)?.let { bytes ->
decodeForViewer(bytes, targetWidth, targetHeight)
}
}.getOrNull()
activity?.runOnUiThread {
if (!isAdded) return@runOnUiThread
progress.visibility = View.GONE
if (bitmap == null) {
root.addView(TextView(requireContext()).apply {
text = "[image]"
setTextColor(Color.WHITE)
textSize = 18f
}, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
} else {
image.setImageBitmap(bitmap)
image.visibility = View.VISIBLE
}
}
}.start()
}
private fun decodeForViewer(bytes: ByteArray, targetWidth: Int, targetHeight: Int): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
var sampleSize = 1
while (bounds.outWidth / sampleSize > targetWidth * MAX_SCREEN_SCALE ||
bounds.outHeight / sampleSize > targetHeight * MAX_SCREEN_SCALE
) sampleSize *= 2
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sampleSize })
}
private fun toggleSystemControls() {
if (controlsVisible) hideSystemControls() else showSystemControls()
}
private fun hideSystemControls() {
val window = dialog?.window ?: return
WindowInsetsControllerCompat(window, root).apply {
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
hide(WindowInsetsCompat.Type.systemBars())
}
controlsVisible = false
}
private fun showSystemControls() {
val window = dialog?.window ?: return
WindowInsetsControllerCompat(window, root).show(WindowInsetsCompat.Type.systemBars())
controlsVisible = true
}
companion object {
private const val ARGUMENT_IMAGE_ID = "image_id"
private const val MAX_SCREEN_SCALE = 4
fun newInstance(imageId: String) = ImageViewerDialogFragment().apply {
arguments = bundleOf(ARGUMENT_IMAGE_ID to imageId)
}
}
}
@@ -198,6 +198,11 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
maxHeight = dp(240) maxHeight = dp(240)
contentDescription = "Notification image" contentDescription = "Notification image"
setPadding(0, dp(4), 0, 0) setPadding(0, dp(4), 0, 0)
setOnClickListener {
if (isAdded) {
ImageViewerDialogFragment.newInstance(imageId).show(parentFragmentManager, IMAGE_VIEWER_TAG)
}
}
}) })
} }
} }
@@ -285,5 +290,6 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
const val SCROLL_TO_TOP_THRESHOLD_DP = 120 const val SCROLL_TO_TOP_THRESHOLD_DP = 120
const val LOAD_MORE_THRESHOLD_DP = 480 const val LOAD_MORE_THRESHOLD_DP = 480
const val PAGE_SIZE = 80 const val PAGE_SIZE = 80
const val IMAGE_VIEWER_TAG = "image-viewer"
} }
} }
@@ -0,0 +1,132 @@
package se.ajpanton.notificationlog
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Matrix
import android.graphics.RectF
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import androidx.appcompat.widget.AppCompatImageView
/** A fit-to-screen image which supports pinch zoom and one-finger panning. */
class ZoomImageView @JvmOverloads constructor(
context: Context,
attributes: AttributeSet? = null,
) : AppCompatImageView(context, attributes) {
var onSingleTap: (() -> Unit)? = null
private val transform = Matrix()
private val bounds = RectF()
private var minimumScale = 1f
private var currentScale = 1f
private var lastX = 0f
private var lastY = 0f
private var dragging = false
private val scaleDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
val scale = (currentScale * detector.scaleFactor).coerceIn(minimumScale, minimumScale * MAX_ZOOM)
transform.postScale(scale / currentScale, scale / currentScale, detector.focusX, detector.focusY)
currentScale = scale
constrain()
return true
}
})
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapConfirmed(event: MotionEvent): Boolean {
onSingleTap?.invoke()
return true
}
override fun onDoubleTap(event: MotionEvent): Boolean {
val target = if (currentScale > minimumScale * 1.1f) minimumScale else minimumScale * DOUBLE_TAP_ZOOM
transform.postScale(target / currentScale, target / currentScale, event.x, event.y)
currentScale = target
constrain()
return true
}
})
init {
scaleType = ScaleType.MATRIX
}
override fun setImageBitmap(bitmap: Bitmap?) {
super.setImageBitmap(bitmap)
post(::resetToFit)
}
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
super.onSizeChanged(width, height, oldWidth, oldHeight)
resetToFit()
}
override fun onTouchEvent(event: MotionEvent): Boolean {
scaleDetector.onTouchEvent(event)
gestureDetector.onTouchEvent(event)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastX = event.x
lastY = event.y
dragging = false
}
MotionEvent.ACTION_MOVE -> if (!scaleDetector.isInProgress) {
val dx = event.x - lastX
val dy = event.y - lastY
if (dx != 0f || dy != 0f) {
dragging = true
transform.postTranslate(dx, dy)
constrain()
}
lastX = event.x
lastY = event.y
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (dragging) performClick()
}
}
return true
}
override fun performClick(): Boolean = super.performClick()
private fun resetToFit() {
val drawable = drawable ?: return
if (width == 0 || height == 0 || drawable.intrinsicWidth <= 0 || drawable.intrinsicHeight <= 0) return
minimumScale = minOf(width.toFloat() / drawable.intrinsicWidth, height.toFloat() / drawable.intrinsicHeight)
currentScale = minimumScale
transform.reset()
transform.postScale(minimumScale, minimumScale)
transform.postTranslate(
(width - drawable.intrinsicWidth * minimumScale) / 2f,
(height - drawable.intrinsicHeight * minimumScale) / 2f,
)
imageMatrix = transform
}
private fun constrain() {
val drawable = drawable ?: return
bounds.set(0f, 0f, drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat())
transform.mapRect(bounds)
val dx = when {
bounds.width() <= width -> width / 2f - bounds.centerX()
bounds.left > 0f -> -bounds.left
bounds.right < width -> width - bounds.right
else -> 0f
}
val dy = when {
bounds.height() <= height -> height / 2f - bounds.centerY()
bounds.top > 0f -> -bounds.top
bounds.bottom < height -> height - bounds.bottom
else -> 0f
}
transform.postTranslate(dx, dy)
imageMatrix = transform
}
private companion object {
const val MAX_ZOOM = 6f
const val DOUBLE_TAP_ZOOM = 2.5f
}
}