[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>
This commit is contained in:
Priyank Shankar 2023-10-24 09:34:21 +05:30 committed by GitHub
parent 6b8954b4a9
commit 2ddb6b2e5e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 570 additions and 28 deletions

View file

@ -140,6 +140,8 @@ dependencies {
implementation "androidx.preference:preference:$PREFERENCE_VERSION" implementation "androidx.preference:preference:$PREFERENCE_VERSION"
// Kotlin // Kotlin
implementation "androidx.preference:preference-ktx:$PREFERENCE_VERSION" implementation "androidx.preference:preference-ktx:$PREFERENCE_VERSION"
//Android Media
implementation 'com.github.juanitobananas:AndroidMediaUtil:v1.0-1'
implementation "androidx.multidex:multidex:$MULTIDEX_VERSION" implementation "androidx.multidex:multidex:$MULTIDEX_VERSION"
@ -236,8 +238,8 @@ android {
} }
} }
debug { debug {
minifyEnabled false
testCoverageEnabled true testCoverageEnabled true
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
testProguardFile 'test-proguard-rules.txt' testProguardFile 'test-proguard-rules.txt'
versionNameSuffix "-debug-" + getBranchName() versionNameSuffix "-debug-" + getBranchName()

View file

@ -48,9 +48,14 @@
tools:ignore="GoogleAppIndexingWarning"> tools:ignore="GoogleAppIndexingWarning">
<activity <activity
android:theme="@style/EditActivityTheme"
android:name=".description.DescriptionEditActivity" android:name=".description.DescriptionEditActivity"
android:exported="true" /> android:exported="true" />
<activity
android:name=".edit.EditActivity"
android:exported="false" />
<activity android:name="org.acra.dialog.CrashReportDialog" <activity android:name="org.acra.dialog.CrashReportDialog"
android:process=":acra" android:process=":acra"
android:launchMode="singleInstance" android:launchMode="singleInstance"

View file

@ -0,0 +1,249 @@
package fr.free.nrw.commons.edit
import android.animation.Animator
import android.animation.Animator.AnimatorListener
import android.animation.ValueAnimator
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.graphics.drawable.BitmapDrawable
import android.media.ExifInterface
import android.os.Bundle
import android.util.Log
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.ImageView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.rotationMatrix
import androidx.core.graphics.scaleMatrix
import androidx.core.net.toUri
import androidx.lifecycle.ViewModelProvider
import fr.free.nrw.commons.R
import kotlinx.android.synthetic.main.activity_edit.btn_save
import kotlinx.android.synthetic.main.activity_edit.iv
import kotlinx.android.synthetic.main.activity_edit.rotate_btn
import timber.log.Timber
import java.io.File
/**
* An activity class for editing and rotating images using LLJTran with EXIF attribute preservation.
*
* This activity allows loads an image, allows users to rotate it by 90-degree increments, and
* save the edited image while preserving its EXIF attributes. The class includes methods
* for initializing the UI, animating image rotations, copying EXIF data, and handling
* the image-saving process.
*/
class EditActivity : AppCompatActivity() {
private var imageUri = ""
private lateinit var vm: EditViewModel
private val sourceExifAttributeList = mutableListOf<Pair<String, String?>>()
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()
}
}

View file

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

View file

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

View file

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

View file

@ -65,6 +65,8 @@ import fr.free.nrw.commons.utils.ViewUtil;
import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.schedulers.Schedulers; import io.reactivex.schedulers.Schedulers;
import java.io.File;
import java.security.Permission;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
@ -484,6 +486,23 @@ public class UploadActivity extends BaseActivity implements UploadContract.View,
presenter.deletePictureAtIndex(index); 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 @Override
public void onNextButtonClicked(int index) { public void onNextButtonClicked(int index) {
UploadActivity.this.onNextButtonClicked(index); UploadActivity.this.onNextButtonClicked(index);

View file

@ -14,7 +14,7 @@ import java.util.List;
public class UploadItem { public class UploadItem {
private final Uri mediaUri; private Uri mediaUri;
private final String mimeType; private final String mimeType;
private ImageCoordinates gpsCoords; private ImageCoordinates gpsCoords;
private List<UploadMediaDetail> uploadMediaDetails; private List<UploadMediaDetail> uploadMediaDetails;
@ -31,7 +31,7 @@ public class UploadItem {
* Uri of uploadItem * Uri of uploadItem
* Uri points to image location or name, eg content://media/external/images/camera/10495 (Android 10) * 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") @SuppressLint("CheckResult")
@ -160,4 +160,16 @@ public class UploadItem {
public String getCountryCode() { public String getCountryCode() {
return countryCode; 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;
}
} }

View file

@ -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 static fr.free.nrw.commons.utils.ImageUtils.getErrorMessageForResult;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils; import android.text.TextUtils;
import android.view.LayoutInflater; import android.view.LayoutInflater;
@ -32,6 +35,7 @@ import com.github.chrisbanes.photoview.PhotoView;
import com.mapbox.mapboxsdk.camera.CameraPosition; import com.mapbox.mapboxsdk.camera.CameraPosition;
import fr.free.nrw.commons.LocationPicker.LocationPicker; import fr.free.nrw.commons.LocationPicker.LocationPicker;
import fr.free.nrw.commons.R; 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.contributions.MainActivity;
import fr.free.nrw.commons.filepicker.UploadableFile; import fr.free.nrw.commons.filepicker.UploadableFile;
import fr.free.nrw.commons.kvstore.JsonKvStore; 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.ImageUtils;
import fr.free.nrw.commons.utils.ViewUtil; import fr.free.nrw.commons.utils.ViewUtil;
import fr.free.nrw.commons.R.drawable.*; import fr.free.nrw.commons.R.drawable.*;
import java.io.File;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
@ -63,10 +68,11 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
UploadMediaDetailsContract.View, UploadMediaDetailAdapter.EventListener { UploadMediaDetailsContract.View, UploadMediaDetailAdapter.EventListener {
private static final int REQUEST_CODE = 1211; private static final int REQUEST_CODE = 1211;
private static final int REQUEST_CODE_FOR_EDIT_ACTIVITY = 1212;
/** /**
* A key for applicationKvStore. * A key for applicationKvStore. By this key we can retrieve the location of last UploadItem ex.
* By this key we can retrieve the location of last UploadItem ex. 12.3433,54.78897 * 12.3433,54.78897 from applicationKvStore.
* from applicationKvStore.
*/ */
public static final String LAST_LOCATION = "last_location_while_uploading"; public static final String LAST_LOCATION = "last_location_while_uploading";
public static final String LAST_ZOOM = "last_zoom_level_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; AppCompatButton btnNext;
@BindView(R.id.btn_previous) @BindView(R.id.btn_previous)
AppCompatButton btnPrevious; AppCompatButton btnPrevious;
@BindView(R.id.edit_image)
AppCompatButton editImage;
@BindView(R.id.tooltip) @BindView(R.id.tooltip)
ImageView tooltip; ImageView tooltip;
private UploadMediaDetailAdapter uploadMediaDetailAdapter; private UploadMediaDetailAdapter uploadMediaDetailAdapter;
@BindView(R.id.btn_copy_subsequent_media) @BindView(R.id.btn_copy_subsequent_media)
AppCompatButton btnCopyToSubsequentMedia; AppCompatButton btnCopyToSubsequentMedia;
@ -108,14 +117,14 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
private boolean isExpanded = true; private boolean isExpanded = true;
/** /**
* True if location is added via the "missing location" popup dialog (which appears after tapping * True if location is added via the "missing location" popup dialog (which appears after
* "Next" if the picture has no geographical coordinates). * tapping "Next" if the picture has no geographical coordinates).
*/ */
private boolean isMissingLocationDialog; private boolean isMissingLocationDialog;
/** /**
* showNearbyFound will be true, if any nearby location found that needs pictures and the nearby popup is yet to be shown * showNearbyFound will be true, if any nearby location found that needs pictures and the nearby
* Used to show and check if the nearby found popup is already shown * popup is yet to be shown Used to show and check if the nearby found popup is already shown
*/ */
private boolean showNearbyFound; private boolean showNearbyFound;
@ -125,8 +134,8 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
private Place nearbyPlace; private Place nearbyPlace;
private UploadItem uploadItem; private UploadItem uploadItem;
/** /**
* inAppPictureLocation: use location recorded while using the in-app camera if * inAppPictureLocation: use location recorded while using the in-app camera if device camera
* device camera does not record it in the EXIF * does not record it in the EXIF
*/ */
private LatLng inAppPictureLocation; private LatLng inAppPictureLocation;
/** /**
@ -145,7 +154,8 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
super.onCreate(savedInstanceState); 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.uploadableFile = uploadableFile;
this.place = place; this.place = place;
this.inAppPictureLocation = inAppPictureLocation; 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 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); btnCopyToSubsequentMedia.setVisibility(View.GONE);
} else { } else {
btnCopyToSubsequentMedia.setVisibility(View.VISIBLE); btnCopyToSubsequentMedia.setVisibility(View.VISIBLE);
@ -228,7 +238,8 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
* init the description recycler veiw and caption recyclerview * init the description recycler veiw and caption recyclerview
*/ */
private void initRecyclerView() { 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.setCallback(this::showInfoAlert);
uploadMediaDetailAdapter.setEventListener(this); uploadMediaDetailAdapter.setEventListener(this);
rvDescriptions.setLayoutManager(new LinearLayoutManager(getContext())); rvDescriptions.setLayoutManager(new LinearLayoutManager(getContext()));
@ -241,7 +252,8 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
* @param messageStringId * @param messageStringId
*/ */
private void showInfoAlert(int titleStringID, int 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) @OnClick(R.id.btn_next)
@ -267,6 +279,10 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
rvDescriptions.smoothScrollToPosition(uploadMediaDetailAdapter.getItemCount()-1); rvDescriptions.smoothScrollToPosition(uploadMediaDetailAdapter.getItemCount()-1);
} }
@OnClick(R.id.edit_image)
public void onEditButtonClicked() {
presenter.onEditButtonClicked(callback.getIndexInViewFlipper(this));
}
@Override @Override
public void showSimilarImageFragment(String originalFilePath, String possibleFilePath, public void showSimilarImageFragment(String originalFilePath, String possibleFilePath,
ImageCoordinates similarImageCoordinates) { ImageCoordinates similarImageCoordinates) {
@ -312,7 +328,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
nearbyPlace = place; nearbyPlace = place;
this.uploadItem = uploadItem; this.uploadItem = uploadItem;
showNearbyFound = true; showNearbyFound = true;
if(callback.getIndexInViewFlipper(this) == 0) { if (callback.getIndexInViewFlipper(this) == 0) {
if (UploadActivity.nearbyPopupAnswers.containsKey(nearbyPlace)) { if (UploadActivity.nearbyPopupAnswers.containsKey(nearbyPlace)) {
final boolean response = UploadActivity.nearbyPopupAnswers.get(nearbyPlace); final boolean response = UploadActivity.nearbyPopupAnswers.get(nearbyPlace);
if (response) { if (response) {
@ -330,7 +346,8 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
* Shows nearby place found popup * Shows nearby place found popup
* @param place * @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) { private void showNearbyPlaceFound(Place place) {
final View customLayout = getLayoutInflater().inflate(R.layout.custom_nearby_found, null); final View customLayout = getLayoutInflater().inflate(R.layout.custom_nearby_found, null);
ImageView nearbyFoundImage = customLayout.findViewById(R.id.nearbyItemImage); ImageView nearbyFoundImage = customLayout.findViewById(R.id.nearbyItemImage);
@ -367,7 +384,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
protected void onBecameVisible() { protected void onBecameVisible() {
super.onBecameVisible(); super.onBecameVisible();
presenter.fetchTitleAndDescription(callback.getIndexInViewFlipper(this)); presenter.fetchTitleAndDescription(callback.getIndexInViewFlipper(this));
if(showNearbyFound) { if (showNearbyFound) {
if (UploadActivity.nearbyPopupAnswers.containsKey(nearbyPlace)) { if (UploadActivity.nearbyPopupAnswers.containsKey(nearbyPlace)) {
final boolean response = UploadActivity.nearbyPopupAnswers.get(nearbyPlace); final boolean response = UploadActivity.nearbyPopupAnswers.get(nearbyPlace);
if (response) { if (response) {
@ -465,6 +482,24 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
goToLocationPickerActivity(uploadItem); 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 * Start Location picker activity. Show the location first then user can modify it by clicking
* modify location button. * modify location button.
@ -500,7 +535,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
defaultLatitude = Double.parseDouble(locationLatLng[0]); defaultLatitude = Double.parseDouble(locationLatLng[0]);
defaultLongitude = Double.parseDouble(locationLatLng[1]); defaultLongitude = Double.parseDouble(locationLatLng[1]);
} }
if(defaultKvStore.getString(LAST_ZOOM) != null){ if (defaultKvStore.getString(LAST_ZOOM) != null) {
defaultZoom = Double.parseDouble(defaultKvStore.getString(LAST_ZOOM)); defaultZoom = Double.parseDouble(defaultKvStore.getString(LAST_ZOOM));
} }
startActivityForResult(new LocationPicker.IntentBuilder() startActivityForResult(new LocationPicker.IntentBuilder()
@ -535,17 +570,33 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
final String longitude = String.valueOf(cameraPosition.target.getLongitude()); final String longitude = String.valueOf(cameraPosition.target.getLongitude());
final double zoom = cameraPosition.zoom; 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 If isMissingLocationDialog is true, it means that the user has already tapped the
"Next" button, so go directly to the next step. "Next" button, so go directly to the next step.
*/ */
if(isMissingLocationDialog){ if (isMissingLocationDialog) {
isMissingLocationDialog = false; isMissingLocationDialog = false;
onNextButtonClicked(); 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 latitude new latitude
* @param longitude new longitude * @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().setDecLatitude(Double.parseDouble(latitude));
editableUploadItem.getGpsCoords().setDecLongitude(Double.parseDouble(longitude)); editableUploadItem.getGpsCoords().setDecLongitude(Double.parseDouble(longitude));
editableUploadItem.getGpsCoords().setDecimalCoords(latitude+"|"+longitude); editableUploadItem.getGpsCoords().setDecimalCoords(latitude + "|" + longitude);
editableUploadItem.getGpsCoords().setImageCoordsExists(true); editableUploadItem.getGpsCoords().setImageCoordsExists(true);
editableUploadItem.getGpsCoords().setZoomLevel(zoom); editableUploadItem.getGpsCoords().setZoomLevel(zoom);
@ -574,8 +625,9 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
uploadMediaDetailAdapter.setItems(uploadMediaDetails); uploadMediaDetailAdapter.setItems(uploadMediaDetails);
showNearbyFound = showNearbyFound =
showNearbyFound && ( showNearbyFound && (
uploadMediaDetails == null || uploadMediaDetails.isEmpty() || listContainsEmptyDetails( uploadMediaDetails == null || uploadMediaDetails.isEmpty()
uploadMediaDetails)); || listContainsEmptyDetails(
uploadMediaDetails));
} }
/** /**
@ -647,15 +699,17 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
public void onPrimaryCaptionTextChange(boolean isNotEmpty) { public void onPrimaryCaptionTextChange(boolean isNotEmpty) {
btnCopyToSubsequentMedia.setEnabled(isNotEmpty); btnCopyToSubsequentMedia.setEnabled(isNotEmpty);
btnCopyToSubsequentMedia.setClickable(isNotEmpty); btnCopyToSubsequentMedia.setClickable(isNotEmpty);
btnCopyToSubsequentMedia.setAlpha(isNotEmpty ? 1.0f: 0.5f); btnCopyToSubsequentMedia.setAlpha(isNotEmpty ? 1.0f : 0.5f);
btnNext.setEnabled(isNotEmpty); btnNext.setEnabled(isNotEmpty);
btnNext.setClickable(isNotEmpty); btnNext.setClickable(isNotEmpty);
btnNext.setAlpha(isNotEmpty ? 1.0f: 0.5f); btnNext.setAlpha(isNotEmpty ? 1.0f : 0.5f);
} }
public interface UploadMediaDetailFragmentCallback extends Callback { public interface UploadMediaDetailFragmentCallback extends Callback {
void deletePictureAtIndex(int index); void deletePictureAtIndex(int index);
void changeThumbnail(int index, String uri);
} }

View file

@ -37,6 +37,8 @@ public interface UploadMediaDetailsContract {
void showExternalMap(UploadItem uploadItem); void showExternalMap(UploadItem uploadItem);
void showEditActivity(UploadItem uploadItem);
void updateMediaDetails(List<UploadMediaDetail> uploadMediaDetails); void updateMediaDetails(List<UploadMediaDetail> uploadMediaDetails);
void displayAddLocationDialog(Runnable runnable); void displayAddLocationDialog(Runnable runnable);
@ -56,6 +58,8 @@ public interface UploadMediaDetailsContract {
void onMapIconClicked(int indexInViewFlipper); void onMapIconClicked(int indexInViewFlipper);
void onEditButtonClicked(int indexInViewFlipper);
void onUserConfirmedUploadIsOfPlace(Place place, int uploadItemPosition); void onUserConfirmedUploadIsOfPlace(Place place, int uploadItemPosition);
} }

View file

@ -281,6 +281,11 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt
view.showExternalMap(repository.getUploads().get(indexInViewFlipper)); view.showExternalMap(repository.getUploads().get(indexInViewFlipper));
} }
@Override
public void onEditButtonClicked(int indexInViewFlipper){
view.showEditActivity(repository.getUploads().get(indexInViewFlipper));
}
@Override @Override
public void onUserConfirmedUploadIsOfPlace(Place place, int uploadItemPosition) { public void onUserConfirmedUploadIsOfPlace(Place place, int uploadItemPosition) {
final List<UploadMediaDetail> uploadMediaDetails = repository.getUploads() final List<UploadMediaDetail> uploadMediaDetails = repository.getUploads()

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="@color/item_white_background"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M15.55,5.55L11,1v3.07C7.06,4.56 4,7.92 4,12s3.05,7.44 7,7.93v-2.02c-2.84,-0.48 -5,-2.94 -5,-5.91s2.16,-5.43 5,-5.91L11,10l4.55,-4.45zM19.93,11c-0.17,-1.39 -0.72,-2.73 -1.62,-3.89l-1.42,1.42c0.54,0.75 0.88,1.6 1.02,2.47h2.02zM13,17.9v2.02c1.39,-0.17 2.74,-0.71 3.9,-1.61l-1.44,-1.44c-0.75,0.54 -1.59,0.89 -2.46,1.03zM16.89,15.48l1.42,1.41c0.9,-1.16 1.45,-2.5 1.62,-3.89h-2.02c-0.14,0.87 -0.48,1.72 -1.02,2.48z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="@color/bottom_bar_light"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM12,19c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,9L5,9L5,5h10v4z"/>
</vector>

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="bottom"
tools:context=".edit.EditActivity"
android:gravity="center"
android:layout_margin="2dp"
android:orientation="vertical">
<ImageView
android:id="@+id/iv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
/>
<LinearLayout
android:elevation="2dp"
android:gravity="center"
android:orientation="horizontal"
android:layout_gravity="bottom|center"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="2dp">
<androidx.appcompat.widget.AppCompatButton
android:drawablePadding="4dp"
android:drawableStart="@drawable/baseline_rotate_right"
android:id="@+id/rotate_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@android:color/white"
android:text="@string/rotate"/>
<androidx.appcompat.widget.AppCompatButton
android:onClick="getRotatedImage"
android:drawablePadding="4dp"
android:drawableStart="@drawable/baseline_save_24"
android:id="@+id/btn_save"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/menu_save_categories"
android:textColor="@android:color/white" />
</LinearLayout>
</FrameLayout>

View file

@ -142,6 +142,16 @@
android:layout_marginRight="@dimen/standard_gap" android:layout_marginRight="@dimen/standard_gap"
android:layout_toLeftOf="@+id/btn_next" android:layout_toLeftOf="@+id/btn_next"
android:text="@string/previous" /> android:text="@string/previous" />
<Button
android:id="@+id/edit_image"
style="@style/Widget.AppCompat.Button.Borderless"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/standard_gap"
android:layout_marginBottom="24dp"
android:layout_toStartOf="@id/btn_previous"
android:contentDescription="Edit Image"
android:text="Edit Image" />
</RelativeLayout> </RelativeLayout>

View file

@ -353,6 +353,7 @@
<string name="wrong">Wrong Answer</string> <string name="wrong">Wrong Answer</string>
<string name="quiz_screenshot_question">Is this screenshot OK to upload?</string> <string name="quiz_screenshot_question">Is this screenshot OK to upload?</string>
<string name="share_app_title">Share App</string> <string name="share_app_title">Share App</string>
<string name="rotate">Rotate</string>
<string name="error_fetching_nearby_places">Error fetching nearby places.</string> <string name="error_fetching_nearby_places">Error fetching nearby places.</string>
<string name="no_nearby_places_around">No nearby places around</string> <string name="no_nearby_places_around">No nearby places around</string>

View file

@ -158,6 +158,9 @@
<style name="LightFlatNearbyPermissionButton" parent="LightAppTheme"> <style name="LightFlatNearbyPermissionButton" parent="LightAppTheme">
<item name="colorControlHighlight">@color/primaryColor</item> <item name="colorControlHighlight">@color/primaryColor</item>
</style> </style>
<style name="EditActivityTheme" parent="Theme.AppCompat.DayNight.DarkActionBar">
<item name="colorPrimary">@color/primaryColor</item>
</style>
<style name="ProgressBar" parent="Widget.AppCompat.ProgressBar.Horizontal" /> <style name="ProgressBar" parent="Widget.AppCompat.ProgressBar.Horizontal" />