Skip to content

Commit f06920d

Browse files
toyajiAlexV525
andauthored
feat: Android 11+ batch move assets (#1319)
This pull request introduces a new batch move feature for Android 11+ (API 30+) in the photo manager plugin, allowing multiple assets to be moved to a different album with a single user permission dialog. The implementation includes both platform-side (Kotlin) and Dart-side changes, as well as an example usage for developers. The main areas of change are the addition of the batch move API, integration of the new manager, and updates to the example app. **Batch Move Feature Implementation** * Added new method `moveAssetsToPath` to support batch moving of assets to a target album/folder, which triggers only one system permission dialog for all selected assets. [[1]](diffhunk://#diff-8c2e6037bac8f1d70b0e894c4dce0779ac080ba908d9e6fc6310aab8feea23c6R86) [[2]](diffhunk://#diff-879a3e732a2f4305e1600272748b44c9fb104d335d2fe538a7a3c9536921e6c0R558-R583) * Introduced `PhotoManagerWriteManager` class in `PhotoManagerWriteManager.kt` to handle write requests and batch move operations, including permission handling and actual asset relocation logic. **Plugin Integration** * Registered `writeManager` as an `ActivityResultListener` in the plugin lifecycle, ensuring proper permission and result handling for batch move operations. [[1]](diffhunk://#diff-cea18dace7ce6804f7272079a1bd5f66c4920f364df7c6c614dae4684d758c60R87) [[2]](diffhunk://#diff-cea18dace7ce6804f7272079a1bd5f66c4920f364df7c6c614dae4684d758c60R97) [[3]](diffhunk://#diff-879a3e732a2f4305e1600272748b44c9fb104d335d2fe538a7a3c9536921e6c0R61-R67) * Updated activity binding logic to include `writeManager` for context and activity reference management. **Example and Documentation** * Added `move_assets_example.dart` with clear documentation and sample code demonstrating how to use the new batch move API, including important notes on Android version requirements, path formats, and error handling. * Updated example app navigation to include the new "Move Assets example" page for easier developer access and testing. [[1]](diffhunk://#diff-0502524377b0a7e252905c71e9e595b4b5d3ff4732ae81494b4bdcc787c17a76R8) [[2]](diffhunk://#diff-0502524377b0a7e252905c71e9e595b4b5d3ff4732ae81494b4bdcc787c17a76R32) --------- Co-authored-by: Alex Li <github@alexv525.com>
1 parent 4a8753b commit f06920d

File tree

11 files changed

+755
-0
lines changed

11 files changed

+755
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ To know more about breaking changes, see the [Migration Guide][].
2525
- Add optional `latitude`, `longitude`, and `creationDate` parameters to `saveImage`, `saveImageWithPath`, and `saveVideo` methods.
2626
- On iOS: Sets location and creation date metadata for saved assets.
2727
- On Android Q+: Sets DATE_TAKEN field and location metadata for saved assets.
28+
- Add batch asset move functionality using `createWriteRequest` API for Android 11+.
2829

2930
### Improvements
3031

android/src/main/kotlin/com/fluttercandies/photo_manager/PhotoManagerPlugin.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ class PhotoManagerPlugin : FlutterPlugin, ActivityAware {
8484
binding.addRequestPermissionsResultListener(listener)
8585
plugin?.let {
8686
binding.addActivityResultListener(it.deleteManager)
87+
binding.addActivityResultListener(it.writeManager)
8788
binding.addActivityResultListener(it.favoriteManager)
8889
}
8990
}
@@ -94,6 +95,7 @@ class PhotoManagerPlugin : FlutterPlugin, ActivityAware {
9495
}
9596
plugin?.let { p ->
9697
oldBinding.removeActivityResultListener(p.deleteManager)
98+
oldBinding.removeActivityResultListener(p.writeManager)
9799
oldBinding.removeActivityResultListener(p.favoriteManager)
98100
}
99101
}

android/src/main/kotlin/com/fluttercandies/photo_manager/constant/Methods.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ class Methods {
8585
const val favoriteAsset = "favoriteAsset"
8686
const val copyAsset = "copyAsset"
8787
const val moveAssetToPath = "moveAssetToPath"
88+
const val moveAssetsToPath = "moveAssetsToPath"
8889
const val removeNoExistsAssets = "removeNoExistsAssets"
8990
const val getColumnNames = "getColumnNames"
9091

android/src/main/kotlin/com/fluttercandies/photo_manager/core/PhotoManagerPlugin.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,14 @@ class PhotoManagerPlugin(
5858
}
5959

6060
val deleteManager = PhotoManagerDeleteManager(applicationContext, activity)
61+
val writeManager = PhotoManagerWriteManager(applicationContext, activity)
6162
val favoriteManager = PhotoManagerFavoriteManager(applicationContext)
6263

6364
fun bindActivity(activity: Activity?) {
6465
this.activity = activity
6566
permissionsUtils.withActivity(activity)
6667
deleteManager.bindActivity(activity)
68+
writeManager.bindActivity(activity)
6769
favoriteManager.bindActivity(activity)
6870
}
6971

@@ -590,6 +592,32 @@ class PhotoManagerPlugin(
590592
photoManager.moveToGallery(assetId, albumId, resultHandler)
591593
}
592594

595+
Methods.moveAssetsToPath -> {
596+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
597+
try {
598+
val assetIds = call.argument<List<String>>("assetIds")!!
599+
val targetPath = call.argument<String>("targetPath")!!
600+
601+
val uris = assetIds.mapNotNull { photoManager.getUri(it) }
602+
if (uris.isEmpty()) {
603+
resultHandler.replyError("No valid URIs found for the given asset IDs")
604+
return
605+
}
606+
607+
writeManager.moveToPathWithPermission(uris, targetPath, resultHandler)
608+
} catch (e: Exception) {
609+
LogUtils.error("moveAssetsToPath failed", e)
610+
resultHandler.replyError("moveAssetsToPath failed", message = e.message)
611+
}
612+
} else {
613+
LogUtils.error("moveAssetsToPath requires Android 11+ (API 30+)")
614+
resultHandler.replyError(
615+
"moveAssetsToPath requires Android 11+ (API 30+)",
616+
message = "Current API level: ${Build.VERSION.SDK_INT}"
617+
)
618+
}
619+
}
620+
593621
Methods.deleteWithIds -> {
594622
try {
595623
val ids = call.argument<List<String>>("ids")!!
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package com.fluttercandies.photo_manager.core
2+
3+
import android.app.Activity
4+
import android.content.ContentResolver
5+
import android.content.ContentValues
6+
import android.content.Context
7+
import android.content.Intent
8+
import android.net.Uri
9+
import android.os.Build
10+
import android.provider.MediaStore
11+
import androidx.annotation.RequiresApi
12+
import com.fluttercandies.photo_manager.util.LogUtils
13+
import com.fluttercandies.photo_manager.util.ResultHandler
14+
import io.flutter.plugin.common.PluginRegistry
15+
16+
/**
17+
* Manager for handling write requests (modifications) on Android 11+ (API 30+)
18+
* Uses MediaStore.createWriteRequest() to request user permission for batch modifications
19+
*/
20+
class PhotoManagerWriteManager(val context: Context, private var activity: Activity?) :
21+
PluginRegistry.ActivityResultListener {
22+
23+
fun bindActivity(activity: Activity?) {
24+
this.activity = activity
25+
}
26+
27+
private var androidRWriteRequestCode = 40071
28+
private var writeHandler: ResultHandler? = null
29+
private var pendingOperation: WriteOperation? = null
30+
31+
private val cr: ContentResolver
32+
get() = context.contentResolver
33+
34+
/**
35+
* Represents a pending write operation that will be executed after user grants permission
36+
*/
37+
private data class WriteOperation(
38+
val uris: List<Uri>,
39+
val targetPath: String,
40+
val operationType: OperationType
41+
)
42+
43+
enum class OperationType {
44+
MOVE, // Move files to another folder
45+
UPDATE // Generic update operation
46+
}
47+
48+
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?): Boolean {
49+
if (requestCode == androidRWriteRequestCode) {
50+
handleWriteResult(resultCode)
51+
return true
52+
}
53+
return false
54+
}
55+
56+
private fun handleWriteResult(resultCode: Int) {
57+
if (resultCode == Activity.RESULT_OK) {
58+
// User granted permission, execute the pending operation
59+
val operation = pendingOperation
60+
if (operation != null) {
61+
val success = when (operation.operationType) {
62+
OperationType.MOVE -> performMove(operation.uris, operation.targetPath)
63+
OperationType.UPDATE -> performUpdate(operation.uris, operation.targetPath)
64+
}
65+
writeHandler?.reply(success)
66+
} else {
67+
LogUtils.error("No pending operation found after write permission granted")
68+
writeHandler?.reply(false)
69+
}
70+
} else {
71+
// User denied permission
72+
LogUtils.info("User denied write permission")
73+
writeHandler?.reply(false)
74+
}
75+
76+
// Clean up
77+
pendingOperation = null
78+
writeHandler = null
79+
}
80+
81+
/**
82+
* Request permission to move assets to a different album/folder on Android 11+ (API 30+)
83+
*
84+
* @param uris List of content URIs to move
85+
* @param targetPath Target RELATIVE_PATH (e.g., "Pictures/MyAlbum")
86+
* @param resultHandler Callback with result (true if successful, false otherwise)
87+
*/
88+
@RequiresApi(Build.VERSION_CODES.R)
89+
fun moveToPathWithPermission(uris: List<Uri>, targetPath: String, resultHandler: ResultHandler) {
90+
if (activity == null) {
91+
LogUtils.error("Activity is null, cannot request write permission")
92+
resultHandler.reply(false)
93+
return
94+
}
95+
96+
this.writeHandler = resultHandler
97+
this.pendingOperation = WriteOperation(uris, targetPath, OperationType.MOVE)
98+
99+
try {
100+
val pendingIntent = MediaStore.createWriteRequest(cr, uris)
101+
activity?.startIntentSenderForResult(
102+
pendingIntent.intentSender,
103+
androidRWriteRequestCode,
104+
null,
105+
0,
106+
0,
107+
0
108+
)
109+
} catch (e: Exception) {
110+
LogUtils.error("Failed to create write request", e)
111+
resultHandler.reply(false)
112+
pendingOperation = null
113+
writeHandler = null
114+
}
115+
}
116+
117+
/**
118+
* Perform the actual move operation after permission is granted
119+
* Updates the RELATIVE_PATH of each URI to move files to a different folder
120+
*/
121+
private fun performMove(uris: List<Uri>, targetPath: String): Boolean {
122+
return try {
123+
val values = ContentValues().apply {
124+
put(MediaStore.MediaColumns.RELATIVE_PATH, targetPath)
125+
}
126+
127+
var successCount = 0
128+
for (uri in uris) {
129+
try {
130+
val updated = cr.update(uri, values, null, null)
131+
if (updated > 0) {
132+
successCount++
133+
}
134+
} catch (e: Exception) {
135+
LogUtils.error("Failed to move URI: $uri", e)
136+
}
137+
}
138+
139+
LogUtils.info("Moved $successCount/${uris.size} files to $targetPath")
140+
successCount > 0 // Return true if at least one file was moved
141+
} catch (e: Exception) {
142+
LogUtils.error("Failed to perform move operation", e)
143+
false
144+
}
145+
}
146+
147+
/**
148+
* Perform a generic update operation after permission is granted
149+
* This can be extended for other types of modifications
150+
*/
151+
private fun performUpdate(uris: List<Uri>, updateData: String): Boolean {
152+
// Placeholder for generic update operations
153+
// Can be extended based on specific needs
154+
LogUtils.info("Generic update operation not yet implemented")
155+
return false
156+
}
157+
158+
/**
159+
* Request permission to update/modify assets on Android 11+ (API 30+)
160+
* This is a generic method that can be used for various update operations
161+
*
162+
* @param uris List of content URIs to update
163+
* @param resultHandler Callback with result (true if permission granted, false otherwise)
164+
*/
165+
@RequiresApi(Build.VERSION_CODES.R)
166+
fun requestWritePermission(uris: List<Uri>, resultHandler: ResultHandler) {
167+
if (activity == null) {
168+
LogUtils.error("Activity is null, cannot request write permission")
169+
resultHandler.reply(false)
170+
return
171+
}
172+
173+
this.writeHandler = resultHandler
174+
175+
try {
176+
val pendingIntent = MediaStore.createWriteRequest(cr, uris)
177+
activity?.startIntentSenderForResult(
178+
pendingIntent.intentSender,
179+
androidRWriteRequestCode,
180+
null,
181+
0,
182+
0,
183+
0
184+
)
185+
} catch (e: Exception) {
186+
LogUtils.error("Failed to create write request", e)
187+
resultHandler.reply(false)
188+
writeHandler = null
189+
}
190+
}
191+
}

example/lib/page/index_page.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:photo_manager_example/widget/nav_button.dart';
55
import 'change_notify_page.dart';
66
import 'developer/develop_index_page.dart';
77
import 'home_page.dart';
8+
import 'move_assets_page.dart';
89
import 'save_image_example.dart';
910

1011
class IndexPage extends StatefulWidget {
@@ -28,6 +29,7 @@ class _IndexPageState extends State<IndexPage> {
2829
routePage('Custom filter example', const CustomFilterExamplePage()),
2930
routePage('Save media example', const SaveMediaExample()),
3031
routePage('Change notify example', const ChangeNotifyExample()),
32+
routePage('Move Assets example', const MoveAssetsBatchTestPage()),
3133
routePage('For Developer page', const DeveloperIndexPage()),
3234
],
3335
),

0 commit comments

Comments
 (0)