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
9 changed files with 364 additions and 5 deletions
+2 -2
View File
@@ -431,8 +431,8 @@
"transcoding_realtime_description": "Permet au transcodage d'être réalisé en temps réel durant la diffusion du flux de la vidéo. Active la bascule automatique entre résolutions, mais peut entraîner une latence importante de lecture et des microcoupures en fonction des capacités du serveur.",
"transcoding_realtime_enabled": "Activer la transcodification en temps réel",
"transcoding_realtime_enabled_description": "Si désactivé, le serveur refusera de démarrer de nouvelles sessions de transcodifications en temps réel.",
"transcoding_realtime_resolutions": "Résolutions",
"transcoding_realtime_resolutions_description": "Les résolutions proposées pour le transcodage en temps réel. Des résolutions supérieures peuvent causer des soucis pendant la lecture si le serveur ne peut pas les transcoder suffisamment rapidement.",
"transcoding_realtime_resolutions": "Définitions",
"transcoding_realtime_resolutions_description": "Les définitions proposées pour le transcodage en temps réel. Des définitions supérieures peuvent causer des soucis pendant la lecture si le serveur ne peut pas les transcoder suffisamment rapidement.",
"transcoding_realtime_video_codecs": "Codecs vidéo",
"transcoding_realtime_video_codecs_description": "Les codecs vidéo proposés pour le transcodage en temps réel. Les clients choisissent la meilleure option qu'ils supportent durant la lecture. AV1 est plus efficace que HEVC, qui est plus efficace que H.264. En utilisant l'accélération matérielle, sélectionnez seulement les codecs que l'accélérateur peut encoder. En utilisant le transcodage logiciel, notez que H.264 est plus rapide que AV1, qui est plus rapide que HEVC.",
"transcoding_reference_frames": "Trames de référence",
@@ -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);
});
});
}