-
-
Notifications
You must be signed in to change notification settings - Fork 5.2k
feat: use immich as an image provider #23155
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0fcc568
b409125
d5fdd75
1ef4fcb
162682e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 --> | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Standard file provider |
||
| <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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)) { | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perhaps a bit weird to verify the intent in two different places? |
||
| 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) | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to override and manually setup configureFlutterEngine? Isn't there another place that already does this? |
||
|
|
||
| // Register all plugins | ||
| Log.d(TAG, "Registering plugins...") | ||
| MainActivity.registerPlugins(this, flutterEngine) | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Registering plugins is quite weird, shouldn't some place upstream already do this? Should we be duplicating this behavior? |
||
| 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)) { | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In what case can we call configureFlutterEngine twice? |
||
| 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() { | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe this could return an image URI/URI list instead of setting the result inside of the function, and this way be more readable? |
||
| 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() | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All looks good |
||
| } | ||
| }, 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") | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't need these many logs I think |
||
| } | ||
|
|
||
| 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" | ||
|
|
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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))) | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| <?xml version="1.0" encoding="utf-8"?> | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Am uncertain if this is the best way to share files, does this look normal? |
||
| <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> | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Standard intent boilerplate