Skip to content

Commit 2ddb6b2

Browse files
[WIP]Lossless Transformations(Rotate feature) (#5252)
* UI setup for the crop feature almost setup * basic setup of rotate feature done * Added basic changes for editing feature * Getting data back from edit activity * Getting data back from edit activity * Updated contentUri * Finally the rotated image is getting uploaded * Minor Improvements for better testing * Fixed thumbnail preview * Fixed loss of exif data * Copy exif data * Save exif data * Added java docs * Minor fix * Added Javadoc * Refactoring * Formatting fixes * Minor Formatting Fix * Fix unit test * Add test coverage * Formatting fixes * Formatting Fixes --------- Co-authored-by: Priyank Shankar <priyankshankar@changejar.in>
1 parent 6b8954b commit 2ddb6b2

17 files changed

+570
-28
lines changed

app/build.gradle

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ dependencies {
140140
implementation "androidx.preference:preference:$PREFERENCE_VERSION"
141141
// Kotlin
142142
implementation "androidx.preference:preference-ktx:$PREFERENCE_VERSION"
143+
//Android Media
144+
implementation 'com.github.juanitobananas:AndroidMediaUtil:v1.0-1'
143145

144146
implementation "androidx.multidex:multidex:$MULTIDEX_VERSION"
145147

@@ -236,8 +238,8 @@ android {
236238
}
237239
}
238240
debug {
239-
minifyEnabled false
240241
testCoverageEnabled true
242+
minifyEnabled false
241243
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
242244
testProguardFile 'test-proguard-rules.txt'
243245
versionNameSuffix "-debug-" + getBranchName()

app/src/main/AndroidManifest.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,14 @@
4848
tools:ignore="GoogleAppIndexingWarning">
4949

5050
<activity
51+
android:theme="@style/EditActivityTheme"
5152
android:name=".description.DescriptionEditActivity"
5253
android:exported="true" />
5354

55+
<activity
56+
android:name=".edit.EditActivity"
57+
android:exported="false" />
58+
5459
<activity android:name="org.acra.dialog.CrashReportDialog"
5560
android:process=":acra"
5661
android:launchMode="singleInstance"
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
package fr.free.nrw.commons.edit
2+
3+
import android.animation.Animator
4+
import android.animation.Animator.AnimatorListener
5+
import android.animation.ValueAnimator
6+
import android.content.Intent
7+
import android.graphics.BitmapFactory
8+
import android.graphics.Matrix
9+
import android.graphics.drawable.BitmapDrawable
10+
import android.media.ExifInterface
11+
import android.os.Bundle
12+
import android.util.Log
13+
import android.view.animation.AccelerateDecelerateInterpolator
14+
import android.widget.ImageView
15+
import android.widget.Toast
16+
import androidx.appcompat.app.AppCompatActivity
17+
import androidx.core.graphics.rotationMatrix
18+
import androidx.core.graphics.scaleMatrix
19+
import androidx.core.net.toUri
20+
import androidx.lifecycle.ViewModelProvider
21+
import fr.free.nrw.commons.R
22+
import kotlinx.android.synthetic.main.activity_edit.btn_save
23+
import kotlinx.android.synthetic.main.activity_edit.iv
24+
import kotlinx.android.synthetic.main.activity_edit.rotate_btn
25+
import timber.log.Timber
26+
import java.io.File
27+
28+
/**
29+
* An activity class for editing and rotating images using LLJTran with EXIF attribute preservation.
30+
*
31+
* This activity allows loads an image, allows users to rotate it by 90-degree increments, and
32+
* save the edited image while preserving its EXIF attributes. The class includes methods
33+
* for initializing the UI, animating image rotations, copying EXIF data, and handling
34+
* the image-saving process.
35+
*/
36+
class EditActivity : AppCompatActivity() {
37+
private var imageUri = ""
38+
private lateinit var vm: EditViewModel
39+
private val sourceExifAttributeList = mutableListOf<Pair<String, String?>>()
40+
41+
override fun onCreate(savedInstanceState: Bundle?) {
42+
super.onCreate(savedInstanceState)
43+
setContentView(R.layout.activity_edit)
44+
supportActionBar?.title = ""
45+
val intent = intent
46+
imageUri = intent.getStringExtra("image") ?: ""
47+
vm = ViewModelProvider(this).get(EditViewModel::class.java)
48+
val sourceExif = imageUri.toUri().path?.let { ExifInterface(it) }
49+
val exifTags = arrayOf(
50+
ExifInterface.TAG_APERTURE,
51+
ExifInterface.TAG_DATETIME,
52+
ExifInterface.TAG_EXPOSURE_TIME,
53+
ExifInterface.TAG_FLASH,
54+
ExifInterface.TAG_FOCAL_LENGTH,
55+
ExifInterface.TAG_GPS_ALTITUDE,
56+
ExifInterface.TAG_GPS_ALTITUDE_REF,
57+
ExifInterface.TAG_GPS_DATESTAMP,
58+
ExifInterface.TAG_GPS_LATITUDE,
59+
ExifInterface.TAG_GPS_LATITUDE_REF,
60+
ExifInterface.TAG_GPS_LONGITUDE,
61+
ExifInterface.TAG_GPS_LONGITUDE_REF,
62+
ExifInterface.TAG_GPS_PROCESSING_METHOD,
63+
ExifInterface.TAG_GPS_TIMESTAMP,
64+
ExifInterface.TAG_IMAGE_LENGTH,
65+
ExifInterface.TAG_IMAGE_WIDTH,
66+
ExifInterface.TAG_ISO,
67+
ExifInterface.TAG_MAKE,
68+
ExifInterface.TAG_MODEL,
69+
ExifInterface.TAG_ORIENTATION,
70+
ExifInterface.TAG_WHITE_BALANCE,
71+
ExifInterface.WHITEBALANCE_AUTO,
72+
ExifInterface.WHITEBALANCE_MANUAL
73+
)
74+
for (tag in exifTags) {
75+
val attribute = sourceExif?.getAttribute(tag.toString())
76+
sourceExifAttributeList.add(Pair(tag.toString(), attribute))
77+
}
78+
79+
init()
80+
}
81+
82+
/**
83+
* Initializes the ImageView and associated UI elements.
84+
*
85+
* This function sets up the ImageView for displaying an image, adjusts its view bounds,
86+
* and scales the initial image to fit within the ImageView. It also sets click listeners
87+
* for the "Rotate" and "Save" buttons.
88+
*/
89+
private fun init() {
90+
iv.adjustViewBounds = true
91+
iv.scaleType = ImageView.ScaleType.MATRIX
92+
iv.post(Runnable {
93+
val bitmap = BitmapFactory.decodeFile(imageUri)
94+
iv.setImageBitmap(bitmap)
95+
if (bitmap.width > 0) {
96+
val scale =
97+
iv.measuredWidth.toFloat() / (iv.drawable as BitmapDrawable).bitmap.width.toFloat()
98+
iv.layoutParams.height =
99+
(scale * (iv.drawable as BitmapDrawable).bitmap.height).toInt()
100+
iv.imageMatrix = scaleMatrix(scale, scale)
101+
}
102+
})
103+
rotate_btn.setOnClickListener {
104+
animateImageHeight()
105+
}
106+
btn_save.setOnClickListener {
107+
getRotatedImage()
108+
}
109+
}
110+
111+
var imageRotation = 0
112+
113+
/**
114+
* Animates the height, rotation, and scale of an ImageView to provide a smooth
115+
* transition effect when rotating an image by 90 degrees.
116+
*
117+
* This function calculates the new height, rotation, and scale for the ImageView
118+
* based on the current image rotation angle and animates the changes using a
119+
* ValueAnimator. It also disables a rotate button during the animation to prevent
120+
* further rotation actions.
121+
*/
122+
private fun animateImageHeight() {
123+
val drawableWidth: Float = iv.getDrawable().getIntrinsicWidth().toFloat()
124+
val drawableHeight: Float = iv.getDrawable().getIntrinsicHeight().toFloat()
125+
val viewWidth: Float = iv.getMeasuredWidth().toFloat()
126+
val viewHeight: Float = iv.getMeasuredHeight().toFloat()
127+
val rotation = imageRotation % 360
128+
val newRotation = rotation + 90
129+
130+
val newViewHeight: Int
131+
val imageScale: Float
132+
val newImageScale: Float
133+
134+
Timber.d("Rotation $rotation")
135+
Timber.d("new Rotation $newRotation")
136+
137+
138+
if (rotation == 0 || rotation == 180) {
139+
imageScale = viewWidth / drawableWidth
140+
newImageScale = viewWidth / drawableHeight
141+
newViewHeight = (drawableWidth * newImageScale).toInt()
142+
} else if (rotation == 90 || rotation == 270) {
143+
imageScale = viewWidth / drawableHeight
144+
newImageScale = viewWidth / drawableWidth
145+
newViewHeight = (drawableHeight * newImageScale).toInt()
146+
} else {
147+
throw UnsupportedOperationException("rotation can 0, 90, 180 or 270. \${rotation} is unsupported")
148+
}
149+
150+
val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(1000L)
151+
152+
animator.interpolator = AccelerateDecelerateInterpolator()
153+
154+
animator.addListener(object : AnimatorListener {
155+
override fun onAnimationStart(animation: Animator) {
156+
rotate_btn.setEnabled(false)
157+
}
158+
159+
override fun onAnimationEnd(animation: Animator) {
160+
imageRotation = newRotation % 360
161+
rotate_btn.setEnabled(true)
162+
}
163+
164+
override fun onAnimationCancel(animation: Animator) {
165+
}
166+
167+
override fun onAnimationRepeat(animation: Animator) {
168+
}
169+
170+
})
171+
172+
animator.addUpdateListener { animation ->
173+
val animVal = animation.animatedValue as Float
174+
val complementaryAnimVal = 1 - animVal
175+
val animatedHeight =
176+
(complementaryAnimVal * viewHeight + animVal * newViewHeight).toInt()
177+
val animatedScale = complementaryAnimVal * imageScale + animVal * newImageScale
178+
val animatedRotation = complementaryAnimVal * rotation + animVal * newRotation
179+
iv.getLayoutParams().height = animatedHeight
180+
val matrix: Matrix = rotationMatrix(
181+
animatedRotation,
182+
drawableWidth / 2,
183+
drawableHeight / 2
184+
)
185+
matrix.postScale(
186+
animatedScale,
187+
animatedScale,
188+
drawableWidth / 2,
189+
drawableHeight / 2
190+
)
191+
matrix.postTranslate(
192+
-(drawableWidth - iv.getMeasuredWidth()) / 2,
193+
-(drawableHeight - iv.getMeasuredHeight()) / 2
194+
)
195+
iv.setImageMatrix(matrix)
196+
iv.requestLayout()
197+
}
198+
199+
animator.start()
200+
}
201+
202+
/**
203+
* Rotates and edits the current image, copies EXIF data, and returns the edited image path.
204+
*
205+
* This function retrieves the path of the current image specified by `imageUri`,
206+
* rotates it based on the `imageRotation` angle using the `rotateImage` method
207+
* from the `vm`, and updates the EXIF attributes of the
208+
* rotated image based on the `sourceExifAttributeList`. It then copies the EXIF data
209+
* using the `copyExifData` method, creates an Intent to return the edited image's file path
210+
* as a result, and finishes the current activity.
211+
*/
212+
fun getRotatedImage() {
213+
214+
val filePath = imageUri.toUri().path
215+
val file = filePath?.let { File(it) }
216+
217+
218+
val rotatedImage = file?.let { vm.rotateImage(imageRotation, it) }
219+
if (rotatedImage == null) {
220+
Toast.makeText(this, "Failed to rotate to image", Toast.LENGTH_LONG).show()
221+
}
222+
val editedImageExif = rotatedImage?.path?.let { ExifInterface(it) }
223+
copyExifData(editedImageExif)
224+
val resultIntent = Intent()
225+
resultIntent.putExtra("editedImageFilePath", rotatedImage?.toUri()?.path ?: "Error");
226+
setResult(RESULT_OK, resultIntent);
227+
finish();
228+
}
229+
230+
/**
231+
* Copies EXIF data from sourceExifAttributeList to the provided ExifInterface object.
232+
*
233+
* This function iterates over the `sourceExifAttributeList` and sets the EXIF attributes
234+
* on the provided `editedImageExif` object.
235+
*
236+
* @param editedImageExif The ExifInterface object for the edited image.
237+
*/
238+
private fun copyExifData(editedImageExif: ExifInterface?) {
239+
240+
for (attr in sourceExifAttributeList) {
241+
Log.d("Tag is ${attr.first}", "Value is ${attr.second}")
242+
editedImageExif!!.setAttribute(attr.first, attr.second)
243+
Log.d("Tag is ${attr.first}", "Value is ${attr.second}")
244+
}
245+
246+
editedImageExif?.saveAttributes()
247+
}
248+
249+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package fr.free.nrw.commons.edit
2+
3+
import androidx.lifecycle.ViewModel
4+
import java.io.File
5+
6+
/**
7+
* ViewModel for image editing operations.
8+
*
9+
* This ViewModel class is responsible for managing image editing operations, such as
10+
* rotating images. It utilizes a TransformImage implementation to perform image transformations.
11+
*/
12+
class EditViewModel() : ViewModel() {
13+
14+
// Ideally should be injected using DI
15+
private val transformImage: TransformImage = TransformImageImpl()
16+
17+
/**
18+
* Rotates the specified image file by the given degree.
19+
*
20+
* @param degree The degree by which to rotate the image.
21+
* @param imageFile The File representing the image to be rotated.
22+
* @return The rotated image File, or null if the rotation operation fails.
23+
*/
24+
fun rotateImage(degree: Int, imageFile: File): File? {
25+
return transformImage.rotateImage(imageFile, degree)
26+
}
27+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package fr.free.nrw.commons.edit
2+
3+
import java.io.File
4+
5+
/**
6+
* Interface for image transformation operations.
7+
*
8+
* This interface defines a contract for image transformation operations, allowing
9+
* implementations to provide specific functionality for tasks like rotating images.
10+
*/
11+
interface TransformImage {
12+
13+
/**
14+
* Rotates the specified image file by the given degree.
15+
*
16+
* @param imageFile The File representing the image to be rotated.
17+
* @param degree The degree by which to rotate the image.
18+
* @return The rotated image File, or null if the rotation operation fails.
19+
*/
20+
fun rotateImage(imageFile: File, degree : Int ):File?
21+
}

0 commit comments

Comments
 (0)