...
This commit is contained in:
556
android/src/main/kotlin/io/swipelab/ux/GalleryPlugin.kt
Normal file
556
android/src/main/kotlin/io/swipelab/ux/GalleryPlugin.kt
Normal file
@@ -0,0 +1,556 @@
|
||||
package io.swipelab.ux
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.provider.Settings
|
||||
import android.util.Size
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
/// `MediaStore` bridge for `UxGallery` — paginated photo + video
|
||||
/// queries via `ContentResolver`, cell-sized thumbnails via
|
||||
/// `ContentResolver.loadThumbnail` (API 29+), and stream-copy file
|
||||
/// resolution into the app cache so `dart:io` can read what the
|
||||
/// system holds behind a `content://` URI.
|
||||
class GalleryPlugin : NativePlugin, MethodChannel.MethodCallHandler,
|
||||
PluginRegistry.RequestPermissionsResultListener {
|
||||
private var methodChannel: MethodChannel? = null
|
||||
private var context: Context? = null
|
||||
private var activity: Activity? = null
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
|
||||
/// Active permission request — set when [handleRequestPermission]
|
||||
/// asks the user, cleared in [onRequestPermissionsResult]. Reentrancy
|
||||
/// is rejected.
|
||||
private var pendingPermissionResult: MethodChannel.Result? = null
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
context = binding.applicationContext
|
||||
methodChannel = MethodChannel(binding.binaryMessenger, "ux/gallery").also {
|
||||
it.setMethodCallHandler(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
methodChannel?.setMethodCallHandler(null)
|
||||
methodChannel = null
|
||||
context = null
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
activityBinding = binding
|
||||
binding.addRequestPermissionsResultListener(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
activityBinding?.removeRequestPermissionsResultListener(this)
|
||||
activityBinding = null
|
||||
activity = null
|
||||
pendingPermissionResult?.success("denied")
|
||||
pendingPermissionResult = null
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"permission" -> handlePermission(result)
|
||||
"requestPermission" -> handleRequestPermission(result)
|
||||
"openSettings" -> handleOpenSettings(result)
|
||||
"presentLimitedLibraryPicker" -> result.success(null) // no-op on Android
|
||||
"albums" -> handleAlbums(call, result)
|
||||
"assets" -> handleAssets(call, result)
|
||||
"thumbnail" -> handleThumbnail(call, result)
|
||||
"resolveFile" -> handleResolveFile(call, result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Permission ─────────────────────────────────────────────────────
|
||||
|
||||
/// Permissions we need at runtime. API 33+ uses the per-media-type
|
||||
/// split (`READ_MEDIA_IMAGES` / `READ_MEDIA_VIDEO`) — and on API 34+
|
||||
/// `READ_MEDIA_VISUAL_USER_SELECTED` is the "limited" analogue of
|
||||
/// iOS's limited photo access. On older devices everything routes
|
||||
/// through the legacy `READ_EXTERNAL_STORAGE`.
|
||||
private fun requiredPermissions(): Array<String> =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
arrayOf(
|
||||
Manifest.permission.READ_MEDIA_IMAGES,
|
||||
Manifest.permission.READ_MEDIA_VIDEO,
|
||||
)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
}
|
||||
|
||||
private fun isGranted(permission: String): Boolean {
|
||||
val ctx = context ?: return false
|
||||
return ContextCompat.checkSelfPermission(ctx, permission) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun handlePermission(result: MethodChannel.Result) {
|
||||
result.success(currentPermissionString())
|
||||
}
|
||||
|
||||
private fun currentPermissionString(): String {
|
||||
// API 34+: USER_SELECTED grants partial access (telegram's
|
||||
// "limited" equivalent). We surface it as "limited" so the
|
||||
// picker can prompt the user with the system "manage" sheet.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
val userSelected = isGranted(
|
||||
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED,
|
||||
)
|
||||
val full = isGranted(Manifest.permission.READ_MEDIA_IMAGES) &&
|
||||
isGranted(Manifest.permission.READ_MEDIA_VIDEO)
|
||||
if (full) return "granted"
|
||||
if (userSelected) return "limited"
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val full = isGranted(Manifest.permission.READ_MEDIA_IMAGES) &&
|
||||
isGranted(Manifest.permission.READ_MEDIA_VIDEO)
|
||||
if (full) return "granted"
|
||||
} else {
|
||||
if (isGranted(Manifest.permission.READ_EXTERNAL_STORAGE)) {
|
||||
return "granted"
|
||||
}
|
||||
}
|
||||
// We can't tell apart "user denied permanently" from "never asked"
|
||||
// without using ActivityCompat.shouldShowRequestPermissionRationale,
|
||||
// which itself requires an Activity reference and only works after
|
||||
// a request. Default to "notDetermined" so the caller will try
|
||||
// requestPermission(); if denied permanently, the request resolves
|
||||
// immediately to "denied" and the UI routes to settings.
|
||||
return "notDetermined"
|
||||
}
|
||||
|
||||
private fun handleRequestPermission(result: MethodChannel.Result) {
|
||||
val act = activity ?: run {
|
||||
result.error("no_activity", "plugin not attached to an activity", null)
|
||||
return
|
||||
}
|
||||
if (pendingPermissionResult != null) {
|
||||
result.error(
|
||||
"in_progress", "a permission request is already in progress", null,
|
||||
)
|
||||
return
|
||||
}
|
||||
// If already granted, short-circuit — Android will pop the dialog
|
||||
// anyway if we call requestPermissions on a granted perm, which
|
||||
// is a worse UX than a no-op.
|
||||
val cur = currentPermissionString()
|
||||
if (cur == "granted" || cur == "limited") {
|
||||
result.success(cur)
|
||||
return
|
||||
}
|
||||
pendingPermissionResult = result
|
||||
act.requestPermissions(requiredPermissions(), REQUEST_CODE_PERMISSION)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray,
|
||||
): Boolean {
|
||||
if (requestCode != REQUEST_CODE_PERMISSION) return false
|
||||
val r = pendingPermissionResult ?: return true
|
||||
pendingPermissionResult = null
|
||||
r.success(currentPermissionString())
|
||||
return true
|
||||
}
|
||||
|
||||
private fun handleOpenSettings(result: MethodChannel.Result) {
|
||||
val ctx = context ?: run {
|
||||
result.error("no_context", "no application context", null)
|
||||
return
|
||||
}
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", ctx.packageName, null)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
ctx.startActivity(intent)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
// ─── Albums ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun handleAlbums(call: MethodCall, result: MethodChannel.Result) {
|
||||
val ctx = context ?: run {
|
||||
result.error("no_context", "no application context", null)
|
||||
return
|
||||
}
|
||||
val filter = (call.argument<String>("filter") ?: "any")
|
||||
val albums = mutableListOf<Map<String, Any?>>()
|
||||
|
||||
// Recents — virtual album over the entire library.
|
||||
val recentsCount = countAssets(ctx, filter, bucketId = null)
|
||||
if (recentsCount > 0) {
|
||||
albums.add(
|
||||
mapOf(
|
||||
"id" to "recents",
|
||||
"name" to "Recents",
|
||||
"count" to recentsCount,
|
||||
"cover_kind" to firstAssetKind(ctx, filter, bucketId = null),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// User buckets — DISTINCT bucket_id ordered by most-recent.
|
||||
val uri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
val projection = arrayOf(
|
||||
MediaStore.Files.FileColumns.BUCKET_ID,
|
||||
MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME,
|
||||
)
|
||||
val seenBuckets = LinkedHashMap<Long, String?>()
|
||||
val (selection, selectionArgs) = filterToSelection(filter)
|
||||
ctx.contentResolver.query(
|
||||
uri,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgs,
|
||||
"${MediaStore.Files.FileColumns.DATE_ADDED} DESC",
|
||||
)?.use { c ->
|
||||
val bucketIdIdx = c.getColumnIndex(MediaStore.Files.FileColumns.BUCKET_ID)
|
||||
val bucketNameIdx = c.getColumnIndex(
|
||||
MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME,
|
||||
)
|
||||
while (c.moveToNext()) {
|
||||
if (bucketIdIdx < 0 || c.isNull(bucketIdIdx)) continue
|
||||
val id = c.getLong(bucketIdIdx)
|
||||
if (seenBuckets.containsKey(id)) continue
|
||||
val name = if (bucketNameIdx >= 0) c.getString(bucketNameIdx) else null
|
||||
seenBuckets[id] = name
|
||||
}
|
||||
}
|
||||
for ((id, name) in seenBuckets) {
|
||||
val count = countAssets(ctx, filter, bucketId = id)
|
||||
if (count == 0) continue
|
||||
albums.add(
|
||||
mapOf(
|
||||
"id" to id.toString(),
|
||||
"name" to (name ?: ""),
|
||||
"count" to count,
|
||||
"cover_kind" to firstAssetKind(ctx, filter, bucketId = id),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
result.success(albums)
|
||||
}
|
||||
|
||||
private fun countAssets(ctx: Context, filter: String, bucketId: Long?): Int {
|
||||
val (selection, selectionArgs) = filterToSelection(filter, bucketId)
|
||||
return ctx.contentResolver.query(
|
||||
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
||||
arrayOf(MediaStore.Files.FileColumns._ID),
|
||||
selection,
|
||||
selectionArgs,
|
||||
null,
|
||||
)?.use { it.count } ?: 0
|
||||
}
|
||||
|
||||
private fun firstAssetKind(
|
||||
ctx: Context, filter: String, bucketId: Long?,
|
||||
): String? {
|
||||
// API 30+ rejects `LIMIT` / `OFFSET` injected into the sort-order
|
||||
// string ("Invalid token LIMIT"). Just sort and take the first row.
|
||||
val (selection, selectionArgs) = filterToSelection(filter, bucketId)
|
||||
return ctx.contentResolver.query(
|
||||
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
||||
arrayOf(MediaStore.Files.FileColumns.MEDIA_TYPE),
|
||||
selection,
|
||||
selectionArgs,
|
||||
"${MediaStore.Files.FileColumns.DATE_ADDED} DESC",
|
||||
)?.use { c ->
|
||||
if (c.moveToFirst()) mediaTypeToKind(c.getInt(0)) else null
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Assets ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun handleAssets(call: MethodCall, result: MethodChannel.Result) {
|
||||
val ctx = context ?: run {
|
||||
result.error("no_context", "no application context", null)
|
||||
return
|
||||
}
|
||||
val albumId = call.argument<String>("albumId")
|
||||
val filter = call.argument<String>("filter") ?: "any"
|
||||
val start = (call.argument<Number>("start") ?: 0).toInt()
|
||||
val end = (call.argument<Number>("end") ?: 0).toInt()
|
||||
val limit = (end - start).coerceAtLeast(0)
|
||||
if (limit == 0) {
|
||||
result.success(emptyList<Any?>())
|
||||
return
|
||||
}
|
||||
|
||||
val bucketId = albumId?.takeIf { it != "recents" }?.toLongOrNull()
|
||||
val (selection, selectionArgs) = filterToSelection(filter, bucketId)
|
||||
|
||||
val projection = arrayOf(
|
||||
MediaStore.Files.FileColumns._ID,
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE,
|
||||
MediaStore.Files.FileColumns.WIDTH,
|
||||
MediaStore.Files.FileColumns.HEIGHT,
|
||||
MediaStore.Files.FileColumns.DURATION,
|
||||
MediaStore.Files.FileColumns.DATE_ADDED,
|
||||
MediaStore.Files.FileColumns.DATE_TAKEN,
|
||||
)
|
||||
|
||||
val uri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
// API 30+ rejects `LIMIT` / `OFFSET` baked into the sort-order
|
||||
// string. Sort here, paginate via cursor positioning below.
|
||||
val sortOrder = "${MediaStore.Files.FileColumns.DATE_ADDED} DESC"
|
||||
|
||||
val out = mutableListOf<Map<String, Any?>>()
|
||||
ctx.contentResolver.query(uri, projection, selection, selectionArgs, sortOrder)
|
||||
?.use { c ->
|
||||
val idIdx = c.getColumnIndex(MediaStore.Files.FileColumns._ID)
|
||||
val typeIdx = c.getColumnIndex(MediaStore.Files.FileColumns.MEDIA_TYPE)
|
||||
val widthIdx = c.getColumnIndex(MediaStore.Files.FileColumns.WIDTH)
|
||||
val heightIdx = c.getColumnIndex(MediaStore.Files.FileColumns.HEIGHT)
|
||||
val durationIdx = c.getColumnIndex(MediaStore.Files.FileColumns.DURATION)
|
||||
val takenIdx = c.getColumnIndex(MediaStore.Files.FileColumns.DATE_TAKEN)
|
||||
val addedIdx = c.getColumnIndex(MediaStore.Files.FileColumns.DATE_ADDED)
|
||||
if (start > 0 && !c.moveToPosition(start - 1)) return@use
|
||||
var taken = 0
|
||||
while (taken < limit && c.moveToNext()) {
|
||||
val id = c.getLong(idIdx)
|
||||
val mediaType = c.getInt(typeIdx)
|
||||
val kind = mediaTypeToKind(mediaType) ?: continue
|
||||
val width = if (widthIdx >= 0) c.getInt(widthIdx) else 0
|
||||
val height = if (heightIdx >= 0) c.getInt(heightIdx) else 0
|
||||
val durationMs = if (durationIdx >= 0 && !c.isNull(durationIdx)) {
|
||||
c.getLong(durationIdx)
|
||||
} else null
|
||||
// DATE_TAKEN is in ms since epoch; DATE_ADDED is in
|
||||
// seconds. Prefer taken when set, fall back to added.
|
||||
val createdMs = when {
|
||||
takenIdx >= 0 && !c.isNull(takenIdx) && c.getLong(takenIdx) > 0 ->
|
||||
c.getLong(takenIdx)
|
||||
addedIdx >= 0 && !c.isNull(addedIdx) ->
|
||||
c.getLong(addedIdx) * 1000L
|
||||
else -> 0L
|
||||
}
|
||||
|
||||
out.add(
|
||||
mapOf<String, Any?>(
|
||||
"id" to assetUri(id, mediaType).toString(),
|
||||
"kind" to kind,
|
||||
"duration_ms" to (
|
||||
if (kind == "video") durationMs else null
|
||||
),
|
||||
"width" to width,
|
||||
"height" to height,
|
||||
"created_ms" to createdMs,
|
||||
),
|
||||
)
|
||||
taken++
|
||||
}
|
||||
}
|
||||
result.success(out)
|
||||
}
|
||||
|
||||
// ─── Thumbnail ──────────────────────────────────────────────────────
|
||||
|
||||
private fun handleThumbnail(call: MethodCall, result: MethodChannel.Result) {
|
||||
val ctx = context ?: run {
|
||||
result.error("no_context", "no application context", null)
|
||||
return
|
||||
}
|
||||
val assetId = call.argument<String>("assetId") ?: run {
|
||||
result.error("bad_args", "missing assetId", null)
|
||||
return
|
||||
}
|
||||
val sizePx = (call.argument<Number>("sizePx") ?: 0).toInt()
|
||||
if (sizePx <= 0) {
|
||||
result.error("bad_args", "sizePx must be > 0", null)
|
||||
return
|
||||
}
|
||||
|
||||
val uri = parseAssetUri(assetId) ?: run {
|
||||
result.error("bad_args", "assetId is not a content URI", null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// ContentResolver.loadThumbnail (API 29+) is the right call —
|
||||
// backed by the system thumbnail cache, no need to roll our
|
||||
// own. For older devices, fall back to opening the original
|
||||
// and Bitmap-decoding (slower; expected to be rare).
|
||||
val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ctx.contentResolver.loadThumbnail(uri, Size(sizePx, sizePx), null)
|
||||
} else {
|
||||
ctx.contentResolver.openInputStream(uri)?.use { input ->
|
||||
android.graphics.BitmapFactory.decodeStream(input)
|
||||
} ?: run {
|
||||
result.error("decode_failed", "could not decode bitmap", null)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 85, baos)
|
||||
result.success(
|
||||
mapOf<String, Any?>(
|
||||
"bytes" to baos.toByteArray(),
|
||||
"width" to bitmap.width,
|
||||
"height" to bitmap.height,
|
||||
),
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
result.error("thumbnail_failed", e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Resolve file ───────────────────────────────────────────────────
|
||||
|
||||
private fun handleResolveFile(call: MethodCall, result: MethodChannel.Result) {
|
||||
val ctx = context ?: run {
|
||||
result.error("no_context", "no application context", null)
|
||||
return
|
||||
}
|
||||
val assetId = call.argument<String>("assetId") ?: run {
|
||||
result.error("bad_args", "missing assetId", null)
|
||||
return
|
||||
}
|
||||
val uri = parseAssetUri(assetId) ?: run {
|
||||
result.error("bad_args", "assetId is not a content URI", null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val (displayName, mime) = queryNameAndMime(ctx, uri)
|
||||
val safeName = (displayName ?: ContentUris.parseId(uri).toString())
|
||||
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
||||
val ext = mime?.let { extensionForMime(it) }
|
||||
?: safeName.substringAfterLast('.', "")
|
||||
val dir = File(ctx.cacheDir, "ux_gallery").apply { mkdirs() }
|
||||
val out = File(
|
||||
dir,
|
||||
if (ext.isNotEmpty() && !safeName.endsWith(".$ext")) {
|
||||
"$safeName.$ext"
|
||||
} else {
|
||||
safeName
|
||||
},
|
||||
)
|
||||
|
||||
if (out.exists() && out.length() > 0) {
|
||||
result.success(out.absolutePath)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.contentResolver.openInputStream(uri).use { input ->
|
||||
requireNotNull(input) { "openInputStream returned null for $uri" }
|
||||
FileOutputStream(out).use { output ->
|
||||
val buf = ByteArray(16 * 1024)
|
||||
while (true) {
|
||||
val n = input.read(buf)
|
||||
if (n <= 0) break
|
||||
output.write(buf, 0, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.success(out.absolutePath)
|
||||
} catch (e: Throwable) {
|
||||
result.error("resolve_failed", e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryNameAndMime(ctx: Context, uri: Uri): Pair<String?, String?> {
|
||||
var name: String? = null
|
||||
ctx.contentResolver.query(
|
||||
uri,
|
||||
arrayOf(MediaStore.MediaColumns.DISPLAY_NAME),
|
||||
null, null, null,
|
||||
)?.use { c: Cursor ->
|
||||
if (c.moveToFirst() && c.columnCount > 0 && !c.isNull(0)) {
|
||||
name = c.getString(0)
|
||||
}
|
||||
}
|
||||
return name to ctx.contentResolver.getType(uri)
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private fun filterToSelection(
|
||||
filter: String, bucketId: Long? = null,
|
||||
): Pair<String, Array<String>> {
|
||||
val parts = mutableListOf<String>()
|
||||
val args = mutableListOf<String>()
|
||||
when (filter) {
|
||||
"image" -> {
|
||||
parts += "${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?"
|
||||
args += MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString()
|
||||
}
|
||||
"video" -> {
|
||||
parts += "${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?"
|
||||
args += MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString()
|
||||
}
|
||||
else -> {
|
||||
parts += "(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR " +
|
||||
"${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
|
||||
args += MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString()
|
||||
args += MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString()
|
||||
}
|
||||
}
|
||||
if (bucketId != null) {
|
||||
parts += "${MediaStore.Files.FileColumns.BUCKET_ID} = ?"
|
||||
args += bucketId.toString()
|
||||
}
|
||||
return parts.joinToString(" AND ") to args.toTypedArray()
|
||||
}
|
||||
|
||||
private fun mediaTypeToKind(mediaType: Int): String? = when (mediaType) {
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> "image"
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> "video"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun assetUri(id: Long, mediaType: Int): Uri {
|
||||
val base = when (mediaType) {
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO ->
|
||||
MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
else ->
|
||||
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
}
|
||||
return ContentUris.withAppendedId(base, id)
|
||||
}
|
||||
|
||||
private fun parseAssetUri(s: String): Uri? = try {
|
||||
val uri = Uri.parse(s)
|
||||
if (uri.scheme == "content") uri else null
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun extensionForMime(mime: String): String = when (mime) {
|
||||
"image/jpeg" -> "jpg"
|
||||
"image/png" -> "png"
|
||||
"image/heic", "image/heif" -> "heic"
|
||||
"image/webp" -> "webp"
|
||||
"image/gif" -> "gif"
|
||||
"video/mp4" -> "mp4"
|
||||
"video/quicktime" -> "mov"
|
||||
"video/3gpp" -> "3gp"
|
||||
"video/webm" -> "webm"
|
||||
else -> mime.substringAfter('/', "bin")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_CODE_PERMISSION = 0xC52
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ class UxPlugin : FlutterPlugin, ActivityAware {
|
||||
FilePlugin(),
|
||||
ScannerPlugin(),
|
||||
ClipboardPlugin(),
|
||||
GalleryPlugin(),
|
||||
)
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[],"windows":[],"web":[]},"dependencyGraph":[{"name":"ux","dependencies":[]}],"date_created":"2026-04-24 12:21:00.100805","version":"3.41.5","swift_package_manager_enabled":{"ios":false,"macos":false}}
|
||||
{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[],"windows":[],"web":[]},"dependencyGraph":[{"name":"ux","dependencies":[]}],"date_created":"2026-05-10 03:28:28.809284","version":"3.41.7","swift_package_manager_enabled":{"ios":false,"macos":false}}
|
||||
@@ -3,12 +3,12 @@
|
||||
export "FLUTTER_ROOT=/Users/agra/sdk/flutter"
|
||||
export "FLUTTER_APPLICATION_PATH=/Users/agra/projects/ux/example"
|
||||
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
||||
export "FLUTTER_TARGET=/Users/agra/projects/ux/example/lib/main.dart"
|
||||
export "FLUTTER_TARGET=lib/main.dart"
|
||||
export "FLUTTER_BUILD_DIR=build"
|
||||
export "FLUTTER_BUILD_NAME=1.0.0"
|
||||
export "FLUTTER_BUILD_NUMBER=1"
|
||||
export "DART_DEFINES=RkxVVFRFUl9WRVJTSU9OPTMuNDEuNQ==,RkxVVFRFUl9DSEFOTkVMPVt1c2VyLWJyYW5jaF0=,RkxVVFRFUl9HSVRfVVJMPXVua25vd24gc291cmNl,RkxVVFRFUl9GUkFNRVdPUktfUkVWSVNJT049MmM5ZWIyMDczOQ==,RkxVVFRFUl9FTkdJTkVfUkVWSVNJT049MDUyZjMxZDExNQ==,RkxVVFRFUl9EQVJUX1ZFUlNJT049My4xMS4z"
|
||||
export "DART_DEFINES=RkxVVFRFUl9WRVJTSU9OPTMuNDEuNw==,RkxVVFRFUl9DSEFOTkVMPVt1c2VyLWJyYW5jaF0=,RkxVVFRFUl9HSVRfVVJMPXVua25vd24gc291cmNl,RkxVVFRFUl9GUkFNRVdPUktfUkVWSVNJT049Y2MwNzM0YWM3MQ==,RkxVVFRFUl9FTkdJTkVfUkVWSVNJT049NTlhYTU4NGZkZg==,RkxVVFRFUl9EQVJUX1ZFUlNJT049My4xMS41"
|
||||
export "DART_OBFUSCATION=false"
|
||||
export "TRACK_WIDGET_CREATION=true"
|
||||
export "TRACK_WIDGET_CREATION=false"
|
||||
export "TREE_SHAKE_ICONS=false"
|
||||
export "PACKAGE_CONFIG=/Users/agra/projects/ux/example/.dart_tool/package_config.json"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
PODS:
|
||||
- Flutter (1.0.0)
|
||||
- ux (0.2.0):
|
||||
- ux (0.9.0):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
@@ -15,7 +15,7 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
ux: efeefb7a32deec80e615338198f103c26f8c78a1
|
||||
ux: 86fcd1a58b4329c8675be9906edfc6db5ca4e546
|
||||
|
||||
PODFILE CHECKSUM: 5c8eb167e48255b7544ab290f70b4d6a1076ca06
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
PODS:
|
||||
- FlutterMacOS (1.0.0)
|
||||
- ux (0.6.0):
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- ux (from `Flutter/ephemeral/.symlinks/plugins/ux/macos`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
FlutterMacOS:
|
||||
:path: Flutter/ephemeral
|
||||
ux:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/ux/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
ux: 69a2fc0e618f93fbeac7ed16f2fba53d23b3fdfa
|
||||
|
||||
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
||||
|
||||
|
||||
@@ -240,6 +240,7 @@
|
||||
33CC10EB2044A3C60003C045 /* Resources */,
|
||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||
43F7C1DE66BB38BD46D4828D /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -360,6 +361,23 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
|
||||
};
|
||||
43F7C1DE66BB38BD46D4828D /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
C226DFC83DE631039283F9D1 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
|
||||
347
ios/Classes/GalleryPlugin.swift
Normal file
347
ios/Classes/GalleryPlugin.swift
Normal file
@@ -0,0 +1,347 @@
|
||||
import Flutter
|
||||
import Photos
|
||||
import PhotosUI
|
||||
import UIKit
|
||||
|
||||
/// `Photos.framework` bridge for `UxGallery` — paginated asset queries,
|
||||
/// cell-sized thumbnails via `PHCachingImageManager`, and on-demand
|
||||
/// file resolution into the app cache.
|
||||
public class GalleryPlugin: NSObject, NativePlugin {
|
||||
private let imageManager = PHCachingImageManager()
|
||||
private var fetchCache: [String: PHFetchResult<PHAsset>] = [:]
|
||||
|
||||
public func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "ux/gallery",
|
||||
binaryMessenger: registrar.messenger(),
|
||||
)
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
self?.handle(call: call, result: result)
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "permission":
|
||||
result(Self.permissionString(Self.currentAuthorization()))
|
||||
case "requestPermission":
|
||||
Self.requestAuthorization { status in
|
||||
DispatchQueue.main.async {
|
||||
result(Self.permissionString(status))
|
||||
}
|
||||
}
|
||||
case "openSettings":
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
}
|
||||
result(nil)
|
||||
case "presentLimitedLibraryPicker":
|
||||
if #available(iOS 14, *), let vc = UxWindow.topViewController {
|
||||
PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: vc)
|
||||
}
|
||||
result(nil)
|
||||
case "albums":
|
||||
handleAlbums(call: call, result: result)
|
||||
case "assets":
|
||||
handleAssets(call: call, result: result)
|
||||
case "thumbnail":
|
||||
handleThumbnail(call: call, result: result)
|
||||
case "resolveFile":
|
||||
handleResolveFile(call: call, result: result)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Permission
|
||||
|
||||
private static func currentAuthorization() -> PHAuthorizationStatus {
|
||||
if #available(iOS 14, *) {
|
||||
return PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
}
|
||||
return PHPhotoLibrary.authorizationStatus()
|
||||
}
|
||||
|
||||
private static func requestAuthorization(_ handler: @escaping (PHAuthorizationStatus) -> Void) {
|
||||
if #available(iOS 14, *) {
|
||||
PHPhotoLibrary.requestAuthorization(for: .readWrite, handler: handler)
|
||||
} else {
|
||||
PHPhotoLibrary.requestAuthorization(handler)
|
||||
}
|
||||
}
|
||||
|
||||
private static func permissionString(_ status: PHAuthorizationStatus) -> String {
|
||||
switch status {
|
||||
case .notDetermined: return "notDetermined"
|
||||
case .denied: return "denied"
|
||||
case .restricted: return "restricted"
|
||||
case .authorized: return "granted"
|
||||
default:
|
||||
// .limited is iOS 14+; reaching here on iOS 13 means a future
|
||||
// case we don't handle yet — treat as denied to be safe.
|
||||
if #available(iOS 14, *), status == .limited { return "limited" }
|
||||
return "denied"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Albums
|
||||
|
||||
/// Subset of smart albums we expose. Cut: bursts, animated, depth-effect,
|
||||
/// long-exposure, panoramas, slo-mo, etc. — matches the picker's scope cuts.
|
||||
private static let smartAlbumKept: [PHAssetCollectionSubtype] = [
|
||||
.smartAlbumVideos,
|
||||
.smartAlbumFavorites,
|
||||
.smartAlbumScreenshots,
|
||||
]
|
||||
|
||||
private func handleAlbums(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
let args = call.arguments as? [String: Any] ?? [:]
|
||||
let filter = Self.kindFilter(args["filter"])
|
||||
|
||||
let baseOptions = PHFetchOptions()
|
||||
if let f = filter {
|
||||
baseOptions.predicate = NSPredicate(format: "mediaType == %d", f.rawValue)
|
||||
}
|
||||
|
||||
var albums: [[String: Any?]] = []
|
||||
|
||||
// Recents — virtual album over the entire library.
|
||||
let recents = PHAsset.fetchAssets(with: baseOptions)
|
||||
if recents.count > 0 {
|
||||
albums.append([
|
||||
"id": "recents",
|
||||
"name": "Recents",
|
||||
"count": recents.count,
|
||||
"cover_kind": Self.coverKindString(recents.firstObject),
|
||||
])
|
||||
}
|
||||
|
||||
// Smart albums.
|
||||
for subtype in Self.smartAlbumKept {
|
||||
let collections = PHAssetCollection.fetchAssetCollections(
|
||||
with: .smartAlbum, subtype: subtype, options: nil,
|
||||
)
|
||||
collections.enumerateObjects { collection, _, _ in
|
||||
let assets = PHAsset.fetchAssets(in: collection, options: baseOptions)
|
||||
if assets.count > 0 {
|
||||
albums.append([
|
||||
"id": collection.localIdentifier,
|
||||
"name": collection.localizedTitle ?? "",
|
||||
"count": assets.count,
|
||||
"cover_kind": Self.coverKindString(assets.firstObject),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// User albums.
|
||||
let userCollections = PHAssetCollection.fetchAssetCollections(
|
||||
with: .album, subtype: .albumRegular, options: nil,
|
||||
)
|
||||
userCollections.enumerateObjects { collection, _, _ in
|
||||
let assets = PHAsset.fetchAssets(in: collection, options: baseOptions)
|
||||
if assets.count > 0 {
|
||||
albums.append([
|
||||
"id": collection.localIdentifier,
|
||||
"name": collection.localizedTitle ?? "",
|
||||
"count": assets.count,
|
||||
"cover_kind": Self.coverKindString(assets.firstObject),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
result(albums)
|
||||
}
|
||||
|
||||
private static func coverKindString(_ asset: PHAsset?) -> String? {
|
||||
guard let asset else { return nil }
|
||||
return asset.mediaType == .video ? "video" : "image"
|
||||
}
|
||||
|
||||
// MARK: - Assets
|
||||
|
||||
private static func kindFilter(_ raw: Any?) -> PHAssetMediaType? {
|
||||
switch raw as? String {
|
||||
case "image": return .image
|
||||
case "video": return .video
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAssets(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
let args = call.arguments as? [String: Any] ?? [:]
|
||||
let albumId = args["albumId"] as? String
|
||||
let filterRaw = args["filter"] as? String ?? "any"
|
||||
let filter = Self.kindFilter(filterRaw)
|
||||
let start = args["start"] as? Int ?? 0
|
||||
let end = args["end"] as? Int ?? 0
|
||||
|
||||
let cacheKey = "\(albumId ?? "_recents")|\(filterRaw)"
|
||||
let fetch = fetchCache[cacheKey] ?? {
|
||||
let options = PHFetchOptions()
|
||||
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
|
||||
if let f = filter {
|
||||
options.predicate = NSPredicate(format: "mediaType == %d", f.rawValue)
|
||||
}
|
||||
let result: PHFetchResult<PHAsset>
|
||||
if let id = albumId, id != "recents" {
|
||||
if let collection = PHAssetCollection.fetchAssetCollections(
|
||||
withLocalIdentifiers: [id], options: nil,
|
||||
).firstObject {
|
||||
result = PHAsset.fetchAssets(in: collection, options: options)
|
||||
} else {
|
||||
result = PHAsset.fetchAssets(with: options)
|
||||
}
|
||||
} else {
|
||||
result = PHAsset.fetchAssets(with: options)
|
||||
}
|
||||
fetchCache[cacheKey] = result
|
||||
return result
|
||||
}()
|
||||
|
||||
let total = fetch.count
|
||||
let from = max(0, min(start, total))
|
||||
let to = max(from, min(end, total))
|
||||
var assets: [[String: Any?]] = []
|
||||
assets.reserveCapacity(to - from)
|
||||
for i in from..<to {
|
||||
assets.append(Self.assetMap(fetch.object(at: i)))
|
||||
}
|
||||
result(assets)
|
||||
}
|
||||
|
||||
private static func assetMap(_ asset: PHAsset) -> [String: Any?] {
|
||||
return [
|
||||
"id": asset.localIdentifier,
|
||||
"kind": asset.mediaType == .video ? "video" : "image",
|
||||
"duration_ms": asset.mediaType == .video ? Int(asset.duration * 1000) : nil,
|
||||
"width": asset.pixelWidth,
|
||||
"height": asset.pixelHeight,
|
||||
"created_ms": Int((asset.creationDate ?? Date()).timeIntervalSince1970 * 1000),
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Thumbnail
|
||||
|
||||
private func handleThumbnail(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
let args = call.arguments as? [String: Any] ?? [:]
|
||||
guard let assetId = args["assetId"] as? String,
|
||||
let sizePx = args["sizePx"] as? Int
|
||||
else {
|
||||
result(FlutterError(code: "bad_args", message: "missing assetId/sizePx", details: nil))
|
||||
return
|
||||
}
|
||||
guard let asset = PHAsset.fetchAssets(
|
||||
withLocalIdentifiers: [assetId], options: nil,
|
||||
).firstObject else {
|
||||
result(FlutterError(code: "not_found", message: "no asset for id", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
let options = PHImageRequestOptions()
|
||||
options.deliveryMode = .opportunistic
|
||||
options.resizeMode = .fast
|
||||
options.isNetworkAccessAllowed = false
|
||||
options.isSynchronous = false
|
||||
|
||||
let target = CGSize(width: sizePx, height: sizePx)
|
||||
var delivered = false
|
||||
|
||||
imageManager.requestImage(
|
||||
for: asset,
|
||||
targetSize: target,
|
||||
contentMode: .aspectFill,
|
||||
options: options,
|
||||
) { image, info in
|
||||
// requestImage fires twice (degraded preview + final). Skip the
|
||||
// preview so the channel emits exactly once.
|
||||
let isDegraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false
|
||||
if delivered { return }
|
||||
if isDegraded { return }
|
||||
delivered = true
|
||||
|
||||
guard let image, let data = image.jpegData(compressionQuality: 0.85) else {
|
||||
let err = FlutterError(
|
||||
code: "encode_failed",
|
||||
message: "thumbnail unavailable",
|
||||
details: nil,
|
||||
)
|
||||
DispatchQueue.main.async { result(err) }
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
result([
|
||||
"bytes": FlutterStandardTypedData(bytes: data),
|
||||
"width": Int(image.size.width * image.scale),
|
||||
"height": Int(image.size.height * image.scale),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File resolution
|
||||
|
||||
private func handleResolveFile(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
let args = call.arguments as? [String: Any] ?? [:]
|
||||
guard let assetId = args["assetId"] as? String else {
|
||||
result(FlutterError(code: "bad_args", message: "missing assetId", details: nil))
|
||||
return
|
||||
}
|
||||
guard let asset = PHAsset.fetchAssets(
|
||||
withLocalIdentifiers: [assetId], options: nil,
|
||||
).firstObject else {
|
||||
result(FlutterError(code: "not_found", message: "no asset for id", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
let resources = PHAssetResource.assetResources(for: asset)
|
||||
let primary = resources.first { r in
|
||||
switch r.type {
|
||||
case .photo, .video, .fullSizePhoto, .fullSizeVideo: return true
|
||||
default: return false
|
||||
}
|
||||
} ?? resources.first
|
||||
|
||||
guard let resource = primary else {
|
||||
result(FlutterError(code: "no_resource", message: "asset has no readable resource", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
let cacheDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("ux_gallery", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(
|
||||
at: cacheDir, withIntermediateDirectories: true,
|
||||
)
|
||||
|
||||
let ext = (resource.originalFilename as NSString).pathExtension
|
||||
let safe = assetId.replacingOccurrences(of: "/", with: "_")
|
||||
let fileURL = cacheDir.appendingPathComponent(
|
||||
"\(safe).\(ext.isEmpty ? "bin" : ext)",
|
||||
)
|
||||
|
||||
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
result(fileURL.path)
|
||||
return
|
||||
}
|
||||
|
||||
let opts = PHAssetResourceRequestOptions()
|
||||
opts.isNetworkAccessAllowed = true
|
||||
|
||||
PHAssetResourceManager.default().writeData(
|
||||
for: resource, toFile: fileURL, options: opts,
|
||||
) { error in
|
||||
DispatchQueue.main.async {
|
||||
if let error {
|
||||
result(FlutterError(
|
||||
code: "write_failed",
|
||||
message: error.localizedDescription,
|
||||
details: nil,
|
||||
))
|
||||
} else {
|
||||
result(fileURL.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ public class UxPlugin: NSObject, FlutterPlugin {
|
||||
FilePlugin(),
|
||||
ScannerPlugin(),
|
||||
ClipboardPlugin(),
|
||||
GalleryPlugin(),
|
||||
]
|
||||
for plugin in plugins {
|
||||
plugin.register(with: registrar)
|
||||
|
||||
280
lib/src/gallery.dart
Normal file
280
lib/src/gallery.dart
Normal file
@@ -0,0 +1,280 @@
|
||||
import 'dart:io' as io;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Authorisation state of the system photo library.
|
||||
///
|
||||
/// Mirrors the union of `PHAuthorizationStatus` (iOS / macOS) and
|
||||
/// Android's manifest-permission outcomes:
|
||||
/// - [notDetermined] — never asked. Prompt the user with
|
||||
/// [UxGallery.requestPermission].
|
||||
/// - [denied] — user said no. [UxGallery.openSettings] is the only
|
||||
/// way back.
|
||||
/// - [restricted] — parental controls / MDM. Same UI as [denied].
|
||||
/// - [limited] — iOS 14+. User picked a subset; the grid still
|
||||
/// populates from that subset. Call
|
||||
/// [UxGallery.presentLimitedLibraryPicker] to let them adjust.
|
||||
/// - [granted] — full access.
|
||||
enum UxGalleryPermission { notDetermined, denied, restricted, limited, granted }
|
||||
|
||||
enum UxAssetKind { image, video }
|
||||
|
||||
/// A user-visible album / collection in the system photo library
|
||||
/// (`PHAssetCollection` on Apple, `MediaStore.Bucket` on Android).
|
||||
class UxAlbum {
|
||||
const UxAlbum({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.count,
|
||||
this.coverKind,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final int count;
|
||||
final UxAssetKind? coverKind;
|
||||
}
|
||||
|
||||
/// A single asset (photo or video) in the system library
|
||||
/// (`PHAsset` / `ContentResolver` row).
|
||||
class UxAsset {
|
||||
const UxAsset({
|
||||
required this.id,
|
||||
required this.kind,
|
||||
this.duration,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
/// Stable identifier (e.g. iOS `localIdentifier`, Android `content://` URI).
|
||||
final String id;
|
||||
final UxAssetKind kind;
|
||||
|
||||
/// Duration for videos; null for photos.
|
||||
final Duration? duration;
|
||||
final int width;
|
||||
final int height;
|
||||
final DateTime createdAt;
|
||||
}
|
||||
|
||||
/// Cell-sized thumbnail bytes ready for `Image.memory`. Backed by
|
||||
/// `PHCachingImageManager` on Apple and `MediaStore.Thumbnails` on
|
||||
/// Android.
|
||||
class UxAssetThumbnail {
|
||||
const UxAssetThumbnail({
|
||||
required this.bytes,
|
||||
required this.width,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
final Uint8List bytes;
|
||||
final int width;
|
||||
final int height;
|
||||
}
|
||||
|
||||
/// Backend contract that [UxGallery] dispatches into. The default
|
||||
/// implementation calls into native code via the `ux/gallery` method
|
||||
/// channel; tests substitute their own (see
|
||||
/// `ux/lib/testing.dart`'s `FakeUxGalleryBackend`).
|
||||
abstract class UxGalleryBackend {
|
||||
Future<UxGalleryPermission> permission();
|
||||
Future<UxGalleryPermission> requestPermission();
|
||||
Future<void> openSettings();
|
||||
Future<void> presentLimitedLibraryPicker();
|
||||
Future<List<UxAlbum>> albums({UxAssetKind? filter});
|
||||
Future<List<UxAsset>> assets({
|
||||
String? albumId,
|
||||
UxAssetKind? filter,
|
||||
required int start,
|
||||
required int end,
|
||||
});
|
||||
Future<UxAssetThumbnail> thumbnail(String assetId, {required int sizePx});
|
||||
Future<io.File> resolveFile(String assetId);
|
||||
}
|
||||
|
||||
/// Static facade for the system photo library. All state lives in the
|
||||
/// platform; this class is a thin Dart-side wrapper that dispatches
|
||||
/// into [backend].
|
||||
class UxGallery {
|
||||
UxGallery._();
|
||||
|
||||
/// Swap to inject a fake (e.g.
|
||||
/// `FakeUxGalleryBackend` from `package:ux/testing.dart`) before
|
||||
/// any UI code mounts.
|
||||
static UxGalleryBackend backend = MethodChannelGalleryBackend();
|
||||
|
||||
static Future<UxGalleryPermission> permission() => backend.permission();
|
||||
|
||||
static Future<UxGalleryPermission> requestPermission() =>
|
||||
backend.requestPermission();
|
||||
|
||||
static Future<void> openSettings() => backend.openSettings();
|
||||
|
||||
/// iOS 14+ only — opens the system "manage limited access" sheet.
|
||||
/// No-op on Android / older iOS.
|
||||
static Future<void> presentLimitedLibraryPicker() =>
|
||||
backend.presentLimitedLibraryPicker();
|
||||
|
||||
/// Albums in the user's library, ordered Recents → smart → user.
|
||||
/// Pass [filter] to restrict to image-only / video-only albums.
|
||||
static Future<List<UxAlbum>> albums({UxAssetKind? filter}) =>
|
||||
backend.albums(filter: filter);
|
||||
|
||||
/// Paginate assets within [albumId] (or all assets if null), sorted
|
||||
/// by `createdAt DESC`. [start] inclusive, [end] exclusive.
|
||||
static Future<List<UxAsset>> assets({
|
||||
String? albumId,
|
||||
UxAssetKind? filter,
|
||||
required int start,
|
||||
required int end,
|
||||
}) =>
|
||||
backend.assets(
|
||||
albumId: albumId,
|
||||
filter: filter,
|
||||
start: start,
|
||||
end: end,
|
||||
);
|
||||
|
||||
/// Cell-sized thumbnail at `~max(width,height) <= sizePx`. Native
|
||||
/// caches keep repeated calls cheap; the caller still maintains a
|
||||
/// small Dart-side LRU keyed by `(assetId, sizePx)`.
|
||||
static Future<UxAssetThumbnail> thumbnail(
|
||||
String assetId, {
|
||||
required int sizePx,
|
||||
}) =>
|
||||
backend.thumbnail(assetId, sizePx: sizePx);
|
||||
|
||||
/// Resolve a real `io.File` for the asset. Photo / video alike.
|
||||
/// On Android, this stream-copies a `content://` source into the
|
||||
/// app cache (since `dart:io` can't read content URIs directly).
|
||||
static Future<io.File> resolveFile(String assetId) =>
|
||||
backend.resolveFile(assetId);
|
||||
}
|
||||
|
||||
/// Default [UxGalleryBackend] — dispatches to native code via the
|
||||
/// `ux/gallery` method channel. Public so test code can reinstall it
|
||||
/// after swapping to a fake.
|
||||
class MethodChannelGalleryBackend implements UxGalleryBackend {
|
||||
static const _channel = MethodChannel('ux/gallery');
|
||||
|
||||
static String _kindArg(UxAssetKind? k) => switch (k) {
|
||||
null => 'any',
|
||||
UxAssetKind.image => 'image',
|
||||
UxAssetKind.video => 'video',
|
||||
};
|
||||
|
||||
static UxGalleryPermission _parsePermission(String? name) =>
|
||||
switch (name) {
|
||||
'notDetermined' => UxGalleryPermission.notDetermined,
|
||||
'denied' => UxGalleryPermission.denied,
|
||||
'restricted' => UxGalleryPermission.restricted,
|
||||
'limited' => UxGalleryPermission.limited,
|
||||
'granted' => UxGalleryPermission.granted,
|
||||
_ => UxGalleryPermission.denied,
|
||||
};
|
||||
|
||||
static UxAssetKind _parseKind(Object? v) =>
|
||||
v == 'video' ? UxAssetKind.video : UxAssetKind.image;
|
||||
|
||||
static UxAsset _parseAsset(Map<Object?, Object?> m) => UxAsset(
|
||||
id: m['id']! as String,
|
||||
kind: _parseKind(m['kind']),
|
||||
duration: m['duration_ms'] != null
|
||||
? Duration(milliseconds: (m['duration_ms']! as num).toInt())
|
||||
: null,
|
||||
width: (m['width']! as num).toInt(),
|
||||
height: (m['height']! as num).toInt(),
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
(m['created_ms']! as num).toInt(),
|
||||
),
|
||||
);
|
||||
|
||||
static UxAlbum _parseAlbum(Map<Object?, Object?> m) => UxAlbum(
|
||||
id: m['id']! as String,
|
||||
name: m['name']! as String,
|
||||
count: (m['count']! as num).toInt(),
|
||||
coverKind: m['cover_kind'] == null ? null : _parseKind(m['cover_kind']),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<UxGalleryPermission> permission() async {
|
||||
final s = await _channel.invokeMethod<String>('permission');
|
||||
return _parsePermission(s);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UxGalleryPermission> requestPermission() async {
|
||||
final s = await _channel.invokeMethod<String>('requestPermission');
|
||||
return _parsePermission(s);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openSettings() async {
|
||||
await _channel.invokeMethod<void>('openSettings');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> presentLimitedLibraryPicker() async {
|
||||
await _channel.invokeMethod<void>('presentLimitedLibraryPicker');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<UxAlbum>> albums({UxAssetKind? filter}) async {
|
||||
final list = await _channel.invokeMethod<List<Object?>>(
|
||||
'albums',
|
||||
{'filter': _kindArg(filter)},
|
||||
);
|
||||
if (list == null) return const [];
|
||||
return [
|
||||
for (final raw in list) _parseAlbum((raw! as Map).cast()),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<UxAsset>> assets({
|
||||
String? albumId,
|
||||
UxAssetKind? filter,
|
||||
required int start,
|
||||
required int end,
|
||||
}) async {
|
||||
final list = await _channel.invokeMethod<List<Object?>>(
|
||||
'assets',
|
||||
{
|
||||
'albumId': albumId,
|
||||
'filter': _kindArg(filter),
|
||||
'start': start,
|
||||
'end': end,
|
||||
},
|
||||
);
|
||||
if (list == null) return const [];
|
||||
return [
|
||||
for (final raw in list) _parseAsset((raw! as Map).cast()),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UxAssetThumbnail> thumbnail(
|
||||
String assetId, {
|
||||
required int sizePx,
|
||||
}) async {
|
||||
final m = await _channel.invokeMethod<Map<Object?, Object?>>(
|
||||
'thumbnail',
|
||||
{'assetId': assetId, 'sizePx': sizePx},
|
||||
);
|
||||
return UxAssetThumbnail(
|
||||
bytes: m!['bytes']! as Uint8List,
|
||||
width: (m['width']! as num).toInt(),
|
||||
height: (m['height']! as num).toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<io.File> resolveFile(String assetId) async {
|
||||
final path = await _channel.invokeMethod<String>(
|
||||
'resolveFile',
|
||||
{'assetId': assetId},
|
||||
);
|
||||
return io.File(path!);
|
||||
}
|
||||
}
|
||||
119
lib/src/testing/fake_gallery.dart
Normal file
119
lib/src/testing/fake_gallery.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io' as io;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:ux/src/gallery.dart';
|
||||
|
||||
/// 1×1 transparent PNG. Decodable by `Image.memory`, so tests that
|
||||
/// mount the picker UI against [FakeUxGalleryBackend] don't need to
|
||||
/// provide their own thumbnail bytes.
|
||||
final Uint8List _placeholderPng = base64Decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
);
|
||||
|
||||
/// In-memory backend for [UxGallery] tests. Swap in via
|
||||
/// `UxGallery.backend = FakeUxGalleryBackend(...)` before any UI
|
||||
/// mounts; restore with `UxGallery.backend = ...` (or by replacing
|
||||
/// with another fake) in `tearDown`.
|
||||
class FakeUxGalleryBackend implements UxGalleryBackend {
|
||||
FakeUxGalleryBackend({
|
||||
this.permissionState = UxGalleryPermission.granted,
|
||||
List<UxAlbum> albums = const [],
|
||||
Map<String, List<UxAsset>> assetsByAlbum = const {},
|
||||
List<UxAsset> recents = const [],
|
||||
this.onRequestPermission,
|
||||
this.onOpenSettings,
|
||||
this.onPresentLimitedLibraryPicker,
|
||||
UxAssetThumbnail Function(String assetId, int sizePx)? thumbnailFor,
|
||||
io.File Function(String assetId)? fileFor,
|
||||
}) : _albums = List.unmodifiable(albums),
|
||||
_assetsByAlbum = Map.unmodifiable(
|
||||
assetsByAlbum.map(
|
||||
(k, v) => MapEntry(k, List<UxAsset>.unmodifiable(v)),
|
||||
),
|
||||
),
|
||||
_recents = List.unmodifiable(recents),
|
||||
_thumbnailFor = thumbnailFor ?? _defaultThumbnail,
|
||||
_fileFor = fileFor ?? _defaultFile;
|
||||
|
||||
/// Mutable so tests can simulate user grant after `requestPermission`.
|
||||
UxGalleryPermission permissionState;
|
||||
|
||||
final List<UxAlbum> _albums;
|
||||
final Map<String, List<UxAsset>> _assetsByAlbum;
|
||||
final List<UxAsset> _recents;
|
||||
final UxAssetThumbnail Function(String assetId, int sizePx) _thumbnailFor;
|
||||
final io.File Function(String assetId) _fileFor;
|
||||
|
||||
/// Optional hook fired on `requestPermission`. Default updates
|
||||
/// `permissionState` to [UxGalleryPermission.granted].
|
||||
final UxGalleryPermission Function()? onRequestPermission;
|
||||
final void Function()? onOpenSettings;
|
||||
final void Function()? onPresentLimitedLibraryPicker;
|
||||
|
||||
static UxAssetThumbnail _defaultThumbnail(String _, int sizePx) =>
|
||||
UxAssetThumbnail(
|
||||
bytes: _placeholderPng,
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
);
|
||||
|
||||
static io.File _defaultFile(String assetId) =>
|
||||
io.File('/dev/null/$assetId');
|
||||
|
||||
@override
|
||||
Future<UxGalleryPermission> permission() async => permissionState;
|
||||
|
||||
@override
|
||||
Future<UxGalleryPermission> requestPermission() async {
|
||||
permissionState =
|
||||
onRequestPermission?.call() ?? UxGalleryPermission.granted;
|
||||
return permissionState;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openSettings() async {
|
||||
onOpenSettings?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> presentLimitedLibraryPicker() async {
|
||||
onPresentLimitedLibraryPicker?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<UxAlbum>> albums({UxAssetKind? filter}) async {
|
||||
if (filter == null) return _albums;
|
||||
return [
|
||||
for (final a in _albums)
|
||||
if (a.coverKind == null || a.coverKind == filter) a,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<UxAsset>> assets({
|
||||
String? albumId,
|
||||
UxAssetKind? filter,
|
||||
required int start,
|
||||
required int end,
|
||||
}) async {
|
||||
final source = albumId == null
|
||||
? _recents
|
||||
: _assetsByAlbum[albumId] ?? const <UxAsset>[];
|
||||
final filtered = filter == null
|
||||
? source
|
||||
: [for (final a in source) if (a.kind == filter) a];
|
||||
if (start >= filtered.length) return const [];
|
||||
return filtered.sublist(start, end.clamp(start, filtered.length));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UxAssetThumbnail> thumbnail(
|
||||
String assetId, {
|
||||
required int sizePx,
|
||||
}) async =>
|
||||
_thumbnailFor(assetId, sizePx);
|
||||
|
||||
@override
|
||||
Future<io.File> resolveFile(String assetId) async => _fileFor(assetId);
|
||||
}
|
||||
@@ -5,4 +5,5 @@
|
||||
/// ```
|
||||
library;
|
||||
|
||||
export 'src/testing/fake_gallery.dart';
|
||||
export 'src/testing/text_golden.dart';
|
||||
|
||||
@@ -10,6 +10,7 @@ export 'src/json_extension.dart';
|
||||
export 'src/bezier.dart';
|
||||
export 'src/clipboard.dart';
|
||||
export 'src/file.dart';
|
||||
export 'src/gallery.dart';
|
||||
export 'src/keyboard.dart';
|
||||
export 'src/auto_map.dart';
|
||||
export 'src/scanner.dart';
|
||||
|
||||
356
macos/Classes/GalleryPlugin.swift
Normal file
356
macos/Classes/GalleryPlugin.swift
Normal file
@@ -0,0 +1,356 @@
|
||||
import AppKit
|
||||
import FlutterMacOS
|
||||
import Photos
|
||||
|
||||
/// macOS counterpart of the iOS gallery bridge — same `Photos.framework`
|
||||
/// data layer, with `NSImage` swapped in for `UIImage` and the
|
||||
/// limited-library picker dropped (macOS has no equivalent).
|
||||
public class GalleryPlugin: NSObject, NativePlugin {
|
||||
private let imageManager = PHCachingImageManager()
|
||||
private var fetchCache: [String: PHFetchResult<PHAsset>] = [:]
|
||||
|
||||
public func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "ux/gallery",
|
||||
binaryMessenger: registrar.messenger,
|
||||
)
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
self?.handle(call: call, result: result)
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "permission":
|
||||
result(Self.permissionString(Self.currentAuthorization()))
|
||||
case "requestPermission":
|
||||
Self.requestAuthorization { status in
|
||||
DispatchQueue.main.async {
|
||||
result(Self.permissionString(status))
|
||||
}
|
||||
}
|
||||
case "openSettings":
|
||||
// macOS opens the Privacy → Photos pane. Falls back to the
|
||||
// top-level System Settings if the URL scheme is unavailable.
|
||||
if let url = URL(
|
||||
string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Photos",
|
||||
) {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
result(nil)
|
||||
case "presentLimitedLibraryPicker":
|
||||
// macOS has no analogue — no-op.
|
||||
result(nil)
|
||||
case "albums":
|
||||
handleAlbums(call: call, result: result)
|
||||
case "assets":
|
||||
handleAssets(call: call, result: result)
|
||||
case "thumbnail":
|
||||
handleThumbnail(call: call, result: result)
|
||||
case "resolveFile":
|
||||
handleResolveFile(call: call, result: result)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Permission
|
||||
|
||||
private static func currentAuthorization() -> PHAuthorizationStatus {
|
||||
if #available(macOS 11.0, *) {
|
||||
return PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
}
|
||||
return PHPhotoLibrary.authorizationStatus()
|
||||
}
|
||||
|
||||
private static func requestAuthorization(_ handler: @escaping (PHAuthorizationStatus) -> Void) {
|
||||
if #available(macOS 11.0, *) {
|
||||
PHPhotoLibrary.requestAuthorization(for: .readWrite, handler: handler)
|
||||
} else {
|
||||
PHPhotoLibrary.requestAuthorization(handler)
|
||||
}
|
||||
}
|
||||
|
||||
private static func permissionString(_ status: PHAuthorizationStatus) -> String {
|
||||
switch status {
|
||||
case .notDetermined: return "notDetermined"
|
||||
case .denied: return "denied"
|
||||
case .restricted: return "restricted"
|
||||
case .authorized: return "granted"
|
||||
default:
|
||||
if #available(macOS 11.0, *), status == .limited { return "limited" }
|
||||
return "denied"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Albums
|
||||
|
||||
private static let smartAlbumKept: [PHAssetCollectionSubtype] = [
|
||||
.smartAlbumVideos,
|
||||
.smartAlbumFavorites,
|
||||
.smartAlbumScreenshots,
|
||||
]
|
||||
|
||||
private func handleAlbums(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
let args = call.arguments as? [String: Any] ?? [:]
|
||||
let filter = Self.kindFilter(args["filter"])
|
||||
|
||||
let baseOptions = PHFetchOptions()
|
||||
if let f = filter {
|
||||
baseOptions.predicate = NSPredicate(format: "mediaType == %d", f.rawValue)
|
||||
}
|
||||
|
||||
var albums: [[String: Any?]] = []
|
||||
|
||||
let recents = PHAsset.fetchAssets(with: baseOptions)
|
||||
if recents.count > 0 {
|
||||
albums.append([
|
||||
"id": "recents",
|
||||
"name": "Recents",
|
||||
"count": recents.count,
|
||||
"cover_kind": Self.coverKindString(recents.firstObject),
|
||||
])
|
||||
}
|
||||
|
||||
for subtype in Self.smartAlbumKept {
|
||||
let collections = PHAssetCollection.fetchAssetCollections(
|
||||
with: .smartAlbum, subtype: subtype, options: nil,
|
||||
)
|
||||
collections.enumerateObjects { collection, _, _ in
|
||||
let assets = PHAsset.fetchAssets(in: collection, options: baseOptions)
|
||||
if assets.count > 0 {
|
||||
albums.append([
|
||||
"id": collection.localIdentifier,
|
||||
"name": collection.localizedTitle ?? "",
|
||||
"count": assets.count,
|
||||
"cover_kind": Self.coverKindString(assets.firstObject),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let userCollections = PHAssetCollection.fetchAssetCollections(
|
||||
with: .album, subtype: .albumRegular, options: nil,
|
||||
)
|
||||
userCollections.enumerateObjects { collection, _, _ in
|
||||
let assets = PHAsset.fetchAssets(in: collection, options: baseOptions)
|
||||
if assets.count > 0 {
|
||||
albums.append([
|
||||
"id": collection.localIdentifier,
|
||||
"name": collection.localizedTitle ?? "",
|
||||
"count": assets.count,
|
||||
"cover_kind": Self.coverKindString(assets.firstObject),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
result(albums)
|
||||
}
|
||||
|
||||
private static func coverKindString(_ asset: PHAsset?) -> String? {
|
||||
guard let asset else { return nil }
|
||||
return asset.mediaType == .video ? "video" : "image"
|
||||
}
|
||||
|
||||
// MARK: - Assets
|
||||
|
||||
private static func kindFilter(_ raw: Any?) -> PHAssetMediaType? {
|
||||
switch raw as? String {
|
||||
case "image": return .image
|
||||
case "video": return .video
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAssets(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
let args = call.arguments as? [String: Any] ?? [:]
|
||||
let albumId = args["albumId"] as? String
|
||||
let filterRaw = args["filter"] as? String ?? "any"
|
||||
let filter = Self.kindFilter(filterRaw)
|
||||
let start = args["start"] as? Int ?? 0
|
||||
let end = args["end"] as? Int ?? 0
|
||||
|
||||
let cacheKey = "\(albumId ?? "_recents")|\(filterRaw)"
|
||||
let fetch = fetchCache[cacheKey] ?? {
|
||||
let options = PHFetchOptions()
|
||||
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
|
||||
if let f = filter {
|
||||
options.predicate = NSPredicate(format: "mediaType == %d", f.rawValue)
|
||||
}
|
||||
let result: PHFetchResult<PHAsset>
|
||||
if let id = albumId, id != "recents" {
|
||||
if let collection = PHAssetCollection.fetchAssetCollections(
|
||||
withLocalIdentifiers: [id], options: nil,
|
||||
).firstObject {
|
||||
result = PHAsset.fetchAssets(in: collection, options: options)
|
||||
} else {
|
||||
result = PHAsset.fetchAssets(with: options)
|
||||
}
|
||||
} else {
|
||||
result = PHAsset.fetchAssets(with: options)
|
||||
}
|
||||
fetchCache[cacheKey] = result
|
||||
return result
|
||||
}()
|
||||
|
||||
let total = fetch.count
|
||||
let from = max(0, min(start, total))
|
||||
let to = max(from, min(end, total))
|
||||
var assets: [[String: Any?]] = []
|
||||
assets.reserveCapacity(to - from)
|
||||
for i in from..<to {
|
||||
assets.append(Self.assetMap(fetch.object(at: i)))
|
||||
}
|
||||
result(assets)
|
||||
}
|
||||
|
||||
private static func assetMap(_ asset: PHAsset) -> [String: Any?] {
|
||||
return [
|
||||
"id": asset.localIdentifier,
|
||||
"kind": asset.mediaType == .video ? "video" : "image",
|
||||
"duration_ms": asset.mediaType == .video ? Int(asset.duration * 1000) : nil,
|
||||
"width": asset.pixelWidth,
|
||||
"height": asset.pixelHeight,
|
||||
"created_ms": Int((asset.creationDate ?? Date()).timeIntervalSince1970 * 1000),
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Thumbnail
|
||||
|
||||
private func handleThumbnail(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
let args = call.arguments as? [String: Any] ?? [:]
|
||||
guard let assetId = args["assetId"] as? String,
|
||||
let sizePx = args["sizePx"] as? Int
|
||||
else {
|
||||
result(FlutterError(code: "bad_args", message: "missing assetId/sizePx", details: nil))
|
||||
return
|
||||
}
|
||||
guard let asset = PHAsset.fetchAssets(
|
||||
withLocalIdentifiers: [assetId], options: nil,
|
||||
).firstObject else {
|
||||
result(FlutterError(code: "not_found", message: "no asset for id", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
let options = PHImageRequestOptions()
|
||||
options.deliveryMode = .opportunistic
|
||||
options.resizeMode = .fast
|
||||
options.isNetworkAccessAllowed = false
|
||||
options.isSynchronous = false
|
||||
|
||||
let target = CGSize(width: sizePx, height: sizePx)
|
||||
var delivered = false
|
||||
|
||||
imageManager.requestImage(
|
||||
for: asset,
|
||||
targetSize: target,
|
||||
contentMode: .aspectFill,
|
||||
options: options,
|
||||
) { image, info in
|
||||
let isDegraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false
|
||||
if delivered { return }
|
||||
if isDegraded { return }
|
||||
delivered = true
|
||||
|
||||
guard let image,
|
||||
let cg = image.cgImage(forProposedRect: nil, context: nil, hints: nil)
|
||||
else {
|
||||
let err = FlutterError(
|
||||
code: "encode_failed",
|
||||
message: "thumbnail unavailable",
|
||||
details: nil,
|
||||
)
|
||||
DispatchQueue.main.async { result(err) }
|
||||
return
|
||||
}
|
||||
|
||||
let rep = NSBitmapImageRep(cgImage: cg)
|
||||
rep.size = NSSize(width: cg.width, height: cg.height)
|
||||
guard let data = rep.representation(
|
||||
using: .jpeg,
|
||||
properties: [.compressionFactor: 0.85],
|
||||
) else {
|
||||
let err = FlutterError(
|
||||
code: "encode_failed",
|
||||
message: "jpeg encode failed",
|
||||
details: nil,
|
||||
)
|
||||
DispatchQueue.main.async { result(err) }
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
result([
|
||||
"bytes": FlutterStandardTypedData(bytes: data),
|
||||
"width": cg.width,
|
||||
"height": cg.height,
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File resolution
|
||||
|
||||
private func handleResolveFile(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
let args = call.arguments as? [String: Any] ?? [:]
|
||||
guard let assetId = args["assetId"] as? String else {
|
||||
result(FlutterError(code: "bad_args", message: "missing assetId", details: nil))
|
||||
return
|
||||
}
|
||||
guard let asset = PHAsset.fetchAssets(
|
||||
withLocalIdentifiers: [assetId], options: nil,
|
||||
).firstObject else {
|
||||
result(FlutterError(code: "not_found", message: "no asset for id", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
let resources = PHAssetResource.assetResources(for: asset)
|
||||
let primary = resources.first { r in
|
||||
switch r.type {
|
||||
case .photo, .video, .fullSizePhoto, .fullSizeVideo: return true
|
||||
default: return false
|
||||
}
|
||||
} ?? resources.first
|
||||
|
||||
guard let resource = primary else {
|
||||
result(FlutterError(code: "no_resource", message: "asset has no readable resource", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
let cacheDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("ux_gallery", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(
|
||||
at: cacheDir, withIntermediateDirectories: true,
|
||||
)
|
||||
|
||||
let ext = (resource.originalFilename as NSString).pathExtension
|
||||
let safe = assetId.replacingOccurrences(of: "/", with: "_")
|
||||
let fileURL = cacheDir.appendingPathComponent(
|
||||
"\(safe).\(ext.isEmpty ? "bin" : ext)",
|
||||
)
|
||||
|
||||
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
result(fileURL.path)
|
||||
return
|
||||
}
|
||||
|
||||
let opts = PHAssetResourceRequestOptions()
|
||||
opts.isNetworkAccessAllowed = true
|
||||
|
||||
PHAssetResourceManager.default().writeData(
|
||||
for: resource, toFile: fileURL, options: opts,
|
||||
) { error in
|
||||
DispatchQueue.main.async {
|
||||
if let error {
|
||||
result(FlutterError(
|
||||
code: "write_failed",
|
||||
message: error.localizedDescription,
|
||||
details: nil,
|
||||
))
|
||||
} else {
|
||||
result(fileURL.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ public class UxPlugin: NSObject, FlutterPlugin {
|
||||
plugins = [
|
||||
FilePlugin(),
|
||||
ClipboardPlugin(),
|
||||
GalleryPlugin(),
|
||||
]
|
||||
for plugin in plugins {
|
||||
plugin.register(with: registrar)
|
||||
|
||||
190
test/gallery_test.dart
Normal file
190
test/gallery_test.dart
Normal file
@@ -0,0 +1,190 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ux/testing.dart';
|
||||
import 'package:ux/ux.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('UxGallery facade — method channel parsing', () {
|
||||
const channel = MethodChannel('ux/gallery');
|
||||
|
||||
setUp(() {
|
||||
UxGallery.backend = MethodChannelGalleryBackend();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
test('permission() parses the granted state', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
expect(call.method, 'permission');
|
||||
return 'granted';
|
||||
});
|
||||
|
||||
expect(await UxGallery.permission(), UxGalleryPermission.granted);
|
||||
});
|
||||
|
||||
test('permission() falls back to denied on unknown values', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (_) async => 'mystery');
|
||||
|
||||
expect(await UxGallery.permission(), UxGalleryPermission.denied);
|
||||
});
|
||||
|
||||
test('albums() decodes a list of UxAlbum', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
expect(call.method, 'albums');
|
||||
expect((call.arguments as Map)['filter'], 'any');
|
||||
return [
|
||||
{
|
||||
'id': 'recents',
|
||||
'name': 'Recents',
|
||||
'count': 1234,
|
||||
'cover_kind': 'image',
|
||||
},
|
||||
{
|
||||
'id': 'videos',
|
||||
'name': 'Videos',
|
||||
'count': 12,
|
||||
'cover_kind': 'video',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
final albums = await UxGallery.albums();
|
||||
expect(albums, hasLength(2));
|
||||
expect(albums[0].id, 'recents');
|
||||
expect(albums[0].count, 1234);
|
||||
expect(albums[0].coverKind, UxAssetKind.image);
|
||||
expect(albums[1].coverKind, UxAssetKind.video);
|
||||
});
|
||||
|
||||
test('assets() decodes durations and timestamps', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
expect(call.method, 'assets');
|
||||
final args = call.arguments as Map;
|
||||
expect(args['albumId'], 'recents');
|
||||
expect(args['filter'], 'video');
|
||||
expect(args['start'], 0);
|
||||
expect(args['end'], 60);
|
||||
return [
|
||||
{
|
||||
'id': 'a1',
|
||||
'kind': 'video',
|
||||
'duration_ms': 8500,
|
||||
'width': 1080,
|
||||
'height': 1920,
|
||||
'created_ms': 1700000000000,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
final assets = await UxGallery.assets(
|
||||
albumId: 'recents',
|
||||
filter: UxAssetKind.video,
|
||||
start: 0,
|
||||
end: 60,
|
||||
);
|
||||
expect(assets, hasLength(1));
|
||||
expect(assets.single.kind, UxAssetKind.video);
|
||||
expect(assets.single.duration, const Duration(milliseconds: 8500));
|
||||
expect(assets.single.createdAt.millisecondsSinceEpoch, 1700000000000);
|
||||
});
|
||||
|
||||
test('thumbnail() returns the byte payload + dims', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
expect(call.method, 'thumbnail');
|
||||
final args = call.arguments as Map;
|
||||
expect(args['assetId'], 'a1');
|
||||
expect(args['sizePx'], 381);
|
||||
return {
|
||||
'bytes': Uint8List.fromList([1, 2, 3]),
|
||||
'width': 381,
|
||||
'height': 254,
|
||||
};
|
||||
});
|
||||
|
||||
final thumb = await UxGallery.thumbnail('a1', sizePx: 381);
|
||||
expect(thumb.bytes, [1, 2, 3]);
|
||||
expect(thumb.width, 381);
|
||||
expect(thumb.height, 254);
|
||||
});
|
||||
});
|
||||
|
||||
group('FakeUxGalleryBackend', () {
|
||||
test('requestPermission flips state to granted by default', () async {
|
||||
final fake = FakeUxGalleryBackend(
|
||||
permissionState: UxGalleryPermission.notDetermined,
|
||||
);
|
||||
UxGallery.backend = fake;
|
||||
|
||||
expect(await UxGallery.permission(),
|
||||
UxGalleryPermission.notDetermined);
|
||||
expect(await UxGallery.requestPermission(),
|
||||
UxGalleryPermission.granted);
|
||||
expect(await UxGallery.permission(), UxGalleryPermission.granted);
|
||||
});
|
||||
|
||||
test('assets honours the (start, end) page window per album', () async {
|
||||
final pile = [
|
||||
for (var i = 0; i < 50; i++)
|
||||
UxAsset(
|
||||
id: 'a$i',
|
||||
kind: UxAssetKind.image,
|
||||
width: 100,
|
||||
height: 100,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(i * 1000),
|
||||
),
|
||||
];
|
||||
final fake = FakeUxGalleryBackend(
|
||||
recents: pile,
|
||||
assetsByAlbum: {'all': pile},
|
||||
);
|
||||
UxGallery.backend = fake;
|
||||
|
||||
final firstPage = await UxGallery.assets(start: 0, end: 10);
|
||||
expect(firstPage.map((a) => a.id), [
|
||||
for (var i = 0; i < 10; i++) 'a$i',
|
||||
]);
|
||||
final tail = await UxGallery.assets(albumId: 'all', start: 45, end: 999);
|
||||
expect(tail, hasLength(5));
|
||||
final past = await UxGallery.assets(start: 200, end: 210);
|
||||
expect(past, isEmpty);
|
||||
});
|
||||
|
||||
test('assets filters by kind when requested', () async {
|
||||
final mix = [
|
||||
UxAsset(
|
||||
id: 'p',
|
||||
kind: UxAssetKind.image,
|
||||
width: 1,
|
||||
height: 1,
|
||||
createdAt: DateTime(2024),
|
||||
),
|
||||
UxAsset(
|
||||
id: 'v',
|
||||
kind: UxAssetKind.video,
|
||||
duration: const Duration(seconds: 3),
|
||||
width: 1,
|
||||
height: 1,
|
||||
createdAt: DateTime(2024),
|
||||
),
|
||||
];
|
||||
UxGallery.backend = FakeUxGalleryBackend(recents: mix);
|
||||
|
||||
final justVideos = await UxGallery.assets(
|
||||
filter: UxAssetKind.video,
|
||||
start: 0,
|
||||
end: 10,
|
||||
);
|
||||
expect(justVideos.map((a) => a.id), ['v']);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user