mirror of
https://github.com/commons-app/apps-android-commons.git
synced 2025-10-26 20:33:53 +01:00
[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:
parent
5559282c1a
commit
a6c51a75a8
10 changed files with 638 additions and 163 deletions
|
|
@ -47,6 +47,19 @@ abstract class UploadedStatusDao {
|
||||||
insert(uploadedStatus)
|
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.
|
* Asynchronous image sha1 query.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,20 @@ import fr.free.nrw.commons.customselector.model.Folder
|
||||||
import fr.free.nrw.commons.customselector.model.Image
|
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 {
|
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.
|
* Returns the list of folders from given image list.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package fr.free.nrw.commons.customselector.ui.adapter
|
package fr.free.nrw.commons.customselector.ui.adapter
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
|
|
@ -13,9 +14,14 @@ import com.bumptech.glide.Glide
|
||||||
import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView
|
import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView
|
||||||
import fr.free.nrw.commons.R
|
import fr.free.nrw.commons.R
|
||||||
import fr.free.nrw.commons.customselector.helper.ImageHelper
|
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.ImageSelectListener
|
||||||
import fr.free.nrw.commons.customselector.model.Image
|
import fr.free.nrw.commons.customselector.model.Image
|
||||||
import fr.free.nrw.commons.customselector.ui.selector.ImageLoader
|
import fr.free.nrw.commons.customselector.ui.selector.ImageLoader
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom selector ImageAdapter.
|
* Custom selector ImageAdapter.
|
||||||
|
|
@ -49,6 +55,8 @@ class ImageAdapter(
|
||||||
*/
|
*/
|
||||||
class ImageUnselected
|
class ImageUnselected
|
||||||
|
|
||||||
|
private var stopAddition: Boolean = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Currently selected images.
|
* Currently selected images.
|
||||||
*/
|
*/
|
||||||
|
|
@ -64,6 +72,35 @@ class ImageAdapter(
|
||||||
*/
|
*/
|
||||||
private var images: ArrayList<Image> = ArrayList()
|
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.
|
* Create View holder.
|
||||||
*/
|
*/
|
||||||
|
|
@ -76,7 +113,7 @@ class ImageAdapter(
|
||||||
* Bind View holder, load image, selected view, click listeners.
|
* Bind View holder, load image, selected view, click listeners.
|
||||||
*/
|
*/
|
||||||
override fun onBindViewHolder(holder: ImageViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: ImageViewHolder, position: Int) {
|
||||||
val image=images[position]
|
var image=images[position]
|
||||||
holder.image.setImageDrawable (null)
|
holder.image.setImageDrawable (null)
|
||||||
if (context.contentResolver.getType(image.uri) == null) {
|
if (context.contentResolver.getType(image.uri) == null) {
|
||||||
// Image does not exist anymore, update adapter.
|
// Image does not exist anymore, update adapter.
|
||||||
|
|
@ -87,17 +124,106 @@ class ImageAdapter(
|
||||||
notifyItemRangeChanged(updatedPosition, images.size)
|
notifyItemRangeChanged(updatedPosition, images.size)
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
val isSelected = selectedIndex != -1
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
holder.itemSelected(selectedIndex + 1)
|
holder.itemSelected(selectedImages.size)
|
||||||
} else {
|
} 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 {
|
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.
|
// launch media preview on long click.
|
||||||
|
|
@ -112,26 +238,60 @@ class ImageAdapter(
|
||||||
* Handle click event on an image, update counter on images.
|
* Handle click event on an image, update counter on images.
|
||||||
*/
|
*/
|
||||||
private fun selectOrRemoveImage(holder: ImageViewHolder, position: Int){
|
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) {
|
if (clickedIndex != -1) {
|
||||||
selectedImages.removeAt(clickedIndex)
|
selectedImages.removeAt(clickedIndex)
|
||||||
if (holder.isItemNotForUpload()) {
|
if (holder.isItemNotForUpload()) {
|
||||||
selectedNotForUploadImages--
|
selectedNotForUploadImages--
|
||||||
}
|
}
|
||||||
notifyItemChanged(position, ImageUnselected())
|
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) {
|
for (index in indexes) {
|
||||||
notifyItemChanged(index, ImageSelectedOrUpdated())
|
notifyItemChanged(index, ImageSelectedOrUpdated())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if(holder.isItemUploaded()){
|
if (holder.isItemUploaded()) {
|
||||||
Toast.makeText(context, R.string.custom_selector_already_uploaded_image_text, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, R.string.custom_selector_already_uploaded_image_text, Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
} else {
|
||||||
if (holder.isItemNotForUpload()) {
|
if (holder.isItemNotForUpload()) {
|
||||||
selectedNotForUploadImages++
|
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)
|
imageSelectListener.onSelectedImagesChanged(selectedImages, selectedNotForUploadImages)
|
||||||
|
|
@ -140,9 +300,16 @@ class ImageAdapter(
|
||||||
/**
|
/**
|
||||||
* Initialize the data set.
|
* 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 oldImageList:ArrayList<Image> = images
|
||||||
val newImageList:ArrayList<Image> = ArrayList(newImages)
|
val newImageList:ArrayList<Image> = ArrayList(newImages)
|
||||||
|
mapActionableImages = emptyMap
|
||||||
|
alreadyAddedPositions = ArrayList()
|
||||||
|
nextImage = 0
|
||||||
|
stopAddition = false
|
||||||
|
selectedImages = ArrayList()
|
||||||
|
count = 0
|
||||||
val diffResult = DiffUtil.calculateDiff(
|
val diffResult = DiffUtil.calculateDiff(
|
||||||
ImagesDiffCallback(oldImageList, newImageList)
|
ImagesDiffCallback(oldImageList, newImageList)
|
||||||
)
|
)
|
||||||
|
|
@ -153,12 +320,12 @@ class ImageAdapter(
|
||||||
/**
|
/**
|
||||||
* Refresh the data in the adapter
|
* Refresh the data in the adapter
|
||||||
*/
|
*/
|
||||||
fun refresh(newImages: List<Image>) {
|
fun refresh(newImages: List<Image>, fixedImages: List<Image>) {
|
||||||
selectedNotForUploadImages = 0
|
selectedNotForUploadImages = 0
|
||||||
selectedImages.clear()
|
selectedImages.clear()
|
||||||
images.clear()
|
images.clear()
|
||||||
selectedImages = arrayListOf()
|
selectedImages = arrayListOf()
|
||||||
init(newImages)
|
init(newImages, fixedImages, TreeMap())
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,13 +335,38 @@ class ImageAdapter(
|
||||||
* @return The total number of items in this adapter.
|
* @return The total number of items in this adapter.
|
||||||
*/
|
*/
|
||||||
override fun getItemCount(): Int {
|
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 {
|
fun getImageIdAt(position: Int): Long {
|
||||||
return images.get(position).id
|
return images.get(position).id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CleanUp function.
|
||||||
|
*/
|
||||||
|
fun cleanUp() {
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Image view holder.
|
* Image view holder.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,26 @@
|
||||||
package fr.free.nrw.commons.customselector.ui.selector
|
package fr.free.nrw.commons.customselector.ui.selector
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.net.Uri
|
import android.content.Context.MODE_PRIVATE
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ProgressBar
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.Switch
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.lifecycle.Observer
|
import androidx.lifecycle.Observer
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import fr.free.nrw.commons.R
|
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
|
||||||
|
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.ImageSelectListener
|
||||||
import fr.free.nrw.commons.customselector.listeners.RefreshUIListener
|
import fr.free.nrw.commons.customselector.listeners.RefreshUIListener
|
||||||
import fr.free.nrw.commons.customselector.model.CallbackStatus
|
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.model.Result
|
||||||
import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter
|
import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter
|
||||||
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
|
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.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.*
|
||||||
import kotlinx.android.synthetic.main.fragment_custom_selector.view.*
|
import kotlinx.android.synthetic.main.fragment_custom_selector.view.*
|
||||||
import java.io.File
|
import kotlinx.coroutines.*
|
||||||
import java.io.FileInputStream
|
import java.util.*
|
||||||
import java.net.URI
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom Selector Image Fragment.
|
* Custom Selector Image Fragment.
|
||||||
|
|
@ -54,8 +64,14 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener {
|
||||||
*/
|
*/
|
||||||
private var selectorRV: RecyclerView? = null
|
private var selectorRV: RecyclerView? = null
|
||||||
private var loader: ProgressBar? = null
|
private var loader: ProgressBar? = null
|
||||||
|
private var switch: Switch? = null
|
||||||
lateinit var filteredImages: ArrayList<Image>;
|
lateinit var filteredImages: ArrayList<Image>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores all images
|
||||||
|
*/
|
||||||
|
var allImages: ArrayList<Image> = ArrayList()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View model Factory.
|
* View model Factory.
|
||||||
*/
|
*/
|
||||||
|
|
@ -78,9 +94,48 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener {
|
||||||
*/
|
*/
|
||||||
private lateinit var gridLayoutManager: GridLayoutManager
|
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 {
|
companion object {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch state
|
||||||
|
*/
|
||||||
|
var switchState: Boolean = true
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BucketId args name
|
* BucketId args name
|
||||||
*/
|
*/
|
||||||
|
|
@ -131,12 +186,50 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener {
|
||||||
handleResult(it)
|
handleResult(it)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
switch = root.switchWidget
|
||||||
|
switch?.visibility = View.VISIBLE
|
||||||
|
switch?.setOnCheckedChangeListener { _, isChecked -> onChangeSwitchState(isChecked) }
|
||||||
selectorRV = root.selector_rv
|
selectorRV = root.selector_rv
|
||||||
loader = root.loader
|
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
|
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
|
* Attaching data listener
|
||||||
*/
|
*/
|
||||||
|
|
@ -157,7 +250,12 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener {
|
||||||
val images = result.images
|
val images = result.images
|
||||||
if(images.isNotEmpty()) {
|
if(images.isNotEmpty()) {
|
||||||
filteredImages = ImageHelper.filterImages(images, bucketId)
|
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 {
|
selectorRV?.let {
|
||||||
it.visibility = View.VISIBLE
|
it.visibility = View.VISIBLE
|
||||||
lastItemId?.let { pos ->
|
lastItemId?.let { pos ->
|
||||||
|
|
@ -205,7 +303,7 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener {
|
||||||
* Save the Image Fragment state.
|
* Save the Image Fragment state.
|
||||||
*/
|
*/
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
imageLoader?.cleanUP()
|
imageAdapter.cleanUp()
|
||||||
|
|
||||||
val position = (selectorRV?.layoutManager as GridLayoutManager)
|
val position = (selectorRV?.layoutManager as GridLayoutManager)
|
||||||
.findFirstVisibleItemPosition()
|
.findFirstVisibleItemPosition()
|
||||||
|
|
@ -227,6 +325,6 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun refresh() {
|
override fun refresh() {
|
||||||
imageAdapter.refresh(filteredImages)
|
imageAdapter.refresh(filteredImages, allImages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,27 +1,23 @@
|
||||||
package fr.free.nrw.commons.customselector.ui.selector
|
package fr.free.nrw.commons.customselector.ui.selector
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.exifinterface.media.ExifInterface
|
|
||||||
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
|
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
|
||||||
import fr.free.nrw.commons.customselector.database.UploadedStatus
|
import fr.free.nrw.commons.customselector.database.UploadedStatus
|
||||||
import fr.free.nrw.commons.customselector.database.UploadedStatusDao
|
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.model.Image
|
||||||
import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter.ImageViewHolder
|
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.media.MediaClient
|
||||||
import fr.free.nrw.commons.upload.FileProcessor
|
import fr.free.nrw.commons.upload.FileProcessor
|
||||||
import fr.free.nrw.commons.upload.FileUtilsWrapper
|
import fr.free.nrw.commons.upload.FileUtilsWrapper
|
||||||
import fr.free.nrw.commons.utils.CustomSelectorUtils
|
import fr.free.nrw.commons.utils.CustomSelectorUtils
|
||||||
|
import fr.free.nrw.commons.utils.CustomSelectorUtils.Companion.querySHA1
|
||||||
import kotlinx.coroutines.*
|
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.*
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.collections.HashMap
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Image Loader class, loads images, depending on API results.
|
* 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 mapResult: HashMap<String, Result> = HashMap()
|
||||||
private var mapImageSHA1: HashMap<Uri, String> = 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.
|
* 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.
|
* Recycler view uses same view holder, so we can identify the latest query image from holder.
|
||||||
*/
|
*/
|
||||||
mapHolderImage[holder] = image
|
mapHolderImage[holder] = image
|
||||||
holder.itemNotUploaded()
|
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) {
|
val imageSHA1: String = when(mapImageSHA1[image.uri] != null) {
|
||||||
return@launch
|
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) {
|
if (mapHolderImage[holder] != image) {
|
||||||
true -> mapImageSHA1[image.uri]!!
|
return
|
||||||
else -> CustomSelectorUtils.getImageSHA1(image.uri, ioDispatcher, fileUtilsWrapper, context.contentResolver)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if(imageSHA1.isEmpty())
|
val exists = notForUploadStatusDao.find(imageSHA1)
|
||||||
return@launch
|
|
||||||
val uploadedStatus = getFromUploaded(imageSHA1)
|
|
||||||
|
|
||||||
val sha1 = uploadedStatus?.let {
|
if (result in arrayOf(Result.NOTFOUND, Result.INVALID) && sha1.isNotEmpty()) {
|
||||||
result = getResultFromUploadedStatus(uploadedStatus)
|
when {
|
||||||
uploadedStatus.modifiedImageSHA1
|
mapResult[imageSHA1] == null -> {
|
||||||
} ?: run {
|
// Query original image.
|
||||||
if (mapHolderImage[holder] == image) {
|
result = querySHA1(imageSHA1, ioDispatcher, mediaClient)
|
||||||
getSHA1(image)
|
when (result) {
|
||||||
} else {
|
is Result.TRUE -> {
|
||||||
""
|
mapResult[imageSHA1] = Result.TRUE
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else -> {
|
||||||
|
result = mapResult[imageSHA1]!!
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if(mapHolderImage[holder] == image) {
|
if (result is Result.TRUE) {
|
||||||
if (result is Result.TRUE) holder.itemUploaded() else holder.itemNotUploaded()
|
// Original image found.
|
||||||
if (exists > 0) holder.itemNotForUpload() else holder.itemForUpload()
|
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.
|
* Finds out the next actionable image position
|
||||||
*
|
|
||||||
* @return Query result.
|
|
||||||
*/
|
*/
|
||||||
|
suspend fun nextActionableImage(
|
||||||
|
allImages: List<Image>, ioDispatcher: CoroutineDispatcher,
|
||||||
|
defaultDispatcher: CoroutineDispatcher,
|
||||||
|
nextImagePosition: Int
|
||||||
|
): Int {
|
||||||
|
var next = -1
|
||||||
|
|
||||||
suspend fun querySHA1(SHA1: String): Result {
|
// Traversing from given position to the end
|
||||||
return withContext(ioDispatcher) {
|
for (i in nextImagePosition until allImages.size){
|
||||||
mapResult[SHA1]?.let {
|
val it = allImages[i]
|
||||||
return@withContext it
|
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
|
next = notForUploadStatusDao.find(imageSHA1)
|
||||||
try {
|
|
||||||
if (mediaClient.checkFileExistsUsingSha(SHA1).blockingGet()) {
|
// After checking the image in the not for upload database, if the image is present then
|
||||||
mapResult[SHA1] = Result.TRUE
|
// skips the image and moves to next image for checking
|
||||||
result = Result.TRUE
|
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
|
* @return sha1 of the image
|
||||||
*/
|
*/
|
||||||
suspend fun getSHA1(image: Image): String {
|
suspend fun getSHA1(image: Image, defaultDispatcher: CoroutineDispatcher): String {
|
||||||
mapModifiedImageSHA1[image]?.let{
|
mapModifiedImageSHA1[image]?.let{
|
||||||
return it
|
return it
|
||||||
}
|
}
|
||||||
val sha1 = generateModifiedSHA1(image);
|
val sha1 = CustomSelectorUtils
|
||||||
|
.generateModifiedSHA1(image,
|
||||||
|
defaultDispatcher,
|
||||||
|
context,
|
||||||
|
fileProcessor,
|
||||||
|
fileUtilsWrapper
|
||||||
|
)
|
||||||
mapModifiedImageSHA1[image] = sha1;
|
mapModifiedImageSHA1[image] = sha1;
|
||||||
return sha1;
|
return sha1;
|
||||||
}
|
}
|
||||||
|
|
@ -221,35 +296,6 @@ class ImageLoader @Inject constructor(
|
||||||
return Result.INVALID
|
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.
|
* Sealed Result class.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,28 @@
|
||||||
package fr.free.nrw.commons.utils
|
package fr.free.nrw.commons.utils
|
||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
import android.net.Uri
|
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 fr.free.nrw.commons.upload.FileUtilsWrapper
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Util Class for Custom Selector
|
* Util Class for Custom Selector
|
||||||
*/
|
*/
|
||||||
class CustomSelectorUtils {
|
class CustomSelectorUtils {
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get image sha1 from uri, used to retrieve the original image sha1.
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,13 +3,26 @@
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="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
|
<com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView
|
||||||
android:id="@+id/selector_rv"
|
android:id="@+id/selector_rv"
|
||||||
android:background="?attr/mainBackground"
|
|
||||||
android:layout_width="match_parent"
|
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:fastScrollPopupBgColor="@color/primaryColor"
|
||||||
app:fastScrollPopupTextColor="@android:color/primary_text_dark"
|
app:fastScrollPopupTextColor="@android:color/primary_text_dark"
|
||||||
app:fastScrollPopupTextSize="@dimen/subheading_text_size"
|
app:fastScrollPopupTextSize="@dimen/subheading_text_size"
|
||||||
|
|
@ -17,6 +30,10 @@
|
||||||
app:fastScrollThumbColor="@color/primaryColor"
|
app:fastScrollThumbColor="@color/primaryColor"
|
||||||
app:fastScrollTrackColor="@color/upload_overlay_background_light"
|
app:fastScrollTrackColor="@color/upload_overlay_background_light"
|
||||||
app:fastScrollPopupPosition="adjacent"
|
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
|
<TextView
|
||||||
|
|
@ -45,4 +62,41 @@
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
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>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
@ -736,4 +736,7 @@ Upload your first media by tapping on the add button.</string>
|
||||||
<string name="your_feedback">Your feedback</string>
|
<string name="your_feedback">Your feedback</string>
|
||||||
<string name="mark_as_not_for_upload">Mark as not for upload</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="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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package fr.free.nrw.commons.customselector.ui.adapter
|
||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
|
@ -23,6 +24,8 @@ import org.robolectric.Robolectric
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import java.lang.reflect.Field
|
import java.lang.reflect.Field
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom Selector image adapter test.
|
* Custom Selector image adapter test.
|
||||||
|
|
@ -88,8 +91,10 @@ class ImageAdapterTest {
|
||||||
|
|
||||||
// Parameters.
|
// Parameters.
|
||||||
images.add(image)
|
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.
|
// Test conditions.
|
||||||
imageAdapter.onBindViewHolder(holder, 0)
|
imageAdapter.onBindViewHolder(holder, 0)
|
||||||
selectedImageField.set(imageAdapter, images)
|
selectedImageField.set(imageAdapter, images)
|
||||||
|
|
@ -101,7 +106,7 @@ class ImageAdapterTest {
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun init() {
|
fun init() {
|
||||||
imageAdapter.init(images)
|
imageAdapter.init(images, images, TreeMap())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -115,7 +120,7 @@ class ImageAdapterTest {
|
||||||
|
|
||||||
// Parameters
|
// Parameters
|
||||||
images.addAll(listOf(image, image))
|
images.addAll(listOf(image, image))
|
||||||
imageAdapter.init(images)
|
imageAdapter.init(images, images, TreeMap())
|
||||||
|
|
||||||
// Test conditions
|
// Test conditions
|
||||||
holder.itemUploaded()
|
holder.itemUploaded()
|
||||||
|
|
@ -142,7 +147,7 @@ class ImageAdapterTest {
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun getImageIdAt() {
|
fun getImageIdAt() {
|
||||||
imageAdapter.init(listOf(image))
|
imageAdapter.init(listOf(image), listOf(image), TreeMap())
|
||||||
Assertions.assertEquals(1, imageAdapter.getImageIdAt(0))
|
Assertions.assertEquals(1, imageAdapter.getImageIdAt(0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package fr.free.nrw.commons.customselector.ui.selector
|
||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.nhaarman.mockitokotlin2.*
|
import com.nhaarman.mockitokotlin2.*
|
||||||
import fr.free.nrw.commons.TestCommonsApplication
|
import fr.free.nrw.commons.TestCommonsApplication
|
||||||
|
|
@ -117,8 +118,6 @@ class ImageLoaderTest {
|
||||||
Whitebox.setInternalState(imageLoader, "mapModifiedImageSHA1", mapModifiedImageSHA1);
|
Whitebox.setInternalState(imageLoader, "mapModifiedImageSHA1", mapModifiedImageSHA1);
|
||||||
Whitebox.setInternalState(imageLoader, "mapResult", mapResult);
|
Whitebox.setInternalState(imageLoader, "mapResult", mapResult);
|
||||||
Whitebox.setInternalState(imageLoader, "context", context)
|
Whitebox.setInternalState(imageLoader, "context", context)
|
||||||
Whitebox.setInternalState(imageLoader, "ioDispatcher", testDispacher)
|
|
||||||
Whitebox.setInternalState(imageLoader, "defaultDispatcher", testDispacher)
|
|
||||||
|
|
||||||
whenever(contentResolver.openInputStream(uri)).thenReturn(inputStream)
|
whenever(contentResolver.openInputStream(uri)).thenReturn(inputStream)
|
||||||
whenever(context.contentResolver).thenReturn(contentResolver)
|
whenever(context.contentResolver).thenReturn(contentResolver)
|
||||||
|
|
@ -141,14 +140,17 @@ class ImageLoaderTest {
|
||||||
@Test
|
@Test
|
||||||
fun testQueryAndSetViewUploadedStatusNull() = testDispacher.runBlockingTest {
|
fun testQueryAndSetViewUploadedStatusNull() = testDispacher.runBlockingTest {
|
||||||
whenever(uploadedStatusDao.getUploadedFromImageSHA1(any())).thenReturn(null)
|
whenever(uploadedStatusDao.getUploadedFromImageSHA1(any())).thenReturn(null)
|
||||||
|
whenever(notForUploadStatusDao.find(any())).thenReturn(0)
|
||||||
mapModifiedImageSHA1[image] = "testSha1"
|
mapModifiedImageSHA1[image] = "testSha1"
|
||||||
mapImageSHA1[uri] = "testSha1"
|
mapImageSHA1[uri] = "testSha1"
|
||||||
|
whenever(context.getSharedPreferences("custom_selector", 0))
|
||||||
|
.thenReturn(Mockito.mock(SharedPreferences::class.java))
|
||||||
|
|
||||||
mapResult["testSha1"] = ImageLoader.Result.TRUE
|
mapResult["testSha1"] = ImageLoader.Result.TRUE
|
||||||
imageLoader.queryAndSetView(holder, image)
|
imageLoader.queryAndSetView(holder, image, testDispacher, testDispacher)
|
||||||
|
|
||||||
mapResult["testSha1"] = ImageLoader.Result.FALSE
|
mapResult["testSha1"] = ImageLoader.Result.FALSE
|
||||||
imageLoader.queryAndSetView(holder, image)
|
imageLoader.queryAndSetView(holder, image, testDispacher, testDispacher)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -157,20 +159,10 @@ class ImageLoaderTest {
|
||||||
@Test
|
@Test
|
||||||
fun testQueryAndSetViewUploadedStatusNotNull() = testDispacher.runBlockingTest {
|
fun testQueryAndSetViewUploadedStatusNotNull() = testDispacher.runBlockingTest {
|
||||||
whenever(uploadedStatusDao.getUploadedFromImageSHA1(any())).thenReturn(uploadedStatus)
|
whenever(uploadedStatusDao.getUploadedFromImageSHA1(any())).thenReturn(uploadedStatus)
|
||||||
imageLoader.queryAndSetView(holder, image)
|
whenever(notForUploadStatusDao.find(any())).thenReturn(0)
|
||||||
}
|
whenever(context.getSharedPreferences("custom_selector", 0))
|
||||||
|
.thenReturn(Mockito.mock(SharedPreferences::class.java))
|
||||||
/**
|
imageLoader.queryAndSetView(holder, image, testDispacher, testDispacher)
|
||||||
* 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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -188,13 +180,13 @@ class ImageLoaderTest {
|
||||||
whenever(fileUtilsWrapper.getFileInputStream("ABC")).thenReturn(inputStream)
|
whenever(fileUtilsWrapper.getFileInputStream("ABC")).thenReturn(inputStream)
|
||||||
whenever(fileUtilsWrapper.getSHA1(inputStream)).thenReturn("testSha1")
|
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(
|
whenever(PickedFiles.pickedExistingPicture(context, Uri.parse("test"))).thenReturn(
|
||||||
uploadableFile
|
uploadableFile
|
||||||
)
|
)
|
||||||
|
|
||||||
mapModifiedImageSHA1[image] = "testSha2"
|
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))
|
imageLoader.getResultFromUploadedStatus(uploadedStatus))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testCleanUP() {
|
|
||||||
imageLoader.cleanUP()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue