[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
This commit is contained in:
Ayan Sarkar 2022-08-20 14:49:27 +05:30 committed by GitHub
parent 5559282c1a
commit a6c51a75a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 638 additions and 163 deletions

View file

@ -47,6 +47,19 @@ 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.
*/

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 SWITCH_STATE_PREFERENCE_KEY: String = "switch_state"
/**
* Returns the list of folders from given image list.
*/

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
@ -13,9 +14,14 @@ 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.SWITCH_STATE_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.
@ -49,6 +55,8 @@ class ImageAdapter(
*/
class ImageUnselected
private var stopAddition: Boolean = false
/**
* Currently selected images.
*/
@ -64,6 +72,35 @@ class ImageAdapter(
*/
private var images: ArrayList<Image> = ArrayList()
/**
* Stores all images
*/
private var allImages: List<Image> = ArrayList()
/**
* Map to store actionable images
*/
private var mapActionableImages: 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 nextImage = 0
private var count = 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.
*/
@ -76,7 +113,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.
@ -87,17 +124,106 @@ class ImageAdapter(
notifyItemRangeChanged(updatedPosition, images.size)
}
} else {
val selectedIndex = ImageHelper.getIndex(selectedImages, image)
val sharedPreferences: SharedPreferences =
context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0)
val switchState =
sharedPreferences.getBoolean(SWITCH_STATE_PREFERENCE_KEY, true)
// Getting selected index when switch is on
val selectedIndex: Int = if (switchState) {
ImageHelper.getIndex(selectedImages, image)
// Getting selected index when switch is off
} else if (mapActionableImages.size > position) {
ImageHelper
.getIndex(selectedImages, ArrayList(mapActionableImages.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)
scope.launch {
imageLoader.queryAndSetView(
holder, image, ioDispatcher, defaultDispatcher
)
val sharedPreferences: SharedPreferences =
context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0)
val switchState =
sharedPreferences.getBoolean(SWITCH_STATE_PREFERENCE_KEY, true)
if (!switchState) {
// 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)) {
val next = imageLoader.nextActionableImage(
allImages, ioDispatcher, defaultDispatcher,
nextImage
)
// 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) {
nextImage = next+1
// If map doesn't contains the next actionable image, that means it's a
// new actionable image, if will put it to the map as actionable images
// and it will load the new image in the view holder
if (!mapActionableImages.containsKey(next)) {
mapActionableImages[next] = allImages[next]
alreadyAddedPositions.add(count)
count++
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 {
stopAddition = true
notifyItemRemoved(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(mapActionableImages.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)
val sharedPreferences: SharedPreferences =
context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0)
val switchState =
sharedPreferences.getBoolean(SWITCH_STATE_PREFERENCE_KEY, true)
// While switch is turned off, lets user click on image only if the position is
// added inside map
if (!switchState) {
if (mapActionableImages.size > position) {
selectOrRemoveImage(holder, position)
}
} else {
selectOrRemoveImage(holder, position)
}
}
// launch media preview on long click.
@ -112,26 +238,60 @@ class ImageAdapter(
* 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 switchState =
sharedPreferences.getBoolean(SWITCH_STATE_PREFERENCE_KEY, true)
// Getting clicked index from all images index when switch is on
val clickedIndex: Int = if(switchState) {
ImageHelper.getIndex(selectedImages, images[position])
// Getting clicked index from actionable images when switch is off
} else {
ImageHelper.getIndex(selectedImages, ArrayList(mapActionableImages.values)[position])
}
if (clickedIndex != -1) {
selectedImages.removeAt(clickedIndex)
if (holder.isItemNotForUpload()) {
selectedNotForUploadImages--
}
notifyItemChanged(position, ImageUnselected())
val indexes = ImageHelper.getIndexList(selectedImages, images)
// Getting index from all images index when switch is on
val indexes = if (switchState) {
ImageHelper.getIndexList(selectedImages, images)
// Getting index from actionable images when switch is off
} else {
ImageHelper.getIndexList(selectedImages, ArrayList(mapActionableImages.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 {
if (holder.isItemNotForUpload()) {
selectedNotForUploadImages++
}
selectedImages.add(images[position])
notifyItemChanged(position, ImageSelectedOrUpdated())
// Getting index from all images index when switch is on
val indexes: ArrayList<Int> = if (switchState) {
selectedImages.add(images[position])
ImageHelper.getIndexList(selectedImages, images)
// Getting index from actionable images when switch is off
} else {
selectedImages.add(ArrayList(mapActionableImages.values)[position])
ImageHelper.getIndexList(selectedImages, ArrayList(mapActionableImages.values))
}
for (index in indexes) {
notifyItemChanged(index, ImageSelectedOrUpdated())
}
}
}
imageSelectListener.onSelectedImagesChanged(selectedImages, selectedNotForUploadImages)
@ -140,9 +300,16 @@ class ImageAdapter(
/**
* 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)
mapActionableImages = emptyMap
alreadyAddedPositions = ArrayList()
nextImage = 0
stopAddition = false
selectedImages = ArrayList()
count = 0
val diffResult = DiffUtil.calculateDiff(
ImagesDiffCallback(oldImageList, newImageList)
)
@ -153,12 +320,12 @@ class ImageAdapter(
/**
* Refresh the data in the adapter
*/
fun refresh(newImages: List<Image>) {
fun refresh(newImages: List<Image>, fixedImages: List<Image>) {
selectedNotForUploadImages = 0
selectedImages.clear()
images.clear()
selectedImages = arrayListOf()
init(newImages)
init(newImages, fixedImages, TreeMap())
notifyDataSetChanged()
}
@ -168,13 +335,38 @@ class ImageAdapter(
* @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 switchState =
sharedPreferences.getBoolean(SWITCH_STATE_PREFERENCE_KEY, true)
// While switch is on initializes the holder with all images size
return if(switchState) {
allImages.size
// While switch is off and searching for next actionable has ended, initializes the holder
// with size of all actionable images
} else if (mapActionableImages.size == allImages.size || stopAddition) {
mapActionableImages.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 {
mapActionableImages.size + 1
}
}
fun getImageIdAt(position: Int): Long {
return images.get(position).id
}
/**
* CleanUp function.
*/
fun cleanUp() {
scope.cancel()
}
/**
* Image view holder.
*/

View file

@ -1,19 +1,26 @@
package fr.free.nrw.commons.customselector.ui.selector
import android.app.Activity
import android.net.Uri
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.helper.ImageHelper.CUSTOM_SELECTOR_PREFERENCE_KEY
import fr.free.nrw.commons.customselector.helper.ImageHelper.SWITCH_STATE_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
@ -21,13 +28,16 @@ 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 kotlinx.coroutines.*
import java.util.*
import javax.inject.Inject
import kotlin.collections.ArrayList
/**
* Custom Selector Image Fragment.
@ -54,8 +64,14 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener {
*/
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.
*/
@ -78,9 +94,48 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener {
*/
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 switchState: Boolean = true
/**
* BucketId args name
*/
@ -131,12 +186,50 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener {
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)
switchState = sharedPreferences.getBoolean(SWITCH_STATE_PREFERENCE_KEY, true)
switch?.isChecked = switchState
switch?.text =
if (switchState) getString(R.string.hide_already_actioned_pictures)
else getString(R.string.show_already_actioned_pictures)
return root
}
private fun onChangeSwitchState(checked: Boolean) {
if (checked) {
switchState = true
val sharedPreferences: SharedPreferences =
requireContext().getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.putBoolean(SWITCH_STATE_PREFERENCE_KEY, true)
editor.apply()
switch?.text = getString(R.string.hide_already_actioned_pictures)
imageAdapter.init(allImages, allImages, TreeMap())
imageAdapter.notifyDataSetChanged()
} else {
switchState = false
val sharedPreferences: SharedPreferences =
requireContext().getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.putBoolean(SWITCH_STATE_PREFERENCE_KEY, false)
editor.apply()
switch?.text = getString(R.string.show_already_actioned_pictures)
imageAdapter.init(allImages, allImages, TreeMap())
imageAdapter.notifyDataSetChanged()
}
}
/**
* Attaching data listener
*/
@ -157,7 +250,12 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener {
val images = result.images
if(images.isNotEmpty()) {
filteredImages = ImageHelper.filterImages(images, bucketId)
imageAdapter.init(filteredImages)
allImages = ArrayList(filteredImages)
if(switchState) {
imageAdapter.init(filteredImages, allImages, TreeMap())
} else {
imageAdapter.init(filteredImages, allImages, TreeMap())
}
selectorRV?.let {
it.visibility = View.VISIBLE
lastItemId?.let { pos ->
@ -205,7 +303,7 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener {
* Save the Image Fragment state.
*/
override fun onDestroy() {
imageLoader?.cleanUP()
imageAdapter.cleanUp()
val position = (selectorRV?.layoutManager as GridLayoutManager)
.findFirstVisibleItemPosition()
@ -227,6 +325,6 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener {
}
override fun refresh() {
imageAdapter.refresh(filteredImages)
imageAdapter.refresh(filteredImages, allImages)
}
}

View file

@ -1,27 +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.querySHA1
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.
@ -67,106 +63,179 @@ class ImageLoader @Inject constructor(
private var mapResult: HashMap<String, Result> = HashMap()
private var mapImageSHA1: HashMap<Uri, String> = HashMap()
/**
* Coroutine Dispatchers and 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) {
suspend 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
var result: Result = Result.NOTFOUND
if (mapHolderImage[holder] != image) {
return
}
if (mapHolderImage[holder] != image) {
return@launch
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
}
val uploadedStatus = getFromUploaded(imageSHA1)
val sha1 = uploadedStatus?.let {
result = getResultFromUploadedStatus(uploadedStatus)
uploadedStatus.modifiedImageSHA1
} ?: run {
if (mapHolderImage[holder] == image) {
getSHA1(image, defaultDispatcher)
} else {
""
}
}
val imageSHA1: String = when(mapImageSHA1[image.uri] != null) {
true -> mapImageSHA1[image.uri]!!
else -> CustomSelectorUtils.getImageSHA1(image.uri, ioDispatcher, fileUtilsWrapper, context.contentResolver)
}
if (mapHolderImage[holder] != image) {
return
}
if(imageSHA1.isEmpty())
return@launch
val uploadedStatus = getFromUploaded(imageSHA1)
val exists = notForUploadStatusDao.find(imageSHA1)
val sha1 = uploadedStatus?.let {
result = getResultFromUploadedStatus(uploadedStatus)
uploadedStatus.modifiedImageSHA1
} ?: run {
if (mapHolderImage[holder] == image) {
getSHA1(image)
} else {
""
}
}
if (mapHolderImage[holder] != image) {
return@launch
}
val exists = notForUploadStatusDao.find(imageSHA1)
if (result in arrayOf(Result.NOTFOUND, Result.INVALID) && sha1.isNotEmpty()) {
// Query original image.
result = querySHA1(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)
if (result != Result.ERROR) {
insertIntoUploaded(imageSHA1, sha1, false, result is Result.TRUE)
if (result in arrayOf(Result.NOTFOUND, Result.INVALID) && sha1.isNotEmpty()) {
when {
mapResult[imageSHA1] == null -> {
// Query original image.
result = querySHA1(imageSHA1, ioDispatcher, mediaClient)
when (result) {
is Result.TRUE -> {
mapResult[imageSHA1] = Result.TRUE
}
}
}
else -> {
result = mapResult[imageSHA1]!!
}
}
if(mapHolderImage[holder] == image) {
if (result is Result.TRUE) holder.itemUploaded() else holder.itemNotUploaded()
if (exists > 0) holder.itemNotForUpload() else holder.itemForUpload()
if (result is Result.TRUE) {
// Original image found.
insertIntoUploaded(imageSHA1, sha1, result is Result.TRUE, false)
} else {
when {
mapResult[sha1] == null -> {
// Original image not found, query modified image.
result = querySHA1(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)
}
}
}
val sharedPreferences: SharedPreferences =
context
.getSharedPreferences(ImageHelper.CUSTOM_SELECTOR_PREFERENCE_KEY, 0)
val switchState =
sharedPreferences.getBoolean(ImageHelper.SWITCH_STATE_PREFERENCE_KEY, true)
if(mapHolderImage[holder] == image) {
if (result is Result.TRUE) {
if (switchState) holder.itemUploaded() else holder.itemNotUploaded()
} else holder.itemNotUploaded()
if (exists > 0) {
if (switchState) holder.itemNotForUpload() else holder.itemForUpload()
} 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 = -1
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 database, 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 database
} else {
next = uploadedStatusDao.findByImageSHA1(imageSHA1, true)
// If the image is not present in the already uploaded database, checks for it's
// modified SHA1 in already uploaded database
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 database,
// returns the position as next actionable image position
if (next <= 0) {
return i
// If present in tha db then skips iteration for the image and moves to the next
// for checking
} else {
continue
}
// If present in tha 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
}
/**
@ -174,11 +243,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;
}
@ -221,35 +296,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

@ -1,17 +1,28 @@
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.customselector.ui.selector.ImageLoader
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 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.
*/
@ -31,5 +42,60 @@ class CustomSelectorUtils {
}
}
}
/**
* Generate Modified SHA1 using present Exif settings.
*
* @return modified sha1
*/
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 Query result.
*/
suspend fun querySHA1(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
}
}
}
}

View file

@ -3,13 +3,26 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
xmlns:app="http://schemas.android.com/apk/res-auto"
android:background="?attr/mainBackground">
<Switch
android:id="@+id/switchWidget"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:text="@string/hide_already_actioned_pictures"
android:padding="@dimen/dimen_10"
android:checked="true" />
<com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView
android:id="@+id/selector_rv"
android:background="?attr/mainBackground"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/mainBackground"
android:layout_height="@dimen/dimen_0"
app:fastScrollPopupBgColor="@color/primaryColor"
app:fastScrollPopupTextColor="@android:color/primary_text_dark"
app:fastScrollPopupTextSize="@dimen/subheading_text_size"
@ -17,6 +30,10 @@
app:fastScrollThumbColor="@color/primaryColor"
app:fastScrollTrackColor="@color/upload_overlay_background_light"
app:fastScrollPopupPosition="adjacent"
app:layout_constraintBottom_toTopOf="@id/progressLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/switchWidget"
/>
<TextView
@ -45,4 +62,41 @@
app:layout_constraintStart_toStartOf="parent"
/>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/progressLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="@dimen/dimen_5"
android:background="@color/drawerHeader_background_light"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/selector_rv">
<TextView
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/hiding_already_actioned_pictures"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:gravity="center"
android:padding="@dimen/dimen_5"/>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="visible"
app:layout_constraintTop_toBottomOf="@id/text"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -736,4 +736,7 @@ Upload your first media by tapping on the add button.</string>
<string name="your_feedback">Your feedback</string>
<string name="mark_as_not_for_upload">Mark as not for upload</string>
<string name="unmark_as_not_for_upload">Unmark as not for upload</string>
<string name="show_already_actioned_pictures">Show already actioned pictures</string>
<string name="hide_already_actioned_pictures">Hide already actioned pictures</string>
<string name="hiding_already_actioned_pictures">Hiding already actioned pictures</string>
</resources>

View file

@ -2,6 +2,7 @@ package fr.free.nrw.commons.customselector.ui.adapter
import android.content.ContentResolver
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import android.view.LayoutInflater
import android.view.View
@ -23,6 +24,8 @@ import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.lang.reflect.Field
import java.util.*
import kotlin.collections.ArrayList
/**
* Custom Selector image adapter test.
@ -88,8 +91,10 @@ class ImageAdapterTest {
// Parameters.
images.add(image)
imageAdapter.init(images)
imageAdapter.init(images, images, TreeMap())
whenever(context.getSharedPreferences("custom_selector", 0))
.thenReturn(Mockito.mock(SharedPreferences::class.java))
// Test conditions.
imageAdapter.onBindViewHolder(holder, 0)
selectedImageField.set(imageAdapter, images)
@ -101,7 +106,7 @@ class ImageAdapterTest {
*/
@Test
fun init() {
imageAdapter.init(images)
imageAdapter.init(images, images, TreeMap())
}
/**
@ -115,7 +120,7 @@ class ImageAdapterTest {
// Parameters
images.addAll(listOf(image, image))
imageAdapter.init(images)
imageAdapter.init(images, images, TreeMap())
// Test conditions
holder.itemUploaded()
@ -142,7 +147,7 @@ class ImageAdapterTest {
*/
@Test
fun getImageIdAt() {
imageAdapter.init(listOf(image))
imageAdapter.init(listOf(image), listOf(image), TreeMap())
Assertions.assertEquals(1, imageAdapter.getImageIdAt(0))
}
}

View file

@ -2,6 +2,7 @@ package fr.free.nrw.commons.customselector.ui.selector
import android.content.ContentResolver
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import com.nhaarman.mockitokotlin2.*
import fr.free.nrw.commons.TestCommonsApplication
@ -117,8 +118,6 @@ class ImageLoaderTest {
Whitebox.setInternalState(imageLoader, "mapModifiedImageSHA1", mapModifiedImageSHA1);
Whitebox.setInternalState(imageLoader, "mapResult", mapResult);
Whitebox.setInternalState(imageLoader, "context", context)
Whitebox.setInternalState(imageLoader, "ioDispatcher", testDispacher)
Whitebox.setInternalState(imageLoader, "defaultDispatcher", testDispacher)
whenever(contentResolver.openInputStream(uri)).thenReturn(inputStream)
whenever(context.contentResolver).thenReturn(contentResolver)
@ -141,14 +140,17 @@ class ImageLoaderTest {
@Test
fun testQueryAndSetViewUploadedStatusNull() = testDispacher.runBlockingTest {
whenever(uploadedStatusDao.getUploadedFromImageSHA1(any())).thenReturn(null)
whenever(notForUploadStatusDao.find(any())).thenReturn(0)
mapModifiedImageSHA1[image] = "testSha1"
mapImageSHA1[uri] = "testSha1"
whenever(context.getSharedPreferences("custom_selector", 0))
.thenReturn(Mockito.mock(SharedPreferences::class.java))
mapResult["testSha1"] = ImageLoader.Result.TRUE
imageLoader.queryAndSetView(holder, image)
imageLoader.queryAndSetView(holder, image, testDispacher, testDispacher)
mapResult["testSha1"] = ImageLoader.Result.FALSE
imageLoader.queryAndSetView(holder, image)
imageLoader.queryAndSetView(holder, image, testDispacher, testDispacher)
}
/**
@ -157,20 +159,10 @@ class ImageLoaderTest {
@Test
fun testQueryAndSetViewUploadedStatusNotNull() = testDispacher.runBlockingTest {
whenever(uploadedStatusDao.getUploadedFromImageSHA1(any())).thenReturn(uploadedStatus)
imageLoader.queryAndSetView(holder, image)
}
/**
* Test querySha1
*/
@Test
fun testQuerySha1() = testDispacher.runBlockingTest {
whenever(single.blockingGet()).thenReturn(true)
whenever(mediaClient.checkFileExistsUsingSha("testSha1")).thenReturn(single)
whenever(fileUtilsWrapper.getSHA1(any())).thenReturn("testSha1")
imageLoader.querySHA1("testSha1")
whenever(notForUploadStatusDao.find(any())).thenReturn(0)
whenever(context.getSharedPreferences("custom_selector", 0))
.thenReturn(Mockito.mock(SharedPreferences::class.java))
imageLoader.queryAndSetView(holder, image, testDispacher, testDispacher)
}
/**
@ -188,13 +180,13 @@ class ImageLoaderTest {
whenever(fileUtilsWrapper.getFileInputStream("ABC")).thenReturn(inputStream)
whenever(fileUtilsWrapper.getSHA1(inputStream)).thenReturn("testSha1")
Assert.assertEquals("testSha1", imageLoader.getSHA1(image));
Assert.assertEquals("testSha1", imageLoader.getSHA1(image, testDispacher));
whenever(PickedFiles.pickedExistingPicture(context, Uri.parse("test"))).thenReturn(
uploadableFile
)
mapModifiedImageSHA1[image] = "testSha2"
Assert.assertEquals("testSha2", imageLoader.getSHA1(image));
Assert.assertEquals("testSha2", imageLoader.getSHA1(image, testDispacher));
}
/**
@ -218,8 +210,4 @@ class ImageLoaderTest {
imageLoader.getResultFromUploadedStatus(uploadedStatus))
}
@Test
fun testCleanUP() {
imageLoader.cleanUP()
}
}