Fragment with RecylerView
class ImportPhotos2Fragment : Fragment() { private lateinit var adapter: LocalAdapter override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { return inflater.inflate(R.layout.importphotos2, container, false) } private fun initList() { val layoutManager = GridLayoutManager(context, 3) list.layoutManager = layoutManager // true if adapter changes cannot affect the size of the RecyclerView list.setHasFixedSize(true) if (!::adapter.isInitialized) { adapter = LocalAdapter(viewModel.gallery) } list.adapter = adapter val gallery = Gallery2(requireContext()) return gallery.getCameraBucketId()?.let { bucketId -> gallery.query(bucketId)?.also { cursor -> adapter.submitCursor(cursor) } } }}
ViewItem
sealed class ViewItem(open val id: String, val resource: Int) { data class PhotoItem(override val id: String, val uri: Uri) : ViewItem(id, R.layout.importphotos2_item)}
Adapter
class LocalAdapter(private val helper: Gallery2) : RecyclerView.Adapter<LocalAdapter.ViewHolder>() { private var cursor: Cursor? = null private var resizeOptions: ResizeOptions? = null init { // since every photos have an unique id setHasStableIds(true) } fun requireCursor(): Cursor { return cursor ?: throw IllegalStateException("Cursor is not initialized.") } override fun getItemCount() = requireCursor().count override fun getItemId(position: Int): Long { val cursor = requireCursor() cursor.moveToPosition(position) return helper.getKey(cursor) } fun submitCursor(cursor: Cursor) { val oldCursor = this.cursor this.cursor = cursor notifyDataSetChanged() oldCursor?.close() } fun getItem(position: Int): ViewItem { val cursor = requireCursor() cursor.moveToPosition(position) val data = helper.toItem(cursor) val mediaId = data.get(Image.DEVICE_MEDIA_ID) as Long val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, mediaId) return ViewItem.PhotoItem(id = mediaId.toString(), uri = uri) } /* fun getItemPositionByKey(key: String): Int { // TODO: consider using map for key to position var position = 0 val cursor = requireCursor() if (cursor.moveToFirst()) { do { if (helper.getKey(cursor).toString() == key) { return position } position += 1 } while (cursor.moveToNext()) } return RecyclerView.NO_POSITION } override fun getItemViewType(position: Int): Int { // return getItem(position).resource return R.layout.importphotos2_item } */ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val view = LayoutInflater.from(parent.context) // .inflate(viewType, parent, false) .inflate(R.layout.importphotos2_item, parent, false) return ViewHolder(view) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { val item = getItem(position) when(val item = getItem(position)) { is ViewItem.PhotoItem -> bindPhoto(item, holder) } } fun bindPhoto(item: ViewItem.PhotoItem, holder: ViewHolder) { holder.apply { // optimize loading of images by knowing the container size val localResizeOptions = if (resizeOptions == null) { holder.draweeView.doOnLayout { resizeOptions = ResizeOptions(holder.draweeView.width, holder.draweeView.height) } // temporary size // - you can estimate size by dividing screen width by 3 (columns) // - don't specify a size, where we load the full image // - estimate a size ResizeOptions(300, 300) } else resizeOptions // load image with Fresco // draweeView.setImageURI(uri) draweeView.setImageURISize(item.uri, localResizeOptions) } } inner class ViewHolder(override val containerView: View) : RecyclerView.ViewHolder( containerView ), LayoutContainer { }}
R.layout.importphotos2
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
R.layout.importphoto2_item
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools">
<!-- square view -->
<com.facebook.drawee.view.SimpleDraweeView
android:id="@+id/draweeView"
android:layout_width="0dp"
android:layout_height="0dp"
android:gravity="center"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Test">
</com.facebook.drawee.view.SimpleDraweeView>
</androidx.constraintlayout.widget.ConstraintLayout>
NOTE: Refer Android RecyclerView Get Item View Width and Height - Load Image with Fresco
Gallery helper class to query photos in album
class Gallery2(val context: Context) { private val contentResolver by lazy { context.contentResolver } private var cameraBucketId: Long? = null @Suppress("DEPRECATION") fun getCameraBucketId(): Long? { if (cameraBucketId == null) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { val bucketPath = Environment.getExternalStoragePublicDirectory("/DCIM/Camera").toString() cameraBucketId = bucketPath.toLowerCase().hashCode().toLong() return cameraBucketId } else { for (item in findAlbums()) { Timber.d("${item.name}=${item.count}, ${item.id}") if (item.name == "Camera") { cameraBucketId = item.id return cameraBucketId } } } } return cameraBucketId } class Album(val id: Long, val name: String, var count: Long = 0, var lastImageUri: Uri? = null) fun findAlbums(): List<Album> { val contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI // display name might not be unique val projections = arrayOf( // "DISTINCT ${MediaStore.Images.ImageColumns.BUCKET_DISPLAY_NAME}" MediaStore.Images.ImageColumns.BUCKET_ID, MediaStore.Images.ImageColumns.BUCKET_DISPLAY_NAME, "COUNT(" + MediaStore.Images.ImageColumns._ID + ") AS image_count" ) val groupBy = "1) GROUP BY ${MediaStore.Images.ImageColumns.BUCKET_ID}, (${MediaStore.Images.ImageColumns.BUCKET_DISPLAY_NAME}" val albums = mutableListOf<Album>() contentResolver.query(contentUri, projections, groupBy, null, null)?.use { cursor -> if (cursor.moveToFirst()) { val bucketIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.BUCKET_ID) val nameIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.BUCKET_DISPLAY_NAME) val countIndex = cursor.getColumnIndexOrThrow("image_count") do { val bucketId = cursor.getLong(bucketIdIndex) val bucketName = cursor.getString(nameIndex) val imageCount = cursor.getLong(countIndex) val album = Album(id = bucketId, name = bucketName, count = imageCount) albums.add(album) // MAYBE can be optimized further? val projections = arrayOf( MediaStore.Images.ImageColumns._ID, MediaStore.Images.ImageColumns.DATA ) val selection = "${MediaStore.Images.ImageColumns.BUCKET_ID} == ?" val selectionArgs = arrayOf<String>( bucketId.toString() ) val sortOrder = "${MediaStore.Images.ImageColumns.DATE_TAKEN} DESC LIMIT 1" contentResolver.query(contentUri, projections, selection, selectionArgs, sortOrder)?.use { imageCursor -> if (imageCursor.moveToFirst()) { val imageUriIndex = imageCursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATA) val imageUri = imageCursor.getString(imageUriIndex) album.lastImageUri = Uri.parse(imageUri) } // imageCursor.close() } } while (cursor.moveToNext()) } // cursor.close() } return albums } fun query(albumId: Long): Cursor? { val contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI val projections = arrayOf( MediaStore.Images.ImageColumns._ID, // MediaStore.Images.ImageColumns.DATA, MediaStore.Images.ImageColumns.BUCKET_DISPLAY_NAME, MediaStore.Images.ImageColumns.DATE_TAKEN, MediaStore.Images.ImageColumns.DISPLAY_NAME, MediaStore.Images.ImageColumns.ORIENTATION, MediaStore.Images.ImageColumns.WIDTH, MediaStore.Images.ImageColumns.HEIGHT, MediaStore.Images.ImageColumns.SIZE, @Suppress("DEPRECATION") MediaStore.Images.ImageColumns.LATITUDE, @Suppress("DEPRECATION") MediaStore.Images.ImageColumns.LONGITUDE ) val selection = "${MediaStore.Images.ImageColumns.BUCKET_ID} == ?" val selectionArgs = arrayOf<String>( albumId.toString() ) val cursor = contentResolver.query( contentUri, projections, selection, selectionArgs, "${MediaStore.Images.ImageColumns.DATE_TAKEN} ASC" ) cursor?.also { if (cursor.moveToFirst()) { initIndex(cursor) } } return cursor } var idIndex = -1 var dateTakenIndex = -1 var displayNameIndex = -1 var orientationIndex = -1 var widthIndex = -1 var heightIndex = -1 var sizeIndex = -1 var latIndex = -1 var lonIndex = -1 fun initIndex(cursor: Cursor) { // if (idIndex == -1) { idIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns._ID) Timber.d("initIndex, id=$idIndex") // val uriIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATA) // val bucketDisplayName = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.BUCKET_DISPLAY_NAME) dateTakenIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_TAKEN) displayNameIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DISPLAY_NAME) orientationIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.ORIENTATION) widthIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.WIDTH) heightIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.HEIGHT) sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.SIZE) @Suppress("DEPRECATION") latIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.LATITUDE) @Suppress("DEPRECATION") lonIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.LONGITUDE) // } } fun getKey(cursor: Cursor) = cursor.getLong(idIndex) fun toItem(cursor: Cursor): MutableMap<String, Any> { val mediaId = cursor.getLong(idIndex) // val uri = Uri.parse(cursor.getString(uriIndex)) // val filepath = cursor.getString(uriIndex) // val filepath = cursor.getString(bucketDisplayName) // val filename = File(uri.path).name val filename = cursor.getString(displayNameIndex) val millis = cursor.getLong(dateTakenIndex) val orientation = cursor.getInt(orientationIndex) val width = cursor.getInt(widthIndex) val height = cursor.getInt(heightIndex) val size = cursor.getLong(sizeIndex) var lat = cursor.getDoubleOrNull(latIndex) var lon = cursor.getDoubleOrNull(lonIndex) var uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, mediaId) // seems to only warn in IDE, but compile OK // NoSuchMethodError: No static method setRequireOriginal if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // https://developer.android.com/reference/kotlin/android/provider/MediaStore#setrequireoriginal uri = MediaStore.setRequireOriginal(uri) if (lat == null || lon == null) { // UnsupportedOperationException: Caller must hold ACCESS_MEDIA_LOCATION permission to access original contentResolver.openInputStream(uri).use { stream -> stream?.also { ExifInterface(stream).run { lat = latLong?.get(0) lon = latLong?.get(1) } } } } } val timezone = ZoneId.systemDefault() val date = LocalDateTime.ofInstant(Instant.ofEpochMilli(millis), timezone) val data = mutableMapOf( Image.DEVICE_MEDIA_ID to mediaId, // Image.DEVICE_FILE_PATH to filepath, // TODO: store device bucket instead? // use UTC to store exact time Image.CREATED to date!!.toTimestamp(ZoneOffset.UTC), Image.FILENAME to filename, Image.META to mapOf( Image.Meta.ORIENTATION to orientation, Image.Meta.WIDTH to width, Image.Meta.HEIGHT to height, Image.Meta.SIZE to size, Image.Meta.LAT to lat, Image.Meta.LON to lon ) ) return data }}