RecyclerView with Cursor Adapter

August 31, 2020
Show Photos in Album

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

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
    }
}
This work is licensed under a
Creative Commons Attribution-NonCommercial 4.0 International License.