package org.meshbay.client.photos import android.content.ContentUris import android.content.Context import android.net.Uri import android.os.Build import android.provider.MediaStore import java.io.InputStream /** * The phone's photos and videos, through MediaStore and nothing else. * * Opened through MediaStore by an application without ACCESS_MEDIA_LOCATION, * a file's location is **redacted by the platform** (Android 10+): the EXIF of * a photo, and the location boxes of an MP4 or MOV video (MediaProvider's * IsoInterface). A whole camera roll going to several people does not say * where its owner lives, and nobody had to do anything for that. The manifest * does not ask for it. * * An album is a MediaStore bucket, one directory on the phone; photos and * videos in it share its id. */ class PhotoSource(private val context: Context) { data class Album( val id: String, val name: String, val count: Int, val bytes: Long, val camera: Boolean, val videos: Int = 0, val videoBytes: Long = 0, ) private val images: Uri = if (Build.VERSION.SDK_INT >= 29) MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) else MediaStore.Images.Media.EXTERNAL_CONTENT_URI private val videos: Uri = if (Build.VERSION.SDK_INT >= 29) MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) else MediaStore.Video.Media.EXTERNAL_CONTENT_URI /** Every album with photos in it, and with videos too when `withVideos`. */ fun albums(withVideos: Boolean): List { val by = LinkedHashMap() query(images, false, null, null) { p, name, camera -> val a = by[p.bucketId] by[p.bucketId] = if (a == null) Album(p.bucketId, name, 1, p.size, camera) else a.copy(count = a.count + 1, bytes = a.bytes + p.size, camera = a.camera || camera) } if (withVideos) query(videos, true, null, null) { p, name, camera -> val a = by[p.bucketId] ?: Album(p.bucketId, name, 0, 0, camera) by[p.bucketId] = a.copy(videos = a.videos + 1, videoBytes = a.videoBytes + p.size, camera = a.camera || camera) } return by.values.sortedWith(compareByDescending { it.camera }.thenByDescending { it.count + it.videos }) } fun photos(albums: List, withVideos: Boolean): List { if (albums.isEmpty()) return emptyList() val out = ArrayList() val where = "${MediaStore.Images.Media.BUCKET_ID} IN (${albums.joinToString(",") { "?" }})" query(images, false, where, albums.toTypedArray()) { p, _, _ -> out += p } if (withVideos) query(videos, true, where, albums.toTypedArray()) { p, _, _ -> out += p } return out } /** * The files the photo backup sends, as lowercase `relative/path/name` * from the top of their volume: what the files backup leaves out, so * nothing is stored twice. */ fun relativePaths(albums: List, withVideos: Boolean): Set { if (albums.isEmpty()) return emptySet() val out = HashSet() val where = "${MediaStore.Images.Media.BUCKET_ID} IN (${albums.joinToString(",") { "?" }})" @Suppress("DEPRECATION") val cols = if (Build.VERSION.SDK_INT >= 29) arrayOf(MediaStore.MediaColumns.RELATIVE_PATH, MediaStore.MediaColumns.DISPLAY_NAME) else arrayOf(MediaStore.MediaColumns.DATA) for (collection in listOfNotNull(images, videos.takeIf { withVideos })) { context.contentResolver.query(collection, cols, where, albums.toTypedArray(), null)?.use { c -> while (c.moveToNext()) { val path = if (Build.VERSION.SDK_INT >= 29) (c.getString(0) ?: continue).trim('/') + "/" + (c.getString(1) ?: continue) else volumeRelative(c.getString(0) ?: continue) out += path.lowercase(java.util.Locale.ROOT) } } } return out } fun open(p: Photo): InputStream? = context.contentResolver.openInputStream(ContentUris.withAppendedId(if (p.video) videos else images, p.mediaId)) private fun query(collection: Uri, video: Boolean, where: String?, args: Array?, each: (Photo, String, Boolean) -> Unit) { // The same columns exist for images and videos (MediaColumns), and on // Android 8 and 9 under the same names in each collection. val cols = mutableListOf( MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.SIZE, MediaStore.Images.Media.DATE_TAKEN, MediaStore.MediaColumns.DATE_ADDED, MediaStore.MediaColumns.DATE_MODIFIED, MediaStore.Images.Media.BUCKET_ID, MediaStore.Images.Media.BUCKET_DISPLAY_NAME, MediaStore.MediaColumns.MIME_TYPE, ) @Suppress("DEPRECATION") val location = if (Build.VERSION.SDK_INT >= 29) MediaStore.MediaColumns.RELATIVE_PATH else MediaStore.MediaColumns.DATA cols += location // A photo or video still being written by the camera is not one yet. val pending = if (Build.VERSION.SDK_INT >= 29) "${MediaStore.MediaColumns.IS_PENDING} = 0" else null val selection = listOfNotNull(pending, where).joinToString(" AND ").ifEmpty { null } context.contentResolver.query(collection, cols.toTypedArray(), selection, args, null)?.use { c -> val id = c.getColumnIndexOrThrow(cols[0]); val name = c.getColumnIndexOrThrow(cols[1]) val size = c.getColumnIndexOrThrow(cols[2]); val taken = c.getColumnIndexOrThrow(cols[3]) val added = c.getColumnIndexOrThrow(cols[4]); val modified = c.getColumnIndexOrThrow(cols[5]) val bucket = c.getColumnIndexOrThrow(cols[6]); val bucketName = c.getColumnIndexOrThrow(cols[7]) val mime = c.getColumnIndexOrThrow(cols[8]); val where2 = c.getColumnIndexOrThrow(cols[9]) while (c.moveToNext()) { val bucketId = c.getString(bucket) ?: continue val path = (c.getString(where2) ?: "").replace('\\', '/') val display = c.getString(name) ?: "" val album = c.getString(bucketName) ?: "" // Where it was on the phone, from the top of its volume: what a // restore puts it back to (the manifest, §9.12). val relPath = if (Build.VERSION.SDK_INT >= 29) path.trim('/') + "/" + display else volumeRelative(path) val photo = Photo( c.getLong(id), display, c.getLong(size), if (c.isNull(taken)) 0 else c.getLong(taken), c.getLong(added), c.getLong(modified), bucketId, c.getString(mime) ?: "", video, relPath, album, ) each(photo, album, isCamera(path)) } } } companion object { /** `/storage/emulated/0/Documents/a.jpg` or `/storage/1234-ABCD/x/a.jpg` → the part after the volume. */ fun volumeRelative(absolute: String): String { val m = Regex("^/storage/(?:emulated/\\d+|[^/]+)/(.*)$").matchEntire(absolute) return (m?.groupValues?.get(1) ?: absolute).trim('/') } /** `DCIM/Camera/` (RELATIVE_PATH) or `…/DCIM/Camera/x.jpg` (DATA, before Android 10). */ fun isCamera(path: String): Boolean = path.startsWith("DCIM/Camera") || path.contains("/DCIM/Camera/") } }