mirror of
https://github.com/immich-app/immich.git
synced 2026-07-10 06:03:14 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b0dda65cc |
@@ -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)
|
||||
|
||||
+97
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user