package sushi.hardcore.droidfs.file_operations import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Intent import android.net.Uri import android.os.Binder import android.os.Build import android.os.Bundle import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.documentfile.provider.DocumentFile import sushi.hardcore.droidfs.GocryptfsVolume import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.explorers.ExplorerElement import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.Wiper import java.io.File import java.io.FileNotFoundException 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 gocryptfsVolume: GocryptfsVolume private lateinit var notificationManager: NotificationManagerCompat private var notifications = HashMap() private var lastNotificationId = 0 inner class LocalBinder : Binder() { fun getService(): FileOperationService = this@FileOperationService fun setGocryptfsVolume(g: GocryptfsVolume) { gocryptfsVolume = g } } override fun onBind(p0: Intent?): IBinder { return binder } private fun showNotification(message: Int, total: Int?): FileOperationNotification { ++lastNotificationId if (!::notificationManager.isInitialized){ notificationManager = NotificationManagerCompat.from(this) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { notificationManager.createNotificationChannel( NotificationChannel( NOTIFICATION_CHANNEL_ID, getString(R.string.file_operations), NotificationManager.IMPORTANCE_LOW ) ) } val notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) notificationBuilder .setContentTitle(getString(message)) .setSmallIcon(R.mipmap.icon_launcher) .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 ) )) if (total != null) { notificationBuilder .setContentText("0/$total") .setProgress(total, 0, false) } else { notificationBuilder .setContentText(getString(R.string.discovering_files)) .setProgress(0, 0, true) } notifications[lastNotificationId] = false notificationManager.notify(lastNotificationId, notificationBuilder.build()) return FileOperationNotification(notificationBuilder, lastNotificationId) } private fun updateNotificationProgress(notification: FileOperationNotification, progress: Int, total: Int){ notification.notificationBuilder .setProgress(total, progress, false) .setContentText("$progress/$total") notificationManager.notify(notification.notificationId, notification.notificationBuilder.build()) } private fun cancelNotification(notification: FileOperationNotification){ notificationManager.cancel(notification.notificationId) } fun cancelOperation(notificationId: Int){ notifications[notificationId] = true } private fun copyFile(srcPath: String, dstPath: String, remoteGocryptfsVolume: GocryptfsVolume = gocryptfsVolume): Boolean { var success = true val srcHandleId = remoteGocryptfsVolume.openReadMode(srcPath) if (srcHandleId != -1){ val dstHandleId = gocryptfsVolume.openWriteMode(dstPath) if (dstHandleId != -1){ var offset: Long = 0 val ioBuffer = ByteArray(GocryptfsVolume.DefaultBS) var length: Int while (remoteGocryptfsVolume.readFile(srcHandleId, offset, ioBuffer).also { length = it } > 0) { val written = gocryptfsVolume.writeFile(dstHandleId, offset, ioBuffer, length).toLong() if (written == length.toLong()) { offset += written } else { success = false break } } gocryptfsVolume.closeFile(dstHandleId) } else { success = false } remoteGocryptfsVolume.closeFile(srcHandleId) } else { success = false } return success } fun copyElements(items: ArrayList, remoteGocryptfsVolume: GocryptfsVolume = gocryptfsVolume, callback: (String?) -> Unit){ Thread { val notification = showNotification(R.string.file_op_copy_msg, items.size) var failedItem: String? = null for (i in 0 until items.size){ if (notifications[notification.notificationId]!!){ cancelNotification(notification) return@Thread } if (items[i].explorerElement.isDirectory){ if (!gocryptfsVolume.pathExists(items[i].dstPath!!)) { if (!gocryptfsVolume.mkdir(items[i].dstPath!!)) { failedItem = items[i].explorerElement.fullPath } } } else { if (!copyFile(items[i].explorerElement.fullPath, items[i].dstPath!!, remoteGocryptfsVolume)){ failedItem = items[i].explorerElement.fullPath } } if (failedItem == null){ updateNotificationProgress(notification, i, items.size) } else { break } } cancelNotification(notification) callback(failedItem) }.start() } fun moveElements(items: ArrayList, callback: (String?) -> Unit){ Thread { val notification = showNotification(R.string.file_op_move_msg, items.size) val mergedFolders = ArrayList() var failedItem: String? = null for (i in 0 until items.size){ if (notifications[notification.notificationId]!!){ cancelNotification(notification) return@Thread } if (items[i].explorerElement.isDirectory && gocryptfsVolume.pathExists(items[i].dstPath!!)){ //folder will be merged mergedFolders.add(items[i].explorerElement.fullPath) } else { if (!gocryptfsVolume.rename(items[i].explorerElement.fullPath, items[i].dstPath!!)){ failedItem = items[i].explorerElement.fullPath break } else { updateNotificationProgress(notification, i, items.size) } } } if (failedItem == null){ for (i in 0 until mergedFolders.size) { if (notifications[notification.notificationId]!!){ cancelNotification(notification) return@Thread } if (!gocryptfsVolume.rmdir(mergedFolders[i])){ failedItem = mergedFolders[i] break } else { updateNotificationProgress(notification, items.size-(mergedFolders.size-i), items.size) } } } cancelNotification(notification) callback(failedItem) }.start() } private fun importFilesFromUris(dstPaths: List, uris: List, reuseNotification: FileOperationNotification? = null, callback: (String?) -> Unit){ val notification = reuseNotification ?: showNotification(R.string.file_op_import_msg, dstPaths.size) var failedIndex = -1 for (i in dstPaths.indices) { if (notifications[notification.notificationId]!!){ cancelNotification(notification) return } try { if (!gocryptfsVolume.importFile(this, uris[i], dstPaths[i])) { failedIndex = i } } catch (e: FileNotFoundException){ failedIndex = i } if (failedIndex == -1) { updateNotificationProgress(notification, i, dstPaths.size) } else { cancelNotification(notification) callback(uris[failedIndex].toString()) break } } if (failedIndex == -1){ cancelNotification(notification) callback(null) } } fun importFilesFromUris(dstPaths: List, uris: List, callback: (String?) -> Unit) { Thread { importFilesFromUris(dstPaths, uris, null, callback) }.start() } /** * 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 fun recursiveMapDirectoryForImport( rootSrcDir: DocumentFile, rootDstPath: String, dstFiles: ArrayList, srcUris: ArrayList, dstDirs: ArrayList, notification: FileOperationNotification ): Boolean { dstDirs.add(rootDstPath) for (child in rootSrcDir.listFiles()) { if (notifications[notification.notificationId]!!) { cancelNotification(notification) return false } child.name?.let { name -> val subPath = PathUtils.pathJoin(rootDstPath, name) if (child.isDirectory) { if (!recursiveMapDirectoryForImport(child, subPath, dstFiles, srcUris, dstDirs, notification)) { return false } } else if (child.isFile) { srcUris.add(child.uri) dstFiles.add(subPath) } } } return true } fun importDirectory(rootDstPath: String, rootSrcDir: DocumentFile, callback: (String?, List) -> Unit) { Thread { val notification = showNotification(R.string.file_op_import_msg, null) val dstFiles = arrayListOf() val srcUris = arrayListOf() val dstDirs = arrayListOf() if (!recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs, notification)) { return@Thread } // create destination folders so the new files can use them for (dir in dstDirs) { if (notifications[notification.notificationId]!!) { cancelNotification(notification) return@Thread } if (!gocryptfsVolume.mkdir(dir)) { cancelNotification(notification) callback(dir, srcUris) break } } importFilesFromUris(dstFiles, srcUris, notification) { failedItem -> callback(failedItem, srcUris) } }.start() } fun wipeUris(uris: List, rootFile: DocumentFile? = null, callback: (String?) -> Unit){ Thread { val notification = showNotification(R.string.file_op_wiping_msg, uris.size) var errorMsg: String? = null for (i in uris.indices) { if (notifications[notification.notificationId]!!){ cancelNotification(notification) return@Thread } errorMsg = Wiper.wipe(this, uris[i]) if (errorMsg == null) { updateNotificationProgress(notification, i, uris.size) } else { break } } if (errorMsg == null) { rootFile?.delete() } cancelNotification(notification) callback(errorMsg) }.start() } private fun exportFileInto(srcPath: String, treeDocumentFile: DocumentFile): Boolean { val outputStream = treeDocumentFile.createFile("*/*", File(srcPath).name)?.uri?.let { contentResolver.openOutputStream(it) } return if (outputStream == null) { false } else { gocryptfsVolume.exportFile(srcPath, outputStream) } } private fun recursiveExportDirectory(plain_directory_path: String, treeDocumentFile: DocumentFile): String? { treeDocumentFile.createDirectory(File(plain_directory_path).name)?.let { childTree -> val explorerElements = gocryptfsVolume.listDir(plain_directory_path) for (e in explorerElements) { val fullPath = PathUtils.pathJoin(plain_directory_path, e.name) if (e.isDirectory) { val failedItem = recursiveExportDirectory(fullPath, childTree) failedItem?.let { return it } } else { if (!exportFileInto(fullPath, childTree)){ return fullPath } } } return null } return treeDocumentFile.name } fun exportFiles(uri: Uri, items: List, callback: (String?) -> Unit){ Thread { contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION) DocumentFile.fromTreeUri(this, uri)?.let { treeDocumentFile -> val notification = showNotification(R.string.file_op_export_msg, items.size) var failedItem: String? = null for (i in items.indices) { if (notifications[notification.notificationId]!!){ cancelNotification(notification) return@Thread } failedItem = if (items[i].isDirectory) { recursiveExportDirectory(items[i].fullPath, treeDocumentFile) } else { if (exportFileInto(items[i].fullPath, treeDocumentFile)) null else items[i].fullPath } if (failedItem == null) { updateNotificationProgress(notification, i, items.size) } else { break } } cancelNotification(notification) callback(failedItem) } }.start() } private fun recursiveCountChildElements(rootDirectory: DocumentFile): Int { val children = rootDirectory.listFiles() var count = children.size for (child in children) { if (child.isDirectory) { count += recursiveCountChildElements(child) } } return count } internal class ObjRef(var value: T) private fun recursiveCopyVolume( src: DocumentFile, dst: DocumentFile, dstRootDirectory: ObjRef?, notification: FileOperationNotification, total: Int, progress: ObjRef = ObjRef(0) ): DocumentFile? { val dstDir = dst.createDirectory(src.name ?: return src) ?: return src for (child in src.listFiles()) { if (notifications[notification.notificationId]!!) { cancelNotification(notification) return null } if (child.isFile) { val dstFile = dstDir.createFile("", child.name ?: return child) ?: return child val outputStream = contentResolver.openOutputStream(dstFile.uri) val inputStream = contentResolver.openInputStream(child.uri) if (outputStream == null || inputStream == null) return child val written = inputStream.copyTo(outputStream) outputStream.close() inputStream.close() if (written != child.length()) return child } else { recursiveCopyVolume(child, dstDir, null, notification, total, progress)?.let { return it } } progress.value++ updateNotificationProgress(notification, progress.value, total) } dstRootDirectory?.let { it.value = dstDir } return null } fun copyVolume(src: DocumentFile, dst: DocumentFile, callback: (DocumentFile?, DocumentFile?) -> Unit) { Thread { val notification = showNotification(R.string.copy_volume_notification, null) val total = recursiveCountChildElements(src) updateNotificationProgress(notification, 0, total) val dstRootDirectory = ObjRef(null) val failedItem = recursiveCopyVolume(src, dst, dstRootDirectory, notification, total) cancelNotification(notification) callback(dstRootDirectory.value, failedItem) }.start() } }