view app/src/main/java/com/bartsent/simpleresizer/EditImage.kt @ 36:70f1d11d53ad

Attempt to make it work on Android 9 and earlier.
author David Barts <n5jrn@me.com>
date Fri, 19 Mar 2021 12:49:13 -0700
parents bead5d7e8c69
children 0dbd924cb5e8
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 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
    }
    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 {
        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()
    }

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

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

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

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

    private val REQUEST_WRITE_EXTERNAL = 42

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray): Unit {
        if (requestCode != REQUEST_WRITE_EXTERNAL) {
            Log.e("EditImage", "unexpected request code in onRequestPermissionsResult!")
            return
        }
        if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
            doneClicked(null)
        } else {
            showError(getString(R.string.error_unable_no_permissions))
        }
    }

    fun doneClicked(view: View?): Unit {
        // If we need a permission, request it and bail. We will be called again
        // (with the permission) if 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) {
                requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_WRITE_EXTERNAL)
                return
            }
        }

        // If we get here, we have permission to save (if we need it).
        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
            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 (!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) {
                    unsetImage()
                    finish()
                } else {
                    showError(errorMessage)
                }
            }
        }
    }
}