feat(android): enhance playback style detection using MIME type, reducing glide exposure (#26747)

* feat(android): enhance playback style detection using MIME type

* feat(android): improve playback style detection for GIF and WebP formats

* fix(android): make playback style detection faster

* refactor(android): simplify XMP reading logic for API 29 and below

* update playback style detection documentation

* use DefaultImageHeaderParser instead of all available ones for webp playbackStyle type detection
This commit is contained in:
Luis Nachtigall
2026-03-07 16:41:26 +01:00
committed by GitHub
parent 6e9a425592
commit e73686bd76

View File

@@ -16,6 +16,7 @@ import app.alextran.immich.core.ImmichPlugin
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.ImageHeaderParser import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.ImageHeaderParserUtils import com.bumptech.glide.load.ImageHeaderParserUtils
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -81,10 +82,13 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
} }
if (hasSpecialFormatColumn()) { if (hasSpecialFormatColumn()) {
add(SPECIAL_FORMAT_COLUMN) add(SPECIAL_FORMAT_COLUMN)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { } else {
// Fallback: read XMP from MediaStore to detect Motion Photos // fallback to mimetype and xmp for playback style detection on older Android versions
// only needed if SPECIAL_FORMAT column isn't available // both only needed if special format column is not available
add(MediaStore.MediaColumns.XMP) add(MediaStore.MediaColumns.MIME_TYPE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
add(MediaStore.MediaColumns.XMP)
}
} }
}.toTypedArray() }.toTypedArray()
@@ -131,6 +135,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED) val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE) val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
val mimeTypeColumn = c.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)
val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID) val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID)
val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH) val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT) val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
@@ -177,7 +182,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0 val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
val playbackStyle = detectPlaybackStyle( val playbackStyle = detectPlaybackStyle(
numericId, rawMediaType, specialFormatColumn, xmpColumn, c numericId, rawMediaType, mimeTypeColumn, specialFormatColumn, xmpColumn, c
) )
val asset = PlatformAsset( val asset = PlatformAsset(
@@ -200,13 +205,14 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
} }
/** /**
* Detects the playback style for an asset using _special_format (API 33+) * Detects the playback style for an asset using _special_format (SDK Extension 21+)
* or XMP / MIME / RIFF header fallbacks (pre-33). * or XMP / MIME / RIFF header fallbacks.
*/ */
@SuppressLint("NewApi") @SuppressLint("NewApi")
private fun detectPlaybackStyle( private fun detectPlaybackStyle(
assetId: Long, assetId: Long,
rawMediaType: Int, rawMediaType: Int,
mimeTypeColumn: Int,
specialFormatColumn: Int, specialFormatColumn: Int,
xmpColumn: Int, xmpColumn: Int,
cursor: Cursor cursor: Cursor
@@ -231,46 +237,56 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
return PlatformAssetPlaybackStyle.UNKNOWN return PlatformAssetPlaybackStyle.UNKNOWN
} }
// Pre-API 33 fallback 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( val uri = ContentUris.withAppendedId(
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
assetId assetId
) )
// Read XMP from cursor (API 30+) or ExifInterface stream (pre-30) // 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") {
try {
val glide = Glide.get(ctx)
ctx.contentResolver.openInputStream(uri)?.use { stream ->
val type = ImageHeaderParserUtils.getType(
listOf(DefaultImageHeaderParser()),
stream,
glide.arrayPool
)
// 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
}
// Read XMP from cursor (API 30+)
val xmp: String? = if (xmpColumn != -1) { val xmp: String? = if (xmpColumn != -1) {
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8) cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
} else { } else {
try { // if xmp column is not available, we are on API 29 or below
ctx.contentResolver.openInputStream(uri)?.use { stream -> // theoretically there were motion photos but the Camera:MotionPhoto xmp tag
ExifInterface(stream).getAttribute(ExifInterface.TAG_XMP) // was only added in Android 11, so we should not have to worry about parsing XMP on older versions
} null
} catch (e: Exception) {
Log.w(TAG, "Failed to read XMP for asset $assetId", e)
null
}
} }
if (xmp != null && "Camera:MotionPhoto" in xmp) { if (xmp != null && "Camera:MotionPhoto" in xmp) {
return PlatformAssetPlaybackStyle.LIVE_PHOTO return PlatformAssetPlaybackStyle.LIVE_PHOTO
} }
try {
ctx.contentResolver.openInputStream(uri)?.use { stream ->
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
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
}
return PlatformAssetPlaybackStyle.IMAGE return PlatformAssetPlaybackStyle.IMAGE
} }