pull/23155/merge
ttmx 2025-12-10 17:02:32 +07:00 committed by GitHub
commit 0c005cef69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 659 additions and 0 deletions

@ -123,6 +123,34 @@
</intent-filter>
</activity>
<!-- Image picker provider activity - handles ACTION_GET_CONTENT and ACTION_PICK -->
<activity
android:name=".picker.ImagePickerActivity"
android:exported="true"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<!-- Image picker handling - respond to GET_CONTENT and PICK requests -->
<intent-filter android:label="Select from Immich">
<action android:name="android.intent.action.GET_CONTENT" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter android:label="Select from Immich">
<action android:name="android.intent.action.PICK" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<activity
android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
@ -144,6 +172,17 @@
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<!-- FileProvider for sharing images with other apps -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/fileprovider_paths" />
</provider>
<!-- Widgets -->
<receiver

@ -0,0 +1,208 @@
package app.alextran.immich.picker
import android.app.Activity
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import app.alextran.immich.MainActivity
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
import java.io.File
/**
* Activity that handles ACTION_GET_CONTENT and ACTION_PICK intents
* Communicates with Flutter to get the selected image URI
*/
class ImagePickerActivity : FlutterActivity() {
private var imagePickerApi: ImagePickerProviderApi? = null
private var hasRequestedImage = false
override fun onCreate(savedInstanceState: Bundle?) {
Log.d(TAG, "onCreate() called")
super.onCreate(savedInstanceState)
val action = intent.action
val type = intent.type
Log.d(TAG, "ImagePickerActivity started with action: $action, type: $type")
if ((action == Intent.ACTION_GET_CONTENT || action == Intent.ACTION_PICK)) {
Log.d(TAG, "Valid intent detected")
} else {
// Invalid intent, finish immediately
Log.w(TAG, "Invalid intent action or type, finishing activity")
setResult(RESULT_CANCELED)
finish()
}
Log.d(TAG, "onCreate() finished")
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
Log.d(TAG, "configureFlutterEngine() called, hasRequestedImage = $hasRequestedImage")
super.configureFlutterEngine(flutterEngine)
// Register all plugins
Log.d(TAG, "Registering plugins...")
MainActivity.registerPlugins(this, flutterEngine)
Log.d(TAG, "Plugins registered")
// Set up the image picker API
Log.d(TAG, "Setting up ImagePickerProviderApi...")
imagePickerApi = ImagePickerProviderApi(flutterEngine.dartExecutor.binaryMessenger)
Log.d(TAG, "ImagePickerProviderApi set up: ${true}")
// Check if this is a valid image picker intent and we haven't requested yet
val action = intent.action
if (!hasRequestedImage && (action == Intent.ACTION_GET_CONTENT || action == Intent.ACTION_PICK)) {
Log.d(TAG, "Valid intent and haven't requested yet, calling requestImageFromFlutter()")
hasRequestedImage = true
requestImageFromFlutter()
} else {
Log.w(
TAG,
"NOT calling requestImageFromFlutter() - hasRequestedImage: $hasRequestedImage, action: $action"
)
}
Log.d(TAG, "configureFlutterEngine() finished")
}
private fun requestImageFromFlutter() {
Log.d(TAG, "=== requestImageFromFlutter() CALLED ===")
Log.d(TAG, "imagePickerApi is null: ${imagePickerApi == null}")
// Check if the calling app allows multiple selection
val allowMultiple = intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)
Log.d(TAG, "Intent allows multiple selection: $allowMultiple")
imagePickerApi?.pickImagesForIntent { result ->
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"
}
}

@ -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<Any?> 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<List<String?>?>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.immich_mobile.ImagePickerProviderApi.pickImagesForIntent$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(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<String?>?
callback(Result.success(output))
}
} else {
callback(Result.failure(ImagePickerProviderPigeonUtils.createConnectionError(channelName)))
}
}
}
}

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Temporary directory for sharing images -->
<cache-path name="shared_images" path="." />
<!-- External cache directory -->
<external-cache-path name="external_cache" path="." />
</paths>

@ -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<T>(_ 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))
}
}
}
}

@ -32,6 +32,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';
@ -209,6 +210,9 @@ class ImmichAppState extends ConsumerState<ImmichApp> 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();

@ -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<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
if (empty) {
return <Object?>[];
}
if (error == null) {
return <Object?>[result];
}
return <Object?>[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<Object?> 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<List<String?>?> pickImagesForIntent();
static void setUp(ImagePickerProviderApi? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
{
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
'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<String?>? 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()));
}
});
}
}
}
}

@ -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<List<String?>?> 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<Set<BaseAsset>>(
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<String> 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<String?> _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;
}
}
}

@ -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

@ -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<String?>? pickImagesForIntent();
}