diff --git a/app/build.gradle b/app/build.gradle index 557056c..9b1bffe 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -36,7 +36,7 @@ android { defaultConfig { applicationId "sushi.hardcore.droidfs" minSdkVersion 21 - targetSdkVersion 32 + targetSdkVersion 33 versionCode 36 versionName "2.1.3" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f045d33..502260f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,9 @@ android:installLocation="auto"> + + + @@ -56,7 +59,7 @@ - + diff --git a/app/src/main/java/sushi/hardcore/droidfs/MainActivity.kt b/app/src/main/java/sushi/hardcore/droidfs/MainActivity.kt index ec681b7..be3fb76 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/MainActivity.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/MainActivity.kt @@ -132,13 +132,8 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener { } } startService(Intent(this, WiperService::class.java)) - Intent(this, FileOperationService::class.java).also { - bindService(it, object : ServiceConnection { - override fun onServiceConnected(className: ComponentName, service: IBinder) { - fileOperationService = (service as FileOperationService.LocalBinder).getService() - } - override fun onServiceDisconnected(arg0: ComponentName) {} - }, Context.BIND_AUTO_CREATE) + FileOperationService.bind(this) { + fileOperationService = it } } @@ -434,9 +429,6 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener { lifecycleScope.launch { val result = fileOperationService.copyVolume(srcDocumentFile, dstDocumentFile) when (result.taskResult.state) { - TaskResult.State.CANCELLED -> { - result.dstRootDirectory?.delete() - } TaskResult.State.SUCCESS -> { result.dstRootDirectory?.let { getResultVolume(it)?.let { volume -> @@ -458,6 +450,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener { .show() } TaskResult.State.ERROR -> result.taskResult.showErrorAlertDialog(this@MainActivity, theme) + TaskResult.State.CANCELLED -> {} } } } diff --git a/app/src/main/java/sushi/hardcore/droidfs/explorers/BaseExplorerActivity.kt b/app/src/main/java/sushi/hardcore/droidfs/explorers/BaseExplorerActivity.kt index 1955915..2dd734c 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/explorers/BaseExplorerActivity.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/explorers/BaseExplorerActivity.kt @@ -190,15 +190,9 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene setContentView(R.layout.activity_explorer) } - protected open fun bindFileOperationService(){ - Intent(this, FileOperationService::class.java).also { - bindService(it, object : ServiceConnection { - override fun onServiceConnected(className: ComponentName, service: IBinder) { - val binder = service as FileOperationService.LocalBinder - fileOperationService = binder.getService() - } - override fun onServiceDisconnected(arg0: ComponentName) {} - }, Context.BIND_AUTO_CREATE) + protected open fun bindFileOperationService() { + FileOperationService.bind(this) { + fileOperationService = it } } diff --git a/app/src/main/java/sushi/hardcore/droidfs/file_operations/FileOperationNotification.kt b/app/src/main/java/sushi/hardcore/droidfs/file_operations/FileOperationNotification.kt deleted file mode 100644 index 7f8f8bf..0000000 --- a/app/src/main/java/sushi/hardcore/droidfs/file_operations/FileOperationNotification.kt +++ /dev/null @@ -1,5 +0,0 @@ -package sushi.hardcore.droidfs.file_operations - -import androidx.core.app.NotificationCompat - -class FileOperationNotification(val notificationBuilder: NotificationCompat.Builder, val notificationId: Int) \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/file_operations/FileOperationService.kt b/app/src/main/java/sushi/hardcore/droidfs/file_operations/FileOperationService.kt index 0ce022f..d953dc3 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/file_operations/FileOperationService.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/file_operations/FileOperationService.kt @@ -1,65 +1,191 @@ package sushi.hardcore.droidfs.file_operations +import android.Manifest +import android.annotation.SuppressLint +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service +import android.content.ComponentName +import android.content.Context import android.content.Intent +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo import android.net.Uri import android.os.Binder import android.os.Build import android.os.Bundle import android.os.IBinder +import android.provider.Settings +import android.util.Log +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat import androidx.documentfile.provider.DocumentFile import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.MainScope import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.yield +import sushi.hardcore.droidfs.BaseActivity import sushi.hardcore.droidfs.Constants import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.VolumeManager import sushi.hardcore.droidfs.VolumeManagerApp import sushi.hardcore.droidfs.explorers.ExplorerElement import sushi.hardcore.droidfs.filesystems.EncryptedVolume +import sushi.hardcore.droidfs.filesystems.Stat import sushi.hardcore.droidfs.util.ObjRef import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.Wiper +import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import java.io.File import java.io.FileNotFoundException +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +/** + * Foreground service for file operations. + * + * Clients **must** bind to it using the [bind] method. + * + * This implementation is not thread-safe. It must only be called from the main UI thread. + */ class FileOperationService : Service() { - companion object { - const val NOTIFICATION_CHANNEL_ID = "FileOperations" - const val ACTION_CANCEL = "file_operation_cancel" - } - - private val binder = LocalBinder() - private lateinit var volumeManger: VolumeManager - private var serviceScope = MainScope() - private lateinit var notificationManager: NotificationManagerCompat - private val tasks = HashMap() - private var lastNotificationId = 0 inner class LocalBinder : Binder() { fun getService(): FileOperationService = this@FileOperationService } - override fun onBind(p0: Intent?): IBinder { - volumeManger = (application as VolumeManagerApp).volumeManager - return binder + inner class PendingTask( + val title: Int, + val total: Int?, + private val getTask: (Int) -> Deferred, + private val onStart: (taskId: Int, job: Deferred) -> Unit, + ) { + fun start(taskId: Int): Deferred = getTask(taskId).also { onStart(taskId, it) } } - private fun showNotification(message: Int, total: Int?): FileOperationNotification { - ++lastNotificationId - if (!::notificationManager.isInitialized){ + companion object { + const val TAG = "FileOperationService" + const val NOTIFICATION_CHANNEL_ID = "FileOperations" + const val ACTION_CANCEL = "file_operation_cancel" + + /** + * Bind to the service. + * + * Registers an [ActivityResultLauncher] in the provided activity to request notification permission. Consequently, the activity must not yet be started. + * + * The activity must stay running while calling the service's methods. + * + * If multiple activities bind simultaneously, only the latest one will be used by the service. + */ + fun bind(activity: BaseActivity, onBound: (FileOperationService) -> Unit) { + var service: FileOperationService? = null + val launcher = activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + service!!.processPendingTask() + } else { + CustomAlertDialogBuilder(activity, activity.theme) + .setTitle(R.string.warning) + .setMessage(R.string.notification_denied_msg) + .setPositiveButton(R.string.settings) { _, _ -> + activity.startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", activity.packageName, null) + ) + ) + } + .setNegativeButton(R.string.later, null) + .setOnDismissListener { service!!.processPendingTask() } + .show() + } + } + activity.bindService(Intent(activity, FileOperationService::class.java), object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, binder: IBinder) { + onBound((binder as FileOperationService.LocalBinder).getService().also { + it.notificationPermissionLauncher = launcher + service = it + }) + } + override fun onServiceDisconnected(arg0: ComponentName) {} + }, Context.BIND_AUTO_CREATE) + } + } + + private var isStarted = false + private val binder = LocalBinder() + private lateinit var volumeManger: VolumeManager + private var serviceScope = MainScope() + private lateinit var notificationPermissionLauncher: ActivityResultLauncher + private var askForNotificationPermission = true + private lateinit var notificationManager: NotificationManagerCompat + private val notifications = HashMap() + private var foregroundNotificationId = -1 + private val tasks = HashMap() + private var newTaskId = 1 + private var pendingTask: PendingTask<*>? = null + + override fun onCreate() { + volumeManger = (application as VolumeManagerApp).volumeManager + } + + override fun onBind(p0: Intent?): IBinder = binder + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startPendingTask { id, notification -> + // on service start, the pending task is the foreground task + setForeground(id, notification) + } + isStarted = true + return START_NOT_STICKY + } + + override fun onDestroy() { + isStarted = false + } + + private fun processPendingTask() { + if (isStarted) { + startPendingTask { id, notification -> + if (foregroundNotificationId == -1) { + // service started but not in foreground yet + setForeground(id, notification) + } else { + // already running in foreground, just add a new notification + notificationManager.notify(id, notification) + } + } + } else { + ContextCompat.startForegroundService( + this, + Intent(this, FileOperationService::class.java) + ) + } + } + + /** + * Start the pending task and create an associated notification. + */ + private fun startPendingTask(showNotification: (id: Int, Notification) -> Unit) { + val task = pendingTask + pendingTask = null + if (task == null) { + Log.w(TAG, "Started without pending task") + return + } + if (!::notificationManager.isInitialized) { notificationManager = NotificationManagerCompat.from(this) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -72,87 +198,173 @@ class FileOperationService : Service() { ) } val notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) - notificationBuilder - .setContentTitle(getString(message)) - .setSmallIcon(R.drawable.ic_notification) - .setOngoing(true) - .addAction(NotificationCompat.Action( - R.drawable.icon_close, - getString(R.string.cancel), - PendingIntent.getBroadcast( - this, - 0, - Intent(this, NotificationBroadcastReceiver::class.java).apply { - val bundle = Bundle() - bundle.putBinder("binder", LocalBinder()) - bundle.putInt("notificationId", lastNotificationId) - putExtra("bundle", bundle) - action = ACTION_CANCEL - }, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - )) - if (total != null) { + .setContentTitle(getString(task.title)) + .setSmallIcon(R.drawable.ic_notification) + .setOngoing(true) + .addAction(NotificationCompat.Action( + R.drawable.icon_close, + getString(R.string.cancel), + PendingIntent.getBroadcast( + this, + newTaskId, + Intent(this, NotificationBroadcastReceiver::class.java).apply { + putExtra("bundle", Bundle().apply { + putBinder("binder", LocalBinder()) + putInt("taskId", newTaskId) + }) + action = ACTION_CANCEL + }, + PendingIntent.FLAG_IMMUTABLE + ) + )) + if (task.total != null) { notificationBuilder - .setContentText("0/$total") - .setProgress(total, 0, false) + .setContentText("0/${task.total}") + .setProgress(task.total, 0, false) } else { notificationBuilder .setContentText(getString(R.string.discovering_files)) .setProgress(0, 0, true) } - notificationManager.notify(lastNotificationId, notificationBuilder.build()) - return FileOperationNotification(notificationBuilder, lastNotificationId) + showNotification(newTaskId, notificationBuilder.build()) + notifications[newTaskId] = notificationBuilder + tasks[newTaskId] = task.start(newTaskId) + newTaskId++ } - private fun updateNotificationProgress(notification: FileOperationNotification, progress: Int, total: Int){ - notification.notificationBuilder + private fun setForeground(id: Int, notification: Notification) { + ServiceCompat.startForeground(this, id, notification, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } else { + 0 + } + ) + foregroundNotificationId = id + } + + private fun updateNotificationProgress(taskId: Int, progress: Int, total: Int) { + val notificationBuilder = notifications[taskId] ?: return + notificationBuilder .setProgress(total, progress, false) .setContentText("$progress/$total") - notificationManager.notify(notification.notificationId, notification.notificationBuilder.build()) + notificationManager.notify(taskId, notificationBuilder.build()) } - private fun cancelNotification(notification: FileOperationNotification){ - notificationManager.cancel(notification.notificationId) - } - - fun cancelOperation(notificationId: Int){ - tasks[notificationId]?.cancel() + fun cancelOperation(taskId: Int) { + tasks[taskId]?.cancel() } private fun getEncryptedVolume(volumeId: Int): EncryptedVolume { return volumeManger.getVolume(volumeId) ?: throw IllegalArgumentException("Invalid volumeId: $volumeId") } - private suspend fun waitForTask(notification: FileOperationNotification, task: Deferred): TaskResult { - tasks[notification.notificationId] = task + /** + * Wait on a task, returning the appropriate [TaskResult]. + * + * This method also performs cleanup and foreground state management so it must be always used. + */ + private suspend fun waitForTask( + taskId: Int, + task: Deferred, + onCancelled: (suspend () -> Unit)?, + ): TaskResult { return coroutineScope { withContext(serviceScope.coroutineContext) { try { TaskResult.completed(task.await()) } catch (e: CancellationException) { + onCancelled?.invoke() TaskResult.cancelled() } catch (e: Throwable) { e.printStackTrace() TaskResult.error(e.localizedMessage) } finally { - cancelNotification(notification) + notificationManager.cancel(taskId) + notifications.remove(taskId) + tasks.remove(taskId) + if (tasks.size == 0) { + // last task finished, remove from foreground state but don't stop the service + ServiceCompat.stopForeground(this@FileOperationService, ServiceCompat.STOP_FOREGROUND_REMOVE) + foregroundNotificationId = -1 + } else if (taskId == foregroundNotificationId) { + // foreground task finished, falling back to the next one + val entry = notifications.entries.first() + setForeground(entry.key, entry.value.build()) + } } } } } - private suspend fun volumeTask( - volumeId: Int, - notification: FileOperationNotification, - task: suspend (encryptedVolume: EncryptedVolume) -> T + /** + * Create and run a new task until completion. + * + * Handles notification permission request, service startup and notification management. + * + * Overrides [pendingTask] without checking! (safe if user is not insanely fast) + */ + private suspend fun newTask( + title: Int, + total: Int?, + getTask: (taskId: Int) -> Deferred, + onCancelled: (suspend () -> Unit)?, ): TaskResult { - return waitForTask( - notification, - volumeManger.getCoroutineScope(volumeId).async { - task(getEncryptedVolume(volumeId)) + val startedTask = suspendCoroutine { continuation -> + val task = PendingTask(title, total, getTask) { taskId, job -> + continuation.resume(Pair(taskId, job)) } - ) + pendingTask = task + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if ( + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + && askForNotificationPermission + ) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + askForNotificationPermission = false // only ask once per service instance + return@suspendCoroutine + } + } + processPendingTask() + } + return waitForTask(startedTask.first, startedTask.second, onCancelled) + } + + private suspend fun volumeTask( + title: Int, + total: Int?, + volumeId: Int, + task: suspend (taskId: Int, encryptedVolume: EncryptedVolume) -> T + ): TaskResult { + return newTask(title, total, { taskId -> + volumeManger.getCoroutineScope(volumeId).async { + task(taskId, getEncryptedVolume(volumeId)) + } + }, null) + } + + private suspend fun globalTask( + title: Int, + total: Int?, + task: suspend (taskId: Int) -> T, + onCancelled: (suspend () -> Unit)? = null, + ): TaskResult { + return newTask(title, total, { taskId -> + serviceScope.async(Dispatchers.IO) { + task(taskId) + } + }, if (onCancelled == null) { + null + } else { + { + serviceScope.launch(Dispatchers.IO) { + onCancelled() + } + } + }) } private suspend fun copyFile( @@ -196,9 +408,8 @@ class FileOperationService : Service() { items: List, srcVolumeId: Int = volumeId, ): TaskResult { - val notification = showNotification(R.string.file_op_copy_msg, items.size) val srcEncryptedVolume = getEncryptedVolume(srcVolumeId) - return volumeTask(volumeId, notification) { encryptedVolume -> + return volumeTask(R.string.file_op_copy_msg, items.size, volumeId) { taskId, encryptedVolume -> var failedItem: String? = null for (i in items.indices) { yield() @@ -212,7 +423,7 @@ class FileOperationService : Service() { failedItem = items[i].srcPath } if (failedItem == null) { - updateNotificationProgress(notification, i+1, items.size) + updateNotificationProgress(taskId, i+1, items.size) } else { break } @@ -222,8 +433,7 @@ class FileOperationService : Service() { } suspend fun moveElements(volumeId: Int, toMove: List, toClean: List): TaskResult { - val notification = showNotification(R.string.file_op_move_msg, toMove.size) - return volumeTask(volumeId, notification) { encryptedVolume -> + return volumeTask(R.string.file_op_move_msg, toMove.size, volumeId) { taskId, encryptedVolume -> val total = toMove.size+toClean.size var failedItem: String? = null for ((i, item) in toMove.withIndex()) { @@ -231,7 +441,7 @@ class FileOperationService : Service() { failedItem = item.srcPath break } else { - updateNotificationProgress(notification, i+1, total) + updateNotificationProgress(taskId, i+1, total) } } if (failedItem == null) { @@ -240,7 +450,7 @@ class FileOperationService : Service() { failedItem = folderPath break } else { - updateNotificationProgress(notification, toMove.size+i+1, total) + updateNotificationProgress(taskId, toMove.size+i+1, total) } } } @@ -252,7 +462,7 @@ class FileOperationService : Service() { encryptedVolume: EncryptedVolume, dstPaths: List, uris: List, - notification: FileOperationNotification, + taskId: Int, ): String? { var failedIndex = -1 for (i in dstPaths.indices) { @@ -265,7 +475,7 @@ class FileOperationService : Service() { failedIndex = i } if (failedIndex == -1) { - updateNotificationProgress(notification, i+1, dstPaths.size) + updateNotificationProgress(taskId, i+1, dstPaths.size) } else { return uris[failedIndex].toString() } @@ -274,9 +484,8 @@ class FileOperationService : Service() { } suspend fun importFilesFromUris(volumeId: Int, dstPaths: List, uris: List): TaskResult { - val notification = showNotification(R.string.file_op_import_msg, dstPaths.size) - return volumeTask(volumeId, notification) { encryptedVolume -> - importFilesFromUris(encryptedVolume, dstPaths, uris, notification) + return volumeTask(R.string.file_op_import_msg, dstPaths.size, volumeId) { taskId, encryptedVolume -> + importFilesFromUris(encryptedVolume, dstPaths, uris, taskId) } } @@ -284,8 +493,6 @@ class FileOperationService : Service() { * Map the content of an unencrypted directory to prepare its import * * Contents of dstFiles and srcUris, at the same index, will match each other - * - * @return false if cancelled early, true otherwise. */ private suspend fun recursiveMapDirectoryForImport( rootSrcDir: DocumentFile, @@ -316,36 +523,35 @@ class FileOperationService : Service() { rootDstPath: String, rootSrcDir: DocumentFile, ): ImportDirectoryResult { - val notification = showNotification(R.string.file_op_import_msg, null) val srcUris = arrayListOf() - return ImportDirectoryResult(volumeTask(volumeId, notification) { encryptedVolume -> + return ImportDirectoryResult(volumeTask(R.string.file_op_import_msg, null, volumeId) { taskId, encryptedVolume -> var failedItem: String? = null val dstFiles = arrayListOf() val dstDirs = arrayListOf() recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs) // create destination folders so the new files can use them for (dir in dstDirs) { - if (!encryptedVolume.mkdir(dir)) { + // if directory creation fails, check if it was already present + if (!encryptedVolume.mkdir(dir) && encryptedVolume.getAttr(dir)?.type != Stat.S_IFDIR) { failedItem = dir break } } if (failedItem == null) { - failedItem = importFilesFromUris(encryptedVolume, dstFiles, srcUris, notification) + failedItem = importFilesFromUris(encryptedVolume, dstFiles, srcUris, taskId) } failedItem }, srcUris) } suspend fun wipeUris(uris: List, rootFile: DocumentFile? = null): TaskResult { - val notification = showNotification(R.string.file_op_wiping_msg, uris.size) - val task = serviceScope.async(Dispatchers.IO) { + return globalTask(R.string.file_op_wiping_msg, uris.size, { taskId -> var errorMsg: String? = null for (i in uris.indices) { yield() errorMsg = Wiper.wipe(this@FileOperationService, uris[i]) if (errorMsg == null) { - updateNotificationProgress(notification, i+1, uris.size) + updateNotificationProgress(taskId, i+1, uris.size) } else { break } @@ -354,8 +560,7 @@ class FileOperationService : Service() { rootFile?.delete() } errorMsg - } - return waitForTask(notification, task) + }) } private fun exportFileInto(encryptedVolume: EncryptedVolume, srcPath: String, treeDocumentFile: DocumentFile): Boolean { @@ -391,8 +596,7 @@ class FileOperationService : Service() { } suspend fun exportFiles(volumeId: Int, items: List, uri: Uri): TaskResult { - val notification = showNotification(R.string.file_op_export_msg, items.size) - return volumeTask(volumeId, notification) { encryptedVolume -> + return volumeTask(R.string.file_op_export_msg, items.size, volumeId) { taskId, encryptedVolume -> val treeDocumentFile = DocumentFile.fromTreeUri(this@FileOperationService, uri)!! var failedItem: String? = null for (i in items.indices) { @@ -407,7 +611,7 @@ class FileOperationService : Service() { } } if (failedItem == null) { - updateNotificationProgress(notification, i+1, items.size) + updateNotificationProgress(taskId, i+1, items.size) } else { break } @@ -436,8 +640,7 @@ class FileOperationService : Service() { } suspend fun removeElements(volumeId: Int, items: List): String? { - val notification = showNotification(R.string.file_op_delete_msg, items.size) - return volumeTask(volumeId, notification) { encryptedVolume -> + return volumeTask(R.string.file_op_delete_msg, items.size, volumeId) { taskId, encryptedVolume -> var failedItem: String? = null for ((i, element) in items.withIndex()) { yield() @@ -447,7 +650,7 @@ class FileOperationService : Service() { failedItem = element.fullPath } if (failedItem == null) { - updateNotificationProgress(notification, i + 1, items.size) + updateNotificationProgress(taskId, i + 1, items.size) } else { break } @@ -456,13 +659,13 @@ class FileOperationService : Service() { }.failedItem // treat cancellation as success } - private suspend fun recursiveCountChildElements(rootDirectory: DocumentFile, scope: CoroutineScope): Int { + private suspend fun recursiveCountChildElements(rootDirectory: DocumentFile): Int { yield() val children = rootDirectory.listFiles() var count = children.size for (child in children) { if (child.isDirectory) { - count += recursiveCountChildElements(child, scope) + count += recursiveCountChildElements(child) } } return count @@ -472,9 +675,8 @@ class FileOperationService : Service() { src: DocumentFile, dst: DocumentFile, dstRootDirectory: ObjRef?, - notification: FileOperationNotification, + taskId: Int, total: Int, - scope: CoroutineScope, progress: ObjRef = ObjRef(0) ): DocumentFile? { val dstDir = dst.createDirectory(src.name ?: return src) ?: return src @@ -491,10 +693,10 @@ class FileOperationService : Service() { inputStream.close() if (written != child.length()) return child } else { - recursiveCopyVolume(child, dstDir, null, notification, total, scope, progress)?.let { return it } + recursiveCopyVolume(child, dstDir, null, taskId, total, progress)?.let { return it } } progress.value++ - updateNotificationProgress(notification, progress.value, total) + updateNotificationProgress(taskId, progress.value, total) } return null } @@ -502,13 +704,14 @@ class FileOperationService : Service() { class CopyVolumeResult(val taskResult: TaskResult, val dstRootDirectory: DocumentFile?) suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult { - val notification = showNotification(R.string.copy_volume_notification, null) val dstRootDirectory = ObjRef(null) - val task = serviceScope.async(Dispatchers.IO) { - val total = recursiveCountChildElements(src, this) - updateNotificationProgress(notification, 0, total) - recursiveCopyVolume(src, dst, dstRootDirectory, notification, total, this) - } - return CopyVolumeResult(waitForTask(notification, task), dstRootDirectory.value) + val result = globalTask(R.string.copy_volume_notification, null, { taskId -> + val total = recursiveCountChildElements(src) + updateNotificationProgress(taskId, 0, total) + recursiveCopyVolume(src, dst, dstRootDirectory, taskId, total) + }, { + dstRootDirectory.value?.delete() + }) + return CopyVolumeResult(result, dstRootDirectory.value) } } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/file_operations/NotificationBroadcastReceiver.kt b/app/src/main/java/sushi/hardcore/droidfs/file_operations/NotificationBroadcastReceiver.kt index e10840c..ae0ebca 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/file_operations/NotificationBroadcastReceiver.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/file_operations/NotificationBroadcastReceiver.kt @@ -6,13 +6,11 @@ import android.content.Intent class NotificationBroadcastReceiver: BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - if (intent.action == FileOperationService.ACTION_CANCEL){ + if (intent.action == FileOperationService.ACTION_CANCEL) { intent.getBundleExtra("bundle")?.let { bundle -> - (bundle.getBinder("binder") as FileOperationService.LocalBinder?)?.let { binder -> - val notificationId = bundle.getInt("notificationId") - val service = binder.getService() - service.cancelOperation(notificationId) - } + // TODO: use peekService instead? + val binder = (bundle.getBinder("binder") as FileOperationService.LocalBinder?) + binder?.getService()?.cancelOperation(bundle.getInt("taskId")) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd68b14..11a2e58 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -279,4 +279,6 @@ Debug DroidFS Logcat Logcat saved + Later + Notification permission has been denied. Background file operations won\'t be visible. You can change this in the app\'s permission settings.