-
-
Notifications
You must be signed in to change notification settings - Fork 6.4k
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
Changes from 4 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" | ||
|
Member
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. Configure |
||
| 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,209 @@ | ||
| 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?
Member
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 can leave this in, but we don't need the if part, check and early return for invalid actions |
||
| Log.d(TAG, "Valid intent detected") | ||
| } else { | ||
| // Invalid intent, finish immediately | ||
| Log.w(TAG, "Invalid intent action or type, finishing activity") | ||
| setResult(Activity.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?
Member
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. The upstream handling will only register the third party plugins that come from pubspec. We can simplify the registration, but a manual override is still needed |
||
| 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: ${imagePickerApi != null}") | ||
|
|
||
| // 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?
Member
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. I don't think this is needed. This should be called only during an activity creation |
||
| 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?
Member
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. Yes and pass the allow multiple call over to the flutter side so we can allow or disable multiple selection in the UI, rather than letting the user pick multiple assets when the request if for a single image |
||
| 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(Activity.RESULT_CANCELED) | ||
| finish() | ||
| return@fold | ||
| } | ||
|
|
||
| try { | ||
| // Convert all URIs to content URIs | ||
| val contentUris = imageUriList.mapNotNull { uriString -> | ||
| uriString?.let { | ||
| try { | ||
| convertToContentUri(it) | ||
| } catch (e: Exception) { | ||
| Log.e(TAG, "Error converting URI: $it", e) | ||
| null | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (contentUris.isEmpty()) { | ||
| Log.e(TAG, "No valid content URIs after conversion") | ||
| setResult(Activity.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(Activity.RESULT_OK, resultIntent) | ||
| finish() | ||
| } catch (e: Exception) { | ||
| Log.e(TAG, "Error processing URIs", e) | ||
| setResult(Activity.RESULT_CANCELED) | ||
| finish() | ||
| } | ||
| }, | ||
| onFailure = { error -> | ||
| Log.e(TAG, "Error getting images from Flutter", error) | ||
| setResult(Activity.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 |
||
| } | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 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("immich_engine") | ||
|
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. Should refactor this magic string out of here ("immich_engine") and perhaps call it "immich_image_provider_engine" |
||
| 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
Member
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 |
||
| } | ||
|
|
||
| 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" | ||
| } | ||
| } | ||
| 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?
Member
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.
|
||
| <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