aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoSource.kt
blob: b3b283470dfcadeda9af44d2318d374a98eb0c26 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
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<Album> {
        val by = LinkedHashMap<String, Album>()
        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<Album> { it.camera }.thenByDescending { it.count + it.videos })
    }

    fun photos(albums: List<String>, withVideos: Boolean): List<Photo> {
        if (albums.isEmpty()) return emptyList()
        val out = ArrayList<Photo>()
        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<String>, withVideos: Boolean): Set<String> {
        if (albums.isEmpty()) return emptySet()
        val out = HashSet<String>()
        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<String>?,
                      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/")
    }
}