feat: add option to overwrite original file in editor (#686)

Refs: https://github.com/FossifyOrg/Gallery/issues/62
This commit is contained in:
Naveen Singh
2025-10-16 23:45:19 +05:30
committed by GitHub
parent 5e4a8ab013
commit ca3c6f7965
11 changed files with 486 additions and 275 deletions

View File

@@ -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

View File

@@ -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<Float, Float>? = 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<View>(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<String, Boolean> {
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
}
}
}

View File

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

View File

@@ -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<String, Boolean> {
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)
}

View File

@@ -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<Medium>
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<ThumbnailItem>)
@@ -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()
}
}

View File

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

View File

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

View File

@@ -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

View File

@@ -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" />
<item
android:id="@+id/overwrite_original"
android:icon="@drawable/ic_check_vector"
android:title="@string/overwrite_original"
app:showAsAction="never" />
<item
android:id="@+id/edit"
android:icon="@drawable/ic_edit_vector"
android:title="@string/edit_with"
app:showAsAction="always" />
app:showAsAction="never" />
<item
android:id="@+id/share"
android:icon="@drawable/ic_share_vector"
android:title="@string/share"
app:showAsAction="always" />
app:showAsAction="ifRoom" />
</menu>

View File

@@ -104,6 +104,7 @@
<string name="no_image_editor_found">No image editor found</string>
<string name="no_video_editor_found">No video editor found</string>
<string name="unknown_file_location">Unknown file location</string>
<string name="overwrite_original">Overwrite original</string>
<string name="error_saving_file">Could not overwrite the source file</string>
<string name="rotate_left">Rotate left</string>
<string name="rotate_right">Rotate right</string>

View File

@@ -40,6 +40,11 @@ style:
maxLineLength: 120
excludePackageStatements: true
excludeImportStatements: true
ReturnCount:
active: true
max: 4
excludeGuardClauses: true
excludes: ["**/test/**", "**/androidTest/**"]
naming:
FunctionNaming: