diff --git a/CHANGELOG.md b/CHANGELOG.md index 34820b8d4..72015947f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Wikimedia Commons for Android +## v4.2.1 + +- Provide the ability to edit an image to losslessly rotate it while uploading +- Fix a bug in v4.2.0 where the nearby places were not loading +- Fix a bug where editing depictions was showing a progress bar indefinitely +- In the upload screen, use different map icons to indicate if image is being uploaded with location + metadata +- For nearby uploads, it is no longer possible to deselect the item's category and depiction +- The Mapbox account key used by the app has been changed +- Category search now shows exact matches without any discrepancies +- Various bug and crash fixes + ## v4.2.0 - Dark mode colour improvements - Enhancements done to address location metadata loss including the metadata loss that occurs in diff --git a/app/build.gradle b/app/build.gradle index b38987ec7..44db9ab7e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -94,7 +94,7 @@ dependencies { testImplementation "androidx.arch.core:core-testing:2.2.0" testImplementation "org.junit.jupiter:junit-jupiter-api:5.10.0" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.10.0" - testImplementation 'com.facebook.soloader:soloader:0.10.1' + testImplementation 'com.facebook.soloader:soloader:0.10.5' testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3" // Android testing @@ -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" @@ -176,8 +178,8 @@ android { defaultConfig { //applicationId 'fr.free.nrw.commons' - versionCode 1035 - versionName '4.2.0' + versionCode 1036 + versionName '4.2.1' setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName()) minSdkVersion 21 @@ -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/proguard-rules.txt b/app/proguard-rules.txt index 91f540693..8a0b24e83 100644 --- a/app/proguard-rules.txt +++ b/app/proguard-rules.txt @@ -31,8 +31,15 @@ -keepattributes Signature # Retain declared checked exceptions for use by a Proxy instance. -keepattributes Exceptions -# Classes used by retrofit to fetch API repsonse + +# Application classes that will be serialized/deserialized over Gson -keepclasseswithmembers class org.wikipedia.** { *; } +# Note: The model package right now seems to include some other classes that +# are not used for serialization / deserialization over Gson. Hopefully +# that's not a problem since it only prevents R8 from avoiding trimming +# of few more classes. +-keepclasseswithmembers class fr.free.nrw.commons.*.model.** { *; } + # --- /Retrofit --- # --- OkHttp + Okio --- 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"> + + pauseUploads = new HashMap<>(); + /** + * In-memory list of uploads that have been cancelled by the user + */ + public static HashSet cancelledUploads = new HashSet<>(); + /** * Used to declare and initialize various components and dependencies */ diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java index 468272efb..6fed83a5d 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java @@ -36,6 +36,7 @@ import butterknife.BindView; import butterknife.ButterKnife; import butterknife.OnClick; import com.google.android.material.floatingactionbutton.FloatingActionButton; +import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.Media; import fr.free.nrw.commons.R; import fr.free.nrw.commons.Utils; @@ -429,6 +430,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl () -> { ViewUtil.showShortToast(getContext(), R.string.cancelling_upload); contributionsListPresenter.deleteUpload(contribution); + CommonsApplication.cancelledUploads.add(contribution.getPageId()); }, () -> { // Do nothing }); 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 new file mode 100644 index 000000000..ad2ddf4ea --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/edit/EditActivity.kt @@ -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>() + + 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 7b4716b41..3109aec53 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 @@ -43,6 +43,7 @@ import fr.free.nrw.commons.R; import fr.free.nrw.commons.auth.LoginActivity; import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.contributions.ContributionController; +import fr.free.nrw.commons.contributions.MainActivity; import fr.free.nrw.commons.filepicker.Constants.RequestCodes; import fr.free.nrw.commons.filepicker.UploadableFile; import fr.free.nrw.commons.kvstore.JsonKvStore; @@ -65,6 +66,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; @@ -529,6 +532,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/categories/UploadCategoriesFragment.java b/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.java index e378ef53b..f32fb286b 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.java @@ -198,6 +198,22 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate } else { adapter.setItems(categories); } + adapter.notifyDataSetChanged(); + + // Nested waiting for search result data to load into the category + // list and smoothly scroll to the top of the search result list. + rvCategories.post(new Runnable() { + @Override + public void run() { + rvCategories.smoothScrollToPosition(0); + rvCategories.post(new Runnable() { + @Override + public void run() { + rvCategories.smoothScrollToPosition(0); + } + }); + } + }); } @Override diff --git a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsFragment.java b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsFragment.java index 3582f69c0..4450f67ef 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsFragment.java @@ -246,7 +246,21 @@ public class DepictsFragment extends UploadBaseFragment implements DepictsContra adapter.setItems(depictedItemList); } } - depictsRecyclerView.smoothScrollToPosition(0); + + // Nested waiting for search result data to load into the depicted item + // list and smoothly scroll to the top of the search result list. + depictsRecyclerView.post(new Runnable() { + @Override + public void run() { + depictsRecyclerView.smoothScrollToPosition(0); + depictsRecyclerView.post(new Runnable() { + @Override + public void run() { + depictsRecyclerView.smoothScrollToPosition(0); + } + }); + } + }); } /** 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 2f54ae15c..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 @@ -1,11 +1,15 @@ package fr.free.nrw.commons.upload.mediaDetails; import static android.app.Activity.RESULT_OK; +import static fr.free.nrw.commons.utils.ActivityUtils.startActivityWithFlags; 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; @@ -31,6 +35,8 @@ 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; import fr.free.nrw.commons.location.LatLng; @@ -49,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; @@ -61,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"; @@ -84,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; @@ -106,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; @@ -123,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; /** @@ -143,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; @@ -195,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); @@ -226,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())); @@ -239,12 +252,18 @@ 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) public void onNextButtonClicked() { - presenter.verifyImageQuality(callback.getIndexInViewFlipper(this), inAppPictureLocation); + boolean isValidUploads = presenter.verifyImageQuality(callback.getIndexInViewFlipper(this), inAppPictureLocation); + if (!isValidUploads) { + startActivityWithFlags( + getActivity(), MainActivity.class, Intent.FLAG_ACTIVITY_CLEAR_TOP, + Intent.FLAG_ACTIVITY_SINGLE_TOP); + } } @OnClick(R.id.btn_previous) @@ -260,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) { @@ -305,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) { @@ -323,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); @@ -360,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) { @@ -458,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. @@ -493,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() @@ -528,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); + } + } } /** @@ -546,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); @@ -567,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)); } /** @@ -640,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 99299e2a9..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); @@ -46,7 +48,7 @@ public interface UploadMediaDetailsContract { void receiveImage(UploadableFile uploadableFile, Place place, LatLng inAppPictureLocation); - void verifyImageQuality(int uploadItemIndex, LatLng inAppPictureLocation); + boolean verifyImageQuality(int uploadItemIndex, LatLng inAppPictureLocation); void copyTitleAndDescriptionToSubsequentMedia(int indexInViewFlipper); @@ -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 04d69e825..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 @@ -178,8 +178,15 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt * @param uploadItemIndex */ @Override - public void verifyImageQuality(int uploadItemIndex, LatLng inAppPictureLocation) { - final UploadItem uploadItem = repository.getUploads().get(uploadItemIndex); + public boolean verifyImageQuality(int uploadItemIndex, LatLng inAppPictureLocation) { + final List uploadItems = repository.getUploads(); + if (uploadItems.size()==0) { + view.showProgress(false); + // No internationalization required for this error message because it's an internal error. + view.showMessage("Internal error: Zero upload items received by the Upload Media Detail Fragment. Sorry, please upload again.",R.color.color_error); + return false; + } + UploadItem uploadItem = uploadItems.get(uploadItemIndex); if (uploadItem.getGpsCoords().getDecimalCoords() == null && inAppPictureLocation == null) { final Runnable onSkipClicked = () -> { @@ -227,6 +234,7 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt }) ); } + return true; } @@ -273,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/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt b/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt index c26243762..ee03ac845 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt @@ -172,6 +172,16 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) : CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL )!! withContext(Dispatchers.IO) { + /* + queuedContributions receives the results from a one-shot query. + This means that once the list has been fetched from the database, + it does not get updated even if some changes (insertions, deletions, etc.) + are made to the contribution table afterwards. + + Related issues (fixed): + https://github.com/commons-app/apps-android-commons/issues/5136 + https://github.com/commons-app/apps-android-commons/issues/5346 + */ val queuedContributions = contributionDao.getContribution(statesToProcess) .blockingGet() //Showing initial notification for the number of uploads being processed @@ -202,24 +212,32 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) : } queuedContributions.asFlow().map { contribution -> - /** - * If the limited connection mode is on, lets iterate through the queued - * contributions - * and set the state as STATE_QUEUED_LIMITED_CONNECTION_MODE , - * otherwise proceed with the upload - */ - if (isLimitedConnectionModeEnabled()) { - if (contribution.state == Contribution.STATE_QUEUED) { - contribution.state = Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE + // Upload the contribution if it has not been cancelled by the user + if (!CommonsApplication.cancelledUploads.contains(contribution.pageId)) { + /** + * If the limited connection mode is on, lets iterate through the queued + * contributions + * and set the state as STATE_QUEUED_LIMITED_CONNECTION_MODE , + * otherwise proceed with the upload + */ + if (isLimitedConnectionModeEnabled()) { + if (contribution.state == Contribution.STATE_QUEUED) { + contribution.state = Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE + contributionDao.saveSynchronous(contribution) + } + } else { + contribution.transferred = 0 + contribution.state = Contribution.STATE_IN_PROGRESS contributionDao.saveSynchronous(contribution) + setProgressAsync(Data.Builder().putInt("progress", countUpload).build()) + countUpload++ + uploadContribution(contribution = contribution) } } else { - contribution.transferred = 0 - contribution.state = Contribution.STATE_IN_PROGRESS - contributionDao.saveSynchronous(contribution) - setProgressAsync(Data.Builder().putInt("progress", countUpload).build()) - countUpload++ - uploadContribution(contribution = contribution) + /* We can remove the cancelled upload from the hashset + as this contribution will not be processed again + */ + removeUploadFromInMemoryHashSet(contribution) } }.collect() @@ -240,6 +258,13 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) : return Result.success() } + /** + * Removes the processed contribution from the cancelledUploads in-memory hashset + */ + private fun removeUploadFromInMemoryHashSet(contribution: Contribution) { + CommonsApplication.cancelledUploads.remove(contribution.pageId) + } + /** * Create new notification for foreground service */ 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" /> +