diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt index 9fc5a94d..b0f203f0 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt @@ -15,6 +15,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import com.getcapacitor.JSObject import org.json.JSONException +import java.io.File class FolderScanner(var ctx: Context) { private val tag = "FolderScanner" @@ -231,8 +232,189 @@ class FolderScanner(var ctx: Context) { } } + private fun scanInternalDownloadItem(downloadItem:DownloadItem, cb: (DownloadItemScanResult?) -> Unit) { + val localLibraryItemId = "local_${downloadItem.libraryItemId}" + + var localEpisodeId:String? = null + var localLibraryItem:LocalLibraryItem? + if (downloadItem.mediaType == "book") { + localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId) + } else { + // Lookup or create podcast local library item + localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) + if (localLibraryItem == null) { + Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}") + localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId) + } + } + + val audioTracks:MutableList = mutableListOf() + var foundEBookFile = false + + downloadItem.downloadItemParts.forEach { downloadItemPart -> + Log.d(tag, "Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}") + + val file = File(downloadItemPart.finalDestinationPath) + Log.d(tag, "Scan internal storage item created file ${file.name}") + + if (file == null) { + Log.e(tag, "scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}") + } else { + if (downloadItemPart.audioTrack != null) { + val audioTrackFromServer = downloadItemPart.audioTrack + Log.d( + tag, + "scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}" + ) + + val localFileId = DeviceManager.getBase64Id(file.name) + Log.d(tag, "Scan internal file localFileId=$localFileId") + val localFile = LocalFile( + localFileId, + file.name, + downloadItemPart.finalDestinationUri.toString(), + file.getBasePath(ctx), + file.absolutePath, + file.getSimplePath(ctx), + file.mimeType, + file.length() + ) + localLibraryItem.localFiles.add(localFile) + + // Create new audio track + val track = AudioTrack( + audioTrackFromServer.index, + audioTrackFromServer.startOffset, + audioTrackFromServer.duration, + localFile.filename ?: "", + localFile.contentUrl, + localFile.mimeType ?: "", + null, + true, + localFileId, + null, + audioTrackFromServer.index + ) + audioTracks.add(track) + + Log.d( + tag, + "scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}" + ) + + // Add podcast episodes to library + downloadItemPart.episode?.let { podcastEpisode -> + val podcast = localLibraryItem.media as Podcast + val newEpisode = podcast.addEpisode(track, podcastEpisode) + localEpisodeId = newEpisode.id + Log.d( + tag, + "scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}" + ) + } + + } else if (downloadItemPart.ebookFile != null) { + foundEBookFile = true + Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}") + val localFileId = DeviceManager.getBase64Id(file.name) + val localFile = LocalFile( + localFileId, + file.name, + Uri.fromFile(file).toString(), + file.getBasePath(ctx), + file.absolutePath, + file.getSimplePath(ctx), + file.mimeType, + file.length() + ) + localLibraryItem.localFiles.add(localFile) + + val ebookFile = EBookFile( + downloadItemPart.ebookFile.ino, + downloadItemPart.ebookFile.metadata, + downloadItemPart.ebookFile.ebookFormat, + true, + localFileId, + localFile.contentUrl + ) + (localLibraryItem.media as Book).ebookFile = ebookFile + Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}") + } else { + val localFileId = DeviceManager.getBase64Id(file.name) + val localFile = LocalFile(localFileId,file.name,Uri.fromFile(file).toString(),file.getBasePath(ctx),file.absolutePath,file.getSimplePath(ctx),file.mimeType,file.length()) + + localLibraryItem.coverAbsolutePath = localFile.absolutePath + localLibraryItem.coverContentUrl = localFile.contentUrl + localLibraryItem.localFiles.add(localFile) + } + } + } + + if (audioTracks.isEmpty() && !foundEBookFile) { + Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}") + return cb(null) + } + + // For books sort audio tracks then set + if (downloadItem.mediaType == "book") { + audioTracks.sortBy { it.index } + + var indexCheck = 1 + var startOffset = 0.0 + audioTracks.forEach { audioTrack -> + if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) { + audioTrack.index = indexCheck + audioTrack.startOffset = startOffset + } + indexCheck++ + startOffset += audioTrack.duration + } + + localLibraryItem.media.setAudioTracks(audioTracks) + } + + val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null) + + // If library item had media progress then make local media progress and save + downloadItem.userMediaProgress?.let { mediaProgress -> + val localMediaProgressId = if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId" + val newLocalMediaProgress = LocalMediaProgress( + id = localMediaProgressId, + localLibraryItemId = localLibraryItemId, + localEpisodeId = localEpisodeId, + duration = mediaProgress.duration, + progress = mediaProgress.progress, + currentTime = mediaProgress.currentTime, + isFinished = false, + ebookLocation = mediaProgress.ebookLocation, + ebookProgress = mediaProgress.ebookProgress, + lastUpdate = mediaProgress.lastUpdate, + startedAt = mediaProgress.startedAt, + finishedAt = mediaProgress.finishedAt, + serverConnectionConfigId = downloadItem.serverConnectionConfigId, + serverAddress = downloadItem.serverAddress, + serverUserId = downloadItem.serverUserId, + libraryItemId = downloadItem.libraryItemId, + episodeId = downloadItem.episodeId) + Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}") + DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress) + + downloadItemScanResult.localMediaProgress = newLocalMediaProgress + } + + DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) + + cb(downloadItemScanResult) + } + // Scan item after download and create local library item fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) { + // If downloading to internal storage handle separately + if (downloadItem.isInternalStorage) { + scanInternalDownloadItem(downloadItem, cb) + return + } + val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl)) val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf() diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt index 5a6e0fdd..027ea804 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt @@ -22,6 +22,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.io.File +import java.io.FileOutputStream import java.util.* class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) { @@ -44,6 +46,11 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde fun onDownloadItemComplete(jsobj:JSObject) } + interface InternalProgressCallback { + fun onProgress(totalBytesWritten:Long, progress: Long) + fun onComplete(failed: Boolean) + } + companion object { var isDownloading:Boolean = false } @@ -65,11 +72,32 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde if (nextDownloadItemParts.size > 0) { nextDownloadItemParts.forEach { - val dlRequest = it.getDownloadRequest() - val downloadId = downloadManager.enqueue(dlRequest) - it.downloadId = downloadId - Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId") - currentDownloadItemParts.add(it) + if (it.isInternalStorage) { + val file = File(it.finalDestinationPath) + file.parentFile?.mkdirs() + + val fileOutputStream = FileOutputStream(it.finalDestinationPath) + val internalProgressCallback = (object : InternalProgressCallback { + override fun onProgress(totalBytesWritten:Long, progress: Long) { + it.bytesDownloaded = totalBytesWritten + it.progress = progress + } + override fun onComplete(failed:Boolean) { + it.failed = failed + it.completed = true + } + }) + + Log.d(tag, "Start internal download to destination path ${it.finalDestinationPath} from ${it.serverUrl}") + InternalDownloadManager(fileOutputStream, internalProgressCallback).download(it.serverUrl) + currentDownloadItemParts.add(it) + } else { + val dlRequest = it.getDownloadRequest() + val downloadId = downloadManager.enqueue(dlRequest) + it.downloadId = downloadId + Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId") + currentDownloadItemParts.add(it) + } } } @@ -89,14 +117,25 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde isDownloading = true while (currentDownloadItemParts.size > 0) { - val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it } for (downloadItemPart in itemParts) { - val downloadCheckStatus = checkDownloadItemPart(downloadItemPart) - clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart) + if (downloadItemPart.isInternalStorage) { + clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart) - // Will move to final destination, remove current item parts, and check if download item is finished - handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart) + if (downloadItemPart.completed) { + val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId } + downloadItem?.let { + checkDownloadItemFinished(it) + } + currentDownloadItemParts.remove(downloadItemPart) + } + } else { + val downloadCheckStatus = checkDownloadItemPart(downloadItemPart) + clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart) + + // Will move to final destination, remove current item parts, and check if download item is finished + handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart) + } } delay(500) @@ -166,7 +205,6 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde Log.e(tag, "Download item part finished but download item not found ${downloadItemPart.filename}") currentDownloadItemParts.remove(downloadItemPart) } else if (downloadCheckStatus == DownloadCheckStatus.Successful) { - val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri) Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}") diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt new file mode 100644 index 00000000..a9282666 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt @@ -0,0 +1,74 @@ +package com.audiobookshelf.app.managers + +import android.util.Log +import com.google.common.net.HttpHeaders.CONTENT_LENGTH +import okhttp3.* +import java.io.* +import java.util.* + +class InternalDownloadManager(outputStream:FileOutputStream, private val progressCallback: DownloadItemManager.InternalProgressCallback) : AutoCloseable { + private val tag = "InternalDownloadManager" + + private val client: OkHttpClient = OkHttpClient() + private val writer = BinaryFileWriter(outputStream, progressCallback) + + @Throws(IOException::class) + fun download(url:String) { + val request: Request = Request.Builder().url(url).build() + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.e(tag, "download URL $url FAILED") + progressCallback.onComplete(true) + } + + override fun onResponse(call: Call, response: Response) { + val responseBody: ResponseBody = response.body + ?: throw IllegalStateException("Response doesn't contain a file") + + val length: Long = (response.header(CONTENT_LENGTH, "1") ?: "0").toLong() + writer.write(responseBody.byteStream(), length) + } + }) + } + + @Throws(Exception::class) + override fun close() { + writer.close() + } +} + +class BinaryFileWriter(outputStream: OutputStream, progressCallback: DownloadItemManager.InternalProgressCallback) : + AutoCloseable { + private val outputStream: OutputStream + private val progressCallback: DownloadItemManager.InternalProgressCallback + + init { + this.outputStream = outputStream + this.progressCallback = progressCallback + } + + @Throws(IOException::class) + fun write(inputStream: InputStream?, length: Long): Long { + BufferedInputStream(inputStream).use { input -> + val dataBuffer = ByteArray(CHUNK_SIZE) + var readBytes: Int + var totalBytes: Long = 0 + while (input.read(dataBuffer).also { readBytes = it } != -1) { + totalBytes += readBytes.toLong() + outputStream.write(dataBuffer, 0, readBytes) + progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length) + } + progressCallback.onComplete(false) + return totalBytes + } + } + + @Throws(IOException::class) + override fun close() { + outputStream.close() + } + + companion object { + private const val CHUNK_SIZE = 1024 + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt index 9f949cfd..918744eb 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt @@ -21,6 +21,9 @@ data class DownloadItem( val media: MediaType, val downloadItemParts: MutableList ) { + @get:JsonIgnore + val isInternalStorage get() = localFolder.id.startsWith("internal-") + @get:JsonIgnore val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving } diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt index ba5c7423..d2fa1be1 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt @@ -73,6 +73,12 @@ data class DownloadItemPart( } } + @get:JsonIgnore + val isInternalStorage get() = localFolderId.startsWith("internal-") + + @get:JsonIgnore + val serverUrl get() = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}" + @JsonIgnore fun getDownloadRequest(): DownloadManager.Request { val dlRequest = DownloadManager.Request(uri) diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt index 51b0ef25..18c5c303 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt @@ -57,7 +57,7 @@ class AbsDownloader : Plugin() { val libraryItemId = call.data.getString("libraryItemId").toString() var episodeId = call.data.getString("episodeId").toString() if (episodeId == "null") episodeId = "" - val localFolderId = call.data.getString("localFolderId").toString() + var localFolderId = call.data.getString("localFolderId", "").toString() Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId") val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId" @@ -72,9 +72,18 @@ class AbsDownloader : Plugin() { } else { Log.d(tag, "Got library item from server ${libraryItem.id}") - val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId) - if (localFolder != null) { + if (localFolderId == "") { + localFolderId = "internal-${libraryItem.mediaType}" + } + var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId) + if (localFolder == null && localFolderId.startsWith("internal-")) { + Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId") + localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "", "internal", libraryItem.mediaType) + DeviceManager.dbManager.saveLocalFolder(localFolder) + } + + if (localFolder != null) { if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") { Log.e(tag, "Library item is not a podcast but episode was requested") call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}")) @@ -127,7 +136,9 @@ class AbsDownloader : Plugin() { } private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) { - val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) + val isInternal = localFolder.id.startsWith("internal-") + + val tempFolderPath = if (isInternal) "${mainActivity.filesDir}/downloads/${libraryItem.id}" else mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) Log.d(tag, "downloadCacheDirectory=$tempFolderPath") @@ -138,7 +149,7 @@ class AbsDownloader : Plugin() { val tracks = libraryItem.media.getAudioTracks() Log.d(tag, "Starting library item download with ${tracks.size} tracks") val itemSubfolder = "$bookAuthor/$bookTitle" - val itemFolderPath = "${localFolder.absolutePath}/$itemSubfolder" + val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$itemSubfolder" val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf()) val book = libraryItem.media as Book diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt index 33730bad..5a5c2cc9 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt @@ -227,35 +227,44 @@ class AbsFileSystem : Plugin() { val contentUrl = call.data.getString("contentUrl", "").toString() Log.d(tag, "deleteItem $absolutePath | $contentUrl") - var subfolderPathToDelete = "" - // Check if should delete subfolder val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) - localLibraryItem?.folderId?.let { folderId -> - val folder = DeviceManager.dbManager.getLocalFolder(folderId) - folder?.absolutePath?.let { folderPath -> - val splitAbsolutePath = absolutePath.split("/") - val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/") - if (fullSubDir != folderPath) { - val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem -> - if (_localLibraryItem.id == localLibraryItemId) { - false - } else { - _localLibraryItem.absolutePath.startsWith(fullSubDir) + + val success: Boolean + + // If internal library item use File to delete + if (localLibraryItem?.folderId?.startsWith("internal-") == true) { + Log.d(tag, "Deleting internal library item at absolutePath $absolutePath") + val file = File(absolutePath) + success = file.deleteRecursively() + } else { + var subfolderPathToDelete = "" + localLibraryItem?.folderId?.let { folderId -> + val folder = DeviceManager.dbManager.getLocalFolder(folderId) + folder?.absolutePath?.let { folderPath -> + val splitAbsolutePath = absolutePath.split("/") + val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/") + if (fullSubDir != folderPath) { + val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem -> + if (_localLibraryItem.id == localLibraryItemId) { + false + } else { + _localLibraryItem.absolutePath.startsWith(fullSubDir) + } } + subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir } - subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir } } - } - val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl)) - val success = docfile?.delete() == true + val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl)) + success = docfile?.delete() == true - if (subfolderPathToDelete != "") { - Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete") - val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete) - docfilesub?.delete() + if (subfolderPathToDelete != "") { + Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete") + val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete) + docfilesub?.delete() + } } if (success) { diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt~ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt~ new file mode 100644 index 00000000..5a5c2cc9 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt~ @@ -0,0 +1,300 @@ +package com.audiobookshelf.app.plugins + +import android.app.AlertDialog +import android.database.Cursor +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.SimpleStorage +import com.anggrayudi.storage.callback.FolderPickerCallback +import com.anggrayudi.storage.callback.StorageAccessCallback +import com.anggrayudi.storage.file.* +import com.audiobookshelf.app.MainActivity +import com.audiobookshelf.app.data.LocalFolder +import com.audiobookshelf.app.data.LocalLibraryItem +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.device.FolderScanner +import com.fasterxml.jackson.core.json.JsonReadFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.getcapacitor.* +import com.getcapacitor.annotation.CapacitorPlugin +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.io.File + +@CapacitorPlugin(name = "AbsFileSystem") +class AbsFileSystem : Plugin() { + private val TAG = "AbsFileSystem" + private val tag = "AbsFileSystem" + private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + + lateinit var mainActivity: MainActivity + + override fun load() { + mainActivity = (activity as MainActivity) + + mainActivity.storage.storageAccessCallback = object : StorageAccessCallback { + override fun onRootPathNotSelected( + requestCode: Int, + rootPath: String, + uri: Uri, + selectedStorageType: StorageType, + expectedStorageType: StorageType + ) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + + override fun onCanceledByUser(requestCode: Int) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + + override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + + override fun onStoragePermissionDenied(requestCode: Int) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + + override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + } + } + + @PluginMethod + fun selectFolder(call: PluginCall) { + val mediaType = call.data.getString("mediaType", "book").toString() + val REQUEST_CODE_SELECT_FOLDER = 6 + val REQUEST_CODE_SDCARD_ACCESS = 7 + + mainActivity.storage.folderPickerCallback = object : FolderPickerCallback { + override fun onFolderSelected(requestCode: Int, folder: DocumentFile) { + Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}") + val absolutePath = folder.getAbsolutePath(activity) + val storageType = folder.getStorageType(activity) + val simplePath = folder.getSimplePath(activity) + val basePath = folder.getBasePath(activity) + val folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT) + + val localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType) + + DeviceManager.dbManager.saveLocalFolder(localFolder) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder))) + } + + override fun onStorageAccessDenied( + requestCode: Int, + folder: DocumentFile?, + storageType: StorageType, + storageId: String + ) { + Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}") + + val jsobj = JSObject() + if (requestCode == REQUEST_CODE_SELECT_FOLDER) { + + val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity) + builder.setMessage( + "You have no write access to this storage, thus selecting this folder is useless." + + "\nWould you like to grant access to this folder?") + builder.setNegativeButton("Dont Allow") { _, _ -> + run { + jsobj.put("error", "User Canceled, Access Denied") + call.resolve(jsobj) + } + } + builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, initialPath = FileFullPath(mainActivity, storageId, "")) } + builder.show() + } else { + Log.d(TAG, "STORAGE ACCESS DENIED $requestCode") + jsobj.put("error", "Access Denied") + call.resolve(jsobj) + } + } + + + override fun onStoragePermissionDenied(requestCode: Int) { + Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode") + val jsobj = JSObject() + jsobj.put("error", "Permission Denied") + call.resolve(jsobj) + } + + } + + mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER) + } + + @RequiresApi(Build.VERSION_CODES.R) + @PluginMethod + fun requestStoragePermission(call: PluginCall) { + Log.d(TAG, "Request Storage Permissions") + mainActivity.storageHelper.requestStorageAccess() + call.resolve() + } + + @PluginMethod + fun checkStoragePermission(call: PluginCall) { + val res: Boolean + if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) { + res = SimpleStorage.hasStoragePermission(context) + Log.d(TAG, "checkStoragePermission: Check Storage Access $res") + } else { + Log.d(TAG, "checkStoragePermission: Has permission on Android 10 or up") + res = true + } + + val jsobj = JSObject() + jsobj.put("value", res) + call.resolve(jsobj) + } + + @PluginMethod + fun checkFolderPermissions(call: PluginCall) { + val folderUrl = call.data.getString("folderUrl", "").toString() + Log.d(TAG, "Check Folder Permissions for $folderUrl") + + val hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true) + + val jsobj = JSObject() + jsobj.put("value", hasAccess) + call.resolve(jsobj) + } + + @PluginMethod + fun scanFolder(call: PluginCall) { + val folderId = call.data.getString("folderId", "").toString() + val forceAudioProbe = call.data.getBoolean("forceAudioProbe") + Log.d(TAG, "Scan Folder $folderId | Force Audio Probe $forceAudioProbe") + + val folder: LocalFolder? = DeviceManager.dbManager.getLocalFolder(folderId) + folder?.let { + val folderScanner = FolderScanner(context) + val folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe) + if (folderScanResult == null) { + Log.d(TAG, "NO Scan DATA") + return call.resolve(JSObject()) + } else { + Log.d(TAG, "Scan DATA ${jacksonMapper.writeValueAsString(folderScanResult)}") + return call.resolve(JSObject(jacksonMapper.writeValueAsString(folderScanResult))) + } + } ?: call.resolve(JSObject()) + } + + @PluginMethod + fun removeFolder(call: PluginCall) { + val folderId = call.data.getString("folderId", "").toString() + DeviceManager.dbManager.removeLocalFolder(folderId) + call.resolve() + } + + @PluginMethod + fun removeLocalLibraryItem(call: PluginCall) { + val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString() + DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId) + call.resolve() + } + + @PluginMethod + fun scanLocalLibraryItem(call: PluginCall) { + val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString() + val forceAudioProbe = call.data.getBoolean("forceAudioProbe") + Log.d(TAG, "Scan Local library item $localLibraryItemId | Force Audio Probe $forceAudioProbe") + GlobalScope.launch(Dispatchers.IO) { + val localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) + localLibraryItem?.let { + val folderScanner = FolderScanner(context) + val scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe) + if (scanResult == null) { + Log.d(TAG, "NO Scan DATA") + call.resolve(JSObject()) + } else { + Log.d(TAG, "Scan DATA ${jacksonMapper.writeValueAsString(scanResult)}") + call.resolve(JSObject(jacksonMapper.writeValueAsString(scanResult))) + } + } ?: call.resolve(JSObject()) + } + } + + @PluginMethod + fun deleteItem(call: PluginCall) { + val localLibraryItemId = call.data.getString("id", "").toString() + val absolutePath = call.data.getString("absolutePath", "").toString() + val contentUrl = call.data.getString("contentUrl", "").toString() + Log.d(tag, "deleteItem $absolutePath | $contentUrl") + + // Check if should delete subfolder + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) + + val success: Boolean + + // If internal library item use File to delete + if (localLibraryItem?.folderId?.startsWith("internal-") == true) { + Log.d(tag, "Deleting internal library item at absolutePath $absolutePath") + val file = File(absolutePath) + success = file.deleteRecursively() + } else { + var subfolderPathToDelete = "" + localLibraryItem?.folderId?.let { folderId -> + val folder = DeviceManager.dbManager.getLocalFolder(folderId) + folder?.absolutePath?.let { folderPath -> + val splitAbsolutePath = absolutePath.split("/") + val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/") + if (fullSubDir != folderPath) { + val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem -> + if (_localLibraryItem.id == localLibraryItemId) { + false + } else { + _localLibraryItem.absolutePath.startsWith(fullSubDir) + } + } + subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir + } + } + } + + val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl)) + success = docfile?.delete() == true + + if (subfolderPathToDelete != "") { + Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete") + val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete) + docfilesub?.delete() + } + } + + if (success) { + DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId) + } + call.resolve(JSObject("{\"success\":$success}")) + } + + @PluginMethod + fun deleteTrackFromItem(call: PluginCall) { + val localLibraryItemId = call.data.getString("id", "").toString() + val trackLocalFileId = call.data.getString("trackLocalFileId", "").toString() + val contentUrl = call.data.getString("trackContentUrl", "").toString() + Log.d(tag, "deleteTrackFromItem $contentUrl") + + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) + if (localLibraryItem == null) { + Log.e(tag, "deleteTrackFromItem: LLI does not exist $localLibraryItemId") + return call.resolve(JSObject("{\"success\":false}")) + } + + val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl)) + val success = docfile?.delete() == true + if (success) { + localLibraryItem.media.removeAudioTrack(trackLocalFileId) + localLibraryItem.removeLocalFile(trackLocalFileId) + DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem))) + } else { + call.resolve(JSObject("{\"success\":false}")) + } + } +} diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml index bd0c4d80..782d63b9 100644 --- a/android/app/src/main/res/xml/file_paths.xml +++ b/android/app/src/main/res/xml/file_paths.xml @@ -1,5 +1,5 @@ - - - \ No newline at end of file + + + diff --git a/components/app/AudioPlayer.vue b/components/app/AudioPlayer.vue index 23d78c1e..abe3bddc 100644 --- a/components/app/AudioPlayer.vue +++ b/components/app/AudioPlayer.vue @@ -272,7 +272,7 @@ export default { return this.playbackSession ? this.playbackSession.localLibraryItem || null : null }, localLibraryItemCoverSrc() { - var localItemCover = this.localLibraryItem ? this.localLibraryItem.coverContentUrl : null + var localItemCover = this.localLibraryItem?.coverContentUrl || null if (localItemCover) return Capacitor.convertFileSrc(localItemCover) return null }, diff --git a/components/modals/SelectLocalFolderModal.vue b/components/modals/SelectLocalFolderModal.vue index bc326bd9..e9b0dbfe 100644 --- a/components/modals/SelectLocalFolderModal.vue +++ b/components/modals/SelectLocalFolderModal.vue @@ -10,8 +10,9 @@