Skip to content

Commit 1058652

Browse files
committed
Minor edits
* While testing disk cache I noticed coil3 does not automatically disk cache images that have been resized, the Fetcher is wholly responsible for using the disk cache. So I rewrote the chain to use 1 fetcher per task to allow scoping disk cache logic. * To allow use of the disk cache in album art provider, I made AlbumArtProvider also use coil to load the jpeg, using a fake decoder that gives out the JPEG data if it's JPEG and only recompresses if data is in wrong format * Then I inlined the ThumbnailUtils code in order to not decode JPEG, and let coil do it, so that we can skip the re-encoding for embedded/sidecar JPEG files also in high definition case * Then I noticed MediaStore already has its own disk cache for images and we don't actually need one ourselves so I got rid of our own and made it delegate to proper disk cache as needed * Also I removed the Wear OS workaround because I don't like decoding into byte[] due to memory usage, I'd at least try to investigate more before committing it
1 parent 0735b85 commit 1058652

8 files changed

Lines changed: 564 additions & 598 deletions

File tree

app/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ dependencies {
305305
implementation("androidx.fragment:fragment-ktx:1.8.9")
306306
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4")
307307
implementation("androidx.mediarouter:mediarouter:1.8.1")
308-
implementation("io.github.nift4.mediastorecompat:mediastorecompat:1.0.0-alpha31")
308+
implementation("io.github.nift4.mediastorecompat:mediastorecompat:1.0.0-alpha32")
309309
val media3Version = "1.10.1"
310310
implementation("androidx.media3:media3-common-ktx:$media3Version")
311311
implementation("androidx.media3:media3-exoplayer:$media3Version")
Lines changed: 239 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,271 @@
11
package org.akanework.gramophone.logic
22

3+
import android.content.ClipDescription
34
import android.content.ContentProvider
5+
import android.content.ContentResolver
46
import android.content.ContentValues
7+
import android.content.Context
8+
import android.content.res.AssetFileDescriptor
59
import android.database.Cursor
10+
import android.graphics.Bitmap
11+
import android.graphics.Point
612
import android.net.Uri
13+
import android.os.Bundle
14+
import android.os.CancellationSignal
15+
import android.os.OperationCanceledException
716
import android.os.ParcelFileDescriptor
817
import 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
930
import kotlinx.coroutines.CoroutineScope
1031
import 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
1236
import 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
1443
import 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
*/
2751
class 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

Comments
 (0)