diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index c6e04e5a10a84..0c652d23dc8b1 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -123,6 +123,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Log.d(TAG, "pickImagesForIntent callback received")
+ result.fold(onSuccess = { imageUriList ->
+ Log.d(TAG, "SUCCESS: Received ${imageUriList?.size ?: 0} image URI(s) from Flutter")
+
+ if (imageUriList.isNullOrEmpty()) {
+ // User cancelled or no images selected
+ Log.d(TAG, "No images selected, returning RESULT_CANCELED")
+ setResult(RESULT_CANCELED)
+ finish()
+ return@fold
+ }
+
+ try {
+ // Convert all URIs to content URIs
+ val contentUris = imageUriList.filterNotNull().map { uriString ->
+ try {
+ convertToContentUri(uriString)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error converting URI: $uriString", e)
+ null
+ }
+ }
+
+ if (contentUris.isEmpty()) {
+ Log.e(TAG, "No valid content URIs after conversion")
+ setResult(RESULT_CANCELED)
+ finish()
+ return@fold
+ }
+
+ val resultIntent = Intent()
+
+ if (contentUris.size == 1 || !allowMultiple) {
+ // Single image or app doesn't support multiple
+ Log.d(TAG, "Returning single image URI: ${contentUris.first()}")
+ resultIntent.data = contentUris.first()
+ } else {
+ // Multiple images - use ClipData
+ Log.d(TAG, "Returning ${contentUris.size} images using ClipData")
+ val clipData = ClipData.newUri(contentResolver, "Images", contentUris.first())
+
+ // Add the rest of the URIs to ClipData
+ for (i in 1 until contentUris.size) {
+ clipData.addItem(ClipData.Item(contentUris[i]))
+ }
+
+ resultIntent.clipData = clipData
+ resultIntent.data = contentUris.first() // Also set primary URI for compatibility
+ }
+
+ // Grant temporary read permission to all URIs
+ resultIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+
+ setResult(RESULT_OK, resultIntent)
+ finish()
+ } catch (e: Exception) {
+ Log.e(TAG, "Error processing URIs", e)
+ setResult(RESULT_CANCELED)
+ finish()
+ }
+ }, onFailure = { error ->
+ Log.e(TAG, "Error getting images from Flutter", error)
+ setResult(RESULT_CANCELED)
+ finish()
+ })
+ }
+ }
+
+ /**
+ * Converts a file:// URI to a content:// URI using FileProvider
+ * This is required for API 24+ to share files with other apps
+ */
+ private fun convertToContentUri(uriString: String): Uri {
+ val uri = uriString.toUri()
+
+ return if (uri.scheme == "file") {
+ val file = File(uri.path!!)
+ FileProvider.getUriForFile(
+ this, "${applicationContext.packageName}.fileprovider", file
+ )
+ } else {
+ // Already a content URI or other type
+ uri
+ }
+ }
+
+ override fun getCachedEngineId(): String? {
+ // Try to use the cached engine if available
+ val hasCachedEngine = FlutterEngineCache.getInstance().contains(ENGINE_CACHE_KEY)
+ Log.d(TAG, "getCachedEngineId() called, has cached engine: $hasCachedEngine")
+ return if (hasCachedEngine) {
+ Log.d(TAG, "Using cached engine 'immich_engine'")
+ "immich_engine"
+ } else {
+ Log.d(TAG, "No cached engine found, will create new engine")
+ null
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ Log.d(TAG, "onStart() called")
+ }
+
+ override fun onResume() {
+ super.onResume()
+ Log.d(TAG, "onResume() called")
+ }
+
+ override fun onPause() {
+ super.onPause()
+ Log.d(TAG, "onPause() called")
+ }
+
+ override fun onDestroy() {
+ Log.d(TAG, "onDestroy() called")
+ super.onDestroy()
+ }
+
+ companion object {
+ private const val TAG = "ImagePickerActivity"
+ const val ENGINE_CACHE_KEY = "immich::image_picker::engine"
+
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/picker/ImagePickerProvider.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/picker/ImagePickerProvider.g.kt
new file mode 100644
index 0000000000000..c885940023f2e
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/picker/ImagePickerProvider.g.kt
@@ -0,0 +1,77 @@
+// Autogenerated from Pigeon (v26.0.0), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
+
+package app.alextran.immich.picker
+
+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 ImagePickerProviderPigeonUtils {
+
+ fun createConnectionError(channelName: String): FlutterError {
+ return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") }
+}
+
+/**
+ * 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
+) : Throwable()
+private open class ImagePickerProviderPigeonCodec : 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)
+ }
+}
+
+/**
+ * API for Android native to request an image from Flutter
+ *
+ * Generated class from Pigeon that represents Flutter messages that can be called from Kotlin.
+ */
+class ImagePickerProviderApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
+ companion object {
+ /** The codec used by ImagePickerProviderApi. */
+ val codec: MessageCodec by lazy {
+ ImagePickerProviderPigeonCodec()
+ }
+ }
+ /**
+ * Called when Android needs images for ACTION_GET_CONTENT/ACTION_PICK
+ * Returns a list of URIs of the selected images (content:// or file:// URIs)
+ * Returns null or empty list if user cancels
+ */
+ fun pickImagesForIntent(callback: (Result?>) -> Unit)
+{
+ val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
+ val channelName = "dev.flutter.pigeon.immich_mobile.ImagePickerProviderApi.pickImagesForIntent$separatedMessageChannelSuffix"
+ val channel = BasicMessageChannel(binaryMessenger, channelName, codec)
+ channel.send(null) {
+ if (it is List<*>) {
+ if (it.size > 1) {
+ callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
+ } else {
+ val output = it[0] as List?
+ callback(Result.success(output))
+ }
+ } else {
+ callback(Result.failure(ImagePickerProviderPigeonUtils.createConnectionError(channelName)))
+ }
+ }
+ }
+}
diff --git a/mobile/android/app/src/main/res/xml/fileprovider_paths.xml b/mobile/android/app/src/main/res/xml/fileprovider_paths.xml
new file mode 100644
index 0000000000000..d328e2b012d3c
--- /dev/null
+++ b/mobile/android/app/src/main/res/xml/fileprovider_paths.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/mobile/ios/Runner/ImagePickerProvider.g.swift b/mobile/ios/Runner/ImagePickerProvider.g.swift
new file mode 100644
index 0000000000000..f7c47256b9722
--- /dev/null
+++ b/mobile/ios/Runner/ImagePickerProvider.g.swift
@@ -0,0 +1,89 @@
+// Autogenerated from Pigeon (v26.0.0), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+
+import Foundation
+
+#if os(iOS)
+ import Flutter
+#elseif os(macOS)
+ import FlutterMacOS
+#else
+ #error("Unsupported platform.")
+#endif
+
+private func createConnectionError(withChannelName channelName: String) -> PigeonError {
+ return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "")
+}
+
+private func isNullish(_ value: Any?) -> Bool {
+ return value is NSNull || value == nil
+}
+
+private func nilOrValue(_ value: Any?) -> T? {
+ if value is NSNull { return nil }
+ return value as! T?
+}
+
+
+private class ImagePickerProviderPigeonCodecReader: FlutterStandardReader {
+}
+
+private class ImagePickerProviderPigeonCodecWriter: FlutterStandardWriter {
+}
+
+private class ImagePickerProviderPigeonCodecReaderWriter: FlutterStandardReaderWriter {
+ override func reader(with data: Data) -> FlutterStandardReader {
+ return ImagePickerProviderPigeonCodecReader(data: data)
+ }
+
+ override func writer(with data: NSMutableData) -> FlutterStandardWriter {
+ return ImagePickerProviderPigeonCodecWriter(data: data)
+ }
+}
+
+class ImagePickerProviderPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
+ static let shared = ImagePickerProviderPigeonCodec(readerWriter: ImagePickerProviderPigeonCodecReaderWriter())
+}
+
+/// API for Android native to request an image from Flutter
+///
+/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift.
+protocol ImagePickerProviderApiProtocol {
+ /// Called when Android needs images for ACTION_GET_CONTENT/ACTION_PICK
+ /// Returns a list of URIs of the selected images (content:// or file:// URIs)
+ /// Returns null or empty list if user cancels
+ func pickImagesForIntent(completion: @escaping (Result<[String?]?, PigeonError>) -> Void)
+}
+class ImagePickerProviderApi: ImagePickerProviderApiProtocol {
+ private let binaryMessenger: FlutterBinaryMessenger
+ private let messageChannelSuffix: String
+ init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") {
+ self.binaryMessenger = binaryMessenger
+ self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
+ }
+ var codec: ImagePickerProviderPigeonCodec {
+ return ImagePickerProviderPigeonCodec.shared
+ }
+ /// Called when Android needs images for ACTION_GET_CONTENT/ACTION_PICK
+ /// Returns a list of URIs of the selected images (content:// or file:// URIs)
+ /// Returns null or empty list if user cancels
+ func pickImagesForIntent(completion: @escaping (Result<[String?]?, PigeonError>) -> Void) {
+ let channelName: String = "dev.flutter.pigeon.immich_mobile.ImagePickerProviderApi.pickImagesForIntent\(messageChannelSuffix)"
+ let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
+ channel.sendMessage(nil) { response in
+ guard let listResponse = response as? [Any?] else {
+ completion(.failure(createConnectionError(withChannelName: channelName)))
+ return
+ }
+ if listResponse.count > 1 {
+ let code: String = listResponse[0] as! String
+ let message: String? = nilOrValue(listResponse[1])
+ let details: String? = nilOrValue(listResponse[2])
+ completion(.failure(PigeonError(code: code, message: message, details: details)))
+ } else {
+ let result: [String?]? = nilOrValue(listResponse[0])
+ completion(.success(result))
+ }
+ }
+ }
+}
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
index 263a5ef7695cb..f295d1a1a4ad7 100644
--- a/mobile/lib/main.dart
+++ b/mobile/lib/main.dart
@@ -31,6 +31,7 @@ import 'package:immich_mobile/routing/app_navigation_observer.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/deep_link.service.dart';
+import 'package:immich_mobile/services/image_picker_provider.service.dart';
import 'package:immich_mobile/services/local_notification.service.dart';
import 'package:immich_mobile/theme/dynamic_theme.dart';
import 'package:immich_mobile/theme/theme_data.dart';
@@ -208,6 +209,9 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve
super.initState();
initApp().then((_) => dPrint(() => "App Init Completed"));
WidgetsBinding.instance.addPostFrameCallback((_) {
+ // Initialize the image picker provider service for ACTION_GET_CONTENT handling
+ ref.read(imagePickerProviderServiceProvider);
+
// needs to be delayed so that EasyLocalization is working
if (Store.isBetaTimelineEnabled) {
ref.read(backgroundServiceProvider).disableService();
diff --git a/mobile/lib/platform/image_picker_provider_api.g.dart b/mobile/lib/platform/image_picker_provider_api.g.dart
new file mode 100644
index 0000000000000..5f12b6877b74c
--- /dev/null
+++ b/mobile/lib/platform/image_picker_provider_api.g.dart
@@ -0,0 +1,74 @@
+// Autogenerated from Pigeon (v26.0.0), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
+
+import 'dart:async';
+import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
+
+import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
+import 'package:flutter/services.dart';
+
+List