view app/src/main/java/com/bartsent/simpleresizer/EditImage.kt @ 43:9cb9bb5da247

At long last it auto-deletes old cruft files.
author David Barts <n5jrn@me.com>
date Sat, 10 Apr 2021 17:29:08 -0700
parents 45e4df5226c0
children 2b91619da650
line wrap: on
line source

package com.bartsent.simpleresizer

import android.Manifest
import android.content.ContentUris
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 permissionsCallback: (() -> Unit)? = null
        var sharable: Boolean = false
    }
    private lateinit var viewModel: State

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

    private val IMAGE_TO_SEND = "simple_resizer_sent_image.jpg"

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

    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()
        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 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)) {
            val cb = viewModel.permissionsCallback
            if (cb != null) {
                viewModel.permissionsCallback = null
                cb()
            }
        } else {
            showError(getString(R.string.error_unable_no_permissions))
        }
    }

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

    private fun needsWritePermission(callback: () -> Unit): Boolean {
        if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) {
            if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) {
                viewModel.permissionsCallback = callback
                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()
                        }
                        it.create()
                    }.show()
                else
                    requestWritePermission()
                return true
            }
        }
        return false
    }

    fun shareClicked(view: View): Unit {
        // If we need WRITE_EXTERNAL_STORAGE, request it and bail. We will be called again
        // (with the permission) if it is granted.
        if (needsWritePermission({ shareClicked(view) }))
            return

        // If we get here, we have permission to save (if we need it).
        val contentValues = makeContentValues(IMAGE_TO_SEND)

        // Delete any old file(s)
        val cols = arrayOf<String>(MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.MIME_TYPE)
        val query = StringBuilder().run {
            for (col in cols) {
                if (isNotEmpty())
                    append(" and ")
                append(col)
                append(" = ?")
            }
            toString()
        }
        try {
            val cursor = contentResolver.query(
                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                arrayOf<String>(MediaStore.MediaColumns._ID),
                query,
                cols.map { contentValues.getAsString(it) }.toTypedArray(),
                null)
            var deleted = 0
            cursor?.use {
                // Log.d("EditImage", "${it.count} entries matched")
                while (it.moveToNext()) {
                    val uri =
                        ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, it.getLong(0))
                    deleted += contentResolver.delete(uri, null, null)
                }
            }
            // Log.d("EditImage", "$deleted entries deleted")
        } catch (e: Exception) {
            Log.e("EditImage", "unexpected exception when sharing!", e)
        }

        // Save new file, use it to share data.
        saveAs(contentValues) {
            val shareIntent: Intent = Intent().apply {
                action = Intent.ACTION_SEND
                putExtra(Intent.EXTRA_STREAM, it)
                type = "image/jpeg"
            }
            startActivity(Intent.createChooser(shareIntent, resources.getText(R.string.share_text)))
        }
    }

    fun doneClicked(view: View): Unit {
        // If we need WRITE_EXTERNAL_STORAGE, request it and bail. We will be called again
        // (with the permission) if it is granted.
        if (needsWritePermission({ doneClicked(view) }))
            return

        // If we get here, we have permission to save (if we need it).
        val image = viewModel.bitmap!!
        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"
        saveAs(makeContentValues(fileName)) {
            unsetImage()
            makeMundane()
            finish()
        }
    }

    private fun makeContentValues(fileName: String): ContentValues = ContentValues().apply {
        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)
        }
    }

    private fun saveAs(contentValues: ContentValues, callback: (Uri) -> Unit) {
        val image = viewModel.bitmap!!
        binding.progressBar.visibility = ProgressBar.VISIBLE
        ThreadPools.WORKERS.execute {
            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) {
                    callback(myUri!!)
                } else {
                    showError(errorMessage)
                }
            }
        }
    }
}