Mercurial > cgi-bin > hgweb.cgi > SimpleResizer
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) } } } } }