From dbb8797723ada755dcf710db25692466a8f799e9 Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:57:20 +0100 Subject: [PATCH 1/6] feat(android): enhance playback style detection using MIME type --- .../alextran/immich/sync/MessagesImplBase.kt | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index 0cc642c862311..ea9537753ab2e 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -70,6 +70,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { add(MediaStore.MediaColumns.DATE_ADDED) add(MediaStore.MediaColumns.DATE_MODIFIED) add(MediaStore.Files.FileColumns.MEDIA_TYPE) + add(MediaStore.MediaColumns.MIME_TYPE) add(MediaStore.MediaColumns.BUCKET_ID) add(MediaStore.MediaColumns.WIDTH) add(MediaStore.MediaColumns.HEIGHT) @@ -131,6 +132,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED) val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE) + val mimeTypeColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE) val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID) val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH) val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT) @@ -176,8 +178,9 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { val orientation = c.getInt(orientationColumn) val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0 + val mimeType = c.getString(mimeTypeColumn) val playbackStyle = detectPlaybackStyle( - numericId, rawMediaType, specialFormatColumn, xmpColumn, c + numericId, rawMediaType, mimeType, specialFormatColumn, xmpColumn, c ) val asset = PlatformAsset( @@ -207,6 +210,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { private fun detectPlaybackStyle( assetId: Long, rawMediaType: Int, + mimeType: String, specialFormatColumn: Int, xmpColumn: Int, cursor: Cursor @@ -255,20 +259,28 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { return PlatformAssetPlaybackStyle.LIVE_PHOTO } - try { - ctx.contentResolver.openInputStream(uri)?.use { stream -> + // Use MIME type to detect animated formats without opening file streams + if (mimeType == "image/gif") { + return PlatformAssetPlaybackStyle.IMAGE_ANIMATED + } + + // Only WebP needs a stream check to distinguish static vs animated + if (mimeType == "image/webp") { + try { val glide = Glide.get(ctx) - val type = ImageHeaderParserUtils.getType( - glide.registry.imageHeaderParsers, - stream, - glide.arrayPool - ) - if (type == ImageHeaderParser.ImageType.GIF || type == ImageHeaderParser.ImageType.ANIMATED_WEBP) { - return PlatformAssetPlaybackStyle.IMAGE_ANIMATED + ctx.contentResolver.openInputStream(uri)?.use { stream -> + val type = ImageHeaderParserUtils.getType( + glide.registry.imageHeaderParsers, + stream, + glide.arrayPool + ) + if (type == ImageHeaderParser.ImageType.ANIMATED_WEBP) { + return PlatformAssetPlaybackStyle.IMAGE_ANIMATED + } } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse image header for asset $assetId", e) } - } catch (e: Exception) { - Log.w(TAG, "Failed to parse image header for asset $assetId", e) } return PlatformAssetPlaybackStyle.IMAGE From 1700760f7a70f34be1506043b8f3540e06c1f7c9 Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:02:23 +0100 Subject: [PATCH 2/6] feat(android): improve playback style detection for GIF and WebP formats --- .../alextran/immich/sync/MessagesImplBase.kt | 56 ++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index ea9537753ab2e..65584e3b9a758 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -235,7 +235,37 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { return PlatformAssetPlaybackStyle.UNKNOWN } - // Pre-API 33 fallback + // GIFs are always animated and cannot be motion photos; no I/O needed + if (mimeType == "image/gif") { + return PlatformAssetPlaybackStyle.IMAGE_ANIMATED + } + + // Only WebP needs a stream check to distinguish static vs animated; + // WebP files are not used as motion photos, so skip XMP detection + if (mimeType == "image/webp") { + val uri = ContentUris.withAppendedId( + MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), + assetId + ) + try { + val glide = Glide.get(ctx) + ctx.contentResolver.openInputStream(uri)?.use { stream -> + val type = ImageHeaderParserUtils.getType( + glide.registry.imageHeaderParsers, + stream, + glide.arrayPool + ) + if (type == ImageHeaderParser.ImageType.ANIMATED_WEBP) { + return PlatformAssetPlaybackStyle.IMAGE_ANIMATED + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse image header for asset $assetId", e) + } + return PlatformAssetPlaybackStyle.IMAGE + } + + // Motion photo detection via XMP (only relevant for JPEG/HEIC) val uri = ContentUris.withAppendedId( MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), assetId @@ -259,30 +289,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { return PlatformAssetPlaybackStyle.LIVE_PHOTO } - // Use MIME type to detect animated formats without opening file streams - if (mimeType == "image/gif") { - return PlatformAssetPlaybackStyle.IMAGE_ANIMATED - } - - // Only WebP needs a stream check to distinguish static vs animated - if (mimeType == "image/webp") { - try { - val glide = Glide.get(ctx) - ctx.contentResolver.openInputStream(uri)?.use { stream -> - val type = ImageHeaderParserUtils.getType( - glide.registry.imageHeaderParsers, - stream, - glide.arrayPool - ) - if (type == ImageHeaderParser.ImageType.ANIMATED_WEBP) { - return PlatformAssetPlaybackStyle.IMAGE_ANIMATED - } - } - } catch (e: Exception) { - Log.w(TAG, "Failed to parse image header for asset $assetId", e) - } - } - return PlatformAssetPlaybackStyle.IMAGE } From 7908f19e3d95effae8f18565daaae8fb0a758365 Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:56:57 +0100 Subject: [PATCH 3/6] fix(android): make playback style detection faster --- .../alextran/immich/sync/MessagesImplBase.kt | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index 65584e3b9a758..e725ca301ad7f 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -70,7 +70,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { add(MediaStore.MediaColumns.DATE_ADDED) add(MediaStore.MediaColumns.DATE_MODIFIED) add(MediaStore.Files.FileColumns.MEDIA_TYPE) - add(MediaStore.MediaColumns.MIME_TYPE) add(MediaStore.MediaColumns.BUCKET_ID) add(MediaStore.MediaColumns.WIDTH) add(MediaStore.MediaColumns.HEIGHT) @@ -82,10 +81,13 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { } if (hasSpecialFormatColumn()) { add(SPECIAL_FORMAT_COLUMN) - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // Fallback: read XMP from MediaStore to detect Motion Photos - // only needed if SPECIAL_FORMAT column isn't available - add(MediaStore.MediaColumns.XMP) + } else { + // fallback to mimetype and xmp for playback style detection on older Android versions + // both only needed if special format column is not available + add(MediaStore.MediaColumns.MIME_TYPE) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + add(MediaStore.MediaColumns.XMP) + } } }.toTypedArray() @@ -132,7 +134,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED) val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE) - val mimeTypeColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE) + val mimeTypeColumn = c.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE) val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID) val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH) val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT) @@ -178,9 +180,8 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { val orientation = c.getInt(orientationColumn) val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0 - val mimeType = c.getString(mimeTypeColumn) val playbackStyle = detectPlaybackStyle( - numericId, rawMediaType, mimeType, specialFormatColumn, xmpColumn, c + numericId, rawMediaType, mimeTypeColumn, specialFormatColumn, xmpColumn, c ) val asset = PlatformAsset( @@ -210,7 +211,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { private fun detectPlaybackStyle( assetId: Long, rawMediaType: Int, - mimeType: String, + mimeTypeColumn: Int, specialFormatColumn: Int, xmpColumn: Int, cursor: Cursor @@ -235,18 +236,21 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { return PlatformAssetPlaybackStyle.UNKNOWN } + val mimeType = if (mimeTypeColumn != -1) cursor.getString(mimeTypeColumn) else null + // GIFs are always animated and cannot be motion photos; no I/O needed if (mimeType == "image/gif") { return PlatformAssetPlaybackStyle.IMAGE_ANIMATED } + val uri = ContentUris.withAppendedId( + MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), + assetId + ) + // Only WebP needs a stream check to distinguish static vs animated; // WebP files are not used as motion photos, so skip XMP detection if (mimeType == "image/webp") { - val uri = ContentUris.withAppendedId( - MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), - assetId - ) try { val glide = Glide.get(ctx) ctx.contentResolver.openInputStream(uri)?.use { stream -> @@ -255,21 +259,18 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { stream, glide.arrayPool ) - if (type == ImageHeaderParser.ImageType.ANIMATED_WEBP) { + // Also check for GIF just in case MIME type is incorrect; Doesn't hurt performance + if (type == ImageHeaderParser.ImageType.ANIMATED_WEBP || type == ImageHeaderParser.ImageType.GIF) { return PlatformAssetPlaybackStyle.IMAGE_ANIMATED } } } catch (e: Exception) { Log.w(TAG, "Failed to parse image header for asset $assetId", e) } + // if mimeType is webp but not animated, its just an image. return PlatformAssetPlaybackStyle.IMAGE } - // Motion photo detection via XMP (only relevant for JPEG/HEIC) - val uri = ContentUris.withAppendedId( - MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), - assetId - ) // Read XMP from cursor (API 30+) or ExifInterface stream (pre-30) val xmp: String? = if (xmpColumn != -1) { From 8e3b6e971ebef56dfb41138df13a67563df03e2c Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:27:06 +0100 Subject: [PATCH 4/6] refactor(android): simplify XMP reading logic for API 29 and below --- .../app/alextran/immich/sync/MessagesImplBase.kt | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index e725ca301ad7f..30879d83f3712 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -272,18 +272,14 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { } - // Read XMP from cursor (API 30+) or ExifInterface stream (pre-30) + // Read XMP from cursor (API 30+) val xmp: String? = if (xmpColumn != -1) { cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8) } else { - try { - ctx.contentResolver.openInputStream(uri)?.use { stream -> - ExifInterface(stream).getAttribute(ExifInterface.TAG_XMP) - } - } catch (e: Exception) { - Log.w(TAG, "Failed to read XMP for asset $assetId", e) - null - } + // if xmp column is not available, we are on API 29 or below + // theoretically there were motion photos but the Camera:MotionPhoto xmp tag + // was only added in Android 11, so we should not have to worry about parsing XMP on older versions + null } if (xmp != null && "Camera:MotionPhoto" in xmp) { From 672275dbcd8e06ee805bbb27db217e3efa886fec Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:30:05 +0100 Subject: [PATCH 5/6] update playback style detection documentation --- .../main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index 30879d83f3712..1f137b58241c1 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -204,8 +204,8 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { } /** - * Detects the playback style for an asset using _special_format (API 33+) - * or XMP / MIME / RIFF header fallbacks (pre-33). + * Detects the playback style for an asset using _special_format (SDK Extension 21+) + * or XMP / MIME / RIFF header fallbacks. */ @SuppressLint("NewApi") private fun detectPlaybackStyle( From 35259e827991827e64be223c670a93884fdc495a Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Sat, 7 Mar 2026 11:46:57 +0100 Subject: [PATCH 6/6] use DefaultImageHeaderParser instead of all available ones for webp playbackStyle type detection --- .../main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index 1f137b58241c1..949720325ec72 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -16,6 +16,7 @@ import app.alextran.immich.core.ImmichPlugin import com.bumptech.glide.Glide import com.bumptech.glide.load.ImageHeaderParser import com.bumptech.glide.load.ImageHeaderParserUtils +import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -255,7 +256,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { val glide = Glide.get(ctx) ctx.contentResolver.openInputStream(uri)?.use { stream -> val type = ImageHeaderParserUtils.getType( - glide.registry.imageHeaderParsers, + listOf(DefaultImageHeaderParser()), stream, glide.arrayPool )