From 0b0dda65cc3f6ac80689f3c6169a98f1f827c209 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Fri, 10 Jul 2026 02:01:42 +0600 Subject: [PATCH] fix(mobile): save raw photo downloads that fail on android --- .../app/alextran/immich/MainActivity.kt | 2 + .../alextran/immich/mediasave/MediaSave.g.kt | 97 +++++++++++++++++ .../immich/mediasave/MediaSavePlugin.kt | 102 ++++++++++++++++++ mobile/lib/platform/media_save_api.g.dart | 81 ++++++++++++++ .../repositories/file_media.repository.dart | 23 +++- mobile/lib/utils/mime.utils.dart | 12 +++ mobile/pigeon/media_save_api.dart | 21 ++++ mobile/test/utils/mime_utils_test.dart | 27 +++++ 8 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/mediasave/MediaSave.g.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/mediasave/MediaSavePlugin.kt create mode 100644 mobile/lib/platform/media_save_api.g.dart create mode 100644 mobile/lib/utils/mime.utils.dart create mode 100644 mobile/pigeon/media_save_api.dart create mode 100644 mobile/test/utils/mime_utils_test.dart diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index fc9ab28fa2..ec9c4dd882 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -18,6 +18,7 @@ import app.alextran.immich.images.LocalImageApi import app.alextran.immich.images.LocalImagesImpl import app.alextran.immich.images.RemoteImageApi import app.alextran.immich.images.RemoteImagesImpl +import app.alextran.immich.mediasave.MediaSavePlugin import app.alextran.immich.permission.PermissionApi import app.alextran.immich.permission.PermissionApiImpl import app.alextran.immich.sync.NativeSyncApi @@ -63,6 +64,7 @@ class MainActivity : FlutterFragmentActivity() { ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx)) flutterEngine.plugins.add(ViewIntentPlugin()) + flutterEngine.plugins.add(MediaSavePlugin()) flutterEngine.plugins.add(backgroundEngineLockImpl) flutterEngine.plugins.add(nativeSyncApiImpl) flutterEngine.plugins.add(permissionApiImpl) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/mediasave/MediaSave.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/mediasave/MediaSave.g.kt new file mode 100644 index 0000000000..2ec549e61b --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/mediasave/MediaSave.g.kt @@ -0,0 +1,97 @@ +// Autogenerated from Pigeon (v26.3.4), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package app.alextran.immich.mediasave + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object MediaSavePigeonUtils { + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : RuntimeException() +private open class MediaSavePigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return super.readValueOfType(type, buffer) + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + super.writeValue(stream, value) + } +} + + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface MediaSaveApi { + fun saveToDownloads(filePath: String, title: String, relativePath: String?, callback: (Result) -> Unit) + + companion object { + /** The codec used by MediaSaveApi. */ + val codec: MessageCodec by lazy { + MediaSavePigeonCodec() + } + /** Sets up an instance of `MediaSaveApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: MediaSaveApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.MediaSaveApi.saveToDownloads$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val filePathArg = args[0] as String + val titleArg = args[1] as String + val relativePathArg = args[2] as String? + api.saveToDownloads(filePathArg, titleArg, relativePathArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(MediaSavePigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(MediaSavePigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/mediasave/MediaSavePlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/mediasave/MediaSavePlugin.kt new file mode 100644 index 0000000000..578a7aeb52 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/mediasave/MediaSavePlugin.kt @@ -0,0 +1,102 @@ +package app.alextran.immich.mediasave + +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import io.flutter.embedding.engine.plugins.FlutterPlugin +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +class MediaSavePlugin : FlutterPlugin, MediaSaveApi { + private var context: Context? = null + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + MediaSaveApi.setUp(binding.binaryMessenger, this) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + MediaSaveApi.setUp(binding.binaryMessenger, null) + ioScope.cancel() + context = null + } + + override fun saveToDownloads( + filePath: String, + title: String, + relativePath: String?, + callback: (Result) -> Unit, + ) { + val context = context ?: run { + callback(Result.success(null)) + return + } + + ioScope.launch { + try { + callback(Result.success(insertIntoFiles(context, filePath, title, relativePath))) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } + } + + // Uses the Files collection, not Images: Images only accepts MIME types the + // platform knows and rejects raw formats like CR3, while Files accepts any + // type. The file lands under [relativePath] (Download/Immich), not the gallery. + private fun insertIntoFiles( + context: Context, + filePath: String, + title: String, + relativePath: String?, + ): String? { + val resolver = context.contentResolver + val collection = MediaStore.Files.getContentUri("external") + val source = File(filePath) + // Anything reaching this fallback is a format the platform can't type, so + // store it as a generic binary. The file saves and stays openable. + val mimeType = "application/octet-stream" + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, title) + put(MediaStore.MediaColumns.MIME_TYPE, mimeType) + relativePath?.let { put(MediaStore.MediaColumns.RELATIVE_PATH, it) } + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + val uri = resolver.insert(collection, values) ?: return null + + try { + val out = resolver.openOutputStream(uri) + if (out == null) { + resolver.delete(uri, null, null) + return null + } + out.use { source.inputStream().use { input -> input.copyTo(it) } } + resolver.update(uri, ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }, null, null) + return uri.lastPathSegment + } catch (e: Exception) { + resolver.delete(uri, null, null) + throw e + } + } + + val dir = File(Environment.getExternalStorageDirectory(), relativePath ?: Environment.DIRECTORY_DCIM).apply { mkdirs() } + val target = File(dir, title) + source.inputStream().use { input -> target.outputStream().use { input.copyTo(it) } } + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, title) + put(MediaStore.MediaColumns.MIME_TYPE, mimeType) + @Suppress("DEPRECATION") + put(MediaStore.MediaColumns.DATA, target.absolutePath) + } + return resolver.insert(collection, values)?.lastPathSegment + } +} diff --git a/mobile/lib/platform/media_save_api.g.dart b/mobile/lib/platform/media_save_api.g.dart new file mode 100644 index 0000000000..0faf31c8cb --- /dev/null +++ b/mobile/lib/platform/media_save_api.g.dart @@ -0,0 +1,81 @@ +// Autogenerated from Pigeon (v26.3.4), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: unused_import, unused_shown_name +// ignore_for_file: type=lint + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List; + +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart' show immutable, protected, visibleForTesting; + +Object? _extractReplyValueOrThrow(List? replyList, String channelName, {required bool isNullValid}) { + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); + } else if (replyList.length > 1) { + throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]); + } else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } + return replyList.firstOrNull; +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + default: + return super.readValueOfType(type, buffer); + } + } +} + +class MediaSaveApi { + /// Constructor for [MediaSaveApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + MediaSaveApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future saveToDownloads(String filePath, String title, String? relativePath) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.MediaSaveApi.saveToDownloads$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([filePath, title, relativePath]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + return pigeonVar_replyValue as String?; + } +} diff --git a/mobile/lib/repositories/file_media.repository.dart b/mobile/lib/repositories/file_media.repository.dart index c54813a757..e11ea20b87 100644 --- a/mobile/lib/repositories/file_media.repository.dart +++ b/mobile/lib/repositories/file_media.repository.dart @@ -1,10 +1,16 @@ import 'dart:io'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/platform/media_save_api.g.dart'; +import 'package:immich_mobile/utils/mime.utils.dart'; +import 'package:path/path.dart' as p; import 'package:photo_manager/photo_manager.dart' hide AssetType; +final _mediaSaveApi = MediaSaveApi(); + final fileMediaRepositoryProvider = Provider((ref) => const FileMediaRepository()); class FileMediaRepository { @@ -25,8 +31,19 @@ class FileMediaRepository { } Future saveImageWithFile(String filePath, {String? title, String? relativePath}) async { - final entity = await PhotoManager.editor.saveImageWithPath(filePath, title: title, relativePath: relativePath); - return entity; + try { + return await PhotoManager.editor.saveImageWithPath(filePath, title: title, relativePath: relativePath); + } on PlatformException catch (e) { + // Some formats (e.g. raw like CR3) have no MIME the platform recognises, so + // photo_manager falls back to `image/*` and MediaStore rejects the save. + // Save those to Downloads ourselves, where the Files collection takes any + // type. Anything else isn't ours to handle. + if (!CurrentPlatform.isAndroid || !isUnsupportedMimeError(e)) { + rethrow; + } + final id = await _mediaSaveApi.saveToDownloads(filePath, title ?? p.basename(filePath), 'Download/Immich'); + return id == null ? null : AssetEntity(id: id, typeInt: 1, width: 0, height: 0); + } } Future saveLivePhoto({required File image, required File video, required String title}) async { diff --git a/mobile/lib/utils/mime.utils.dart b/mobile/lib/utils/mime.utils.dart new file mode 100644 index 0000000000..4e2b35cd36 --- /dev/null +++ b/mobile/lib/utils/mime.utils.dart @@ -0,0 +1,12 @@ +import 'package:flutter/services.dart'; + +// True when a gallery save failed because MediaStore rejected the file's MIME. +// Android raises `Unsupported MIME type` for formats it can't type (e.g. raw +// like CR3), where photo_manager falls back to `image/*`. Matched on the detail +// string (case-insensitive) because photo_manager surfaces no distinct error +// code for it. Keeps `mime type` in the match so it doesn't catch unrelated +// `Unsupported*` errors (e.g. UnsupportedOperationException). +bool isUnsupportedMimeError(PlatformException e) { + final details = e.details; + return details is String && details.toLowerCase().contains('unsupported mime type'); +} diff --git a/mobile/pigeon/media_save_api.dart b/mobile/pigeon/media_save_api.dart new file mode 100644 index 0000000000..0d092d318e --- /dev/null +++ b/mobile/pigeon/media_save_api.dart @@ -0,0 +1,21 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/media_save_api.g.dart', + kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/mediasave/MediaSave.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.mediasave'), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) +@HostApi() +abstract class MediaSaveApi { + // Saves a file to a MediaStore Files-collection entry under [relativePath] + // (e.g. Download/Immich). Fallback for when photo_manager can't save a file + // because the platform has no MIME for it (e.g. raw like CR3) and MediaStore + // rejects the `image/*` it falls back to; the Files collection accepts any + // type. Returns the new media id, or null on failure. + @async + String? saveToDownloads(String filePath, String title, String? relativePath); +} diff --git a/mobile/test/utils/mime_utils_test.dart b/mobile/test/utils/mime_utils_test.dart new file mode 100644 index 0000000000..0d456c4f4e --- /dev/null +++ b/mobile/test/utils/mime_utils_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/utils/mime.utils.dart'; + +void main() { + group('isUnsupportedMimeError', () { + PlatformException err(Object? details) => PlatformException(code: 'saveImageWithPath', details: details); + + test('detects the MediaStore unsupported-mime failure', () { + expect(isUnsupportedMimeError(err('java.lang.IllegalArgumentException: Unsupported MIME type image/*')), isTrue); + }); + + test('is case-insensitive', () { + expect(isUnsupportedMimeError(err('unsupported mime type foo')), isTrue); + }); + + test('ignores other Unsupported* errors', () { + expect(isUnsupportedMimeError(err('java.lang.UnsupportedOperationException')), isFalse); + }); + + test('ignores unrelated failures and non-string details', () { + expect(isUnsupportedMimeError(err('Permission denied')), isFalse); + expect(isUnsupportedMimeError(err(null)), isFalse); + expect(isUnsupportedMimeError(err(42)), isFalse); + }); + }); +}