mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 23:58:58 +03:00
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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user