view app/src/main/java/com/bartsent/simpleresizer/EditImage.kt @ 21:7e7e71724770

No longer breaks when phone rotated.
author David Barts <n5jrn@me.com>
date Mon, 22 Feb 2021 17:49:27 -0800
parents eedf995462d9
children c29f941d09cd
line wrap: on
line source

package com.bartsent.simpleresizer

import android.annotation.SuppressLint
import android.content.ContentValues
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Matrix
import android.net.Uri
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.EditText
import android.widget.ProgressBar
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.PopupMenu
import com.bartsent.simpleresizer.databinding.ActivityEditImageBinding
import com.bartsent.simpleresizer.lib.getScaledInstance
import java.io.File
import java.io.IOException
import kotlin.concurrent.thread
import androidx.preference.PreferenceManager
import com.bartsent.simpleresizer.lib.ThreadPools

class EditImage : AppCompatActivity() {
    private object State {
        var uri: Uri? = null
        var bitmap: Bitmap? = null
    }

    private val STDDIMS = arrayOf<Int>(1600, 1280, 1024, 800, 640, 512, 400, 320).apply {
        sort()
    }

    private lateinit var binding: ActivityEditImageBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityEditImageBinding.inflate(layoutInflater)
        setContentView(binding.root)
        PreferenceManager.setDefaultValues(applicationContext, R.xml.root_preferences, false)
    }

    override fun onCreateOptionsMenu(menu: Menu?): Boolean {
        menuInflater.inflate(R.menu.menu_edit, menu)
        return super.onCreateOptionsMenu(menu)
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        if (item.itemId == R.id.settings_item) {
            startActivity(
                Intent(Intent.ACTION_APPLICATION_PREFERENCES, null, this,
                    SettingsActivity::class.java))
            return true
        }
        return false
    }

    // Cribbed from: https://stackoverflow.com/questions/5568874/how-to-extract-the-file-name-from-uri-returned-from-intent-action-get-content
    private fun getFileName(uri: Uri): String? {
        var result: String? = null
        if (uri.scheme == "content") {
            contentResolver.query(uri, null, null, null, null).use { cursor ->
                if (cursor != null && cursor.moveToFirst())
                    result = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
            }
        }
        if (result == null) {
            val uriPath = uri.path
            result = uriPath?.substring(uriPath.lastIndexOf('/') + 1)
        }
        return result
    }

    private fun showFatalError(message: String): Unit {
        AlertDialog.Builder(this).also {
            it.setMessage(message)
            it.setNeutralButton(R.string.ok_text) { dialog, _ ->
                dialog.dismiss()
            }
            it.setOnDismissListener {
                finish()
            }
            it.create()
        }.show()
    }

    override fun onResume() {
        super.onResume()

        // Read the URI, die if we can't.
        val imageUri = intent?.data
        if (imageUri == null) {
            if (State.bitmap == null)
                showFatalError(getString(R.string.error_no_uri))
            else
                setImage(State.bitmap!!)
            return
        }

        // User has opened a new image.
        if (imageUri != State.uri) {
            State.uri = imageUri
            binding.progressBar.visibility = ProgressBar.VISIBLE
            ThreadPools.WORKERS.execute {
                State.bitmap = contentResolver.openInputStream(imageUri).use {
                    BitmapFactory.decodeStream(it)
                }
                runOnUiThread {
                    binding.progressBar.visibility = ProgressBar.INVISIBLE
                    if (State.bitmap == null)
                        showFatalError(getString(R.string.error_bad_image))
                    else
                        setImage(State.bitmap!!)
                }
            }
            return
        }

        // Rotation (of the phone).
        val oldBitmap = State.bitmap
        if (oldBitmap != null)
            setImage(oldBitmap)
    }

    private fun setImage(image: Bitmap): Unit {
        binding.imageSize.text = getString(R.string.image_size_text, image.width, image.height)
        binding.image.setImageBitmap(image)
        State.bitmap = image
        binding.root.invalidate()
    }

    private val CUSTOM = 999998
    private val CANCEL = 999999

    fun scaleClicked(view: View): Unit {
        val (maxSize, horizontal) = State.bitmap!!.run {
            if (width > height) Pair(width, true) else Pair(height, false)
        }
        PopupMenu(this, view).apply {
            menu.run {
                STDDIMS.filter { it < maxSize }.forEach { major ->
                    val minor = major * 3 / 4
                    add(Menu.NONE, major, Menu.NONE,
                            if (horizontal) "$major ✕ $minor" else "$minor ✕ $major")
                }
                add(Menu.NONE, CUSTOM, Menu.NONE, R.string.custom_text)
                add(Menu.NONE, CANCEL, Menu.NONE, R.string.cancel_text)
            }
            setOnMenuItemClickListener(::scaleMenuItemClicked)
            show()
        }
    }

    private fun scaleMenuItemClicked(item: MenuItem) : Boolean =
        when (item.itemId) {
            CUSTOM -> { showCustomScaleDialog(); true }
            CANCEL -> true
            in STDDIMS -> { doScale(item.itemId); true }
            else -> false
        }

    private fun copyColorSpace(old: Bitmap, new: Bitmap): Unit {
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
            val oldColorSpace = old.colorSpace
            if (oldColorSpace != null)
                new.setColorSpace(oldColorSpace)
        }
    }

    private fun doScale(newMax: Int): Unit {
        val oldBitmap = State.bitmap!!
        val factor = newMax.toDouble() / maxOf(oldBitmap.width, oldBitmap.height).toDouble()
        if (factor >= 1.0) {
            throw IllegalArgumentException("can only scale down")
        }
        val scaleType = PreferenceManager.getDefaultSharedPreferences(applicationContext).getString(
            "scale_type", "speed" )
        Log.d("EditImage", "scaling, scale_type = $scaleType")
        binding.progressBar.visibility = ProgressBar.VISIBLE
        ThreadPools.WORKERS.execute {
            val newWidth = (oldBitmap.width.toDouble() * factor + 0.5).toInt()
            val newHeight = (oldBitmap.height.toDouble() * factor + 0.5).toInt()
            val newBitmap = if (scaleType == "quality")
                oldBitmap.getScaledInstance(newWidth, newHeight)
            else
                Bitmap.createScaledBitmap(oldBitmap, newWidth, newHeight, true)
            runOnUiThread {
                binding.progressBar.visibility = ProgressBar.INVISIBLE
                setImage(newBitmap)
            }
        }
    }

    private fun showCustomScaleDialog(): Unit {
        val image = State.bitmap!!
        val curMaxDim = maxOf(image.width, image.height)
        val dialogView = layoutInflater.inflate(R.layout.dialog_custom_scale, null)
        AlertDialog.Builder(this).also {
            it.setPositiveButton(R.string.ok_text) { dialog, _ ->
                val maxDim = dialogView.findViewById<EditText>(R.id.custom_scale)?.text.toString().toIntOrNull()
                dialog.dismiss()
                if (maxDim == null || maxDim < 8 || maxDim >= curMaxDim) {
                    Toast.makeText(applicationContext, R.string.bad_scale, Toast.LENGTH_LONG).show()
                } else {
                    doScale(maxDim)
                }
            }
            it.setNegativeButton(R.string.cancel_text) { dialog, _ ->
                dialog.dismiss()
            }
            it.setView(dialogView)
            it.create()
        }.show()
    }

    fun rotateClicked(view: View): Unit {
        PopupMenu(this, view).apply {
            setOnMenuItemClickListener {
                when(it.itemId) {
                    R.id.r_90_cw -> {
                        doRotate(90)
                        true
                    }
                    R.id.r_180 -> {
                        doRotate(180)
                        true
                    }
                    R.id.r_90_ccw -> {
                        doRotate(270)
                        true
                    }
                    R.id.r_cancel -> true
                    else -> false
                }
            }
            inflate(R.menu.rotate)
            show()
        }
    }

    private fun doRotate(deg: Int): Unit {
        val oldBitmap = State.bitmap!!
        if (deg % 90 != 0) {
            throw IllegalArgumentException("$deg not a multiple of 90")
        }
        val (w, h) = if (deg % 180 == 0) Pair(oldBitmap.width, oldBitmap.height) else Pair(oldBitmap.height, oldBitmap.width)
        val rotater = Matrix().apply {
            setRotate(deg.toFloat(), oldBitmap.width.toFloat()/2.0f, oldBitmap.height.toFloat()/2.0f)
            postTranslate((w - oldBitmap.width).toFloat()/2.0f, (h - oldBitmap.height).toFloat()/2.0f)
        }
        binding.progressBar.visibility = ProgressBar.VISIBLE
        ThreadPools.WORKERS.execute {
            val newBitmap = Bitmap.createBitmap(w, h, oldBitmap.config)
            copyColorSpace(oldBitmap, newBitmap)
            Canvas(newBitmap).run {
                drawBitmap(oldBitmap, rotater, null)
            }
            runOnUiThread {
                binding.progressBar.visibility = ProgressBar.INVISIBLE
                setImage(newBitmap)
            }
        }
    }

     fun cancelClicked(view: View): Unit {
        State.uri = null
        State.bitmap = null
        finish()
    }

    fun doneClicked(view: View): Unit {
        val contentValues = ContentValues().apply {
            var fileName = getFileName(State.uri!!)
            if (fileName == null) {
                val d = java.util.Date()
                fileName = "IMG_%tY%tm%td_%tH%tM%tS.jpg".format(d, d, d, d, d, d)
            } else {
                val dot = fileName.lastIndexOf('.')
                if (dot == -1) {
                    fileName = fileName + ".jpg"
                } else {
                    val fileExt = fileName.substring(dot).toLowerCase()
                    if (fileExt !in setOf(".jpg", ".jpeg"))
                        fileName = fileName.substring(0..dot) + ".jpg"
                }
            }
            put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
            put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
            if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
                put(MediaStore.MediaColumns.RELATIVE_PATH,
                    File(Environment.DIRECTORY_PICTURES, getString(R.string.app_name)).getPath())
            }
        }
        binding.progressBar.visibility = ProgressBar.VISIBLE
        ThreadPools.WORKERS.execute {
            var errorMessage: String? = null
            try {
                val myUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
                if (myUri == null) {
                    throw IOException(getString(R.string.error_create_mediastore))
                }
                val stream = contentResolver.openOutputStream(myUri)
                if (stream == null) {
                    throw IOException(getString(R.string.error_get_output))
                }
                val quality = maxOf(0, minOf(100,
                        PreferenceManager.getDefaultSharedPreferences(applicationContext).getInt(
                                "jpeg_quality", 85)))
                Log.d("EditImage", "saving, jpeg_quality = $quality")
                stream.use {
                    if (!State.bitmap!!.compress(Bitmap.CompressFormat.JPEG, quality, it)) {
                        throw IOException(getString(R.string.error_save_bitmap))
                    }
                }
            } catch (ioe: IOException) {
                errorMessage = ioe.message ?: getString(R.string.error_io)
            }
            runOnUiThread {
                binding.progressBar.visibility = ProgressBar.INVISIBLE
                if (errorMessage == null)
                    finish()
                else
                    Toast.makeText(applicationContext, errorMessage, Toast.LENGTH_LONG).show()
            }
        }
    }
}