diff --git a/app/src/main/java/se/ajpanton/notificationlog/ImageViewerDialogFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/ImageViewerDialogFragment.kt new file mode 100644 index 0000000..3c1c5a0 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/ImageViewerDialogFragment.kt @@ -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) + } + } +} diff --git a/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt index 0425de0..ce06810 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt @@ -198,6 +198,11 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { maxHeight = dp(240) contentDescription = "Notification image" 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 LOAD_MORE_THRESHOLD_DP = 480 const val PAGE_SIZE = 80 + const val IMAGE_VIEWER_TAG = "image-viewer" } } diff --git a/app/src/main/java/se/ajpanton/notificationlog/ZoomImageView.kt b/app/src/main/java/se/ajpanton/notificationlog/ZoomImageView.kt new file mode 100644 index 0000000..9f4b171 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/ZoomImageView.kt @@ -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 + } +}