Compare commits

...

1 Commits

Author SHA1 Message Date
Santo Shakil 0b0dda65cc fix(mobile): save raw photo downloads that fail on android 2026-07-10 02:02:15 +06:00
8 changed files with 362 additions and 3 deletions
@@ -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)
@@ -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<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
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<String?>) -> Unit)
companion object {
/** The codec used by MediaSaveApi. */
val codec: MessageCodec<Any?> 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<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.MediaSaveApi.saveToDownloads$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
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<String?> ->
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)
}
}
}
}
}
@@ -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<String?>) -> 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
}
}
+81
View File
@@ -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<Object?>? 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<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<String?> saveToDownloads(String filePath, String title, String? relativePath) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.MediaSaveApi.saveToDownloads$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[filePath, title, relativePath]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
return pigeonVar_replyValue as String?;
}
}
@@ -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<AssetEntity?> 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<AssetEntity?> saveLivePhoto({required File image, required File video, required String title}) async {
+12
View File
@@ -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');
}
+21
View File
@@ -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);
}
+27
View File
@@ -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);
});
});
}