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

* Inserted and marked images as not for upload

* Documentation added

* Test delete

* Implemented remove from not for upload

* Test fixed

* Requested changes done

* Added tests for new lines in existing classes
This commit is contained in:
Ayan Sarkar 2022-06-29 12:40:23 +05:30 committed by GitHub
parent 3bb3908eeb
commit 40323be3a0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 359 additions and 29 deletions

View file

@ -46,6 +46,12 @@ abstract class NotForUploadStatusDao {
suspend fun deleteNotForUploadWithImageSHA1(imageSHA1: String) {
return deleteWithImageSHA1(imageSHA1)
}
/**
* Check whether the imageSHA1 is present in database
*/
@Query("SELECT COUNT() FROM not_for_upload_table WHERE imageSHA1 = (:imageSHA1) ")
abstract suspend fun find(imageSHA1 : String): Int
}

View file

@ -11,8 +11,9 @@ interface ImageSelectListener {
/**
* onSelectedImagesChanged
* @param selectedImages : new selected images.
* @param selectedNotForUploadImages : number of selected not for upload images
*/
fun onSelectedImagesChanged(selectedImages: ArrayList<Image>)
fun onSelectedImagesChanged(selectedImages: ArrayList<Image>, selectedNotForUploadImages: Int)
/**
* onLongPress

View file

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

View file

@ -53,6 +53,11 @@ class ImageAdapter(
*/
private var selectedImages = arrayListOf<Image>()
/**
* Number of selected not for upload images
*/
private var selectedNotForUploadImages = 0
/**
* List of all images in adapter.
*/
@ -109,6 +114,9 @@ class ImageAdapter(
val clickedIndex = ImageHelper.getIndex(selectedImages, images[position])
if (clickedIndex != -1) {
selectedImages.removeAt(clickedIndex)
if (holder.isItemNotForUpload()) {
selectedNotForUploadImages--
}
notifyItemChanged(position, ImageUnselected())
val indexes = ImageHelper.getIndexList(selectedImages, images)
for (index in indexes) {
@ -118,11 +126,14 @@ class ImageAdapter(
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())
}
}
imageSelectListener.onSelectedImagesChanged(selectedImages)
imageSelectListener.onSelectedImagesChanged(selectedImages, selectedNotForUploadImages)
}
/**
@ -138,6 +149,18 @@ class ImageAdapter(
diffResult.dispatchUpdatesTo(this)
}
/**
* Refresh the data in the adapter
*/
fun refresh(newImages: List<Image>) {
selectedNotForUploadImages = 0
selectedImages.clear()
images.clear()
selectedImages = arrayListOf()
init(newImages)
notifyDataSetChanged()
}
/**
* Returns the total number of items in the data set held by the adapter.
*
@ -158,6 +181,7 @@ class ImageAdapter(
val image: ImageView = itemView.findViewById(R.id.image_thumbnail)
private val selectedNumber: TextView = itemView.findViewById(R.id.selected_count)
private val uploadedGroup: Group = itemView.findViewById(R.id.uploaded_group)
private val notForUploadGroup: Group = itemView.findViewById(R.id.not_for_upload_group)
private val selectedGroup: Group = itemView.findViewById(R.id.selected_group)
/**
@ -182,9 +206,24 @@ class ImageAdapter(
uploadedGroup.visibility = View.VISIBLE
}
/**
* Item is not for upload view
*/
fun itemNotForUpload() {
notForUploadGroup.visibility = View.VISIBLE
}
fun isItemUploaded():Boolean {
return uploadedGroup.visibility == View.VISIBLE
}
/**
* Item is not for upload
*/
fun isItemNotForUpload():Boolean {
return notForUploadGroup.visibility == View.VISIBLE
}
/**
* Item Not Uploaded view.
*/
@ -192,6 +231,12 @@ class ImageAdapter(
uploadedGroup.visibility = View.GONE
}
/**
* Item can be uploaded view
*/
fun itemForUpload() {
notForUploadGroup.visibility = View.GONE
}
}
/**

View file

@ -14,11 +14,17 @@ import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.ViewModelProvider
import fr.free.nrw.commons.R
import fr.free.nrw.commons.customselector.database.NotForUploadStatus
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
import fr.free.nrw.commons.customselector.listeners.FolderClickListener
import fr.free.nrw.commons.customselector.listeners.ImageSelectListener
import fr.free.nrw.commons.customselector.model.Image
import fr.free.nrw.commons.media.ZoomableActivity
import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.upload.FileUtilsWrapper
import fr.free.nrw.commons.utils.CustomSelectorUtils
import kotlinx.android.synthetic.main.custom_selector_bottom_layout.*
import kotlinx.coroutines.*
import java.io.File
import javax.inject.Inject
@ -54,6 +60,29 @@ class CustomSelectorActivity: BaseActivity(), FolderClickListener, ImageSelectLi
*/
@Inject lateinit var customSelectorViewModelFactory: CustomSelectorViewModelFactory
/**
* NotForUploadStatus Dao class for database operations
*/
@Inject
lateinit var notForUploadStatusDao: NotForUploadStatusDao
/**
* FileUtilsWrapper class to get imageSHA1 from uri
*/
@Inject
lateinit var fileUtilsWrapper: FileUtilsWrapper
/**
* Coroutine Dispatchers and Scope.
*/
private val scope : CoroutineScope = MainScope()
private var ioDispatcher : CoroutineDispatcher = Dispatchers.IO
/**
* Image Fragment instance
*/
var imageFragment: ImageFragment? = null
/**
* onCreate Activity, sets theme, initialises the view model, setup view.
*/
@ -112,6 +141,99 @@ class CustomSelectorActivity: BaseActivity(), FolderClickListener, ImageSelectLi
private fun setUpBottomLayout() {
val done : Button = findViewById(R.id.upload)
done.setOnClickListener { onDone() }
val notForUpload : Button = findViewById(R.id.not_for_upload)
notForUpload.setOnClickListener{ onClickNotForUpload() }
}
/**
* Gets selected images and proceed for database operations
*/
private fun onClickNotForUpload() {
val selectedImages = viewModel.selectedImages.value
if(selectedImages.isNullOrEmpty()) {
markAsNotForUpload(arrayListOf())
return
}
var i = 0
while (i < selectedImages.size) {
val path = selectedImages[i].path
val file = File(path)
if (!file.exists()) {
selectedImages.removeAt(i)
i--
}
i++
}
markAsNotForUpload(selectedImages)
}
/**
* Insert selected images in the database
*/
private fun markAsNotForUpload(images: ArrayList<Image>) {
insertIntoNotForUpload(images)
}
/**
* Initializing ImageFragment
*/
fun setOnDataListener(imageFragment: ImageFragment?) {
this.imageFragment = imageFragment
}
/**
* Insert images into not for upload
* Remove images from not for upload
* Refresh the UI
*/
private fun insertIntoNotForUpload(images: ArrayList<Image>) {
scope.launch {
var allImagesAlreadyNotForUpload = true
images.forEach{
val imageSHA1 = CustomSelectorUtils.getImageSHA1(
it.uri,
ioDispatcher,
fileUtilsWrapper,
contentResolver
)
val exists = notForUploadStatusDao.find(imageSHA1)
if (exists < 1) {
allImagesAlreadyNotForUpload = false
}
}
if (!allImagesAlreadyNotForUpload) {
images.forEach {
val imageSHA1 = CustomSelectorUtils.getImageSHA1(
it.uri,
ioDispatcher,
fileUtilsWrapper,
contentResolver
)
notForUploadStatusDao.insert(
NotForUploadStatus(
imageSHA1,
true
)
)
}
} else {
images.forEach {
val imageSHA1 = CustomSelectorUtils.getImageSHA1(
it.uri,
ioDispatcher,
fileUtilsWrapper,
contentResolver
)
notForUploadStatusDao.deleteNotForUploadWithImageSHA1(imageSHA1)
}
}
imageFragment!!.refresh()
val bottomLayout : ConstraintLayout = findViewById(R.id.bottom_layout)
bottomLayout.visibility = View.GONE
}
}
/**
@ -156,11 +278,25 @@ class CustomSelectorActivity: BaseActivity(), FolderClickListener, ImageSelectLi
}
/**
* override Selected Images Change, update view model selected images.
* override Selected Images Change, update view model selected images and change UI.
*/
override fun onSelectedImagesChanged(selectedImages: ArrayList<Image>) {
override fun onSelectedImagesChanged(selectedImages: ArrayList<Image>,
selectedNotForUploadImages: Int) {
viewModel.selectedImages.value = selectedImages
if (selectedNotForUploadImages > 0) {
upload.isEnabled = false
upload.alpha = 0.5f
} else {
upload.isEnabled = true
upload.alpha = 1f
}
not_for_upload.text = when (selectedImages.size == selectedNotForUploadImages) {
true -> getString(R.string.unmark_as_not_for_upload)
else -> getString(R.string.mark_as_not_for_upload)
}
val bottomLayout : ConstraintLayout = findViewById(R.id.bottom_layout)
bottomLayout.visibility = if (selectedImages.isEmpty()) View.GONE else View.VISIBLE
}

View file

@ -1,5 +1,6 @@
package fr.free.nrw.commons.customselector.ui.selector
import android.app.Activity
import android.net.Uri
import android.os.Bundle
import android.util.Log
@ -14,6 +15,7 @@ import androidx.recyclerview.widget.RecyclerView
import fr.free.nrw.commons.R
import fr.free.nrw.commons.customselector.helper.ImageHelper
import fr.free.nrw.commons.customselector.listeners.ImageSelectListener
import fr.free.nrw.commons.customselector.listeners.RefreshUIListener
import fr.free.nrw.commons.customselector.model.CallbackStatus
import fr.free.nrw.commons.customselector.model.Image
import fr.free.nrw.commons.customselector.model.Result
@ -30,7 +32,7 @@ import javax.inject.Inject
/**
* Custom Selector Image Fragment.
*/
class ImageFragment: CommonsDaggerSupportFragment() {
class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener {
/**
* Current bucketId.
@ -135,6 +137,18 @@ class ImageFragment: CommonsDaggerSupportFragment() {
return root
}
/**
* Attaching data listener
*/
override fun onAttach(activity: Activity) {
super.onAttach(activity)
try {
(getActivity() as CustomSelectorActivity).setOnDataListener(this)
} catch (ex: Exception) {
ex.printStackTrace()
}
}
/**
* Handle view model result.
*/
@ -211,4 +225,8 @@ class ImageFragment: CommonsDaggerSupportFragment() {
}
super.onDestroy()
}
override fun refresh() {
imageAdapter.refresh(filteredImages)
}
}

View file

@ -3,6 +3,7 @@ package fr.free.nrw.commons.customselector.ui.selector
import android.content.Context
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.model.Image
@ -11,6 +12,7 @@ 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 kotlinx.coroutines.*
import timber.log.Timber
import java.io.FileNotFoundException
@ -46,6 +48,11 @@ class ImageLoader @Inject constructor(
*/
var uploadedStatusDao: UploadedStatusDao,
/**
* NotForUploadDao for database operations
*/
var notForUploadStatusDao: NotForUploadStatusDao,
/**
* Context for coroutine.
*/
@ -86,7 +93,11 @@ class ImageLoader @Inject constructor(
return@launch
}
val imageSHA1 = getImageSHA1(image.uri)
val imageSHA1: String = when(mapImageSHA1[image.uri] != null) {
true -> mapImageSHA1[image.uri]!!
else -> CustomSelectorUtils.getImageSHA1(image.uri, ioDispatcher, fileUtilsWrapper, context.contentResolver)
}
if(imageSHA1.isEmpty())
return@launch
val uploadedStatus = getFromUploaded(imageSHA1)
@ -106,6 +117,8 @@ class ImageLoader @Inject constructor(
return@launch
}
val exists = notForUploadStatusDao.find(imageSHA1)
if (result in arrayOf(Result.NOTFOUND, Result.INVALID) && sha1.isNotEmpty()) {
// Query original image.
result = querySHA1(imageSHA1)
@ -122,6 +135,7 @@ class ImageLoader @Inject constructor(
}
if(mapHolderImage[holder] == image) {
if (result is Result.TRUE) holder.itemUploaded() else holder.itemNotUploaded()
if (exists > 0) holder.itemNotForUpload() else holder.itemForUpload()
}
}
}
@ -190,25 +204,6 @@ class ImageLoader @Inject constructor(
)
}
/**
* Get image sha1 from uri, used to retrieve the original image sha1.
*/
suspend fun getImageSHA1(uri: Uri): String {
return withContext(ioDispatcher) {
mapImageSHA1[uri]?.let{
return@withContext it
}
try {
val result = fileUtilsWrapper.getSHA1(context.contentResolver.openInputStream(uri))
mapImageSHA1[uri] = result
result
} catch (e: FileNotFoundException){
e.printStackTrace()
""
}
}
}
/**
* Get result data from database.
*/

