Target Android 13 & Make FileOperationService a foreground service

This commit is contained in:
Matéo Duparc 2024-06-13 16:01:12 +02:00
parent d44601f69f
commit 52a29b034c
Signed by: hardcoresushi
GPG Key ID: AFE384344A45E13A
8 changed files with 326 additions and 138 deletions

View File

@ -36,7 +36,7 @@ android {
defaultConfig { defaultConfig {
applicationId "sushi.hardcore.droidfs" applicationId "sushi.hardcore.droidfs"
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 32 targetSdkVersion 33
versionCode 36 versionCode 36
versionName "2.1.3" versionName "2.1.3"

View File

@ -4,6 +4,9 @@
android:installLocation="auto"> android:installLocation="auto">
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" /> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" /> <uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
@ -56,7 +59,7 @@
<activity android:name=".LogcatActivity"/> <activity android:name=".LogcatActivity"/>
<service android:name=".WiperService" android:exported="false" android:stopWithTask="false"/> <service android:name=".WiperService" android:exported="false" android:stopWithTask="false"/>
<service android:name=".file_operations.FileOperationService" android:exported="false"/> <service android:name=".file_operations.FileOperationService" android:exported="false" android:foregroundServiceType="dataSync"/>
<receiver android:name=".file_operations.NotificationBroadcastReceiver" android:exported="false"> <receiver android:name=".file_operations.NotificationBroadcastReceiver" android:exported="false">
<intent-filter> <intent-filter>

View File

@ -132,13 +132,8 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
} }
} }
startService(Intent(this, WiperService::class.java)) startService(Intent(this, WiperService::class.java))
Intent(this, FileOperationService::class.java).also { FileOperationService.bind(this) {
bindService(it, object : ServiceConnection { fileOperationService = it
override fun onServiceConnected(className: ComponentName, service: IBinder) {
fileOperationService = (service as FileOperationService.LocalBinder).getService()
}
override fun onServiceDisconnected(arg0: ComponentName) {}
}, Context.BIND_AUTO_CREATE)
} }
} }
@ -434,9 +429,6 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
lifecycleScope.launch { lifecycleScope.launch {
val result = fileOperationService.copyVolume(srcDocumentFile, dstDocumentFile) val result = fileOperationService.copyVolume(srcDocumentFile, dstDocumentFile)
when (result.taskResult.state) { when (result.taskResult.state) {
TaskResult.State.CANCELLED -> {
result.dstRootDirectory?.delete()
}
TaskResult.State.SUCCESS -> { TaskResult.State.SUCCESS -> {
result.dstRootDirectory?.let { result.dstRootDirectory?.let {
getResultVolume(it)?.let { volume -> getResultVolume(it)?.let { volume ->
@ -458,6 +450,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
.show() .show()
} }
TaskResult.State.ERROR -> result.taskResult.showErrorAlertDialog(this@MainActivity, theme) TaskResult.State.ERROR -> result.taskResult.showErrorAlertDialog(this@MainActivity, theme)
TaskResult.State.CANCELLED -> {}
} }
} }
} }

View File

@ -190,15 +190,9 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
setContentView(R.layout.activity_explorer) setContentView(R.layout.activity_explorer)
} }
protected open fun bindFileOperationService(){ protected open fun bindFileOperationService() {
Intent(this, FileOperationService::class.java).also { FileOperationService.bind(this) {
bindService(it, object : ServiceConnection { fileOperationService = it
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)
} }
} }

View File

@ -1,5 +0,0 @@
package sushi.hardcore.droidfs.file_operations
import androidx.core.app.NotificationCompat
class FileOperationNotification(val notificationBuilder: NotificationCompat.Builder, val notificationId: Int)

View File

@ -1,65 +1,191 @@
package sushi.hardcore.droidfs.file_operations package sushi.hardcore.droidfs.file_operations
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.ComponentName
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.net.Uri import android.net.Uri
import android.os.Binder import android.os.Binder
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.IBinder 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.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import sushi.hardcore.droidfs.BaseActivity
import sushi.hardcore.droidfs.Constants import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.VolumeManager import sushi.hardcore.droidfs.VolumeManager
import sushi.hardcore.droidfs.VolumeManagerApp import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.explorers.ExplorerElement import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.filesystems.EncryptedVolume import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.Stat
import sushi.hardcore.droidfs.util.ObjRef import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.util.Wiper import sushi.hardcore.droidfs.util.Wiper
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File import java.io.File
import java.io.FileNotFoundException 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() { 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<Int, Job>()
private var lastNotificationId = 0
inner class LocalBinder : Binder() { inner class LocalBinder : Binder() {
fun getService(): FileOperationService = this@FileOperationService fun getService(): FileOperationService = this@FileOperationService
} }
override fun onBind(p0: Intent?): IBinder { inner class PendingTask<T>(
volumeManger = (application as VolumeManagerApp).volumeManager val title: Int,
return binder val total: Int?,
private val getTask: (Int) -> Deferred<T>,
private val onStart: (taskId: Int, job: Deferred<T>) -> Unit,
) {
fun start(taskId: Int): Deferred<T> = getTask(taskId).also { onStart(taskId, it) }
} }
private fun showNotification(message: Int, total: Int?): FileOperationNotification { companion object {
++lastNotificationId const val TAG = "FileOperationService"
if (!::notificationManager.isInitialized){ 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<String>
private var askForNotificationPermission = true
private lateinit var notificationManager: NotificationManagerCompat
private val notifications = HashMap<Int, NotificationCompat.Builder>()
private var foregroundNotificationId = -1
private val tasks = HashMap<Int, Job>()
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) notificationManager = NotificationManagerCompat.from(this)
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -72,87 +198,173 @@ class FileOperationService : Service() {
) )
} }
val notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) val notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
notificationBuilder .setContentTitle(getString(task.title))
.setContentTitle(getString(message)) .setSmallIcon(R.drawable.ic_notification)
.setSmallIcon(R.drawable.ic_notification) .setOngoing(true)
.setOngoing(true) .addAction(NotificationCompat.Action(
.addAction(NotificationCompat.Action( R.drawable.icon_close,
R.drawable.icon_close, getString(R.string.cancel),
getString(R.string.cancel), PendingIntent.getBroadcast(
PendingIntent.getBroadcast( this,
this, newTaskId,
0, Intent(this, NotificationBroadcastReceiver::class.java).apply {
Intent(this, NotificationBroadcastReceiver::class.java).apply { putExtra("bundle", Bundle().apply {
val bundle = Bundle() putBinder("binder", LocalBinder())
bundle.putBinder("binder", LocalBinder()) putInt("taskId", newTaskId)
bundle.putInt("notificationId", lastNotificationId) })
putExtra("bundle", bundle) action = ACTION_CANCEL
action = ACTION_CANCEL },
}, PendingIntent.FLAG_IMMUTABLE
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE )
) ))
)) if (task.total != null) {
if (total != null) {
notificationBuilder notificationBuilder
.setContentText("0/$total") .setContentText("0/${task.total}")
.setProgress(total, 0, false) .setProgress(task.total, 0, false)
} else { } else {
notificationBuilder notificationBuilder
.setContentText(getString(R.string.discovering_files)) .setContentText(getString(R.string.discovering_files))
.setProgress(0, 0, true) .setProgress(0, 0, true)
} }
notificationManager.notify(lastNotificationId, notificationBuilder.build()) showNotification(newTaskId, notificationBuilder.build())
return FileOperationNotification(notificationBuilder, lastNotificationId) notifications[newTaskId] = notificationBuilder
tasks[newTaskId] = task.start(newTaskId)
newTaskId++
} }
private fun updateNotificationProgress(notification: FileOperationNotification, progress: Int, total: Int){ private fun setForeground(id: Int, notification: Notification) {
notification.notificationBuilder 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) .setProgress(total, progress, false)
.setContentText("$progress/$total") .setContentText("$progress/$total")
notificationManager.notify(notification.notificationId, notification.notificationBuilder.build()) notificationManager.notify(taskId, notificationBuilder.build())
} }
private fun cancelNotification(notification: FileOperationNotification){ fun cancelOperation(taskId: Int) {
notificationManager.cancel(notification.notificationId) tasks[taskId]?.cancel()
}
fun cancelOperation(notificationId: Int){
tasks[notificationId]?.cancel()
} }
private fun getEncryptedVolume(volumeId: Int): EncryptedVolume { private fun getEncryptedVolume(volumeId: Int): EncryptedVolume {
return volumeManger.getVolume(volumeId) ?: throw IllegalArgumentException("Invalid volumeId: $volumeId") return volumeManger.getVolume(volumeId) ?: throw IllegalArgumentException("Invalid volumeId: $volumeId")
} }
private suspend fun <T> waitForTask(notification: FileOperationNotification, task: Deferred<T>): TaskResult<out T> { /**
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 <T> waitForTask(
taskId: Int,
task: Deferred<T>,
onCancelled: (suspend () -> Unit)?,
): TaskResult<out T> {
return coroutineScope { return coroutineScope {
withContext(serviceScope.coroutineContext) { withContext(serviceScope.coroutineContext) {
try { try {
TaskResult.completed(task.await()) TaskResult.completed(task.await())
} catch (e: CancellationException) { } catch (e: CancellationException) {
onCancelled?.invoke()
TaskResult.cancelled() TaskResult.cancelled()
} catch (e: Throwable) { } catch (e: Throwable) {
e.printStackTrace() e.printStackTrace()
TaskResult.error(e.localizedMessage) TaskResult.error(e.localizedMessage)
} finally { } 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 <T> volumeTask( /**
volumeId: Int, * Create and run a new task until completion.
notification: FileOperationNotification, *
task: suspend (encryptedVolume: EncryptedVolume) -> T * Handles notification permission request, service startup and notification management.
*
* Overrides [pendingTask] without checking! (safe if user is not insanely fast)
*/
private suspend fun <T> newTask(
title: Int,
total: Int?,
getTask: (taskId: Int) -> Deferred<T>,
onCancelled: (suspend () -> Unit)?,
): TaskResult<out T> { ): TaskResult<out T> {
return waitForTask( val startedTask = suspendCoroutine { continuation ->
notification, val task = PendingTask(title, total, getTask) { taskId, job ->
volumeManger.getCoroutineScope(volumeId).async { continuation.resume(Pair(taskId, job))
task(getEncryptedVolume(volumeId))
} }
) 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 <T> volumeTask(
title: Int,
total: Int?,
volumeId: Int,
task: suspend (taskId: Int, encryptedVolume: EncryptedVolume) -> T
): TaskResult<out T> {
return newTask(title, total, { taskId ->
volumeManger.getCoroutineScope(volumeId).async {
task(taskId, getEncryptedVolume(volumeId))
}
}, null)
}
private suspend fun <T> globalTask(
title: Int,
total: Int?,
task: suspend (taskId: Int) -> T,
onCancelled: (suspend () -> Unit)? = null,
): TaskResult<out T> {
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( private suspend fun copyFile(
@ -196,9 +408,8 @@ class FileOperationService : Service() {
items: List<OperationFile>, items: List<OperationFile>,
srcVolumeId: Int = volumeId, srcVolumeId: Int = volumeId,
): TaskResult<out String?> { ): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_copy_msg, items.size)
val srcEncryptedVolume = getEncryptedVolume(srcVolumeId) 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 var failedItem: String? = null
for (i in items.indices) { for (i in items.indices) {
yield() yield()
@ -212,7 +423,7 @@ class FileOperationService : Service() {
failedItem = items[i].srcPath failedItem = items[i].srcPath
} }
if (failedItem == null) { if (failedItem == null) {
updateNotificationProgress(notification, i+1, items.size) updateNotificationProgress(taskId, i+1, items.size)
} else { } else {
break break
} }
@ -222,8 +433,7 @@ class FileOperationService : Service() {
} }
suspend fun moveElements(volumeId: Int, toMove: List<OperationFile>, toClean: List<String>): TaskResult<out String?> { suspend fun moveElements(volumeId: Int, toMove: List<OperationFile>, toClean: List<String>): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_move_msg, toMove.size) return volumeTask(R.string.file_op_move_msg, toMove.size, volumeId) { taskId, encryptedVolume ->
return volumeTask(volumeId, notification) { encryptedVolume ->
val total = toMove.size+toClean.size val total = toMove.size+toClean.size
var failedItem: String? = null var failedItem: String? = null
for ((i, item) in toMove.withIndex()) { for ((i, item) in toMove.withIndex()) {
@ -231,7 +441,7 @@ class FileOperationService : Service() {
failedItem = item.srcPath failedItem = item.srcPath
break break
} else { } else {
updateNotificationProgress(notification, i+1, total) updateNotificationProgress(taskId, i+1, total)
} }
} }
if (failedItem == null) { if (failedItem == null) {
@ -240,7 +450,7 @@ class FileOperationService : Service() {
failedItem = folderPath failedItem = folderPath
break break
} else { } else {
updateNotificationProgress(notification, toMove.size+i+1, total) updateNotificationProgress(taskId, toMove.size+i+1, total)
} }
} }
} }
@ -252,7 +462,7 @@ class FileOperationService : Service() {
encryptedVolume: EncryptedVolume, encryptedVolume: EncryptedVolume,
dstPaths: List<String>, dstPaths: List<String>,
uris: List<Uri>, uris: List<Uri>,
notification: FileOperationNotification, taskId: Int,
): String? { ): String? {
var failedIndex = -1 var failedIndex = -1
for (i in dstPaths.indices) { for (i in dstPaths.indices) {
@ -265,7 +475,7 @@ class FileOperationService : Service() {
failedIndex = i failedIndex = i
} }
if (failedIndex == -1) { if (failedIndex == -1) {
updateNotificationProgress(notification, i+1, dstPaths.size) updateNotificationProgress(taskId, i+1, dstPaths.size)
} else { } else {
return uris[failedIndex].toString() return uris[failedIndex].toString()
} }
@ -274,9 +484,8 @@ class FileOperationService : Service() {
} }
suspend fun importFilesFromUris(volumeId: Int, dstPaths: List<String>, uris: List<Uri>): TaskResult<out String?> { suspend fun importFilesFromUris(volumeId: Int, dstPaths: List<String>, uris: List<Uri>): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_import_msg, dstPaths.size) return volumeTask(R.string.file_op_import_msg, dstPaths.size, volumeId) { taskId, encryptedVolume ->
return volumeTask(volumeId, notification) { encryptedVolume -> importFilesFromUris(encryptedVolume, dstPaths, uris, taskId)
importFilesFromUris(encryptedVolume, dstPaths, uris, notification)
} }
} }
@ -284,8 +493,6 @@ class FileOperationService : Service() {
* Map the content of an unencrypted directory to prepare its import * Map the content of an unencrypted directory to prepare its import
* *
* Contents of dstFiles and srcUris, at the same index, will match each other * Contents of dstFiles and srcUris, at the same index, will match each other
*
* @return false if cancelled early, true otherwise.
*/ */
private suspend fun recursiveMapDirectoryForImport( private suspend fun recursiveMapDirectoryForImport(
rootSrcDir: DocumentFile, rootSrcDir: DocumentFile,
@ -316,36 +523,35 @@ class FileOperationService : Service() {
rootDstPath: String, rootDstPath: String,
rootSrcDir: DocumentFile, rootSrcDir: DocumentFile,
): ImportDirectoryResult { ): ImportDirectoryResult {
val notification = showNotification(R.string.file_op_import_msg, null)
val srcUris = arrayListOf<Uri>() val srcUris = arrayListOf<Uri>()
return ImportDirectoryResult(volumeTask(volumeId, notification) { encryptedVolume -> return ImportDirectoryResult(volumeTask(R.string.file_op_import_msg, null, volumeId) { taskId, encryptedVolume ->
var failedItem: String? = null var failedItem: String? = null
val dstFiles = arrayListOf<String>() val dstFiles = arrayListOf<String>()
val dstDirs = arrayListOf<String>() val dstDirs = arrayListOf<String>()
recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs) recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs)
// create destination folders so the new files can use them // create destination folders so the new files can use them
for (dir in dstDirs) { 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 failedItem = dir
break break
} }
} }
if (failedItem == null) { if (failedItem == null) {
failedItem = importFilesFromUris(encryptedVolume, dstFiles, srcUris, notification) failedItem = importFilesFromUris(encryptedVolume, dstFiles, srcUris, taskId)
} }
failedItem failedItem
}, srcUris) }, srcUris)
} }
suspend fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null): TaskResult<out String?> { suspend fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_wiping_msg, uris.size) return globalTask(R.string.file_op_wiping_msg, uris.size, { taskId ->
val task = serviceScope.async(Dispatchers.IO) {
var errorMsg: String? = null var errorMsg: String? = null
for (i in uris.indices) { for (i in uris.indices) {
yield() yield()
errorMsg = Wiper.wipe(this@FileOperationService, uris[i]) errorMsg = Wiper.wipe(this@FileOperationService, uris[i])
if (errorMsg == null) { if (errorMsg == null) {
updateNotificationProgress(notification, i+1, uris.size) updateNotificationProgress(taskId, i+1, uris.size)
} else { } else {
break break
} }
@ -354,8 +560,7 @@ class FileOperationService : Service() {
rootFile?.delete() rootFile?.delete()
} }
errorMsg errorMsg
} })
return waitForTask(notification, task)
} }
private fun exportFileInto(encryptedVolume: EncryptedVolume, srcPath: String, treeDocumentFile: DocumentFile): Boolean { private fun exportFileInto(encryptedVolume: EncryptedVolume, srcPath: String, treeDocumentFile: DocumentFile): Boolean {
@ -391,8 +596,7 @@ class FileOperationService : Service() {
} }
suspend fun exportFiles(volumeId: Int, items: List<ExplorerElement>, uri: Uri): TaskResult<out String?> { suspend fun exportFiles(volumeId: Int, items: List<ExplorerElement>, uri: Uri): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_export_msg, items.size) return volumeTask(R.string.file_op_export_msg, items.size, volumeId) { taskId, encryptedVolume ->
return volumeTask(volumeId, notification) { encryptedVolume ->
val treeDocumentFile = DocumentFile.fromTreeUri(this@FileOperationService, uri)!! val treeDocumentFile = DocumentFile.fromTreeUri(this@FileOperationService, uri)!!
var failedItem: String? = null var failedItem: String? = null
for (i in items.indices) { for (i in items.indices) {
@ -407,7 +611,7 @@ class FileOperationService : Service() {
} }
} }
if (failedItem == null) { if (failedItem == null) {
updateNotificationProgress(notification, i+1, items.size) updateNotificationProgress(taskId, i+1, items.size)
} else { } else {
break break
} }
@ -436,8 +640,7 @@ class FileOperationService : Service() {
} }
suspend fun removeElements(volumeId: Int, items: List<ExplorerElement>): String? { suspend fun removeElements(volumeId: Int, items: List<ExplorerElement>): String? {
val notification = showNotification(R.string.file_op_delete_msg, items.size) return volumeTask(R.string.file_op_delete_msg, items.size, volumeId) { taskId, encryptedVolume ->
return volumeTask(volumeId, notification) { encryptedVolume ->
var failedItem: String? = null var failedItem: String? = null
for ((i, element) in items.withIndex()) { for ((i, element) in items.withIndex()) {
yield() yield()
@ -447,7 +650,7 @@ class FileOperationService : Service() {
failedItem = element.fullPath failedItem = element.fullPath
} }
if (failedItem == null) { if (failedItem == null) {
updateNotificationProgress(notification, i + 1, items.size) updateNotificationProgress(taskId, i + 1, items.size)
} else { } else {
break break
} }
@ -456,13 +659,13 @@ class FileOperationService : Service() {
}.failedItem // treat cancellation as success }.failedItem // treat cancellation as success
} }
private suspend fun recursiveCountChildElements(rootDirectory: DocumentFile, scope: CoroutineScope): Int { private suspend fun recursiveCountChildElements(rootDirectory: DocumentFile): Int {
yield() yield()
val children = rootDirectory.listFiles() val children = rootDirectory.listFiles()
var count = children.size var count = children.size
for (child in children) { for (child in children) {
if (child.isDirectory) { if (child.isDirectory) {
count += recursiveCountChildElements(child, scope) count += recursiveCountChildElements(child)
} }
} }
return count return count
@ -472,9 +675,8 @@ class FileOperationService : Service() {
src: DocumentFile, src: DocumentFile,
dst: DocumentFile, dst: DocumentFile,
dstRootDirectory: ObjRef<DocumentFile?>?, dstRootDirectory: ObjRef<DocumentFile?>?,
notification: FileOperationNotification, taskId: Int,
total: Int, total: Int,
scope: CoroutineScope,
progress: ObjRef<Int> = ObjRef(0) progress: ObjRef<Int> = ObjRef(0)
): DocumentFile? { ): DocumentFile? {
val dstDir = dst.createDirectory(src.name ?: return src) ?: return src val dstDir = dst.createDirectory(src.name ?: return src) ?: return src
@ -491,10 +693,10 @@ class FileOperationService : Service() {
inputStream.close() inputStream.close()
if (written != child.length()) return child if (written != child.length()) return child
} else { } else {
recursiveCopyVolume(child, dstDir, null, notification, total, scope, progress)?.let { return it } recursiveCopyVolume(child, dstDir, null, taskId, total, progress)?.let { return it }
} }
progress.value++ progress.value++
updateNotificationProgress(notification, progress.value, total) updateNotificationProgress(taskId, progress.value, total)
} }
return null return null
} }
@ -502,13 +704,14 @@ class FileOperationService : Service() {
class CopyVolumeResult(val taskResult: TaskResult<out DocumentFile?>, val dstRootDirectory: DocumentFile?) class CopyVolumeResult(val taskResult: TaskResult<out DocumentFile?>, val dstRootDirectory: DocumentFile?)
suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult { suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult {
val notification = showNotification(R.string.copy_volume_notification, null)
val dstRootDirectory = ObjRef<DocumentFile?>(null) val dstRootDirectory = ObjRef<DocumentFile?>(null)
val task = serviceScope.async(Dispatchers.IO) { val result = globalTask(R.string.copy_volume_notification, null, { taskId ->
val total = recursiveCountChildElements(src, this) val total = recursiveCountChildElements(src)
updateNotificationProgress(notification, 0, total) updateNotificationProgress(taskId, 0, total)
recursiveCopyVolume(src, dst, dstRootDirectory, notification, total, this) recursiveCopyVolume(src, dst, dstRootDirectory, taskId, total)
} }, {
return CopyVolumeResult(waitForTask(notification, task), dstRootDirectory.value) dstRootDirectory.value?.delete()
})
return CopyVolumeResult(result, dstRootDirectory.value)
} }
} }

View File

@ -6,13 +6,11 @@ import android.content.Intent
class NotificationBroadcastReceiver: BroadcastReceiver() { class NotificationBroadcastReceiver: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action == FileOperationService.ACTION_CANCEL){ if (intent.action == FileOperationService.ACTION_CANCEL) {
intent.getBundleExtra("bundle")?.let { bundle -> intent.getBundleExtra("bundle")?.let { bundle ->
(bundle.getBinder("binder") as FileOperationService.LocalBinder?)?.let { binder -> // TODO: use peekService instead?
val notificationId = bundle.getInt("notificationId") val binder = (bundle.getBinder("binder") as FileOperationService.LocalBinder?)
val service = binder.getService() binder?.getService()?.cancelOperation(bundle.getInt("taskId"))
service.cancelOperation(notificationId)
}
} }
} }
} }

View File

@ -279,4 +279,6 @@
<string name="debug">Debug</string> <string name="debug">Debug</string>
<string name="logcat_title">DroidFS Logcat</string> <string name="logcat_title">DroidFS Logcat</string>
<string name="logcat_saved">Logcat saved</string> <string name="logcat_saved">Logcat saved</string>
<string name="later">Later</string>
<string name="notification_denied_msg">Notification permission has been denied. Background file operations won\'t be visible. You can change this in the app\'s permission settings.</string>
</resources> </resources>