From ca3c6f796598ced3f82c684e5c6764f00e8498a8 Mon Sep 17 00:00:00 2001 From: Naveen Singh <36371707+naveensingh@users.noreply.github.com> Date: Thu, 16 Oct 2025 23:45:19 +0530 Subject: [PATCH] feat: add option to overwrite original file in editor (#686) Refs: https://github.com/FossifyOrg/Gallery/issues/62 --- CHANGELOG.md | 3 + .../gallery/activities/EditActivity.kt | 426 +++++++++--------- .../fossify/gallery/dialogs/SaveAsDialog.kt | 124 ++--- .../fossify/gallery/extensions/Activity.kt | 116 ++++- .../org/fossify/gallery/extensions/Context.kt | 27 +- .../{ExifInterface.kt => ExifUtils.kt} | 34 ++ .../org/fossify/gallery/extensions/View.kt | 11 + .../org/fossify/gallery/helpers/Constants.kt | 3 + app/src/main/res/menu/menu_editor.xml | 11 +- app/src/main/res/values/strings.xml | 1 + detekt.yml | 5 + 11 files changed, 486 insertions(+), 275 deletions(-) rename app/src/main/kotlin/org/fossify/gallery/extensions/{ExifInterface.kt => ExifUtils.kt} (84%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ef129fae..ac296bc20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Option to overwrite the original image when saving edits ([#62]) ## [1.6.0] - 2025-10-01 ### Added @@ -165,6 +167,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed privacy policy link [#29]: https://github.com/FossifyOrg/Gallery/issues/29 +[#62]: https://github.com/FossifyOrg/Gallery/issues/62 [#128]: https://github.com/FossifyOrg/Gallery/issues/128 [#166]: https://github.com/FossifyOrg/Gallery/issues/166 [#199]: https://github.com/FossifyOrg/Gallery/issues/199 diff --git a/app/src/main/kotlin/org/fossify/gallery/activities/EditActivity.kt b/app/src/main/kotlin/org/fossify/gallery/activities/EditActivity.kt index 311cb8186..400842e97 100644 --- a/app/src/main/kotlin/org/fossify/gallery/activities/EditActivity.kt +++ b/app/src/main/kotlin/org/fossify/gallery/activities/EditActivity.kt @@ -10,7 +10,6 @@ import android.os.Bundle import android.os.Handler import android.provider.MediaStore import android.view.View -import android.widget.ImageView import android.widget.RelativeLayout import androidx.core.view.isInvisible import androidx.exifinterface.media.ExifInterface @@ -42,7 +41,6 @@ import org.fossify.gallery.dialogs.OtherAspectRatioDialog import org.fossify.gallery.dialogs.ResizeDialog import org.fossify.gallery.dialogs.SaveAsDialog import org.fossify.gallery.extensions.config -import org.fossify.gallery.extensions.copyNonDimensionAttributesTo import org.fossify.gallery.extensions.fixDateTaken import org.fossify.gallery.extensions.openEditor import org.fossify.gallery.helpers.* @@ -51,6 +49,14 @@ import java.io.* import kotlin.math.max import androidx.core.graphics.scale import androidx.core.net.toUri +import org.fossify.gallery.extensions.writeExif +import org.fossify.gallery.extensions.ensureWritablePath +import org.fossify.gallery.extensions.getCompressionFormatFromUri +import org.fossify.gallery.extensions.readExif +import org.fossify.gallery.extensions.proposeNewFilePath +import org.fossify.gallery.extensions.resolveUriScheme +import org.fossify.gallery.extensions.showContentDescriptionOnLongClick +import org.fossify.gallery.extensions.writeBitmapToCache class EditActivity : SimpleActivity() { companion object { @@ -58,7 +64,6 @@ class EditActivity : SimpleActivity() { System.loadLibrary("NativeImageProcessor") } - private const val TEMP_FOLDER_NAME = "images" private const val ASPECT_X = "aspectX" private const val ASPECT_Y = "aspectY" private const val CROP = "crop" @@ -73,17 +78,14 @@ class EditActivity : SimpleActivity() { private const val CROP_ROTATE_ASPECT_RATIO = 1 } - private lateinit var saveUri: Uri private var uri: Uri? = null private var resizeWidth = 0 private var resizeHeight = 0 private var drawColor = 0 private var lastOtherAspectRatio: Pair? = null - private var currPrimaryAction = - PRIMARY_ACTION_NONE - private var currCropRotateAction = - CROP_ROTATE_ASPECT_RATIO + private var currPrimaryAction = PRIMARY_ACTION_NONE + private var currCropRotateAction = CROP_ROTATE_ASPECT_RATIO private var currAspectRatio = ASPECT_RATIO_FREE private var isCropIntent = false private var isEditingWithThirdParty = false @@ -95,6 +97,8 @@ class EditActivity : SimpleActivity() { private var bitmapCroppingJob: Job? = null private val binding by viewBinding(ActivityEditBinding::inflate) + private var overwriteRequested = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) @@ -130,7 +134,8 @@ class EditActivity : SimpleActivity() { private fun setupOptionsMenu() { binding.editorToolbar.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { - R.id.save_as -> saveImage() + R.id.save_as -> startSaveFlow(overwrite = false) + R.id.overwrite_original -> startSaveFlow(overwrite = true) R.id.edit -> editWith() R.id.share -> shareImage() else -> return@setOnMenuItemClickListener false @@ -154,11 +159,12 @@ class EditActivity : SimpleActivity() { return } - if (intent.extras?.containsKey(REAL_FILE_PATH) == true) { + val extras = intent.extras + if (extras?.containsKey(REAL_FILE_PATH) == true) { val realPath = intent.extras!!.getString(REAL_FILE_PATH) uri = when { isPathOnOTG(realPath!!) -> uri - realPath.startsWith("file:/") -> Uri.parse(realPath) + realPath.startsWith("file:/") -> realPath.toUri() else -> Uri.fromFile(File(realPath)) } } else { @@ -168,14 +174,16 @@ class EditActivity : SimpleActivity() { } saveUri = when { - intent.extras?.containsKey(MediaStore.EXTRA_OUTPUT) == true && intent.extras!!.get(MediaStore.EXTRA_OUTPUT) is Uri -> intent.extras!!.get(MediaStore.EXTRA_OUTPUT) as Uri + extras?.containsKey(MediaStore.EXTRA_OUTPUT) == true + && extras.get(MediaStore.EXTRA_OUTPUT) is Uri -> extras.get(MediaStore.EXTRA_OUTPUT) as Uri else -> uri!! } - isCropIntent = intent.extras?.get(CROP) == "true" + isCropIntent = extras?.get(CROP) == "true" if (isCropIntent) { binding.bottomEditorPrimaryActions.root.beGone() (binding.bottomEditorCropRotateActions.root.layoutParams as RelativeLayout.LayoutParams).addRule(RelativeLayout.ALIGN_PARENT_BOTTOM, 1) + binding.editorToolbar.menu.findItem(R.id.overwrite_original).isVisible = false } loadDefaultImageView() @@ -321,76 +329,110 @@ class EditActivity : SimpleActivity() { } } - private fun saveImage() { + private fun setOldExif() { + oldExif = readExif(uri!!) + } + + private fun startSaveFlow(overwrite: Boolean) { + overwriteRequested = overwrite setOldExif() - - if (binding.cropImageView.isVisible()) { - cropImageAsync() - } else if (binding.editorDrawCanvas.isVisible()) { - val bitmap = binding.editorDrawCanvas.getBitmap() - if (saveUri.scheme == "file") { - SaveAsDialog(this, saveUri.path!!, true) { - saveBitmapToFile(bitmap, it, true) - } - } else if (saveUri.scheme == "content") { - val filePathGetter = getNewFilePath() - SaveAsDialog(this, filePathGetter.first, filePathGetter.second) { - saveBitmapToFile(bitmap, it, true) - } - } - } else { - val currentFilter = getFiltersAdapter()?.getCurrentFilter() ?: return - val filePathGetter = getNewFilePath() - SaveAsDialog(this, filePathGetter.first, filePathGetter.second) { - toast(org.fossify.commons.R.string.saving) - - // clean up everything to free as much memory as possible - binding.defaultImageView.setImageResource(0) - binding.cropImageView.setImageBitmap(null) - binding.bottomEditorFilterActions.bottomActionsFilterList.adapter = null - binding.bottomEditorFilterActions.bottomActionsFilterList.beGone() - - ensureBackgroundThread { - try { - val originalBitmap = Glide.with(applicationContext).asBitmap().load(uri).submit(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL).get() - currentFilter.filter.processFilter(originalBitmap) - saveBitmapToFile(originalBitmap, it, false) - } catch (e: OutOfMemoryError) { - toast(org.fossify.commons.R.string.out_of_memory_error) - } - } - } + when { + binding.cropImageView.isVisible() -> saveCroppedImage() + binding.editorDrawCanvas.isVisible() -> saveDrawnImage() + else -> saveFilteredImage(overwrite) } } - private fun setCropProgressBarVisibility(visible: Boolean) { - val progressBar: View? = binding.cropImageView.findViewById(com.canhub.cropper.R.id.CropProgressBar) - progressBar?.isInvisible = visible.not() + private fun saveDrawnImage() { + saveBitmap( + overwrite = overwriteRequested, + bitmap = binding.editorDrawCanvas.getBitmap() + ) } - private fun cropImageAsync() { - setCropProgressBarVisibility(visible = true) + private fun setCropProgressBarVisibility(visible: Boolean) { + binding.cropImageView + .findViewById(com.canhub.cropper.R.id.CropProgressBar) + ?.isInvisible = visible.not() + } + + private fun saveCroppedImage() { + setCropProgressBarVisibility(true) bitmapCroppingJob?.cancel() bitmapCroppingJob = lifecycleScope.launch(CoroutineExceptionHandler { _, t -> - onCropImageComplete(bitmap = null, error = Exception(t)) + onImageCropped(bitmap = null, error = Exception(t)) }) { val bitmap = withContext(Dispatchers.Default) { binding.cropImageView.getCroppedImage() } - onCropImageComplete(bitmap, null) + onImageCropped(bitmap, null) }.apply { - invokeOnCompletion { setCropProgressBarVisibility(visible = false) } + invokeOnCompletion { setCropProgressBarVisibility(false) } } } - private fun setOldExif() { - var inputStream: InputStream? = null - try { - inputStream = contentResolver.openInputStream(uri!!) - oldExif = ExifInterface(inputStream!!) - } catch (e: Exception) { - } finally { - inputStream?.close() + private fun onImageCropped(bitmap: Bitmap?, error: Exception?) { + if (error != null || bitmap == null) { + toast("${getString(R.string.image_editing_failed)}: ${error?.message}") + return + } + + setOldExif() + + if (isSharingBitmap) { + isSharingBitmap = false + shareBitmap(bitmap) + return + } + + if (isCropIntent) { + resolveUriScheme( + uri = saveUri, + onPath = { saveBitmapToPath(bitmap, it, true) }, + onContentUri = { + saveBitmapToContentUri(bitmap, it, showSavingToast = true, isCropCommit = true) + } + ) + return + } + + saveBitmap(overwriteRequested, bitmap, showSavingToast = true) + } + + private fun getOriginalBitmap(): Bitmap { + return Glide.with(applicationContext) + .asBitmap() + .load(uri) + .submit(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) + .get() + } + + private fun withFilteredImage(callback: (Bitmap) -> Unit) { + val currentFilter = getFiltersAdapter()?.getCurrentFilter() ?: return + ensureBackgroundThread { + try { + val original = getOriginalBitmap() + currentFilter.filter.processFilter(original) + callback(original) + } catch (_: OutOfMemoryError) { + toast(org.fossify.commons.R.string.out_of_memory_error) + } + } + } + + private fun saveFilteredImage(overwrite: Boolean) { + if (overwrite) { + freeMemory() + withFilteredImage { + saveBitmap(true, it) + } + } else { + resolveSaveAsPath { path -> + freeMemory() + withFilteredImage { + saveBitmapToPath(it, path, showSavingToast = true) + } + } } } @@ -404,7 +446,7 @@ class EditActivity : SimpleActivity() { return@ensureBackgroundThread } - val originalBitmap = Glide.with(applicationContext).asBitmap().load(uri).submit(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL).get() + val originalBitmap = getOriginalBitmap() currentFilter.filter.processFilter(originalBitmap) shareBitmap(originalBitmap) } @@ -412,7 +454,7 @@ class EditActivity : SimpleActivity() { binding.cropImageView.isVisible() -> { isSharingBitmap = true runOnUiThread { - cropImageAsync() + saveCroppedImage() } } @@ -421,41 +463,8 @@ class EditActivity : SimpleActivity() { } } - private fun getTempImagePath(bitmap: Bitmap, callback: (path: String?) -> Unit) { - val bytes = ByteArrayOutputStream() - bitmap.compress(CompressFormat.PNG, 0, bytes) - - val folder = File( - cacheDir, - TEMP_FOLDER_NAME - ) - if (!folder.exists()) { - if (!folder.mkdir()) { - callback(null) - return - } - } - - val filename = applicationContext.getFilenameFromContentUri(saveUri) ?: "tmp-${System.currentTimeMillis()}.jpg" - val newPath = "$folder/$filename" - val fileDirItem = FileDirItem(newPath, filename) - getFileOutputStream(fileDirItem, true) { - if (it != null) { - try { - it.write(bytes.toByteArray()) - callback(newPath) - } catch (e: Exception) { - } finally { - it.close() - } - } else { - callback("") - } - } - } - private fun shareBitmap(bitmap: Bitmap) { - getTempImagePath(bitmap) { + writeBitmapToCache(saveUri, bitmap) { if (it != null) { sharePathIntent(it, BuildConfig.APPLICATION_ID) } else { @@ -464,7 +473,9 @@ class EditActivity : SimpleActivity() { } } - private fun getFiltersAdapter() = binding.bottomEditorFilterActions.bottomActionsFilterList.adapter as? FiltersAdapter + private fun getFiltersAdapter(): FiltersAdapter? { + return binding.bottomEditorFilterActions.bottomActionsFilterList.adapter as? FiltersAdapter + } private fun setupBottomActions() { setupPrimaryActionButtons() @@ -490,7 +501,7 @@ class EditActivity : SimpleActivity() { binding.bottomEditorPrimaryActions.bottomPrimaryCropRotate, binding.bottomEditorPrimaryActions.bottomPrimaryDraw ).forEach { - setupLongPress(it) + it.showContentDescriptionOnLongClick() } } @@ -559,7 +570,7 @@ class EditActivity : SimpleActivity() { binding.bottomEditorCropRotateActions.bottomFlipVertically, binding.bottomEditorCropRotateActions.bottomAspectRatio ).forEach { - setupLongPress(it) + it.showContentDescriptionOnLongClick() } } @@ -712,8 +723,7 @@ class EditActivity : SimpleActivity() { if (currPrimaryAction != PRIMARY_ACTION_CROP_ROTATE) { binding.bottomAspectRatios.root.beGone() - currCropRotateAction = - CROP_ROTATE_NONE + currCropRotateAction = CROP_ROTATE_NONE } updateCropRotateActionButtons() } @@ -796,19 +806,14 @@ class EditActivity : SimpleActivity() { ResizeDialog(this, point) { resizeWidth = it.x resizeHeight = it.y - cropImageAsync() + saveCroppedImage() } } private fun shouldCropSquare(): Boolean { val extras = intent.extras - return if (extras != null && extras.containsKey(ASPECT_X) && extras.containsKey( - ASPECT_Y - ) - ) { - extras.getInt(ASPECT_X) == extras.getInt( - ASPECT_Y - ) + return if (extras != null && extras.containsKey(ASPECT_X) && extras.containsKey(ASPECT_Y)) { + extras.getInt(ASPECT_X) == extras.getInt(ASPECT_Y) } else { false } @@ -824,98 +829,70 @@ class EditActivity : SimpleActivity() { } } - private fun onCropImageComplete(bitmap: Bitmap?, error: Exception?) { - if (error == null && bitmap != null) { - setOldExif() + private fun resolveSaveAsPath(callback: (String) -> Unit) { + runOnUiThread { + resolveUriScheme( + uri = saveUri, + onPath = { + SaveAsDialog(this, it, true, callback = callback) + }, + onContentUri = { + val (path, append) = proposeNewFilePath(it) + SaveAsDialog(this, path, append, callback = callback) + } + ) + } + } - if (isSharingBitmap) { - isSharingBitmap = false - shareBitmap(bitmap) - return - } - - if (isCropIntent) { - if (saveUri.scheme == "file") { - saveBitmapToFile(bitmap, saveUri.path!!, true) - } else { - var inputStream: InputStream? = null - var outputStream: OutputStream? = null - try { - val stream = ByteArrayOutputStream() - bitmap.compress(CompressFormat.JPEG, 100, stream) - inputStream = ByteArrayInputStream(stream.toByteArray()) - outputStream = contentResolver.openOutputStream(saveUri) - inputStream.copyTo(outputStream!!) - } catch (e: Exception) { - showErrorToast(e) - return - } finally { - inputStream?.close() - outputStream?.close() + private fun saveBitmap(overwrite: Boolean, bitmap: Bitmap, showSavingToast: Boolean = true) { + if (overwrite) { + resolveUriScheme( + uri = saveUri, + onPath = { path -> + ensureWritablePath(targetPath = path, confirmOverwrite = false) { + saveBitmapToPath(bitmap, it, showSavingToast) } - - copyExifToUri(oldExif, saveUri) - Intent().apply { - data = saveUri - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - setResult(RESULT_OK, this) - } - finish() + }, + onContentUri = { contentUri -> + saveBitmapToContentUri(bitmap, contentUri, showSavingToast, isCropCommit = false) } - } else if (saveUri.scheme == "file") { - SaveAsDialog(this, saveUri.path!!, true) { - saveBitmapToFile(bitmap, it, true) - } - } else if (saveUri.scheme == "content") { - val filePathGetter = getNewFilePath() - SaveAsDialog(this, filePathGetter.first, filePathGetter.second) { - saveBitmapToFile(bitmap, it, true) - } - } else { - toast(R.string.unknown_file_location) - } + ) } else { - toast("${getString(R.string.image_editing_failed)}: ${error?.message}") - } - } - - private fun getNewFilePath(): Pair { - var newPath = applicationContext.getRealPathFromURI(saveUri) ?: "" - if (newPath.startsWith("/mnt/")) { - newPath = "" - } - - var shouldAppendFilename = true - if (newPath.isEmpty()) { - val filename = applicationContext.getFilenameFromContentUri(saveUri) ?: "" - if (filename.isNotEmpty()) { - val path = - if (intent.extras?.containsKey(REAL_FILE_PATH) == true) intent.getStringExtra(REAL_FILE_PATH)?.getParentPath() else internalStoragePath - newPath = "$path/$filename" - shouldAppendFilename = false + resolveSaveAsPath { path -> + saveBitmapToPath(bitmap, path, showSavingToast) } } - - if (newPath.isEmpty()) { - newPath = "$internalStoragePath/${getCurrentFormattedDateTime()}.${saveUri.toString().getFilenameExtension()}" - shouldAppendFilename = false - } - - return Pair(newPath, shouldAppendFilename) } - private fun saveBitmapToFile(bitmap: Bitmap, path: String, showSavingToast: Boolean) { + private fun finishCropResultForContent(uri: Uri) { + val result = Intent().apply { + data = uri + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + setResult(RESULT_OK, result) + finish() + } + + private fun freeMemory() { + // clean up everything to free as much memory as possible + binding.defaultImageView.setImageResource(0) + binding.cropImageView.setImageBitmap(null) + binding.bottomEditorFilterActions.bottomActionsFilterList.adapter = null + binding.bottomEditorFilterActions.bottomActionsFilterList.beGone() + } + + private fun saveBitmapToPath(bitmap: Bitmap, path: String, showSavingToast: Boolean) { try { ensureBackgroundThread { val file = File(path) val fileDirItem = FileDirItem(path, path.getFilenameFromPath()) try { val out = FileOutputStream(file) - saveBitmap(file, bitmap, out, showSavingToast) + saveBitmapToFile(file, bitmap, out, showSavingToast) } catch (e: Exception) { getFileOutputStream(fileDirItem, true) { if (it != null) { - saveBitmap(file, bitmap, it, showSavingToast) + saveBitmapToFile(file, bitmap, it, showSavingToast) } else { toast(R.string.image_editing_failed) } @@ -929,7 +906,7 @@ class EditActivity : SimpleActivity() { } } - private fun saveBitmap(file: File, bitmap: Bitmap, out: OutputStream, showSavingToast: Boolean) { + private fun saveBitmapToFile(file: File, bitmap: Bitmap, out: OutputStream, showSavingToast: Boolean) { if (showSavingToast) { toast(org.fossify.commons.R.string.saving) } @@ -943,22 +920,55 @@ class EditActivity : SimpleActivity() { } } - copyExifToUri(oldExif, file.toUri()) + writeExif(oldExif, file.toUri()) setResult(RESULT_OK, intent) scanFinalPath(file.absolutePath) } - private fun copyExifToUri(source: ExifInterface?, destination: Uri?) { - if (source == null || destination == null) return - if (destination.scheme == "content") { - contentResolver.openFileDescriptor(destination, "rw")?.use { pfd -> - val destExif = ExifInterface(pfd.fileDescriptor) - source.copyNonDimensionAttributesTo(destExif) + private fun saveBitmapToContentUri( + bitmap: Bitmap, + uri: Uri, + showSavingToast: Boolean, + isCropCommit: Boolean + ) { + if (showSavingToast) { + toast(org.fossify.commons.R.string.saving) + } + + ensureBackgroundThread { + var out: OutputStream? = null + try { + out = contentResolver.openOutputStream(uri, "wt") + ?: contentResolver.openOutputStream(uri) + if (out == null) { + val (path, append) = proposeNewFilePath(uri) + runOnUiThread { + SaveAsDialog(this, path, append) { path -> + saveBitmapToPath(bitmap, path, showSavingToast) + } + } + return@ensureBackgroundThread + } + + val quality = if (isCropCommit) 100 else 90 + bitmap.compress(getCompressionFormatFromUri(uri), quality, out) + out.flush() + writeExif(oldExif, uri) + + runOnUiThread { + if (isCropCommit) { + finishCropResultForContent(uri) + } else { + setResult(RESULT_OK, intent) + toast(org.fossify.commons.R.string.file_saved) + finish() + } + } + } catch (e: Exception) { + showErrorToast(e) + } finally { + try { out?.close() } catch (_: Exception) {} } - } else { - val file = File(destination.path!!) - val destExif = ExifInterface(file.absolutePath) - source.copyNonDimensionAttributesTo(destExif) } } @@ -976,14 +986,4 @@ class EditActivity : SimpleActivity() { finish() } } - - private fun setupLongPress(view: ImageView) { - view.setOnLongClickListener { - val contentDescription = view.contentDescription - if (contentDescription != null) { - toast(contentDescription.toString()) - } - true - } - } } diff --git a/app/src/main/kotlin/org/fossify/gallery/dialogs/SaveAsDialog.kt b/app/src/main/kotlin/org/fossify/gallery/dialogs/SaveAsDialog.kt index 254394ea0..66905d39b 100644 --- a/app/src/main/kotlin/org/fossify/gallery/dialogs/SaveAsDialog.kt +++ b/app/src/main/kotlin/org/fossify/gallery/dialogs/SaveAsDialog.kt @@ -2,24 +2,41 @@ package org.fossify.gallery.dialogs import androidx.appcompat.app.AlertDialog import org.fossify.commons.activities.BaseSimpleActivity -import org.fossify.commons.dialogs.ConfirmationDialog import org.fossify.commons.dialogs.FilePickerDialog -import org.fossify.commons.extensions.* -import org.fossify.commons.helpers.isRPlus +import org.fossify.commons.extensions.getAlertDialogBuilder +import org.fossify.commons.extensions.getFilenameFromPath +import org.fossify.commons.extensions.getParentPath +import org.fossify.commons.extensions.getPicturesDirectoryPath +import org.fossify.commons.extensions.hideKeyboard +import org.fossify.commons.extensions.humanizePath +import org.fossify.commons.extensions.isAValidFilename +import org.fossify.commons.extensions.isInDownloadDir +import org.fossify.commons.extensions.isRestrictedWithSAFSdk30 +import org.fossify.commons.extensions.setupDialogStuff +import org.fossify.commons.extensions.showKeyboard +import org.fossify.commons.extensions.toast +import org.fossify.commons.extensions.value import org.fossify.gallery.databinding.DialogSaveAsBinding -import java.io.File +import org.fossify.gallery.extensions.ensureWritablePath class SaveAsDialog( - val activity: BaseSimpleActivity, val path: String, val appendFilename: Boolean, val cancelCallback: (() -> Unit)? = null, + val activity: BaseSimpleActivity, + val path: String, + val appendFilename: Boolean, + val cancelCallback: (() -> Unit)? = null, val callback: (savePath: String) -> Unit ) { - init { - var realPath = path.getParentPath() - if (activity.isRestrictedWithSAFSdk30(realPath) && !activity.isInDownloadDir(realPath)) { - realPath = activity.getPicturesDirectoryPath(realPath) + private val binding = DialogSaveAsBinding.inflate(activity.layoutInflater) + private var realPath = path.getParentPath().run { + if (activity.isRestrictedWithSAFSdk30(this) && !activity.isInDownloadDir(this)) { + activity.getPicturesDirectoryPath(this) + } else { + this } + } - val binding = DialogSaveAsBinding.inflate(activity.layoutInflater).apply { + init { + binding.apply { folderValue.setText("${activity.humanizePath(realPath).trimEnd('/')}/") val fullName = path.getFilenameFromPath() @@ -39,7 +56,14 @@ class SaveAsDialog( filenameValue.setText(name) folderValue.setOnClickListener { activity.hideKeyboard(folderValue) - FilePickerDialog(activity, realPath, false, false, true, true) { + FilePickerDialog( + activity = activity, + currPath = realPath, + pickFile = false, + showHidden = false, + showFAB = true, + canAddShowHiddenButton = true + ) { folderValue.setText(activity.humanizePath(it)) realPath = it } @@ -51,59 +75,47 @@ class SaveAsDialog( .setNegativeButton(org.fossify.commons.R.string.cancel) { dialog, which -> cancelCallback?.invoke() } .setOnCancelListener { cancelCallback?.invoke() } .apply { - activity.setupDialogStuff(binding.root, this, org.fossify.commons.R.string.save_as) { alertDialog -> + activity.setupDialogStuff( + view = binding.root, + dialog = this, + titleId = org.fossify.commons.R.string.save_as + ) { alertDialog -> alertDialog.showKeyboard(binding.filenameValue) alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { - val filename = binding.filenameValue.value - val extension = binding.extensionValue.value - - if (filename.isEmpty()) { - activity.toast(org.fossify.commons.R.string.filename_cannot_be_empty) - return@setOnClickListener - } - - if (extension.isEmpty()) { - activity.toast(org.fossify.commons.R.string.extension_cannot_be_empty) - return@setOnClickListener - } - - val newFilename = "$filename.$extension" - val newPath = "${realPath.trimEnd('/')}/$newFilename" - if (!newFilename.isAValidFilename()) { - activity.toast(org.fossify.commons.R.string.filename_invalid_characters) - return@setOnClickListener - } - - if (activity.getDoesFilePathExist(newPath)) { - val title = String.format(activity.getString(org.fossify.commons.R.string.file_already_exists_overwrite), newFilename) - ConfirmationDialog(activity, title) { - if ((isRPlus() && !isExternalStorageManager())) { - val fileDirItem = arrayListOf(File(newPath).toFileDirItem(activity)) - val fileUris = activity.getFileUrisFromFileDirItems(fileDirItem) - activity.updateSDK30Uris(fileUris) { success -> - if (success) { - selectPath(alertDialog, newPath) - } - } - } else { - selectPath(alertDialog, newPath) - } - } - } else { - selectPath(alertDialog, newPath) - } + validateAndConfirmPath(alertDialog::dismiss) } } } } - private fun selectPath(alertDialog: AlertDialog, newPath: String) { - activity.handleSAFDialogSdk30(newPath) { - if (!it) { - return@handleSAFDialogSdk30 - } + private fun validateAndConfirmPath(dismiss: () -> Unit) { + val filename = binding.filenameValue.value + val extension = binding.extensionValue.value + + if (filename.isEmpty()) { + activity.toast(org.fossify.commons.R.string.filename_cannot_be_empty) + return + } + + if (extension.isEmpty()) { + activity.toast(org.fossify.commons.R.string.extension_cannot_be_empty) + return + } + + val newFilename = "$filename.$extension" + val newPath = "${realPath.trimEnd('/')}/$newFilename" + if (!newFilename.isAValidFilename()) { + activity.toast(org.fossify.commons.R.string.filename_invalid_characters) + return + } + + activity.ensureWritablePath( + targetPath = newPath, + confirmOverwrite = true, + onCancel = cancelCallback + ) { callback(newPath) - alertDialog.dismiss() + dismiss() } } } diff --git a/app/src/main/kotlin/org/fossify/gallery/extensions/Activity.kt b/app/src/main/kotlin/org/fossify/gallery/extensions/Activity.kt index 3ea73c70b..9e90e1ff2 100644 --- a/app/src/main/kotlin/org/fossify/gallery/extensions/Activity.kt +++ b/app/src/main/kotlin/org/fossify/gallery/extensions/Activity.kt @@ -5,6 +5,7 @@ import android.content.ContentProviderOperation import android.content.ContentValues import android.content.Intent import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat import android.graphics.BitmapFactory import android.graphics.Matrix import android.graphics.Point @@ -29,6 +30,8 @@ import org.fossify.commons.activities.BaseSimpleActivity import org.fossify.commons.dialogs.ConfirmationDialog import org.fossify.commons.dialogs.SecurityDialog import org.fossify.commons.extensions.* +import org.fossify.commons.extensions.getCurrentFormattedDateTime +import org.fossify.commons.extensions.internalStoragePath import org.fossify.commons.helpers.* import org.fossify.commons.models.FAQItem import org.fossify.commons.models.FileDirItem @@ -43,10 +46,12 @@ import org.fossify.gallery.dialogs.ResizeMultipleImagesDialog import org.fossify.gallery.dialogs.ResizeWithPathDialog import org.fossify.gallery.helpers.DIRECTORY import org.fossify.gallery.helpers.RECYCLE_BIN +import org.fossify.gallery.helpers.TEMP_FOLDER_NAME import org.fossify.gallery.models.DateTaken import java.io.* import java.text.SimpleDateFormat import java.util.Locale +import androidx.core.net.toUri fun Activity.sharePath(path: String) { sharePathIntent(path, BuildConfig.APPLICATION_ID) @@ -164,7 +169,7 @@ fun BaseSimpleActivity.launchGrantAllFilesIntent() { try { val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) intent.addCategory("android.intent.category.DEFAULT") - intent.data = Uri.parse("package:$packageName") + intent.data = "package:$packageName".toUri() startActivity(intent) } catch (e: Exception) { val intent = Intent() @@ -914,7 +919,7 @@ fun Activity.getShortcutImage(tmb: String, drawable: Drawable, callback: () -> U fun Activity.showFileOnMap(path: String) { val exif = try { if (path.startsWith("content://")) { - ExifInterface(contentResolver.openInputStream(Uri.parse(path))!!) + ExifInterface(contentResolver.openInputStream(path.toUri())!!) } else { ExifInterface(path) } @@ -949,3 +954,110 @@ fun Activity.openRecycleBin() { startActivity(this) } } + +fun BaseSimpleActivity.writeBitmapToCache( + source: Uri, + bitmap: Bitmap, + callback: (path: String?) -> Unit +) { + val bytes = ByteArrayOutputStream() + bitmap.compress(CompressFormat.PNG, 0, bytes) + + val folder = File(cacheDir, TEMP_FOLDER_NAME) + if (!folder.exists()) { + if (!folder.mkdir()) { + callback(null) + return + } + } + + val filename = applicationContext.getFilenameFromContentUri(source) + ?: "tmp-${System.currentTimeMillis()}.jpg" + val newPath = "$folder/$filename" + val fileDirItem = FileDirItem(newPath, filename) + getFileOutputStream(fileDirItem, true) { + if (it != null) { + try { + it.write(bytes.toByteArray()) + callback(newPath) + } catch (_: Exception) { + callback(null) + } finally { + it.close() + } + } else { + callback(null) + } + } +} + +fun BaseSimpleActivity.ensureWritablePath( + targetPath: String, + confirmOverwrite: Boolean = true, + onCancel: (() -> Unit)? = null, + callback: (String) -> Unit, +) { + fun proceedAfterGrants() { + handleSAFDialogSdk30(targetPath) { granted -> + if (!granted) { + onCancel?.invoke() + return@handleSAFDialogSdk30 + } + callback(targetPath) + } + } + + fun requestGrantsThenProceed() { + if (isRPlus() && !isExternalStorageManager()) { + val fileDirItem = arrayListOf(File(targetPath).toFileDirItem(this)) + val fileUris = getFileUrisFromFileDirItems(fileDirItem) + updateSDK30Uris(fileUris) { success -> + if (success) proceedAfterGrants() else onCancel?.invoke() + } + } else { + proceedAfterGrants() + } + } + + if (confirmOverwrite && getDoesFilePathExist(targetPath)) { + val title = String.format( + getString(org.fossify.commons.R.string.file_already_exists_overwrite), + targetPath.getFilenameFromPath() + ) + ConfirmationDialog(this, title) { + requestGrantsThenProceed() + } + } else { + requestGrantsThenProceed() + } +} + +fun Activity.proposeNewFilePath(uri: Uri): Pair { + var newPath = applicationContext.getRealPathFromURI(uri) ?: "" + if (newPath.startsWith("/mnt/")) { + newPath = "" + } + + var shouldAppendFilename = true + if (newPath.isEmpty()) { + val filename = applicationContext.getFilenameFromContentUri(uri) ?: "" + if (filename.isNotEmpty()) { + val path = if (intent.extras?.containsKey(REAL_FILE_PATH) == true) { + intent.getStringExtra(REAL_FILE_PATH)?.getParentPath() + } else { + internalStoragePath + } + newPath = "$path/$filename" + shouldAppendFilename = false + } + } + + if (newPath.isEmpty()) { + newPath = "$internalStoragePath/${getCurrentFormattedDateTime()}.${ + uri.toString().getFilenameExtension() + }" + shouldAppendFilename = false + } + + return Pair(newPath, shouldAppendFilename) +} diff --git a/app/src/main/kotlin/org/fossify/gallery/extensions/Context.kt b/app/src/main/kotlin/org/fossify/gallery/extensions/Context.kt index d0c91a259..a399e69f0 100644 --- a/app/src/main/kotlin/org/fossify/gallery/extensions/Context.kt +++ b/app/src/main/kotlin/org/fossify/gallery/extensions/Context.kt @@ -7,9 +7,11 @@ import android.content.Context import android.content.Intent import android.database.Cursor import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat import android.graphics.drawable.Drawable import android.graphics.drawable.PictureDrawable import android.media.AudioManager +import android.net.Uri import android.os.Process import android.provider.MediaStore.Files import android.provider.MediaStore.Images @@ -39,6 +41,7 @@ import org.fossify.commons.extensions.getDoesFilePathExist import org.fossify.commons.extensions.getDuration import org.fossify.commons.extensions.getFilenameFromPath import org.fossify.commons.extensions.getLongValue +import org.fossify.commons.extensions.getMimeTypeFromUri import org.fossify.commons.extensions.getOTGPublicPath import org.fossify.commons.extensions.getParentPath import org.fossify.commons.extensions.getStringValue @@ -977,7 +980,7 @@ fun Context.getCachedMedia( } }) as ArrayList - val pathToUse = if (path.isEmpty()) SHOW_ALL else path + val pathToUse = path.ifEmpty { SHOW_ALL } mediaFetcher.sortMedia(media, config.getFolderSorting(pathToUse)) val grouped = mediaFetcher.groupMedia(media, pathToUse) callback(grouped.clone() as ArrayList) @@ -1402,3 +1405,25 @@ fun Context.getFileDateTaken(path: String): Long { return 0L } + +fun Context.getCompressionFormatFromUri(uri: Uri): CompressFormat { + val type = getMimeTypeFromUri(uri) + return when { + type.equals("image/png", true) -> CompressFormat.PNG + type.equals("image/webp", true) -> CompressFormat.WEBP + else -> CompressFormat.JPEG + } +} + +fun Context.resolveUriScheme( + uri: Uri, + onPath: (String) -> Unit, + onContentUri: (Uri) -> Unit, + onUnknown: () -> Unit = { toast(R.string.unknown_file_location) } +) { + when (uri.scheme) { + "file" -> onPath(uri.path!!) + "content" -> onContentUri(uri) + else -> onUnknown() + } +} diff --git a/app/src/main/kotlin/org/fossify/gallery/extensions/ExifInterface.kt b/app/src/main/kotlin/org/fossify/gallery/extensions/ExifUtils.kt similarity index 84% rename from app/src/main/kotlin/org/fossify/gallery/extensions/ExifInterface.kt rename to app/src/main/kotlin/org/fossify/gallery/extensions/ExifUtils.kt index 682323c16..297d877ff 100644 --- a/app/src/main/kotlin/org/fossify/gallery/extensions/ExifInterface.kt +++ b/app/src/main/kotlin/org/fossify/gallery/extensions/ExifUtils.kt @@ -1,6 +1,10 @@ package org.fossify.gallery.extensions +import android.content.Context +import android.net.Uri import androidx.exifinterface.media.ExifInterface +import java.io.File +import java.io.InputStream /** * A non-exhaustive list of all Exif attributes *excluding* dimension related attributes. @@ -141,3 +145,33 @@ fun ExifInterface.copyNonDimensionAttributesTo(destination: ExifInterface) { } catch (_: Exception) { } } + +fun Context.readExif(uri: Uri): ExifInterface? { + var inputStream: InputStream? = null + return try { + inputStream = contentResolver.openInputStream(uri) + ExifInterface(inputStream!!) + } catch (_: Exception) { + null + } finally { + inputStream?.close() + } +} + +fun Context.writeExif(exif: ExifInterface?, uri: Uri?) { + if (exif == null || uri == null) return + resolveUriScheme( + uri = uri, + onContentUri = { + contentResolver.openFileDescriptor(it, "rw")?.use { pfd -> + val destExif = ExifInterface(pfd.fileDescriptor) + exif.copyNonDimensionAttributesTo(destExif) + } + }, + onPath = { + val file = File(it) + val destExif = ExifInterface(file.absolutePath) + exif.copyNonDimensionAttributesTo(destExif) + } + ) +} diff --git a/app/src/main/kotlin/org/fossify/gallery/extensions/View.kt b/app/src/main/kotlin/org/fossify/gallery/extensions/View.kt index fb26dca30..6a5886a83 100644 --- a/app/src/main/kotlin/org/fossify/gallery/extensions/View.kt +++ b/app/src/main/kotlin/org/fossify/gallery/extensions/View.kt @@ -3,6 +3,7 @@ package org.fossify.gallery.extensions import android.os.SystemClock import android.view.MotionEvent import android.view.View +import org.fossify.commons.extensions.toast fun View.sendFakeClick(x: Float, y: Float) { val uptime = SystemClock.uptimeMillis() @@ -11,3 +12,13 @@ fun View.sendFakeClick(x: Float, y: Float) { event.action = MotionEvent.ACTION_UP dispatchTouchEvent(event) } + +fun View.showContentDescriptionOnLongClick() { + setOnLongClickListener { + val contentDescription = contentDescription + if (contentDescription != null) { + context.toast(contentDescription.toString()) + } + true + } +} diff --git a/app/src/main/kotlin/org/fossify/gallery/helpers/Constants.kt b/app/src/main/kotlin/org/fossify/gallery/helpers/Constants.kt index 6b7a31304..f5261455d 100644 --- a/app/src/main/kotlin/org/fossify/gallery/helpers/Constants.kt +++ b/app/src/main/kotlin/org/fossify/gallery/helpers/Constants.kt @@ -159,6 +159,9 @@ const val SHOULD_INIT_FRAGMENT = "should_init_fragment" const val PORTRAIT_PATH = "portrait_path" const val SKIP_AUTHENTICATION = "skip_authentication" +// editor +const val TEMP_FOLDER_NAME = "images" + // rotations const val ROTATE_BY_SYSTEM_SETTING = 0 const val ROTATE_BY_DEVICE_ROTATION = 1 diff --git a/app/src/main/res/menu/menu_editor.xml b/app/src/main/res/menu/menu_editor.xml index a4a4466a1..7d7573353 100644 --- a/app/src/main/res/menu/menu_editor.xml +++ b/app/src/main/res/menu/menu_editor.xml @@ -5,15 +5,20 @@ android:id="@+id/save_as" android:icon="@drawable/ic_check_vector" android:title="@string/save_as" - app:showAsAction="always" /> + app:showAsAction="ifRoom" /> + + app:showAsAction="never" /> + app:showAsAction="ifRoom" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b69568c14..7ccd2fc52 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -104,6 +104,7 @@ No image editor found No video editor found Unknown file location + Overwrite original Could not overwrite the source file Rotate left Rotate right diff --git a/detekt.yml b/detekt.yml index 6472c5a35..73ddc0c1a 100644 --- a/detekt.yml +++ b/detekt.yml @@ -40,6 +40,11 @@ style: maxLineLength: 120 excludePackageStatements: true excludeImportStatements: true + ReturnCount: + active: true + max: 4 + excludeGuardClauses: true + excludes: ["**/test/**", "**/androidTest/**"] naming: FunctionNaming: