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
- https://android-developers.googleblog.com/2019/04/android-q-scoped-storage-best-practices.html
- https://www.youtube.com/watch?v=UnJ3amzJM94
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
- Request READ_EXTERNAL_STORAGE permission - to read file from device gallery
- Request ACCESS_MEDIA_LOCATION permission - to get GPS location from external images
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_URIval 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 forMediaStore.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
andImageColumns.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
andImageColumns.LONGITUDE
Manifest.permission.ACCESS_MEDIA_LOCATION
only exist whentargetSdkVersion
is 29
References: