app/src/main/kotlin/space/subread/anki/Media.kt (6522 bytes)
1 package space.subread.anki 2 3 import android.content.Context 4 import android.graphics.Bitmap 5 import android.net.Uri 6 import android.util.Base64 7 import androidx.core.content.FileProvider 8 import androidx.core.graphics.scale 9 import androidx.core.net.toUri 10 import java.io.File 11 import java.net.HttpURLConnection 12 import java.net.URL 13 14 /** The media files of a card, in the cache, until AnkiDroid has copied them. */ 15 object Media { 16 17 /** A file bigger than this is refused: a card holds a clip, not a book. */ 18 const val MAX_BYTES = 30L * 1024 * 1024 19 20 enum class Kind { AUDIO, IMAGE } 21 22 fun dir(context: Context): File = File(context.cacheDir, "media").apply { mkdirs() } 23 24 fun file(context: Context, name: String): File = File(dir(context), name) 25 26 /** The Uri that AnkiDroid reads the file through. */ 27 fun uriFor(context: Context, file: File): Uri = 28 FileProvider.getUriForFile(context, context.packageName + ".files", file) 29 30 /** Deletes the files of earlier cards. AnkiDroid copied them at once; a day is plenty. */ 31 fun cleanOld(context: Context) { 32 val limit = System.currentTimeMillis() - 24 * 3600_000L 33 dir(context).listFiles()?.filter { it.lastModified() < limit }?.forEach { it.delete() } 34 } 35 36 /** 37 * Fetches a media file: a `content://` Uri, an `http(s)://` URL, or a `data:` Uri. The 38 * bytes say what the file is; the name gets that extension. Null when the source cannot 39 * be read, is empty, or is not a file of [kind]. 40 */ 41 fun fetch(context: Context, source: String, stem: String, kind: Kind): File? { 42 val bytes = runCatching { bytes(context, source) }.getOrNull() ?: return null 43 return write(context, bytes, stem, kind) 44 } 45 46 /** Writes the bytes of a file of [kind] to the cache, named by what the bytes are. Null for an unknown or empty file. */ 47 fun write(context: Context, bytes: ByteArray, stem: String, kind: Kind): File? { 48 if (bytes.isEmpty()) return null 49 val extension = extension(bytes, kind) ?: return null 50 val out = file(context, "$stem.$extension") 51 out.writeBytes(bytes) 52 return out 53 } 54 55 /** The longest side of a picture on a card, in pixels. */ 56 const val MAX_SIDE = 1600 57 58 /** The picture as a JPEG in [out], at most [MAX_SIDE] pixels on the long side. Null when the write fails. */ 59 fun writeJpeg(bitmap: Bitmap, out: File): File? { 60 val longest = maxOf(bitmap.width, bitmap.height) 61 val scaled = if (longest <= MAX_SIDE) bitmap else { 62 val scale = MAX_SIDE.toFloat() / longest 63 bitmap.scale((bitmap.width * scale).toInt(), (bitmap.height * scale).toInt()) 64 } 65 return runCatching { 66 out.outputStream().use { scaled.compress(Bitmap.CompressFormat.JPEG, 85, it) } 67 out 68 }.getOrNull() 69 } 70 71 /** The bytes of a source. */ 72 fun bytes(context: Context, source: String): ByteArray? { 73 val uri = source.toUri() 74 return when (uri.scheme?.lowercase()) { 75 "content" -> context.contentResolver.openInputStream(uri)?.use { it.readBytes(MAX_BYTES) } 76 "http", "https" -> { 77 val connection = URL(source).openConnection() as HttpURLConnection 78 connection.connectTimeout = 15_000 79 connection.readTimeout = 30_000 80 try { 81 if (connection.responseCode != 200) null else connection.inputStream.use { it.readBytes(MAX_BYTES) } 82 } finally { 83 connection.disconnect() 84 } 85 } 86 "data" -> { 87 // data:audio/mpeg;base64,SUQz... 88 val comma = source.indexOf(',') 89 if (comma < 0) return null 90 val head = source.substring(0, comma) 91 val body = source.substring(comma + 1) 92 if (head.endsWith(";base64")) Base64.decode(body, Base64.DEFAULT) else Uri.decode(body).toByteArray() 93 } 94 // A file path would read the private files of this app for the sender. 95 else -> null 96 } 97 } 98 99 private fun java.io.InputStream.readBytes(limit: Long): ByteArray? { 100 val out = java.io.ByteArrayOutputStream() 101 val buffer = ByteArray(64 * 1024) 102 var total = 0L 103 while (true) { 104 val n = read(buffer) 105 if (n < 0) break 106 total += n 107 if (total > limit) return null 108 out.write(buffer, 0, n) 109 } 110 return out.toByteArray() 111 } 112 113 /** The extension for the bytes of a file of [kind], from its first bytes. Null for an unknown file. */ 114 fun extension(bytes: ByteArray, kind: Kind): String? { 115 fun at(offset: Int, text: String) = 116 bytes.size >= offset + text.length && text.indices.all { bytes[offset + it] == text[it].code.toByte() } 117 return when (kind) { 118 Kind.IMAGE -> when { 119 bytes.size > 2 && bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() -> "jpg" 120 at(1, "PNG") -> "png" 121 at(0, "RIFF") && at(8, "WEBP") -> "webp" 122 at(0, "GIF8") -> "gif" 123 else -> null 124 } 125 Kind.AUDIO -> when { 126 at(0, "ID3") -> "mp3" 127 bytes.size > 2 && bytes[0] == 0xFF.toByte() && (bytes[1].toInt() and 0xE0) == 0xE0 -> "mp3" 128 at(0, "OggS") -> "ogg" 129 at(0, "RIFF") && at(8, "WAVE") -> "wav" 130 at(0, "fLaC") -> "flac" 131 at(4, "ftyp") -> "m4a" 132 else -> null 133 } 134 } 135 } 136 137 /** The MIME type for an extension of [extension]. AnkiDroid names its copy from it. */ 138 fun mimeType(extension: String): String? = when (extension.lowercase()) { 139 "mp3" -> "audio/mpeg" 140 "m4a" -> "audio/mp4" 141 "ogg", "oga", "opus" -> "audio/ogg" 142 "wav" -> "audio/x-wav" 143 "flac" -> "audio/flac" 144 "aac" -> "audio/aac" 145 "jpg", "jpeg" -> "image/jpeg" 146 "png" -> "image/png" 147 "webp" -> "image/webp" 148 "gif" -> "image/gif" 149 else -> null 150 } 151 } 152 153 /** 154 * The file provider for AnkiDroid. It names the MIME type itself: AnkiDroid takes the 155 * extension of its copy from the type, and the map of Android would call an `.m4a` an mp3. 156 */ 157 class MediaProvider : FileProvider() { 158 override fun getType(uri: Uri): String? = 159 uri.lastPathSegment?.substringAfterLast('.', "")?.let { Media.mimeType(it) } ?: super.getType(uri) 160 }