Skip to content

Commit 3e2cdbe

Browse files
committed
Overengineering much...?
1 parent 17a55df commit 3e2cdbe

3 files changed

Lines changed: 182 additions & 68 deletions

File tree

app/src/main/java/org/akanework/gramophone/logic/GramophoneAlbumArtProvider.kt

Lines changed: 133 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,17 @@ import android.database.Cursor
1010
import android.graphics.Bitmap
1111
import android.graphics.Point
1212
import android.net.Uri
13+
import android.os.Build
1314
import android.os.Bundle
1415
import android.os.CancellationSignal
16+
import android.os.Handler
17+
import android.os.HandlerThread
1518
import android.os.OperationCanceledException
1619
import android.os.ParcelFileDescriptor
20+
import android.os.ProxyFileDescriptorCallback
21+
import android.os.storage.StorageManager
1722
import android.util.Log
23+
import androidx.core.content.getSystemService
1824
import androidx.core.os.BundleCompat
1925
import coil3.ColorImage
2026
import coil3.decode.ContentMetadata
@@ -28,19 +34,27 @@ import coil3.toBitmap
2834
import kotlinx.coroutines.CancellationException
2935
import kotlinx.coroutines.CompletableDeferred
3036
import kotlinx.coroutines.CoroutineScope
37+
import kotlinx.coroutines.CoroutineStart
3138
import kotlinx.coroutines.Dispatchers
32-
import kotlinx.coroutines.async
33-
import kotlinx.coroutines.coroutineScope
39+
import kotlinx.coroutines.InternalCoroutinesApi
40+
import kotlinx.coroutines.Job
41+
import kotlinx.coroutines.SupervisorJob
42+
import kotlinx.coroutines.TimeoutCancellationException
43+
import kotlinx.coroutines.cancel
3444
import kotlinx.coroutines.currentCoroutineContext
45+
import kotlinx.coroutines.ensureActive
3546
import kotlinx.coroutines.job
3647
import kotlinx.coroutines.launch
3748
import kotlinx.coroutines.runBlocking
49+
import kotlinx.coroutines.withTimeout
3850
import okio.buffer
3951
import okio.sink
4052
import org.akanework.gramophone.BuildConfig
4153
import org.akanework.gramophone.logic.utils.CoilArtPipeline
42-
import java.io.FileOutputStream
54+
import java.io.ByteArrayOutputStream
4355
import java.io.IOException
56+
import java.io.OutputStream
57+
import kotlin.time.Duration.Companion.milliseconds
4458

