11package org.akanework.gramophone.logic
22
3+ import android.content.ClipDescription
34import android.content.ContentProvider
5+ import android.content.ContentResolver
46import android.content.ContentValues
7+ import android.content.Context
8+ import android.content.res.AssetFileDescriptor
59import android.database.Cursor
10+ import android.graphics.Bitmap
11+ import android.graphics.Point
612import android.net.Uri
13+ import android.os.Bundle
14+ import android.os.CancellationSignal
15+ import android.os.OperationCanceledException
716import android.os.ParcelFileDescriptor
817import android.util.Log
18+ import androidx.core.os.BundleCompat
19+ import coil3.ColorImage
20+ import coil3.decode.ContentMetadata
21+ import coil3.decode.DecodeResult
22+ import coil3.decode.Decoder
23+ import coil3.imageLoader
24+ import coil3.request.CachePolicy
25+ import coil3.request.ImageRequest
26+ import coil3.request.allowHardware
27+ import coil3.toBitmap
28+ import kotlinx.coroutines.CancellationException
29+ import kotlinx.coroutines.CompletableDeferred
930import kotlinx.coroutines.CoroutineScope
1031import kotlinx.coroutines.Dispatchers
11- import kotlinx.coroutines.SupervisorJob
32+ import kotlinx.coroutines.async
33+ import kotlinx.coroutines.coroutineScope
34+ import kotlinx.coroutines.currentCoroutineContext
35+ import kotlinx.coroutines.job
1236import kotlinx.coroutines.launch
13- import org.akanework.gramophone.logic.utils.ArtResolver
37+ import kotlinx.coroutines.runBlocking
38+ import okio.buffer
39+ import okio.sink
40+ import org.akanework.gramophone.BuildConfig
41+ import org.akanework.gramophone.logic.utils.CoilArtPipeline
42+ import java.io.FileOutputStream
1443import java.io.IOException
1544
1645/* *
1746 * ContentProvider that serves album artwork to external processes (e.g. Android Auto).
1847 *
19- * External processes cannot resolve Gramophone's internal URI schemes
20- * (`gramophoneSongCover://`, `gramophoneAlbumCover://`). This provider acts as a bridge,
21- * using the shared [ArtResolver] and Coil's disk cache to resolve, cache and serve the artwork
22- * over a standard `content://` URI.
23- *
2448 * URI format: `content://org.akanework.gramophone.albumart/{type}/{id}/{encodedPath}`
2549 * where `type` is "song" or "album".
2650 */
2751class GramophoneAlbumArtProvider : ContentProvider () {
2852
2953 companion object {
3054 private const val TAG = " GramophoneArtProvider"
55+
56+ /* * Authority for the ContentProvider that serves art to external processes. */
57+ const val PROVIDER_AUTHORITY = " ${BuildConfig .APPLICATION_ID } .albumart"
58+
59+ /* *
60+ * Builds a `content://` URI pointing to [GramophoneAlbumArtProvider].
61+ *
62+ * @param id the ID of any song (!!! not album ID)
63+ * @param imageName the image file's name
64+ */
65+ fun buildAlbumUri (id : Long , imageName : String ): Uri =
66+ Uri .Builder ()
67+ .scheme(ContentResolver .SCHEME_CONTENT )
68+ .authority(PROVIDER_AUTHORITY )
69+ .appendPath(" album" )
70+ .appendPath(id.toString())
71+ .appendPath(imageName)
72+ .build()
73+
74+ /* *
75+ * Builds a `content://` URI pointing to [GramophoneAlbumArtProvider].
76+ *
77+ * @param id the song ID
78+ */
79+ fun buildSongUri (id : Long ): Uri =
80+ Uri .Builder ()
81+ .scheme(ContentResolver .SCHEME_CONTENT )
82+ .authority(PROVIDER_AUTHORITY )
83+ .appendPath(" song" )
84+ .appendPath(id.toString())
85+ .build()
3186 }
3287
33- private val providerScope = CoroutineScope (Dispatchers .IO + SupervisorJob ())
3488 override fun onCreate () = true
3589
36- override fun openFile (uri : Uri , mode : String ): ParcelFileDescriptor ? {
37- val context = context ? : return null
38- val app = (context.applicationContext as ? GramophoneApplication ) ? : return null
39-
40- try {
41- val pipe = ParcelFileDescriptor .createPipe()
42- val readSide = pipe[0 ]
43- val writeSide = pipe[1 ]
44-
45- providerScope.launch(Dispatchers .IO ) {
46- try {
47- val size = 1024
48- val candidates = ArtResolver .getResolutionList(uri, size)
49- var found = false
50- for (candidate in candidates) {
51- val art = ArtResolver .openResourceStream(context, candidate, size)
52- if (art != null ) {
53- art.stream.use { input ->
54- ParcelFileDescriptor .AutoCloseOutputStream (writeSide).use { output ->
55- input.copyTo(output)
56- }
90+ private suspend fun CoroutineScope.openFileCommon (uri : Uri , size : Point ? ,
91+ allowPartial : Boolean ): AssetFileDescriptor ? {
92+ val context = context!!
93+ val cfd = CompletableDeferred <AssetFileDescriptor ?>()
94+ launch(Dispatchers .IO ) {
95+ context.imageLoader.execute(
96+ ImageRequest .Builder (context)
97+ .data(uri)
98+ .let {
99+ // size will be used to decide if underlying file is small or full size, and if
100+ // we can't use the dummy decoder, we will also get the data resized by Coil.
101+ if (size != null )
102+ it.size(size.x, size.y)
103+ // The URI is explicitly designed as the place to get high-quality artwork in
104+ // MediaMetadata.METADATA_KEY_ALBUM_ART javadoc, so don't default to thumbnail.
105+ else it
106+ }
107+ // Memory cache stores Bitmap, not compressed data, so we shouldn't read from
108+ // it (otherwise our dummy decoder wouldn't get any data ever, and we would
109+ // recompress decoded bitmap, wasting battery). But if we do produce a Bitmap,
110+ // we can write it there for benefit of UI code somewhere else.
111+ .memoryCachePolicy(CachePolicy .WRITE_ONLY )
112+ .allowHardware(false )
113+ .decoderFactory { result, _, _ ->
114+ val src = result.source.source()
115+ src.peek().let { peekSrc ->
116+ if (peekSrc.readByte() != 0xff .toByte() ||
117+ peekSrc.readByte() != 0xd8 .toByte() ||
118+ peekSrc.readByte() != 0xff .toByte() ||
119+ run {
120+ val peek = peekSrc.readByte().toInt()
121+ peek != 0xdb && peek !in 0xe0 .. 0xef
57122 }
58- found = true
59- break
123+ ) {
124+ // Not JPEG. We'll have to re-encode to JPEG (here done in target)
125+ return @decoderFactory null
60126 }
61127 }
62- if (! found) {
63- Log .w(TAG , " No artwork found for URI: $uri " )
64- try { writeSide.close() } catch (_: Exception ) {}
128+ Decoder {
129+ // We can send this stream of bytes as is!
130+ if (result.source.metadata is ContentMetadata && (allowPartial ||
131+ (result.source.metadata as ContentMetadata )
132+ .assetFileDescriptor.let {
133+ it.declaredLength ==
134+ AssetFileDescriptor .UNKNOWN_LENGTH &&
135+ it.startOffset == 0L
136+ })
137+ ) {
138+ (result.source.metadata as ContentMetadata ).assetFileDescriptor.let {
139+ cfd.complete(AssetFileDescriptor (it
140+ .parcelFileDescriptor.dup(), it.startOffset, it.declaredLength))
141+ }
142+ } else {
143+ val pipe = ParcelFileDescriptor .createPipe()
144+ cfd.complete(
145+ AssetFileDescriptor (
146+ pipe[0 ], 0 ,
147+ AssetFileDescriptor .UNKNOWN_LENGTH
148+ )
149+ )
150+ try {
151+ FileOutputStream (pipe[1 ].fileDescriptor).sink()
152+ .buffer().writeAll(src)
153+ } finally {
154+ pipe[1 ].close()
155+ }
156+ }
157+ // shareable is false to avoid writing dummy to memory cache
158+ DecodeResult (
159+ ColorImage (0 , shareable = false ),
160+ false
161+ )
65162 }
66- } catch (e: Exception ) {
67- Log .e(TAG , " Error streaming artwork for URI: $uri " , e)
68- try { writeSide.close() } catch (_: Exception ) {}
69163 }
164+ .target(onSuccess = {
165+ if (! cfd.isCompleted) {
166+ val pipe = ParcelFileDescriptor .createPipe()
167+ cfd.complete(AssetFileDescriptor (
168+ pipe[0 ], 0 , AssetFileDescriptor .UNKNOWN_LENGTH ))
169+ try {
170+ FileOutputStream (pipe[1 ].fileDescriptor).use { os ->
171+ it.toBitmap().compress(
172+ Bitmap .CompressFormat .JPEG , 95 , os)
173+ }
174+ } finally {
175+ pipe[1 ].close()
176+ }
177+ }
178+ })
179+ .listener(onError = { _, result ->
180+ if (cfd.isCompleted) {
181+ Log .e(TAG , " Listener errored after successful decode" ,
182+ result.throwable)
183+ return @listener
184+ }
185+ if (result.throwable is CoilArtPipeline .NoAlbumArtException || result.throwable
186+ is IOException && (result.throwable.message == " No album art found"
187+ || result.throwable.message == " No embedded album art found"
188+ || result.throwable.message == " No thumbnails in Downloads directories"
189+ || result.throwable.message == " No thumbnails in top-level directories" ))
190+ cfd.complete(null )
191+ else
192+ cfd.completeExceptionally(result.throwable)
193+ })
194+ .build())
195+ }
196+ return cfd.await()
197+ }
198+
199+ private fun openFileCommon (uri : Uri , size : Point ? , signal : CancellationSignal ? ,
200+ allowPartial : Boolean ): AssetFileDescriptor ? {
201+ if (uri.pathSegments.size < 2 )
202+ throw IllegalArgumentException (" Invalid uri: $uri " )
203+ return runBlocking {
204+ signal?.throwIfCanceled()
205+ val job = currentCoroutineContext().job
206+ signal?.setOnCancelListener {
207+ job.cancel()
70208 }
71- return readSide
72- } catch (e: IOException ) {
73- Log .e(TAG , " Failed to create pipe for URI: $uri " , e)
74- return null
209+ openFileCommon(uri, size, allowPartial)
210+ }
211+ }
212+
213+ override fun openFile (
214+ uri : Uri ,
215+ mode : String ,
216+ signal : CancellationSignal ?
217+ ): ParcelFileDescriptor ? {
218+ try {
219+ if (mode != " r" )
220+ throw IllegalArgumentException (" Unsupported mode $mode , this provider is read-only" )
221+ val afd = openFileCommon(uri, null , signal, false )
222+ if (afd != null && (afd.declaredLength != AssetFileDescriptor .UNKNOWN_LENGTH ||
223+ afd.startOffset != 0L ))
224+ throw IllegalStateException (" Logic bug in album art provider, got partial file..." )
225+ return afd?.parcelFileDescriptor
226+ } catch (_: CancellationException ) {
227+ throw OperationCanceledException ()
228+ } catch (e: Exception ) {
229+ Log .e(TAG , " Failed to load $uri " , e)
230+ throw e
231+ }
232+ }
233+
234+ override fun openAssetFile (
235+ uri : Uri ,
236+ mode : String ,
237+ signal : CancellationSignal ?
238+ ): AssetFileDescriptor ? {
239+ try {
240+ if (mode != " r" )
241+ throw IllegalArgumentException (" Unsupported mode $mode , this provider is read-only" )
242+ return openFileCommon(uri, null , signal, true )
243+ } catch (_: CancellationException ) {
244+ throw OperationCanceledException ()
245+ } catch (e: Exception ) {
246+ Log .e(TAG , " Failed to load $uri " , e)
247+ throw e
248+ }
249+ }
250+
251+ override fun openTypedAssetFile (
252+ uri : Uri ,
253+ mimeTypeFilter : String ,
254+ opts : Bundle ? ,
255+ signal : CancellationSignal ?
256+ ): AssetFileDescriptor ? {
257+ try {
258+ return if (ClipDescription .compareMimeTypes(" image/jpeg" ,
259+ mimeTypeFilter)) {
260+ val size = opts?.let { BundleCompat .getParcelable(it,
261+ ContentResolver .EXTRA_SIZE , Point ::class .java) }
262+ openFileCommon(uri, size, signal, true )
263+ } else throw IllegalArgumentException (" Unsupported MIME filter $mimeTypeFilter " )
264+ } catch (_: CancellationException ) {
265+ throw OperationCanceledException ()
266+ } catch (e: Exception ) {
267+ Log .e(TAG , " Failed to load $uri " , e)
268+ throw e
75269 }
76270 }
77271
@@ -85,18 +279,19 @@ class GramophoneAlbumArtProvider : ContentProvider() {
85279
86280 override fun getType (uri : Uri ): String = " image/jpeg"
87281
88- override fun insert (uri : Uri , values : ContentValues ? ): Uri ? = null
282+ override fun insert (uri : Uri , values : ContentValues ? ): Uri =
283+ throw UnsupportedOperationException ()
89284
90285 override fun delete (
91286 uri : Uri ,
92287 selection : String? ,
93288 selectionArgs : Array <out String >?
94- ): Int = 0
289+ ): Int = throw UnsupportedOperationException ()
95290
96291 override fun update (
97292 uri : Uri ,
98293 values : ContentValues ? ,
99294 selection : String? ,
100295 selectionArgs : Array <out String >?
101- ): Int = 0
296+ ): Int = throw UnsupportedOperationException ()
102297}
0 commit comments