[GSoC 2022] Improve custom picker (all features) (#5035)

* Project Initiated by creating helper classes for database operations

* Database created

* Rest of the work and documentation

* Requested changes done

* Localisation updates from https://translatewiki.net.

* Localisation updates from https://translatewiki.net.

* Localisation updates from https://translatewiki.net.

* [GSoC] Insert and Remove Images from not for upload (#4999)

* Inserted and marked images as not for upload

* Documentation added

* Test delete

* Implemented remove from not for upload

* Test fixed

* Requested changes done

* Added tests for new lines in existing classes

* [GSoC] Added Bubble Scroll (#5023)

* Library added

* Bubble scroll implemented

* Left and right swipe

* Requested changes

* [GSoC] Hide/Unhide actioned pictures and change numbering (#5012)

* Changed numbering of marked images

* Hide Unhide implemented

* Test fixed

* Improved speed for database operation

* Improved speed for database operation

* Changed progress dialog

* Improved hiding speed

* Test fixed

* Fixed bug

* Fixed bug and improved performance

* Fixed bug and improved performance

* Test fixed

* Bug fixed

* Bug fixed

* Bug fixed

* Bug fixed

* Bug fixed

* Code clean up

* Test hiding images

* Test hiding images

* Test hiding images

* Code clean up and test fixed

* Fixed layout

* Fixed bug

* Bug fixed

* Renamed method

* Documentation added explaining logic

* Documentation added explaining logic

* [GSoC] Full Screen Mode (#5032)

* Gesture detection implemented

* Left and right swipe

* Selection implemented

* onDown implemented

* onDown implemented

* FS mode implemented

* OnSwipe doc

* Scope cancel

* Added label in Manifest

* Merged two features

* Requested changes

* Image uploaded bug fixed

* Increased DB version

* Made requested changes

* Made requested changes

* Made requested changes

* Made requested changes

* Solved image flashing bug

* Solved image flashing bug

* Requested changes

* Requested changes

* Changed name of a function

* Fixed transaction failure on large number of images

* Tested with isIdentity

* Tested with isIdentity

* Increased the threshold

* Added info dialog

* Minor changes

* ImageAdapter Test

* CustomSelectorActivity Test

* Requested changes

* Test for ZoomableActivity

* Test for ZoomableActivity

* Test for ImageLoader

* Test for OnSwipeTouchListener

* Test for rest

* Reverted some test changes

* Added more tests for ImageAdapter

* Added more tests for ImageAdapter and swipe gesture

Co-authored-by: translatewiki.net <l10n-bot@translatewiki.net>
This commit is contained in:
Ayan Sarkar 2022-09-16 14:44:16 +05:30 committed by GitHub
parent b5ffe7120c
commit 33679eb6b4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 2560 additions and 284 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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.
*/

View file

@ -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<Int, Int> {
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())
}
}

View file

@ -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<Image>)
fun onSelectedImagesChanged(selectedImages: ArrayList<Image>, selectedNotForUploadImages: Int)
/**
* onLongPress
* @param imageUri : uri of image
*/
fun onLongPress(imageUri: Uri)
fun onLongPress(
position: Int,
images: ArrayList<Image>,
selectedImages: ArrayList<Image>
)
}

View file

@ -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<Image>, shouldRefresh: Boolean)
}

View file

@ -0,0 +1,11 @@
package fr.free.nrw.commons.customselector.listeners
/**
* Refresh UI Listener
*/
interface RefreshUIListener {
/**
* Refreshes the data in adapter
*/
fun refresh()
}

View file

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

View file

@ -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<ImageAdapter.ImageViewHolder>(context) {
RecyclerViewAdapter<ImageAdapter.ImageViewHolder>(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<Image>()
/**
* 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<Image> = ArrayList()
/**
* Stores all images
*/
private var allImages: List<Image> = ArrayList()
/**
* Map to store actionable images
*/
private var actionableImagesMap: TreeMap<Int, Image> = TreeMap()
/**
* Stores already added positions of actionable images
*/
private var alreadyAddedPositions: ArrayList<Int> = 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<Image> = 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<Int> = 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<Image>) {
fun init(newImages: List<Image>, fixedImages: List<Image>, emptyMap: TreeMap<Int, Image>) {
allImages = fixedImages
val oldImageList:ArrayList<Image> = images
val newImageList:ArrayList<Image> = 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<Image>){
selectedImages = ArrayList(newSelectedImages)
imageSelectListener.onSelectedImagesChanged(selectedImages, 0)
}
/**
* Refresh the data in the adapter
*/
fun refresh(newImages: List<Image>, fixedImages: List<Image>) {
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
}
}

View file

@ -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<Image> =
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<Image>) {
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<Image>) {
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<Image>) {
override fun onSelectedImagesChanged(selectedImages: ArrayList<Image>,
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<Image>,
selectedImages: ArrayList<Image>
) {
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)
}
/**

View file

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

View file

@ -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<Image>;
/**
* Stores all images
*/
var allImages: ArrayList<Image> = 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<Image>, shouldRefresh: Boolean){
imageAdapter.setSelectedImages(selectedImages)
if (!showAlreadyActionedImages && shouldRefresh) {
imageAdapter.init(filteredImages, allImages, TreeMap())
imageAdapter.setSelectedImages(selectedImages)
}
}
}

View file

@ -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<Uri, String> = 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<Image>, 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.
*/

View file

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

View file

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

View file

@ -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();

View file

@ -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;
}
/**

View file

@ -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<ImageInfo>() {
@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);
}
}
}

View file

@ -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<Image>? = null
/**
* Total selected images present in folder
*/
private var selectedImages: ArrayList<Image>? = 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: 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<ImageInfo?> =
object : BaseControllerListener<ImageInfo?>() {
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()
}
}

View file

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