diff --git a/CHANGELOG.md b/CHANGELOG.md index 25b89b94..1942583f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ To know more about breaking changes, see the [Migration Guide][]. - Add optional `latitude`, `longitude`, and `creationDate` parameters to `saveImage`, `saveImageWithPath`, and `saveVideo` methods. - On iOS: Sets location and creation date metadata for saved assets. - On Android Q+: Sets DATE_TAKEN field and location metadata for saved assets. +- Add batch asset move functionality using `createWriteRequest` API for Android 11+. ### Improvements diff --git a/android/src/main/kotlin/com/fluttercandies/photo_manager/PhotoManagerPlugin.kt b/android/src/main/kotlin/com/fluttercandies/photo_manager/PhotoManagerPlugin.kt index 12b03f48..6446e3fe 100644 --- a/android/src/main/kotlin/com/fluttercandies/photo_manager/PhotoManagerPlugin.kt +++ b/android/src/main/kotlin/com/fluttercandies/photo_manager/PhotoManagerPlugin.kt @@ -84,6 +84,7 @@ class PhotoManagerPlugin : FlutterPlugin, ActivityAware { binding.addRequestPermissionsResultListener(listener) plugin?.let { binding.addActivityResultListener(it.deleteManager) + binding.addActivityResultListener(it.writeManager) binding.addActivityResultListener(it.favoriteManager) } } @@ -94,6 +95,7 @@ class PhotoManagerPlugin : FlutterPlugin, ActivityAware { } plugin?.let { p -> oldBinding.removeActivityResultListener(p.deleteManager) + oldBinding.removeActivityResultListener(p.writeManager) oldBinding.removeActivityResultListener(p.favoriteManager) } } diff --git a/android/src/main/kotlin/com/fluttercandies/photo_manager/constant/Methods.kt b/android/src/main/kotlin/com/fluttercandies/photo_manager/constant/Methods.kt index 18fed638..46b959a5 100644 --- a/android/src/main/kotlin/com/fluttercandies/photo_manager/constant/Methods.kt +++ b/android/src/main/kotlin/com/fluttercandies/photo_manager/constant/Methods.kt @@ -84,6 +84,7 @@ class Methods { const val favoriteAsset = "favoriteAsset" const val copyAsset = "copyAsset" const val moveAssetToPath = "moveAssetToPath" + const val moveAssetsToPath = "moveAssetsToPath" const val removeNoExistsAssets = "removeNoExistsAssets" const val getColumnNames = "getColumnNames" diff --git a/android/src/main/kotlin/com/fluttercandies/photo_manager/core/PhotoManagerPlugin.kt b/android/src/main/kotlin/com/fluttercandies/photo_manager/core/PhotoManagerPlugin.kt index 9c3adc0c..9fc52b13 100644 --- a/android/src/main/kotlin/com/fluttercandies/photo_manager/core/PhotoManagerPlugin.kt +++ b/android/src/main/kotlin/com/fluttercandies/photo_manager/core/PhotoManagerPlugin.kt @@ -58,12 +58,14 @@ class PhotoManagerPlugin( } val deleteManager = PhotoManagerDeleteManager(applicationContext, activity) + val writeManager = PhotoManagerWriteManager(applicationContext, activity) val favoriteManager = PhotoManagerFavoriteManager(applicationContext) fun bindActivity(activity: Activity?) { this.activity = activity permissionsUtils.withActivity(activity) deleteManager.bindActivity(activity) + writeManager.bindActivity(activity) favoriteManager.bindActivity(activity) } @@ -584,6 +586,32 @@ class PhotoManagerPlugin( photoManager.moveToGallery(assetId, albumId, resultHandler) } + Methods.moveAssetsToPath -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + try { + val assetIds = call.argument>("assetIds")!! + val targetPath = call.argument("targetPath")!! + + val uris = assetIds.mapNotNull { photoManager.getUri(it) } + if (uris.isEmpty()) { + resultHandler.replyError("No valid URIs found for the given asset IDs") + return + } + + writeManager.moveToPathWithPermission(uris, targetPath, resultHandler) + } catch (e: Exception) { + LogUtils.error("moveAssetsToPath failed", e) + resultHandler.replyError("moveAssetsToPath failed", message = e.message) + } + } else { + LogUtils.error("moveAssetsToPath requires Android 11+ (API 30+)") + resultHandler.replyError( + "moveAssetsToPath requires Android 11+ (API 30+)", + message = "Current API level: ${Build.VERSION.SDK_INT}" + ) + } + } + Methods.deleteWithIds -> { try { val ids = call.argument>("ids")!! diff --git a/android/src/main/kotlin/com/fluttercandies/photo_manager/core/PhotoManagerWriteManager.kt b/android/src/main/kotlin/com/fluttercandies/photo_manager/core/PhotoManagerWriteManager.kt new file mode 100644 index 00000000..f3032183 --- /dev/null +++ b/android/src/main/kotlin/com/fluttercandies/photo_manager/core/PhotoManagerWriteManager.kt @@ -0,0 +1,191 @@ +package com.fluttercandies.photo_manager.core + +import android.app.Activity +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.annotation.RequiresApi +import com.fluttercandies.photo_manager.util.LogUtils +import com.fluttercandies.photo_manager.util.ResultHandler +import io.flutter.plugin.common.PluginRegistry + +/** + * Manager for handling write requests (modifications) on Android 11+ (API 30+) + * Uses MediaStore.createWriteRequest() to request user permission for batch modifications + */ +class PhotoManagerWriteManager(val context: Context, private var activity: Activity?) : + PluginRegistry.ActivityResultListener { + + fun bindActivity(activity: Activity?) { + this.activity = activity + } + + private var androidRWriteRequestCode = 40071 + private var writeHandler: ResultHandler? = null + private var pendingOperation: WriteOperation? = null + + private val cr: ContentResolver + get() = context.contentResolver + + /** + * Represents a pending write operation that will be executed after user grants permission + */ + private data class WriteOperation( + val uris: List, + val targetPath: String, + val operationType: OperationType + ) + + enum class OperationType { + MOVE, // Move files to another folder + UPDATE // Generic update operation + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?): Boolean { + if (requestCode == androidRWriteRequestCode) { + handleWriteResult(resultCode) + return true + } + return false + } + + private fun handleWriteResult(resultCode: Int) { + if (resultCode == Activity.RESULT_OK) { + // User granted permission, execute the pending operation + val operation = pendingOperation + if (operation != null) { + val success = when (operation.operationType) { + OperationType.MOVE -> performMove(operation.uris, operation.targetPath) + OperationType.UPDATE -> performUpdate(operation.uris, operation.targetPath) + } + writeHandler?.reply(success) + } else { + LogUtils.error("No pending operation found after write permission granted") + writeHandler?.reply(false) + } + } else { + // User denied permission + LogUtils.info("User denied write permission") + writeHandler?.reply(false) + } + + // Clean up + pendingOperation = null + writeHandler = null + } + + /** + * Request permission to move assets to a different album/folder on Android 11+ (API 30+) + * + * @param uris List of content URIs to move + * @param targetPath Target RELATIVE_PATH (e.g., "Pictures/MyAlbum") + * @param resultHandler Callback with result (true if successful, false otherwise) + */ + @RequiresApi(Build.VERSION_CODES.R) + fun moveToPathWithPermission(uris: List, targetPath: String, resultHandler: ResultHandler) { + if (activity == null) { + LogUtils.error("Activity is null, cannot request write permission") + resultHandler.reply(false) + return + } + + this.writeHandler = resultHandler + this.pendingOperation = WriteOperation(uris, targetPath, OperationType.MOVE) + + try { + val pendingIntent = MediaStore.createWriteRequest(cr, uris) + activity?.startIntentSenderForResult( + pendingIntent.intentSender, + androidRWriteRequestCode, + null, + 0, + 0, + 0 + ) + } catch (e: Exception) { + LogUtils.error("Failed to create write request", e) + resultHandler.reply(false) + pendingOperation = null + writeHandler = null + } + } + + /** + * Perform the actual move operation after permission is granted + * Updates the RELATIVE_PATH of each URI to move files to a different folder + */ + private fun performMove(uris: List, targetPath: String): Boolean { + return try { + val values = ContentValues().apply { + put(MediaStore.MediaColumns.RELATIVE_PATH, targetPath) + } + + var successCount = 0 + for (uri in uris) { + try { + val updated = cr.update(uri, values, null, null) + if (updated > 0) { + successCount++ + } + } catch (e: Exception) { + LogUtils.error("Failed to move URI: $uri", e) + } + } + + LogUtils.info("Moved $successCount/${uris.size} files to $targetPath") + successCount > 0 // Return true if at least one file was moved + } catch (e: Exception) { + LogUtils.error("Failed to perform move operation", e) + false + } + } + + /** + * Perform a generic update operation after permission is granted + * This can be extended for other types of modifications + */ + private fun performUpdate(uris: List, updateData: String): Boolean { + // Placeholder for generic update operations + // Can be extended based on specific needs + LogUtils.info("Generic update operation not yet implemented") + return false + } + + /** + * Request permission to update/modify assets on Android 11+ (API 30+) + * This is a generic method that can be used for various update operations + * + * @param uris List of content URIs to update + * @param resultHandler Callback with result (true if permission granted, false otherwise) + */ + @RequiresApi(Build.VERSION_CODES.R) + fun requestWritePermission(uris: List, resultHandler: ResultHandler) { + if (activity == null) { + LogUtils.error("Activity is null, cannot request write permission") + resultHandler.reply(false) + return + } + + this.writeHandler = resultHandler + + try { + val pendingIntent = MediaStore.createWriteRequest(cr, uris) + activity?.startIntentSenderForResult( + pendingIntent.intentSender, + androidRWriteRequestCode, + null, + 0, + 0, + 0 + ) + } catch (e: Exception) { + LogUtils.error("Failed to create write request", e) + resultHandler.reply(false) + writeHandler = null + } + } +} diff --git a/example/lib/page/index_page.dart b/example/lib/page/index_page.dart index 5edc2ac7..1454458b 100644 --- a/example/lib/page/index_page.dart +++ b/example/lib/page/index_page.dart @@ -5,6 +5,7 @@ import 'package:photo_manager_example/widget/nav_button.dart'; import 'change_notify_page.dart'; import 'developer/develop_index_page.dart'; import 'home_page.dart'; +import 'move_assets_page.dart'; import 'save_image_example.dart'; class IndexPage extends StatefulWidget { @@ -28,6 +29,7 @@ class _IndexPageState extends State { routePage('Custom filter example', const CustomFilterExamplePage()), routePage('Save media example', const SaveMediaExample()), routePage('Change notify example', const ChangeNotifyExample()), + routePage('Move Assets example', const MoveAssetsBatchTestPage()), routePage('For Developer page', const DeveloperIndexPage()), ], ), diff --git a/example/lib/page/move_assets_example.dart b/example/lib/page/move_assets_example.dart new file mode 100644 index 00000000..59d88a4c --- /dev/null +++ b/example/lib/page/move_assets_example.dart @@ -0,0 +1,135 @@ +// Example: How to use moveAssetsToPath for batch moving assets +// +// This example demonstrates how to move multiple images to a different album +// on Android 11+ (API 30+) with a single user permission dialog. + +import 'package:photo_manager/photo_manager.dart'; + +/// Move multiple assets to a target album with user permission. +/// +/// This method is useful when you want to move 20+ images at once, +/// as it shows only one system permission dialog instead of multiple. +Future moveAssetsToAlbumExample() async { + // Step 1: Request permission + final PermissionState permission = await PhotoManager.requestPermissionExtend(); + if (!permission.isAuth) { + print('Permission denied'); + return; + } + + // Step 2: Get the assets you want to move + // For example, get 20 recent images + final List paths = await PhotoManager.getAssetPathList( + type: RequestType.image, + ); + + if (paths.isEmpty) { + print('No albums found'); + return; + } + + final AssetPathEntity recentAlbum = paths.first; + final List assets = await recentAlbum.getAssetListRange( + start: 0, + end: 20, + ); + + if (assets.isEmpty) { + print('No assets found'); + return; + } + + // Step 3: Define the target path + // IMPORTANT: Use RELATIVE_PATH format, not album ID + // Examples: + // - "Pictures/MyAlbum" + // - "DCIM/Camera" + // - "Pictures/Vacation2024" + final String targetPath = 'Pictures/MyAlbum'; + + // Step 4: Move assets with permission + print('Moving ${assets.length} assets to $targetPath...'); + + final bool success = await PhotoManager.editor.android.moveAssetsToPath( + entities: assets, + targetPath: targetPath, + ); + + if (success) { + print('✅ Successfully moved ${assets.length} assets!'); + print('User approved the permission and files were moved.'); + } else { + print('❌ Failed to move assets.'); + print('User may have denied permission or an error occurred.'); + } +} + +/// Example: Move specific assets (e.g., assets from a specific date) +Future moveSpecificAssetsExample() async { + final PermissionState permission = await PhotoManager.requestPermissionExtend(); + if (!permission.isAuth) return; + + // Get all images + final List paths = await PhotoManager.getAssetPathList( + type: RequestType.image, + ); + + if (paths.isEmpty) return; + + final AssetPathEntity allPhotos = paths.first; + final List allAssets = await allPhotos.getAssetListRange( + start: 0, + end: 100, + ); + + // Filter assets (e.g., from a specific date) + final DateTime targetDate = DateTime(2024, 11, 1); + final List filteredAssets = allAssets.where((asset) { + final DateTime assetDate = asset.createDateTime; + return assetDate.year == targetDate.year && + assetDate.month == targetDate.month && + assetDate.day == targetDate.day; + }).toList(); + + if (filteredAssets.isEmpty) { + print('No assets found for the specified date'); + return; + } + + print('Found ${filteredAssets.length} assets from ${targetDate.toString().split(' ')[0]}'); + + // Move to a date-specific album + final String targetPath = 'Pictures/2024-11-01'; + final bool success = await PhotoManager.editor.android.moveAssetsToPath( + entities: filteredAssets, + targetPath: targetPath, + ); + + print(success ? '✅ Moved successfully' : '❌ Move failed'); +} + +/// Important Notes: +/// +/// 1. Android Version Requirements: +/// - Android 11+ (API 30+): Use moveAssetsToPath +/// - Android 10 and below: Use moveAssetToAnother +/// +/// 2. Target Path Format: +/// - Use RELATIVE_PATH format: "Pictures/AlbumName" +/// - NOT album ID or absolute path +/// - Common prefixes: "Pictures/", "DCIM/", "Download/" +/// +/// 3. User Permission: +/// - Shows a single system dialog for all assets +/// - User can approve or deny the entire batch +/// - Returns false if user denies permission +/// +/// 4. Error Handling: +/// - Returns false on any error (permission denied, invalid path, etc.) +/// - Check Android version before calling (API 30+) +/// - Verify target path format is correct +/// +/// 5. Performance: +/// - Batch operations are efficient (single permission dialog) +/// - Can move 20+ images with one user interaction +/// - Much better UX than individual move operations diff --git a/example/lib/page/move_assets_page.dart b/example/lib/page/move_assets_page.dart new file mode 100644 index 00000000..b1596fe1 --- /dev/null +++ b/example/lib/page/move_assets_page.dart @@ -0,0 +1,332 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:oktoast/oktoast.dart'; +import 'package:photo_manager/photo_manager.dart'; +import 'package:photo_manager_image_provider/photo_manager_image_provider.dart'; + +/// Test page for moveAssetsToPath API (Android 11+ batch move with createWriteRequest) +class MoveAssetsBatchTestPage extends StatefulWidget { + const MoveAssetsBatchTestPage({super.key}); + + @override + State createState() => + _MoveAssetsBatchTestPageState(); +} + +class _MoveAssetsBatchTestPageState extends State { + List allAssets = []; + Set selectedAssets = {}; + bool isLoading = false; + String statusMessage = 'Ready to test'; + final TextEditingController _pathController = + TextEditingController(text: 'Pictures/TestAlbum'); + + @override + void initState() { + super.initState(); + _checkAndroid11(); + _loadAssets(); + } + + void _checkAndroid11() { + if (Platform.isAndroid) { + // You can check Android version if needed + statusMessage = 'Android detected - Ready to test'; + } else { + statusMessage = 'This feature is Android-only'; + } + } + + Future _loadAssets() async { + setState(() { + isLoading = true; + statusMessage = 'Loading assets...'; + }); + + try { + final PermissionState permission = + await PhotoManager.requestPermissionExtend(); + if (!permission.isAuth) { + setState(() { + isLoading = false; + statusMessage = '❌ Permission denied'; + }); + showToast('Permission denied. Please grant photo access.'); + return; + } + + final List paths = await PhotoManager.getAssetPathList( + type: RequestType.image, + ); + + if (paths.isEmpty) { + setState(() { + isLoading = false; + statusMessage = '❌ No albums found'; + }); + return; + } + + // Get first 30 images for testing + final List assets = await paths.first.getAssetListRange( + start: 0, + end: 30, + ); + + setState(() { + allAssets = assets; + isLoading = false; + statusMessage = + '✅ Loaded ${assets.length} assets. Select some to move.'; + }); + } catch (e) { + setState(() { + isLoading = false; + statusMessage = '❌ Error: $e'; + }); + showToast('Error loading assets: $e'); + } + } + + Future _testMoveAssets() async { + if (selectedAssets.isEmpty) { + showToast('Please select at least one image'); + return; + } + + final targetPath = _pathController.text.trim(); + if (targetPath.isEmpty) { + showToast('Please enter a target path'); + return; + } + + setState(() { + isLoading = true; + statusMessage = + 'Moving ${selectedAssets.length} assets to $targetPath...'; + }); + + try { + final success = await PhotoManager.editor.android.moveAssetsToPath( + entities: selectedAssets.toList(), + targetPath: targetPath, + ); + + setState(() { + isLoading = false; + if (success) { + statusMessage = + '✅ Successfully moved ${selectedAssets.length} assets to $targetPath!'; + selectedAssets.clear(); + } else { + statusMessage = + '❌ Failed to move assets (user denied or error occurred)'; + } + }); + + showToast( + success + ? '✅ Move successful! Check "$targetPath" folder.' + : '❌ Move failed or cancelled by user.', + duration: const Duration(seconds: 3), + ); + + // Reload to see changes + if (success) { + await Future.delayed(const Duration(milliseconds: 500)); + _loadAssets(); + } + } catch (e) { + setState(() { + isLoading = false; + statusMessage = '❌ Error during move: $e'; + }); + showToast('Error: $e', duration: const Duration(seconds: 3)); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Test: Move Assets (Batch)'), + actions: [ + if (selectedAssets.isNotEmpty) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + '${selectedAssets.length} selected', + style: const TextStyle(fontSize: 16), + ), + ), + ), + ], + ), + body: Column( + children: [ + // Status banner + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + color: Colors.blue.shade50, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Android 11+ (API 30+) Batch Move Test', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: 4), + Text( + statusMessage, + style: TextStyle(color: Colors.grey.shade700), + ), + ], + ), + ), + + // Target path input + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Target Path (RELATIVE_PATH):', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + TextField( + controller: _pathController, + decoration: const InputDecoration( + hintText: 'e.g., Pictures/MyAlbum', + helperText: 'MediaStore RELATIVE_PATH format', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + const Text( + 'Examples: Pictures/TestAlbum, DCIM/Camera, Download/', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ), + ), + + // Grid of images + Expanded( + child: isLoading && allAssets.isEmpty + ? const Center(child: CircularProgressIndicator()) + : allAssets.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + statusMessage, + style: const TextStyle(fontSize: 16), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadAssets, + child: const Text('Reload Assets'), + ), + ], + ), + ) + : GridView.builder( + padding: const EdgeInsets.all(8), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 4, + crossAxisSpacing: 4, + ), + itemCount: allAssets.length, + itemBuilder: (context, index) { + final asset = allAssets[index]; + final isSelected = selectedAssets.contains(asset); + + return GestureDetector( + onTap: () { + setState(() { + if (isSelected) { + selectedAssets.remove(asset); + } else { + selectedAssets.add(asset); + } + }); + }, + child: Stack( + fit: StackFit.expand, + children: [ + AssetEntityImage( + asset, + isOriginal: false, + fit: BoxFit.cover, + ), + if (isSelected) + Container( + color: Colors.blue.withOpacity(0.5), + child: const Icon( + Icons.check_circle, + color: Colors.white, + size: 32, + ), + ), + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + floatingActionButton: selectedAssets.isNotEmpty + ? FloatingActionButton.extended( + onPressed: isLoading ? null : _testMoveAssets, + icon: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.drive_file_move), + label: Text( + isLoading ? 'Moving...' : 'Move (${selectedAssets.length})', + ), + ) + : null, + ); + } + + @override + void dispose() { + _pathController.dispose(); + super.dispose(); + } +} diff --git a/lib/src/internal/constants.dart b/lib/src/internal/constants.dart index b71eed8c..e2c9ed29 100644 --- a/lib/src/internal/constants.dart +++ b/lib/src/internal/constants.dart @@ -59,6 +59,7 @@ class PMConstants { static const String mCreateFolder = 'createFolder'; static const String mRemoveInAlbum = 'removeInAlbum'; static const String mMoveAssetToPath = 'moveAssetToPath'; + static const String mMoveAssetsToPath = 'moveAssetsToPath'; static const String mColumnNames = 'getColumnNames'; static const String mGetAssetCount = 'getAssetCount'; diff --git a/lib/src/internal/editor.dart b/lib/src/internal/editor.dart index c41f60b3..6832af60 100644 --- a/lib/src/internal/editor.dart +++ b/lib/src/internal/editor.dart @@ -369,6 +369,34 @@ class AndroidEditor { return plugin.androidMoveAssetToPath(entity, target); } + /// Moves multiple assets to a different path/album on Android 11+ (API 30+) with user permission. + /// + /// This method uses MediaStore.createWriteRequest() to request user permission + /// for batch modifications, showing a single system dialog for all assets. + /// + /// [entities] List of assets to move + /// [targetPath] Target RELATIVE_PATH (e.g., "Pictures/MyAlbum") + /// + /// Returns `true` if the operation was successful; otherwise, `false`. + /// + /// Note: This method requires Android 11 (API 30) or higher. + /// For Android 10 and below, use [moveAssetToAnother] instead. + /// + /// Example: + /// ```dart + /// final success = await PhotoManager.editor.android.moveAssetsToPath( + /// entities: [asset1, asset2, asset3], + /// targetPath: 'Pictures/MyAlbum', + /// ); + /// ``` + Future moveAssetsToPath({ + required List entities, + required String targetPath, + }) { + final assetIds = entities.map((e) => e.id).toList(); + return plugin.androidMoveAssetsToPath(assetIds, targetPath); + } + /// Removes all assets from the gallery that are no longer available on disk. /// /// This method is intended to be used after manually deleting files from the diff --git a/lib/src/internal/plugin.dart b/lib/src/internal/plugin.dart index be089d3d..bc5c0fa0 100644 --- a/lib/src/internal/plugin.dart +++ b/lib/src/internal/plugin.dart @@ -909,6 +909,36 @@ mixin AndroidPlugin on BasePlugin { return result != null; } + /// Move multiple assets to a different path/album on Android 11+ (API 30+) with user permission. + /// + /// This method uses MediaStore.createWriteRequest() to request user permission + /// for batch modifications, showing a single system dialog for all assets. + /// + /// [assetIds] List of asset IDs to move + /// [targetPath] Target RELATIVE_PATH (e.g., "Pictures/MyAlbum") + /// + /// Returns true if the operation was successful, false otherwise. + /// + /// Note: This method requires Android 11 (API 30) or higher. + /// For Android 10 and below, use [androidMoveAssetToPath] instead. + Future androidMoveAssetsToPath( + List assetIds, + String targetPath, + ) async { + try { + final result = await _channel.invokeMethod( + PMConstants.mMoveAssetsToPath, + { + 'assetIds': assetIds, + 'targetPath': targetPath, + }, + ); + return result == true; + } catch (e) { + return false; + } + } + Future androidRemoveNoExistsAssets() async { final bool? result = await _channel.invokeMethod( PMConstants.mRemoveNoExistsAssets,