Get Photo Gps Location With Android 10 Scoped Storage

January 13, 2020
ACCESS_MEDIA_LOCATION, Read EXIF /ExifInterface with Scoped Storage

What is Android Q Scoped Storage

To give users more control over their files and to limit file clutter, apps that target Android 10 (API level 29) and higher are given scoped access into external storage, or scoped storage, by default. Such apps have access only to the app-specific directory on external storage, as well as specific types of media that the app has created. Scoped storage

Basically, it limits your app’s ability to access files on Android device in the name of security and privacy, which means some old code would not work on Android 10 and need to change existing code to access external files via the API/Framework/Uri.

More about Scoped Storage

Coverage

This article would only cover

  • Read images from shared Gallery
  • Get image file uri (can use the uri to display image in app)
  • Read GPS information from image file

Prerequisite

You need the following permissions

Edit Module:app build.gradle.

compileSdkVersion 29
defaultConfig {
    ...

    targetSdkVersion 29

    ...
}

Code

The following code try to process all image in a specific album/bucket.

NOTE: Android Find Albums in Photo Gallery - Not Scoped Storage Ready.

val albumId = ...

val contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI

val projections = arrayOf(
    MediaStore.Images.ImageColumns._ID,
    // Deprecated on Android Q - actual file path is no longer used, access file via ID instead
    // MediaStore.Images.ImageColumns.DATA,
    // Complaint of Field Require API Level Q (current min is 17), but still works
    MediaStore.Images.ImageColumns.BUCKET_DISPLAY_NAME,
    // Complaint of Field Require API Level Q (current min is 17), but still works
    MediaStore.Images.ImageColumns.DATE_TAKEN,
    MediaStore.Images.ImageColumns.DISPLAY_NAME,
    // Complaint of Field Require API Level Q (current min is 17), but still works
    MediaStore.Images.ImageColumns.ORIENTATION,
    MediaStore.Images.ImageColumns.WIDTH,
    MediaStore.Images.ImageColumns.HEIGHT,
    MediaStore.Images.ImageColumns.SIZE,
    @Suppress("DEPRECATION")
    MediaStore.Images.ImageColumns.LATITUDE, // return value on device below Android Q
    @Suppress("DEPRECATION")
    MediaStore.Images.ImageColumns.LONGITUDE // return value on device below Android Q
)
val selection = "${MediaStore.Images.ImageColumns.BUCKET_ID} == ?"
val selectionArgs = arrayOf(
    albumId
)

contentResolver.query(contentUri, projections, selection, selectionArgs, null)?.use { cursor ->
    if (cursor.moveToFirst()) {
        // index
        val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns._ID)
        val dateTakenIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_TAKEN)
        val displayName = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DISPLAY_NAME)

        val orientationIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.ORIENTATION)
        val widthIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.WIDTH)
        val heightIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.HEIGHT)
        val sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.SIZE)
        @Suppress("DEPRECATION")
        val latIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.LATITUDE)
        @Suppress("DEPRECATION")
        val lonIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.LONGITUDE)

        do {
            val id = cursor.getLong(idIndex)
            val filename = cursor.getString(displayName)
            val millis = cursor.getLong(dateTakenIndex)
            // since timestamp in photo doesn't contain timezone, you have to guess the timezone or use device default timezone
            val date = LocalDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneId.systemDefault())

            val orientation = cursor.getInt(orientationIndex)
            val width = cursor.getInt(widthIndex)
            val height = cursor.getInt(heightIndex)
            val size = cursor.getLong(sizeIndex)

            // this will return result for device below Android Q
            var lat = cursor.getDoubleOrNull(latIndex)
            var lon = cursor.getDoubleOrNull(lonIndex)

            // uri to access file
            var uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)

            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) {
                    // must request ACCESS_MEDIA_LOCATION permission prior to call this function, else
                    // 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)
                            }
                        }
                    }
                }
            }

        } while (cursor.moveToNext())
    }
}

NOTE: You might want to use androidx Exifinterface

Observations

Android Q Device

ImageColumns.LATITUDE and ImageColumns.LONGITUDE column in cursor will always return null regardless of targetSdkVersion or setting android:requestLegacyExternalStorage="true" in AndroidManifest.xml.

If targetSdkVersion is 29

  • Must request ACCESS_MEDIA_LOCATION permission for MediaStore.setRequireOriginal to work without exception
  • Extract photo location using ExifInterface

If targetSdkVersion < 29

  • Requesting ACCESS_MEDIA_LOCATION permission is not mandatory
  • Extract photo location using ExifInterface

Android Device below Q

If targetSdkVersion is 29

  • Can access photo location via ImageColumns.LATITUDE and ImageColumns.LONGITUDE
  • ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_MEDIA_LOCATION) == PERMISSION_GRANTED will return false, and request permission won’t turn this to true. So, don’t perform permission check and request for device below Android Q.

If targetSdkVersion < 29

  • Can access photo location via ImageColumns.LATITUDE and ImageColumns.LONGITUDE
  • Manifest.permission.ACCESS_MEDIA_LOCATION only exist when targetSdkVersion is 29

References:

This work is licensed under a
Creative Commons Attribution-NonCommercial 4.0 International License.