diff --git a/app/build.gradle b/app/build.gradle index 684ad65df..ddd786bac 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -122,6 +122,7 @@ dependencies { implementation "androidx.exifinterface:exifinterface:1.3.2" implementation "androidx.core:core-ktx:$CORE_KTX_VERSION" implementation "androidx.multidex:multidex:2.0.1" + compile 'com.simplecityapps:recyclerview-fastscroll:2.0.1' //swipe_layout implementation 'com.daimajia.swipelayout:library:1.2.0@aar' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cf245ed7c..803945f9a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -56,7 +56,10 @@ android:finishOnTaskLaunch="true" /> + android:name=".media.ZoomableActivity" + android:label="Zoomable Activity" + android:configChanges="screenSize|keyboard|orientation" + android:parentActivityName=".customselector.ui.selector.CustomSelectorActivity" /> diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadDao.kt b/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadDao.kt new file mode 100644 index 000000000..d6c9235ce --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadDao.kt @@ -0,0 +1,57 @@ +package fr.free.nrw.commons.customselector.database + +import androidx.room.* + + +/** + * Dao class for Not For Upload + */ +@Dao +abstract class NotForUploadStatusDao { + + /** + * Insert into Not For Upload status. + */ + @Insert( onConflict = OnConflictStrategy.REPLACE ) + abstract suspend fun insert(notForUploadStatus: NotForUploadStatus) + + /** + * Delete Not For Upload status entry. + */ + @Delete + abstract suspend fun delete(notForUploadStatus: NotForUploadStatus) + + /** + * Query Not For Upload status with image sha1. + */ + @Query("SELECT * FROM images_not_for_upload_table WHERE imageSHA1 = (:imageSHA1) ") + abstract suspend fun getFromImageSHA1(imageSHA1 : String) : NotForUploadStatus? + + /** + * Asynchronous image sha1 query. + */ + suspend fun getNotForUploadFromImageSHA1(imageSHA1: String):NotForUploadStatus? { + return getFromImageSHA1(imageSHA1) + } + + /** + * Deletion Not For Upload status with image sha1. + */ + @Query("DELETE FROM images_not_for_upload_table WHERE imageSHA1 = (:imageSHA1) ") + abstract suspend fun deleteWithImageSHA1(imageSHA1 : String) + + /** + * Asynchronous image sha1 deletion. + */ + suspend fun deleteNotForUploadWithImageSHA1(imageSHA1: String) { + return deleteWithImageSHA1(imageSHA1) + } + + /** + * Check whether the imageSHA1 is present in database + */ + @Query("SELECT COUNT() FROM images_not_for_upload_table WHERE imageSHA1 = (:imageSHA1) ") + abstract suspend fun find(imageSHA1 : String): Int +} + + diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadStatus.kt b/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadStatus.kt new file mode 100644 index 000000000..31d48af97 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadStatus.kt @@ -0,0 +1,16 @@ +package fr.free.nrw.commons.customselector.database + +import androidx.room.* + +/** + * Entity class for Not For Upload status. + */ +@Entity(tableName = "images_not_for_upload_table") +data class NotForUploadStatus( + + /** + * Original image sha1. + */ + @PrimaryKey + val imageSHA1 : String +) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedDao.kt b/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedDao.kt index 49a1f61c3..70454cc9e 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedDao.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedDao.kt @@ -47,10 +47,24 @@ abstract class UploadedStatusDao { insert(uploadedStatus) } + /** + * Check whether the imageSHA1 is present in database + */ + @Query("SELECT COUNT() FROM uploaded_table WHERE imageSHA1 = (:imageSHA1) AND imageResult = (:imageResult) ") + abstract suspend fun findByImageSHA1(imageSHA1 : String, imageResult: Boolean): Int + + /** + * Check whether the modifiedImageSHA1 is present in database + */ + @Query("SELECT COUNT() FROM uploaded_table WHERE modifiedImageSHA1 = (:modifiedImageSHA1) AND modifiedImageResult = (:modifiedImageResult) ") + abstract suspend fun findByModifiedImageSHA1(modifiedImageSHA1 : String, + modifiedImageResult: Boolean): Int + /** * Asynchronous image sha1 query. */ suspend fun getUploadedFromImageSHA1(imageSHA1: String):UploadedStatus? { return getFromImageSHA1(imageSHA1) } + } \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/helper/CustomSelectorConstants.kt b/app/src/main/java/fr/free/nrw/commons/customselector/helper/CustomSelectorConstants.kt new file mode 100644 index 000000000..f28a1c613 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/CustomSelectorConstants.kt @@ -0,0 +1,15 @@ +package fr.free.nrw.commons.customselector.helper + +/** + * Stores constants related to custom image selector + */ +object CustomSelectorConstants { + + const val BUCKET_ID = "bucket_id" + const val TOTAL_SELECTED_IMAGES = "total_selected_images" + const val PRESENT_POSITION = "present_position" + const val NEW_SELECTED_IMAGES = "new_selected_images" + const val SHOULD_REFRESH = "should_refresh" + const val FULL_SCREEN_MODE_FIRST_LUNCH = "full_screen_mode_first_launch" + +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt b/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt index 06ec4c36c..f59d8a844 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt @@ -4,10 +4,20 @@ import fr.free.nrw.commons.customselector.model.Folder import fr.free.nrw.commons.customselector.model.Image /** - * Image Helper object, includes all the static functions required by custom selector. + * Image Helper object, includes all the static functions and variables required by custom selector. */ object ImageHelper { + /** + * Custom selector preference key + */ + const val CUSTOM_SELECTOR_PREFERENCE_KEY: String = "custom_selector" + + /** + * Switch state preference key + */ + const val SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY: String = "show_already_actioned_images" + /** * Returns the list of folders from given image list. */ diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/helper/OnSwipeTouchListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/helper/OnSwipeTouchListener.kt new file mode 100644 index 000000000..89cbb8fb4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/OnSwipeTouchListener.kt @@ -0,0 +1,103 @@ +package fr.free.nrw.commons.customselector.helper + +import android.content.Context +import android.util.DisplayMetrics +import android.view.* +import kotlin.math.abs + +/** + * Class for detecting swipe gestures + */ +open class OnSwipeTouchListener(context: Context?) : View.OnTouchListener { + + private val gestureDetector: GestureDetector + + private val SWIPE_THRESHOLD_HEIGHT = (getScreenResolution(context!!)).second / 3 + private val SWIPE_THRESHOLD_WIDTH = (getScreenResolution(context!!)).first / 3 + private val SWIPE_VELOCITY_THRESHOLD = 1000 + + override fun onTouch(view: View?, motionEvent: MotionEvent?): Boolean { + return gestureDetector.onTouchEvent(motionEvent) + } + + fun getScreenResolution(context: Context): Pair { + val wm: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + val display: Display = wm.getDefaultDisplay() + val metrics = DisplayMetrics() + display.getMetrics(metrics) + val width: Int = metrics.widthPixels + val height: Int = metrics.heightPixels + return width to height + } + + inner class GestureListener : GestureDetector.SimpleOnGestureListener() { + + override fun onDown(e: MotionEvent?): Boolean { + return true + } + + /** + * Detects the gestures + */ + override fun onFling( + event1: MotionEvent, + event2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + try { + val diffY: Float = event2.y - event1.y + val diffX: Float = event2.x - event1.x + if (abs(diffX) > abs(diffY)) { + if (abs(diffX) > SWIPE_THRESHOLD_WIDTH && abs(velocityX) > + SWIPE_VELOCITY_THRESHOLD) { + if (diffX > 0) { + onSwipeRight() + } else { + onSwipeLeft() + } + } + } else { + if (abs(diffY) > SWIPE_THRESHOLD_HEIGHT && abs(velocityY) > + SWIPE_VELOCITY_THRESHOLD) { + if (diffY > 0) { + onSwipeDown() + } else { + onSwipeUp() + } + } + } + } catch (exception: Exception) { + exception.printStackTrace() + } + return false + } + } + + /** + * Swipe right to view previous image + */ + open fun onSwipeRight() {} + + /** + * Swipe left to view next image + */ + open fun onSwipeLeft() {} + + /** + * Swipe up to select the picture (the equivalent of tapping it in non-fullscreen mode) + * and show the next picture skipping pictures that have either already been uploaded or + * marked as not for upload + */ + open fun onSwipeUp() {} + + /** + * Swipe down to mark that picture as "Not for upload" (the equivalent of selecting it then + * tapping "Mark as not for upload" in non-fullscreen mode), and show the next picture. + */ + open fun onSwipeDown() {} + + init { + gestureDetector = GestureDetector(context, GestureListener()) + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt index 1d7310b1d..d6349eb49 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt @@ -11,12 +11,17 @@ interface ImageSelectListener { /** * onSelectedImagesChanged * @param selectedImages : new selected images. + * @param selectedNotForUploadImages : number of selected not for upload images */ - fun onSelectedImagesChanged(selectedImages: ArrayList) + fun onSelectedImagesChanged(selectedImages: ArrayList, selectedNotForUploadImages: Int) /** * onLongPress * @param imageUri : uri of image */ - fun onLongPress(imageUri: Uri) + fun onLongPress( + position: Int, + images: ArrayList, + selectedImages: ArrayList + ) } \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/PassDataListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/PassDataListener.kt new file mode 100644 index 000000000..d2110d208 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/PassDataListener.kt @@ -0,0 +1,10 @@ +package fr.free.nrw.commons.customselector.listeners + +import fr.free.nrw.commons.customselector.model.Image + +/** + * Interface to pass data between fragment and activity + */ +interface PassDataListener { + fun passSelectedImages(selectedImages: ArrayList, shouldRefresh: Boolean) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/RefreshUIListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/RefreshUIListener.kt new file mode 100644 index 000000000..d271c1d0b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/RefreshUIListener.kt @@ -0,0 +1,11 @@ +package fr.free.nrw.commons.customselector.listeners + +/** + * Refresh UI Listener + */ +interface RefreshUIListener { + /** + * Refreshes the data in adapter + */ + fun refresh() +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/model/Image.kt b/app/src/main/java/fr/free/nrw/commons/customselector/model/Image.kt index 12e75580d..38e7b6b85 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/model/Image.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/model/Image.kt @@ -41,7 +41,13 @@ data class Image( /** sha1 : sha1 of original image. */ - var sha1: String = "" + var sha1: String = "", + + /** + * date: Creation date of the image to show it inside the bubble during bubble scroll. + */ + var date: String = "" + ) : Parcelable { /** @@ -54,6 +60,7 @@ data class Image( parcel.readString()!!, parcel.readLong(), parcel.readString()!!, + parcel.readString()!!, parcel.readString()!! ) @@ -68,6 +75,7 @@ data class Image( parcel.writeLong(bucketId) parcel.writeString(bucketName) parcel.writeString(sha1) + parcel.writeString(date) } /** diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt index f3fce1cc0..4180f0082 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt @@ -1,6 +1,7 @@ package fr.free.nrw.commons.customselector.ui.adapter import android.content.Context +import android.content.SharedPreferences import android.view.View import android.view.ViewGroup import android.widget.ImageView @@ -10,11 +11,17 @@ import androidx.constraintlayout.widget.Group import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide +import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView import fr.free.nrw.commons.R import fr.free.nrw.commons.customselector.helper.ImageHelper +import fr.free.nrw.commons.customselector.helper.ImageHelper.CUSTOM_SELECTOR_PREFERENCE_KEY +import fr.free.nrw.commons.customselector.helper.ImageHelper.SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY import fr.free.nrw.commons.customselector.listeners.ImageSelectListener import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.ui.selector.ImageLoader +import kotlinx.coroutines.* +import java.util.* +import kotlin.collections.ArrayList /** * Custom selector ImageAdapter. @@ -36,7 +43,7 @@ class ImageAdapter( private var imageLoader: ImageLoader ): - RecyclerViewAdapter(context) { + RecyclerViewAdapter(context), FastScrollRecyclerView.SectionedAdapter { /** * ImageSelectedOrUpdated payload class. @@ -48,16 +55,58 @@ class ImageAdapter( */ class ImageUnselected + /** + * Determines whether addition of all actionable images is done or not + */ + private var reachedEndOfFolder: Boolean = false + /** * Currently selected images. */ private var selectedImages = arrayListOf() + /** + * Number of selected images that are marked as not for upload + */ + private var numberOfSelectedImagesMarkedAsNotForUpload = 0 + /** * List of all images in adapter. */ private var images: ArrayList = ArrayList() + /** + * Stores all images + */ + private var allImages: List = ArrayList() + + /** + * Map to store actionable images + */ + private var actionableImagesMap: TreeMap = TreeMap() + + /** + * Stores already added positions of actionable images + */ + private var alreadyAddedPositions: ArrayList = ArrayList() + + /** + * Next starting index to initiate query to find next actionable image + */ + private var nextImagePosition = 0 + + /** + * Helps to maintain the increasing sequence of the position. eg- 0, 1, 2, 3 + */ + private var imagePositionAsPerIncreasingOrder = 0 + + /** + * Coroutine Dispatchers and Scope. + */ + private var defaultDispatcher : CoroutineDispatcher = Dispatchers.Default + private var ioDispatcher : CoroutineDispatcher = Dispatchers.IO + private val scope : CoroutineScope = MainScope() + /** * Create View holder. */ @@ -70,7 +119,7 @@ class ImageAdapter( * Bind View holder, load image, selected view, click listeners. */ override fun onBindViewHolder(holder: ImageViewHolder, position: Int) { - val image=images[position] + var image=images[position] holder.image.setImageDrawable (null) if (context.contentResolver.getType(image.uri) == null) { // Image does not exist anymore, update adapter. @@ -81,56 +130,214 @@ class ImageAdapter( notifyItemRangeChanged(updatedPosition, images.size) } } else { - val selectedIndex = ImageHelper.getIndex(selectedImages, image) + val sharedPreferences: SharedPreferences = + context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0) + val showAlreadyActionedImages = + sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) + + // Getting selected index when switch is on + val selectedIndex: Int = if (showAlreadyActionedImages) { + ImageHelper.getIndex(selectedImages, image) + + // Getting selected index when switch is off + } else if (actionableImagesMap.size > position) { + ImageHelper + .getIndex(selectedImages, ArrayList(actionableImagesMap.values)[position]) + + // For any other case return -1 + } else { + -1 + } + val isSelected = selectedIndex != -1 if (isSelected) { - holder.itemSelected(selectedIndex + 1) + holder.itemSelected(selectedImages.size) } else { - holder.itemUnselected(); + holder.itemUnselected() } - Glide.with(holder.image).load(image.uri).thumbnail(0.3f).into(holder.image) - imageLoader.queryAndSetView(holder, image) + + imageLoader.queryAndSetView( + holder, image, ioDispatcher, defaultDispatcher + ) + scope.launch { + val sharedPreferences: SharedPreferences = + context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0) + val showAlreadyActionedImages = + sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) + if (!showAlreadyActionedImages) { + // If the position is not already visited, that means the position is new then + // finds the next actionable image position from all images + if (!alreadyAddedPositions.contains(position)) { + processThumbnailForActionedImage(holder, position) + + // If the position is already visited, that means the image is already present + // inside map, so it will fetch the image from the map and load in the holder + } else { + val actionableImages: List = ArrayList(actionableImagesMap.values) + image = actionableImages[position] + Glide.with(holder.image).load(image.uri) + .thumbnail(0.3f).into(holder.image) + } + + // If switch is turned off, it just fetches the image from all images without any + // further operations + } else { + Glide.with(holder.image).load(image.uri) + .thumbnail(0.3f).into(holder.image) + } + } + holder.itemView.setOnClickListener { - selectOrRemoveImage(holder, position) + onThumbnailClicked(position, holder) } // launch media preview on long click. holder.itemView.setOnLongClickListener { - imageSelectListener.onLongPress(image.uri) + imageSelectListener.onLongPress(images.indexOf(image), images, selectedImages) true } } } + /** + * Process thumbnail for actioned image + */ + suspend fun processThumbnailForActionedImage( + holder: ImageViewHolder, + position: Int + ) { + val next = imageLoader.nextActionableImage( + allImages, ioDispatcher, defaultDispatcher, + nextImagePosition + ) + + // If next actionable image is found, saves it, as the the search for + // finding next actionable image will start from this position + if (next > -1) { + nextImagePosition = next + 1 + + // If map doesn't contains the next actionable image, that means it's a + // new actionable image, it will put it to the map as actionable images + // and it will load the new image in the view holder + if (!actionableImagesMap.containsKey(next)) { + actionableImagesMap[next] = allImages[next] + alreadyAddedPositions.add(imagePositionAsPerIncreasingOrder) + imagePositionAsPerIncreasingOrder++ + Glide.with(holder.image).load(allImages[next].uri) + .thumbnail(0.3f).into(holder.image) + notifyItemInserted(position) + notifyItemRangeChanged(position, itemCount + 1) + } + + // If next actionable image is not found, that means searching is + // complete till end, and it will stop searching. + } else { + reachedEndOfFolder = true + notifyItemRemoved(position) + } + } + + /** + * Handles click on thumbnail + */ + private fun onThumbnailClicked( + position: Int, + holder: ImageViewHolder + ) { + val sharedPreferences: SharedPreferences = + context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0) + val switchState = + sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) + + // While switch is turned off, lets user click on image only if the position is + // added inside map + if (!switchState) { + if (actionableImagesMap.size > position) { + selectOrRemoveImage(holder, position) + } + } else { + selectOrRemoveImage(holder, position) + } + } + /** * Handle click event on an image, update counter on images. */ private fun selectOrRemoveImage(holder: ImageViewHolder, position: Int){ - val clickedIndex = ImageHelper.getIndex(selectedImages, images[position]) + val sharedPreferences: SharedPreferences = + context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0) + val showAlreadyActionedImages = + sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) + + // Getting clicked index from all images index when show_already_actioned_images + // switch is on + val clickedIndex: Int = if(showAlreadyActionedImages) { + ImageHelper.getIndex(selectedImages, images[position]) + + // Getting clicked index from actionable images when show_already_actioned_images + // switch is off + } else { + ImageHelper.getIndex(selectedImages, ArrayList(actionableImagesMap.values)[position]) + } + if (clickedIndex != -1) { selectedImages.removeAt(clickedIndex) + if (holder.isItemNotForUpload()) { + numberOfSelectedImagesMarkedAsNotForUpload-- + } notifyItemChanged(position, ImageUnselected()) - val indexes = ImageHelper.getIndexList(selectedImages, images) + + // Getting index from all images index when switch is on + val indexes = if (showAlreadyActionedImages) { + ImageHelper.getIndexList(selectedImages, images) + + // Getting index from actionable images when switch is off + } else { + ImageHelper.getIndexList(selectedImages, ArrayList(actionableImagesMap.values)) + } for (index in indexes) { notifyItemChanged(index, ImageSelectedOrUpdated()) } } else { - if(holder.isItemUploaded()){ + if (holder.isItemUploaded()) { Toast.makeText(context, R.string.custom_selector_already_uploaded_image_text, Toast.LENGTH_SHORT).show() } else { - selectedImages.add(images[position]) - notifyItemChanged(position, ImageSelectedOrUpdated()) + if (holder.isItemNotForUpload()) { + numberOfSelectedImagesMarkedAsNotForUpload++ + } + + // Getting index from all images index when switch is on + val indexes: ArrayList = if (showAlreadyActionedImages) { + selectedImages.add(images[position]) + ImageHelper.getIndexList(selectedImages, images) + + // Getting index from actionable images when switch is off + } else { + selectedImages.add(ArrayList(actionableImagesMap.values)[position]) + ImageHelper.getIndexList(selectedImages, ArrayList(actionableImagesMap.values)) + } + + for (index in indexes) { + notifyItemChanged(index, ImageSelectedOrUpdated()) + } } } - imageSelectListener.onSelectedImagesChanged(selectedImages) + imageSelectListener.onSelectedImagesChanged(selectedImages, numberOfSelectedImagesMarkedAsNotForUpload) } /** * Initialize the data set. */ - fun init(newImages: List) { + fun init(newImages: List, fixedImages: List, emptyMap: TreeMap) { + allImages = fixedImages val oldImageList:ArrayList = images val newImageList:ArrayList = ArrayList(newImages) + actionableImagesMap = emptyMap + alreadyAddedPositions = ArrayList() + nextImagePosition = 0 + reachedEndOfFolder = false + selectedImages = ArrayList() + imagePositionAsPerIncreasingOrder = 0 val diffResult = DiffUtil.calculateDiff( ImagesDiffCallback(oldImageList, newImageList) ) @@ -138,19 +345,63 @@ class ImageAdapter( diffResult.dispatchUpdatesTo(this) } + /** + * Set new selected images + */ + fun setSelectedImages(newSelectedImages: ArrayList){ + selectedImages = ArrayList(newSelectedImages) + imageSelectListener.onSelectedImagesChanged(selectedImages, 0) + } + /** + * Refresh the data in the adapter + */ + fun refresh(newImages: List, fixedImages: List) { + numberOfSelectedImagesMarkedAsNotForUpload = 0 + selectedImages.clear() + images.clear() + selectedImages = arrayListOf() + init(newImages, fixedImages, TreeMap()) + notifyDataSetChanged() + } + /** * Returns the total number of items in the data set held by the adapter. * * @return The total number of items in this adapter. */ override fun getItemCount(): Int { - return images.size + val sharedPreferences: SharedPreferences = + context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0) + val showAlreadyActionedImages = + sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) + + // While switch is on initializes the holder with all images size + return if(showAlreadyActionedImages) { + allImages.size + + // While switch is off and searching for next actionable has ended, initializes the holder + // with size of all actionable images + } else if (actionableImagesMap.size == allImages.size || reachedEndOfFolder) { + actionableImagesMap.size + + // While switch is off, initializes the holder with and extra view holder so that finding + // and addition of the next actionable image in the adapter can be continued + } else { + actionableImagesMap.size + 1 + } } fun getImageIdAt(position: Int): Long { return images.get(position).id } + /** + * CleanUp function. + */ + fun cleanUp() { + scope.cancel() + } + /** * Image view holder. */ @@ -158,6 +409,7 @@ class ImageAdapter( val image: ImageView = itemView.findViewById(R.id.image_thumbnail) private val selectedNumber: TextView = itemView.findViewById(R.id.selected_count) private val uploadedGroup: Group = itemView.findViewById(R.id.uploaded_group) + private val notForUploadGroup: Group = itemView.findViewById(R.id.not_for_upload_group) private val selectedGroup: Group = itemView.findViewById(R.id.selected_group) /** @@ -182,9 +434,24 @@ class ImageAdapter( uploadedGroup.visibility = View.VISIBLE } + /** + * Item is not for upload view + */ + fun itemNotForUpload() { + notForUploadGroup.visibility = View.VISIBLE + } + fun isItemUploaded():Boolean { return uploadedGroup.visibility == View.VISIBLE } + + /** + * Item is not for upload + */ + fun isItemNotForUpload():Boolean { + return notForUploadGroup.visibility == View.VISIBLE + } + /** * Item Not Uploaded view. */ @@ -192,6 +459,12 @@ class ImageAdapter( uploadedGroup.visibility = View.GONE } + /** + * Item can be uploaded view + */ + fun itemForUpload() { + notForUploadGroup.visibility = View.GONE + } } /** @@ -233,4 +506,11 @@ class ImageAdapter( } + /** + * Returns the text for showing inside the bubble during bubble scroll. + */ + override fun getSectionName(position: Int): String { + return images[position].date + } + } \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt index 8dffe7306..f0c8cb947 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt @@ -4,20 +4,29 @@ import android.app.Activity import android.app.Dialog import android.content.Intent import android.content.SharedPreferences -import android.net.Uri import android.os.Bundle import android.view.View import android.view.Window import android.widget.Button import android.widget.ImageButton import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout import androidx.lifecycle.ViewModelProvider import fr.free.nrw.commons.R +import fr.free.nrw.commons.customselector.database.NotForUploadStatus +import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao +import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants +import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants.SHOULD_REFRESH import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.listeners.ImageSelectListener import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.filepicker.Constants import fr.free.nrw.commons.media.ZoomableActivity import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.upload.FileUtilsWrapper +import fr.free.nrw.commons.utils.CustomSelectorUtils +import kotlinx.android.synthetic.main.custom_selector_bottom_layout.* +import kotlinx.coroutines.* import java.io.File import javax.inject.Inject @@ -53,6 +62,29 @@ class CustomSelectorActivity: BaseActivity(), FolderClickListener, ImageSelectLi */ @Inject lateinit var customSelectorViewModelFactory: CustomSelectorViewModelFactory + /** + * NotForUploadStatus Dao class for database operations + */ + @Inject + lateinit var notForUploadStatusDao: NotForUploadStatusDao + + /** + * FileUtilsWrapper class to get imageSHA1 from uri + */ + @Inject + lateinit var fileUtilsWrapper: FileUtilsWrapper + + /** + * Coroutine Dispatchers and Scope. + */ + private val scope : CoroutineScope = MainScope() + private var ioDispatcher : CoroutineDispatcher = Dispatchers.IO + + /** + * Image Fragment instance + */ + var imageFragment: ImageFragment? = null + /** * onCreate Activity, sets theme, initialises the view model, setup view. */ @@ -82,6 +114,21 @@ class CustomSelectorActivity: BaseActivity(), FolderClickListener, ImageSelectLi } } + /** + * When data will be send from full screen mode, it will be passed to fragment + */ + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == Constants.RequestCodes.RECEIVE_DATA_FROM_FULL_SCREEN_MODE && + resultCode == Activity.RESULT_OK) { + val selectedImages: ArrayList = + data!! + .getParcelableArrayListExtra(CustomSelectorConstants.NEW_SELECTED_IMAGES)!! + val shouldRefresh = data.getBooleanExtra(SHOULD_REFRESH, false) + imageFragment!!.passSelectedImages(selectedImages, shouldRefresh) + } + } + /** * Show Custom Selector Welcome Dialog. */ @@ -102,6 +149,114 @@ class CustomSelectorActivity: BaseActivity(), FolderClickListener, ImageSelectLi .commit() fetchData() setUpToolbar() + setUpBottomLayout() + } + + /** + * Set up bottom layout + */ + private fun setUpBottomLayout() { + val done : Button = findViewById(R.id.upload) + done.setOnClickListener { onDone() } + + val notForUpload : Button = findViewById(R.id.not_for_upload) + notForUpload.setOnClickListener{ onClickNotForUpload() } + } + + /** + * Gets selected images and proceed for database operations + */ + private fun onClickNotForUpload() { + val selectedImages = viewModel.selectedImages.value + if(selectedImages.isNullOrEmpty()) { + markAsNotForUpload(arrayListOf()) + return + } + var i = 0 + while (i < selectedImages.size) { + val path = selectedImages[i].path + val file = File(path) + if (!file.exists()) { + selectedImages.removeAt(i) + i-- + } + i++ + } + markAsNotForUpload(selectedImages) + } + + /** + * Insert selected images in the database + */ + private fun markAsNotForUpload(images: ArrayList) { + insertIntoNotForUpload(images) + } + + /** + * Initializing ImageFragment + */ + fun setOnDataListener(imageFragment: ImageFragment?) { + this.imageFragment = imageFragment + } + + /** + * Insert images into not for upload + * Remove images from not for upload + * Refresh the UI + */ + private fun insertIntoNotForUpload(images: ArrayList) { + scope.launch { + var allImagesAlreadyNotForUpload = true + images.forEach{ + val imageSHA1 = CustomSelectorUtils.getImageSHA1( + it.uri, + ioDispatcher, + fileUtilsWrapper, + contentResolver + ) + val exists = notForUploadStatusDao.find(imageSHA1) + + // If image exists in not for upload table make allImagesAlreadyNotForUpload false + if (exists < 1) { + allImagesAlreadyNotForUpload = false + } + } + + // if all images is not already marked as not for upload, insert all images in + // not for upload table + if (!allImagesAlreadyNotForUpload) { + images.forEach { + val imageSHA1 = CustomSelectorUtils.getImageSHA1( + it.uri, + ioDispatcher, + fileUtilsWrapper, + contentResolver + ) + notForUploadStatusDao.insert( + NotForUploadStatus( + imageSHA1 + ) + ) + } + + // if all images is already marked as not for upload, delete all images from + // not for upload table + } else { + images.forEach { + val imageSHA1 = CustomSelectorUtils.getImageSHA1( + it.uri, + ioDispatcher, + fileUtilsWrapper, + contentResolver + ) + notForUploadStatusDao.deleteNotForUploadWithImageSHA1(imageSHA1) + } + } + + imageFragment!!.refresh() + val bottomLayout : ConstraintLayout = findViewById(R.id.bottom_layout) + bottomLayout.visibility = View.GONE + } } /** @@ -127,9 +282,6 @@ class CustomSelectorActivity: BaseActivity(), FolderClickListener, ImageSelectLi private fun setUpToolbar() { val back : ImageButton = findViewById(R.id.back) back.setOnClickListener { onBackPressed() } - - val done : ImageButton = findViewById(R.id.done) - done.setOnClickListener { onDone() } } /** @@ -149,22 +301,46 @@ class CustomSelectorActivity: BaseActivity(), FolderClickListener, ImageSelectLi } /** - * override Selected Images Change, update view model selected images. + * override Selected Images Change, update view model selected images and change UI. */ - override fun onSelectedImagesChanged(selectedImages: ArrayList) { + override fun onSelectedImagesChanged(selectedImages: ArrayList, + selectedNotForUploadImages: Int) { viewModel.selectedImages.value = selectedImages - val done : ImageButton = findViewById(R.id.done) - done.visibility = if (selectedImages.isEmpty()) View.INVISIBLE else View.VISIBLE + if (selectedNotForUploadImages > 0) { + upload.isEnabled = false + upload.alpha = 0.5f + } else { + upload.isEnabled = true + upload.alpha = 1f + } + + not_for_upload.text = when (selectedImages.size == selectedNotForUploadImages) { + true -> getString(R.string.unmark_as_not_for_upload) + else -> getString(R.string.mark_as_not_for_upload) + } + + val bottomLayout : ConstraintLayout = findViewById(R.id.bottom_layout) + bottomLayout.visibility = if (selectedImages.isEmpty()) View.GONE else View.VISIBLE } /** * onLongPress * @param imageUri : uri of image */ - override fun onLongPress(imageUri: Uri) { - val intent = Intent(this, ZoomableActivity::class.java).setData(imageUri); - startActivity(intent) + override fun onLongPress( + position: Int, + images: ArrayList, + selectedImages: ArrayList + ) { + val intent = Intent(this, ZoomableActivity::class.java) + intent.putExtra(CustomSelectorConstants.PRESENT_POSITION, position); + intent.putParcelableArrayListExtra( + CustomSelectorConstants.TOTAL_SELECTED_IMAGES, + selectedImages + ) + intent.putExtra(CustomSelectorConstants.BUCKET_ID, bucketId) + startActivityForResult(intent, Constants.RequestCodes.RECEIVE_DATA_FROM_FULL_SCREEN_MODE) } /** diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFileLoader.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFileLoader.kt index 38b5cf0a4..8e22574e3 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFileLoader.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFileLoader.kt @@ -3,10 +3,15 @@ package fr.free.nrw.commons.customselector.ui.selector import android.content.ContentUris import android.content.Context import android.provider.MediaStore +import android.text.format.DateFormat import fr.free.nrw.commons.customselector.listeners.ImageLoaderListener import fr.free.nrw.commons.customselector.model.Image -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File +import java.util.* import kotlin.coroutines.CoroutineContext /** @@ -28,7 +33,9 @@ class ImageFileLoader(val context: Context) : CoroutineScope{ MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.DATA, MediaStore.Images.Media.BUCKET_ID, - MediaStore.Images.Media.BUCKET_DISPLAY_NAME) + MediaStore.Images.Media.BUCKET_DISPLAY_NAME, + MediaStore.Images.Media.DATE_ADDED + ) /** * Load Device Images under coroutine. @@ -57,6 +64,7 @@ class ImageFileLoader(val context: Context) : CoroutineScope{ val dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA) val bucketIdColumn = cursor.getColumnIndex(MediaStore.Images.Media.BUCKET_ID) val bucketNameColumn = cursor.getColumnIndex(MediaStore.Images.Media.BUCKET_DISPLAY_NAME) + val dateColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATE_ADDED) val images = arrayListOf() if (cursor.moveToFirst()) { @@ -70,6 +78,7 @@ class ImageFileLoader(val context: Context) : CoroutineScope{ val path = cursor.getString(dataColumn) val bucketId = cursor.getLong(bucketIdColumn) val bucketName = cursor.getString(bucketNameColumn) + val date = cursor.getLong(dateColumn) val file = if (path == null || path.isEmpty()) { @@ -84,7 +93,22 @@ class ImageFileLoader(val context: Context) : CoroutineScope{ if (file != null && file.exists()) { if (name != null && path != null && bucketName != null) { val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) - val image = Image(id, name, uri, path, bucketId, bucketName) + + val calendar = Calendar.getInstance() + calendar.timeInMillis = date * 1000L + val date: Date = calendar.time + val dateFormat = DateFormat.getMediumDateFormat(context) + val formattedDate = dateFormat.format(date) + + val image = Image( + id, + name, + uri, + path, + bucketId, + bucketName, + date = (formattedDate) + ) images.add(image) } } diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt index 36f298e6b..986e331e9 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt @@ -1,36 +1,47 @@ package fr.free.nrw.commons.customselector.ui.selector -import android.net.Uri +import android.app.Activity +import android.content.Context.MODE_PRIVATE +import android.content.SharedPreferences import android.os.Bundle -import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ProgressBar +import android.widget.Switch +import androidx.constraintlayout.widget.ConstraintLayout import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import fr.free.nrw.commons.R +import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao +import fr.free.nrw.commons.customselector.database.UploadedStatusDao import fr.free.nrw.commons.customselector.helper.ImageHelper +import fr.free.nrw.commons.customselector.listeners.PassDataListener +import fr.free.nrw.commons.customselector.helper.ImageHelper.CUSTOM_SELECTOR_PREFERENCE_KEY +import fr.free.nrw.commons.customselector.helper.ImageHelper.SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY import fr.free.nrw.commons.customselector.listeners.ImageSelectListener +import fr.free.nrw.commons.customselector.listeners.RefreshUIListener import fr.free.nrw.commons.customselector.model.CallbackStatus import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.model.Result import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter import fr.free.nrw.commons.di.CommonsDaggerSupportFragment +import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.upload.FileProcessor +import fr.free.nrw.commons.upload.FileUtilsWrapper import kotlinx.android.synthetic.main.fragment_custom_selector.* import kotlinx.android.synthetic.main.fragment_custom_selector.view.* -import java.io.File -import java.io.FileInputStream -import java.net.URI +import java.util.* import javax.inject.Inject +import kotlin.collections.ArrayList /** * Custom Selector Image Fragment. */ -class ImageFragment: CommonsDaggerSupportFragment() { +class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener, PassDataListener { /** * Current bucketId. @@ -52,8 +63,14 @@ class ImageFragment: CommonsDaggerSupportFragment() { */ private var selectorRV: RecyclerView? = null private var loader: ProgressBar? = null + private var switch: Switch? = null lateinit var filteredImages: ArrayList; + /** + * Stores all images + */ + var allImages: ArrayList = ArrayList() + /** * View model Factory. */ @@ -76,9 +93,48 @@ class ImageFragment: CommonsDaggerSupportFragment() { */ private lateinit var gridLayoutManager: GridLayoutManager + /** + * For showing progress + */ + private var progressLayout: ConstraintLayout? = null + + /** + * NotForUploadStatus Dao class for database operations + */ + @Inject + lateinit var notForUploadStatusDao: NotForUploadStatusDao + + /** + * UploadedStatus Dao class for database operations + */ + @Inject + lateinit var uploadedStatusDao: UploadedStatusDao + + /** + * FileUtilsWrapper class to get imageSHA1 from uri + */ + @Inject + lateinit var fileUtilsWrapper: FileUtilsWrapper + + /** + * FileProcessor to pre-process the file. + */ + @Inject + lateinit var fileProcessor: FileProcessor + + /** + * MediaClient for SHA1 query. + */ + @Inject + lateinit var mediaClient: MediaClient companion object { + /** + * Switch state + */ + var showAlreadyActionedImages: Boolean = true + /** * BucketId args name */ @@ -129,12 +185,54 @@ class ImageFragment: CommonsDaggerSupportFragment() { handleResult(it) }) + switch = root.switchWidget + switch?.visibility = View.VISIBLE + switch?.setOnCheckedChangeListener { _, isChecked -> onChangeSwitchState(isChecked) } selectorRV = root.selector_rv loader = root.loader + progressLayout = root.progressLayout + + val sharedPreferences: SharedPreferences = + requireContext().getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, MODE_PRIVATE) + showAlreadyActionedImages = sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) + switch?.isChecked = showAlreadyActionedImages return root } + private fun onChangeSwitchState(checked: Boolean) { + if (checked) { + showAlreadyActionedImages = true + val sharedPreferences: SharedPreferences = + requireContext().getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, MODE_PRIVATE) + val editor = sharedPreferences.edit() + editor.putBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) + editor.apply() + } else { + showAlreadyActionedImages = false + val sharedPreferences: SharedPreferences = + requireContext().getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, MODE_PRIVATE) + val editor = sharedPreferences.edit() + editor.putBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, false) + editor.apply() + } + + imageAdapter.init(allImages, allImages, TreeMap()) + imageAdapter.notifyDataSetChanged() + } + + /** + * Attaching data listener + */ + override fun onAttach(activity: Activity) { + super.onAttach(activity) + try { + (getActivity() as CustomSelectorActivity).setOnDataListener(this) + } catch (ex: Exception) { + ex.printStackTrace() + } + } + /** * Handle view model result. */ @@ -143,7 +241,8 @@ class ImageFragment: CommonsDaggerSupportFragment() { val images = result.images if(images.isNotEmpty()) { filteredImages = ImageHelper.filterImages(images, bucketId) - imageAdapter.init(filteredImages) + allImages = ArrayList(filteredImages) + imageAdapter.init(filteredImages, allImages, TreeMap()) selectorRV?.let { it.visibility = View.VISIBLE lastItemId?.let { pos -> @@ -191,7 +290,7 @@ class ImageFragment: CommonsDaggerSupportFragment() { * Save the Image Fragment state. */ override fun onDestroy() { - imageLoader?.cleanUP() + imageAdapter.cleanUp() val position = (selectorRV?.layoutManager as GridLayoutManager) .findFirstVisibleItemPosition() @@ -211,4 +310,21 @@ class ImageFragment: CommonsDaggerSupportFragment() { } super.onDestroy() } + + override fun refresh() { + imageAdapter.refresh(filteredImages, allImages) + } + + /** + * Passes selected images and other information from Activity to Fragment and connects it with + * the adapter + */ + override fun passSelectedImages(selectedImages: ArrayList, shouldRefresh: Boolean){ + imageAdapter.setSelectedImages(selectedImages) + + if (!showAlreadyActionedImages && shouldRefresh) { + imageAdapter.init(filteredImages, allImages, TreeMap()) + imageAdapter.setSelectedImages(selectedImages) + } + } } \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt index cf45a87c1..b5ce07fc3 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt @@ -1,25 +1,23 @@ package fr.free.nrw.commons.customselector.ui.selector import android.content.Context +import android.content.SharedPreferences import android.net.Uri -import androidx.exifinterface.media.ExifInterface +import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao import fr.free.nrw.commons.customselector.database.UploadedStatus import fr.free.nrw.commons.customselector.database.UploadedStatusDao +import fr.free.nrw.commons.customselector.helper.ImageHelper import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter.ImageViewHolder -import fr.free.nrw.commons.filepicker.PickedFiles import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.upload.FileProcessor import fr.free.nrw.commons.upload.FileUtilsWrapper +import fr.free.nrw.commons.utils.CustomSelectorUtils +import fr.free.nrw.commons.utils.CustomSelectorUtils.Companion.checkWhetherFileExistsOnCommonsUsingSHA1 import kotlinx.coroutines.* -import timber.log.Timber -import java.io.FileNotFoundException -import java.io.IOException -import java.net.UnknownHostException import java.util.* import java.util.concurrent.TimeUnit import javax.inject.Inject -import kotlin.collections.HashMap /** * Image Loader class, loads images, depending on API results. @@ -46,6 +44,11 @@ class ImageLoader @Inject constructor( */ var uploadedStatusDao: UploadedStatusDao, + /** + * NotForUploadDao for database operations + */ + var notForUploadStatusDao: NotForUploadStatusDao, + /** * Context for coroutine. */ @@ -61,34 +64,48 @@ class ImageLoader @Inject constructor( private var mapImageSHA1: HashMap = HashMap() /** - * Coroutine Dispatchers and Scope. + * Coroutine Scope. */ - private var defaultDispatcher : CoroutineDispatcher = Dispatchers.Default - private var ioDispatcher : CoroutineDispatcher = Dispatchers.IO private val scope : CoroutineScope = MainScope() /** * Query image and setUp the view. */ - fun queryAndSetView(holder: ImageViewHolder, image: Image) { + fun queryAndSetView( + holder: ImageViewHolder, + image: Image, + ioDispatcher: CoroutineDispatcher, + defaultDispatcher: CoroutineDispatcher + ) { /** * Recycler view uses same view holder, so we can identify the latest query image from holder. */ mapHolderImage[holder] = image holder.itemNotUploaded() + holder.itemForUpload() scope.launch { - var result: Result = Result.NOTFOUND if (mapHolderImage[holder] != image) { return@launch } - val imageSHA1 = getImageSHA1(image.uri) - if(imageSHA1.isEmpty()) + val imageSHA1: String = when (mapImageSHA1[image.uri] != null) { + true -> mapImageSHA1[image.uri]!! + else -> CustomSelectorUtils.getImageSHA1( + image.uri, + ioDispatcher, + fileUtilsWrapper, + context.contentResolver + ) + } + mapImageSHA1[image.uri] = imageSHA1 + + if (imageSHA1.isEmpty()) { return@launch + } val uploadedStatus = getFromUploaded(imageSHA1) val sha1 = uploadedStatus?.let { @@ -96,7 +113,7 @@ class ImageLoader @Inject constructor( uploadedStatus.modifiedImageSHA1 } ?: run { if (mapHolderImage[holder] == image) { - getSHA1(image) + getSHA1(image, defaultDispatcher) } else { "" } @@ -106,53 +123,137 @@ class ImageLoader @Inject constructor( return@launch } + val existsInNotForUploadTable = notForUploadStatusDao.find(imageSHA1) + if (result in arrayOf(Result.NOTFOUND, Result.INVALID) && sha1.isNotEmpty()) { - // Query original image. - result = querySHA1(imageSHA1) + when { + mapResult[imageSHA1] == null -> { + // Query original image. + result = checkWhetherFileExistsOnCommonsUsingSHA1( + imageSHA1, + ioDispatcher, + mediaClient + ) + when (result) { + is Result.TRUE -> { + mapResult[imageSHA1] = Result.TRUE + } + } + } + else -> { + result = mapResult[imageSHA1]!! + } + } if (result is Result.TRUE) { // Original image found. insertIntoUploaded(imageSHA1, sha1, result is Result.TRUE, false) } else { - // Original image not found, query modified image. - result = querySHA1(sha1) + when { + mapResult[sha1] == null -> { + // Original image not found, query modified image. + result = checkWhetherFileExistsOnCommonsUsingSHA1( + sha1, + ioDispatcher, + mediaClient + ) + when (result) { + is Result.TRUE -> { + mapResult[sha1] = Result.TRUE + } + } + } + else -> { + result = mapResult[sha1]!! + } + } if (result != Result.ERROR) { insertIntoUploaded(imageSHA1, sha1, false, result is Result.TRUE) } } } - if(mapHolderImage[holder] == image) { - if (result is Result.TRUE) holder.itemUploaded() else holder.itemNotUploaded() + + val sharedPreferences: SharedPreferences = + context + .getSharedPreferences(ImageHelper.CUSTOM_SELECTOR_PREFERENCE_KEY, 0) + val showAlreadyActionedImages = + sharedPreferences.getBoolean( + ImageHelper.SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, + true + ) + + if (mapHolderImage[holder] == image) { + if ((result is Result.TRUE) && showAlreadyActionedImages) { + holder.itemUploaded() + } else holder.itemNotUploaded() + + if ((existsInNotForUploadTable > 0) && showAlreadyActionedImages) { + holder.itemNotForUpload() + } else holder.itemForUpload() } } } /** - * Query SHA1, return result if previously queried, otherwise start a new query. - * - * @return Query result. + * Finds out the next actionable image position */ + suspend fun nextActionableImage( + allImages: List, ioDispatcher: CoroutineDispatcher, + defaultDispatcher: CoroutineDispatcher, + nextImagePosition: Int + ): Int { + var next: Int - suspend fun querySHA1(SHA1: String): Result { - return withContext(ioDispatcher) { - mapResult[SHA1]?.let { - return@withContext it + // Traversing from given position to the end + for (i in nextImagePosition until allImages.size){ + val it = allImages[i] + val imageSHA1: String = when (mapImageSHA1[it.uri] != null) { + true -> mapImageSHA1[it.uri]!! + else -> CustomSelectorUtils.getImageSHA1( + it.uri, + ioDispatcher, + fileUtilsWrapper, + context.contentResolver + ) } - var result: Result = Result.FALSE - try { - if (mediaClient.checkFileExistsUsingSha(SHA1).blockingGet()) { - mapResult[SHA1] = Result.TRUE - result = Result.TRUE + next = notForUploadStatusDao.find(imageSHA1) + + // After checking the image in the not for upload table, if the image is present then + // skips the image and moves to next image for checking + if(next > 0){ + continue + + // Otherwise checks in already uploaded table + } else { + next = uploadedStatusDao.findByImageSHA1(imageSHA1, true) + + // If the image is not present in the already uploaded table, checks for its + // modified SHA1 in already uploaded table + if (next <= 0) { + val modifiedImageSha1 = getSHA1(it, defaultDispatcher) + next = uploadedStatusDao.findByModifiedImageSHA1( + modifiedImageSha1, + true + ) + + // If the modified image SHA1 is not present in the already uploaded table, + // returns the position as next actionable image position + if (next <= 0) { + return i + + // If present in the db then skips iteration for the image and moves to the next + // for checking + } else { + continue + } + + // If present in the db then skips iteration for the image and moves to the next + // for checking + } else { + continue } - } catch (e: Exception) { - if (e is UnknownHostException) { - // Handle no network connection. - Timber.e(e, "Network Connection Error") - } - result = Result.ERROR - e.printStackTrace() } - result } + return -1 } /** @@ -160,11 +261,17 @@ class ImageLoader @Inject constructor( * * @return sha1 of the image */ - suspend fun getSHA1(image: Image): String { + suspend fun getSHA1(image: Image, defaultDispatcher: CoroutineDispatcher): String { mapModifiedImageSHA1[image]?.let{ return it } - val sha1 = generateModifiedSHA1(image); + val sha1 = CustomSelectorUtils + .generateModifiedSHA1(image, + defaultDispatcher, + context, + fileProcessor, + fileUtilsWrapper + ) mapModifiedImageSHA1[image] = sha1; return sha1; } @@ -190,25 +297,6 @@ class ImageLoader @Inject constructor( ) } - /** - * Get image sha1 from uri, used to retrieve the original image sha1. - */ - suspend fun getImageSHA1(uri: Uri): String { - return withContext(ioDispatcher) { - mapImageSHA1[uri]?.let{ - return@withContext it - } - try { - val result = fileUtilsWrapper.getSHA1(context.contentResolver.openInputStream(uri)) - mapImageSHA1[uri] = result - result - } catch (e: FileNotFoundException){ - e.printStackTrace() - "" - } - } - } - /** * Get result data from database. */ @@ -226,35 +314,6 @@ class ImageLoader @Inject constructor( return Result.INVALID } - /** - * Generate Modified SHA1 using present Exif settings. - * - * @return modified sha1 - */ - private suspend fun generateModifiedSHA1(image: Image) : String { - return withContext(defaultDispatcher) { - val uploadableFile = PickedFiles.pickedExistingPicture(context, image.uri) - val exifInterface: ExifInterface? = try { - ExifInterface(uploadableFile.file!!) - } catch (e: IOException) { - Timber.e(e) - null - } - fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact()) - val sha1 = - fileUtilsWrapper.getSHA1(fileUtilsWrapper.getFileInputStream(uploadableFile.filePath)) - uploadableFile.file.delete() - sha1 - } - } - - /** - * CleanUp function. - */ - fun cleanUP() { - scope.cancel() - } - /** * Sealed Result class. */ diff --git a/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt b/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt index a877e14c4..a0991c4ba 100644 --- a/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt +++ b/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt @@ -5,8 +5,7 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import fr.free.nrw.commons.contributions.Contribution import fr.free.nrw.commons.contributions.ContributionDao -import fr.free.nrw.commons.customselector.database.UploadedStatus -import fr.free.nrw.commons.customselector.database.UploadedStatusDao +import fr.free.nrw.commons.customselector.database.* import fr.free.nrw.commons.upload.depicts.Depicts import fr.free.nrw.commons.upload.depicts.DepictsDao @@ -14,10 +13,11 @@ import fr.free.nrw.commons.upload.depicts.DepictsDao * The database for accessing the respective DAOs * */ -@Database(entities = [Contribution::class, Depicts::class, UploadedStatus::class], version = 13, exportSchema = false) +@Database(entities = [Contribution::class, Depicts::class, UploadedStatus::class, NotForUploadStatus::class], version = 14, exportSchema = false) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { abstract fun contributionDao(): ContributionDao abstract fun DepictsDao(): DepictsDao; abstract fun UploadedStatusDao(): UploadedStatusDao; + abstract fun NotForUploadStatusDao(): NotForUploadStatusDao } diff --git a/app/src/main/java/fr/free/nrw/commons/di/ActivityBuilderModule.java b/app/src/main/java/fr/free/nrw/commons/di/ActivityBuilderModule.java index 31412236d..6a9277906 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/ActivityBuilderModule.java +++ b/app/src/main/java/fr/free/nrw/commons/di/ActivityBuilderModule.java @@ -13,6 +13,7 @@ import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity; import fr.free.nrw.commons.description.DescriptionEditActivity; import fr.free.nrw.commons.explore.depictions.WikidataItemDetailsActivity; import fr.free.nrw.commons.explore.SearchActivity; +import fr.free.nrw.commons.media.ZoomableActivity; import fr.free.nrw.commons.notification.NotificationActivity; import fr.free.nrw.commons.profile.ProfileActivity; import fr.free.nrw.commons.review.ReviewActivity; @@ -75,4 +76,7 @@ public abstract class ActivityBuilderModule { @ContributesAndroidInjector abstract DescriptionEditActivity bindDescriptionEditActivity(); + + @ContributesAndroidInjector + abstract ZoomableActivity bindZoomableActivity(); } diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java index 443dd8433..9185d01ec 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java @@ -17,6 +17,7 @@ import fr.free.nrw.commons.R; import fr.free.nrw.commons.auth.AccountUtil; import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.contributions.ContributionDao; +import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao; import fr.free.nrw.commons.customselector.database.UploadedStatusDao; import fr.free.nrw.commons.customselector.ui.selector.ImageFileLoader; import fr.free.nrw.commons.data.DBOpenHelper; @@ -290,6 +291,14 @@ public class CommonsApplicationModule { return appDatabase.UploadedStatusDao(); } + /** + * Get the reference of NotForUploadStatus class. + */ + @Provides + public NotForUploadStatusDao providesNotForUploadStatusDao(AppDatabase appDatabase) { + return appDatabase.NotForUploadStatusDao(); + } + @Provides public ContentResolver providesContentResolver(Context context){ return context.getContentResolver(); diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java b/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java index 4b5b91e68..c25461740 100644 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java @@ -15,6 +15,8 @@ public interface Constants { int PICK_PICTURE_FROM_GALLERY = FILE_PICKER_IMAGE_IDENTIFICATOR + (1 << 12); int TAKE_PICTURE = FILE_PICKER_IMAGE_IDENTIFICATOR + (1 << 13); int CAPTURE_VIDEO = FILE_PICKER_IMAGE_IDENTIFICATOR + (1 << 14); + + int RECEIVE_DATA_FROM_FULL_SCREEN_MODE = 1 << 9; } /** diff --git a/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.java b/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.java deleted file mode 100644 index 286384ff1..000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.java +++ /dev/null @@ -1,92 +0,0 @@ -package fr.free.nrw.commons.media; - -import android.graphics.drawable.Animatable; -import android.net.Uri; -import android.os.Bundle; -import android.view.View; -import android.widget.ProgressBar; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import butterknife.BindView; -import butterknife.ButterKnife; -import com.facebook.drawee.backends.pipeline.Fresco; -import com.facebook.drawee.controller.BaseControllerListener; -import com.facebook.drawee.controller.ControllerListener; -import com.facebook.drawee.drawable.ProgressBarDrawable; -import com.facebook.drawee.drawable.ScalingUtils; -import com.facebook.drawee.generic.GenericDraweeHierarchy; -import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; -import com.facebook.drawee.interfaces.DraweeController; -import com.facebook.imagepipeline.image.ImageInfo; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.media.zoomControllers.zoomable.DoubleTapGestureListener; -import fr.free.nrw.commons.media.zoomControllers.zoomable.ZoomableDraweeView; -import timber.log.Timber; - - -public class ZoomableActivity extends AppCompatActivity { - private Uri imageUri; - - @BindView(R.id.zoomable) - ZoomableDraweeView photo; - @BindView(R.id.zoom_progress_bar) - ProgressBar spinner; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - imageUri = getIntent().getData(); - if (null == imageUri) { - throw new IllegalArgumentException("No data to display"); - } - Timber.d("URl = " + imageUri); - - setContentView(R.layout.activity_zoomable); - ButterKnife.bind(this); - init(); - } - - /** - * Two types of loading indicators have been added to the zoom activity: - * 1. An Indeterminate spinner for showing the time lapsed between dispatch of the image request - * and starting to receiving the image. - * 2. ProgressBarDrawable that reflects how much image has been downloaded - */ - private final ControllerListener loadingListener = new BaseControllerListener() { - @Override - public void onSubmit(String id, Object callerContext) { - // Sometimes the spinner doesn't appear when rapidly switching between images, this fixes that - spinner.setVisibility(View.VISIBLE); - } - - @Override - public void onIntermediateImageSet(String id, @Nullable ImageInfo imageInfo) { - spinner.setVisibility(View.GONE); - } - @Override - public void onFinalImageSet(String id, @Nullable ImageInfo imageInfo, @Nullable Animatable animatable) { - spinner.setVisibility(View.GONE); - } - }; - private void init() { - if( imageUri != null ) { - GenericDraweeHierarchy hierarchy = GenericDraweeHierarchyBuilder.newInstance(getResources()) - .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) - .setProgressBarImage(new ProgressBarDrawable()) - .setProgressBarImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) - .build(); - photo.setHierarchy(hierarchy); - photo.setAllowTouchInterceptionWhileZoomed(true); - photo.setIsLongpressEnabled(false); - photo.setTapListener(new DoubleTapGestureListener(photo)); - DraweeController controller = Fresco.newDraweeControllerBuilder() - .setUri(imageUri) - .setControllerListener(loadingListener) - .build(); - photo.setController(controller); - } - } - - -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt b/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt new file mode 100644 index 000000000..41f37d1a6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt @@ -0,0 +1,653 @@ +package fr.free.nrw.commons.media + +import android.app.Activity +import android.app.Dialog +import android.content.Intent +import android.content.SharedPreferences +import android.graphics.drawable.Animatable +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.view.Window +import android.widget.Button +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.Toast +import androidx.lifecycle.ViewModelProvider +import butterknife.BindView +import butterknife.ButterKnife +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.drawee.controller.BaseControllerListener +import com.facebook.drawee.controller.ControllerListener +import com.facebook.drawee.drawable.ProgressBarDrawable +import com.facebook.drawee.drawable.ScalingUtils +import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder +import com.facebook.drawee.interfaces.DraweeController +import com.facebook.imagepipeline.image.ImageInfo +import fr.free.nrw.commons.R +import fr.free.nrw.commons.customselector.database.NotForUploadStatus +import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao +import fr.free.nrw.commons.customselector.database.UploadedStatusDao +import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants +import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants.SHOULD_REFRESH +import fr.free.nrw.commons.customselector.helper.ImageHelper +import fr.free.nrw.commons.customselector.helper.OnSwipeTouchListener +import fr.free.nrw.commons.customselector.model.CallbackStatus +import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.model.Result +import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorViewModel +import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorViewModelFactory +import fr.free.nrw.commons.media.zoomControllers.zoomable.DoubleTapGestureListener +import fr.free.nrw.commons.media.zoomControllers.zoomable.ZoomableDraweeView +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.upload.FileProcessor +import fr.free.nrw.commons.upload.FileUtilsWrapper +import fr.free.nrw.commons.utils.CustomSelectorUtils +import kotlinx.coroutines.* +import timber.log.Timber +import javax.inject.Inject +import kotlin.collections.ArrayList + +/** + * Activity for helping to view an image in full-screen mode with some other features + * like zoom, and swap gestures + */ +class ZoomableActivity : BaseActivity() { + + private lateinit var imageUri: Uri + + /** + * View model. + */ + private lateinit var viewModel: CustomSelectorViewModel + + /** + * Pref for saving states. + */ + private lateinit var prefs: SharedPreferences + + @JvmField + @BindView(R.id.zoomable) + var photo: ZoomableDraweeView? = null + + @JvmField + @BindView(R.id.zoom_progress_bar) + var spinner: ProgressBar? = null + + @JvmField + @BindView(R.id.selection_count) + var selectedCount: TextView? = null + + /** + * Total images present in folder + */ + private var images: ArrayList? = null + + /** + * Total selected images present in folder + */ + private var selectedImages: ArrayList? = null + + /** + * Present position of the image + */ + private var position = 0 + + /** + * Present bucket ID + */ + private var bucketId: Long = 0L + + /** + * Determines whether the adapter should refresh + */ + private var shouldRefresh = false + + /** + * FileUtilsWrapper class to get imageSHA1 from uri + */ + @Inject + lateinit var fileUtilsWrapper: FileUtilsWrapper + + /** + * FileProcessor to pre-process the file. + */ + @Inject + lateinit var fileProcessor: FileProcessor + + /** + * NotForUploadStatus Dao class for database operations + */ + @Inject + lateinit var notForUploadStatusDao: NotForUploadStatusDao + + /** + * UploadedStatus Dao class for database operations + */ + @Inject + lateinit var uploadedStatusDao: UploadedStatusDao + + /** + * View Model Factory. + */ + @Inject + lateinit var customSelectorViewModelFactory: CustomSelectorViewModelFactory + + /** + * Coroutine Dispatchers and Scope. + */ + private var defaultDispatcher : CoroutineDispatcher = Dispatchers.Default + private var ioDispatcher : CoroutineDispatcher = Dispatchers.IO + private val scope : CoroutineScope = MainScope() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_zoomable) + ButterKnife.bind(this) + + prefs = applicationContext.getSharedPreferences( + ImageHelper.CUSTOM_SELECTOR_PREFERENCE_KEY, + MODE_PRIVATE + ) + + selectedImages = intent.getParcelableArrayListExtra( + CustomSelectorConstants.TOTAL_SELECTED_IMAGES + ) + position = intent.getIntExtra(CustomSelectorConstants.PRESENT_POSITION, 0) + bucketId = intent.getLongExtra(CustomSelectorConstants.BUCKET_ID, 0L) + viewModel = ViewModelProvider(this, customSelectorViewModelFactory).get( + CustomSelectorViewModel::class.java + ) + viewModel.fetchImages() + viewModel.result.observe(this) { + handleResult(it) + } + + if(prefs.getBoolean(CustomSelectorConstants.FULL_SCREEN_MODE_FIRST_LUNCH, true)) { + // show welcome dialog on first launch + showWelcomeDialog() + prefs.edit().putBoolean( + CustomSelectorConstants.FULL_SCREEN_MODE_FIRST_LUNCH, + false + ).apply() + } + } + + /** + * Show Full Screen Mode Welcome Dialog. + */ + private fun showWelcomeDialog() { + val dialog = Dialog(this) + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) + dialog.setContentView(R.layout.full_screen_mode_info_dialog) + (dialog.findViewById(R.id.btn_ok) as Button).setOnClickListener { dialog.dismiss() } + dialog.show() + } + + /** + * Handle view model result. + */ + private fun handleResult(result: Result){ + if(result.status is CallbackStatus.SUCCESS){ + val images = result.images + if(images.isNotEmpty()) { + this@ZoomableActivity.images = ImageHelper.filterImages(images, bucketId) + imageUri = if (this@ZoomableActivity.images.isNullOrEmpty()) { + intent.data as Uri + } else { + this@ZoomableActivity.images!![position].uri + } + Timber.d("URL = $imageUri") + init(imageUri) + onSwipe() + } + } + spinner?.let { + it.visibility = if (result.status is CallbackStatus.FETCHING) View.VISIBLE else View.GONE + } + } + + /** + * Handle swap gestures. Ex. onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown + */ + private fun onSwipe() { + val sharedPreferences: SharedPreferences = + getSharedPreferences(ImageHelper.CUSTOM_SELECTOR_PREFERENCE_KEY, 0) + val showAlreadyActionedImages = + sharedPreferences.getBoolean(ImageHelper.SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) + + if (!images.isNullOrEmpty()) { + photo!!.setOnTouchListener(object : OnSwipeTouchListener(this) { + // Swipe left to view next image in the folder. (if available) + override fun onSwipeLeft() { + super.onSwipeLeft() + onLeftSwiped(showAlreadyActionedImages) + } + + // Swipe right to view previous image in the folder. (if available) + override fun onSwipeRight() { + super.onSwipeRight() + onRightSwiped(showAlreadyActionedImages) + } + + // Swipe up to select the picture (the equivalent of tapping it in non-fullscreen mode) + // and show the next picture skipping pictures that have either already been uploaded or + // marked as not for upload + override fun onSwipeUp() { + super.onSwipeUp() + onUpSwiped() + } + + // Swipe down to mark that picture as "Not for upload" (the equivalent of selecting it then + // tapping "Mark as not for upload" in non-fullscreen mode), and show the next picture. + override fun onSwipeDown() { + super.onSwipeDown() + onDownSwiped() + } + }) + } + } + + /** + * Handles down swipe action + */ + private fun onDownSwiped() { + if (photo?.zoomableController?.isIdentity == false) + return + + scope.launch { + val imageSHA1 = CustomSelectorUtils.getImageSHA1( + images!![position].uri, + ioDispatcher, + fileUtilsWrapper, + contentResolver + ) + var isUploaded = uploadedStatusDao.findByImageSHA1(imageSHA1, true) + if (isUploaded > 0) { + Toast.makeText( + this@ZoomableActivity, + getString(R.string.this_image_is_already_uploaded), + Toast.LENGTH_SHORT + ).show() + } else { + val imageModifiedSHA1 = CustomSelectorUtils.generateModifiedSHA1( + images!![position], + defaultDispatcher, + this@ZoomableActivity, + fileProcessor, + fileUtilsWrapper + ) + isUploaded = uploadedStatusDao.findByModifiedImageSHA1( + imageModifiedSHA1, + true + ) + if (isUploaded > 0) { + Toast.makeText( + this@ZoomableActivity, + getString(R.string.this_image_is_already_uploaded), + Toast.LENGTH_SHORT + ).show() + } else { + insertInNotForUpload(images!![position]) + Toast.makeText( + this@ZoomableActivity, + getString(R.string.image_marked_as_not_for_upload), + Toast.LENGTH_SHORT + ).show() + shouldRefresh = true + if (position < images!!.size - 1) { + position++ + init(images!![position].uri) + } else { + Toast.makeText( + this@ZoomableActivity, + getString(R.string.no_more_images_found), + Toast.LENGTH_SHORT + ).show() + } + } + } + } + } + + /** + * Handles up swipe action + */ + private fun onUpSwiped() { + if (photo?.zoomableController?.isIdentity == false) + return + + scope.launch { + val imageSHA1 = CustomSelectorUtils.getImageSHA1( + images!![position].uri, + ioDispatcher, + fileUtilsWrapper, + contentResolver + ) + var isNonActionable = notForUploadStatusDao.find(imageSHA1) + if (isNonActionable > 0) { + Toast.makeText( + this@ZoomableActivity, + getString(R.string.can_not_select_this_image_for_upload), + Toast.LENGTH_SHORT + ).show() + } else { + isNonActionable = + uploadedStatusDao.findByImageSHA1(imageSHA1, true) + if (isNonActionable > 0) { + Toast.makeText( + this@ZoomableActivity, + getString(R.string.this_image_is_already_uploaded), + Toast.LENGTH_SHORT + ).show() + } else { + val imageModifiedSHA1 = CustomSelectorUtils.generateModifiedSHA1( + images!![position], + defaultDispatcher, + this@ZoomableActivity, + fileProcessor, + fileUtilsWrapper + ) + isNonActionable = uploadedStatusDao.findByModifiedImageSHA1( + imageModifiedSHA1, + true + ) + if (isNonActionable > 0) { + Toast.makeText( + this@ZoomableActivity, + getString(R.string.this_image_is_already_uploaded), + Toast.LENGTH_SHORT + ).show() + } else { + if (!selectedImages!!.contains(images!![position])) { + selectedImages!!.add(images!![position]) + Toast.makeText( + this@ZoomableActivity, + getString(R.string.image_selected), + Toast.LENGTH_SHORT + ).show() + } + position = getNextActionableImage(position + 1) + init(images!![position].uri) + } + } + } + } + } + + /** + * Handles right swipe action + */ + private fun onRightSwiped(showAlreadyActionedImages: Boolean) { + if (photo?.zoomableController?.isIdentity == false) + return + + if (showAlreadyActionedImages) { + if (position > 0) { + position-- + init(images!![position].uri) + } else { + Toast.makeText( + this@ZoomableActivity, + getString(R.string.no_more_images_found), + Toast.LENGTH_SHORT + ).show() + } + } else { + if (position > 0) { + scope.launch { + position = getPreviousActionableImage(position - 1) + init(images!![position].uri) + } + } else { + Toast.makeText( + this@ZoomableActivity, + getString(R.string.no_more_images_found), + Toast.LENGTH_SHORT + ).show() + } + } + } + + /** + * Handles left swipe action + */ + private fun onLeftSwiped(showAlreadyActionedImages: Boolean) { + if (photo?.zoomableController?.isIdentity == false) + return + + if (showAlreadyActionedImages) { + if (position < images!!.size - 1) { + position++ + init(images!![position].uri) + } else { + Toast.makeText( + this@ZoomableActivity, + getString(R.string.no_more_images_found), + Toast.LENGTH_SHORT + ).show() + } + } else { + if (position < images!!.size - 1) { + scope.launch { + position = getNextActionableImage(position + 1) + init(images!![position].uri) + } + } else { + Toast.makeText( + this@ZoomableActivity, + getString(R.string.no_more_images_found), + Toast.LENGTH_SHORT + ).show() + } + } + } + + /** + * Gets next actionable image. + * Iterates from an index to the end of the folder and check whether the current image is + * present in already uploaded table or in not for upload table, + * and returns the first actionable image it can find. + */ + private suspend fun getNextActionableImage(index: Int): Int { + var nextPosition = position + for(i in index until images!!.size){ + nextPosition = i + val imageSHA1 = CustomSelectorUtils.getImageSHA1( + images!![i].uri, + ioDispatcher, + fileUtilsWrapper, + contentResolver + ) + var isNonActionable = notForUploadStatusDao.find(imageSHA1) + if (isNonActionable <= 0) { + isNonActionable = uploadedStatusDao.findByImageSHA1(imageSHA1, true) + if (isNonActionable <= 0) { + val imageModifiedSHA1 = CustomSelectorUtils.generateModifiedSHA1( + images!![i], + defaultDispatcher, + this@ZoomableActivity, + fileProcessor, + fileUtilsWrapper + ) + isNonActionable = uploadedStatusDao.findByModifiedImageSHA1( + imageModifiedSHA1, + true + ) + if (isNonActionable <= 0) { + return i + } else { + continue + } + } else { + continue + } + } else { + continue + } + } + return nextPosition + } + + /** + * Gets previous actionable image. + * Iterates from an index to the first image of the folder and check whether the current image + * is present in already uploaded table or in not for upload table, + * and returns the first actionable image it can find + */ + private suspend fun getPreviousActionableImage(index: Int): Int { + var previousPosition = position + for(i in index downTo 0){ + previousPosition = i + val imageSHA1 = CustomSelectorUtils.getImageSHA1( + images!![i].uri, + ioDispatcher, + fileUtilsWrapper, + contentResolver + ) + var isNonActionable = notForUploadStatusDao.find(imageSHA1) + if (isNonActionable <= 0) { + isNonActionable = uploadedStatusDao.findByImageSHA1(imageSHA1, true) + if (isNonActionable <= 0) { + val imageModifiedSHA1 = CustomSelectorUtils.generateModifiedSHA1( + images!![i], + defaultDispatcher, + this@ZoomableActivity, + fileProcessor, + fileUtilsWrapper + ) + isNonActionable = uploadedStatusDao.findByModifiedImageSHA1( + imageModifiedSHA1, + true + ) + if (isNonActionable <= 0) { + return i + } else { + continue + } + } else { + continue + } + } else { + continue + } + } + return previousPosition + } + + /** + * Unselect item UI + */ + private fun itemUnselected() { + selectedCount!!.visibility = View.INVISIBLE + } + + /** + * Select item UI + */ + private fun itemSelected(i: Int) { + selectedCount!!.visibility = View.VISIBLE + selectedCount!!.text = i.toString() + } + + /** + * Get position of an image from list + */ + private fun getImagePosition(list: ArrayList?, image: Image): Int { + return list!!.indexOf(image) + } + + /** + * Two types of loading indicators have been added to the zoom activity: + * 1. An Indeterminate spinner for showing the time lapsed between dispatch of the image request + * and starting to receiving the image. + * 2. ProgressBarDrawable that reflects how much image has been downloaded + */ + private val loadingListener: ControllerListener = + object : BaseControllerListener() { + override fun onSubmit(id: String, callerContext: Any) { + // Sometimes the spinner doesn't appear when rapidly switching between images, this fixes that + spinner!!.visibility = View.VISIBLE + } + + override fun onIntermediateImageSet(id: String, imageInfo: ImageInfo?) { + spinner!!.visibility = View.GONE + } + + override fun onFinalImageSet( + id: String, + imageInfo: ImageInfo?, + animatable: Animatable? + ) { + spinner!!.visibility = View.GONE + } + } + + private fun init(imageUri: Uri?) { + if (imageUri != null) { + val hierarchy = GenericDraweeHierarchyBuilder.newInstance(resources) + .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) + .setProgressBarImage(ProgressBarDrawable()) + .setProgressBarImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) + .build() + photo!!.hierarchy = hierarchy + photo!!.setAllowTouchInterceptionWhileZoomed(true) + photo!!.setIsLongpressEnabled(false) + photo!!.setTapListener(DoubleTapGestureListener(photo)) + val controller: DraweeController = Fresco.newDraweeControllerBuilder() + .setUri(imageUri) + .setControllerListener(loadingListener) + .build() + photo!!.controller = controller + + if (!images.isNullOrEmpty()) { + val selectedIndex = getImagePosition(selectedImages, images!![position]) + val isSelected = selectedIndex != -1 + if (isSelected) { + itemSelected(selectedImages!!.size) + } else { + itemUnselected() + } + } + } + } + + /** + * Inserts an image in Not For Upload table + */ + private suspend fun insertInNotForUpload(it: Image) { + val imageSHA1 = CustomSelectorUtils.getImageSHA1( + it.uri, + ioDispatcher, + fileUtilsWrapper, + contentResolver + ) + notForUploadStatusDao.insert( + NotForUploadStatus( + imageSHA1 + ) + ) + } + + /** + * Send selected images in fragment + */ + override fun onBackPressed() { + if (!images.isNullOrEmpty()) { + val returnIntent = Intent() + returnIntent.putParcelableArrayListExtra( + CustomSelectorConstants.NEW_SELECTED_IMAGES, + selectedImages + ) + returnIntent.putExtra(SHOULD_REFRESH, shouldRefresh) + setResult(Activity.RESULT_OK, returnIntent) + finish() + } + super.onBackPressed() + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt new file mode 100644 index 000000000..daae40f61 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt @@ -0,0 +1,103 @@ +package fr.free.nrw.commons.utils + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import androidx.exifinterface.media.ExifInterface +import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.filepicker.PickedFiles +import fr.free.nrw.commons.customselector.ui.selector.ImageLoader +import fr.free.nrw.commons.media.MediaClient +import fr.free.nrw.commons.upload.FileProcessor +import fr.free.nrw.commons.upload.FileUtilsWrapper +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.FileNotFoundException +import java.io.IOException +import java.net.UnknownHostException + +/** + * Util Class for Custom Selector + */ +class CustomSelectorUtils { + companion object { + + /** + * Get image sha1 from uri, used to retrieve the original image sha1. + */ + suspend fun getImageSHA1( + uri: Uri, + ioDispatcher: CoroutineDispatcher, + fileUtilsWrapper: FileUtilsWrapper, + contentResolver: ContentResolver + ): String { + return withContext(ioDispatcher) { + + try { + val result = fileUtilsWrapper.getSHA1(contentResolver.openInputStream(uri)) + result + } catch (e: FileNotFoundException) { + e.printStackTrace() + "" + } + } + } + + /** + * Generates modified SHA1 of an image + */ + suspend fun generateModifiedSHA1( + image: Image, + defaultDispatcher: CoroutineDispatcher, + context: Context, + fileProcessor: FileProcessor, + fileUtilsWrapper: FileUtilsWrapper + ): String { + return withContext(defaultDispatcher) { + val uploadableFile = PickedFiles.pickedExistingPicture(context, image.uri) + val exifInterface: ExifInterface? = try { + ExifInterface(uploadableFile.file!!) + } catch (e: IOException) { + Timber.e(e) + null + } + fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact()) + val sha1 = + fileUtilsWrapper.getSHA1( + fileUtilsWrapper.getFileInputStream(uploadableFile.filePath) + ) + uploadableFile.file.delete() + sha1 + } + } + + /** + * Query SHA1, return result if previously queried, otherwise start a new query. + * + * @return true if the image exists on Commons, false otherwise. + */ + suspend fun checkWhetherFileExistsOnCommonsUsingSHA1(SHA1: String, + ioDispatcher : CoroutineDispatcher, + mediaClient: MediaClient + ): ImageLoader.Result { + return withContext(ioDispatcher) { + + var result: ImageLoader.Result = ImageLoader.Result.FALSE + try { + if (mediaClient.checkFileExistsUsingSha(SHA1).blockingGet()) { + result = ImageLoader.Result.TRUE + } + } catch (e: Exception) { + if (e is UnknownHostException) { + // Handle no network connection. + Timber.e(e, "Network Connection Error") + } + result = ImageLoader.Result.ERROR + e.printStackTrace() + } + result + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/not_for_upload.xml b/app/src/main/res/drawable/not_for_upload.xml new file mode 100644 index 000000000..a882eec7b --- /dev/null +++ b/app/src/main/res/drawable/not_for_upload.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_custom_selector.xml b/app/src/main/res/layout/activity_custom_selector.xml index d96918fee..b7b3c70dc 100644 --- a/app/src/main/res/layout/activity_custom_selector.xml +++ b/app/src/main/res/layout/activity_custom_selector.xml @@ -13,7 +13,12 @@ android:id="@+id/fragment_container" android:layout_width="match_parent" android:layout_height="0dp" - app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintBottom_toTopOf="@id/bottom_layout" app:layout_constraintTop_toBottomOf="@+id/toolbar_layout"/> + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_zoomable.xml b/app/src/main/res/layout/activity_zoomable.xml index d022199e2..b0dd45aac 100644 --- a/app/src/main/res/layout/activity_zoomable.xml +++ b/app/src/main/res/layout/activity_zoomable.xml @@ -23,4 +23,21 @@ app:layout_constraintTop_toTopOf="parent" /> + + diff --git a/app/src/main/res/layout/custom_selector_bottom_layout.xml b/app/src/main/res/layout/custom_selector_bottom_layout.xml new file mode 100644 index 000000000..45439c29c --- /dev/null +++ b/app/src/main/res/layout/custom_selector_bottom_layout.xml @@ -0,0 +1,49 @@ + + + + +