[GSOC] Added Image Fetch (#4449)

* Added basic Fetch

* added permission request

* Folder count rectified

* Loaded thumbnail

* disabled overlay

* Added sha1 function

* Documented the code
This commit is contained in:
Aditya-Srivastav 2021-06-13 16:10:04 +05:30 committed by Aditya Srivastava
parent 5cc05ba3a4
commit 133c51ebd5
12 changed files with 240 additions and 40 deletions

View file

@ -142,6 +142,10 @@ dependencies {
def work_version = "2.4.0" def work_version = "2.4.0"
// Kotlin + coroutines // Kotlin + coroutines
implementation "androidx.work:work-runtime-ktx:$work_version" 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 { android {

View file

@ -8,6 +8,7 @@ import android.content.Context;
import android.content.Intent; import android.content.Intent;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import fr.free.nrw.commons.R; 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.DefaultCallback;
import fr.free.nrw.commons.filepicker.FilePicker; import fr.free.nrw.commons.filepicker.FilePicker;
import fr.free.nrw.commons.filepicker.FilePicker.ImageSource; import fr.free.nrw.commons.filepicker.FilePicker.ImageSource;
@ -58,6 +59,25 @@ public class ContributionController {
initiateGalleryUpload(activity, allowMultipleUploads); 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 * Open chooser for gallery uploads
*/ */

View file

@ -267,8 +267,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
@OnClick(R.id.fab_custom_gallery) @OnClick(R.id.fab_custom_gallery)
void launchCustomSelector(){ void launchCustomSelector(){
Intent intent = new Intent(getActivity(), CustomSelectorActivity.class); controller.initiateCustomGalleryPickWithPermission(getActivity());
startActivity(intent);
} }
private void animateFAB(final boolean isFabOpen) { private void animateFAB(final boolean isFabOpen) {

View file

@ -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<Image>): List<Folder> {
val folderMap: MutableMap<Long, Folder> = 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<Image>, bukketId: Long?): ArrayList<Image> {
if (bukketId == null) return images
val filteredImages = arrayListOf<Image>()
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)
}
}

View file

@ -7,6 +7,7 @@ import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import fr.free.nrw.commons.R import fr.free.nrw.commons.R
import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.listeners.FolderClickListener
import fr.free.nrw.commons.customselector.model.Folder import fr.free.nrw.commons.customselector.model.Folder
@ -49,8 +50,9 @@ class FolderAdapter(
val folder = folders[position] val folder = folders[position]
val count = folder.images.size val count = folder.images.size
val previewImage = folder.images[0] val previewImage = folder.images[0]
Glide.with(context).load(previewImage.uri).into(holder.image)
holder.name.text = folder.name holder.name.text = folder.name
holder.count.text= count.toString() holder.count.text = count.toString()
holder.itemView.setOnClickListener{ holder.itemView.setOnClickListener{
itemClickListener.onFolderClick(folder) itemClickListener.onFolderClick(folder)
} }

View file

@ -9,6 +9,7 @@ import android.widget.TextView
import androidx.constraintlayout.widget.Group import androidx.constraintlayout.widget.Group
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
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
@ -49,6 +50,7 @@ class ImageAdapter(
override fun onBindViewHolder(holder: ImageViewHolder, position: Int) { override fun onBindViewHolder(holder: ImageViewHolder, position: Int) {
val image=images[position] val image=images[position]
// todo load image thumbnail, set selected view. // todo load image thumbnail, set selected view.
Glide.with(context).load(image.uri).into(holder.image)
holder.itemView.setOnClickListener { holder.itemView.setOnClickListener {
selectOrRemoveImage(image, position) selectOrRemoveImage(image, position)
} }

View file

@ -1,14 +1,20 @@
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.util.Log
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import fr.free.nrw.commons.customselector.listeners.ImageLoaderListener import fr.free.nrw.commons.customselector.listeners.ImageLoaderListener
import fr.free.nrw.commons.customselector.model.CallbackStatus import fr.free.nrw.commons.customselector.model.CallbackStatus
import fr.free.nrw.commons.customselector.model.Image 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 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 * Result Live Data
@ -20,9 +26,8 @@ class CustomSelectorViewModel(val context: Context,var imageFileLoader: ImageFil
*/ */
fun fetchImages() { fun fetchImages() {
result.postValue(Result(CallbackStatus.FETCHING, arrayListOf())) result.postValue(Result(CallbackStatus.FETCHING, arrayListOf()))
imageFileLoader.abortLoadImage() scope.cancel()
imageFileLoader.loadDeviceImages(object: ImageLoaderListener { imageFileLoader.loadDeviceImages(object: ImageLoaderListener {
override fun onImageLoaded(images: ArrayList<Image>) { override fun onImageLoaded(images: ArrayList<Image>) {
result.postValue(Result(CallbackStatus.SUCCESS, images)) result.postValue(Result(CallbackStatus.SUCCESS, images))
} }
@ -30,7 +35,14 @@ class CustomSelectorViewModel(val context: Context,var imageFileLoader: ImageFil
override fun onFailed(throwable: Throwable) { override fun onFailed(throwable: Throwable) {
result.postValue(Result(CallbackStatus.SUCCESS, arrayListOf())) result.postValue(Result(CallbackStatus.SUCCESS, arrayListOf()))
} }
},scope)
}
}) /**
* Clear the coroutine task linked with context.
*/
override fun onCleared() {
scope.cancel()
super.onCleared()
} }
} }

View file

@ -8,6 +8,7 @@ import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
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.model.Result import fr.free.nrw.commons.customselector.model.Result
import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.listeners.FolderClickListener
import fr.free.nrw.commons.customselector.model.CallbackStatus import fr.free.nrw.commons.customselector.model.CallbackStatus
@ -29,7 +30,7 @@ class FolderFragment : CommonsDaggerSupportFragment() {
* View Model Factory. * View Model Factory.
*/ */
var customSelectorViewModelFactory: CustomSelectorViewModelFactory? = null 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? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val root = inflater.inflate(R.layout.fragment_custom_selector, container, false) 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()) gridLayoutManager = GridLayoutManager(context, columnCount())
with(root.selector_rv){ with(root.selector_rv){
this.layoutManager = gridLayoutManager this.layoutManager = gridLayoutManager
@ -87,10 +88,7 @@ class FolderFragment : CommonsDaggerSupportFragment() {
*/ */
private fun handleResult(result: Result) { private fun handleResult(result: Result) {
if(result.status is CallbackStatus.SUCCESS){ if(result.status is CallbackStatus.SUCCESS){
val folders = arrayListOf<Folder>() val folders = ImageHelper.folderListFromImages(result.images)
for( i in 1..12) {
folders.add(Folder(i.toLong(), "Folder$i",result.images))
}
folderAdapter.init(folders) folderAdapter.init(folders)
folderAdapter.notifyDataSetChanged() folderAdapter.notifyDataSetChanged()
selector_rv.visibility = View.VISIBLE selector_rv.visibility = View.VISIBLE

View file

@ -1,26 +1,97 @@
package fr.free.nrw.commons.customselector.ui.selector package fr.free.nrw.commons.customselector.ui.selector
import android.content.ContentUris
import android.content.Context 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.listeners.ImageLoaderListener
import fr.free.nrw.commons.customselector.model.Image 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) { override val coroutineContext: CoroutineContext = Dispatchers.Main
var tempImage = Image(0, "temp", Uri.parse("http://www.google.com"), "path", 0, "bucket", "1223")
var array: ArrayList<Image> = ArrayList()
for(i in 1..100) {
array.add(tempImage)
}
listener.onImageLoaded(array)
// 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<Image>()
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. * Abort loading images.
*/ */
@ -31,7 +102,6 @@ class ImageFileLoader(val context: Context) {
/** /**
* *
* TODO * TODO
* Runnable Thread for image loading.
* Sha1 for image (original image). * Sha1 for image (original image).
* *
*/ */

View file

@ -4,11 +4,11 @@ import android.os.Bundle
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 androidx.fragment.app.Fragment
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 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.listeners.ImageSelectListener import fr.free.nrw.commons.customselector.listeners.ImageSelectListener
import fr.free.nrw.commons.customselector.model.CallbackStatus import fr.free.nrw.commons.customselector.model.CallbackStatus
import fr.free.nrw.commons.customselector.model.Result import fr.free.nrw.commons.customselector.model.Result
@ -34,7 +34,7 @@ class ImageFragment: CommonsDaggerSupportFragment() {
* View model Factory. * View model Factory.
*/ */
lateinit var customSelectorViewModelFactory: CustomSelectorViewModelFactory lateinit var customSelectorViewModelFactory: CustomSelectorViewModelFactory
@Inject set @Inject set
/** /**
* Image Adapter for recycle view. * Image Adapter for recycle view.
@ -106,7 +106,7 @@ class ImageFragment: CommonsDaggerSupportFragment() {
if(result.status is CallbackStatus.SUCCESS){ if(result.status is CallbackStatus.SUCCESS){
val images = result.images val images = result.images
if(images.isNotEmpty()) { if(images.isNotEmpty()) {
imageAdapter.init(images) imageAdapter.init(ImageHelper.filterImages(images,bucketId))
selector_rv.visibility = View.VISIBLE selector_rv.visibility = View.VISIBLE
} }
else{ else{

View file

@ -19,30 +19,30 @@
android:background="@color/white" android:background="@color/white"
android:layout_height="match_parent"> android:layout_height="match_parent">
<ImageView <androidx.appcompat.widget.AppCompatImageView
android:id="@+id/folder_thumbnail" android:id="@+id/folder_thumbnail"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@color/black"
android:alpha="0.15"
android:scaleType="centerCrop" /> android:scaleType="centerCrop" />
<View <View
android:id="@+id/album_overlay" android:id="@+id/album_overlay"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:alpha="0.05" /> android:background="@color/black"
android:alpha="0.15" />
<LinearLayout <LinearLayout
android:id="@+id/folder_details" android:id="@+id/folder_details"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="#4D000000"
android:orientation="horizontal" android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"> app:layout_constraintBottom_toBottomOf="parent">
<TextView <TextView
android:id="@+id/folder_name" android:id="@+id/folder_name"
style="@style/TextAppearance.AppCompat.Widget.ActionBar.Title" android:textColor="@color/white"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="@dimen/dimen_6" android:layout_margin="@dimen/dimen_6"
@ -50,18 +50,17 @@
android:ellipsize="end" android:ellipsize="end"
android:padding="@dimen/dimen_6" android:padding="@dimen/dimen_6"
android:singleLine="true" android:singleLine="true"
android:textSize="16sp" android:textSize="15sp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent" /> app:layout_constraintLeft_toLeftOf="parent" />
<TextView <TextView
android:id="@+id/folder_count" android:id="@+id/folder_count"
style="@style/TextAppearance.AppCompat.Widget.ActionBar.Title" android:textColor="@color/white"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="5dp" android:layout_margin="5dp"
android:textSize="16sp" android:textSize="14sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent" /> app:layout_constraintRight_toRightOf="parent" />

View file

@ -19,7 +19,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<ImageView <androidx.appcompat.widget.AppCompatImageView
android:id="@+id/image_thumbnail" android:id="@+id/image_thumbnail"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
@ -53,7 +53,7 @@
android:id="@+id/selected_group" android:id="@+id/selected_group"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:visibility="visible" android:visibility="gone"
app:constraint_referenced_ids="selected_overlay,selected_count"/> app:constraint_referenced_ids="selected_overlay,selected_count"/>
@ -78,7 +78,7 @@
android:id="@+id/uploaded_group" android:id="@+id/uploaded_group"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:visibility="visible" android:visibility="gone"
app:constraint_referenced_ids="uploaded_overlay,uploaded_overlay_icon"/> app:constraint_referenced_ids="uploaded_overlay,uploaded_overlay_icon"/>