4559
/**
4660
* ContentProvider that serves album artwork to external processes (e.g. Android Auto).
@@ -87,11 +101,22 @@ class GramophoneAlbumArtProvider : ContentProvider() {
87101

88102
override fun onCreate() = true
89103

90-
private suspend fun CoroutineScope.openFileCommon(uri: Uri, size: Point?,
91-
allowPartial: Boolean): AssetFileDescriptor? {
104+
@OptIn(InternalCoroutinesApi::class)
105+
private suspend fun openFileCommon(uri: Uri, size: Point?, allowPartial: Boolean): AssetFileDescriptor? {
92106
val context = context!!
93107
val cfd = CompletableDeferred<AssetFileDescriptor?>()
94-
launch(Dispatchers.IO) {
108+
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
109+
currentCoroutineContext().job.invokeOnCompletion {
110+
if (it is CancellationException) scope.cancel(it)
111+
else if (it != null) scope.cancel("Error in parent block", it)
112+
}
113+
scope.launch {
114+
currentCoroutineContext().job.invokeOnCompletion {
115+
if (!cfd.isCompleted) {
116+
cfd.completeExceptionally(it
117+
?: IllegalStateException("Completed job without setting file descriptor"))
118+
}
119+
}
95120
context.imageLoader.execute(
96121
ImageRequest.Builder(context)
97122
.data(uri)
@@ -110,15 +135,15 @@ class GramophoneAlbumArtProvider : ContentProvider() {
110135
// we can write it there for benefit of UI code somewhere else.
111136
.memoryCachePolicy(CachePolicy.WRITE_ONLY)
112137
.allowHardware(false)
113-
.decoderFactory { result, _, _ ->
138+
.decoderFactory { result, options, _ ->
114139
val src = result.source.source()
115140
src.peek().let { peekSrc ->
116141
if (peekSrc.readByte() != 0xff.toByte() ||
117142
peekSrc.readByte() != 0xd8.toByte() ||
118143
peekSrc.readByte() != 0xff.toByte() ||
119144
run {
120-
val peek = peekSrc.readByte().toInt()
121-
peek != 0xdb && peek !in 0xe0..0xef
145+
val peek = peekSrc.readByte()
146+
peek != 0xdb.toByte() && peek !in 0xe0.toByte()..0xef.toByte()
122147
}
123148
) {
124149
// Not JPEG. We'll have to re-encode to JPEG (here done in target)
@@ -140,18 +165,11 @@ class GramophoneAlbumArtProvider : ContentProvider() {
140165
.parcelFileDescriptor.dup(), it.startOffset, it.declaredLength))
141166
}
142167
} 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()
168+
writeDataCommon(cfd, scope, options.context) {
169+
if (it != null) {
170+
it.sink().buffer().writeAll(src); null
171+
} else
172+
src.readByteArray()
155173
}
156174
}
157175
// shareable is false to avoid writing dummy to memory cache
@@ -161,18 +179,18 @@ class GramophoneAlbumArtProvider : ContentProvider() {
161179
)
162180
}
163181
}
164-
.target(onSuccess = {
182+
.target(onSuccess = { image ->
165183
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)
184+
launch(start = CoroutineStart.ATOMIC) {
185+
writeDataCommon(cfd, scope, context) {
186+
val os = it ?: ByteArrayOutputStream()
187+
image.toBitmap().compress(Bitmap.CompressFormat.JPEG,
188+
95, os)
189+
if (it != null)
190+
null
191+
else
192+
(os as ByteArrayOutputStream).toByteArray()
173193
}
174-
} finally {
175-
pipe[1].close()
176194
}
177195
}
178196
})
@@ -196,6 +214,90 @@ class GramophoneAlbumArtProvider : ContentProvider() {
196214
return cfd.await()
197215
}
198216

217+
@OptIn(InternalCoroutinesApi::class)
218+
private suspend fun writeDataCommon(cfd: CompletableDeferred<AssetFileDescriptor?>,
219+
scope: CoroutineScope, context: Context,
220+
callback: (OutputStream?) -> ByteArray?) {
221+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
222+
currentCoroutineContext().job.ensureActive()
223+
val bytes = callback(null)!!
224+
currentCoroutineContext().job.ensureActive()
225+
val ht = HandlerThread("pfd_${System.currentTimeMillis()}")
226+
ht.start()
227+
// Specifically ImageDecoder on Android P or later needs a seekable file descriptor
228+
val pfd = context.getSystemService<StorageManager>()!!.openProxyFileDescriptor(
229+
ParcelFileDescriptor.MODE_READ_ONLY,
230+
object : ProxyFileDescriptorCallback() {
231+
override fun onGetSize(): Long {
232+
return bytes.size.toLong()
233+
}
234+
235+
override fun onRead(offset: Long, size: Int, data: ByteArray): Int {
236+
val offset = offset.toInt()
237+
var size = size
238+
if (offset + size > bytes.size) {
239+
size = bytes.size - offset
240+
}
241+
System.arraycopy(bytes, offset, data, 0,
242+
size)
243+
return size
244+
}
245+
246+
override fun onRelease() {
247+
ht.quitSafely()
248+
}
249+
}, Handler(ht.looper)
250+
)
251+
cfd.complete(
252+
AssetFileDescriptor(
253+
pfd, 0, AssetFileDescriptor.UNKNOWN_LENGTH
254+
)
255+
)
256+
return
257+
}
258+
val pipe = ParcelFileDescriptor.createPipe()
259+
cfd.complete(
260+
AssetFileDescriptor(
261+
pipe[0], 0, AssetFileDescriptor.UNKNOWN_LENGTH
262+
)
263+
)
264+
val disposable = scope.coroutineContext[Job]!!.invokeOnCompletion(
265+
onCancelling = true) {
266+
if (it is CancellationException) {
267+
// this will interrupt write to ensure we don't block forever
268+
pipe[1].close()
269+
}
270+
}
271+
try {
272+
withTimeout((30 * 1000).milliseconds) {
273+
launch(Dispatchers.IO, start = CoroutineStart.ATOMIC) {
274+
ParcelFileDescriptor.AutoCloseOutputStream(pipe[1])
275+
.use { os ->
276+
// only check for cancel here to ensure close
277+
ensureActive()
278+
try {
279+
callback(os)
280+
} catch (e: Exception) {
281+
try {
282+
ensureActive()
283+
} catch (e2: CancellationException) {
284+
Log.w(TAG, "eating exception due" +
285+
" to cancel()", e)
286+
e2.addSuppressed(e)
287+
throw e2
288+
}
289+
throw e
290+
}
291+
}
292+
disposable.dispose()
293+
}.join()
294+
}
295+
} catch (_: TimeoutCancellationException) {
296+
// If the other side ain't done reading after 30s, give up
297+
scope.cancel()
298+
}
299+
}
300+
199301
private fun openFileCommon(uri: Uri, size: Point?, signal: CancellationSignal?,
200302
allowPartial: Boolean): AssetFileDescriptor? {
201303
if (uri.pathSegments.size < 2)

app/src/main/java/org/akanework/gramophone/logic/GramophoneApplication.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,8 +318,8 @@ class GramophoneApplication : Application(), SingletonImageLoader.Factory,
318318
add(CoilArtPipeline.AudioCoverKeyer())
319319
add(AndroidUriMapper())
320320
add(CoilArtPipeline.ThumbnailMapper())
321-
add(CoilArtPipeline.AlbumThumbnailMapper())
322321
add(CoilArtPipeline.AudioCoverMapper())
322+
add(CoilArtPipeline.AlbumThumbnailMapper())
323323
add(CoilArtPipeline.ThumbnailFetcherFactory())
324324
add(CoilArtPipeline.AlbumThumbnailFetcherFactory())
325325
add(CoilArtPipeline.SongCoverFetcherFactory())

app/src/main/java/org/akanework/gramophone/logic/utils/CoilArtPipeline.kt

Lines changed: 48 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,22 @@ import kotlin.math.min
4848

4949
object CoilArtPipeline {
5050

51-
private fun isSmallSize(context: Context, size: Size): Boolean {
52-
if (size.width !is Dimension.Pixels || size.height !is Dimension.Pixels) return false
53-
val w = (size.width as Dimension.Pixels).px
54-
val h = (size.height as Dimension.Pixels).px
51+
private fun getSmallSize(context: Context): Point {
5552
if (hasScopedStorageV1()) {
5653
// refer to mThumbSize in MediaProvider.java
5754
val metrics = context.applicationContext.resources.displayMetrics
5855
val thumbSize = min(metrics.widthPixels, metrics.heightPixels) / 2
59-
return w <= thumbSize && h <= thumbSize
56+
return Point(thumbSize, thumbSize)
6057
}
61-
return w <= 512 && h <= 320
58+
return Point(512, 320)
59+
}
60+
61+
private fun isSmallSize(context: Context, size: Size): Boolean {
62+
if (size.width !is Dimension.Pixels || size.height !is Dimension.Pixels) return false
63+
val w = (size.width as Dimension.Pixels).px
64+
val h = (size.height as Dimension.Pixels).px
65+
val smallSize = getSmallSize(context)
66+
return w <= smallSize.x && h <= smallSize.y
6267
}
6368

6469
data class AlbumThumbnailData(val songUri: android.net.Uri, val imageFileName: String)
@@ -95,7 +100,7 @@ object CoilArtPipeline {
95100
throw IllegalArgumentException("Bad data $data")
96101
val imgUri = MediaStoreCompat.getMediaUriForFile(options.context,
97102
imgFile.absolutePath)
98-
val data = if (isSmallSize(options.context, options.size))
103+
val data = if (isSmallSize(options.context, options.size) && false) // TODO(ASAP)
99104
LoadThumbnailData(imgUri)
100105
else
101106
imgUri
@@ -131,7 +136,7 @@ object CoilArtPipeline {
131136
override fun map(data: Uri, options: Options): LoadThumbnailData? {
132137
return if (data.scheme == ContentResolver.SCHEME_CONTENT &&
133138
data.authority == GramophoneAlbumArtProvider.PROVIDER_AUTHORITY &&
134-
data.pathSegments.first() == "song" &&
139+
data.pathSegments.first() == "song" && false && // TODO(ASAP)
135140
isSmallSize(options.context, options.size)) {
136141
LoadThumbnailData(ContentUris.withAppendedId(
137142
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
@@ -161,8 +166,11 @@ object CoilArtPipeline {
161166
val width = options.size.width.let {
162167
if (it is Dimension.Pixels) it.px else null
163168
}
169+
// Size is REQUIRED to get an image!
164170
if (height != null && width != null)
165171
putParcelable(ContentResolver.EXTRA_SIZE, Point(width, height))
172+
else
173+
putParcelable(ContentResolver.EXTRA_SIZE, getSmallSize(options.context))
166174
})
167175
checkNotNull(afd) { "Unable to open '${data.uri}' as thumbnail." }
168176

@@ -192,9 +200,8 @@ object CoilArtPipeline {
192200
class AudioCoverMapper : Mapper<Uri, LoadAudioCoverData> {
193201
override fun map(data: Uri, options: Options): LoadAudioCoverData? {
194202
return if (data.scheme == ContentResolver.SCHEME_CONTENT &&
195-
data.authority == GramophoneAlbumArtProvider.PROVIDER_AUTHORITY) {
196-
if (data.pathSegments.first() != "song")
197-
throw IllegalArgumentException("Invalid uri: $data")
203+
data.authority == GramophoneAlbumArtProvider.PROVIDER_AUTHORITY &&
204+
data.pathSegments.first() == "song") {
198205
LoadAudioCoverData(data.pathSegments[1].toLong())
199206
} else null
200207
}
@@ -209,33 +216,38 @@ object CoilArtPipeline {
209216
return Fetcher {
210217
val uri = ContentUris.withAppendedId(MediaStore.Audio.Media
211218
.EXTERNAL_CONTENT_URI, data.id)
212-
val afd = MediaStoreCompat.openAssetFileDescriptor(options.context,
213-
uri, "r")!!
214-
val retriever = MediaMetadataRetriever()
215-
try {
216-
if (afd.declaredLength == AssetFileDescriptor.UNKNOWN_LENGTH &&
217-
afd.startOffset == 0L)
218-
retriever.setDataSource(afd.fileDescriptor)
219-
else
220-
retriever.setDataSource(afd.fileDescriptor, afd.startOffset,
221-
afd.length)
222-
retriever.embeddedPicture?.let { raw ->
223-
return@Fetcher SourceFetchResult(
224-
source = ImageSource(
225-
Buffer().write(raw),
226-
options.fileSystem,
227-
metadata = null,
228-
),
229-
mimeType = null,
230-
dataSource = DataSource.DISK,
219+
MediaStoreCompat.openAssetFileDescriptor(options.context,
220+
uri, "r")!!.use { afd ->
221+
val retriever = MediaMetadataRetriever()
222+
try {
223+
if (afd.declaredLength == AssetFileDescriptor.UNKNOWN_LENGTH &&
224+
afd.startOffset == 0L
231225
)
226+
retriever.setDataSource(afd.fileDescriptor)
227+
else
228+
retriever.setDataSource(
229+
afd.fileDescriptor, afd.startOffset,
230+
afd.length
231+
)
232+
retriever.embeddedPicture?.let { raw ->
233+
return@Fetcher SourceFetchResult(
234+
source = ImageSource(
235+
Buffer().write(raw),
236+
options.fileSystem,
237+
metadata = null,
238+
),
239+
mimeType = null,
240+
dataSource = DataSource.DISK,
241+
)
242+
}
243+
} catch (e: RuntimeException) {
244+
throw IOException("Failed to create thumbnail", e)
245+
} finally {
246+
try {
247+
retriever.close()
248+
} catch (_: Exception) {
249+
}
232250
}
233-
} catch (e: RuntimeException) {
234-
throw IOException("Failed to create thumbnail", e)
235-
} finally {
236-
try {
237-
retriever.close()
238-
} catch (_: Exception) {}
239251
}
240252
if (hasScopedStorageWithMediaTypes() && !options.context.hasImagePermission()) {
241253
return@Fetcher continueFetchingOrFail(LoadThumbnailData(uri),

0 commit comments

Comments
 (0)