From 720b40afa7fcdd6c71f79d55a64ee6d659abef18 Mon Sep 17 00:00:00 2001 From: rebel onion <87634197+rebelonion@users.noreply.github.com> Date: Thu, 4 Apr 2024 04:03:45 -0500 Subject: [PATCH] feat: custom downloader and downloader location (#313) * feat: custom downloader (novel broken) * fix: send headers to ffmpeg ffmpeg can be a real bitch to work with * fix: offline page for new download system * feat: novel to new system | load freezing * chore: clean manifest * fix: notification incrementing * feat: changing the downloads dir --- app/build.gradle | 12 + app/src/main/AndroidManifest.xml | 10 - app/src/main/java/ani/dantotsu/Functions.kt | 36 +- .../main/java/ani/dantotsu/MainActivity.kt | 8 +- app/src/main/java/ani/dantotsu/Network.kt | 3 +- .../ani/dantotsu/download/DownloadsManager.kt | 332 ++++++++-------- .../download/anime/AnimeDownloaderService.kt | 369 +++++++++++------- .../download/anime/OfflineAnimeFragment.kt | 134 ++++--- .../download/manga/MangaDownloaderService.kt | 69 ++-- .../download/manga/OfflineMangaFragment.kt | 135 ++++--- .../download/novel/NovelDownloaderService.kt | 72 ++-- .../video/ExoplayerDownloadService.kt | 37 -- .../ani/dantotsu/download/video/Helper.kt | 168 +------- .../dantotsu/media/MediaDetailsActivity.kt | 9 +- .../ani/dantotsu/media/SubtitleDownloader.kt | 20 +- .../media/anime/AnimeWatchFragment.kt | 49 ++- .../dantotsu/media/anime/EpisodeAdapters.kt | 26 +- .../ani/dantotsu/media/anime/ExoplayerView.kt | 122 +++--- .../dantotsu/media/manga/MangaReadFragment.kt | 106 +++-- .../manga/mangareader/BaseImageAdapter.kt | 6 + .../dantotsu/media/novel/NovelReadFragment.kt | 35 +- .../comment/CommentNotificationTask.kt | 6 +- .../main/java/ani/dantotsu/others/Download.kt | 56 +-- .../ani/dantotsu/others/ImageViewDialog.kt | 2 +- .../dantotsu/parsers/OfflineAnimeParser.kt | 30 +- .../dantotsu/parsers/OfflineMangaParser.kt | 28 +- .../dantotsu/parsers/OfflineNovelParser.kt | 33 +- .../ani/dantotsu/settings/SettingsActivity.kt | 69 +++- .../dantotsu/settings/saving/Preferences.kt | 2 +- .../ani/dantotsu/util/StoragePermissions.kt | 132 +++++++ .../animesource/online/AnimeHttpSource.kt | 3 +- .../kanade/tachiyomi/network/NetworkHelper.kt | 4 +- .../tachiyomi/source/online/HttpSource.kt | 3 +- .../res/layout/activity_settings_common.xml | 45 ++- app/src/main/res/values/strings.xml | 9 +- 35 files changed, 1162 insertions(+), 1018 deletions(-) delete mode 100644 app/src/main/java/ani/dantotsu/download/video/ExoplayerDownloadService.kt create mode 100644 app/src/main/java/ani/dantotsu/util/StoragePermissions.kt 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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e3ff41fa..934bd7b3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -370,16 +370,6 @@ android:name=".widgets.upcoming.UpcomingRemoteViewsService" android:exported="true" android:permission="android.permission.BIND_REMOTEVIEWS" /> - - - - - - - (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) + } } } } @@ -877,31 +882,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/Network.kt b/app/src/main/java/ani/dantotsu/Network.kt index 91840b3e..1c26b3bb 100644 --- a/app/src/main/java/ani/dantotsu/Network.kt +++ b/app/src/main/java/ani/dantotsu/Network.kt @@ -9,6 +9,7 @@ import com.lagradost.nicehttp.Requests import com.lagradost.nicehttp.ResponseParser import com.lagradost.nicehttp.addGenericDns import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.NetworkHelper.Companion.defaultUserAgentProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.delay @@ -40,7 +41,7 @@ fun initializeNetwork() { defaultHeaders = mapOf( "User-Agent" to - Injekt.get().defaultUserAgentProvider() + defaultUserAgentProvider() .format(Build.VERSION.RELEASE, Build.MODEL) ) diff --git a/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt b/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt index 4eb7f5a3..fa01cc79 100644 --- a/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt +++ b/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt @@ -1,14 +1,25 @@ 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 ani.dantotsu.util.Logger +import com.anggrayudi.storage.callback.FolderCallback +import com.anggrayudi.storage.file.deleteRecursively +import com.anggrayudi.storage.file.findFolder +import com.anggrayudi.storage.file.moveFileTo +import com.anggrayudi.storage.file.moveFolderTo 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 +53,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 +102,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,27 +120,57 @@ 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() + fun moveDownloadsDir(context: Context, oldUri: Uri, newUri: Uri, finished: (Boolean, String) -> Unit) { + try { + if (oldUri == newUri) { + finished(false, "Source and destination are the same") + return + } + CoroutineScope(Dispatchers.IO).launch { + + val oldBase = + DocumentFile.fromTreeUri(context, oldUri) ?: throw Exception("Old base is null") + val newBase = + DocumentFile.fromTreeUri(context, newUri) ?: throw Exception("New base is null") + val folder = + oldBase.findFolder(BASE_LOCATION) ?: throw Exception("Base folder not found") + folder.moveFolderTo(context, newBase, false, BASE_LOCATION, object: + FolderCallback() { + override fun onFailed(errorCode: ErrorCode) { + when (errorCode) { + ErrorCode.CANCELED -> finished(false, "Move canceled") + ErrorCode.CANNOT_CREATE_FILE_IN_TARGET -> finished(false, "Cannot create file in target") + ErrorCode.INVALID_TARGET_FOLDER -> finished(true, "Invalid target folder") // seems to still work + ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH -> finished(false, "No space left on target path") + ErrorCode.UNKNOWN_IO_ERROR -> finished(false, "Unknown IO error") + ErrorCode.SOURCE_FOLDER_NOT_FOUND -> finished(false, "Source folder not found") + ErrorCode.STORAGE_PERMISSION_DENIED -> finished(false, "Storage permission denied") + ErrorCode.TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER -> finished(false, "Target folder cannot have same path with source folder") + else -> finished(false, "Failed to move downloads: $errorCode") + } + Logger.log("Failed to move downloads: $errorCode") + super.onFailed(errorCode) + } + + override fun onCompleted(result: Result) { + finished(true, "Successfully moved downloads") + super.onCompleted(result) + } + }) + } + + } catch (e: Exception) { + snackString("Error: ${e.message}") + finished(false, "Failed to move downloads: ${e.message}") + return } - if (!file.exists()) { - file.createNewFile() - } - file.writeText(jsonString) } fun queryDownload(downloadedType: DownloadedType): Boolean { @@ -149,98 +186,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 +222,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..daaa200e 100644 --- a/app/src/main/java/ani/dantotsu/download/anime/AnimeDownloaderService.kt +++ b/app/src/main/java/ani/dantotsu/download/anime/AnimeDownloaderService.kt @@ -9,24 +9,21 @@ import android.content.IntentFilter import android.content.pm.PackageManager import android.content.pm.ServiceInfo 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 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 +33,12 @@ 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.SessionState import com.google.gson.GsonBuilder import com.google.gson.InstanceCreator import eu.kanade.tachiyomi.animesource.model.SAnime @@ -46,7 +49,6 @@ import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapterImpl import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -56,13 +58,12 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext 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 import java.util.concurrent.ConcurrentLinkedQueue + class AnimeDownloaderService : Service() { private lateinit var notificationManager: NotificationManagerCompat @@ -88,6 +89,7 @@ class AnimeDownloaderService : Service() { setSmallIcon(R.drawable.ic_download_24) priority = NotificationCompat.PRIORITY_DEFAULT setOnlyAlertOnce(true) + setProgress(100, 0, false) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground( @@ -156,27 +158,14 @@ 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 +198,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 +209,80 @@ 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 headersStringBuilder = StringBuilder().append(" ") + task.video.file.headers.forEach { + headersStringBuilder.append("\"${it.key}: ${it.value}\"\'\r\n\'") } + headersStringBuilder.append(" ") + FFprobeKit.executeAsync( + "-headers $headersStringBuilder -i ${task.video.file.url} -show_entries format=duration -v quiet -of csv=\"p=0\"", + { + Logger.log("FFprobeKit: $it") + }, { + if (it.message.toDoubleOrNull() != null) { + totalLength = it.message.toDouble() + } + }) + + var request = "-headers" + val headers = headersStringBuilder.toString() + if (task.video.file.headers.isNotEmpty()) { + request += headers + } + request += "-i ${task.video.file.url} -c copy -bsf:a aac_adtstoasc -tls_verify 0 $path -v trace" + println("Request: $request") + val ffTask = + FFmpegKit.executeAsync(request, + { 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 +296,115 @@ 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( + while (ffTask.state != SessionState.COMPLETED) { + if (ffTask.state == SessionState.FAILED) { + Logger.log("Download failed") + builder.setContentText( + "${ + getTaskName( task.title, - task.episode, - MediaType.ANIME, + task.episode ) - ) - Injekt.get().logException( - Exception( - "Anime Download failed:" + - " ${download.failureReason}" + - " 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() + } Download failed" ) - if (notifi) { - notificationManager.notify(NOTIFICATION_ID, builder.build()) - } + 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:" + + " ${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 + } + builder.setProgress( + 100, percent.coerceAtMost(99), + false + ) + broadcastDownloadProgress( + task.episode, + percent.coerceAtMost(99) + ) + 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 @@ -337,35 +417,24 @@ class AnimeDownloaderService : Service() { } } - @androidx.annotation.OptIn(UnstableApi::class) - suspend fun hasDownloadStarted( - downloadManager: DownloadManager, - task: AnimeDownloadTask, - timeout: Long - ): Boolean { - val startTime = System.currentTimeMillis() - while (System.currentTimeMillis() - startTime < timeout) { - val download = downloadManager.downloadIndex.getDownload(task.video.file.url) - if (download != null) { - return true - } - // Delay between each poll - kotlinx.coroutines.delay(500) - } - return false - } - - @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 +468,25 @@ 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 +497,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 +573,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("/", "")}" } } } @@ -511,7 +595,6 @@ class AnimeDownloaderService : Service() { object AnimeServiceDataSingleton { var video: Video? = null - var sourceMedia: Media? = null var downloadQueue: Queue = ConcurrentLinkedQueue() @Volatile 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..6c21cb86 100644 --- a/app/src/main/java/ani/dantotsu/download/anime/OfflineAnimeFragment.kt +++ b/app/src/main/java/ani/dantotsu/download/anime/OfflineAnimeFragment.kt @@ -4,7 +4,6 @@ package ani.dantotsu.download.anime import android.content.Intent import android.net.Uri import android.os.Bundle -import android.os.Environment import android.text.Editable import android.text.TextWatcher import android.util.TypedValue @@ -25,6 +24,7 @@ import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.core.view.marginBottom import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi import ani.dantotsu.R import ani.dantotsu.bottomBar @@ -33,6 +33,7 @@ import ani.dantotsu.currActivity import ani.dantotsu.currContext import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager +import ani.dantotsu.download.DownloadsManager.Companion.findValidName import ani.dantotsu.initActivity import ani.dantotsu.media.Media import ani.dantotsu.media.MediaDetailsActivity @@ -44,6 +45,7 @@ import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString import ani.dantotsu.util.Logger +import com.anggrayudi.storage.file.openInputStream import com.google.android.material.card.MaterialCardView import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.textfield.TextInputLayout @@ -55,9 +57,13 @@ import eu.kanade.tachiyomi.animesource.model.SEpisode import eu.kanade.tachiyomi.animesource.model.SEpisodeImpl import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapterImpl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.io.File class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { @@ -66,6 +72,7 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { private lateinit var gridView: GridView private lateinit var adapter: OfflineAnimeAdapter private lateinit var total: TextView + private var downloadsJob: Job = Job() override fun onCreateView( inflater: LayoutInflater, @@ -112,10 +119,10 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { }) var style: Int = PrefManager.getVal(PrefName.OfflineView) val layoutList = view.findViewById(R.id.downloadedList) - val layoutcompact = view.findViewById(R.id.downloadedGrid) + val layoutCompact = view.findViewById(R.id.downloadedGrid) var selected = when (style) { 0 -> layoutList - 1 -> layoutcompact + 1 -> layoutCompact else -> layoutList } selected.alpha = 1f @@ -136,7 +143,7 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { grid() } - layoutcompact.setOnClickListener { + layoutCompact.setOnClickListener { selected(it as ImageView) style = 1 PrefManager.setVal(PrefName.OfflineView, style) @@ -156,11 +163,11 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { @OptIn(UnstableApi::class) private fun grid() { gridView.visibility = View.VISIBLE - getDownloads() val fadeIn = AlphaAnimation(0f, 1f) fadeIn.duration = 300 // animations pog gridView.layoutAnimation = LayoutAnimationController(fadeIn) adapter = OfflineAnimeAdapter(requireContext(), downloads, this) + getDownloads() gridView.adapter = adapter gridView.scheduleLayoutAnimation() total.text = if (gridView.count > 0) "Anime (${gridView.count})" else "Empty List" @@ -168,20 +175,22 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { // Get the OfflineAnimeModel that was clicked val item = adapter.getItem(position) as OfflineAnimeModel val media = - downloadManager.animeDownloadedTypes.firstOrNull { it.title == item.title } + downloadManager.animeDownloadedTypes.firstOrNull { it.title == item.title.findValidName() } media?.let { - val mediaModel = getMedia(it) - if (mediaModel == null) { - snackString("Error loading media.json") - return@let + lifecycleScope.launch { + val mediaModel = getMedia(it) + if (mediaModel == null) { + snackString("Error loading media.json") + return@launch + } + MediaDetailsActivity.mediaSingleton = mediaModel + ContextCompat.startActivity( + requireActivity(), + Intent(requireContext(), MediaDetailsActivity::class.java) + .putExtra("download", true), + null + ) } - MediaDetailsActivity.mediaSingleton = mediaModel - ContextCompat.startActivity( - requireActivity(), - Intent(requireContext(), MediaDetailsActivity::class.java) - .putExtra("download", true), - null - ) } ?: run { snackString("no media found") } @@ -204,13 +213,7 @@ 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" } builder.setNegativeButton("No") { _, _ -> // Do nothing @@ -238,7 +241,6 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { gridView.setOnScrollListener(object : AbsListView.OnScrollListener { override fun onScrollStateChanged(view: AbsListView, scrollState: Int) { - // Implement behavior for different scroll states if needed } override fun onScroll( @@ -261,7 +263,6 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { override fun onResume() { super.onResume() getDownloads() - adapter.notifyDataSetChanged() } override fun onPause() { @@ -281,25 +282,39 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { private fun getDownloads() { downloads = listOf() - val animeTitles = downloadManager.animeDownloadedTypes.map { it.title }.distinct() - val newAnimeDownloads = mutableListOf() - for (title in animeTitles) { - val tDownloads = downloadManager.animeDownloadedTypes.filter { it.title == title } - val download = tDownloads.first() - val offlineAnimeModel = loadOfflineAnimeModel(download) - newAnimeDownloads += offlineAnimeModel + if (downloadsJob.isActive) { + downloadsJob.cancel() + } + downloadsJob = Job() + CoroutineScope(Dispatchers.IO + downloadsJob).launch { + val animeTitles = downloadManager.animeDownloadedTypes.map { it.title }.distinct() + val newAnimeDownloads = mutableListOf() + for (title in animeTitles) { + val tDownloads = downloadManager.animeDownloadedTypes.filter { it.title == title } + val download = tDownloads.first() + val offlineAnimeModel = loadOfflineAnimeModel(download) + newAnimeDownloads += offlineAnimeModel + } + downloads = newAnimeDownloads + withContext(Dispatchers.Main) { + adapter.setItems(downloads) + total.text = if (gridView.count > 0) "Anime (${gridView.count})" else "Empty List" + adapter.notifyDataSetChanged() + } } - downloads = newAnimeDownloads } - private fun getMedia(downloadedType: DownloadedType): Media? { - val type = downloadedType.type.asText() - val directory = File( - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/$type/${downloadedType.title}" - ) - //load media.json and convert to media class with gson + /** + * Load media.json file from the directory and convert it to Media class + * @param downloadedType DownloadedType object + * @return Media object + */ + private suspend fun getMedia(downloadedType: DownloadedType): Media? { return try { + val directory = DownloadsManager.getSubDirectory( + context ?: currContext()!!, downloadedType.type, + false, downloadedType.title + ) val gson = GsonBuilder() .registerTypeAdapter(SChapter::class.java, InstanceCreator { SChapterImpl() // Provide an instance of SChapterImpl @@ -311,8 +326,13 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { SEpisodeImpl() // Provide an instance of SEpisodeImpl }) .create() - val media = File(directory, "media.json") - val mediaJson = media.readText() + val media = directory?.findFile("media.json") + ?: return null + val mediaJson = + media.openInputStream(context ?: currContext()!!)?.bufferedReader().use { + it?.readText() + } + ?: return null gson.fromJson(mediaJson, Media::class.java) } catch (e: Exception) { Logger.log("Error loading media.json: ${e.message}") @@ -322,22 +342,26 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { } } - private fun loadOfflineAnimeModel(downloadedType: DownloadedType): OfflineAnimeModel { + /** + * Load OfflineAnimeModel from the directory + * @param downloadedType DownloadedType object + * @return OfflineAnimeModel object + */ + private suspend fun loadOfflineAnimeModel(downloadedType: DownloadedType): OfflineAnimeModel { val type = downloadedType.type.asText() - val directory = File( - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/$type/${downloadedType.title}" - ) - //load media.json and convert to media class with gson try { + val directory = DownloadsManager.getSubDirectory( + context ?: currContext()!!, downloadedType.type, + false, downloadedType.title + ) val mediaModel = getMedia(downloadedType)!! - val cover = File(directory, "cover.jpg") - val coverUri: Uri? = if (cover.exists()) { - Uri.fromFile(cover) + val cover = directory?.findFile("cover.jpg") + val coverUri: Uri? = if (cover?.exists() == true) { + cover.uri } else null - val banner = File(directory, "banner.jpg") - val bannerUri: Uri? = if (banner.exists()) { - Uri.fromFile(banner) + val banner = directory?.findFile("banner.jpg") + val bannerUri: Uri? = if (banner?.exists() == true) { + banner.uri } else null val title = mediaModel.mainName() val score = ((if (mediaModel.userScore == 0) (mediaModel.meanScore 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/manga/OfflineMangaFragment.kt b/app/src/main/java/ani/dantotsu/download/manga/OfflineMangaFragment.kt index 99250edf..1d912887 100644 --- a/app/src/main/java/ani/dantotsu/download/manga/OfflineMangaFragment.kt +++ b/app/src/main/java/ani/dantotsu/download/manga/OfflineMangaFragment.kt @@ -3,7 +3,6 @@ package ani.dantotsu.download.manga import android.content.Intent import android.net.Uri import android.os.Bundle -import android.os.Environment import android.text.Editable import android.text.TextWatcher import android.util.TypedValue @@ -23,6 +22,7 @@ import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.core.view.marginBottom import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import ani.dantotsu.R import ani.dantotsu.bottomBar import ani.dantotsu.connections.crashlytics.CrashlyticsInterface @@ -30,6 +30,7 @@ import ani.dantotsu.currActivity import ani.dantotsu.currContext import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager +import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory import ani.dantotsu.initActivity import ani.dantotsu.media.Media import ani.dantotsu.media.MediaDetailsActivity @@ -41,6 +42,7 @@ import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString import ani.dantotsu.util.Logger +import com.anggrayudi.storage.file.openInputStream import com.google.android.material.card.MaterialCardView import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.textfield.TextInputLayout @@ -48,9 +50,13 @@ import com.google.gson.GsonBuilder import com.google.gson.InstanceCreator import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapterImpl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.io.File class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { @@ -59,6 +65,7 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { private lateinit var gridView: GridView private lateinit var adapter: OfflineMangaAdapter private lateinit var total: TextView + private var downloadsJob: Job = Job() override fun onCreateView( inflater: LayoutInflater, @@ -148,11 +155,11 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { private fun grid() { gridView.visibility = View.VISIBLE - getDownloads() val fadeIn = AlphaAnimation(0f, 1f) fadeIn.duration = 300 // animations pog gridView.layoutAnimation = LayoutAnimationController(fadeIn) adapter = OfflineMangaAdapter(requireContext(), downloads, this) + getDownloads() gridView.adapter = adapter gridView.scheduleLayoutAnimation() total.text = @@ -164,14 +171,15 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { downloadManager.mangaDownloadedTypes.firstOrNull { it.title == item.title } ?: downloadManager.novelDownloadedTypes.firstOrNull { it.title == item.title } media?.let { - - ContextCompat.startActivity( - requireActivity(), - Intent(requireContext(), MediaDetailsActivity::class.java) - .putExtra("media", getMedia(it)) - .putExtra("download", true), - null - ) + lifecycleScope.launch { + ContextCompat.startActivity( + requireActivity(), + Intent(requireContext(), MediaDetailsActivity::class.java) + .putExtra("media", getMedia(it)) + .putExtra("download", true), + null + ) + } } ?: run { snackString("no media found") } @@ -194,9 +202,6 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { builder.setPositiveButton("Yes") { _, _ -> downloadManager.removeMedia(item.title, type) getDownloads() - adapter.setItems(downloads) - total.text = - if (gridView.count > 0) "Manga and Novels (${gridView.count})" else "Empty List" } builder.setNegativeButton("No") { _, _ -> // Do nothing @@ -225,7 +230,6 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { gridView.setOnScrollListener(object : AbsListView.OnScrollListener { override fun onScrollStateChanged(view: AbsListView, scrollState: Int) { - // Implement behavior for different scroll states if needed } override fun onScroll( @@ -248,7 +252,6 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { override fun onResume() { super.onResume() getDownloads() - adapter.notifyDataSetChanged() } override fun onPause() { @@ -268,42 +271,62 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { private fun getDownloads() { downloads = listOf() - val mangaTitles = downloadManager.mangaDownloadedTypes.map { it.title }.distinct() - val newMangaDownloads = mutableListOf() - for (title in mangaTitles) { - val tDownloads = downloadManager.mangaDownloadedTypes.filter { it.title == title } - val download = tDownloads.first() - val offlineMangaModel = loadOfflineMangaModel(download) - newMangaDownloads += offlineMangaModel + if (downloadsJob.isActive) { + downloadsJob.cancel() } - downloads = newMangaDownloads - val novelTitles = downloadManager.novelDownloadedTypes.map { it.title }.distinct() - val newNovelDownloads = mutableListOf() - for (title in novelTitles) { - val tDownloads = downloadManager.novelDownloadedTypes.filter { it.title == title } - val download = tDownloads.first() - val offlineMangaModel = loadOfflineMangaModel(download) - newNovelDownloads += offlineMangaModel + downloads = listOf() + downloadsJob = Job() + CoroutineScope(Dispatchers.IO + downloadsJob).launch { + val mangaTitles = downloadManager.mangaDownloadedTypes.map { it.title }.distinct() + val newMangaDownloads = mutableListOf() + for (title in mangaTitles) { + val tDownloads = downloadManager.mangaDownloadedTypes.filter { it.title == title } + val download = tDownloads.first() + val offlineMangaModel = loadOfflineMangaModel(download) + newMangaDownloads += offlineMangaModel + } + downloads = newMangaDownloads + val novelTitles = downloadManager.novelDownloadedTypes.map { it.title }.distinct() + val newNovelDownloads = mutableListOf() + for (title in novelTitles) { + val tDownloads = downloadManager.novelDownloadedTypes.filter { it.title == title } + val download = tDownloads.first() + val offlineMangaModel = loadOfflineMangaModel(download) + newNovelDownloads += offlineMangaModel + } + downloads += newNovelDownloads + withContext(Dispatchers.Main) { + adapter.setItems(downloads) + total.text = + if (gridView.count > 0) "Manga and Novels (${gridView.count})" else "Empty List" + adapter.notifyDataSetChanged() + } } - downloads += newNovelDownloads } - private fun getMedia(downloadedType: DownloadedType): Media? { - val type = downloadedType.type.asText() - val directory = File( - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/$type/${downloadedType.title}" - ) - //load media.json and convert to media class with gson + /** + * Load media.json file from the directory and convert it to Media class + * @param downloadedType DownloadedType object + * @return Media object + */ + private suspend fun getMedia(downloadedType: DownloadedType): Media? { return try { + val directory = getSubDirectory( + context ?: currContext()!!, downloadedType.type, + false, downloadedType.title + ) val gson = GsonBuilder() .registerTypeAdapter(SChapter::class.java, InstanceCreator { SChapterImpl() // Provide an instance of SChapterImpl }) .create() - val media = File(directory, "media.json") - val mediaJson = media.readText() + val media = directory?.findFile("media.json") + ?: return null + val mediaJson = + media.openInputStream(context ?: currContext()!!)?.bufferedReader().use { + it?.readText() + } gson.fromJson(mediaJson, Media::class.java) } catch (e: Exception) { Logger.log("Error loading media.json: ${e.message}") @@ -313,22 +336,22 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { } } - private fun loadOfflineMangaModel(downloadedType: DownloadedType): OfflineMangaModel { + private suspend fun loadOfflineMangaModel(downloadedType: DownloadedType): OfflineMangaModel { val type = downloadedType.type.asText() - val directory = File( - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/$type/${downloadedType.title}" - ) //load media.json and convert to media class with gson try { + val directory = getSubDirectory( + context ?: currContext()!!, downloadedType.type, + false, downloadedType.title + ) val mediaModel = getMedia(downloadedType)!! - val cover = File(directory, "cover.jpg") - val coverUri: Uri? = if (cover.exists()) { - Uri.fromFile(cover) + val cover = directory?.findFile("cover.jpg") + val coverUri: Uri? = if (cover?.exists() == true) { + cover.uri } else null - val banner = File(directory, "banner.jpg") - val bannerUri: Uri? = if (banner.exists()) { - Uri.fromFile(banner) + val banner = directory?.findFile("banner.jpg") + val bannerUri: Uri? = if (banner?.exists() == true) { + banner.uri } else null val title = mediaModel.mainName() val score = ((if (mediaModel.userScore == 0) (mediaModel.meanScore @@ -336,14 +359,14 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { val isOngoing = mediaModel.status == currActivity()!!.getString(R.string.status_releasing) val isUserScored = mediaModel.userScore != 0 - val readchapter = (mediaModel.userProgress ?: "~").toString() - val totalchapter = "${mediaModel.manga?.totalChapters ?: "??"}" + val readChapter = (mediaModel.userProgress ?: "~").toString() + val totalChapter = "${mediaModel.manga?.totalChapters ?: "??"}" val chapters = " Chapters" return OfflineMangaModel( title, score, - totalchapter, - readchapter, + totalChapter, + readChapter, type, chapters, isOngoing, diff --git a/app/src/main/java/ani/dantotsu/download/novel/NovelDownloaderService.kt b/app/src/main/java/ani/dantotsu/download/novel/NovelDownloaderService.kt index fc432313..123a54c1 100644 --- a/app/src/main/java/ani/dantotsu/download/novel/NovelDownloaderService.kt +++ b/app/src/main/java/ani/dantotsu/download/novel/NovelDownloaderService.kt @@ -16,15 +16,19 @@ 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.novel.NovelReadFragment import ani.dantotsu.snackString import ani.dantotsu.util.Logger +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 @@ -250,24 +254,25 @@ class NovelDownloaderService : Service() { if (!response.isSuccessful) { throw IOException("Failed to download file: ${response.message}") } + val directory = getSubDirectory( + this@NovelDownloaderService, + MediaType.NOVEL, + false, + task.title, + task.chapter + ) ?: throw Exception("Directory not found") + directory.findFile("0.epub")?.forceDelete(this@NovelDownloaderService) - val file = File( - this@NovelDownloaderService.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Novel/${task.title}/${task.chapter}/0.epub" - ) - - // Create directories if they don't exist - file.parentFile?.takeIf { !it.exists() }?.mkdirs() - - // Overwrite existing file - if (file.exists()) file.delete() + val file = directory.createFile("application/epub+zip", "0.epub") + ?: throw Exception("File not created") //download cover task.coverUrl?.let { file.parentFile?.let { it1 -> downloadImage(it, it1, "cover.jpg") } } + val outputStream = this@NovelDownloaderService.contentResolver.openOutputStream(file.uri) ?: throw Exception("Could not open OutputStream") - val sink = file.sink().buffer() + val sink = outputStream.sink().buffer() val responseBody = response.body val totalBytes = responseBody.contentLength() var downloadedBytes = 0L @@ -352,13 +357,16 @@ class NovelDownloaderService : Service() { @OptIn(DelicateCoroutinesApi::class) private fun saveMediaInfo(task: DownloadTask) { launchIO { - val directory = File( - getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Novel/${task.title}" - ) - if (!directory.exists()) directory.mkdirs() - - val file = File(directory, "media.json") + val directory = + DownloadsManager.getSubDirectory( + this@NovelDownloaderService, + MediaType.NOVEL, + false, + task.title + ) ?: throw Exception("Directory not found") + directory.findFile("media.json")?.forceDelete(this@NovelDownloaderService) + 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 @@ -372,33 +380,47 @@ class NovelDownloaderService : Service() { val jsonString = gson.toJson(media) withContext(Dispatchers.Main) { - file.writeText(jsonString) + try { + file.openOutputStream(this@NovelDownloaderService, 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@NovelDownloaderService, + "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") + Logger.log("Downloading url $url") try { connection = URL(url).openConnection() as HttpURLConnection connection.connect() 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@NovelDownloaderService) + val file = + directory.createFile("image/jpeg", name) ?: throw Exception("File not created") + file.openOutputStream(this@NovelDownloaderService, 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..4f93cdde 100644 --- a/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt +++ b/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt @@ -13,6 +13,7 @@ import android.view.View import android.view.ViewGroup import android.view.animation.AccelerateDecelerateInterpolator import android.widget.ImageView +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources @@ -53,6 +54,7 @@ import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString import ani.dantotsu.statusBarHeight import ani.dantotsu.themes.ThemeManager +import ani.dantotsu.util.LauncherWrapper import com.flaviofaria.kenburnsview.RandomTransitionGenerator import com.google.android.material.appbar.AppBarLayout import kotlinx.coroutines.CoroutineScope @@ -66,7 +68,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 +94,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 +581,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 4fde1ed8..7b91d6f7 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 @@ -176,6 +177,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( @@ -186,6 +191,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..c57f943f 100644 --- a/app/src/main/java/ani/dantotsu/media/novel/NovelReadFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/novel/NovelReadFragment.kt @@ -20,6 +20,7 @@ import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager +import ani.dantotsu.currContext import ani.dantotsu.databinding.FragmentAnimeWatchBinding import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager @@ -94,23 +95,23 @@ class NovelReadFragment : Fragment(), ) ) ) { - val file = File( - context?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "${DownloadsManager.novelLocation}/${media.mainName()}/${novel.name}/0.epub" - ) - if (!file.exists()) return false - val fileUri = FileProvider.getUriForFile( - requireContext(), - "${requireContext().packageName}.provider", - file - ) - val intent = Intent(context, NovelReaderActivity::class.java).apply { - action = Intent.ACTION_VIEW - setDataAndType(fileUri, "application/epub+zip") - flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + try { + val directory = + DownloadsManager.getSubDirectory(context?:currContext()!!, MediaType.NOVEL, false, novel.name) + val file = directory?.findFile(novel.name) + if (file?.exists() == false) return false + val fileUri = file?.uri ?: return false + val intent = Intent(context, NovelReaderActivity::class.java).apply { + action = Intent.ACTION_VIEW + setDataAndType(fileUri, "application/epub+zip") + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + } + startActivity(intent) + return true + } catch (e: Exception) { + Logger.log(e) + return false } - startActivity(intent) - return true } else { return false } @@ -135,7 +136,7 @@ class NovelReadFragment : Fragment(), novel.name, MediaType.NOVEL ) - ) + ) {} } private val downloadStatusReceiver = object : BroadcastReceiver() { diff --git a/app/src/main/java/ani/dantotsu/notifications/comment/CommentNotificationTask.kt b/app/src/main/java/ani/dantotsu/notifications/comment/CommentNotificationTask.kt index b8cf6f7f..f6839558 100644 --- a/app/src/main/java/ani/dantotsu/notifications/comment/CommentNotificationTask.kt +++ b/app/src/main/java/ani/dantotsu/notifications/comment/CommentNotificationTask.kt @@ -46,11 +46,11 @@ class CommentNotificationTask : Task { ) notifications = - notifications?.filter { it.type != 3 || it.notificationId > recentGlobal } + notifications?.filter { !it.type.isGlobal() || it.notificationId > recentGlobal } ?.toMutableList() val newRecentGlobal = - notifications?.filter { it.type == 3 }?.maxOfOrNull { it.notificationId } + notifications?.filter { it.type.isGlobal() }?.maxOfOrNull { it.notificationId } if (newRecentGlobal != null) { PrefManager.setVal(PrefName.RecentGlobalNotification, newRecentGlobal) } @@ -313,4 +313,6 @@ class CommentNotificationTask : Task { null } } + + private fun Int?.isGlobal() = this == 3 || this == 420 } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/others/Download.kt b/app/src/main/java/ani/dantotsu/others/Download.kt index 3a266af4..b80c941d 100644 --- a/app/src/main/java/ani/dantotsu/others/Download.kt +++ b/app/src/main/java/ani/dantotsu/others/Download.kt @@ -36,16 +36,8 @@ object Download { } private fun getDownloadDir(context: Context): File { - val direct: File - if (PrefManager.getVal(PrefName.SdDl)) { - val arrayOfFiles = ContextCompat.getExternalFilesDirs(context, null) - val parentDirectory = arrayOfFiles[1].toString() - direct = File(parentDirectory) - if (!direct.exists()) direct.mkdirs() - } else { - direct = File("storage/emulated/0/${Environment.DIRECTORY_DOWNLOADS}/Dantotsu/") - if (!direct.exists()) direct.mkdirs() - } + val direct = File("storage/emulated/0/${Environment.DIRECTORY_DOWNLOADS}/Dantotsu/") + if (!direct.exists()) direct.mkdirs() return direct } @@ -96,52 +88,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/parsers/OfflineNovelParser.kt b/app/src/main/java/ani/dantotsu/parsers/OfflineNovelParser.kt index 11009aed..2ae88200 100644 --- a/app/src/main/java/ani/dantotsu/parsers/OfflineNovelParser.kt +++ b/app/src/main/java/ani/dantotsu/parsers/OfflineNovelParser.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 me.xdrop.fuzzywuzzy.FuzzySearch import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -11,6 +14,7 @@ import java.io.File class OfflineNovelParser : NovelParser() { private val downloadManager = Injekt.get() + private val context = Injekt.get() override val hostUrl: String = "Offline" override val name: String = "Offline" @@ -21,19 +25,16 @@ class OfflineNovelParser : NovelParser() { override suspend fun loadBook(link: String, extra: Map?): Book { //link should be a directory - val directory = File( - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Novel/$link" - ) + val directory = getSubDirectory(context, MediaType.NOVEL, false, link) val chapters = mutableListOf() - if (directory.exists()) { - directory.listFiles()?.forEach { + if (directory?.exists() == true) { + directory.listFiles().forEach { if (it.isDirectory) { val chapter = Book( - it.name, - it.absolutePath + "/cover.jpg", + it.name?:"Unknown", + it.uri.toString(), null, - listOf(it.absolutePath + "/0.epub") + listOf(it.uri.toString()) ) chapters.add(chapter) } @@ -60,20 +61,16 @@ class OfflineNovelParser : NovelParser() { val returnList: MutableList = mutableListOf() for (title in returnTitles) { //need to search the subdirectories for the ShowResponses - val directory = File( - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Novel/$title" - ) + val directory = getSubDirectory(context, MediaType.NOVEL, false, title) val names = mutableListOf() - if (directory.exists()) { - directory.listFiles()?.forEach { + if (directory?.exists() == true) { + directory.listFiles().forEach { if (it.isDirectory) { - names.add(it.name) + names.add(it.name?: "Unknown") } } } - val cover = - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.absolutePath + "/Dantotsu/Novel/$title/cover.jpg" + val cover = directory?.findFile("cover.jpg")?.uri.toString() names.forEach { returnList.add(ShowResponse(it, it, cover)) } diff --git a/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt b/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt index de9e9841..6f2d7a08 100644 --- a/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt @@ -5,6 +5,7 @@ import android.app.AlertDialog import android.content.Context import android.content.Intent import android.graphics.drawable.Animatable +import android.net.Uri import android.os.Build import android.os.Build.BRAND import android.os.Build.DEVICE @@ -52,8 +53,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,7 +81,10 @@ import ani.dantotsu.startMainActivity import ani.dantotsu.statusBarHeight import ani.dantotsu.themes.ThemeManager import ani.dantotsu.toast +import ani.dantotsu.util.LauncherWrapper import ani.dantotsu.util.Logger +import ani.dantotsu.util.StoragePermissions.Companion.accessAlertDialog +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 @@ -91,7 +93,9 @@ import eltos.simpledialogfragment.color.SimpleColorDialog import eu.kanade.domain.base.BasePreferences import io.noties.markwon.Markwon import io.noties.markwon.SoftBreakAddsNewLinePlugin +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import uy.kohesive.injekt.Injekt @@ -104,6 +108,7 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene override fun handleOnBackPressed() = startMainActivity(this@SettingsActivity) } lateinit var binding: ActivitySettingsBinding + lateinit var launcher: LauncherWrapper private lateinit var bindingAccounts: ActivitySettingsAccountsBinding private lateinit var bindingTheme: ActivitySettingsThemeBinding private lateinit var bindingExtensions: ActivitySettingsExtensionsBinding @@ -115,6 +120,7 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene private val extensionInstaller = Injekt.get().extensionInstaller() private var cursedCounter = 0 + @kotlin.OptIn(DelicateCoroutinesApi::class) @OptIn(UnstableApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -166,6 +172,8 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene } } } + val contract = ActivityResultContracts.OpenDocumentTree() + launcher = LauncherWrapper(this, contract) binding.settingsVersion.text = getString(R.string.version_current, BuildConfig.VERSION_NAME) binding.settingsVersion.setOnLongClickListener { @@ -457,11 +465,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, _ -> @@ -724,20 +727,6 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene restartApp(binding.root) } - settingsDownloadInSd.isChecked = PrefManager.getVal(PrefName.SdDl) - settingsDownloadInSd.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - val arrayOfFiles = ContextCompat.getExternalFilesDirs(this@SettingsActivity, null) - if (arrayOfFiles.size > 1 && arrayOfFiles[1] != null) { - PrefManager.setVal(PrefName.SdDl, true) - } else { - settingsDownloadInSd.isChecked = false - PrefManager.setVal(PrefName.SdDl, true) - snackString(getString(R.string.noSdFound)) - } - } else PrefManager.setVal(PrefName.SdDl, true) - } - settingsContinueMedia.isChecked = PrefManager.getVal(PrefName.ContinueMedia) settingsContinueMedia.setOnCheckedChangeListener { _, isChecked -> PrefManager.setVal(PrefName.ContinueMedia, isChecked) @@ -757,6 +746,44 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene PrefManager.setVal(PrefName.AdultOnly, isChecked) restartApp(binding.root) } + + settingsDownloadLocation.setOnClickListener { + val dialog = AlertDialog.Builder(this@SettingsActivity, R.style.MyPopup) + .setTitle(R.string.change_download_location) + .setMessage(R.string.download_location_msg) + .setPositiveButton(R.string.ok) { dialog, _ -> + val oldUri = PrefManager.getVal(PrefName.DownloadsDir) + launcher.registerForCallback { success -> + if (success) { + toast(getString(R.string.please_wait)) + val newUri = PrefManager.getVal(PrefName.DownloadsDir) + GlobalScope.launch(Dispatchers.IO) { + Injekt.get().moveDownloadsDir( + this@SettingsActivity, + Uri.parse(oldUri), Uri.parse(newUri) + ) { finished, message -> + if (finished) { + toast(getString(R.string.success)) + } else { + toast(message) + } + } + } + } else { + toast(getString(R.string.error)) + } + } + launcher.launch() + dialog.dismiss() + } + .setNeutralButton(R.string.cancel) { dialog, _ -> + dialog.dismiss() + } + .create() + dialog.window?.setDimAmount(0.8f) + dialog.show() + } + var previousStart: View = when (PrefManager.getVal(PrefName.DefaultStartUpTab)) { 0 -> uiSettingsAnime 1 -> uiSettingsHome 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..414a45cb 100644 --- a/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt +++ b/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt @@ -13,7 +13,6 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files OfflineView(Pref(Location.General, Int::class, 0)), DownloadManager(Pref(Location.General, Int::class, 0)), NSFWExtension(Pref(Location.General, Boolean::class, true)), - SdDl(Pref(Location.General, Boolean::class, false)), ContinueMedia(Pref(Location.General, Boolean::class, true)), SearchSources(Pref(Location.General, Boolean::class, true)), RecentlyListOnly(Pref(Location.General, Boolean::class, false)), @@ -182,6 +181,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..485b0dd9 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/util/StoragePermissions.kt @@ -0,0 +1,132 @@ +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, + force: Boolean = false, + complete: (Boolean) -> Unit + ) { + if ((PrefManager.getVal(PrefName.DownloadsDir).isNotEmpty() || hasDirAccess(this)) && !force) { + complete(true) + 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/java/eu/kanade/tachiyomi/animesource/online/AnimeHttpSource.kt b/app/src/main/java/eu/kanade/tachiyomi/animesource/online/AnimeHttpSource.kt index 197be008..51f593a9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/animesource/online/AnimeHttpSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/animesource/online/AnimeHttpSource.kt @@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.animesource.model.SEpisode import eu.kanade.tachiyomi.animesource.model.Video import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.NetworkHelper.Companion.defaultUserAgentProvider import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.newCachelessCallWithProgress import okhttp3.Headers @@ -69,7 +70,7 @@ abstract class AnimeHttpSource : AnimeCatalogueSource { * Headers builder for requests. Implementations can override this method for custom headers. */ protected open fun headersBuilder() = Headers.Builder().apply { - add("User-Agent", network.defaultUserAgentProvider()) + add("User-Agent", defaultUserAgentProvider()) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt index 8ae4a8fd..f7c08d19 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -89,5 +89,7 @@ class NetworkHelper( responseParser = Mapper ) - fun defaultUserAgentProvider() = PrefManager.getVal(PrefName.DefaultUserAgent) + companion object { + fun defaultUserAgentProvider() = PrefManager.getVal(PrefName.DefaultUserAgent) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt index 16fd9935..613b58ba 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.source.online import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.NetworkHelper.Companion.defaultUserAgentProvider import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.newCachelessCallWithProgress @@ -69,7 +70,7 @@ abstract class HttpSource : CatalogueSource { * Headers builder for requests. Implementations can override this method for custom headers. */ protected open fun headersBuilder() = Headers.Builder().apply { - add("User-Agent", network.defaultUserAgentProvider()) + add("User-Agent", defaultUserAgentProvider()) } /** diff --git a/app/src/main/res/layout/activity_settings_common.xml b/app/src/main/res/layout/activity_settings_common.xml index 0171808d..9916e78a 100644 --- a/app/src/main/res/layout/activity_settings_common.xml +++ b/app/src/main/res/layout/activity_settings_common.xml @@ -191,27 +191,6 @@ android:layout_marginBottom="16dp" android:background="?android:attr/listDivider" /> - - - + +