From 133c51ebd5ab4c1f11a8efc605d70860daeb8eed Mon Sep 17 00:00:00 2001 From: Aditya-Srivastav <54016427+4D17Y4@users.noreply.github.com> Date: Sun, 13 Jun 2021 16:10:04 +0530 Subject: [PATCH] [GSOC] Added Image Fetch (#4449) * Added basic Fetch * added permission request * Folder count rectified * Loaded thumbnail * disabled overlay * Added sha1 function * Documented the code --- app/build.gradle | 4 + .../contributions/ContributionController.java | 20 ++++ .../ContributionsListFragment.java | 3 +- .../customselector/helper/ImageHelper.kt | 94 +++++++++++++++++++ .../ui/adapter/FolderAdapter.kt | 4 +- .../customselector/ui/adapter/ImageAdapter.kt | 2 + .../ui/selector/CustomSelectorViewModel.kt | 20 +++- .../ui/selector/FolderFragment.kt | 10 +- .../ui/selector/ImageFileLoader.kt | 94 ++++++++++++++++--- .../ui/selector/ImageFragment.kt | 6 +- .../layout/item_custom_selector_folder.xml | 17 ++-- .../res/layout/item_custom_selector_image.xml | 6 +- 12 files changed, 240 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt diff --git a/app/build.gradle b/app/build.gradle index 68405acce..501595fda 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -142,6 +142,10 @@ dependencies { def work_version = "2.4.0" // Kotlin + coroutines implementation "androidx.work:work-runtime-ktx:$work_version" + + //Glide + implementation 'com.github.bumptech.glide:glide:4.12.0' + annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0' } android { diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java index 15c61d836..778b1afdc 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java @@ -8,6 +8,7 @@ import android.content.Context; import android.content.Intent; import androidx.annotation.NonNull; import fr.free.nrw.commons.R; +import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity; import fr.free.nrw.commons.filepicker.DefaultCallback; import fr.free.nrw.commons.filepicker.FilePicker; import fr.free.nrw.commons.filepicker.FilePicker.ImageSource; @@ -58,6 +59,25 @@ public class ContributionController { initiateGalleryUpload(activity, allowMultipleUploads); } + /** + * Initiate gallery picker with permission + */ + public void initiateCustomGalleryPickWithPermission(final Activity activity) { + boolean useExtStorage = defaultKvStore.getBoolean("useExternalStorage", true); + Intent intent = new Intent(activity,CustomSelectorActivity.class); + if (!useExtStorage) { + activity.startActivity(intent); + return; + } + + PermissionUtils.checkPermissionsAndPerformAction(activity, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + () -> activity.startActivity(intent), + R.string.storage_permission_title, + R.string.write_storage_permission_rationale); + } + + /** * Open chooser for gallery uploads */ diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java index 4bb12d7b9..6161e06ac 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java @@ -267,8 +267,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl @OnClick(R.id.fab_custom_gallery) void launchCustomSelector(){ - Intent intent = new Intent(getActivity(), CustomSelectorActivity.class); - startActivity(intent); + controller.initiateCustomGalleryPickWithPermission(getActivity()); } private void animateFAB(final boolean isFabOpen) { 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 new file mode 100644 index 000000000..1b676b6e2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt @@ -0,0 +1,94 @@ +package fr.free.nrw.commons.customselector.helper + +import fr.free.nrw.commons.customselector.model.Folder +import fr.free.nrw.commons.customselector.model.Image +import timber.log.Timber +import java.io.* +import java.math.BigInteger +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import kotlin.collections.ArrayList +import kotlin.collections.LinkedHashMap + +/** + * Image Helper object, includes all the static functions required by custom selector + */ + +object ImageHelper { + + /** + * Returns the list of folders from given image list. + */ + fun folderListFromImages(images: List): List { + val folderMap: MutableMap = LinkedHashMap() + for (image in images) { + val bucketId = image.bucketId + val bucketName = image.bucketName + var folder = folderMap[bucketId] + if (folder == null) { + folder = Folder(bucketId, bucketName) + folderMap[bucketId] = folder + } + folder.images.add(image) + } + return ArrayList(folderMap.values) + } + + /** + * Filters the images based on the given bucketId (folder) + */ + fun filterImages(images: ArrayList, bukketId: Long?): ArrayList { + if (bukketId == null) return images + + val filteredImages = arrayListOf() + for (image in images) { + if (image.bucketId == bukketId) { + filteredImages.add(image) + } + } + return filteredImages + } + + /** + * Generates the file sha1 from file input stream. + */ + fun generateSHA1(`is`: InputStream): String { + val digest: MessageDigest = try { + MessageDigest.getInstance("SHA1") + } catch (e: NoSuchAlgorithmException) { + Timber.e(e, "Exception while getting Digest") + return "" + } + val buffer = ByteArray(8192) + var read: Int + return try { + while (`is`.read(buffer).also { read = it } > 0) { + digest.update(buffer, 0, read) + } + val md5sum = digest.digest() + val bigInt = BigInteger(1, md5sum) + var output = bigInt.toString(16) + output = String.format("%40s", output).replace(' ', '0') + Timber.i("File SHA1: %s", output) + output + } catch (e: IOException) { + Timber.e(e, "IO Exception") + "" + } finally { + try { + `is`.close() + } catch (e: IOException) { + Timber.e(e, "Exception on closing input stream") + } + } + } + + /** + * Gets the file input stream from the file path. + */ + @Throws(FileNotFoundException::class) + fun getFileInputStream(filePath: String?): InputStream { + return FileInputStream(filePath) + } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt index 11450549c..5d28a46d1 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt @@ -7,6 +7,7 @@ import android.widget.ImageView import android.widget.TextView import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide import fr.free.nrw.commons.R import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.model.Folder @@ -49,8 +50,9 @@ class FolderAdapter( val folder = folders[position] val count = folder.images.size val previewImage = folder.images[0] + Glide.with(context).load(previewImage.uri).into(holder.image) holder.name.text = folder.name - holder.count.text= count.toString() + holder.count.text = count.toString() holder.itemView.setOnClickListener{ itemClickListener.onFolderClick(folder) } 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 b29910c00..53de6de77 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 @@ -9,6 +9,7 @@ import android.widget.TextView import androidx.constraintlayout.widget.Group import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide import fr.free.nrw.commons.customselector.listeners.ImageSelectListener import fr.free.nrw.commons.customselector.model.Image @@ -49,6 +50,7 @@ class ImageAdapter( override fun onBindViewHolder(holder: ImageViewHolder, position: Int) { val image=images[position] // todo load image thumbnail, set selected view. + Glide.with(context).load(image.uri).into(holder.image) holder.itemView.setOnClickListener { selectOrRemoveImage(image, position) } diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorViewModel.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorViewModel.kt index a5f7cf6e5..26b8033ba 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorViewModel.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorViewModel.kt @@ -1,14 +1,20 @@ package fr.free.nrw.commons.customselector.ui.selector import android.content.Context +import android.util.Log import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import fr.free.nrw.commons.customselector.listeners.ImageLoaderListener import fr.free.nrw.commons.customselector.model.CallbackStatus import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.model.Result +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel -class CustomSelectorViewModel(val context: Context,var imageFileLoader: ImageFileLoader) : ViewModel() { +class CustomSelectorViewModel(var context: Context,var imageFileLoader: ImageFileLoader) : ViewModel() { + + private val scope = CoroutineScope(Dispatchers.Main) /** * Result Live Data @@ -20,9 +26,8 @@ class CustomSelectorViewModel(val context: Context,var imageFileLoader: ImageFil */ fun fetchImages() { result.postValue(Result(CallbackStatus.FETCHING, arrayListOf())) - imageFileLoader.abortLoadImage() + scope.cancel() imageFileLoader.loadDeviceImages(object: ImageLoaderListener { - override fun onImageLoaded(images: ArrayList) { result.postValue(Result(CallbackStatus.SUCCESS, images)) } @@ -30,7 +35,14 @@ class CustomSelectorViewModel(val context: Context,var imageFileLoader: ImageFil override fun onFailed(throwable: Throwable) { result.postValue(Result(CallbackStatus.SUCCESS, arrayListOf())) } + },scope) + } - }) + /** + * Clear the coroutine task linked with context. + */ + override fun onCleared() { + scope.cancel() + super.onCleared() } } \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/FolderFragment.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/FolderFragment.kt index a3db47571..ffacde0e7 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/FolderFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/FolderFragment.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager import fr.free.nrw.commons.R +import fr.free.nrw.commons.customselector.helper.ImageHelper import fr.free.nrw.commons.customselector.model.Result import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.model.CallbackStatus @@ -29,7 +30,7 @@ class FolderFragment : CommonsDaggerSupportFragment() { * View Model Factory. */ var customSelectorViewModelFactory: CustomSelectorViewModelFactory? = null - @Inject set + @Inject set /** @@ -67,7 +68,7 @@ class FolderFragment : CommonsDaggerSupportFragment() { */ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val root = inflater.inflate(R.layout.fragment_custom_selector, container, false) - folderAdapter = FolderAdapter(requireActivity(), activity as FolderClickListener) + folderAdapter = FolderAdapter(activity!!, activity as FolderClickListener) gridLayoutManager = GridLayoutManager(context, columnCount()) with(root.selector_rv){ this.layoutManager = gridLayoutManager @@ -87,10 +88,7 @@ class FolderFragment : CommonsDaggerSupportFragment() { */ private fun handleResult(result: Result) { if(result.status is CallbackStatus.SUCCESS){ - val folders = arrayListOf() - for( i in 1..12) { - folders.add(Folder(i.toLong(), "Folder$i",result.images)) - } + val folders = ImageHelper.folderListFromImages(result.images) folderAdapter.init(folders) folderAdapter.notifyDataSetChanged() selector_rv.visibility = View.VISIBLE diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFileLoader.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFileLoader.kt index 738c40e98..95cb8233f 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFileLoader.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFileLoader.kt @@ -1,26 +1,97 @@ package fr.free.nrw.commons.customselector.ui.selector +import android.content.ContentUris import android.content.Context -import android.net.Uri +import android.provider.MediaStore import fr.free.nrw.commons.customselector.listeners.ImageLoaderListener import fr.free.nrw.commons.customselector.model.Image +import kotlinx.coroutines.* +import java.io.File +import kotlin.coroutines.CoroutineContext -class ImageFileLoader(val context: Context) { +class ImageFileLoader(val context: Context) : CoroutineScope{ /** - * Load Device Images. + * Coroutine context for fetching images. */ - fun loadDeviceImages(listener: ImageLoaderListener) { - var tempImage = Image(0, "temp", Uri.parse("http://www.google.com"), "path", 0, "bucket", "1223") - var array: ArrayList = ArrayList() - for(i in 1..100) { - array.add(tempImage) - } - listener.onImageLoaded(array) + override val coroutineContext: CoroutineContext = Dispatchers.Main - // todo load images from device using cursor. + /** + * Media paramerters required. + */ + private val projection = arrayOf( + MediaStore.Images.Media._ID, + MediaStore.Images.Media.DISPLAY_NAME, + MediaStore.Images.Media.DATA, + MediaStore.Images.Media.BUCKET_ID, + MediaStore.Images.Media.BUCKET_DISPLAY_NAME) + + /** + * Load Device Images under coroutine. + */ + fun loadDeviceImages(listener: ImageLoaderListener, scope: CoroutineScope) { + launch(Dispatchers.Main) { + withContext(Dispatchers.IO) { + getImages(listener) + } + } } + + /** + * Load the device images using cursor + */ + private fun getImages(listener:ImageLoaderListener) { + val cursor = context.contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, MediaStore.Images.Media.DATE_ADDED + " DESC") + if (cursor == null) { + listener.onFailed(NullPointerException()) + return + } + + val idColumn = cursor.getColumnIndex(MediaStore.Images.Media._ID) + val nameColumn = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME) + val dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA) + val bucketIdColumn = cursor.getColumnIndex(MediaStore.Images.Media.BUCKET_ID) + val bucketNameColumn = cursor.getColumnIndex(MediaStore.Images.Media.BUCKET_DISPLAY_NAME) + + val images = arrayListOf() + if (cursor.moveToFirst()) { + do { + if (Thread.interrupted()) { + listener.onFailed(NullPointerException()) + return + } + val id = cursor.getLong(idColumn) + val name = cursor.getString(nameColumn) + val path = cursor.getString(dataColumn) + val bucketId = cursor.getLong(bucketIdColumn) + val bucketName = cursor.getString(bucketNameColumn) + + val file = + if (path == null || path.isEmpty()) { + null + } else try { + File(path) + } catch (ignored: Exception) { + null + } + + + if (file != null && file.exists()) { + if (id != null && name != null && path != null && bucketId != null && bucketName != null) { + val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) + val image = Image(id, name, uri, path, bucketId, bucketName) + images.add(image) + } + } + + } while (cursor.moveToNext()) + } + cursor.close() + listener.onImageLoaded(images) + } + + /** * Abort loading images. */ @@ -31,7 +102,6 @@ class ImageFileLoader(val context: Context) { /** * * TODO - * Runnable Thread for image loading. * Sha1 for image (original image). * */ 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 c22313d65..f4b5c9934 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 @@ -4,11 +4,11 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager 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.model.CallbackStatus import fr.free.nrw.commons.customselector.model.Result @@ -34,7 +34,7 @@ class ImageFragment: CommonsDaggerSupportFragment() { * View model Factory. */ lateinit var customSelectorViewModelFactory: CustomSelectorViewModelFactory - @Inject set + @Inject set /** * Image Adapter for recycle view. @@ -106,7 +106,7 @@ class ImageFragment: CommonsDaggerSupportFragment() { if(result.status is CallbackStatus.SUCCESS){ val images = result.images if(images.isNotEmpty()) { - imageAdapter.init(images) + imageAdapter.init(ImageHelper.filterImages(images,bucketId)) selector_rv.visibility = View.VISIBLE } else{ diff --git a/app/src/main/res/layout/item_custom_selector_folder.xml b/app/src/main/res/layout/item_custom_selector_folder.xml index 077968c6a..6d4df4307 100644 --- a/app/src/main/res/layout/item_custom_selector_folder.xml +++ b/app/src/main/res/layout/item_custom_selector_folder.xml @@ -19,30 +19,30 @@ android:background="@color/white" android:layout_height="match_parent"> - + android:background="@color/black" + android:alpha="0.15" /> diff --git a/app/src/main/res/layout/item_custom_selector_image.xml b/app/src/main/res/layout/item_custom_selector_image.xml index eec1eb9d9..021f463bc 100644 --- a/app/src/main/res/layout/item_custom_selector_image.xml +++ b/app/src/main/res/layout/item_custom_selector_image.xml @@ -19,7 +19,7 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - @@ -78,7 +78,7 @@ android:id="@+id/uploaded_group" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:visibility="visible" + android:visibility="gone" app:constraint_referenced_ids="uploaded_overlay,uploaded_overlay_icon"/>