...
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(),
|
FilePlugin(),
|
||||||
ScannerPlugin(),
|
ScannerPlugin(),
|
||||||
ClipboardPlugin(),
|
ClipboardPlugin(),
|
||||||
|
GalleryPlugin(),
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =
|
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_ROOT=/Users/agra/sdk/flutter"
|
||||||
export "FLUTTER_APPLICATION_PATH=/Users/agra/projects/ux/example"
|
export "FLUTTER_APPLICATION_PATH=/Users/agra/projects/ux/example"
|
||||||
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
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_DIR=build"
|
||||||
export "FLUTTER_BUILD_NAME=1.0.0"
|
export "FLUTTER_BUILD_NAME=1.0.0"
|
||||||
export "FLUTTER_BUILD_NUMBER=1"
|
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 "DART_OBFUSCATION=false"
|
||||||
export "TRACK_WIDGET_CREATION=true"
|
export "TRACK_WIDGET_CREATION=false"
|
||||||
export "TREE_SHAKE_ICONS=false"
|
export "TREE_SHAKE_ICONS=false"
|
||||||
export "PACKAGE_CONFIG=/Users/agra/projects/ux/example/.dart_tool/package_config.json"
|
export "PACKAGE_CONFIG=/Users/agra/projects/ux/example/.dart_tool/package_config.json"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- ux (0.2.0):
|
- ux (0.9.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
@@ -15,7 +15,7 @@ EXTERNAL SOURCES:
|
|||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
ux: efeefb7a32deec80e615338198f103c26f8c78a1
|
ux: 86fcd1a58b4329c8675be9906edfc6db5ca4e546
|
||||||
|
|
||||||
PODFILE CHECKSUM: 5c8eb167e48255b7544ab290f70b4d6a1076ca06
|
PODFILE CHECKSUM: 5c8eb167e48255b7544ab290f70b4d6a1076ca06
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- FlutterMacOS (1.0.0)
|
- FlutterMacOS (1.0.0)
|
||||||
|
- ux (0.6.0):
|
||||||
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||||
|
- ux (from `Flutter/ephemeral/.symlinks/plugins/ux/macos`)
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
FlutterMacOS:
|
FlutterMacOS:
|
||||||
:path: Flutter/ephemeral
|
:path: Flutter/ephemeral
|
||||||
|
ux:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/ux/macos
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||||
|
ux: 69a2fc0e618f93fbeac7ed16f2fba53d23b3fdfa
|
||||||
|
|
||||||
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
||||||
|
|
||||||
|
|||||||
@@ -240,6 +240,7 @@
|
|||||||
33CC10EB2044A3C60003C045 /* Resources */,
|
33CC10EB2044A3C60003C045 /* Resources */,
|
||||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||||
|
43F7C1DE66BB38BD46D4828D /* [CP] Embed Pods Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -360,6 +361,23 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
|
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 */ = {
|
C226DFC83DE631039283F9D1 /* [CP] Check Pods Manifest.lock */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
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(),
|
FilePlugin(),
|
||||||
ScannerPlugin(),
|
ScannerPlugin(),
|
||||||
ClipboardPlugin(),
|
ClipboardPlugin(),
|
||||||
|
GalleryPlugin(),
|
||||||
]
|
]
|
||||||
for plugin in plugins {
|
for plugin in plugins {
|
||||||
plugin.register(with: registrar)
|
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;
|
library;
|
||||||
|
|
||||||
|
export 'src/testing/fake_gallery.dart';
|
||||||
export 'src/testing/text_golden.dart';
|
export 'src/testing/text_golden.dart';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export 'src/json_extension.dart';
|
|||||||
export 'src/bezier.dart';
|
export 'src/bezier.dart';
|
||||||
export 'src/clipboard.dart';
|
export 'src/clipboard.dart';
|
||||||
export 'src/file.dart';
|
export 'src/file.dart';
|
||||||
|
export 'src/gallery.dart';
|
||||||
export 'src/keyboard.dart';
|
export 'src/keyboard.dart';
|
||||||
export 'src/auto_map.dart';
|
export 'src/auto_map.dart';
|
||||||
export 'src/scanner.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 = [
|
plugins = [
|
||||||
FilePlugin(),
|
FilePlugin(),
|
||||||
ClipboardPlugin(),
|
ClipboardPlugin(),
|
||||||
|
GalleryPlugin(),
|
||||||
]
|
]
|
||||||
for plugin in plugins {
|
for plugin in plugins {
|
||||||
plugin.register(with: registrar)
|
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