diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedDao.kt b/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedDao.kt index 49a1f61c3..619504e7d 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedDao.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedDao.kt @@ -47,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. */ diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt b/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt index 06ec4c36c..2af2d79f3 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt @@ -4,10 +4,20 @@ import fr.free.nrw.commons.customselector.model.Folder import fr.free.nrw.commons.customselector.model.Image /** - * Image Helper object, includes all the static functions required by custom selector. + * Image Helper object, includes all the static functions and variables required by custom selector. */ object ImageHelper { + /** + * Custom selector preference key + */ + const val CUSTOM_SELECTOR_PREFERENCE_KEY: String = "custom_selector" + + /** + * Switch state preference key + */ + const val SWITCH_STATE_PREFERENCE_KEY: String = "switch_state" + /** * Returns the list of folders from given image list. */ diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt index 8bac6ac93..1e81c9466 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt @@ -1,6 +1,7 @@ package fr.free.nrw.commons.customselector.ui.adapter import android.content.Context +import android.content.SharedPreferences import android.view.View import android.view.ViewGroup import android.widget.ImageView @@ -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 = ArrayList() + /** + * Stores all images + */ + private var allImages: List = ArrayList() + + /** + * Map to store actionable images + */ + private var mapActionableImages: TreeMap = TreeMap() + + /** + * Stores already added positions of actionable images + */ + private var alreadyAddedPositions: ArrayList = ArrayList() + + /** + * Next starting index to initiate query to find next actionable image + */ + private var 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 = 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 = 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) { + fun init(newImages: List, fixedImages: List, emptyMap: TreeMap) { + allImages = fixedImages val oldImageList:ArrayList = images val newImageList:ArrayList = 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) { + fun refresh(newImages: List, fixedImages: List) { 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. */ diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt index c06fcf96a..a6025e23d 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt @@ -1,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; + /** + * Stores all images + */ + var allImages: ArrayList = 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) } } \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt index 5e365da88..0944407ca 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt @@ -1,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 = HashMap() private var mapImageSHA1: HashMap = 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, 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. */ diff --git a/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt index 77fabeb70..5d710cef0 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt +++ b/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt @@ -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 + } + } } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_custom_selector.xml b/app/src/main/res/layout/fragment_custom_selector.xml index 8dc97325c..6de86968e 100644 --- a/app/src/main/res/layout/fragment_custom_selector.xml +++ b/app/src/main/res/layout/fragment_custom_selector.xml @@ -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"> + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4fb2f9207..f4bb07239 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -736,4 +736,7 @@ Upload your first media by tapping on the add button. Your feedback Mark as not for upload Unmark as not for upload + Show already actioned pictures + Hide already actioned pictures + Hiding already actioned pictures diff --git a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapterTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapterTest.kt index 3b616ce41..3754fa084 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapterTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapterTest.kt @@ -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)) } } \ No newline at end of file diff --git a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageLoaderTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageLoaderTest.kt index 375ae3316..0787fac64 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageLoaderTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageLoaderTest.kt @@ -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() - } } \ No newline at end of file