Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions mobile/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,34 @@
</intent-filter>
</activity>

<!-- Image picker provider activity - handles ACTION_GET_CONTENT and ACTION_PICK -->
<activity
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Standard intent boilerplate

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"
Expand All @@ -144,6 +172,17 @@
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />

<!-- FileProvider for sharing images with other apps -->
Copy link
Author

Choose a reason for hiding this comment

The 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
Expand Down
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)) {
Copy link
Author

Choose a reason for hiding this comment

The 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)
Copy link
Author

Choose a reason for hiding this comment

The 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)
Copy link
Author

Choose a reason for hiding this comment

The 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)) {
Copy link
Author

Choose a reason for hiding this comment

The 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() {
Copy link
Author

Choose a reason for hiding this comment

The 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()
Copy link
Author

Choose a reason for hiding this comment

The 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")
Copy link
Author

Choose a reason for hiding this comment

The 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)))
}
}
}
}
7 changes: 7 additions & 0 deletions mobile/android/app/src/main/res/xml/fileprovider_paths.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
Copy link
Author

Choose a reason for hiding this comment

The 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>
Loading
Loading