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 wrapResponse({Object? result, PlatformException? error, bool empty = false}) { + if (empty) { + return []; + } + if (error == null) { + return [result]; + } + return [error.code, error.message, error.details]; +} + + +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); + } + } +} + +/// API for Android native to request an image from Flutter +abstract class ImagePickerProviderApi { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + /// 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 + Future?> pickImagesForIntent(); + + static void setUp(ImagePickerProviderApi? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { + messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.immich_mobile.ImagePickerProviderApi.pickImagesForIntent$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + final List? output = await api.pickImagesForIntent(); + return wrapResponse(result: output); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + } +} diff --git a/mobile/lib/services/image_picker_provider.service.dart b/mobile/lib/services/image_picker_provider.service.dart new file mode 100644 index 0000000000000..0a75c315cc93b --- /dev/null +++ b/mobile/lib/services/image_picker_provider.service.dart @@ -0,0 +1,135 @@ +import 'dart:io'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; +import 'package:immich_mobile/platform/image_picker_provider_api.g.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/debug_print.dart'; +import 'package:path_provider/path_provider.dart'; + +final imagePickerProviderServiceProvider = Provider( + (ref) => ImagePickerProviderService( + const StorageRepository(), + ref.watch(apiServiceProvider), + ref.watch(appRouterProvider), + ), +); + +/// Service that handles image picker requests from Android native code +/// When other apps (like Twitter) request an image via ACTION_GET_CONTENT, +/// this service provides the image URI +class ImagePickerProviderService implements ImagePickerProviderApi { + final StorageRepository _storageRepository; + final ApiService _apiService; + final AppRouter _appRouter; + + ImagePickerProviderService( + StorageRepository storageRepository, + ApiService apiService, + AppRouter appRouter, + ) : _storageRepository = storageRepository, + _apiService = apiService, + _appRouter = appRouter { + // Register this service with the platform channel + ImagePickerProviderApi.setUp(this); + dPrint(() => "ImagePickerProviderService registered"); + } + + @override + Future?> pickImagesForIntent() async { + dPrint(() => "pickImagesForIntent called from native"); + + try { + // Show the asset selection timeline page to let the user choose images + final selectedAssets = await _appRouter.push>( + DriftAssetSelectionTimelineRoute(), + ); + + if (selectedAssets == null || selectedAssets.isEmpty) { + dPrint(() => "No assets selected by user"); + return null; + } + + dPrint(() => "User selected ${selectedAssets.length} asset(s)"); + + // Process all selected assets + final List imageUris = []; + + for (final asset in selectedAssets) { + dPrint(() => "Processing asset: ${asset.runtimeType}"); + + String? uri = await _getAssetUri(asset); + if (uri != null) { + imageUris.add(uri); + } + } + + if (imageUris.isEmpty) { + dPrint(() => "No valid URIs obtained, returning null"); + return null; + } + + dPrint(() => "Returning ${imageUris.length} image URI(s)"); + return imageUris; + } catch (e, stackTrace) { + dPrint(() => "Error in pickImagesForIntent: $e\n$stackTrace"); + return null; + } + } + + /// Gets the URI for a single asset (local, merged, or remote) + Future _getAssetUri(BaseAsset asset) async { + try { + // Try to get the file from a local asset + if (asset is LocalAsset) { + final file = await _storageRepository.getFileForAsset(asset.id); + if (file != null) { + dPrint(() => "Got local asset URI: file://${file.path}"); + return 'file://${file.path}'; + } + } else if (asset is RemoteAsset) { + final remoteAsset = asset; + + // Check if remote asset also exists locally + if (remoteAsset.localId != null) { + final file = await _storageRepository.getFileForAsset(remoteAsset.localId!); + if (file != null) { + dPrint(() => "Got merged asset local URI: file://${file.path}"); + return 'file://${file.path}'; + } + } + + // Remote-only asset - download it + dPrint(() => "Downloading remote asset ${remoteAsset.id}"); + final tempDir = await getTemporaryDirectory(); + final fileName = remoteAsset.name; + final tempFile = await File('${tempDir.path}/$fileName').create(); + + try { + final res = await _apiService.assetsApi.downloadAssetWithHttpInfo(remoteAsset.id); + + if (res.statusCode != 200) { + dPrint(() => "Asset download failed with status ${res.statusCode}"); + return null; + } + + await tempFile.writeAsBytes(res.bodyBytes); + dPrint(() => "Downloaded remote asset to: file://${tempFile.path}"); + return 'file://${tempFile.path}'; + } catch (e) { + dPrint(() => "Error downloading remote asset: $e"); + return null; + } + } + + dPrint(() => "No file available for asset"); + return null; + } catch (e) { + dPrint(() => "Error getting asset URI: $e"); + return null; + } + } +} diff --git a/mobile/makefile b/mobile/makefile index b90e95c9024ac..7602f767d0c95 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -11,11 +11,13 @@ pigeon: dart run pigeon --input pigeon/background_worker_api.dart dart run pigeon --input pigeon/background_worker_lock_api.dart dart run pigeon --input pigeon/connectivity_api.dart + dart run pigeon --input pigeon/image_picker_provider_api.dart dart format lib/platform/native_sync_api.g.dart dart format lib/platform/thumbnail_api.g.dart dart format lib/platform/background_worker_api.g.dart dart format lib/platform/background_worker_lock_api.g.dart dart format lib/platform/connectivity_api.g.dart + dart format lib/platform/image_picker_provider_api.g.dart watch: dart run build_runner watch --delete-conflicting-outputs diff --git a/mobile/pigeon/image_picker_provider_api.dart b/mobile/pigeon/image_picker_provider_api.dart new file mode 100644 index 0000000000000..34c48c81f5c5f --- /dev/null +++ b/mobile/pigeon/image_picker_provider_api.dart @@ -0,0 +1,24 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/image_picker_provider_api.g.dart', + swiftOut: 'ios/Runner/ImagePickerProvider.g.swift', + swiftOptions: SwiftOptions(includeErrorClass: false), + kotlinOut: + 'android/app/src/main/kotlin/app/alextran/immich/picker/ImagePickerProvider.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.picker'), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) + +/// API for Android native to request an image from Flutter +@FlutterApi() +abstract class ImagePickerProviderApi { + /// 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 + @async + List? pickImagesForIntent(); +}