Recently Written · git

subread-anki

One tap makes an Anki card on Android: word, definition, sentence, screenshot, audio. Works with any app, with SubRead Dictionary and SubRead Overlay.

git clone https://github.com/equwal/subread-anki

Log | Files | Refs


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 }