diff --git a/.github/nightly-notes/2026-10-21-fix-widget-preview-retriever-crashes.md b/.github/nightly-notes/2026-10-21-fix-widget-preview-retriever-crashes.md new file mode 100644 index 0000000000..d4c909c835 --- /dev/null +++ b/.github/nightly-notes/2026-10-21-fix-widget-preview-retriever-crashes.md @@ -0,0 +1,3 @@ +## Fixed +- Flow no longer crashes at launch in Android's Private Space (#1357) +- Fixed a crash while Flow loads covers and thumbnails for files on your phone (#1353) diff --git a/app/src/main/java/io/github/aedev/flow/data/localmedia/MediaRetrieverDecoderFactory.kt b/app/src/main/java/io/github/aedev/flow/data/localmedia/MediaRetrieverDecoderFactory.kt new file mode 100644 index 0000000000..f87fa1572b --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/data/localmedia/MediaRetrieverDecoderFactory.kt @@ -0,0 +1,25 @@ +package io.github.aedev.flow.data.localmedia + +import coil3.ImageLoader +import coil3.decode.DecodeResult +import coil3.decode.Decoder +import coil3.fetch.SourceFetchResult +import coil3.request.Options +import io.github.aedev.flow.utils.PerformanceDispatcher +import kotlinx.coroutines.withContext + +/** Runs a `MediaMetadataRetriever`-backed Coil decoder on [PerformanceDispatcher.mediaRetriever]. */ +class MediaRetrieverDecoderFactory( + private val delegate: Decoder.Factory, +) : Decoder.Factory { + override fun create( + result: SourceFetchResult, + options: Options, + imageLoader: ImageLoader, + ): Decoder? { + val decoder = delegate.create(result, options, imageLoader) ?: return null + return object : Decoder { + override suspend fun decode(): DecodeResult? = withContext(PerformanceDispatcher.mediaRetriever) { decoder.decode() } + } + } +} diff --git a/app/src/main/java/io/github/aedev/flow/data/localmedia/MediaStoreThumbnailFetcher.kt b/app/src/main/java/io/github/aedev/flow/data/localmedia/MediaStoreThumbnailFetcher.kt index 2050ae7167..96a885f4fa 100644 --- a/app/src/main/java/io/github/aedev/flow/data/localmedia/MediaStoreThumbnailFetcher.kt +++ b/app/src/main/java/io/github/aedev/flow/data/localmedia/MediaStoreThumbnailFetcher.kt @@ -9,7 +9,7 @@ import coil3.fetch.FetchResult import coil3.fetch.Fetcher import coil3.fetch.ImageFetchResult import coil3.request.Options -import kotlinx.coroutines.Dispatchers +import io.github.aedev.flow.utils.PerformanceDispatcher import kotlinx.coroutines.withContext import java.io.IOException @@ -24,7 +24,7 @@ class MediaStoreThumbnailFetcher( private val uri: android.net.Uri, ) : Fetcher { override suspend fun fetch(): FetchResult = - withContext(Dispatchers.IO) { + withContext(PerformanceDispatcher.mediaRetriever) { val bitmap = MediaStoreThumbnails.load(context, uri, ARTWORK_SIZE_PX) ?: throw IOException("No artwork for $uri") ImageFetchResult(image = bitmap.asImage(), isSampled = true, dataSource = DataSource.DISK) } diff --git a/app/src/main/java/io/github/aedev/flow/di/AppModule.kt b/app/src/main/java/io/github/aedev/flow/di/AppModule.kt index c351fde184..efcde30d29 100644 --- a/app/src/main/java/io/github/aedev/flow/di/AppModule.kt +++ b/app/src/main/java/io/github/aedev/flow/di/AppModule.kt @@ -14,6 +14,7 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import io.github.aedev.flow.BuildConfig +import io.github.aedev.flow.data.localmedia.MediaRetrieverDecoderFactory import io.github.aedev.flow.data.localmedia.MediaStoreThumbnailFetcher import io.github.aedev.flow.data.recommendation.FlowNeuroEngine import io.github.aedev.flow.innertube.YouTube @@ -51,7 +52,7 @@ object AppModule { .Builder(context) .components { add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient })) - add(VideoFrameDecoder.Factory()) + add(MediaRetrieverDecoderFactory(VideoFrameDecoder.Factory())) add(MediaStoreThumbnailFetcher.Factory(context)) }.memoryCache { MemoryCache diff --git a/app/src/main/java/io/github/aedev/flow/utils/PerformanceDispatcher.kt b/app/src/main/java/io/github/aedev/flow/utils/PerformanceDispatcher.kt index 63cb61d98b..c9e2884ac8 100644 --- a/app/src/main/java/io/github/aedev/flow/utils/PerformanceDispatcher.kt +++ b/app/src/main/java/io/github/aedev/flow/utils/PerformanceDispatcher.kt @@ -72,6 +72,14 @@ object PerformanceDispatcher { */ val diskIO: CoroutineDispatcher = Dispatchers.IO + /** + * Fan-out work on `MediaMetadataRetriever`. Every retriever's `release()` and finalizer share one + * process-wide native lock, and a release waits on mediaserver while holding it; dozens of image + * loads at once kept the finalizer daemon queued past its 10 s watchdog, which kills the app (#1353). + */ + val mediaRetriever: CoroutineDispatcher = Dispatchers.IO.limitedParallelism(MEDIA_RETRIEVER_PARALLELISM) + private const val MEDIA_RETRIEVER_PARALLELISM = 2 + /** * Main thread dispatcher for UI updates */ diff --git a/app/src/main/java/io/github/aedev/flow/widget/core/FlowWidgets.kt b/app/src/main/java/io/github/aedev/flow/widget/core/FlowWidgets.kt index 5cd38823fe..7516806fca 100644 --- a/app/src/main/java/io/github/aedev/flow/widget/core/FlowWidgets.kt +++ b/app/src/main/java/io/github/aedev/flow/widget/core/FlowWidgets.kt @@ -80,9 +80,16 @@ object FlowWidgets { val stamp = "${BuildConfig.VERSION_CODE}|$themeSignature" if (context.lastWidgetPreviewStamp() == stamp) return val manager = GlanceAppWidgetManager(context) + // Some profiles, such as a Private Space, reject our own providers as invalid (#1357), and + // Glance passes that binder exception straight through. val published = - catalog.all { entry -> - manager.setWidgetPreviews(entry.receiver) == GlanceAppWidgetManager.SET_WIDGET_PREVIEWS_RESULT_SUCCESS + try { + catalog.all { entry -> + manager.setWidgetPreviews(entry.receiver) == GlanceAppWidgetManager.SET_WIDGET_PREVIEWS_RESULT_SUCCESS + } + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Widget previews rejected: ${e.message}") + false } Log.i(TAG, "Widget previews published: $published") if (published) context.writeWidgetPreviewStamp(stamp)