View file

@ -0,0 +1,35 @@
package fr.free.nrw.commons.utils
import android.content.ContentResolver
import android.net.Uri
import fr.free.nrw.commons.upload.FileUtilsWrapper
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import java.io.FileNotFoundException
/**
* Util Class for Custom Selector
*/
class CustomSelectorUtils {
companion object {
/**
* Get image sha1 from uri, used to retrieve the original image sha1.
*/
suspend fun getImageSHA1(uri: Uri,
ioDispatcher : CoroutineDispatcher,
fileUtilsWrapper: FileUtilsWrapper,
contentResolver: ContentResolver
): String {
return withContext(ioDispatcher) {
try {
val result = fileUtilsWrapper.getSHA1(contentResolver.openInputStream(uri))
result
} catch (e: FileNotFoundException){
e.printStackTrace()
""
}
}
}
}
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#D50000" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM4,12c0,-4.42 3.58,-8 8,-8 1.85,0 3.55,0.63 4.9,1.69L5.69,16.9C4.63,15.55 4,13.85 4,12zM12,20c-1.85,0 -3.55,-0.63 -4.9,-1.69L18.31,7.1C19.37,8.45 20,10.15 20,12c0,4.42 -3.58,8 -8,8z"/>
</vector>

View file

@ -82,6 +82,23 @@
android:visibility="gone"
app:constraint_referenced_ids="uploaded_overlay,uploaded_overlay_icon"/>
<ImageView
android:id="@+id/not_for_upload_overlay_icon"
android:layout_width="@dimen/dimen_50"
android:layout_height="@dimen/dimen_50"
android:paddingBottom="@dimen/dimen_20"
android:paddingEnd="@dimen/dimen_20"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:srcCompat="@drawable/not_for_upload"
/>
<androidx.constraintlayout.widget.Group
android:id="@+id/not_for_upload_group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:constraint_referenced_ids="selected_overlay,not_for_upload_overlay_icon"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

View file

@ -65,6 +65,7 @@
<dimen name="dimen_20">20dp</dimen>
<dimen name="dimen_40">40dp</dimen>
<dimen name="dimen_42">42dp</dimen>
<dimen name="dimen_50">50dp</dimen>
<dimen name="dimen_250">250dp</dimen>
<dimen name="dimen_150">150dp</dimen>
<dimen name="dimen_72">72dp</dimen>

View file

@ -735,4 +735,5 @@ Upload your first media by tapping on the add button.</string>
<string name="enter_description">What is 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="unmark_as_not_for_upload">Unmark as not for upload</string>
</resources>

View file

@ -121,6 +121,9 @@ class ImageAdapterTest {
holder.itemUploaded()
func.invoke(imageAdapter, holder, 0)
holder.itemNotUploaded()
holder.itemNotForUpload()
func.invoke(imageAdapter, holder, 0)
holder.itemNotForUpload()
func.invoke(imageAdapter, holder, 0)
selectedImageField.set(imageAdapter, images)
func.invoke(imageAdapter, holder, 1)

View file

@ -31,6 +31,8 @@ class CustomSelectorActivityTest {
private lateinit var activity: CustomSelectorActivity
private lateinit var imageFragment: ImageFragment
/**
* Set up the tests.
*/
@ -44,6 +46,7 @@ class CustomSelectorActivityTest {
val onCreate = activity.javaClass.getDeclaredMethod("onCreate", Bundle::class.java)
onCreate.isAccessible = true
onCreate.invoke(activity, null)
imageFragment = ImageFragment.newInstance(1,0)
}
/**
@ -81,7 +84,7 @@ class CustomSelectorActivityTest {
@Test
@Throws(Exception::class)
fun testOnSelectedImagesChanged() {
activity.onSelectedImagesChanged(ArrayList())
activity.onSelectedImagesChanged(ArrayList(), 0)
}
/**
@ -91,10 +94,40 @@ class CustomSelectorActivityTest {
@Throws(Exception::class)
fun testOnDone() {
activity.onDone()
activity.onSelectedImagesChanged(ArrayList(arrayListOf(Image(1, "test", Uri.parse("test"), "test", 1))));
activity.onSelectedImagesChanged(
ArrayList(arrayListOf(Image(1, "test", Uri.parse("test"), "test", 1))),
1
)
activity.onDone()
}
/**
* Test onClickNotForUpload function.
*/
@Test
@Throws(Exception::class)
fun testOnClickNotForUpload() {
val method: Method = CustomSelectorActivity::class.java.getDeclaredMethod(
"onClickNotForUpload"
)
method.isAccessible = true
method.invoke(activity)
activity.onSelectedImagesChanged(
ArrayList(arrayListOf(Image(1, "test", Uri.parse("test"), "test", 1))),
0
)
method.invoke(activity)
}
/**
* Test setOnDataListener Function.
*/
@Test
@Throws(Exception::class)
fun testSetOnDataListener() {
activity.setOnDataListener(imageFragment)
}
/**
* Test onBackPressed Function.
*/

View file

@ -47,6 +47,7 @@ import java.lang.reflect.Field
class ImageFragmentTest {
private lateinit var fragment: ImageFragment
private lateinit var activity: CustomSelectorActivity
private lateinit var view: View
private lateinit var selectorRV : RecyclerView
private lateinit var loader : ProgressBar
@ -76,7 +77,7 @@ class ImageFragmentTest {
AppAdapter.set(TestAppAdapter())
SoLoader.setInTestMode()
Fresco.initialize(context)
val activity = Robolectric.buildActivity(CustomSelectorActivity::class.java).create().get()
activity = Robolectric.buildActivity(CustomSelectorActivity::class.java).create().get()
fragment = ImageFragment.newInstance(1,0)
val fragmentManager: FragmentManager = activity.supportFragmentManager
@ -92,6 +93,7 @@ class ImageFragmentTest {
Whitebox.setInternalState(fragment, "imageAdapter", adapter)
Whitebox.setInternalState(fragment, "selectorRV", selectorRV )
Whitebox.setInternalState(fragment, "loader", loader)
Whitebox.setInternalState(fragment, "filteredImages", arrayListOf(image,image))
viewModelField = fragment.javaClass.getDeclaredField("viewModel")
viewModelField.isAccessible = true
@ -139,6 +141,21 @@ class ImageFragmentTest {
assertEquals(3, func.invoke(fragment))
}
/**
* Test onAttach function.
*/
@Test
fun testOnAttach() {
fragment.onAttach(activity)
}
/**
* Test refresh function.
*/
@Test
fun testRefresh() {
fragment.refresh()
}
/**
* Test onResume.

View file

@ -5,6 +5,7 @@ import android.content.Context
import android.net.Uri
import com.nhaarman.mockitokotlin2.*
import fr.free.nrw.commons.TestCommonsApplication
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.model.Image
@ -61,6 +62,9 @@ class ImageLoaderTest {
@Mock
private lateinit var uploadedStatusDao: UploadedStatusDao
@Mock
private lateinit var notForUploadStatusDao: NotForUploadStatusDao
@Mock
private lateinit var holder: ImageAdapter.ImageViewHolder
@ -97,7 +101,8 @@ class ImageLoaderTest {
MockitoAnnotations.initMocks(this)
imageLoader =
ImageLoader(mediaClient, fileProcessor, fileUtilsWrapper, uploadedStatusDao, context)
ImageLoader(mediaClient, fileProcessor, fileUtilsWrapper, uploadedStatusDao,
notForUploadStatusDao, context)
uploadedStatus= UploadedStatus(
"testSha1",
"testSha1",