Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -94,6 +95,7 @@ class PhotoManagerPlugin : FlutterPlugin, ActivityAware {
}
plugin?.let { p ->
oldBinding.removeActivityResultListener(p.deleteManager)
oldBinding.removeActivityResultListener(p.writeManager)
oldBinding.removeActivityResultListener(p.favoriteManager)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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<List<String>>("assetIds")!!
val targetPath = call.argument<String>("targetPath")!!

val uris = assetIds.mapNotNull { photoManager.getUri(it) }
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mapNotNull operation doesn't handle exceptions thrown by photoManager.getUri(it). The getUri method can throw a RuntimeException when an asset is not found (line 257 in PhotoManager.kt). These exceptions won't be caught by the outer try-catch block and could crash the operation.

Consider wrapping the mapping operation in a try-catch:

val uris = assetIds.mapNotNull { assetId ->
    try {
        photoManager.getUri(assetId)
    } catch (e: Exception) {
        LogUtils.error("Failed to get URI for asset: $assetId", e)
        null
    }
}
Suggested change
val uris = assetIds.mapNotNull { photoManager.getUri(it) }
val uris = assetIds.mapNotNull { assetId ->
try {
photoManager.getUri(assetId)
} catch (e: Exception) {
LogUtils.error("Failed to get URI for asset: $assetId", e)
null
}
}

Copilot uses AI. Check for mistakes.
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<List<String>>("ids")!!
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Uri>,
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<Uri>, 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(
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant null-safe call on activity. The method already checks if activity == null at line 90 and returns early if it is. The null-safe call activity?.startIntentSenderForResult is unnecessary here and should be activity.startIntentSenderForResult since activity is guaranteed to be non-null at this point.

This makes the code clearer and avoids confusion about whether the operation could silently fail.

Suggested change
activity?.startIntentSenderForResult(
activity.startIntentSenderForResult(

Copilot uses AI. Check for mistakes.
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<Uri>, 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<Uri>, 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<Uri>, 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(
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant null-safe call on activity. The method already checks if activity == null at line 167 and returns early if it is. The null-safe call activity?.startIntentSenderForResult is unnecessary here and should be activity.startIntentSenderForResult since activity is guaranteed to be non-null at this point.

This makes the code clearer and avoids confusion about whether the operation could silently fail.

Suggested change
activity?.startIntentSenderForResult(
activity.startIntentSenderForResult(

Copilot uses AI. Check for mistakes.
pendingIntent.intentSender,
androidRWriteRequestCode,
null,
0,
0,
0
)
} catch (e: Exception) {
LogUtils.error("Failed to create write request", e)
resultHandler.reply(false)
writeHandler = null
}
}
}
2 changes: 2 additions & 0 deletions example/lib/page/index_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -28,6 +29,7 @@ class _IndexPageState extends State<IndexPage> {
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()),
],
),
Expand Down
Loading
Loading