Target Android 13 & Make FileOperationService a foreground service
This commit is contained in:
parent
d44601f69f
commit
52a29b034c
@ -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"
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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 -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -191,14 +191,8 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +0,0 @@
|
|||||||
package sushi.hardcore.droidfs.file_operations
|
|
||||||
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
|
|
||||||
class FileOperationNotification(val notificationBuilder: NotificationCompat.Builder, val notificationId: Int)
|
|
@ -1,64 +1,190 @@
|
|||||||
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"
|
||||||
|
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) {
|
if (!::notificationManager.isInitialized) {
|
||||||
notificationManager = NotificationManagerCompat.from(this)
|
notificationManager = NotificationManagerCompat.from(this)
|
||||||
}
|
}
|
||||||
@ -72,8 +198,7 @@ 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(
|
||||||
@ -81,78 +206,165 @@ class FileOperationService : Service() {
|
|||||||
getString(R.string.cancel),
|
getString(R.string.cancel),
|
||||||
PendingIntent.getBroadcast(
|
PendingIntent.getBroadcast(
|
||||||
this,
|
this,
|
||||||
0,
|
newTaskId,
|
||||||
Intent(this, NotificationBroadcastReceiver::class.java).apply {
|
Intent(this, NotificationBroadcastReceiver::class.java).apply {
|
||||||
val bundle = Bundle()
|
putExtra("bundle", Bundle().apply {
|
||||||
bundle.putBinder("binder", LocalBinder())
|
putBinder("binder", LocalBinder())
|
||||||
bundle.putInt("notificationId", lastNotificationId)
|
putInt("taskId", newTaskId)
|
||||||
putExtra("bundle", bundle)
|
})
|
||||||
action = ACTION_CANCEL
|
action = ACTION_CANCEL
|
||||||
},
|
},
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
))
|
))
|
||||||
if (total != null) {
|
if (task.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -8,11 +8,9 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
Loading…
Reference in New Issue
Block a user