From 2ddb6b2e5ea0643aa46f0ad20bd7b6862b4e1e79 Mon Sep 17 00:00:00 2001 From: Priyank Shankar Date: Tue, 24 Oct 2023 09:34:21 +0530 Subject: [PATCH] [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 --- app/build.gradle | 4 +- app/src/main/AndroidManifest.xml | 5 + .../fr/free/nrw/commons/edit/EditActivity.kt | 249 ++++++++++++++++++ .../fr/free/nrw/commons/edit/EditViewModel.kt | 27 ++ .../free/nrw/commons/edit/TransformImage.kt | 21 ++ .../nrw/commons/edit/TransformImageImpl.kt | 74 ++++++ .../nrw/commons/upload/UploadActivity.java | 19 ++ .../free/nrw/commons/upload/UploadItem.java | 16 +- .../UploadMediaDetailFragment.java | 104 ++++++-- .../UploadMediaDetailsContract.java | 4 + .../mediaDetails/UploadMediaPresenter.java | 5 + .../res/drawable/baseline_rotate_right.xml | 5 + .../main/res/drawable/baseline_save_24.xml | 5 + app/src/main/res/layout/activity_edit.xml | 46 ++++ .../fragment_upload_media_detail_fragment.xml | 10 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/values/styles.xml | 3 + 17 files changed, 570 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/fr/free/nrw/commons/edit/EditActivity.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/edit/EditViewModel.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/edit/TransformImage.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/edit/TransformImageImpl.kt create mode 100644 app/src/main/res/drawable/baseline_rotate_right.xml create mode 100644 app/src/main/res/drawable/baseline_save_24.xml create mode 100644 app/src/main/res/layout/activity_edit.xml diff --git a/app/build.gradle b/app/build.gradle index 5d782320f..4d0fece84 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -140,6 +140,8 @@ dependencies { implementation "androidx.preference:preference:$PREFERENCE_VERSION" // Kotlin implementation "androidx.preference:preference-ktx:$PREFERENCE_VERSION" + //Android Media + implementation 'com.github.juanitobananas:AndroidMediaUtil:v1.0-1' implementation "androidx.multidex:multidex:$MULTIDEX_VERSION" @@ -236,8 +238,8 @@ android { } } debug { - minifyEnabled false testCoverageEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' testProguardFile 'test-proguard-rules.txt' versionNameSuffix "-debug-" + getBranchName() diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cee5fb81a..5ba49201a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -48,9 +48,14 @@ tools:ignore="GoogleAppIndexingWarning"> + + >() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_edit) + supportActionBar?.title = "" + val intent = intent + imageUri = intent.getStringExtra("image") ?: "" + vm = ViewModelProvider(this).get(EditViewModel::class.java) + val sourceExif = imageUri.toUri().path?.let { ExifInterface(it) } + val exifTags = arrayOf( + ExifInterface.TAG_APERTURE, + ExifInterface.TAG_DATETIME, + ExifInterface.TAG_EXPOSURE_TIME, + ExifInterface.TAG_FLASH, + ExifInterface.TAG_FOCAL_LENGTH, + ExifInterface.TAG_GPS_ALTITUDE, + ExifInterface.TAG_GPS_ALTITUDE_REF, + ExifInterface.TAG_GPS_DATESTAMP, + ExifInterface.TAG_GPS_LATITUDE, + ExifInterface.TAG_GPS_LATITUDE_REF, + ExifInterface.TAG_GPS_LONGITUDE, + ExifInterface.TAG_GPS_LONGITUDE_REF, + ExifInterface.TAG_GPS_PROCESSING_METHOD, + ExifInterface.TAG_GPS_TIMESTAMP, + ExifInterface.TAG_IMAGE_LENGTH, + ExifInterface.TAG_IMAGE_WIDTH, + ExifInterface.TAG_ISO, + ExifInterface.TAG_MAKE, + ExifInterface.TAG_MODEL, + ExifInterface.TAG_ORIENTATION, + ExifInterface.TAG_WHITE_BALANCE, + ExifInterface.WHITEBALANCE_AUTO, + ExifInterface.WHITEBALANCE_MANUAL + ) + for (tag in exifTags) { + val attribute = sourceExif?.getAttribute(tag.toString()) + sourceExifAttributeList.add(Pair(tag.toString(), attribute)) + } + + init() + } + + /** + * Initializes the ImageView and associated UI elements. + * + * 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" and "Save" buttons. + */ + private fun init() { + iv.adjustViewBounds = true + iv.scaleType = ImageView.ScaleType.MATRIX + iv.post(Runnable { + val bitmap = BitmapFactory.decodeFile(imageUri) + iv.setImageBitmap(bitmap) + if (bitmap.width > 0) { + val scale = + iv.measuredWidth.toFloat() / (iv.drawable as BitmapDrawable).bitmap.width.toFloat() + iv.layoutParams.height = + (scale * (iv.drawable as BitmapDrawable).bitmap.height).toInt() + iv.imageMatrix = scaleMatrix(scale, scale) + } + }) + rotate_btn.setOnClickListener { + animateImageHeight() + } + btn_save.setOnClickListener { + getRotatedImage() + } + } + + var imageRotation = 0 + + /** + * Animates the height, rotation, and scale of an ImageView to provide a smooth + * transition effect when rotating an image by 90 degrees. + * + * This function calculates the new height, rotation, and scale for the ImageView + * based on the current image rotation angle and animates the changes using a + * ValueAnimator. It also disables a rotate button during the animation to prevent + * further rotation actions. + */ + private fun animateImageHeight() { + val drawableWidth: Float = iv.getDrawable().getIntrinsicWidth().toFloat() + val drawableHeight: Float = iv.getDrawable().getIntrinsicHeight().toFloat() + val viewWidth: Float = iv.getMeasuredWidth().toFloat() + val viewHeight: Float = iv.getMeasuredHeight().toFloat() + val rotation = imageRotation % 360 + val newRotation = rotation + 90 + + val newViewHeight: Int + val imageScale: Float + val newImageScale: Float + + Timber.d("Rotation $rotation") + Timber.d("new Rotation $newRotation") + + + if (rotation == 0 || rotation == 180) { + imageScale = viewWidth / drawableWidth + newImageScale = viewWidth / drawableHeight + newViewHeight = (drawableWidth * newImageScale).toInt() + } else if (rotation == 90 || rotation == 270) { + imageScale = viewWidth / drawableHeight + newImageScale = viewWidth / drawableWidth + newViewHeight = (drawableHeight * newImageScale).toInt() + } else { + throw UnsupportedOperationException("rotation can 0, 90, 180 or 270. \${rotation} is unsupported") + } + + val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(1000L) + + animator.interpolator = AccelerateDecelerateInterpolator() + + animator.addListener(object : AnimatorListener { + override fun onAnimationStart(animation: Animator) { + rotate_btn.setEnabled(false) + } + + override fun onAnimationEnd(animation: Animator) { + imageRotation = newRotation % 360 + rotate_btn.setEnabled(true) + } + + override fun onAnimationCancel(animation: Animator) { + } + + override fun onAnimationRepeat(animation: Animator) { + } + + }) + + animator.addUpdateListener { animation -> + val animVal = animation.animatedValue as Float + val complementaryAnimVal = 1 - animVal + val animatedHeight = + (complementaryAnimVal * viewHeight + animVal * newViewHeight).toInt() + val animatedScale = complementaryAnimVal * imageScale + animVal * newImageScale + val animatedRotation = complementaryAnimVal * rotation + animVal * newRotation + iv.getLayoutParams().height = animatedHeight + val matrix: Matrix = rotationMatrix( + animatedRotation, + drawableWidth / 2, + drawableHeight / 2 + ) + matrix.postScale( + animatedScale, + animatedScale, + drawableWidth / 2, + drawableHeight / 2 + ) + matrix.postTranslate( + -(drawableWidth - iv.getMeasuredWidth()) / 2, + -(drawableHeight - iv.getMeasuredHeight()) / 2 + ) + iv.setImageMatrix(matrix) + iv.requestLayout() + } + + 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) } + if (rotatedImage == null) { + Toast.makeText(this, "Failed to rotate to image", Toast.LENGTH_LONG).show() + } + val editedImageExif = rotatedImage?.path?.let { ExifInterface(it) } + 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. + * + * This function iterates over the `sourceExifAttributeList` and sets the EXIF attributes + * on the provided `editedImageExif` object. + * + * @param editedImageExif The ExifInterface object for the edited image. + */ + private fun copyExifData(editedImageExif: ExifInterface?) { + + for (attr in sourceExifAttributeList) { + Log.d("Tag is ${attr.first}", "Value is ${attr.second}") + editedImageExif!!.setAttribute(attr.first, attr.second) + Log.d("Tag is ${attr.first}", "Value is ${attr.second}") + } + + editedImageExif?.saveAttributes() + } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/edit/EditViewModel.kt b/app/src/main/java/fr/free/nrw/commons/edit/EditViewModel.kt new file mode 100644 index 000000000..80db0f1ab --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/edit/EditViewModel.kt @@ -0,0 +1,27 @@ +package fr.free.nrw.commons.edit + +import androidx.lifecycle.ViewModel +import java.io.File + +/** + * ViewModel for image editing operations. + * + * This ViewModel class is responsible for managing image editing operations, such as + * rotating images. It utilizes a TransformImage implementation to perform image transformations. + */ +class EditViewModel() : ViewModel() { + + // Ideally should be injected using DI + private val transformImage: TransformImage = TransformImageImpl() + + /** + * Rotates the specified image file by the given degree. + * + * @param degree The degree by which to rotate the image. + * @param imageFile The File representing the image to be rotated. + * @return The rotated image File, or null if the rotation operation fails. + */ + fun rotateImage(degree: Int, imageFile: File): File? { + return transformImage.rotateImage(imageFile, degree) + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/edit/TransformImage.kt b/app/src/main/java/fr/free/nrw/commons/edit/TransformImage.kt new file mode 100644 index 000000000..4c3607a8e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/edit/TransformImage.kt @@ -0,0 +1,21 @@ +package fr.free.nrw.commons.edit + +import java.io.File + +/** + * Interface for image transformation operations. + * + * This interface defines a contract for image transformation operations, allowing + * implementations to provide specific functionality for tasks like rotating images. + */ +interface TransformImage { + + /** + * Rotates the specified image file by the given degree. + * + * @param imageFile The File representing the image to be rotated. + * @param degree The degree by which to rotate the image. + * @return The rotated image File, or null if the rotation operation fails. + */ + fun rotateImage(imageFile: File, degree : Int ):File? +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/edit/TransformImageImpl.kt b/app/src/main/java/fr/free/nrw/commons/edit/TransformImageImpl.kt new file mode 100644 index 000000000..fb96ca044 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/edit/TransformImageImpl.kt @@ -0,0 +1,74 @@ +package fr.free.nrw.commons.edit + +import android.mediautil.image.jpeg.LLJTran +import android.mediautil.image.jpeg.LLJTranException +import android.os.Environment +import timber.log.Timber +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream + +/** + * Implementation of the TransformImage interface for image rotation operations. + * + * This class provides an implementation for the TransformImage interface, right now it exposes a + * function for rotating images by a specified degree using the LLJTran library. Right now it reads + * the input image file, performs the rotation, and saves the rotated image to a new file. + */ +class TransformImageImpl() : TransformImage { + + /** + * Rotates the specified image file by the given degree. + * + * @param imageFile The File representing the image to be rotated. + * @param degree The degree by which to rotate the image. + * @return The rotated image File, or null if the rotation operation fails. + */ + override fun rotateImage(imageFile: File, degree : Int): File? { + + Timber.tag("Trying to rotate image").d("Starting") + + val path = Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS + ) + + val imagePath = System.currentTimeMillis() + val file: File = File(path, "$imagePath.jpg") + + val output = file + + val rotated = try { + val lljTran = LLJTran(imageFile) + lljTran.read( + LLJTran.READ_ALL, + false, + ) // This could throw an LLJTranException. I am not catching it for now... Let's see. + lljTran.transform( + when(degree){ + 90 -> LLJTran.ROT_90 + 180 -> LLJTran.ROT_180 + 270 -> LLJTran.ROT_270 + else -> { + LLJTran.ROT_90 + } + }, + LLJTran.OPT_DEFAULTS or LLJTran.OPT_XFORM_ORIENTATION + ) + BufferedOutputStream(FileOutputStream(output)).use { writer -> + lljTran.save(writer, LLJTran.OPT_WRITE_ALL ) + } + lljTran.freeMemory() + true + } catch (e: LLJTranException) { + Timber.tag("Error").d(e) + return null + false + } + + if (rotated) { + Timber.tag("Done rotating image").d("Done") + Timber.tag("Add").d(output.absolutePath) + } + return output + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java index 8351ec7c4..415629256 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java @@ -65,6 +65,8 @@ import fr.free.nrw.commons.utils.ViewUtil; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.schedulers.Schedulers; +import java.io.File; +import java.security.Permission; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -484,6 +486,23 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, presenter.deletePictureAtIndex(index); } + /** + * Changes the thumbnail of an UploadableFile at the specified index. + * This method updates the list of uploadableFiles by replacing the UploadableFile + * at the given index with a new UploadableFile created from the provided file path. + * After updating the list, it notifies the RecyclerView's adapter to refresh its data, + * ensuring that the thumbnail change is reflected in the UI. + * + * @param index The index of the UploadableFile to be updated. + * @param filepath The file path of the new thumbnail image. + */ + @Override + public void changeThumbnail(int index, String filepath) { + uploadableFiles.remove(index); + uploadableFiles.add(index, new UploadableFile(new File(filepath))); + rvThumbnails.getAdapter().notifyDataSetChanged(); + } + @Override public void onNextButtonClicked(int index) { UploadActivity.this.onNextButtonClicked(index); diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadItem.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadItem.java index 87050fb5c..b3c16b962 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadItem.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadItem.java @@ -14,7 +14,7 @@ import java.util.List; public class UploadItem { - private final Uri mediaUri; + private Uri mediaUri; private final String mimeType; private ImageCoordinates gpsCoords; private List uploadMediaDetails; @@ -31,7 +31,7 @@ public class UploadItem { * Uri of uploadItem * Uri points to image location or name, eg content://media/external/images/camera/10495 (Android 10) */ - private final Uri contentUri; + private Uri contentUri; @SuppressLint("CheckResult") @@ -160,4 +160,16 @@ public class UploadItem { public String getCountryCode() { return countryCode; } + + /** + * Sets both the contentUri and mediaUri to the specified Uri. + * This method allows you to assign the same Uri to both the contentUri and mediaUri + * properties. + * + * @param uri The Uri to be set as both the contentUri and mediaUri. + */ + public void setContentUri(Uri uri) { + contentUri = uri; + mediaUri = uri; + } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.java b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.java index 9cbf78e8c..a2da97696 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.java @@ -6,7 +6,10 @@ import static fr.free.nrw.commons.utils.ImageUtils.FILE_NAME_EXISTS; import static fr.free.nrw.commons.utils.ImageUtils.getErrorMessageForResult; import android.annotation.SuppressLint; +import android.content.ContentResolver; +import android.content.Context; import android.content.Intent; +import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; import android.view.LayoutInflater; @@ -32,6 +35,7 @@ import com.github.chrisbanes.photoview.PhotoView; import com.mapbox.mapboxsdk.camera.CameraPosition; import fr.free.nrw.commons.LocationPicker.LocationPicker; import fr.free.nrw.commons.R; +import fr.free.nrw.commons.edit.EditActivity; import fr.free.nrw.commons.contributions.MainActivity; import fr.free.nrw.commons.filepicker.UploadableFile; import fr.free.nrw.commons.kvstore.JsonKvStore; @@ -51,6 +55,7 @@ import fr.free.nrw.commons.utils.DialogUtil; import fr.free.nrw.commons.utils.ImageUtils; import fr.free.nrw.commons.utils.ViewUtil; import fr.free.nrw.commons.R.drawable.*; +import java.io.File; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -63,10 +68,11 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements UploadMediaDetailsContract.View, UploadMediaDetailAdapter.EventListener { private static final int REQUEST_CODE = 1211; + private static final int REQUEST_CODE_FOR_EDIT_ACTIVITY = 1212; + /** - * A key for applicationKvStore. - * By this key we can retrieve the location of last UploadItem ex. 12.3433,54.78897 - * from applicationKvStore. + * A key for applicationKvStore. By this key we can retrieve the location of last UploadItem ex. + * 12.3433,54.78897 from applicationKvStore. */ public static final String LAST_LOCATION = "last_location_while_uploading"; public static final String LAST_ZOOM = "last_zoom_level_while_uploading"; @@ -86,8 +92,11 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements AppCompatButton btnNext; @BindView(R.id.btn_previous) AppCompatButton btnPrevious; + @BindView(R.id.edit_image) + AppCompatButton editImage; @BindView(R.id.tooltip) ImageView tooltip; + private UploadMediaDetailAdapter uploadMediaDetailAdapter; @BindView(R.id.btn_copy_subsequent_media) AppCompatButton btnCopyToSubsequentMedia; @@ -108,14 +117,14 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements private boolean isExpanded = true; /** - * True if location is added via the "missing location" popup dialog (which appears after tapping - * "Next" if the picture has no geographical coordinates). + * True if location is added via the "missing location" popup dialog (which appears after + * tapping "Next" if the picture has no geographical coordinates). */ private boolean isMissingLocationDialog; /** - * showNearbyFound will be true, if any nearby location found that needs pictures and the nearby popup is yet to be shown - * Used to show and check if the nearby found popup is already shown + * showNearbyFound will be true, if any nearby location found that needs pictures and the nearby + * popup is yet to be shown Used to show and check if the nearby found popup is already shown */ private boolean showNearbyFound; @@ -125,8 +134,8 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements private Place nearbyPlace; private UploadItem uploadItem; /** - * inAppPictureLocation: use location recorded while using the in-app camera if - * device camera does not record it in the EXIF + * inAppPictureLocation: use location recorded while using the in-app camera if device camera + * does not record it in the EXIF */ private LatLng inAppPictureLocation; /** @@ -145,7 +154,8 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements super.onCreate(savedInstanceState); } - public void setImageTobeUploaded(UploadableFile uploadableFile, Place place, LatLng inAppPictureLocation) { + public void setImageTobeUploaded(UploadableFile uploadableFile, Place place, + LatLng inAppPictureLocation) { this.uploadableFile = uploadableFile; this.place = place; this.inAppPictureLocation = inAppPictureLocation; @@ -197,7 +207,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements } //If this is the last media, we have nothing to copy, lets not show the button - if (callback.getIndexInViewFlipper(this) == callback.getTotalNumberOfSteps()-4) { + if (callback.getIndexInViewFlipper(this) == callback.getTotalNumberOfSteps() - 4) { btnCopyToSubsequentMedia.setVisibility(View.GONE); } else { btnCopyToSubsequentMedia.setVisibility(View.VISIBLE); @@ -228,7 +238,8 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements * init the description recycler veiw and caption recyclerview */ private void initRecyclerView() { - uploadMediaDetailAdapter = new UploadMediaDetailAdapter(defaultKvStore.getString(Prefs.DESCRIPTION_LANGUAGE, ""), recentLanguagesDao); + uploadMediaDetailAdapter = new UploadMediaDetailAdapter( + defaultKvStore.getString(Prefs.DESCRIPTION_LANGUAGE, ""), recentLanguagesDao); uploadMediaDetailAdapter.setCallback(this::showInfoAlert); uploadMediaDetailAdapter.setEventListener(this); rvDescriptions.setLayoutManager(new LinearLayoutManager(getContext())); @@ -241,7 +252,8 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements * @param messageStringId */ private void showInfoAlert(int titleStringID, int messageStringId) { - DialogUtil.showAlertDialog(getActivity(), getString(titleStringID), getString(messageStringId), getString(android.R.string.ok), null, true); + DialogUtil.showAlertDialog(getActivity(), getString(titleStringID), + getString(messageStringId), getString(android.R.string.ok), null, true); } @OnClick(R.id.btn_next) @@ -267,6 +279,10 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements rvDescriptions.smoothScrollToPosition(uploadMediaDetailAdapter.getItemCount()-1); } + @OnClick(R.id.edit_image) + public void onEditButtonClicked() { + presenter.onEditButtonClicked(callback.getIndexInViewFlipper(this)); + } @Override public void showSimilarImageFragment(String originalFilePath, String possibleFilePath, ImageCoordinates similarImageCoordinates) { @@ -312,7 +328,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements nearbyPlace = place; this.uploadItem = uploadItem; showNearbyFound = true; - if(callback.getIndexInViewFlipper(this) == 0) { + if (callback.getIndexInViewFlipper(this) == 0) { if (UploadActivity.nearbyPopupAnswers.containsKey(nearbyPlace)) { final boolean response = UploadActivity.nearbyPopupAnswers.get(nearbyPlace); if (response) { @@ -330,7 +346,8 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements * Shows nearby place found popup * @param place */ - @SuppressLint("StringFormatInvalid") // To avoid the unwanted lint warning that string 'upload_nearby_place_found_description' is not of a valid format + @SuppressLint("StringFormatInvalid") + // To avoid the unwanted lint warning that string 'upload_nearby_place_found_description' is not of a valid format private void showNearbyPlaceFound(Place place) { final View customLayout = getLayoutInflater().inflate(R.layout.custom_nearby_found, null); ImageView nearbyFoundImage = customLayout.findViewById(R.id.nearbyItemImage); @@ -367,7 +384,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements protected void onBecameVisible() { super.onBecameVisible(); presenter.fetchTitleAndDescription(callback.getIndexInViewFlipper(this)); - if(showNearbyFound) { + if (showNearbyFound) { if (UploadActivity.nearbyPopupAnswers.containsKey(nearbyPlace)) { final boolean response = UploadActivity.nearbyPopupAnswers.get(nearbyPlace); if (response) { @@ -465,6 +482,24 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements goToLocationPickerActivity(uploadItem); } + /** + * Launches the image editing activity to edit the specified UploadItem. + * + * @param uploadItem The UploadItem to be edited. + * + * This method is called to start the image editing activity for a specific UploadItem. + * It sets the UploadItem as the currently editable item, creates an intent to launch the + * EditActivity, and passes the image file path as an extra in the intent. The activity + * is started with a request code, allowing the result to be handled in onActivityResult. + */ + @Override + public void showEditActivity(UploadItem uploadItem) { + editableUploadItem = uploadItem; + Intent intent = new Intent(getContext(), EditActivity.class); + intent.putExtra("image", uploadableFile.getFilePath().toString()); + startActivityForResult(intent, REQUEST_CODE_FOR_EDIT_ACTIVITY); + } + /** * Start Location picker activity. Show the location first then user can modify it by clicking * modify location button. @@ -500,7 +535,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements defaultLatitude = Double.parseDouble(locationLatLng[0]); defaultLongitude = Double.parseDouble(locationLatLng[1]); } - if(defaultKvStore.getString(LAST_ZOOM) != null){ + if (defaultKvStore.getString(LAST_ZOOM) != null) { defaultZoom = Double.parseDouble(defaultKvStore.getString(LAST_ZOOM)); } startActivityForResult(new LocationPicker.IntentBuilder() @@ -535,17 +570,33 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements final String longitude = String.valueOf(cameraPosition.target.getLongitude()); final double zoom = cameraPosition.zoom; - editLocation(latitude, longitude,zoom); + editLocation(latitude, longitude, zoom); /* If isMissingLocationDialog is true, it means that the user has already tapped the "Next" button, so go directly to the next step. */ - if(isMissingLocationDialog){ + if (isMissingLocationDialog) { isMissingLocationDialog = false; onNextButtonClicked(); } } } + if (requestCode == REQUEST_CODE_FOR_EDIT_ACTIVITY && resultCode == RESULT_OK) { + String result = data.getStringExtra("editedImageFilePath"); + + if (Objects.equals(result, "Error")) { + Timber.e("Error in rotating image"); + return; + } + try { + photoViewBackgroundImage.setImageURI(Uri.fromFile(new File(result))); + editableUploadItem.setContentUri(Uri.fromFile(new File(result))); + callback.changeThumbnail(callback.getIndexInViewFlipper(this), + result); + } catch (Exception e) { + Timber.e(e); + } + } } /** @@ -553,11 +604,11 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements * @param latitude new latitude * @param longitude new longitude */ - public void editLocation(final String latitude, final String longitude, final double zoom){ + public void editLocation(final String latitude, final String longitude, final double zoom) { editableUploadItem.getGpsCoords().setDecLatitude(Double.parseDouble(latitude)); editableUploadItem.getGpsCoords().setDecLongitude(Double.parseDouble(longitude)); - editableUploadItem.getGpsCoords().setDecimalCoords(latitude+"|"+longitude); + editableUploadItem.getGpsCoords().setDecimalCoords(latitude + "|" + longitude); editableUploadItem.getGpsCoords().setImageCoordsExists(true); editableUploadItem.getGpsCoords().setZoomLevel(zoom); @@ -574,8 +625,9 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements uploadMediaDetailAdapter.setItems(uploadMediaDetails); showNearbyFound = showNearbyFound && ( - uploadMediaDetails == null || uploadMediaDetails.isEmpty() || listContainsEmptyDetails( - uploadMediaDetails)); + uploadMediaDetails == null || uploadMediaDetails.isEmpty() + || listContainsEmptyDetails( + uploadMediaDetails)); } /** @@ -647,15 +699,17 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements public void onPrimaryCaptionTextChange(boolean isNotEmpty) { btnCopyToSubsequentMedia.setEnabled(isNotEmpty); btnCopyToSubsequentMedia.setClickable(isNotEmpty); - btnCopyToSubsequentMedia.setAlpha(isNotEmpty ? 1.0f: 0.5f); + btnCopyToSubsequentMedia.setAlpha(isNotEmpty ? 1.0f : 0.5f); btnNext.setEnabled(isNotEmpty); btnNext.setClickable(isNotEmpty); - btnNext.setAlpha(isNotEmpty ? 1.0f: 0.5f); + btnNext.setAlpha(isNotEmpty ? 1.0f : 0.5f); } public interface UploadMediaDetailFragmentCallback extends Callback { void deletePictureAtIndex(int index); + + void changeThumbnail(int index, String uri); } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailsContract.java b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailsContract.java index b87c4fc99..fbd642668 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailsContract.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailsContract.java @@ -37,6 +37,8 @@ public interface UploadMediaDetailsContract { void showExternalMap(UploadItem uploadItem); + void showEditActivity(UploadItem uploadItem); + void updateMediaDetails(List uploadMediaDetails); void displayAddLocationDialog(Runnable runnable); @@ -56,6 +58,8 @@ public interface UploadMediaDetailsContract { void onMapIconClicked(int indexInViewFlipper); + void onEditButtonClicked(int indexInViewFlipper); + void onUserConfirmedUploadIsOfPlace(Place place, int uploadItemPosition); } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.java b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.java index 866f87584..3a822df7c 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.java @@ -281,6 +281,11 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt view.showExternalMap(repository.getUploads().get(indexInViewFlipper)); } + @Override + public void onEditButtonClicked(int indexInViewFlipper){ + view.showEditActivity(repository.getUploads().get(indexInViewFlipper)); + } + @Override public void onUserConfirmedUploadIsOfPlace(Place place, int uploadItemPosition) { final List uploadMediaDetails = repository.getUploads() diff --git a/app/src/main/res/drawable/baseline_rotate_right.xml b/app/src/main/res/drawable/baseline_rotate_right.xml new file mode 100644 index 000000000..8a90343f4 --- /dev/null +++ b/app/src/main/res/drawable/baseline_rotate_right.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/baseline_save_24.xml b/app/src/main/res/drawable/baseline_save_24.xml new file mode 100644 index 000000000..923fe8ff6 --- /dev/null +++ b/app/src/main/res/drawable/baseline_save_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/activity_edit.xml b/app/src/main/res/layout/activity_edit.xml new file mode 100644 index 000000000..e64416bfe --- /dev/null +++ b/app/src/main/res/layout/activity_edit.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_upload_media_detail_fragment.xml b/app/src/main/res/layout/fragment_upload_media_detail_fragment.xml index 52b143f82..9e493e768 100644 --- a/app/src/main/res/layout/fragment_upload_media_detail_fragment.xml +++ b/app/src/main/res/layout/fragment_upload_media_detail_fragment.xml @@ -142,6 +142,16 @@ android:layout_marginRight="@dimen/standard_gap" android:layout_toLeftOf="@+id/btn_next" android:text="@string/previous" /> +