This commit is contained in:
agra
2026-05-10 16:37:23 +03:00
parent 65c7a3195a
commit 3eba30358c
16 changed files with 1883 additions and 6 deletions

View 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
}
}

View File

@@ -11,6 +11,7 @@ class UxPlugin : FlutterPlugin, ActivityAware {
FilePlugin(),
ScannerPlugin(),
ClipboardPlugin(),
GalleryPlugin(),
)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =

View File

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

View File

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

View File

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

View File

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

View File

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

View 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)
}
}
}
}
}

View File

@@ -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
View 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!);
}
}

View 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);
}

View File

@@ -5,4 +5,5 @@
/// ```
library;
export 'src/testing/fake_gallery.dart';
export 'src/testing/text_golden.dart';

View File

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

View 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)
}
}
}
}
}

View File

@@ -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
View 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']);
});
});
}