[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 2dbda3a488
commit a120030a44
12 changed files with 240 additions and 40 deletions

View file

@ -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 {

View file

@ -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
*/

View file

@ -271,8 +271,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) {

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 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)
}

View file

@ -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)
}

View file

@ -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<Image>) {
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()
}
}

View file

@ -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<Folder>()
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

View file

@ -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<Image> = 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<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.
*/
@ -31,7 +102,6 @@ class ImageFileLoader(val context: Context) {
/**
*
* TODO
* Runnable Thread for image loading.
* Sha1 for image (original image).
*
*/

View file

@ -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{

View file

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

View file

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