diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 175a1d101e..f27604d9b7 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -355,6 +355,10 @@ dependencies {
kaptTest(libs.androidx.databinding.compiler)
kaptAndroidTest(libs.androidx.databinding.compiler)
+ // Coil
+ implementation(libs.coil)
+ implementation(libs.coil.svg)
+
implementation(libs.coordinates2country.android) {
exclude(group = "com.google.android", module = "android")
}
diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt
index 89fdaa055c..dd342f6437 100644
--- a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt
+++ b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt
@@ -11,6 +11,9 @@ import android.os.Build
import android.os.Process
import android.util.Log
import androidx.multidex.MultiDexApplication
+import coil.Coil
+import coil.ImageLoader
+import coil.decode.SvgDecoder
import com.facebook.drawee.backends.pipeline.Fresco
import com.facebook.imagepipeline.core.ImagePipelineConfig
import fr.free.nrw.commons.auth.LoginActivity
@@ -66,7 +69,7 @@ import javax.inject.Named
resCommentPrompt = R.string.crash_dialog_comment_prompt
)
-class CommonsApplication : MultiDexApplication() {
+class CommonsApplication : MultiDexApplication(), coil.ImageLoaderFactory {
@Inject
lateinit var sessionManager: SessionManager
@@ -139,6 +142,14 @@ class CommonsApplication : MultiDexApplication() {
System.setProperty("in.yuvi.http.fluent.PROGRESS_TRIGGER_THRESHOLD", "3.0")
}
+ override fun newImageLoader(): ImageLoader {
+ return ImageLoader.Builder(this)
+ .components {
+ add(SvgDecoder.Factory())
+ }
+ .build()
+ }
+
/**
* Plants debug and file logging tree. Timber lets you plant your own logging trees.
*/
diff --git a/app/src/main/java/fr/free/nrw/commons/edit/EditActivity.kt b/app/src/main/java/fr/free/nrw/commons/edit/EditActivity.kt
index 8587e94017..49e04edd5a 100644
--- a/app/src/main/java/fr/free/nrw/commons/edit/EditActivity.kt
+++ b/app/src/main/java/fr/free/nrw/commons/edit/EditActivity.kt
@@ -22,6 +22,7 @@ import androidx.core.net.toUri
import androidx.core.view.WindowInsetsCompat
import androidx.exifinterface.media.ExifInterface
import androidx.lifecycle.ViewModelProvider
+import coil.load
import fr.free.nrw.commons.databinding.ActivityEditBinding
import fr.free.nrw.commons.utils.applyEdgeToEdgeBottomInsets
import fr.free.nrw.commons.utils.applyEdgeToEdgeTopPaddingInsets
@@ -104,9 +105,12 @@ class EditActivity : AppCompatActivity() {
ExifInterface.TAG_GPS_LONGITUDE_REF,
ExifInterface.TAG_GPS_PROCESSING_METHOD,
ExifInterface.TAG_GPS_TIMESTAMP,
+ ExifInterface.TAG_IMAGE_LENGTH,
+ ExifInterface.TAG_IMAGE_WIDTH,
ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY,
ExifInterface.TAG_MAKE,
ExifInterface.TAG_MODEL,
+ ExifInterface.TAG_ORIENTATION,
ExifInterface.TAG_WHITE_BALANCE,
ExifInterface.WHITE_BALANCE_AUTO,
ExifInterface.WHITE_BALANCE_MANUAL,
@@ -116,6 +120,11 @@ class EditActivity : AppCompatActivity() {
sourceExifAttributeList.add(Pair(tag.toString(), attribute))
}
+ if (imageUri.substringBefore("?").endsWith(".svg", ignoreCase = true)) {
+ Toast.makeText(this, "SVG files cannot be edited", Toast.LENGTH_SHORT).show()
+ finish()
+ return
+ }
init()
}
@@ -124,16 +133,28 @@ class EditActivity : AppCompatActivity() {
*
* This function sets up the ImageView for displaying an image, adjusts its view bounds,
* and scales the initial image to fit within the ImageView. It also sets click listeners
- * for the "Rotate", "Crop" and "Save" buttons.
+ * for the "Rotate" and "Save" buttons.
*/
private fun init() {
binding.iv.adjustViewBounds = true
binding.iv.scaleType = ImageView.ScaleType.MATRIX
- binding.iv.post {
- val options = BitmapFactory.Options()
- options.inJustDecodeBounds = true
- BitmapFactory.decodeFile(imageUri, options)
-
+ if (imageUri.substringBefore("?").endsWith(".svg", ignoreCase = true)) {
+ binding.rotateBtn.isEnabled = false
+ binding.iv.load(imageUri) {
+ listener(
+ onSuccess = { _, result ->
+ val drawable = result.drawable
+ val scale = binding.iv.measuredWidth.toFloat() / drawable.intrinsicWidth.toFloat()
+ binding.iv.layoutParams.height = (scale * drawable.intrinsicHeight).toInt()
+ binding.iv.imageMatrix = scaleMatrix(scale, scale)
+ }
+ )
+ }
+ } else {
+ binding.iv.post {
+ val options = BitmapFactory.Options()
+ options.inJustDecodeBounds = true
+ BitmapFactory.decodeFile(imageUri, options)
val bitmapWidth = options.outWidth
val bitmapHeight = options.outHeight
@@ -193,7 +214,7 @@ class EditActivity : AppCompatActivity() {
binding.iv.imageMatrix = matrix
}
- }
+ }}
binding.rotateBtn.setOnClickListener {
// Allow rotation while in crop mode - overlay will update after animation
animateImageHeight()
@@ -416,6 +437,16 @@ class EditActivity : AppCompatActivity() {
var imageRotation = 0
+ /**
+ * Data class representing crop coordinates.
+ */
+ private data class CropCoordinates(
+ val left: Int,
+ val top: Int,
+ val width: Int,
+ val height: Int
+ )
+
/**
* Animates the height, rotation, and scale of an ImageView to provide a smooth
* transition effect when rotating an image by 90 degrees.
@@ -452,28 +483,24 @@ class EditActivity : AppCompatActivity() {
when (rotation) {
0, 180 -> {
- imageScale = min(viewWidth / drawableWidth, maxAvailableHeight / drawableHeight)
- val fitW = viewWidth / drawableHeight
- val fitH = maxAvailableHeight / drawableWidth
- newImageScale = min(fitW, fitH)
- newViewHeight = min((drawableWidth * newImageScale).toInt(), maxAvailableHeight.toInt())
+ imageScale = viewWidth / drawableWidth
+ newImageScale = viewWidth / drawableHeight
+ newViewHeight = (drawableWidth * newImageScale).toInt()
}
90, 270 -> {
- imageScale = min(viewWidth / drawableHeight, maxAvailableHeight / drawableWidth)
- val fitW = viewWidth / drawableWidth
- val fitH = maxAvailableHeight / drawableHeight
- newImageScale = min(fitW, fitH)
- newViewHeight = min((drawableHeight * newImageScale).toInt(), maxAvailableHeight.toInt())
+ imageScale = viewWidth / drawableHeight
+ newImageScale = viewWidth / drawableWidth
+ newViewHeight = (drawableHeight * newImageScale).toInt()
}
else -> {
throw
UnsupportedOperationException(
- "rotation can 0, 90, 180 or 270. \${rotation} is unsupported"
+ "rotation can 0, 90, 180 or 270. $rotation is unsupported"
)
}
}
- val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(500L)
+ val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(1000L)
animator.interpolator = AccelerateDecelerateInterpolator()
@@ -486,12 +513,6 @@ class EditActivity : AppCompatActivity() {
override fun onAnimationEnd(animation: Animator) {
imageRotation = newRotation % 360
binding.rotateBtn.setEnabled(true)
-
- // If crop mode is active, update the overlay bounds for new rotation
- // Use post{} to wait for the layout pass triggered by requestLayout()
- if (isCropMode) {
- binding.iv.post { updateCropOverlayBounds() }
- }
}
override fun onAnimationCancel(animation: Animator) {
@@ -533,6 +554,35 @@ class EditActivity : AppCompatActivity() {
animator.start()
}
+ /**
+ * Rotates and edits the current image, copies EXIF data, and returns the edited image path.
+ *
+ * This function retrieves the path of the current image specified by `imageUri`,
+ * rotates it based on the `imageRotation` angle using the `rotateImage` method
+ * from the `vm`, and updates the EXIF attributes of the
+ * rotated image based on the `sourceExifAttributeList`. It then copies the EXIF data
+ * using the `copyExifData` method, creates an Intent to return the edited image's file path
+ * as a result, and finishes the current activity.
+ */
+ fun getRotatedImage() {
+ val filePath = imageUri.toUri().path
+ val file = filePath?.let { File(it) }
+
+ val rotatedImage = file?.let { vm.rotateImage(imageRotation, it, applicationContext.cacheDir) }
+ if (rotatedImage == null) {
+ Toast.makeText(this, "Failed to rotate to image", Toast.LENGTH_LONG).show()
+ }
+ val editedImageExif: ExifInterface?
+ if (rotatedImage?.path != null) {
+ editedImageExif = ExifInterface(rotatedImage.path)
+ copyExifData(editedImageExif)
+ }
+ val resultIntent = Intent()
+ resultIntent.putExtra("editedImageFilePath", rotatedImage?.toUri()?.path ?: "Error")
+ setResult(RESULT_OK, resultIntent)
+ finish()
+ }
+
/**
* Copies EXIF data from sourceExifAttributeList to the provided ExifInterface object.
*
@@ -585,13 +635,3 @@ class EditActivity : AppCompatActivity() {
return scaleFactor
}
}
-
-/**
- * Data class to hold crop coordinates.
- */
-private data class CropCoordinates(
- val left: Int,
- val top: Int,
- val width: Int,
- val height: Int
-)
diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt
index ab089c48b5..52e87aa39a 100644
--- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt
+++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt
@@ -24,6 +24,7 @@ import android.widget.LinearLayout
import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast
+import coil.load
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -733,19 +734,34 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C
val imageBackgroundColor: Int = imageBackgroundColor
if (imageBackgroundColor != DEFAULT_IMAGE_BACKGROUND_COLOR) {
binding.mediaDetailImageView.setBackgroundColor(imageBackgroundColor)
+ binding.mediaDetailImageViewSvg.setBackgroundColor(imageBackgroundColor)
}
- binding.mediaDetailImageView.hierarchy.setPlaceholderImage(R.drawable.image_placeholder)
- binding.mediaDetailImageView.hierarchy.setFailureImage(R.drawable.image_placeholder)
+ val mediaUrl = media?.imageUrl
+ if (mediaUrl != null && mediaUrl.substringBefore("?").endsWith(".svg", ignoreCase = true)) {
+ binding.mediaDetailImageView.visibility = View.GONE
+ binding.mediaDetailImageViewSvg.visibility = View.VISIBLE
- val controller: DraweeController = Fresco.newDraweeControllerBuilder()
- .setLowResImageRequest(ImageRequest.fromUri(if (media != null) media!!.thumbUrl else null))
- .setRetainImageOnFailure(true)
- .setImageRequest(ImageRequest.fromUri(if (media != null) media!!.imageUrl else null))
- .setControllerListener(aspectRatioListener)
- .setOldController(binding.mediaDetailImageView.controller)
- .build()
- binding.mediaDetailImageView.controller = controller
+ binding.mediaDetailImageViewSvg.load(mediaUrl) {
+ placeholder(R.drawable.image_placeholder)
+ error(R.drawable.image_placeholder)
+ }
+ } else {
+ binding.mediaDetailImageViewSvg.visibility = View.GONE
+ binding.mediaDetailImageView.visibility = View.VISIBLE
+
+ binding.mediaDetailImageView.hierarchy.setPlaceholderImage(R.drawable.image_placeholder)
+ binding.mediaDetailImageView.hierarchy.setFailureImage(R.drawable.image_placeholder)
+
+ val controller: DraweeController = Fresco.newDraweeControllerBuilder()
+ .setLowResImageRequest(ImageRequest.fromUri(if (media != null) media!!.thumbUrl else null))
+ .setRetainImageOnFailure(true)
+ .setImageRequest(ImageRequest.fromUri(if (media != null) media!!.imageUrl else null))
+ .setControllerListener(aspectRatioListener)
+ .setOldController(binding.mediaDetailImageView.controller)
+ .build()
+ binding.mediaDetailImageView.controller = controller
+ }
}
private fun updateToDoWarning() {
diff --git a/app/src/main/java/fr/free/nrw/commons/upload/ThumbnailsAdapter.kt b/app/src/main/java/fr/free/nrw/commons/upload/ThumbnailsAdapter.kt
index d467f9bf63..5dc7a3aa63 100644
--- a/app/src/main/java/fr/free/nrw/commons/upload/ThumbnailsAdapter.kt
+++ b/app/src/main/java/fr/free/nrw/commons/upload/ThumbnailsAdapter.kt
@@ -6,8 +6,10 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.RelativeLayout
+import android.view.View
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
+import coil.load
import com.facebook.drawee.view.SimpleDraweeView
import fr.free.nrw.commons.R
import fr.free.nrw.commons.databinding.ItemUploadThumbnailBinding
@@ -50,7 +52,15 @@ internal class ThumbnailsAdapter(private val callback: Callback) :
fun bind(position: Int) {
val uploadableFile = uploadableFiles[position]
val uri = uploadableFile.getMediaUri()
- background.setImageURI(Uri.fromFile(File(uri.toString())))
+ if (uri.toString().substringBefore("?").endsWith(".svg", ignoreCase = true)) {
+ background.visibility = View.GONE
+ binding.ivThumbnailSvg.visibility = View.VISIBLE
+ binding.ivThumbnailSvg.load(uri)
+ } else {
+ background.visibility = View.VISIBLE
+ binding.ivThumbnailSvg.visibility = View.GONE
+ background.setImageURI(Uri.fromFile(File(uri.toString())))
+ }
if (position == callback.getCurrentSelectedFilePosition()) {
val border = GradientDrawable()
border.shape = GradientDrawable.RECTANGLE
diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.kt b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.kt
index fa83a7ccc2..7a96d962eb 100644
--- a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.kt
+++ b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.kt
@@ -20,7 +20,9 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.exifinterface.media.ExifInterface
+import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
+import coil.load
import com.github.chrisbanes.photoview.PhotoView
import fr.free.nrw.commons.CameraPosition
import fr.free.nrw.commons.R
@@ -52,6 +54,9 @@ import fr.free.nrw.commons.utils.ImageUtils.getErrorMessageForResult
import fr.free.nrw.commons.utils.NetworkUtils.isInternetConnectionEstablished
import fr.free.nrw.commons.utils.ViewUtil.showLongToast
import fr.free.nrw.commons.utils.handleKeyboardInsets
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.util.ArrayList
@@ -289,8 +294,11 @@ class UploadMediaDetailFragment : UploadBaseFragment(), UploadMediaDetailsContra
View.VISIBLE
}
- // lljtran only supports lossless JPEG rotation, so we disable editing for other formatts
val filePath = uploadableFile?.getFilePath()?.toString() ?: ""
+ val isSvgFile = filePath.endsWith(".svg", ignoreCase = true) ||
+ uploadItem?.mediaUri?.toString()?.substringBefore("?")?.endsWith(".svg", ignoreCase = true) == true
+ llEditImage.visibility = if (isSvgFile) View.GONE else View.VISIBLE
+ // lljtran only supports lossless JPEG rotation, so we disable editing for other formatts
val isJpeg = filePath.endsWith(".jpeg", ignoreCase = true)
|| filePath.endsWith(".jpg", ignoreCase = true)
llEditImage.visibility = View.VISIBLE
@@ -394,6 +402,12 @@ class UploadMediaDetailFragment : UploadBaseFragment(), UploadMediaDetailsContra
if (_binding == null) {
return
}
+ val mediaUri = uploadItem.mediaUri
+ if (mediaUri != null && mediaUri.toString().substringBefore("?").endsWith(".svg", ignoreCase = true)) {
+ binding.backgroundImage.load(mediaUri)
+ } else {
+ binding.backgroundImage.setImageURI(mediaUri)
+ }
// If the user has already edited the image, show the edited version
// instead of the original. This prevents the async preProcessImage
// callback from overwriting the edited preview.
diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.kt
index fa538bb215..f01f77ea7c 100644
--- a/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.kt
+++ b/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.kt
@@ -102,6 +102,9 @@ object ImageUtils {
*/
@JvmStatic
fun checkIfImageIsTooDark(imagePath: String): Int {
+ if (imagePath.endsWith(".svg", ignoreCase = true)) {
+ return IMAGE_OK
+ }
val millis = System.currentTimeMillis()
return try {
var bmp = ExifInterface(imagePath).thumbnailBitmap
diff --git a/app/src/main/res/layout/fragment_media_detail.xml b/app/src/main/res/layout/fragment_media_detail.xml
index 0978ba8c2a..20ca554729 100644
--- a/app/src/main/res/layout/fragment_media_detail.xml
+++ b/app/src/main/res/layout/fragment_media_detail.xml
@@ -31,6 +31,14 @@
android:layout_gravity="center_horizontal"
app:actualImageScaleType="none" />
+
+
+
+