view app/src/main/java/com/bartsent/simpleresizer/EditImage.kt @ 46:8205f3c17500 default tip

Tweak messages a little.
author David Barts <n5jrn@me.com>
date Wed, 14 Apr 2021 08:18:23 -0700
parents 44848342c321
children
line wrap: on
line source

package com.bartsent.simpleresizer

import android.Manifest
import android.content.ContentValues
import android.content.Intent
import android.content.pm.PackageManager
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 androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager
import com.bartsent.simpleresizer.databinding.ActivityEditImageBinding
import com.bartsent.simpleresizer.lib.ThreadPools
import com.bartsent.simpleresizer.lib.getScaledInstance
import com.google.android.material.floatingactionbutton.FloatingActionButton
import java.io.File
import java.io.IOException
import java.util.concurrent.Callable
import java.util.concurrent.Future

class EditImage : AppCompatActivity() {
    class State: ViewModel() {
        var uri: Uri? = null
        var bitmap: Bitmap? = null
        var reader: Future<Unit>? = null
        var sharable: Boolean = false
        var savedAs: Uri? = null
    }
    private lateinit var viewModel: State

    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)
        viewModel = ViewModelProvider(this).get(State::class.java)
    }

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

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        when (item.itemId) {
            R.id.settings_item -> {
                startActivity(
                    Intent(Intent.ACTION_APPLICATION_PREFERENCES, null, this,
                        SettingsActivity::class.java))
                return true
            }
            R.id.about_item -> {
                startActivity(Intent(this, About::class.java ))
                return true
            }
            else -> 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()
    }

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

    override fun onResume() {
        super.onResume()

        // Read the URI, die if we can't.
        val imageUri = intent?.data ?: intent?.extras?.get(Intent.EXTRA_STREAM) as? Uri
        if (imageUri == null) {
            if (viewModel.bitmap == null)
                showFatalError(getString(R.string.error_no_uri))
            else
                setImage(viewModel.bitmap!!)
            return
        }

        // User has opened a new image.
        if (imageUri != viewModel.uri) {
            viewModel.uri = imageUri
            makeMundane()
            binding.progressBar.visibility = ProgressBar.VISIBLE
            viewModel.reader = ThreadPools.WORKERS.submit(Callable<Unit> {
                val newBitmap = contentResolver.openInputStream(imageUri).use {
                    BitmapFactory.decodeStream(it)
                }
                runOnUiThread {
                    binding.progressBar.visibility = ProgressBar.INVISIBLE
                    if (newBitmap == null)
                        showFatalError(getString(R.string.error_bad_image))
                    else
                        setImage(newBitmap)
                    viewModel.reader = null
                }
            })
            return
        }

        // Rotation (of the phone).
        val oldBitmap = viewModel.bitmap
        if (oldBitmap != null)
            setImage(oldBitmap)
        binding.fabulous.visibility = if (viewModel.sharable) FloatingActionButton.VISIBLE else FloatingActionButton.GONE
    }

    override fun onDestroy() {
        // Read tasks may get badly constipated, since the image may well be on
        // cloud server like Google Pictures. So be sure to terminate any active
        // read task with extreme prejudice.
        val reader = viewModel.reader
        if (reader != null) {
            reader.cancel(true)
            viewModel.reader = null
        }
        super.onDestroy()
    }

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

    private fun unsetImage(): Unit {
        viewModel.uri = null
        viewModel.bitmap = null
    }

    private val CUSTOM = 999998
    private val CANCEL = 999999

    fun scaleClicked(view: View): Unit {
        val (maxSize, horizontal) = viewModel.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 = viewModel.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)
                makeFabulous()
                viewModel.savedAs = null
            }
        }
    }

    private fun showCustomScaleDialog(): Unit {
        val image = viewModel.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 = viewModel.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)
                makeFabulous()
                viewModel.savedAs = null
            }
        }
    }

    fun cancelClicked(view: View): Unit {
        unsetImage()
        makeMundane()
        finish()
    }

    private fun makeFabulous(): Unit {
        binding.fabulous.visibility = FloatingActionButton.VISIBLE
        viewModel.sharable = true
    }

    private fun makeMundane(): Unit {
        binding.fabulous.visibility = FloatingActionButton.GONE
        viewModel.sharable = false
    }

    private val SHARE_OPERATION = 43
    private val DONE_OPERATION = 77

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray): Unit {
        if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
            saveOperation(requestCode)
        } else {
            showError(getString(R.string.error_unable_no_permissions))
        }
    }

    private fun requestWritePermission(requestCode: Int): Unit {
        requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), requestCode)
    }

    fun shareClicked(view: View): Unit = saveOperation(SHARE_OPERATION)

    private fun doShare(uri: Uri): Unit  {
        val shareIntent: Intent = Intent().apply {
            action = Intent.ACTION_SEND
            putExtra(Intent.EXTRA_STREAM, uri)
            type = "image/jpeg"
        }
        startActivity(Intent.createChooser(shareIntent, resources.getText(R.string.share_text)))
    }

    fun doneClicked(view: View): Unit = saveOperation(DONE_OPERATION)

    private fun doDone(): Unit {
        unsetImage()
        makeMundane()
        finish()
    }

    private fun doSaveOperation(requestCode: Int, uri: Uri) {
        when (requestCode) {
            SHARE_OPERATION -> doShare(uri)
            DONE_OPERATION -> doDone()
            else -> Log.e("EditImage", "invalid requestCode: $requestCode")
        }
    }

    private fun saveOperation(requestCode: Int): Unit {
        // Trivial case: no edits since the last save.
        val savedAs = viewModel.savedAs
        if (savedAs != null) {
            doSaveOperation(requestCode, savedAs)
            return
        }

        // If we get here, we must save. We might need (but lack)
        // WRITE_EXTERNAL_STORAGE permission. If so, request it, and arrange to
        // be called again when it is granted.
        if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) {
            if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) {
                if (shouldShowRequestPermissionRationale(Manifest.permission.WRITE_EXTERNAL_STORAGE))
                    AlertDialog.Builder(this).also {
                        it.setMessage(getString(R.string.permission_needed, getString(R.string.app_name)))
                        it.setNeutralButton(R.string.ok_text) { dialog, _ ->
                            dialog.dismiss()
                            requestWritePermission(requestCode)
                        }
                        it.create()
                    }.show()
                else
                    requestWritePermission(requestCode)
                return
            }
        }

        // If we get here, we both need to save, and have permission to save.
        val image = viewModel.bitmap!!
        binding.progressBar.visibility = ProgressBar.VISIBLE
        ThreadPools.WORKERS.execute {
            val contentValues = ContentValues().apply {
                var fileName = getFileName(viewModel.uri!!)
                if (fileName == null) {
                    val d = java.util.Date()
                    fileName = "IMG_%tY%tm%td_%tH%tM%tS".format(d, d, d, d, d, d)
                }
                val dot = fileName.lastIndexOf('.')
                if (dot != -1)
                    fileName = fileName.substring(0, dot)
                fileName = "${fileName}_${image.width}x${image.height}.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)).path)
                }
            }
            var errorMessage: String? = null
            val myUri = try {
                contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
            } catch (e: Exception) {
                Log.e("EditImage", "unexpected exception when saving!", e)
                null
            }
            try {
                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 (!image.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)
            } catch (se: SecurityException) {
                errorMessage = se.message ?: getString(R.string.error_security)
            } catch (e: Exception) {
                Log.e("EditImage", "unexpected exception when saving!", e)
                errorMessage = e.message ?: getString(R.string.error_unexpected, e::class.qualifiedName)
            }
            runOnUiThread {
                binding.progressBar.visibility = ProgressBar.INVISIBLE
                if (errorMessage == null) {
                    viewModel.savedAs = myUri
                    doSaveOperation(requestCode, myUri!!)
                } else {
                    showError(errorMessage)
                }
            }
        }
    }
}