view app/src/main/java/com/bartsent/simpleresizer/EditImage.kt @ 16:3ed74dc0e34a

Fix error messages, make scaling menu more user-friendly, fix return from settings.
author David Barts <n5jrn@me.com>
date Sun, 21 Feb 2021 21:43:54 -0800
parents 20da616dcda0
children eedf995462d9
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

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
        }

        // Being stateful stops data loss when the phone gets rotated.
        if (imageUri != State.uri) {
            State.uri = imageUri
            State.bitmap = contentResolver.openInputStream(imageUri).use {
                BitmapFactory.decodeStream(it)
            }
        }
        if (State.bitmap == null) {
            showFatalError(getString(R.string.error_bad_image))
            return
        }
        setImage(State.bitmap!!)
    }

    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
    }

    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()
        }
    }

    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)
        }
    }

    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
        thread {
            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)
            }
        }
    }

    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()
        }
    }

    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
        thread {
            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())
            }
        }
        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) {
            Toast.makeText(applicationContext, ioe.message ?: getString(R.string.error_io), Toast.LENGTH_LONG).show()
        }
        finish()
    }
}