diff --git a/app/build.gradle b/app/build.gradle index 12fc9b90..5f6fe8cd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,6 +21,14 @@ android { versionName "3.0.0" versionCode 300000000 signingConfig signingConfigs.debug + splits { + abi { + enable true + reset() + include 'armeabi', 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' + universalApk true + } + } } flavorDimensions += "store" @@ -99,6 +107,7 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3' implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.webkit:webkit:1.10.0' + implementation "com.anggrayudi:storage:1.5.5" // Glide ext.glide_version = '4.16.0' @@ -149,6 +158,9 @@ dependencies { // String Matching implementation 'me.xdrop:fuzzywuzzy:1.4.0' + implementation group: 'com.arthenica', name: 'ffmpeg-kit-full-gpl', version: '6.0-2.LTS' + //implementation 'com.github.yausername.youtubedl-android:library:0.15.0' + // Aniyomi implementation 'io.reactivex:rxjava:1.3.8' implementation 'io.reactivex:rxandroid:1.2.1' diff --git a/app/src/main/java/ani/dantotsu/Functions.kt b/app/src/main/java/ani/dantotsu/Functions.kt index d7ef638f..018476b0 100644 --- a/app/src/main/java/ani/dantotsu/Functions.kt +++ b/app/src/main/java/ani/dantotsu/Functions.kt @@ -619,9 +619,14 @@ fun ImageView.loadImage(file: FileUrl?, size: Int = 0) { file?.url = PrefManager.getVal(PrefName.ImageUrl).ifEmpty { file?.url ?: "" } if (file?.url?.isNotEmpty() == true) { tryWith { - val glideUrl = GlideUrl(file.url) { file.headers } - Glide.with(this.context).load(glideUrl).transition(withCrossFade()).override(size) - .into(this) + if (file.url.startsWith("content://")) { + Glide.with(this.context).load(Uri.parse(file.url)).transition(withCrossFade()) + .override(size).into(this) + } else { + val glideUrl = GlideUrl(file.url) { file.headers } + Glide.with(this.context).load(glideUrl).transition(withCrossFade()).override(size) + .into(this) + } } } } @@ -876,31 +881,6 @@ fun savePrefs( } } -fun downloadsPermission(activity: AppCompatActivity): Boolean { - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) return true - val permissions = arrayOf( - Manifest.permission.WRITE_EXTERNAL_STORAGE, - Manifest.permission.READ_EXTERNAL_STORAGE - ) - - val requiredPermissions = permissions.filter { - ContextCompat.checkSelfPermission(activity, it) != PackageManager.PERMISSION_GRANTED - }.toTypedArray() - - return if (requiredPermissions.isNotEmpty()) { - ActivityCompat.requestPermissions( - activity, - requiredPermissions, - DOWNLOADS_PERMISSION_REQUEST_CODE - ) - false - } else { - true - } -} - -private const val DOWNLOADS_PERMISSION_REQUEST_CODE = 100 - fun shareImage(title: String, bitmap: Bitmap, context: Context) { val contentUri = FileProvider.getUriForFile( diff --git a/app/src/main/java/ani/dantotsu/MainActivity.kt b/app/src/main/java/ani/dantotsu/MainActivity.kt index adf1334f..08f6aac6 100644 --- a/app/src/main/java/ani/dantotsu/MainActivity.kt +++ b/app/src/main/java/ani/dantotsu/MainActivity.kt @@ -3,6 +3,7 @@ package ani.dantotsu import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.app.AlertDialog +import android.content.Context import android.content.Intent import android.content.res.Configuration import android.graphics.drawable.Animatable @@ -15,12 +16,11 @@ import android.os.Looper import android.provider.Settings import android.view.LayoutInflater import android.view.View -import android.view.View.OnClickListener import android.view.ViewGroup import android.view.animation.AnticipateInterpolator import android.widget.TextView -import android.widget.Toast import androidx.activity.addCallback +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity @@ -449,7 +449,7 @@ class MainActivity : AppCompatActivity() { } } } - lifecycleScope.launch(Dispatchers.IO) { //simple cleanup + /*lifecycleScope.launch(Dispatchers.IO) { //simple cleanup val index = Helper.downloadManager(this@MainActivity).downloadIndex val downloadCursor = index.getDownloads() while (downloadCursor.moveToNext()) { @@ -458,7 +458,7 @@ class MainActivity : AppCompatActivity() { Helper.downloadManager(this@MainActivity).removeDownload(download.request.id) } } - } + }*/ //TODO: remove this } override fun onRestart() { diff --git a/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt b/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt index 4eb7f5a3..6512faf8 100644 --- a/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt +++ b/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt @@ -1,14 +1,21 @@ package ani.dantotsu.download import android.content.Context -import android.os.Environment -import android.widget.Toast +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import ani.dantotsu.download.DownloadsManager.Companion.findValidName import ani.dantotsu.media.MediaType import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName +import ani.dantotsu.snackString +import com.anggrayudi.storage.file.deleteRecursively +import com.anggrayudi.storage.file.findFolder import com.google.gson.Gson import com.google.gson.reflect.TypeToken -import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.Serializable class DownloadsManager(private val context: Context) { @@ -42,27 +49,29 @@ class DownloadsManager(private val context: Context) { saveDownloads() } - fun removeDownload(downloadedType: DownloadedType) { + fun removeDownload(downloadedType: DownloadedType, onFinished: () -> Unit) { downloadsList.remove(downloadedType) - removeDirectory(downloadedType) + CoroutineScope(Dispatchers.IO).launch { + removeDirectory(downloadedType) + withContext(Dispatchers.Main) { + onFinished() + } + } saveDownloads() } fun removeMedia(title: String, type: MediaType) { - val subDirectory = type.asText() - val directory = File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/$subDirectory/$title" - ) - if (directory.exists()) { - val deleted = directory.deleteRecursively() + val baseDirectory = getBaseDirectory(context, type) + val directory = baseDirectory?.findFolder(title) + if (directory?.exists() == true) { + val deleted = directory.deleteRecursively(context, false) if (deleted) { - Toast.makeText(context, "Successfully deleted", Toast.LENGTH_SHORT).show() + snackString("Successfully deleted") } else { - Toast.makeText(context, "Failed to delete directory", Toast.LENGTH_SHORT).show() + snackString("Failed to delete directory") } } else { - Toast.makeText(context, "Directory does not exist", Toast.LENGTH_SHORT).show() + snackString("Directory does not exist") cleanDownloads() } when (type) { @@ -89,23 +98,17 @@ class DownloadsManager(private val context: Context) { private fun cleanDownload(type: MediaType) { // remove all folders that are not in the downloads list - val subDirectory = type.asText() - val directory = File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/$subDirectory" - ) + val directory = getBaseDirectory(context, type) val downloadsSubLists = when (type) { MediaType.MANGA -> mangaDownloadedTypes MediaType.ANIME -> animeDownloadedTypes else -> novelDownloadedTypes } - if (directory.exists()) { + if (directory?.exists() == true && directory.isDirectory) { val files = directory.listFiles() - if (files != null) { - for (file in files) { - if (!downloadsSubLists.any { it.title == file.name }) { - file.deleteRecursively() - } + for (file in files) { + if (!downloadsSubLists.any { it.title == file.name }) { + file.deleteRecursively(context, false) } } } @@ -113,29 +116,13 @@ class DownloadsManager(private val context: Context) { val iterator = downloadsList.iterator() while (iterator.hasNext()) { val download = iterator.next() - val downloadDir = File(directory, download.title) - if ((!downloadDir.exists() && download.type == type) || download.title.isBlank()) { + val downloadDir = directory?.findFolder(download.title) + if ((downloadDir?.exists() == false && download.type == type) || download.title.isBlank()) { iterator.remove() } } } - fun saveDownloadsListToJSONFileInDownloadsFolder(downloadsList: List) //for debugging - { - val jsonString = gson.toJson(downloadsList) - val file = File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/downloads.json" - ) - if (file.parentFile?.exists() == false) { - file.parentFile?.mkdirs() - } - if (!file.exists()) { - file.createNewFile() - } - file.writeText(jsonString) - } - fun queryDownload(downloadedType: DownloadedType): Boolean { return downloadsList.contains(downloadedType) } @@ -149,98 +136,35 @@ class DownloadsManager(private val context: Context) { } private fun removeDirectory(downloadedType: DownloadedType) { - val directory = when (downloadedType.type) { - MediaType.MANGA -> { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Manga/${downloadedType.title}/${downloadedType.chapter}" - ) - } - MediaType.ANIME -> { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Anime/${downloadedType.title}/${downloadedType.chapter}" - ) - } - else -> { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Novel/${downloadedType.title}/${downloadedType.chapter}" - ) - } - } + val baseDirectory = getBaseDirectory(context, downloadedType.type) + val directory = + baseDirectory?.findFolder(downloadedType.title)?.findFolder(downloadedType.chapter) // Check if the directory exists and delete it recursively - if (directory.exists()) { - val deleted = directory.deleteRecursively() + if (directory?.exists() == true) { + val deleted = directory.deleteRecursively(context, false) if (deleted) { - Toast.makeText(context, "Successfully deleted", Toast.LENGTH_SHORT).show() - } else { - Toast.makeText(context, "Failed to delete directory", Toast.LENGTH_SHORT).show() - } - } else { - Toast.makeText(context, "Directory does not exist", Toast.LENGTH_SHORT).show() - } - } + snackString("Successfully deleted") - fun exportDownloads(downloadedType: DownloadedType) { //copies to the downloads folder available to the user - val directory = when (downloadedType.type) { - MediaType.MANGA -> { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Manga/${downloadedType.title}/${downloadedType.chapter}" - ) - } - MediaType.ANIME -> { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Anime/${downloadedType.title}/${downloadedType.chapter}" - ) - } - else -> { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Novel/${downloadedType.title}/${downloadedType.chapter}" - ) - } - } - val destination = File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/${downloadedType.title}/${downloadedType.chapter}" - ) - if (directory.exists()) { - val copied = directory.copyRecursively(destination, true) - if (copied) { - Toast.makeText(context, "Successfully copied", Toast.LENGTH_SHORT).show() } else { - Toast.makeText(context, "Failed to copy directory", Toast.LENGTH_SHORT).show() + snackString("Failed to delete directory") } } else { - Toast.makeText(context, "Directory does not exist", Toast.LENGTH_SHORT).show() + snackString("Directory does not exist") } } fun purgeDownloads(type: MediaType) { - val directory = when (type) { - MediaType.MANGA -> { - File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Manga") - } - MediaType.ANIME -> { - File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Anime") - } - else -> { - File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Novel") - } - } - if (directory.exists()) { - val deleted = directory.deleteRecursively() + val directory = getBaseDirectory(context, type) + if (directory?.exists() == true) { + val deleted = directory.deleteRecursively(context, false) if (deleted) { - Toast.makeText(context, "Successfully deleted", Toast.LENGTH_SHORT).show() + snackString("Successfully deleted") } else { - Toast.makeText(context, "Failed to delete directory", Toast.LENGTH_SHORT).show() + snackString("Failed to delete directory") } } else { - Toast.makeText(context, "Directory does not exist", Toast.LENGTH_SHORT).show() + snackString("Directory does not exist") } downloadsList.removeAll { it.type == type } @@ -248,59 +172,95 @@ class DownloadsManager(private val context: Context) { } companion object { - const val novelLocation = "Dantotsu/Novel" - const val mangaLocation = "Dantotsu/Manga" - const val animeLocation = "Dantotsu/Anime" + private const val BASE_LOCATION = "Dantotsu" + private const val MANGA_SUB_LOCATION = "Manga" + private const val ANIME_SUB_LOCATION = "Anime" + private const val NOVEL_SUB_LOCATION = "Novel" + private const val RESERVED_CHARS = "|\\?*<\":>+[]/'" - fun getDirectory( - context: Context, - type: MediaType, - title: String, - chapter: String? = null - ): File { + fun String?.findValidName(): String { + return this?.filterNot { RESERVED_CHARS.contains(it) } ?: "" + } + + /** + * Get and create a base directory for the given type + * @param context the context + * @param type the type of media + * @return the base directory + */ + + private fun getBaseDirectory(context: Context, type: MediaType): DocumentFile? { + val baseDirectory = Uri.parse(PrefManager.getVal(PrefName.DownloadsDir)) + if (baseDirectory == Uri.EMPTY) return null + var base = DocumentFile.fromTreeUri(context, baseDirectory) ?: return null + base = base.findOrCreateFolder(BASE_LOCATION, false) ?: return null return when (type) { MediaType.MANGA -> { - if (chapter != null) { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "$mangaLocation/$title/$chapter" - ) - } else { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "$mangaLocation/$title" - ) - } + base.findOrCreateFolder(MANGA_SUB_LOCATION, false) } + MediaType.ANIME -> { - if (chapter != null) { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "$animeLocation/$title/$chapter" - ) - } else { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "$animeLocation/$title" - ) - } + base.findOrCreateFolder(ANIME_SUB_LOCATION, false) } + else -> { - if (chapter != null) { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "$novelLocation/$title/$chapter" - ) - } else { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "$novelLocation/$title" - ) - } + base.findOrCreateFolder(NOVEL_SUB_LOCATION, false) } } } + + /** + * Get and create a subdirectory for the given type + * @param context the context + * @param type the type of media + * @param title the title of the media + * @param chapter the chapter of the media + * @return the subdirectory + */ + fun getSubDirectory( + context: Context, + type: MediaType, + overwrite: Boolean, + title: String, + chapter: String? = null + ): DocumentFile? { + val baseDirectory = getBaseDirectory(context, type) ?: return null + return if (chapter != null) { + baseDirectory.findOrCreateFolder(title, false) + ?.findOrCreateFolder(chapter, overwrite) + } else { + baseDirectory.findOrCreateFolder(title, overwrite) + } + } + + fun getDirSize(context: Context, type: MediaType, title: String, chapter: String? = null): Long { + val directory = getSubDirectory(context, type, false, title, chapter) ?: return 0 + var size = 0L + directory.listFiles().forEach { + size += it.length() + } + return size + } + + private fun DocumentFile.findOrCreateFolder( + name: String, overwrite: Boolean + ): DocumentFile? { + return if (overwrite) { + findFolder(name.findValidName())?.delete() + createDirectory(name.findValidName()) + } else { + findFolder(name.findValidName()) ?: createDirectory(name.findValidName()) + } + } + } } -data class DownloadedType(val title: String, val chapter: String, val type: MediaType) : Serializable +data class DownloadedType( + val pTitle: String, val pChapter: String, val type: MediaType +) : Serializable { + val title: String + get() = pTitle.findValidName() + val chapter: String + get() = pChapter.findValidName() +} diff --git a/app/src/main/java/ani/dantotsu/download/anime/AnimeDownloaderService.kt b/app/src/main/java/ani/dantotsu/download/anime/AnimeDownloaderService.kt index 5d9c7a5b..96aeb363 100644 --- a/app/src/main/java/ani/dantotsu/download/anime/AnimeDownloaderService.kt +++ b/app/src/main/java/ani/dantotsu/download/anime/AnimeDownloaderService.kt @@ -16,17 +16,17 @@ import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import androidx.documentfile.provider.DocumentFile import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.offline.DownloadManager import androidx.media3.exoplayer.offline.DownloadService import ani.dantotsu.FileUrl import ani.dantotsu.R import ani.dantotsu.connections.crashlytics.CrashlyticsInterface -import ani.dantotsu.currActivity import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager -import ani.dantotsu.download.video.ExoplayerDownloadService -import ani.dantotsu.download.video.Helper +import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory +import ani.dantotsu.download.anime.AnimeDownloaderService.AnimeDownloadTask.Companion.getTaskName import ani.dantotsu.media.Media import ani.dantotsu.media.MediaType import ani.dantotsu.media.SubtitleDownloader @@ -36,6 +36,13 @@ import ani.dantotsu.parsers.Video import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.snackString import ani.dantotsu.util.Logger +import com.anggrayudi.storage.file.forceDelete +import com.anggrayudi.storage.file.openOutputStream +import com.arthenica.ffmpegkit.FFmpegKit +import com.arthenica.ffmpegkit.FFmpegKitConfig +import com.arthenica.ffmpegkit.FFprobeKit +import com.arthenica.ffmpegkit.ReturnCode +import com.arthenica.ffmpegkit.SessionState import com.google.gson.GsonBuilder import com.google.gson.InstanceCreator import eu.kanade.tachiyomi.animesource.model.SAnime @@ -63,6 +70,7 @@ import java.net.URL import java.util.Queue import java.util.concurrent.ConcurrentLinkedQueue + class AnimeDownloaderService : Service() { private lateinit var notificationManager: NotificationManagerCompat @@ -156,27 +164,13 @@ class AnimeDownloaderService : Service() { @UnstableApi fun cancelDownload(taskName: String) { - val url = - AnimeServiceDataSingleton.downloadQueue.find { it.getTaskName() == taskName }?.video?.file?.url - ?: currentTasks.find { it.getTaskName() == taskName }?.video?.file?.url ?: "" - if (url.isEmpty()) { - snackString("Failed to cancel download") - return + val sessionIds = AnimeServiceDataSingleton.downloadQueue.filter { it.getTaskName() == taskName } + .map { it.sessionId }.toMutableList() + sessionIds.addAll(currentTasks.filter { it.getTaskName() == taskName }.map { it.sessionId }) + sessionIds.forEach { + FFmpegKit.cancel(it) } currentTasks.removeAll { it.getTaskName() == taskName } - DownloadService.sendSetStopReason( - this@AnimeDownloaderService, - ExoplayerDownloadService::class.java, - url, - androidx.media3.exoplayer.offline.Download.STATE_STOPPED, - false - ) - DownloadService.sendRemoveDownload( - this@AnimeDownloaderService, - ExoplayerDownloadService::class.java, - url, - false - ) CoroutineScope(Dispatchers.Default).launch { mutex.withLock { downloadJobs[taskName]?.cancel() @@ -209,7 +203,7 @@ class AnimeDownloaderService : Service() { @androidx.annotation.OptIn(UnstableApi::class) suspend fun download(task: AnimeDownloadTask) { try { - val downloadManager = Helper.downloadManager(this@AnimeDownloaderService) + //val downloadManager = Helper.downloadManager(this@AnimeDownloaderService) withContext(Dispatchers.Main) { val notifi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ContextCompat.checkSelfPermission( @@ -220,18 +214,62 @@ class AnimeDownloaderService : Service() { true } - builder.setContentText("Downloading ${task.title} - ${task.episode}") + builder.setContentText("Downloading ${getTaskName(task.title, task.episode)}") if (notifi) { notificationManager.notify(NOTIFICATION_ID, builder.build()) } - currActivity()?.let { - Helper.downloadVideo( - it, - task.video, - task.subtitle - ) + val outputDir = getSubDirectory( + this@AnimeDownloaderService, + MediaType.ANIME, + false, + task.title, + task.episode + ) ?: throw Exception("Failed to create output directory") + + outputDir.findFile("${task.getTaskName()}.mp4")?.delete() + val outputFile = outputDir.createFile("video/mp4", "${task.getTaskName()}.mp4") + ?: throw Exception("Failed to create output file") + + var percent = 0 + var totalLength = 0.0 + val path = FFmpegKitConfig.getSafParameterForWrite( + this@AnimeDownloaderService, + outputFile.uri + ) + val info = FFprobeKit.getMediaInformation(task.video.file.url) + info.mediaInformation.duration?.let { + totalLength = it.toDouble() } + val ffTask = + FFmpegKit.executeAsync("-i ${task.video.file.url} -c copy -bsf:a aac_adtstoasc $path", + { session -> + val state: SessionState = session.state + val returnCode = session.returnCode + // CALLED WHEN SESSION IS EXECUTED + Logger.log( + java.lang.String.format( + "FFmpeg process exited with state %s and rc %s.%s", + state, + returnCode, + session.failStackTrace + ) + ) + + }, { + // CALLED WHEN SESSION PRINTS LOGS + Logger.log(it.message) + }) { + // CALLED WHEN SESSION GENERATES STATISTICS + val timeInMilliseconds = it.time + if (timeInMilliseconds > 0 && totalLength > 0) { + percent = ((it.time / 1000) / totalLength * 100).toInt() + } + Logger.log("Statistics: $it") + } + task.sessionId = ffTask.sessionId + currentTasks.find { it.getTaskName() == task.getTaskName() }?.sessionId = ffTask.sessionId + saveMediaInfo(task) task.subtitle?.let { @@ -245,86 +283,88 @@ class AnimeDownloaderService : Service() { ) ) } - val downloadStarted = - hasDownloadStarted(downloadManager, task, 30000) // 30 seconds timeout - - if (!downloadStarted) { - Logger.log("Download failed to start") - builder.setContentText("${task.title} - ${task.episode} Download failed to start") - notificationManager.notify(NOTIFICATION_ID, builder.build()) - snackString("${task.title} - ${task.episode} Download failed to start") - broadcastDownloadFailed(task.episode) - return@withContext - } - // periodically check if the download is complete - while (downloadManager.downloadIndex.getDownload(task.video.file.url) != null) { - val download = downloadManager.downloadIndex.getDownload(task.video.file.url) - if (download != null) { - if (download.state == androidx.media3.exoplayer.offline.Download.STATE_FAILED) { - Logger.log("Download failed") - builder.setContentText("${task.title} - ${task.episode} Download failed") - notificationManager.notify(NOTIFICATION_ID, builder.build()) - snackString("${task.title} - ${task.episode} Download failed") - Logger.log("Download failed: ${download.failureReason}") - downloadsManager.removeDownload( - DownloadedType( - task.title, - task.episode, - MediaType.ANIME, - ) + while (ffTask.state != SessionState.COMPLETED) { + if (ffTask.state == SessionState.FAILED) { + Logger.log("Download failed") + builder.setContentText("${getTaskName(task.title, task.episode)} Download failed") + notificationManager.notify(NOTIFICATION_ID, builder.build()) + snackString("${getTaskName(task.title, task.episode)} Download failed") + Logger.log("Download failed: ${ffTask.failStackTrace}") + downloadsManager.removeDownload( + DownloadedType( + task.title, + task.episode, + MediaType.ANIME, ) - Injekt.get().logException( - Exception( - "Anime Download failed:" + - " ${download.failureReason}" + - " url: ${task.video.file.url}" + - " title: ${task.title}" + - " episode: ${task.episode}" - ) + ) {} + Injekt.get().logException( + Exception( + "Anime Download failed:" + + " ${getTaskName(task.title, task.episode)}" + + " url: ${task.video.file.url}" + + " title: ${task.title}" + + " episode: ${task.episode}" ) - currentTasks.removeAll { it.getTaskName() == task.getTaskName() } - broadcastDownloadFailed(task.episode) - break - } - if (download.state == androidx.media3.exoplayer.offline.Download.STATE_COMPLETED) { - Logger.log("Download completed") - builder.setContentText("${task.title} - ${task.episode} Download completed") - notificationManager.notify(NOTIFICATION_ID, builder.build()) - snackString("${task.title} - ${task.episode} Download completed") - PrefManager.getAnimeDownloadPreferences().edit().putString( - task.getTaskName(), - task.video.file.url - ).apply() - downloadsManager.addDownload( - DownloadedType( - task.title, - task.episode, - MediaType.ANIME, - ) - ) - currentTasks.removeAll { it.getTaskName() == task.getTaskName() } - broadcastDownloadFinished(task.episode) - break - } - if (download.state == androidx.media3.exoplayer.offline.Download.STATE_STOPPED) { - Logger.log("Download stopped") - builder.setContentText("${task.title} - ${task.episode} Download stopped") - notificationManager.notify(NOTIFICATION_ID, builder.build()) - snackString("${task.title} - ${task.episode} Download stopped") - break - } - broadcastDownloadProgress( - task.episode, - download.percentDownloaded.toInt() ) - if (notifi) { - notificationManager.notify(NOTIFICATION_ID, builder.build()) - } + currentTasks.removeAll { it.getTaskName() == task.getTaskName() } + broadcastDownloadFailed(task.episode) + break + } + broadcastDownloadProgress( + task.episode, + percent + ) + if (notifi) { + notificationManager.notify(NOTIFICATION_ID, builder.build()) } kotlinx.coroutines.delay(2000) } + if (ffTask.state == SessionState.COMPLETED) { + if (ffTask.returnCode.isValueError) { + Logger.log("Download failed") + builder.setContentText("${getTaskName(task.title, task.episode)} Download failed") + notificationManager.notify(NOTIFICATION_ID, builder.build()) + snackString("${getTaskName(task.title, task.episode)} Download failed") + downloadsManager.removeDownload( + DownloadedType( + task.title, + task.episode, + MediaType.ANIME, + ) + ) {} + Injekt.get().logException( + Exception( + "Anime Download failed:" + + " ${getTaskName(task.title, task.episode)}" + + " url: ${task.video.file.url}" + + " title: ${task.title}" + + " episode: ${task.episode}" + ) + ) + currentTasks.removeAll { it.getTaskName() == task.getTaskName() } + broadcastDownloadFailed(task.episode) + return@withContext + } + Logger.log("Download completed") + builder.setContentText("${getTaskName(task.title, task.episode)} Download completed") + notificationManager.notify(NOTIFICATION_ID, builder.build()) + snackString("${getTaskName(task.title, task.episode)} Download completed") + PrefManager.getAnimeDownloadPreferences().edit().putString( + task.getTaskName(), + task.video.file.url + ).apply() + downloadsManager.addDownload( + DownloadedType( + task.title, + task.episode, + MediaType.ANIME, + ) + ) + currentTasks.removeAll { it.getTaskName() == task.getTaskName() } + broadcastDownloadFinished(task.episode) + } else throw Exception("Download failed") } } catch (e: Exception) { if (e.message?.contains("Coroutine was cancelled") == false) { //wut @@ -358,14 +398,16 @@ class AnimeDownloaderService : Service() { @OptIn(DelicateCoroutinesApi::class) private fun saveMediaInfo(task: AnimeDownloadTask) { CoroutineScope(Dispatchers.IO).launch { - val directory = File( - getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "${DownloadsManager.animeLocation}/${task.title}" - ) - val episodeDirectory = File(directory, task.episode) - if (!episodeDirectory.exists()) episodeDirectory.mkdirs() + val directory = + getSubDirectory(this@AnimeDownloaderService, MediaType.ANIME, false, task.title) + ?: throw Exception("Directory not found") + directory.findFile("media.json")?.forceDelete(this@AnimeDownloaderService) + val file = directory.createFile("application/json", "media.json") + ?: throw Exception("File not created") + val episodeDirectory = + getSubDirectory(this@AnimeDownloaderService, MediaType.ANIME, false, task.title, task.episode) + ?: throw Exception("Directory not found") - val file = File(directory, "media.json") val gson = GsonBuilder() .registerTypeAdapter(SChapter::class.java, InstanceCreator { SChapterImpl() // Provide an instance of SChapterImpl @@ -399,14 +441,26 @@ class AnimeDownloaderService : Service() { val jsonString = gson.toJson(media) withContext(Dispatchers.Main) { - file.writeText(jsonString) + try { + file.openOutputStream(this@AnimeDownloaderService, false).use { output -> + if (output == null) throw Exception("Output stream is null") + output.write(jsonString.toByteArray()) + } + } catch (e: android.system.ErrnoException) { + e.printStackTrace() + Toast.makeText( + this@AnimeDownloaderService, + "Error while saving: ${e.localizedMessage}", + Toast.LENGTH_LONG + ).show() + } } } } } - private suspend fun downloadImage(url: String, directory: File, name: String): String? = + private suspend fun downloadImage(url: String, directory: DocumentFile, name: String): String? = withContext(Dispatchers.IO) { var connection: HttpURLConnection? = null println("Downloading url $url") @@ -417,13 +471,16 @@ class AnimeDownloaderService : Service() { throw Exception("Server returned HTTP ${connection.responseCode} ${connection.responseMessage}") } - val file = File(directory, name) - FileOutputStream(file).use { output -> + directory.findFile(name)?.forceDelete(this@AnimeDownloaderService) + val file = + directory.createFile("image/jpeg", name) ?: throw Exception("File not created") + file.openOutputStream(this@AnimeDownloaderService, false).use { output -> + if (output == null) throw Exception("Output stream is null") connection.inputStream.use { input -> input.copyTo(output) } } - return@withContext file.absolutePath + return@withContext file.uri.toString() } catch (e: Exception) { e.printStackTrace() withContext(Dispatchers.Main) { @@ -490,14 +547,15 @@ class AnimeDownloaderService : Service() { val episodeImage: String? = null, val retries: Int = 2, val simultaneousDownloads: Int = 2, + var sessionId: Long = -1 ) { fun getTaskName(): String { - return "$title - $episode" + return "${title.replace("/","")}/${episode.replace("/","")}" } companion object { fun getTaskName(title: String, episode: String): String { - return "$title - $episode" + return "${title.replace("/","")}/${episode.replace("/","")}" } } } diff --git a/app/src/main/java/ani/dantotsu/download/anime/OfflineAnimeFragment.kt b/app/src/main/java/ani/dantotsu/download/anime/OfflineAnimeFragment.kt index 64329759..81745773 100644 --- a/app/src/main/java/ani/dantotsu/download/anime/OfflineAnimeFragment.kt +++ b/app/src/main/java/ani/dantotsu/download/anime/OfflineAnimeFragment.kt @@ -204,10 +204,6 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { if (mediaIds.isEmpty()) { snackString("No media found") // if this happens, terrible things have happened } - for (mediaId in mediaIds) { - ani.dantotsu.download.video.Helper.downloadManager(requireContext()) - .removeDownload(mediaId.toString()) - } getDownloads() adapter.setItems(downloads) total.text = if (gridView.count > 0) "Anime (${gridView.count})" else "Empty List" diff --git a/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt b/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt index 92e811bd..08ddb3a7 100644 --- a/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt +++ b/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt @@ -10,17 +10,18 @@ import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.graphics.Bitmap import android.os.Build -import android.os.Environment import android.os.IBinder import android.widget.Toast import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import androidx.documentfile.provider.DocumentFile import ani.dantotsu.R import ani.dantotsu.connections.crashlytics.CrashlyticsInterface import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager +import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory import ani.dantotsu.media.Media import ani.dantotsu.media.MediaType import ani.dantotsu.media.manga.ImageData @@ -31,6 +32,9 @@ import ani.dantotsu.media.manga.MangaReadFragment.Companion.ACTION_DOWNLOAD_STAR import ani.dantotsu.media.manga.MangaReadFragment.Companion.EXTRA_CHAPTER_NUMBER import ani.dantotsu.snackString import ani.dantotsu.util.Logger +import com.anggrayudi.storage.file.deleteRecursively +import com.anggrayudi.storage.file.forceDelete +import com.anggrayudi.storage.file.openOutputStream import com.google.gson.GsonBuilder import com.google.gson.InstanceCreator import eu.kanade.tachiyomi.data.notification.Notifications.CHANNEL_DOWNLOADER_PROGRESS @@ -51,8 +55,6 @@ import kotlinx.coroutines.withContext import tachiyomi.core.util.lang.launchIO import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.io.File -import java.io.FileOutputStream import java.net.HttpURLConnection import java.net.URL import java.util.Queue @@ -189,13 +191,20 @@ class MangaDownloaderService : Service() { true } - //val deferredList = mutableListOf>() val deferredMap = mutableMapOf>() builder.setContentText("Downloading ${task.title} - ${task.chapter}") if (notifi) { notificationManager.notify(NOTIFICATION_ID, builder.build()) } + getSubDirectory( + this@MangaDownloaderService, + MediaType.MANGA, + false, + task.title, + task.chapter + )?.deleteRecursively(this@MangaDownloaderService) + // Loop through each ImageData object from the task var farthest = 0 for ((index, image) in task.imageData.withIndex()) { @@ -263,24 +272,18 @@ class MangaDownloaderService : Service() { private fun saveToDisk(fileName: String, bitmap: Bitmap, title: String, chapter: String) { try { // Define the directory within the private external storage space - val directory = File( - this.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Manga/$title/$chapter" - ) - - if (!directory.exists()) { - directory.mkdirs() - } - - // Create a file reference within that directory for your image - val file = File(directory, fileName) + val directory = getSubDirectory(this, MediaType.MANGA, false, title, chapter) + ?: throw Exception("Directory not found") + directory.findFile(fileName)?.forceDelete(this) + // Create a file reference within that directory for the image + val file = + directory.createFile("image/jpeg", fileName) ?: throw Exception("File not created") // Use a FileOutputStream to write the bitmap to the file - FileOutputStream(file).use { outputStream -> + file.openOutputStream(this, false).use { outputStream -> + if (outputStream == null) throw Exception("Output stream is null") bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) } - - } catch (e: Exception) { println("Exception while saving image: ${e.message}") snackString("Exception while saving image: ${e.message}") @@ -291,13 +294,12 @@ class MangaDownloaderService : Service() { @OptIn(DelicateCoroutinesApi::class) private fun saveMediaInfo(task: DownloadTask) { launchIO { - val directory = File( - getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Manga/${task.title}" - ) - if (!directory.exists()) directory.mkdirs() - - val file = File(directory, "media.json") + val directory = + getSubDirectory(this@MangaDownloaderService, MediaType.MANGA, false, task.title) + ?: throw Exception("Directory not found") + directory.findFile("media.json")?.forceDelete(this@MangaDownloaderService) + val file = directory.createFile("application/json", "media.json") + ?: throw Exception("File not created") val gson = GsonBuilder() .registerTypeAdapter(SChapter::class.java, InstanceCreator { SChapterImpl() // Provide an instance of SChapterImpl @@ -312,7 +314,10 @@ class MangaDownloaderService : Service() { val jsonString = gson.toJson(media) withContext(Dispatchers.Main) { try { - file.writeText(jsonString) + file.openOutputStream(this@MangaDownloaderService, false).use { output -> + if (output == null) throw Exception("Output stream is null") + output.write(jsonString.toByteArray()) + } } catch (e: android.system.ErrnoException) { e.printStackTrace() Toast.makeText( @@ -327,7 +332,7 @@ class MangaDownloaderService : Service() { } - private suspend fun downloadImage(url: String, directory: File, name: String): String? = + private suspend fun downloadImage(url: String, directory: DocumentFile, name: String): String? = withContext(Dispatchers.IO) { var connection: HttpURLConnection? = null println("Downloading url $url") @@ -337,14 +342,16 @@ class MangaDownloaderService : Service() { if (connection.responseCode != HttpURLConnection.HTTP_OK) { throw Exception("Server returned HTTP ${connection.responseCode} ${connection.responseMessage}") } - - val file = File(directory, name) - FileOutputStream(file).use { output -> + directory.findFile(name)?.forceDelete(this@MangaDownloaderService) + val file = + directory.createFile("image/jpeg", name) ?: throw Exception("File not created") + file.openOutputStream(this@MangaDownloaderService, false).use { output -> + if (output == null) throw Exception("Output stream is null") connection.inputStream.use { input -> input.copyTo(output) } } - return@withContext file.absolutePath + return@withContext file.uri.toString() } catch (e: Exception) { e.printStackTrace() withContext(Dispatchers.Main) { diff --git a/app/src/main/java/ani/dantotsu/download/video/ExoplayerDownloadService.kt b/app/src/main/java/ani/dantotsu/download/video/ExoplayerDownloadService.kt deleted file mode 100644 index 4add998c..00000000 --- a/app/src/main/java/ani/dantotsu/download/video/ExoplayerDownloadService.kt +++ /dev/null @@ -1,37 +0,0 @@ -package ani.dantotsu.download.video - -import android.app.Notification -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.offline.Download -import androidx.media3.exoplayer.offline.DownloadManager -import androidx.media3.exoplayer.offline.DownloadNotificationHelper -import androidx.media3.exoplayer.offline.DownloadService -import androidx.media3.exoplayer.scheduler.PlatformScheduler -import androidx.media3.exoplayer.scheduler.Scheduler -import ani.dantotsu.R - -@UnstableApi -class ExoplayerDownloadService : - DownloadService(1, 2000, "download_service", R.string.downloads, 0) { - companion object { - private const val JOB_ID = 1 - private const val FOREGROUND_NOTIFICATION_ID = 1 - } - - override fun getDownloadManager(): DownloadManager = Helper.downloadManager(this) - - override fun getScheduler(): Scheduler = PlatformScheduler(this, JOB_ID) - - override fun getForegroundNotification( - downloads: MutableList, - notMetRequirements: Int - ): Notification = - DownloadNotificationHelper(this, "download_service").buildProgressNotification( - this, - R.drawable.mono, - null, - null, - downloads, - notMetRequirements - ) -} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/download/video/Helper.kt b/app/src/main/java/ani/dantotsu/download/video/Helper.kt index 258c123a..ed146b90 100644 --- a/app/src/main/java/ani/dantotsu/download/video/Helper.kt +++ b/app/src/main/java/ani/dantotsu/download/video/Helper.kt @@ -53,140 +53,6 @@ import java.util.concurrent.Executors @SuppressLint("UnsafeOptInUsageError") object Helper { - - - private var simpleCache: SimpleCache? = null - - fun downloadVideo(context: Context, video: Video, subtitle: Subtitle?) { - val dataSourceFactory = DataSource.Factory { - val dataSource: HttpDataSource = - OkHttpDataSource.Factory(okHttpClient).createDataSource() - defaultHeaders.forEach { - dataSource.setRequestProperty(it.key, it.value) - } - video.file.headers.forEach { - dataSource.setRequestProperty(it.key, it.value) - } - dataSource - } - val mimeType = when (video.format) { - VideoType.M3U8 -> MimeTypes.APPLICATION_M3U8 - VideoType.DASH -> MimeTypes.APPLICATION_MPD - else -> MimeTypes.APPLICATION_MP4 - } - - val builder = MediaItem.Builder().setUri(video.file.url).setMimeType(mimeType) - var sub: MediaItem.SubtitleConfiguration? = null - if (subtitle != null) { - sub = MediaItem.SubtitleConfiguration - .Builder(Uri.parse(subtitle.file.url)) - .setSelectionFlags(C.SELECTION_FLAG_FORCED) - .setMimeType( - when (subtitle.type) { - SubtitleType.VTT -> MimeTypes.TEXT_VTT - SubtitleType.ASS -> MimeTypes.TEXT_SSA - SubtitleType.SRT -> MimeTypes.APPLICATION_SUBRIP - SubtitleType.UNKNOWN -> MimeTypes.TEXT_SSA - } - ) - .build() - } - if (sub != null) builder.setSubtitleConfigurations(mutableListOf(sub)) - val mediaItem = builder.build() - val downloadHelper = DownloadHelper.forMediaItem( - context, - mediaItem, - DefaultRenderersFactory(context), - dataSourceFactory - ) - downloadHelper.prepare(object : DownloadHelper.Callback { - override fun onPrepared(helper: DownloadHelper) { - helper.getDownloadRequest(null).let { - DownloadService.sendAddDownload( - context, - ExoplayerDownloadService::class.java, - it, - false - ) - } - } - - override fun onPrepareError(helper: DownloadHelper, e: IOException) { - logError(e) - } - }) - } - - - private var download: DownloadManager? = null - private const val DOWNLOAD_CONTENT_DIRECTORY = "Anime_Downloads" - - @Synchronized - @UnstableApi - fun downloadManager(context: Context): DownloadManager { - return download ?: let { - val database = Injekt.get() - val dataSourceFactory = DataSource.Factory { - //val dataSource: HttpDataSource = OkHttpDataSource.Factory(okHttpClient).createDataSource() - val networkHelper = Injekt.get() - val okHttpClient = networkHelper.client - val dataSource: HttpDataSource = - OkHttpDataSource.Factory(okHttpClient).createDataSource() - defaultHeaders.forEach { - dataSource.setRequestProperty(it.key, it.value) - } - dataSource - } - val threadPoolSize = Runtime.getRuntime().availableProcessors() - val executorService = Executors.newFixedThreadPool(threadPoolSize) - val downloadManager = DownloadManager( - context, - database, - getSimpleCache(context), - dataSourceFactory, - executorService - ).apply { - requirements = - Requirements(Requirements.NETWORK or Requirements.DEVICE_STORAGE_NOT_LOW) - maxParallelDownloads = 3 - } - downloadManager.addListener( //for testing - object : DownloadManager.Listener { - override fun onDownloadChanged( - downloadManager: DownloadManager, - download: Download, - finalException: Exception? - ) { - when (download.state) { - Download.STATE_COMPLETED -> Logger.log("Download Completed") - Download.STATE_FAILED -> Logger.log("Download Failed") - Download.STATE_STOPPED -> Logger.log("Download Stopped") - Download.STATE_QUEUED -> Logger.log("Download Queued") - Download.STATE_DOWNLOADING -> Logger.log("Download Downloading") - Download.STATE_REMOVING -> Logger.log("Download Removing") - Download.STATE_RESTARTING -> Logger.log("Download Restarting") - } - } - } - ) - - downloadManager - } - } - - private var downloadDirectory: File? = null - - @Synchronized - private fun getDownloadDirectory(context: Context): File { - if (downloadDirectory == null) { - downloadDirectory = context.getExternalFilesDir(null) - if (downloadDirectory == null) { - downloadDirectory = context.filesDir - } - } - return downloadDirectory!! - } - @OptIn(UnstableApi::class) fun startAnimeDownloadService( context: Context, @@ -225,15 +91,6 @@ object Helper { .setTitle("Download Exists") .setMessage("A download for this episode already exists. Do you want to overwrite it?") .setPositiveButton("Yes") { _, _ -> - DownloadService.sendRemoveDownload( - context, - ExoplayerDownloadService::class.java, - PrefManager.getAnimeDownloadPreferences().getString( - animeDownloadTask.getTaskName(), - "" - ) ?: "", - false - ) PrefManager.getAnimeDownloadPreferences().edit() .remove(animeDownloadTask.getTaskName()) .apply() @@ -243,12 +100,13 @@ object Helper { episode, MediaType.ANIME ) - ) - AnimeServiceDataSingleton.downloadQueue.offer(animeDownloadTask) - if (!AnimeServiceDataSingleton.isServiceRunning) { - val intent = Intent(context, AnimeDownloaderService::class.java) - ContextCompat.startForegroundService(context, intent) - AnimeServiceDataSingleton.isServiceRunning = true + ) { + AnimeServiceDataSingleton.downloadQueue.offer(animeDownloadTask) + if (!AnimeServiceDataSingleton.isServiceRunning) { + val intent = Intent(context, AnimeDownloaderService::class.java) + ContextCompat.startForegroundService(context, intent) + AnimeServiceDataSingleton.isServiceRunning = true + } } } .setNegativeButton("No") { _, _ -> } @@ -263,18 +121,6 @@ object Helper { } } - @OptIn(UnstableApi::class) - fun getSimpleCache(context: Context): SimpleCache { - return if (simpleCache == null) { - val downloadDirectory = File(getDownloadDirectory(context), DOWNLOAD_CONTENT_DIRECTORY) - val database = Injekt.get() - simpleCache = SimpleCache(downloadDirectory, NoOpCacheEvictor(), database) - simpleCache!! - } else { - simpleCache!! - } - } - private fun isNotificationPermissionGranted(context: Context): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { return ActivityCompat.checkSelfPermission( diff --git a/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt b/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt index a7b8858f..022496af 100644 --- a/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt +++ b/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt @@ -4,6 +4,7 @@ import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.content.Intent import android.content.res.Configuration +import android.net.Uri import android.os.Bundle import android.text.SpannableStringBuilder import android.util.TypedValue @@ -13,6 +14,8 @@ import android.view.View import android.view.ViewGroup import android.view.animation.AccelerateDecelerateInterpolator import android.widget.ImageView +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources @@ -53,6 +56,9 @@ import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString import ani.dantotsu.statusBarHeight import ani.dantotsu.themes.ThemeManager +import ani.dantotsu.toast +import ani.dantotsu.util.LauncherWrapper +import ani.dantotsu.util.StoragePermissions import com.flaviofaria.kenburnsview.RandomTransitionGenerator import com.google.android.material.appbar.AppBarLayout import kotlinx.coroutines.CoroutineScope @@ -66,7 +72,7 @@ import kotlin.math.abs class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedListener { - + lateinit var launcher: LauncherWrapper lateinit var binding: ActivityMediaBinding private val scope = lifecycleScope private val model: MediaDetailsViewModel by viewModels() @@ -92,6 +98,9 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi onBackPressedDispatcher.onBackPressed() return } + val contract = ActivityResultContracts.OpenDocumentTree() + launcher = LauncherWrapper(this, contract) + mediaSingleton = null ThemeManager(this).applyTheme(MediaSingleton.bitmap) MediaSingleton.bitmap = null @@ -576,4 +585,4 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi companion object { var mediaSingleton: Media? = null } -} +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/media/SubtitleDownloader.kt b/app/src/main/java/ani/dantotsu/media/SubtitleDownloader.kt index 6672f37d..38affa0d 100644 --- a/app/src/main/java/ani/dantotsu/media/SubtitleDownloader.kt +++ b/app/src/main/java/ani/dantotsu/media/SubtitleDownloader.kt @@ -5,6 +5,7 @@ import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager import ani.dantotsu.parsers.SubtitleType import ani.dantotsu.snackString +import com.anggrayudi.storage.file.openOutputStream import eu.kanade.tachiyomi.network.NetworkHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -51,21 +52,17 @@ class SubtitleDownloader { downloadedType: DownloadedType ) { try { - val directory = DownloadsManager.getDirectory( + val directory = DownloadsManager.getSubDirectory( context, downloadedType.type, + false, downloadedType.title, downloadedType.chapter - ) - if (!directory.exists()) { //just in case - directory.mkdirs() - } + ) ?: throw Exception("Could not create directory") val type = loadSubtitleType(url) - val subtiteFile = File(directory, "subtitle.${type}") - if (subtiteFile.exists()) { - subtiteFile.delete() - } - subtiteFile.createNewFile() + directory.findFile("subtitle.${type}")?.delete() + val subtitleFile = directory.createFile("*/*", "subtitle.${type}") + ?: throw Exception("Could not create subtitle file") val client = Injekt.get().client val request = Request.Builder().url(url).build() @@ -77,7 +74,8 @@ class SubtitleDownloader { } reponse.body.byteStream().use { input -> - subtiteFile.outputStream().use { output -> + subtitleFile.openOutputStream(context, false).use { output -> + if (output == null) throw Exception("Could not open output stream") input.copyTo(output) } } diff --git a/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchFragment.kt b/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchFragment.kt index 8298318c..2554f283 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchFragment.kt @@ -14,6 +14,7 @@ import android.view.ViewGroup import android.widget.FrameLayout import android.widget.Toast import androidx.annotation.OptIn +import androidx.appcompat.app.AppCompatActivity import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat import androidx.core.math.MathUtils @@ -34,8 +35,8 @@ import ani.dantotsu.R import ani.dantotsu.databinding.FragmentAnimeWatchBinding import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager +import ani.dantotsu.download.DownloadsManager.Companion.findValidName import ani.dantotsu.download.anime.AnimeDownloaderService -import ani.dantotsu.download.video.ExoplayerDownloadService import ani.dantotsu.dp import ani.dantotsu.media.Media import ani.dantotsu.media.MediaDetailsActivity @@ -54,6 +55,8 @@ import ani.dantotsu.settings.extensionprefs.AnimeSourcePreferencesFragment import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString +import ani.dantotsu.util.StoragePermissions.Companion.accessAlertDialog +import ani.dantotsu.util.StoragePermissions.Companion.hasDirAccess import com.google.android.material.appbar.AppBarLayout import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension @@ -422,7 +425,19 @@ class AnimeWatchFragment : Fragment() { } fun onAnimeEpisodeDownloadClick(i: String) { - model.onEpisodeClick(media, i, requireActivity().supportFragmentManager, isDownload = true) + activity?.let{ + if (!hasDirAccess(it)) { + (it as MediaDetailsActivity).accessAlertDialog(it.launcher) { success -> + if (success) { + model.onEpisodeClick(media, i, requireActivity().supportFragmentManager, isDownload = true) + } else { + snackString("Permission is required to download") + } + } + } else { + model.onEpisodeClick(media, i, requireActivity().supportFragmentManager, isDownload = true) + } + } } fun onAnimeEpisodeStopDownloadClick(i: String) { @@ -442,8 +457,9 @@ class AnimeWatchFragment : Fragment() { i, MediaType.ANIME ) - ) - episodeAdapter.purgeDownload(i) + ) { + episodeAdapter.purgeDownload(i) + } } @OptIn(UnstableApi::class) @@ -454,20 +470,15 @@ class AnimeWatchFragment : Fragment() { i, MediaType.ANIME ) - ) - val taskName = AnimeDownloaderService.AnimeDownloadTask.getTaskName(media.mainName(), i) - val id = PrefManager.getAnimeDownloadPreferences().getString( - taskName, - "" - ) ?: "" - PrefManager.getAnimeDownloadPreferences().edit().remove(taskName).apply() - DownloadService.sendRemoveDownload( - requireContext(), - ExoplayerDownloadService::class.java, - id, - true - ) - episodeAdapter.deleteDownload(i) + ) { + val taskName = AnimeDownloaderService.AnimeDownloadTask.getTaskName(media.mainName(), i) + val id = PrefManager.getAnimeDownloadPreferences().getString( + taskName, + "" + ) ?: "" + PrefManager.getAnimeDownloadPreferences().edit().remove(taskName).apply() + episodeAdapter.deleteDownload(i) + } } private val downloadStatusReceiver = object : BroadcastReceiver() { @@ -531,7 +542,7 @@ class AnimeWatchFragment : Fragment() { episodeAdapter.updateType(style ?: PrefManager.getVal(PrefName.AnimeDefaultView)) episodeAdapter.notifyItemRangeInserted(0, arr.size) for (download in downloadManager.animeDownloadedTypes) { - if (download.title == media.mainName()) { + if (download.title == media.mainName().findValidName()) { episodeAdapter.stopDownload(download.chapter) } } diff --git a/app/src/main/java/ani/dantotsu/media/anime/EpisodeAdapters.kt b/app/src/main/java/ani/dantotsu/media/anime/EpisodeAdapters.kt index 68d3aabd..86e8af61 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/EpisodeAdapters.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/EpisodeAdapters.kt @@ -10,7 +10,6 @@ import androidx.annotation.OptIn import androidx.core.view.isVisible import androidx.lifecycle.coroutineScope import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.offline.DownloadIndex import androidx.recyclerview.widget.RecyclerView import ani.dantotsu.R import ani.dantotsu.connections.updateProgress @@ -18,10 +17,12 @@ import ani.dantotsu.currContext import ani.dantotsu.databinding.ItemEpisodeCompactBinding import ani.dantotsu.databinding.ItemEpisodeGridBinding import ani.dantotsu.databinding.ItemEpisodeListBinding +import ani.dantotsu.download.DownloadsManager +import ani.dantotsu.download.DownloadsManager.Companion.getDirSize import ani.dantotsu.download.anime.AnimeDownloaderService -import ani.dantotsu.download.video.Helper import ani.dantotsu.media.Media import ani.dantotsu.media.MediaNameAdapter +import ani.dantotsu.media.MediaType import ani.dantotsu.setAnimation import ani.dantotsu.settings.saving.PrefManager import com.bumptech.glide.Glide @@ -56,15 +57,7 @@ class EpisodeAdapter( var arr: List = arrayListOf(), var offlineMode: Boolean ) : RecyclerView.Adapter() { - - private lateinit var index: DownloadIndex - - - init { - if (offlineMode) { - index = Helper.downloadManager(fragment.requireContext()).downloadIndex - } - } + val context = fragment.requireContext() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return (when (viewType) { @@ -248,17 +241,8 @@ class EpisodeAdapter( // Find the position of the chapter and notify only that item val position = arr.indexOfFirst { it.number == episodeNumber } if (position != -1) { - val taskName = AnimeDownloaderService.AnimeDownloadTask.getTaskName( - media.mainName(), - episodeNumber - ) - val id = PrefManager.getAnimeDownloadPreferences().getString( - taskName, - "" - ) ?: "" val size = try { - val download = index.getDownload(id) - bytesToHuman(download?.bytesDownloaded ?: 0) + bytesToHuman(getDirSize(context, MediaType.ANIME, media.mainName(), episodeNumber)) } catch (e: Exception) { null } diff --git a/app/src/main/java/ani/dantotsu/media/anime/ExoplayerView.kt b/app/src/main/java/ani/dantotsu/media/anime/ExoplayerView.kt index 7daf8138..8211cb08 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/ExoplayerView.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/ExoplayerView.kt @@ -104,7 +104,7 @@ import ani.dantotsu.connections.discord.RPC import ani.dantotsu.connections.updateProgress import ani.dantotsu.databinding.ActivityExoplayerBinding import ani.dantotsu.defaultHeaders -import ani.dantotsu.download.video.Helper +import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory import ani.dantotsu.dp import ani.dantotsu.getCurrentBrightnessValue import ani.dantotsu.hideSystemBars @@ -114,6 +114,7 @@ import ani.dantotsu.logError import ani.dantotsu.media.Media import ani.dantotsu.media.MediaDetailsViewModel import ani.dantotsu.media.MediaNameAdapter +import ani.dantotsu.media.MediaType import ani.dantotsu.media.SubtitleDownloader import ani.dantotsu.okHttpClient import ani.dantotsu.others.AniSkip @@ -394,7 +395,8 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL isCastApiAvailable = GoogleApiAvailability.getInstance() .isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS try { - castContext = CastContext.getSharedInstance(this, Executors.newSingleThreadExecutor()).result + castContext = + CastContext.getSharedInstance(this, Executors.newSingleThreadExecutor()).result castPlayer = CastPlayer(castContext!!) castPlayer!!.setSessionAvailabilityListener(this) } catch (e: Exception) { @@ -442,41 +444,43 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL }, AUDIO_CONTENT_TYPE_MOVIE, AUDIOFOCUS_GAIN) if (System.getInt(contentResolver, System.ACCELEROMETER_ROTATION, 0) != 1) { - if (PrefManager.getVal(PrefName.RotationPlayer)) { - orientationListener = - object : OrientationEventListener(this, SensorManager.SENSOR_DELAY_UI) { - override fun onOrientationChanged(orientation: Int) { - when (orientation) { - in 45..135 -> { - if (rotation != ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) { - exoRotate.visibility = View.VISIBLE + if (PrefManager.getVal(PrefName.RotationPlayer)) { + orientationListener = + object : OrientationEventListener(this, SensorManager.SENSOR_DELAY_UI) { + override fun onOrientationChanged(orientation: Int) { + when (orientation) { + in 45..135 -> { + if (rotation != ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) { + exoRotate.visibility = View.VISIBLE + } + rotation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + } + + in 225..315 -> { + if (rotation != ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) { + exoRotate.visibility = View.VISIBLE + } + rotation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + + in 315..360, in 0..45 -> { + if (rotation != ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) { + exoRotate.visibility = View.VISIBLE + } + rotation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } } - rotation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE - } - in 225..315 -> { - if (rotation != ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) { - exoRotate.visibility = View.VISIBLE - } - rotation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - } - in 315..360, in 0..45 -> { - if (rotation != ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) { - exoRotate.visibility = View.VISIBLE - } - rotation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT } } - } + orientationListener?.enable() } - orientationListener?.enable() - } - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - exoRotate.setOnClickListener { - requestedOrientation = rotation - it.visibility = View.GONE - } -} + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + exoRotate.setOnClickListener { + requestedOrientation = rotation + it.visibility = View.GONE + } + } setupSubFormatting(playerView) @@ -1089,10 +1093,12 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL "nothing" -> mutableListOf( RPC.Link(getString(R.string.view_anime), media.shareLink ?: ""), ) + "dantotsu" -> mutableListOf( RPC.Link(getString(R.string.view_anime), media.shareLink ?: ""), RPC.Link("Watch on Dantotsu", getString(R.string.dantotsu)) ) + "anilist" -> { val userId = PrefManager.getVal(PrefName.AnilistUserId) val anilistLink = "https://anilist.co/user/$userId/" @@ -1101,6 +1107,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL RPC.Link("View My AniList", anilistLink) ) } + else -> mutableListOf() } val presence = RPC.createPresence( @@ -1113,7 +1120,12 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL ep.number ), state = "Episode : ${ep.number}/${media.anime?.totalEpisodes ?: "??"}", - largeImage = media.cover?.let { RPC.Link(media.userPreferredName, it) }, + largeImage = media.cover?.let { + RPC.Link( + media.userPreferredName, + it + ) + }, smallImage = RPC.Link("Dantotsu", Discord.small_Image), buttons = buttons ) @@ -1161,7 +1173,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL if (PrefManager.getVal(PrefName.Cast)) { playerView.findViewById(R.id.exo_cast).apply { visibility = View.VISIBLE - if(PrefManager.getVal(PrefName.UseInternalCast)) { + if (PrefManager.getVal(PrefName.UseInternalCast)) { try { CastButtonFactory.setUpMediaRouteButton(context, this) dialogFactory = CustomCastThemeFactory() @@ -1324,7 +1336,11 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL ) @Suppress("UNCHECKED_CAST") - val list = (PrefManager.getNullableCustomVal("continueAnimeList", listOf(), List::class.java) as List).toMutableList() + val list = (PrefManager.getNullableCustomVal( + "continueAnimeList", + listOf(), + List::class.java + ) as List).toMutableList() if (list.contains(media.id)) list.remove(media.id) list.add(media.id) PrefManager.setCustomVal("continueAnimeList", list) @@ -1418,7 +1434,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL } val dafuckDataSourceFactory = DefaultDataSource.Factory(this) cacheFactory = CacheDataSource.Factory().apply { - setCache(Helper.getSimpleCache(this@ExoplayerView)) + setCache(VideoCache.getInstance(this@ExoplayerView)) if (ext.server.offline) { setUpstreamDataSourceFactory(dafuckDataSourceFactory) } else { @@ -1435,15 +1451,28 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL val downloadedMediaItem = if (ext.server.offline) { val key = ext.server.name - downloadId = PrefManager.getAnimeDownloadPreferences() - .getString(key, null) - if (downloadId != null) { - Helper.downloadManager(this) - .downloadIndex.getDownload(downloadId!!)?.request?.toMediaItem() + val titleName = ext.server.name.split("/").first() + val episodeName = ext.server.name.split("/").last() + + val directory = getSubDirectory(this, MediaType.ANIME, false, titleName, episodeName) + if (directory != null) { + val files = directory.listFiles() + println(files) + val docFile = directory.listFiles().firstOrNull { + it.name?.endsWith(".mp4") == true || it.name?.endsWith(".mkv") == true + } + if (docFile != null) { + val uri = docFile.uri + MediaItem.Builder().setUri(uri).setMimeType(mimeType).build() + } else { + snackString("File not found") + null + } } else { - snackString("Download not found") + snackString("Directory not found") null } + } else null mediaItem = if (downloadedMediaItem == null) { @@ -1818,7 +1847,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL if (!functionstarted && !disappeared && PrefManager.getVal(PrefName.AutoHideTimeStamps)) { disappearSkip() - } else if (!PrefManager.getVal(PrefName.AutoHideTimeStamps)){ + } else if (!PrefManager.getVal(PrefName.AutoHideTimeStamps)) { skipTimeButton.visibility = View.VISIBLE exoSkip.visibility = View.GONE skipTimeText.text = new.skipType.getType() @@ -2157,11 +2186,16 @@ class CustomCastButton : MediaRouteButton { fun setCastCallback(castCallback: () -> Unit) { this.castCallback = castCallback } + constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet) : super(context, attrs) - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) override fun performClick(): Boolean { return if (PrefManager.getVal(PrefName.UseInternalCast)) { diff --git a/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt b/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt index dccbd72c..26573b6e 100644 --- a/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt @@ -16,6 +16,7 @@ import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity import androidx.cardview.widget.CardView import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat @@ -34,6 +35,7 @@ import ani.dantotsu.R import ani.dantotsu.databinding.FragmentAnimeWatchBinding import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager +import ani.dantotsu.download.DownloadsManager.Companion.findValidName import ani.dantotsu.download.manga.MangaDownloaderService import ani.dantotsu.download.manga.MangaServiceDataSingleton import ani.dantotsu.dp @@ -56,6 +58,8 @@ import ani.dantotsu.settings.extensionprefs.MangaSourcePreferencesFragment import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString +import ani.dantotsu.util.StoragePermissions.Companion.accessAlertDialog +import ani.dantotsu.util.StoragePermissions.Companion.hasDirAccess import com.google.android.material.appbar.AppBarLayout import eu.kanade.tachiyomi.extension.manga.model.MangaExtension import eu.kanade.tachiyomi.source.ConfigurableSource @@ -190,7 +194,7 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener { ) for (download in downloadManager.mangaDownloadedTypes) { - if (download.title == media.mainName()) { + if (download.title == media.mainName().findValidName()) { chapterAdapter.stopDownload(download.chapter) } } @@ -434,51 +438,65 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener { } fun onMangaChapterDownloadClick(i: String) { - if (!isNotificationPermissionGranted()) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - ActivityCompat.requestPermissions( - requireActivity(), - arrayOf(Manifest.permission.POST_NOTIFICATIONS), - 1 - ) - } - } - - model.continueMedia = false - media.manga?.chapters?.get(i)?.let { chapter -> - val parser = - model.mangaReadSources?.get(media.selected!!.sourceIndex) as? DynamicMangaParser - parser?.let { - CoroutineScope(Dispatchers.IO).launch { - val images = parser.imageList(chapter.sChapter) - - // Create a download task - val downloadTask = MangaDownloaderService.DownloadTask( - title = media.mainName(), - chapter = chapter.title!!, - imageData = images, - sourceMedia = media, - retries = 2, - simultaneousDownloads = 2 + activity?.let { + if (!isNotificationPermissionGranted()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ActivityCompat.requestPermissions( + it, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + 1 ) + } + } + fun continueDownload() { + model.continueMedia = false + media.manga?.chapters?.get(i)?.let { chapter -> + val parser = + model.mangaReadSources?.get(media.selected!!.sourceIndex) as? DynamicMangaParser + parser?.let { + CoroutineScope(Dispatchers.IO).launch { + val images = parser.imageList(chapter.sChapter) - MangaServiceDataSingleton.downloadQueue.offer(downloadTask) + // Create a download task + val downloadTask = MangaDownloaderService.DownloadTask( + title = media.mainName(), + chapter = chapter.title!!, + imageData = images, + sourceMedia = media, + retries = 2, + simultaneousDownloads = 2 + ) - // If the service is not already running, start it - if (!MangaServiceDataSingleton.isServiceRunning) { - val intent = Intent(context, MangaDownloaderService::class.java) - withContext(Dispatchers.Main) { - ContextCompat.startForegroundService(requireContext(), intent) + MangaServiceDataSingleton.downloadQueue.offer(downloadTask) + + // If the service is not already running, start it + if (!MangaServiceDataSingleton.isServiceRunning) { + val intent = Intent(context, MangaDownloaderService::class.java) + withContext(Dispatchers.Main) { + ContextCompat.startForegroundService(requireContext(), intent) + } + MangaServiceDataSingleton.isServiceRunning = true + } + + // Inform the adapter that the download has started + withContext(Dispatchers.Main) { + chapterAdapter.startDownload(i) + } } - MangaServiceDataSingleton.isServiceRunning = true - } - - // Inform the adapter that the download has started - withContext(Dispatchers.Main) { - chapterAdapter.startDownload(i) } } } + if (!hasDirAccess(it)) { + (it as MediaDetailsActivity).accessAlertDialog(it.launcher) { success -> + if (success) { + continueDownload() + } else { + snackString("Permission is required to download") + } + } + } else { + continueDownload() + } } } @@ -500,8 +518,9 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener { i, MediaType.MANGA ) - ) - chapterAdapter.deleteDownload(i) + ) { + chapterAdapter.deleteDownload(i) + } } fun onMangaChapterStopDownloadClick(i: String) { @@ -518,8 +537,9 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener { i, MediaType.MANGA ) - ) - chapterAdapter.purgeDownload(i) + ) { + chapterAdapter.purgeDownload(i) + } } private val downloadStatusReceiver = object : BroadcastReceiver() { diff --git a/app/src/main/java/ani/dantotsu/media/manga/mangareader/BaseImageAdapter.kt b/app/src/main/java/ani/dantotsu/media/manga/mangareader/BaseImageAdapter.kt index 67f5048c..a00f4731 100644 --- a/app/src/main/java/ani/dantotsu/media/manga/mangareader/BaseImageAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/manga/mangareader/BaseImageAdapter.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas +import android.net.Uri import android.view.HapticFeedbackConstants import android.view.MotionEvent import android.view.View @@ -165,6 +166,10 @@ abstract class BaseImageAdapter( it.load(localFile.absoluteFile) .skipMemoryCache(true) .diskCacheStrategy(DiskCacheStrategy.NONE) + } else if (link.url.startsWith("content://")) { + it.load(Uri.parse(link.url)) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) } else { mangaCache.get(link.url)?.let { imageData -> val bitmap = imageData.fetchAndProcessImage( @@ -175,6 +180,7 @@ abstract class BaseImageAdapter( .skipMemoryCache(true) .diskCacheStrategy(DiskCacheStrategy.NONE) } + } } ?.let { diff --git a/app/src/main/java/ani/dantotsu/media/novel/NovelReadFragment.kt b/app/src/main/java/ani/dantotsu/media/novel/NovelReadFragment.kt index 867912a7..cf372b40 100644 --- a/app/src/main/java/ani/dantotsu/media/novel/NovelReadFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/novel/NovelReadFragment.kt @@ -96,7 +96,7 @@ class NovelReadFragment : Fragment(), ) { val file = File( context?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "${DownloadsManager.novelLocation}/${media.mainName()}/${novel.name}/0.epub" + "a/${media.mainName()}/${novel.name}/0.epub" //FIXME ) if (!file.exists()) return false val fileUri = FileProvider.getUriForFile( @@ -135,7 +135,7 @@ class NovelReadFragment : Fragment(), novel.name, MediaType.NOVEL ) - ) + ) {} } private val downloadStatusReceiver = object : BroadcastReceiver() { diff --git a/app/src/main/java/ani/dantotsu/others/Download.kt b/app/src/main/java/ani/dantotsu/others/Download.kt index 3a266af4..5a28dd46 100644 --- a/app/src/main/java/ani/dantotsu/others/Download.kt +++ b/app/src/main/java/ani/dantotsu/others/Download.kt @@ -96,52 +96,10 @@ object Download { when (PrefManager.getVal(PrefName.DownloadManager) as Int) { 1 -> oneDM(context, file, notif ?: fileName) 2 -> adm(context, file, fileName, folder) - else -> defaultDownload(context, file, fileName, folder, notif ?: fileName) + else -> oneDM(context, file, notif ?: fileName) } } - private fun defaultDownload( - context: Context, - file: FileUrl, - fileName: String, - folder: String, - notif: String - ) { - val manager = - context.getSystemService(AppCompatActivity.DOWNLOAD_SERVICE) as DownloadManager - val request: DownloadManager.Request = DownloadManager.Request(Uri.parse(file.url)) - file.headers.forEach { - request.addRequestHeader(it.key, it.value) - } - CoroutineScope(Dispatchers.IO).launch { - try { - request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - - val arrayOfFiles = ContextCompat.getExternalFilesDirs(context, null) - if (PrefManager.getVal(PrefName.SdDl) && arrayOfFiles.size > 1 && arrayOfFiles[0] != null && arrayOfFiles[1] != null) { - val parentDirectory = arrayOfFiles[1].toString() + folder - val direct = File(parentDirectory) - if (!direct.exists()) direct.mkdirs() - request.setDestinationUri(Uri.fromFile(File("$parentDirectory$fileName"))) - } else { - val direct = File(Environment.DIRECTORY_DOWNLOADS + "/Dantotsu$folder") - if (!direct.exists()) direct.mkdirs() - request.setDestinationInExternalPublicDir( - Environment.DIRECTORY_DOWNLOADS, - "/Dantotsu$folder$fileName" - ) - } - request.setTitle(notif) - manager.enqueue(request) - toast(currContext()?.getString(R.string.started_downloading, notif)) - } catch (e: SecurityException) { - toast(currContext()?.getString(R.string.permission_required)) - } catch (e: Exception) { - toast(e.toString()) - } - } - } - private fun oneDM(context: Context, file: FileUrl, notif: String) { val appName = if (isPackageInstalled("idm.internet.download.manager.plus", context.packageManager)) { diff --git a/app/src/main/java/ani/dantotsu/others/ImageViewDialog.kt b/app/src/main/java/ani/dantotsu/others/ImageViewDialog.kt index 346bd592..e721e84d 100644 --- a/app/src/main/java/ani/dantotsu/others/ImageViewDialog.kt +++ b/app/src/main/java/ani/dantotsu/others/ImageViewDialog.kt @@ -12,7 +12,6 @@ import ani.dantotsu.BottomSheetDialogFragment import ani.dantotsu.FileUrl import ani.dantotsu.R import ani.dantotsu.databinding.BottomSheetImageBinding -import ani.dantotsu.downloadsPermission import ani.dantotsu.media.manga.mangareader.BaseImageAdapter.Companion.loadBitmap import ani.dantotsu.media.manga.mangareader.BaseImageAdapter.Companion.loadBitmapOld import ani.dantotsu.media.manga.mangareader.BaseImageAdapter.Companion.mergeBitmap @@ -22,6 +21,7 @@ import ani.dantotsu.setSafeOnClickListener import ani.dantotsu.shareImage import ani.dantotsu.snackString import ani.dantotsu.toast +import ani.dantotsu.util.StoragePermissions.Companion.downloadsPermission import com.bumptech.glide.load.resource.bitmap.BitmapTransformation import com.davemorrissey.labs.subscaleview.ImageSource import kotlinx.coroutines.launch diff --git a/app/src/main/java/ani/dantotsu/parsers/OfflineAnimeParser.kt b/app/src/main/java/ani/dantotsu/parsers/OfflineAnimeParser.kt index 582419c9..f4e30c0d 100644 --- a/app/src/main/java/ani/dantotsu/parsers/OfflineAnimeParser.kt +++ b/app/src/main/java/ani/dantotsu/parsers/OfflineAnimeParser.kt @@ -1,9 +1,12 @@ package ani.dantotsu.parsers +import android.app.Application import android.net.Uri import android.os.Environment import ani.dantotsu.currContext import ani.dantotsu.download.DownloadsManager +import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory +import ani.dantotsu.download.anime.AnimeDownloaderService.AnimeDownloadTask.Companion.getTaskName import ani.dantotsu.media.MediaType import ani.dantotsu.media.MediaNameAdapter import ani.dantotsu.tryWithSuspend @@ -18,6 +21,7 @@ import java.util.Locale class OfflineAnimeParser : AnimeParser() { private val downloadManager = Injekt.get() + private val context = Injekt.get() override val name = "Offline" override val saveName = "Offline" @@ -29,22 +33,19 @@ class OfflineAnimeParser : AnimeParser() { extra: Map?, sAnime: SAnime ): List { - val directory = File( - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "${DownloadsManager.animeLocation}/$animeLink" - ) + val directory = getSubDirectory(context, MediaType.ANIME, false, animeLink) //get all of the folder names and add them to the list val episodes = mutableListOf() - if (directory.exists()) { - directory.listFiles()?.forEach { + if (directory?.exists() == true) { + directory.listFiles().forEach { //put the title and episdode number in the extra data val extraData = mutableMapOf() extraData["title"] = animeLink - extraData["episode"] = it.name + extraData["episode"] = it.name!! if (it.isDirectory) { val episode = Episode( - it.name, - "$animeLink - ${it.name}", + it.name!!, + getTaskName(animeLink,it.name!!), it.name, null, null, @@ -131,18 +132,19 @@ class OfflineVideoExtractor(val videoServer: VideoServer) : VideoExtractor() { private fun getSubtitle(title: String, episode: String): List? { currContext()?.let { - DownloadsManager.getDirectory( + DownloadsManager.getSubDirectory( it, MediaType.ANIME, + false, title, episode - ).listFiles()?.forEach { file -> - if (file.name.contains("subtitle")) { + )?.listFiles()?.forEach { file -> + if (file.name?.contains("subtitle") == true) { return listOf( Subtitle( "Downloaded Subtitle", - Uri.fromFile(file).toString(), - determineSubtitletype(file.absolutePath) + file.uri.toString(), + determineSubtitletype(file.name ?: "") ) ) } diff --git a/app/src/main/java/ani/dantotsu/parsers/OfflineMangaParser.kt b/app/src/main/java/ani/dantotsu/parsers/OfflineMangaParser.kt index 983a53ec..a3a239a8 100644 --- a/app/src/main/java/ani/dantotsu/parsers/OfflineMangaParser.kt +++ b/app/src/main/java/ani/dantotsu/parsers/OfflineMangaParser.kt @@ -1,9 +1,12 @@ package ani.dantotsu.parsers +import android.app.Application import android.os.Environment import ani.dantotsu.currContext import ani.dantotsu.download.DownloadsManager +import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory import ani.dantotsu.media.MediaNameAdapter +import ani.dantotsu.media.MediaType import ani.dantotsu.util.Logger import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga @@ -14,6 +17,7 @@ import java.io.File class OfflineMangaParser : MangaParser() { private val downloadManager = Injekt.get() + private val context = Injekt.get() override val hostUrl: String = "Offline" override val name: String = "Offline" @@ -23,17 +27,14 @@ class OfflineMangaParser : MangaParser() { extra: Map?, sManga: SManga ): List { - val directory = File( - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Manga/$mangaLink" - ) + val directory = getSubDirectory(context, MediaType.MANGA, false, mangaLink) //get all of the folder names and add them to the list val chapters = mutableListOf() - if (directory.exists()) { - directory.listFiles()?.forEach { + if (directory?.exists() == true) { + directory.listFiles().forEach { if (it.isDirectory) { val chapter = MangaChapter( - it.name, + it.name!!, "$mangaLink/${it.name}", it.name, null, @@ -50,16 +51,15 @@ class OfflineMangaParser : MangaParser() { } override suspend fun loadImages(chapterLink: String, sChapter: SChapter): List { - val directory = File( - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Manga/$chapterLink" - ) + val title = chapterLink.split("/").first() + val chapter = chapterLink.split("/").last() + val directory = getSubDirectory(context, MediaType.MANGA, false, title, chapter) val images = mutableListOf() val imageNumberRegex = Regex("""(\d+)\.jpg$""") - if (directory.exists()) { - directory.listFiles()?.forEach { + if (directory?.exists() == true) { + directory.listFiles().forEach { if (it.isFile) { - val image = MangaImage(it.absolutePath, false, null) + val image = MangaImage(it.uri.toString(), false, null) images.add(image) } } diff --git a/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt b/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt index e96f0edf..3eb848f7 100644 --- a/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt @@ -51,8 +51,6 @@ import ani.dantotsu.databinding.ActivitySettingsMangaBinding import ani.dantotsu.databinding.ActivitySettingsNotificationsBinding import ani.dantotsu.databinding.ActivitySettingsThemeBinding import ani.dantotsu.download.DownloadsManager -import ani.dantotsu.download.video.ExoplayerDownloadService -import ani.dantotsu.downloadsPermission import ani.dantotsu.initActivity import ani.dantotsu.loadImage import ani.dantotsu.media.MediaType @@ -82,6 +80,7 @@ import ani.dantotsu.statusBarHeight import ani.dantotsu.themes.ThemeManager import ani.dantotsu.toast import ani.dantotsu.util.Logger +import ani.dantotsu.util.StoragePermissions.Companion.downloadsPermission import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.textfield.TextInputEditText import eltos.simpledialogfragment.SimpleDialog @@ -438,11 +437,6 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene .setPositiveButton(R.string.yes) { dialog, _ -> val downloadsManager = Injekt.get() downloadsManager.purgeDownloads(MediaType.ANIME) - DownloadService.sendRemoveAllDownloads( - this@SettingsActivity, - ExoplayerDownloadService::class.java, - false - ) dialog.dismiss() } .setNegativeButton(R.string.no) { dialog, _ -> diff --git a/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt b/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt index aebc894a..239ab7ce 100644 --- a/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt +++ b/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt @@ -182,6 +182,7 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files RecentGlobalNotification(Pref(Location.Irrelevant, Int::class, 0)), CommentNotificationStore(Pref(Location.Irrelevant, List::class, listOf())), UnreadCommentNotifications(Pref(Location.Irrelevant, Int::class, 0)), + DownloadsDir(Pref(Location.Irrelevant, String::class, "")), //Protected DiscordToken(Pref(Location.Protected, String::class, "")), diff --git a/app/src/main/java/ani/dantotsu/util/StoragePermissions.kt b/app/src/main/java/ani/dantotsu/util/StoragePermissions.kt new file mode 100644 index 00000000..7d6ee9af --- /dev/null +++ b/app/src/main/java/ani/dantotsu/util/StoragePermissions.kt @@ -0,0 +1,129 @@ +package ani.dantotsu.util + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import ani.dantotsu.R +import ani.dantotsu.settings.saving.PrefManager +import ani.dantotsu.settings.saving.PrefName +import ani.dantotsu.toast + +class StoragePermissions { + companion object { + fun downloadsPermission(activity: AppCompatActivity): Boolean { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) return true + val permissions = arrayOf( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE + ) + + val requiredPermissions = permissions.filter { + ContextCompat.checkSelfPermission(activity, it) != PackageManager.PERMISSION_GRANTED + }.toTypedArray() + + return if (requiredPermissions.isNotEmpty()) { + ActivityCompat.requestPermissions( + activity, + requiredPermissions, + DOWNLOADS_PERMISSION_REQUEST_CODE + ) + false + } else { + true + } + } + + fun hasDirAccess(context: Context, path: String): Boolean { + val uri = pathToUri(path) + return context.contentResolver.persistedUriPermissions.any { + it.uri == uri && it.isReadPermission && it.isWritePermission + } + + } + + fun hasDirAccess(context: Context, uri: Uri): Boolean { + return context.contentResolver.persistedUriPermissions.any { + it.uri == uri && it.isReadPermission && it.isWritePermission + } + } + + fun hasDirAccess(context: Context): Boolean { + val path = PrefManager.getVal(PrefName.DownloadsDir) + return hasDirAccess(context, path) + } + + fun AppCompatActivity.accessAlertDialog(launcher: LauncherWrapper, + complete: (Boolean) -> Unit) { + if (PrefManager.getVal(PrefName.DownloadsDir).isNotEmpty()) { + return + } + val builder = AlertDialog.Builder(this, R.style.MyPopup) + builder.setTitle(getString(R.string.dir_access)) + builder.setMessage(getString(R.string.dir_access_msg)) + builder.setPositiveButton(getString(R.string.ok)) { dialog, _ -> + launcher.registerForCallback(complete) + launcher.launch() + dialog.dismiss() + } + builder.setNegativeButton(getString(R.string.cancel)) { dialog, _ -> + dialog.dismiss() + complete(false) + } + val dialog = builder.show() + dialog.window?.setDimAmount(0.8f) + } + + private fun pathToUri(path: String): Uri { + return Uri.parse(path) + } + + private const val DOWNLOADS_PERMISSION_REQUEST_CODE = 100 + } +} + + +class LauncherWrapper( + activity: AppCompatActivity, + contract: ActivityResultContracts.OpenDocumentTree) +{ + private var launcher: ActivityResultLauncher + var complete: (Boolean) -> Unit = {} + init{ + launcher = activity.registerForActivityResult(contract) { uri -> + if (uri != null) { + activity.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + + if (StoragePermissions.hasDirAccess(activity, uri)) { + PrefManager.setVal(PrefName.DownloadsDir, uri.toString()) + complete(true) + } else { + toast(activity.getString(R.string.dir_error)) + complete(false) + } + } else { + toast(activity.getString(R.string.dir_error)) + complete(false) + } + } + } + + fun registerForCallback(callback: (Boolean) -> Unit) { + complete = callback + } + + fun launch() { + launcher.launch(null) + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 45b73809..30450f05 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -865,4 +865,7 @@ Non quae tempore quo provident laudantium qui illo dolor vel quia dolor et exerc Trending Manhwa Liked By Adult only content + Your path could not be set + Downloads access + Please choose a directory to save your downloads