Add full-screen notification image viewer
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user