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
This commit is contained in:
rebel onion 2024-04-04 04:03:45 -05:00 committed by GitHub
parent 75e90541c9
commit 720b40afa7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1162 additions and 1018 deletions

View file

@ -21,6 +21,14 @@ android {
versionName "3.0.0" versionName "3.0.0"
versionCode 300000000 versionCode 300000000
signingConfig signingConfigs.debug signingConfig signingConfigs.debug
splits {
abi {
enable true
reset()
include 'armeabi', 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
universalApk true
}
}
} }
flavorDimensions += "store" flavorDimensions += "store"
@ -99,6 +107,7 @@ dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3' implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3'
implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.webkit:webkit:1.10.0' implementation 'androidx.webkit:webkit:1.10.0'
implementation "com.anggrayudi:storage:1.5.5"
// Glide // Glide
ext.glide_version = '4.16.0' ext.glide_version = '4.16.0'
@ -149,6 +158,9 @@ dependencies {
// String Matching // String Matching
implementation 'me.xdrop:fuzzywuzzy:1.4.0' 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 // Aniyomi
implementation 'io.reactivex:rxjava:1.3.8' implementation 'io.reactivex:rxjava:1.3.8'
implementation 'io.reactivex:rxandroid:1.2.1' implementation 'io.reactivex:rxandroid:1.2.1'

View file

@ -370,16 +370,6 @@
android:name=".widgets.upcoming.UpcomingRemoteViewsService" android:name=".widgets.upcoming.UpcomingRemoteViewsService"
android:exported="true" android:exported="true"
android:permission="android.permission.BIND_REMOTEVIEWS" /> android:permission="android.permission.BIND_REMOTEVIEWS" />
<service
android:name=".download.video.ExoplayerDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync">
<intent-filter>
<action android:name="androidx.media3.exoplayer.downloadService.action.RESTART" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</service>
<service <service
android:name="eu.kanade.tachiyomi.extension.util.ExtensionInstallService" android:name="eu.kanade.tachiyomi.extension.util.ExtensionInstallService"
android:exported="false" android:exported="false"

View file

@ -620,11 +620,16 @@ fun ImageView.loadImage(file: FileUrl?, size: Int = 0) {
file?.url = PrefManager.getVal<String>(PrefName.ImageUrl).ifEmpty { file?.url ?: "" } file?.url = PrefManager.getVal<String>(PrefName.ImageUrl).ifEmpty { file?.url ?: "" }
if (file?.url?.isNotEmpty() == true) { if (file?.url?.isNotEmpty() == true) {
tryWith { tryWith {
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 } val glideUrl = GlideUrl(file.url) { file.headers }
Glide.with(this.context).load(glideUrl).transition(withCrossFade()).override(size) Glide.with(this.context).load(glideUrl).transition(withCrossFade()).override(size)
.into(this) .into(this)
} }
} }
}
} }
fun ImageView.loadLocalImage(file: File?, size: Int = 0) { fun ImageView.loadLocalImage(file: File?, size: Int = 0) {
@ -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) { fun shareImage(title: String, bitmap: Bitmap, context: Context) {
val contentUri = FileProvider.getUriForFile( val contentUri = FileProvider.getUriForFile(

View file

@ -3,6 +3,7 @@ package ani.dantotsu
import android.animation.ObjectAnimator import android.animation.ObjectAnimator
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.AlertDialog import android.app.AlertDialog
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
@ -15,12 +16,11 @@ import android.os.Looper
import android.provider.Settings import android.provider.Settings
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.View.OnClickListener
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.AnticipateInterpolator import android.view.animation.AnticipateInterpolator
import android.widget.TextView import android.widget.TextView
import android.widget.Toast
import androidx.activity.addCallback import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity 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 index = Helper.downloadManager(this@MainActivity).downloadIndex
val downloadCursor = index.getDownloads() val downloadCursor = index.getDownloads()
while (downloadCursor.moveToNext()) { while (downloadCursor.moveToNext()) {
@ -458,7 +458,7 @@ class MainActivity : AppCompatActivity() {
Helper.downloadManager(this@MainActivity).removeDownload(download.request.id) Helper.downloadManager(this@MainActivity).removeDownload(download.request.id)
} }
} }
} }*/ //TODO: remove this
} }
override fun onRestart() { override fun onRestart() {

View file

@ -9,6 +9,7 @@ import com.lagradost.nicehttp.Requests
import com.lagradost.nicehttp.ResponseParser import com.lagradost.nicehttp.ResponseParser
import com.lagradost.nicehttp.addGenericDns import com.lagradost.nicehttp.addGenericDns
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.NetworkHelper.Companion.defaultUserAgentProvider
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -40,7 +41,7 @@ fun initializeNetwork() {
defaultHeaders = mapOf( defaultHeaders = mapOf(
"User-Agent" to "User-Agent" to
Injekt.get<NetworkHelper>().defaultUserAgentProvider() defaultUserAgentProvider()
.format(Build.VERSION.RELEASE, Build.MODEL) .format(Build.VERSION.RELEASE, Build.MODEL)
) )

View file

@ -1,14 +1,25 @@
package ani.dantotsu.download package ani.dantotsu.download
import android.content.Context import android.content.Context
import android.os.Environment import android.net.Uri
import android.widget.Toast import androidx.documentfile.provider.DocumentFile
import ani.dantotsu.download.DownloadsManager.Companion.findValidName
import ani.dantotsu.media.MediaType import ani.dantotsu.media.MediaType
import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName 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.Gson
import com.google.gson.reflect.TypeToken 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 import java.io.Serializable
class DownloadsManager(private val context: Context) { class DownloadsManager(private val context: Context) {
@ -42,27 +53,29 @@ class DownloadsManager(private val context: Context) {
saveDownloads() saveDownloads()
} }
fun removeDownload(downloadedType: DownloadedType) { fun removeDownload(downloadedType: DownloadedType, onFinished: () -> Unit) {
downloadsList.remove(downloadedType) downloadsList.remove(downloadedType)
CoroutineScope(Dispatchers.IO).launch {
removeDirectory(downloadedType) removeDirectory(downloadedType)
withContext(Dispatchers.Main) {
onFinished()
}
}
saveDownloads() saveDownloads()
} }
fun removeMedia(title: String, type: MediaType) { fun removeMedia(title: String, type: MediaType) {
val subDirectory = type.asText() val baseDirectory = getBaseDirectory(context, type)
val directory = File( val directory = baseDirectory?.findFolder(title)
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), if (directory?.exists() == true) {
"Dantotsu/$subDirectory/$title" val deleted = directory.deleteRecursively(context, false)
)
if (directory.exists()) {
val deleted = directory.deleteRecursively()
if (deleted) { if (deleted) {
Toast.makeText(context, "Successfully deleted", Toast.LENGTH_SHORT).show() snackString("Successfully deleted")
} else { } else {
Toast.makeText(context, "Failed to delete directory", Toast.LENGTH_SHORT).show() snackString("Failed to delete directory")
} }
} else { } else {
Toast.makeText(context, "Directory does not exist", Toast.LENGTH_SHORT).show() snackString("Directory does not exist")
cleanDownloads() cleanDownloads()
} }
when (type) { when (type) {
@ -89,23 +102,17 @@ class DownloadsManager(private val context: Context) {
private fun cleanDownload(type: MediaType) { private fun cleanDownload(type: MediaType) {
// remove all folders that are not in the downloads list // remove all folders that are not in the downloads list
val subDirectory = type.asText() val directory = getBaseDirectory(context, type)
val directory = File(
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"Dantotsu/$subDirectory"
)
val downloadsSubLists = when (type) { val downloadsSubLists = when (type) {
MediaType.MANGA -> mangaDownloadedTypes MediaType.MANGA -> mangaDownloadedTypes
MediaType.ANIME -> animeDownloadedTypes MediaType.ANIME -> animeDownloadedTypes
else -> novelDownloadedTypes else -> novelDownloadedTypes
} }
if (directory.exists()) { if (directory?.exists() == true && directory.isDirectory) {
val files = directory.listFiles() val files = directory.listFiles()
if (files != null) {
for (file in files) { for (file in files) {
if (!downloadsSubLists.any { it.title == file.name }) { if (!downloadsSubLists.any { it.title == file.name }) {
file.deleteRecursively() file.deleteRecursively(context, false)
}
} }
} }
} }
@ -113,27 +120,57 @@ class DownloadsManager(private val context: Context) {
val iterator = downloadsList.iterator() val iterator = downloadsList.iterator()
while (iterator.hasNext()) { while (iterator.hasNext()) {
val download = iterator.next() val download = iterator.next()
val downloadDir = File(directory, download.title) val downloadDir = directory?.findFolder(download.title)
if ((!downloadDir.exists() && download.type == type) || download.title.isBlank()) { if ((downloadDir?.exists() == false && download.type == type) || download.title.isBlank()) {
iterator.remove() iterator.remove()
} }
} }
} }
fun saveDownloadsListToJSONFileInDownloadsFolder(downloadsList: List<DownloadedType>) //for debugging fun moveDownloadsDir(context: Context, oldUri: Uri, newUri: Uri, finished: (Boolean, String) -> Unit) {
{ try {
val jsonString = gson.toJson(downloadsList) if (oldUri == newUri) {
val file = File( finished(false, "Source and destination are the same")
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), return
"Dantotsu/downloads.json"
)
if (file.parentFile?.exists() == false) {
file.parentFile?.mkdirs()
} }
if (!file.exists()) { CoroutineScope(Dispatchers.IO).launch {
file.createNewFile()
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
} }
file.writeText(jsonString)
} }
fun queryDownload(downloadedType: DownloadedType): Boolean { fun queryDownload(downloadedType: DownloadedType): Boolean {
@ -149,98 +186,35 @@ class DownloadsManager(private val context: Context) {
} }
private fun removeDirectory(downloadedType: DownloadedType) { private fun removeDirectory(downloadedType: DownloadedType) {
val directory = when (downloadedType.type) { val baseDirectory = getBaseDirectory(context, downloadedType.type)
MediaType.MANGA -> { val directory =
File( baseDirectory?.findFolder(downloadedType.title)?.findFolder(downloadedType.chapter)
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}"
)
}
}
// Check if the directory exists and delete it recursively // Check if the directory exists and delete it recursively
if (directory.exists()) { if (directory?.exists() == true) {
val deleted = directory.deleteRecursively() val deleted = directory.deleteRecursively(context, false)
if (deleted) { 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()
}
} else {
Toast.makeText(context, "Directory does not exist", Toast.LENGTH_SHORT).show()
}
}
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 { } else {
Toast.makeText(context, "Failed to copy directory", Toast.LENGTH_SHORT).show() snackString("Failed to delete directory")
} }
} else { } else {
Toast.makeText(context, "Directory does not exist", Toast.LENGTH_SHORT).show() snackString("Directory does not exist")
} }
} }
fun purgeDownloads(type: MediaType) { fun purgeDownloads(type: MediaType) {
val directory = when (type) { val directory = getBaseDirectory(context, type)
MediaType.MANGA -> { if (directory?.exists() == true) {
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Manga") val deleted = directory.deleteRecursively(context, false)
}
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()
if (deleted) { if (deleted) {
Toast.makeText(context, "Successfully deleted", Toast.LENGTH_SHORT).show() snackString("Successfully deleted")
} else { } else {
Toast.makeText(context, "Failed to delete directory", Toast.LENGTH_SHORT).show() snackString("Failed to delete directory")
} }
} else { } else {
Toast.makeText(context, "Directory does not exist", Toast.LENGTH_SHORT).show() snackString("Directory does not exist")
} }
downloadsList.removeAll { it.type == type } downloadsList.removeAll { it.type == type }
@ -248,59 +222,95 @@ class DownloadsManager(private val context: Context) {
} }
companion object { companion object {
const val novelLocation = "Dantotsu/Novel" private const val BASE_LOCATION = "Dantotsu"
const val mangaLocation = "Dantotsu/Manga" private const val MANGA_SUB_LOCATION = "Manga"
const val animeLocation = "Dantotsu/Anime" private const val ANIME_SUB_LOCATION = "Anime"
private const val NOVEL_SUB_LOCATION = "Novel"
private const val RESERVED_CHARS = "|\\?*<\":>+[]/'"
fun getDirectory( fun String?.findValidName(): String {
context: Context, return this?.filterNot { RESERVED_CHARS.contains(it) } ?: ""
type: MediaType, }
title: String,
chapter: String? = null /**
): File { * 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<String>(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) { return when (type) {
MediaType.MANGA -> { MediaType.MANGA -> {
if (chapter != null) { base.findOrCreateFolder(MANGA_SUB_LOCATION, false)
File(
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"$mangaLocation/$title/$chapter"
)
} else {
File(
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"$mangaLocation/$title"
)
}
} }
MediaType.ANIME -> { MediaType.ANIME -> {
if (chapter != null) { base.findOrCreateFolder(ANIME_SUB_LOCATION, false)
File(
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"$animeLocation/$title/$chapter"
)
} else {
File(
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"$animeLocation/$title"
)
}
} }
else -> { else -> {
if (chapter != null) { base.findOrCreateFolder(NOVEL_SUB_LOCATION, false)
File( }
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), }
"$novelLocation/$title/$chapter" }
)
/**
* 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 { } else {
File( baseDirectory.findOrCreateFolder(title, overwrite)
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"$novelLocation/$title"
)
} }
} }
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()
}

View file

@ -9,24 +9,21 @@ import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import android.os.Environment
import android.os.IBinder import android.os.IBinder
import android.widget.Toast import android.widget.Toast
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile
import androidx.media3.common.util.UnstableApi 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.FileUrl
import ani.dantotsu.R import ani.dantotsu.R
import ani.dantotsu.connections.crashlytics.CrashlyticsInterface import ani.dantotsu.connections.crashlytics.CrashlyticsInterface
import ani.dantotsu.currActivity
import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadedType
import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.download.video.ExoplayerDownloadService import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory
import ani.dantotsu.download.video.Helper import ani.dantotsu.download.anime.AnimeDownloaderService.AnimeDownloadTask.Companion.getTaskName
import ani.dantotsu.media.Media import ani.dantotsu.media.Media
import ani.dantotsu.media.MediaType import ani.dantotsu.media.MediaType
import ani.dantotsu.media.SubtitleDownloader import ani.dantotsu.media.SubtitleDownloader
@ -36,6 +33,12 @@ import ani.dantotsu.parsers.Video
import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.snackString import ani.dantotsu.snackString
import ani.dantotsu.util.Logger 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.GsonBuilder
import com.google.gson.InstanceCreator import com.google.gson.InstanceCreator
import eu.kanade.tachiyomi.animesource.model.SAnime 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.SChapter
import eu.kanade.tachiyomi.source.model.SChapterImpl import eu.kanade.tachiyomi.source.model.SChapterImpl
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -56,13 +58,12 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.util.Queue import java.util.Queue
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
class AnimeDownloaderService : Service() { class AnimeDownloaderService : Service() {
private lateinit var notificationManager: NotificationManagerCompat private lateinit var notificationManager: NotificationManagerCompat
@ -88,6 +89,7 @@ class AnimeDownloaderService : Service() {
setSmallIcon(R.drawable.ic_download_24) setSmallIcon(R.drawable.ic_download_24)
priority = NotificationCompat.PRIORITY_DEFAULT priority = NotificationCompat.PRIORITY_DEFAULT
setOnlyAlertOnce(true) setOnlyAlertOnce(true)
setProgress(100, 0, false)
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground( startForeground(
@ -156,27 +158,14 @@ class AnimeDownloaderService : Service() {
@UnstableApi @UnstableApi
fun cancelDownload(taskName: String) { fun cancelDownload(taskName: String) {
val url = val sessionIds =
AnimeServiceDataSingleton.downloadQueue.find { it.getTaskName() == taskName }?.video?.file?.url AnimeServiceDataSingleton.downloadQueue.filter { it.getTaskName() == taskName }
?: currentTasks.find { it.getTaskName() == taskName }?.video?.file?.url ?: "" .map { it.sessionId }.toMutableList()
if (url.isEmpty()) { sessionIds.addAll(currentTasks.filter { it.getTaskName() == taskName }.map { it.sessionId })
snackString("Failed to cancel download") sessionIds.forEach {
return FFmpegKit.cancel(it)
} }
currentTasks.removeAll { it.getTaskName() == taskName } 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 { CoroutineScope(Dispatchers.Default).launch {
mutex.withLock { mutex.withLock {
downloadJobs[taskName]?.cancel() downloadJobs[taskName]?.cancel()
@ -209,7 +198,7 @@ class AnimeDownloaderService : Service() {
@androidx.annotation.OptIn(UnstableApi::class) @androidx.annotation.OptIn(UnstableApi::class)
suspend fun download(task: AnimeDownloadTask) { suspend fun download(task: AnimeDownloadTask) {
try { try {
val downloadManager = Helper.downloadManager(this@AnimeDownloaderService) //val downloadManager = Helper.downloadManager(this@AnimeDownloaderService)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val notifi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val notifi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
@ -220,18 +209,80 @@ class AnimeDownloaderService : Service() {
true true
} }
builder.setContentText("Downloading ${task.title} - ${task.episode}") builder.setContentText("Downloading ${getTaskName(task.title, task.episode)}")
if (notifi) { if (notifi) {
notificationManager.notify(NOTIFICATION_ID, builder.build()) notificationManager.notify(NOTIFICATION_ID, builder.build())
} }
currActivity()?.let { val outputDir = getSubDirectory(
Helper.downloadVideo( this@AnimeDownloaderService,
it, MediaType.ANIME,
task.video, false,
task.subtitle 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) saveMediaInfo(task)
task.subtitle?.let { task.subtitle?.let {
@ -245,40 +296,33 @@ 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 // periodically check if the download is complete
while (downloadManager.downloadIndex.getDownload(task.video.file.url) != null) { while (ffTask.state != SessionState.COMPLETED) {
val download = downloadManager.downloadIndex.getDownload(task.video.file.url) if (ffTask.state == SessionState.FAILED) {
if (download != null) {
if (download.state == androidx.media3.exoplayer.offline.Download.STATE_FAILED) {
Logger.log("Download failed") Logger.log("Download failed")
builder.setContentText("${task.title} - ${task.episode} Download failed") builder.setContentText(
"${
getTaskName(
task.title,
task.episode
)
} Download failed"
)
notificationManager.notify(NOTIFICATION_ID, builder.build()) notificationManager.notify(NOTIFICATION_ID, builder.build())
snackString("${task.title} - ${task.episode} Download failed") snackString("${getTaskName(task.title, task.episode)} Download failed")
Logger.log("Download failed: ${download.failureReason}") Logger.log("Download failed: ${ffTask.failStackTrace}")
downloadsManager.removeDownload( downloadsManager.removeDownload(
DownloadedType( DownloadedType(
task.title, task.title,
task.episode, task.episode,
MediaType.ANIME, MediaType.ANIME,
) )
) ) {}
Injekt.get<CrashlyticsInterface>().logException( Injekt.get<CrashlyticsInterface>().logException(
Exception( Exception(
"Anime Download failed:" + "Anime Download failed:" +
" ${download.failureReason}" + " ${getTaskName(task.title, task.episode)}" +
" url: ${task.video.file.url}" + " url: ${task.video.file.url}" +
" title: ${task.title}" + " title: ${task.title}" +
" episode: ${task.episode}" " episode: ${task.episode}"
@ -288,11 +332,63 @@ class AnimeDownloaderService : Service() {
broadcastDownloadFailed(task.episode) broadcastDownloadFailed(task.episode)
break break
} }
if (download.state == androidx.media3.exoplayer.offline.Download.STATE_COMPLETED) { builder.setProgress(
Logger.log("Download completed") 100, percent.coerceAtMost(99),
builder.setContentText("${task.title} - ${task.episode} Download completed") false
)
broadcastDownloadProgress(
task.episode,
percent.coerceAtMost(99)
)
if (notifi) {
notificationManager.notify(NOTIFICATION_ID, builder.build()) notificationManager.notify(NOTIFICATION_ID, builder.build())
snackString("${task.title} - ${task.episode} Download completed") }
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<CrashlyticsInterface>().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( PrefManager.getAnimeDownloadPreferences().edit().putString(
task.getTaskName(), task.getTaskName(),
task.video.file.url task.video.file.url
@ -304,27 +400,11 @@ class AnimeDownloaderService : Service() {
MediaType.ANIME, MediaType.ANIME,
) )
) )
currentTasks.removeAll { it.getTaskName() == task.getTaskName() } currentTasks.removeAll { it.getTaskName() == task.getTaskName() }
broadcastDownloadFinished(task.episode) broadcastDownloadFinished(task.episode)
break } else throw Exception("Download failed")
}
if (download.state == androidx.media3.exoplayer.offline.Download.STATE_STOPPED) {
Logger.log("Download stopped")
builder.setContentText("${task.title} - ${task.episode} Download stopped")
notificationManager.notify(NOTIFICATION_ID, builder.build())
snackString("${task.title} - ${task.episode} Download stopped")
break
}
broadcastDownloadProgress(
task.episode,
download.percentDownloaded.toInt()
)
if (notifi) {
notificationManager.notify(NOTIFICATION_ID, builder.build())
}
}
kotlinx.coroutines.delay(2000)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e.message?.contains("Coroutine was cancelled") == false) { //wut 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) { private fun saveMediaInfo(task: AnimeDownloadTask) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val directory = File( val directory =
getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), getSubDirectory(this@AnimeDownloaderService, MediaType.ANIME, false, task.title)
"${DownloadsManager.animeLocation}/${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
) )
val episodeDirectory = File(directory, task.episode) ?: throw Exception("Directory not found")
if (!episodeDirectory.exists()) episodeDirectory.mkdirs()
val file = File(directory, "media.json")
val gson = GsonBuilder() val gson = GsonBuilder()
.registerTypeAdapter(SChapter::class.java, InstanceCreator<SChapter> { .registerTypeAdapter(SChapter::class.java, InstanceCreator<SChapter> {
SChapterImpl() // Provide an instance of SChapterImpl SChapterImpl() // Provide an instance of SChapterImpl
@ -399,14 +468,25 @@ class AnimeDownloaderService : Service() {
val jsonString = gson.toJson(media) val jsonString = gson.toJson(media)
withContext(Dispatchers.Main) { 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: DocumentFile, name: String): String? =
private suspend fun downloadImage(url: String, directory: File, name: String): String? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
var connection: HttpURLConnection? = null var connection: HttpURLConnection? = null
println("Downloading url $url") println("Downloading url $url")
@ -417,13 +497,16 @@ class AnimeDownloaderService : Service() {
throw Exception("Server returned HTTP ${connection.responseCode} ${connection.responseMessage}") throw Exception("Server returned HTTP ${connection.responseCode} ${connection.responseMessage}")
} }
val file = File(directory, name) directory.findFile(name)?.forceDelete(this@AnimeDownloaderService)
FileOutputStream(file).use { output -> 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 -> connection.inputStream.use { input ->
input.copyTo(output) input.copyTo(output)
} }
} }
return@withContext file.absolutePath return@withContext file.uri.toString()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -490,14 +573,15 @@ class AnimeDownloaderService : Service() {
val episodeImage: String? = null, val episodeImage: String? = null,
val retries: Int = 2, val retries: Int = 2,
val simultaneousDownloads: Int = 2, val simultaneousDownloads: Int = 2,
var sessionId: Long = -1
) { ) {
fun getTaskName(): String { fun getTaskName(): String {
return "$title - $episode" return "${title.replace("/", "")}/${episode.replace("/", "")}"
} }
companion object { companion object {
fun getTaskName(title: String, episode: String): String { fun getTaskName(title: String, episode: String): String {
return "$title - $episode" return "${title.replace("/", "")}/${episode.replace("/", "")}"
} }
} }
} }
@ -511,7 +595,6 @@ class AnimeDownloaderService : Service() {
object AnimeServiceDataSingleton { object AnimeServiceDataSingleton {
var video: Video? = null var video: Video? = null
var sourceMedia: Media? = null
var downloadQueue: Queue<AnimeDownloaderService.AnimeDownloadTask> = ConcurrentLinkedQueue() var downloadQueue: Queue<AnimeDownloaderService.AnimeDownloadTask> = ConcurrentLinkedQueue()
@Volatile @Volatile

View file

@ -4,7 +4,6 @@ package ani.dantotsu.download.anime
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.util.TypedValue import android.util.TypedValue
@ -25,6 +24,7 @@ import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.marginBottom import androidx.core.view.marginBottom
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import ani.dantotsu.R import ani.dantotsu.R
import ani.dantotsu.bottomBar import ani.dantotsu.bottomBar
@ -33,6 +33,7 @@ import ani.dantotsu.currActivity
import ani.dantotsu.currContext import ani.dantotsu.currContext
import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadedType
import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.download.DownloadsManager.Companion.findValidName
import ani.dantotsu.initActivity import ani.dantotsu.initActivity
import ani.dantotsu.media.Media import ani.dantotsu.media.Media
import ani.dantotsu.media.MediaDetailsActivity import ani.dantotsu.media.MediaDetailsActivity
@ -44,6 +45,7 @@ import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.snackString import ani.dantotsu.snackString
import ani.dantotsu.util.Logger import ani.dantotsu.util.Logger
import com.anggrayudi.storage.file.openInputStream
import com.google.android.material.card.MaterialCardView import com.google.android.material.card.MaterialCardView
import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.textfield.TextInputLayout 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.animesource.model.SEpisodeImpl
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SChapterImpl 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.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.File
class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener {
@ -66,6 +72,7 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener {
private lateinit var gridView: GridView private lateinit var gridView: GridView
private lateinit var adapter: OfflineAnimeAdapter private lateinit var adapter: OfflineAnimeAdapter
private lateinit var total: TextView private lateinit var total: TextView
private var downloadsJob: Job = Job()
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -112,10 +119,10 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener {
}) })
var style: Int = PrefManager.getVal(PrefName.OfflineView) var style: Int = PrefManager.getVal(PrefName.OfflineView)
val layoutList = view.findViewById<ImageView>(R.id.downloadedList) val layoutList = view.findViewById<ImageView>(R.id.downloadedList)
val layoutcompact = view.findViewById<ImageView>(R.id.downloadedGrid) val layoutCompact = view.findViewById<ImageView>(R.id.downloadedGrid)
var selected = when (style) { var selected = when (style) {
0 -> layoutList 0 -> layoutList
1 -> layoutcompact 1 -> layoutCompact
else -> layoutList else -> layoutList
} }
selected.alpha = 1f selected.alpha = 1f
@ -136,7 +143,7 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener {
grid() grid()
} }
layoutcompact.setOnClickListener { layoutCompact.setOnClickListener {
selected(it as ImageView) selected(it as ImageView)
style = 1 style = 1
PrefManager.setVal(PrefName.OfflineView, style) PrefManager.setVal(PrefName.OfflineView, style)
@ -156,11 +163,11 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener {
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private fun grid() { private fun grid() {
gridView.visibility = View.VISIBLE gridView.visibility = View.VISIBLE
getDownloads()
val fadeIn = AlphaAnimation(0f, 1f) val fadeIn = AlphaAnimation(0f, 1f)
fadeIn.duration = 300 // animations pog fadeIn.duration = 300 // animations pog
gridView.layoutAnimation = LayoutAnimationController(fadeIn) gridView.layoutAnimation = LayoutAnimationController(fadeIn)
adapter = OfflineAnimeAdapter(requireContext(), downloads, this) adapter = OfflineAnimeAdapter(requireContext(), downloads, this)
getDownloads()
gridView.adapter = adapter gridView.adapter = adapter
gridView.scheduleLayoutAnimation() gridView.scheduleLayoutAnimation()
total.text = if (gridView.count > 0) "Anime (${gridView.count})" else "Empty List" total.text = if (gridView.count > 0) "Anime (${gridView.count})" else "Empty List"
@ -168,12 +175,13 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener {
// Get the OfflineAnimeModel that was clicked // Get the OfflineAnimeModel that was clicked
val item = adapter.getItem(position) as OfflineAnimeModel val item = adapter.getItem(position) as OfflineAnimeModel
val media = val media =
downloadManager.animeDownloadedTypes.firstOrNull { it.title == item.title } downloadManager.animeDownloadedTypes.firstOrNull { it.title == item.title.findValidName() }
media?.let { media?.let {
lifecycleScope.launch {
val mediaModel = getMedia(it) val mediaModel = getMedia(it)
if (mediaModel == null) { if (mediaModel == null) {
snackString("Error loading media.json") snackString("Error loading media.json")
return@let return@launch
} }
MediaDetailsActivity.mediaSingleton = mediaModel MediaDetailsActivity.mediaSingleton = mediaModel
ContextCompat.startActivity( ContextCompat.startActivity(
@ -182,6 +190,7 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener {
.putExtra("download", true), .putExtra("download", true),
null null
) )
}
} ?: run { } ?: run {
snackString("no media found") snackString("no media found")
} }
@ -204,13 +213,7 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener {
if (mediaIds.isEmpty()) { if (mediaIds.isEmpty()) {
snackString("No media found") // if this happens, terrible things have happened 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() getDownloads()
adapter.setItems(downloads)
total.text = if (gridView.count > 0) "Anime (${gridView.count})" else "Empty List"
} }
builder.setNegativeButton("No") { _, _ -> builder.setNegativeButton("No") { _, _ ->
// Do nothing // Do nothing
@ -238,7 +241,6 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener {
gridView.setOnScrollListener(object : AbsListView.OnScrollListener { gridView.setOnScrollListener(object : AbsListView.OnScrollListener {
override fun onScrollStateChanged(view: AbsListView, scrollState: Int) { override fun onScrollStateChanged(view: AbsListView, scrollState: Int) {
// Implement behavior for different scroll states if needed
} }
override fun onScroll( override fun onScroll(
@ -261,7 +263,6 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
getDownloads() getDownloads()
adapter.notifyDataSetChanged()
} }
override fun onPause() { override fun onPause() {
@ -281,6 +282,11 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener {
private fun getDownloads() { private fun getDownloads() {
downloads = listOf() downloads = listOf()
if (downloadsJob.isActive) {
downloadsJob.cancel()
}
downloadsJob = Job()
CoroutineScope(Dispatchers.IO + downloadsJob).launch {
val animeTitles = downloadManager.animeDownloadedTypes.map { it.title }.distinct() val animeTitles = downloadManager.animeDownloadedTypes.map { it.title }.distinct()
val newAnimeDownloads = mutableListOf<OfflineAnimeModel>() val newAnimeDownloads = mutableListOf<OfflineAnimeModel>()
for (title in animeTitles) { for (title in animeTitles) {
@ -290,16 +296,25 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener {
newAnimeDownloads += offlineAnimeModel newAnimeDownloads += offlineAnimeModel
} }
downloads = newAnimeDownloads downloads = newAnimeDownloads
withContext(Dispatchers.Main) {
adapter.setItems(downloads)
total.text = if (gridView.count > 0) "Anime (${gridView.count})" else "Empty List"
adapter.notifyDataSetChanged()
}
}
} }
private fun getMedia(downloadedType: DownloadedType): Media? { /**
val type = downloadedType.type.asText() * Load media.json file from the directory and convert it to Media class
val directory = File( * @param downloadedType DownloadedType object
currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), * @return Media object
"Dantotsu/$type/${downloadedType.title}" */
) private suspend fun getMedia(downloadedType: DownloadedType): Media? {
//load media.json and convert to media class with gson
return try { return try {
val directory = DownloadsManager.getSubDirectory(
context ?: currContext()!!, downloadedType.type,
false, downloadedType.title
)
val gson = GsonBuilder() val gson = GsonBuilder()
.registerTypeAdapter(SChapter::class.java, InstanceCreator<SChapter> { .registerTypeAdapter(SChapter::class.java, InstanceCreator<SChapter> {
SChapterImpl() // Provide an instance of SChapterImpl SChapterImpl() // Provide an instance of SChapterImpl
@ -311,8 +326,13 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener {
SEpisodeImpl() // Provide an instance of SEpisodeImpl SEpisodeImpl() // Provide an instance of SEpisodeImpl
}) })
.create() .create()
val media = File(directory, "media.json") val media = directory?.findFile("media.json")
val mediaJson = media.readText() ?: return null
val mediaJson =
media.openInputStream(context ?: currContext()!!)?.bufferedReader().use {
it?.readText()
}
?: return null
gson.fromJson(mediaJson, Media::class.java) gson.fromJson(mediaJson, Media::class.java)
} catch (e: Exception) { } catch (e: Exception) {
Logger.log("Error loading media.json: ${e.message}") 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 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 { try {
val directory = DownloadsManager.getSubDirectory(
context ?: currContext()!!, downloadedType.type,
false, downloadedType.title
)
val mediaModel = getMedia(downloadedType)!! val mediaModel = getMedia(downloadedType)!!
val cover = File(directory, "cover.jpg") val cover = directory?.findFile("cover.jpg")
val coverUri: Uri? = if (cover.exists()) { val coverUri: Uri? = if (cover?.exists() == true) {
Uri.fromFile(cover) cover.uri
} else null } else null
val banner = File(directory, "banner.jpg") val banner = directory?.findFile("banner.jpg")
val bannerUri: Uri? = if (banner.exists()) { val bannerUri: Uri? = if (banner?.exists() == true) {
Uri.fromFile(banner) banner.uri
} else null } else null
val title = mediaModel.mainName() val title = mediaModel.mainName()
val score = ((if (mediaModel.userScore == 0) (mediaModel.meanScore val score = ((if (mediaModel.userScore == 0) (mediaModel.meanScore

View file

@ -10,17 +10,18 @@ import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Build import android.os.Build
import android.os.Environment
import android.os.IBinder import android.os.IBinder
import android.widget.Toast import android.widget.Toast
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile
import ani.dantotsu.R import ani.dantotsu.R
import ani.dantotsu.connections.crashlytics.CrashlyticsInterface import ani.dantotsu.connections.crashlytics.CrashlyticsInterface
import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadedType
import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory
import ani.dantotsu.media.Media import ani.dantotsu.media.Media
import ani.dantotsu.media.MediaType import ani.dantotsu.media.MediaType
import ani.dantotsu.media.manga.ImageData 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.media.manga.MangaReadFragment.Companion.EXTRA_CHAPTER_NUMBER
import ani.dantotsu.snackString import ani.dantotsu.snackString
import ani.dantotsu.util.Logger 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.GsonBuilder
import com.google.gson.InstanceCreator import com.google.gson.InstanceCreator
import eu.kanade.tachiyomi.data.notification.Notifications.CHANNEL_DOWNLOADER_PROGRESS 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 tachiyomi.core.util.lang.launchIO
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.util.Queue import java.util.Queue
@ -189,13 +191,20 @@ class MangaDownloaderService : Service() {
true true
} }
//val deferredList = mutableListOf<Deferred<Bitmap?>>()
val deferredMap = mutableMapOf<Int, Deferred<Bitmap?>>() val deferredMap = mutableMapOf<Int, Deferred<Bitmap?>>()
builder.setContentText("Downloading ${task.title} - ${task.chapter}") builder.setContentText("Downloading ${task.title} - ${task.chapter}")
if (notifi) { if (notifi) {
notificationManager.notify(NOTIFICATION_ID, builder.build()) 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 // Loop through each ImageData object from the task
var farthest = 0 var farthest = 0
for ((index, image) in task.imageData.withIndex()) { 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) { private fun saveToDisk(fileName: String, bitmap: Bitmap, title: String, chapter: String) {
try { try {
// Define the directory within the private external storage space // Define the directory within the private external storage space
val directory = File( val directory = getSubDirectory(this, MediaType.MANGA, false, title, chapter)
this.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), ?: throw Exception("Directory not found")
"Dantotsu/Manga/$title/$chapter" directory.findFile(fileName)?.forceDelete(this)
) // Create a file reference within that directory for the image
val file =
if (!directory.exists()) { directory.createFile("image/jpeg", fileName) ?: throw Exception("File not created")
directory.mkdirs()
}
// Create a file reference within that directory for your image
val file = File(directory, fileName)
// Use a FileOutputStream to write the bitmap to the file // 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) bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
} }
} catch (e: Exception) { } catch (e: Exception) {
println("Exception while saving image: ${e.message}") println("Exception while saving image: ${e.message}")
snackString("Exception while saving image: ${e.message}") snackString("Exception while saving image: ${e.message}")
@ -291,13 +294,12 @@ class MangaDownloaderService : Service() {
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
private fun saveMediaInfo(task: DownloadTask) { private fun saveMediaInfo(task: DownloadTask) {
launchIO { launchIO {
val directory = File( val directory =
getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), getSubDirectory(this@MangaDownloaderService, MediaType.MANGA, false, task.title)
"Dantotsu/Manga/${task.title}" ?: throw Exception("Directory not found")
) directory.findFile("media.json")?.forceDelete(this@MangaDownloaderService)
if (!directory.exists()) directory.mkdirs() val file = directory.createFile("application/json", "media.json")
?: throw Exception("File not created")
val file = File(directory, "media.json")
val gson = GsonBuilder() val gson = GsonBuilder()
.registerTypeAdapter(SChapter::class.java, InstanceCreator<SChapter> { .registerTypeAdapter(SChapter::class.java, InstanceCreator<SChapter> {
SChapterImpl() // Provide an instance of SChapterImpl SChapterImpl() // Provide an instance of SChapterImpl
@ -312,7 +314,10 @@ class MangaDownloaderService : Service() {
val jsonString = gson.toJson(media) val jsonString = gson.toJson(media)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
try { 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) { } catch (e: android.system.ErrnoException) {
e.printStackTrace() e.printStackTrace()
Toast.makeText( 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) { withContext(Dispatchers.IO) {
var connection: HttpURLConnection? = null var connection: HttpURLConnection? = null
println("Downloading url $url") println("Downloading url $url")
@ -337,14 +342,16 @@ class MangaDownloaderService : Service() {
if (connection.responseCode != HttpURLConnection.HTTP_OK) { if (connection.responseCode != HttpURLConnection.HTTP_OK) {
throw Exception("Server returned HTTP ${connection.responseCode} ${connection.responseMessage}") throw Exception("Server returned HTTP ${connection.responseCode} ${connection.responseMessage}")
} }
directory.findFile(name)?.forceDelete(this@MangaDownloaderService)
val file = File(directory, name) val file =
FileOutputStream(file).use { output -> 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 -> connection.inputStream.use { input ->
input.copyTo(output) input.copyTo(output)
} }
} }
return@withContext file.absolutePath return@withContext file.uri.toString()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {

View file

@ -3,7 +3,6 @@ package ani.dantotsu.download.manga
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.util.TypedValue import android.util.TypedValue
@ -23,6 +22,7 @@ import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.marginBottom import androidx.core.view.marginBottom
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import ani.dantotsu.R import ani.dantotsu.R
import ani.dantotsu.bottomBar import ani.dantotsu.bottomBar
import ani.dantotsu.connections.crashlytics.CrashlyticsInterface import ani.dantotsu.connections.crashlytics.CrashlyticsInterface
@ -30,6 +30,7 @@ import ani.dantotsu.currActivity
import ani.dantotsu.currContext import ani.dantotsu.currContext
import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadedType
import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory
import ani.dantotsu.initActivity import ani.dantotsu.initActivity
import ani.dantotsu.media.Media import ani.dantotsu.media.Media
import ani.dantotsu.media.MediaDetailsActivity import ani.dantotsu.media.MediaDetailsActivity
@ -41,6 +42,7 @@ import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.snackString import ani.dantotsu.snackString
import ani.dantotsu.util.Logger import ani.dantotsu.util.Logger
import com.anggrayudi.storage.file.openInputStream
import com.google.android.material.card.MaterialCardView import com.google.android.material.card.MaterialCardView
import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textfield.TextInputLayout
@ -48,9 +50,13 @@ import com.google.gson.GsonBuilder
import com.google.gson.InstanceCreator import com.google.gson.InstanceCreator
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SChapterImpl 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.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.File
class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener {
@ -59,6 +65,7 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener {
private lateinit var gridView: GridView private lateinit var gridView: GridView
private lateinit var adapter: OfflineMangaAdapter private lateinit var adapter: OfflineMangaAdapter
private lateinit var total: TextView private lateinit var total: TextView
private var downloadsJob: Job = Job()
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -148,11 +155,11 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener {
private fun grid() { private fun grid() {
gridView.visibility = View.VISIBLE gridView.visibility = View.VISIBLE
getDownloads()
val fadeIn = AlphaAnimation(0f, 1f) val fadeIn = AlphaAnimation(0f, 1f)
fadeIn.duration = 300 // animations pog fadeIn.duration = 300 // animations pog
gridView.layoutAnimation = LayoutAnimationController(fadeIn) gridView.layoutAnimation = LayoutAnimationController(fadeIn)
adapter = OfflineMangaAdapter(requireContext(), downloads, this) adapter = OfflineMangaAdapter(requireContext(), downloads, this)
getDownloads()
gridView.adapter = adapter gridView.adapter = adapter
gridView.scheduleLayoutAnimation() gridView.scheduleLayoutAnimation()
total.text = total.text =
@ -164,7 +171,7 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener {
downloadManager.mangaDownloadedTypes.firstOrNull { it.title == item.title } downloadManager.mangaDownloadedTypes.firstOrNull { it.title == item.title }
?: downloadManager.novelDownloadedTypes.firstOrNull { it.title == item.title } ?: downloadManager.novelDownloadedTypes.firstOrNull { it.title == item.title }
media?.let { media?.let {
lifecycleScope.launch {
ContextCompat.startActivity( ContextCompat.startActivity(
requireActivity(), requireActivity(),
Intent(requireContext(), MediaDetailsActivity::class.java) Intent(requireContext(), MediaDetailsActivity::class.java)
@ -172,6 +179,7 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener {
.putExtra("download", true), .putExtra("download", true),
null null
) )
}
} ?: run { } ?: run {
snackString("no media found") snackString("no media found")
} }
@ -194,9 +202,6 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener {
builder.setPositiveButton("Yes") { _, _ -> builder.setPositiveButton("Yes") { _, _ ->
downloadManager.removeMedia(item.title, type) downloadManager.removeMedia(item.title, type)
getDownloads() getDownloads()
adapter.setItems(downloads)
total.text =
if (gridView.count > 0) "Manga and Novels (${gridView.count})" else "Empty List"
} }
builder.setNegativeButton("No") { _, _ -> builder.setNegativeButton("No") { _, _ ->
// Do nothing // Do nothing
@ -225,7 +230,6 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener {
gridView.setOnScrollListener(object : AbsListView.OnScrollListener { gridView.setOnScrollListener(object : AbsListView.OnScrollListener {
override fun onScrollStateChanged(view: AbsListView, scrollState: Int) { override fun onScrollStateChanged(view: AbsListView, scrollState: Int) {
// Implement behavior for different scroll states if needed
} }
override fun onScroll( override fun onScroll(
@ -248,7 +252,6 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
getDownloads() getDownloads()
adapter.notifyDataSetChanged()
} }
override fun onPause() { override fun onPause() {
@ -268,6 +271,12 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener {
private fun getDownloads() { private fun getDownloads() {
downloads = listOf() downloads = listOf()
if (downloadsJob.isActive) {
downloadsJob.cancel()
}
downloads = listOf()
downloadsJob = Job()
CoroutineScope(Dispatchers.IO + downloadsJob).launch {
val mangaTitles = downloadManager.mangaDownloadedTypes.map { it.title }.distinct() val mangaTitles = downloadManager.mangaDownloadedTypes.map { it.title }.distinct()
val newMangaDownloads = mutableListOf<OfflineMangaModel>() val newMangaDownloads = mutableListOf<OfflineMangaModel>()
for (title in mangaTitles) { for (title in mangaTitles) {
@ -286,24 +295,38 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener {
newNovelDownloads += offlineMangaModel newNovelDownloads += offlineMangaModel
} }
downloads += newNovelDownloads downloads += newNovelDownloads
withContext(Dispatchers.Main) {
adapter.setItems(downloads)
total.text =
if (gridView.count > 0) "Manga and Novels (${gridView.count})" else "Empty List"
adapter.notifyDataSetChanged()
}
}
} }
private fun getMedia(downloadedType: DownloadedType): Media? { /**
val type = downloadedType.type.asText() * Load media.json file from the directory and convert it to Media class
val directory = File( * @param downloadedType DownloadedType object
currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), * @return Media object
"Dantotsu/$type/${downloadedType.title}" */
) private suspend fun getMedia(downloadedType: DownloadedType): Media? {
//load media.json and convert to media class with gson
return try { return try {
val directory = getSubDirectory(
context ?: currContext()!!, downloadedType.type,
false, downloadedType.title
)
val gson = GsonBuilder() val gson = GsonBuilder()
.registerTypeAdapter(SChapter::class.java, InstanceCreator<SChapter> { .registerTypeAdapter(SChapter::class.java, InstanceCreator<SChapter> {
SChapterImpl() // Provide an instance of SChapterImpl SChapterImpl() // Provide an instance of SChapterImpl
}) })
.create() .create()
val media = File(directory, "media.json") val media = directory?.findFile("media.json")
val mediaJson = media.readText() ?: return null
val mediaJson =
media.openInputStream(context ?: currContext()!!)?.bufferedReader().use {
it?.readText()
}
gson.fromJson(mediaJson, Media::class.java) gson.fromJson(mediaJson, Media::class.java)
} catch (e: Exception) { } catch (e: Exception) {
Logger.log("Error loading media.json: ${e.message}") 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 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 and convert to media class with gson
try { try {
val directory = getSubDirectory(
context ?: currContext()!!, downloadedType.type,
false, downloadedType.title
)
val mediaModel = getMedia(downloadedType)!! val mediaModel = getMedia(downloadedType)!!
val cover = File(directory, "cover.jpg") val cover = directory?.findFile("cover.jpg")
val coverUri: Uri? = if (cover.exists()) { val coverUri: Uri? = if (cover?.exists() == true) {
Uri.fromFile(cover) cover.uri
} else null } else null
val banner = File(directory, "banner.jpg") val banner = directory?.findFile("banner.jpg")
val bannerUri: Uri? = if (banner.exists()) { val bannerUri: Uri? = if (banner?.exists() == true) {
Uri.fromFile(banner) banner.uri
} else null } else null
val title = mediaModel.mainName() val title = mediaModel.mainName()
val score = ((if (mediaModel.userScore == 0) (mediaModel.meanScore val score = ((if (mediaModel.userScore == 0) (mediaModel.meanScore
@ -336,14 +359,14 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener {
val isOngoing = val isOngoing =
mediaModel.status == currActivity()!!.getString(R.string.status_releasing) mediaModel.status == currActivity()!!.getString(R.string.status_releasing)
val isUserScored = mediaModel.userScore != 0 val isUserScored = mediaModel.userScore != 0
val readchapter = (mediaModel.userProgress ?: "~").toString() val readChapter = (mediaModel.userProgress ?: "~").toString()
val totalchapter = "${mediaModel.manga?.totalChapters ?: "??"}" val totalChapter = "${mediaModel.manga?.totalChapters ?: "??"}"
val chapters = " Chapters" val chapters = " Chapters"
return OfflineMangaModel( return OfflineMangaModel(
title, title,
score, score,
totalchapter, totalChapter,
readchapter, readChapter,
type, type,
chapters, chapters,
isOngoing, isOngoing,

View file

@ -16,15 +16,19 @@ import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile
import ani.dantotsu.R import ani.dantotsu.R
import ani.dantotsu.connections.crashlytics.CrashlyticsInterface import ani.dantotsu.connections.crashlytics.CrashlyticsInterface
import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadedType
import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory
import ani.dantotsu.media.Media import ani.dantotsu.media.Media
import ani.dantotsu.media.MediaType import ani.dantotsu.media.MediaType
import ani.dantotsu.media.novel.NovelReadFragment import ani.dantotsu.media.novel.NovelReadFragment
import ani.dantotsu.snackString import ani.dantotsu.snackString
import ani.dantotsu.util.Logger 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.GsonBuilder
import com.google.gson.InstanceCreator import com.google.gson.InstanceCreator
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
@ -250,24 +254,25 @@ class NovelDownloaderService : Service() {
if (!response.isSuccessful) { if (!response.isSuccessful) {
throw IOException("Failed to download file: ${response.message}") 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( val file = directory.createFile("application/epub+zip", "0.epub")
this@NovelDownloaderService.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), ?: throw Exception("File not created")
"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()
//download cover //download cover
task.coverUrl?.let { task.coverUrl?.let {
file.parentFile?.let { it1 -> downloadImage(it, it1, "cover.jpg") } 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 responseBody = response.body
val totalBytes = responseBody.contentLength() val totalBytes = responseBody.contentLength()
var downloadedBytes = 0L var downloadedBytes = 0L
@ -352,13 +357,16 @@ class NovelDownloaderService : Service() {
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
private fun saveMediaInfo(task: DownloadTask) { private fun saveMediaInfo(task: DownloadTask) {
launchIO { launchIO {
val directory = File( val directory =
getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), DownloadsManager.getSubDirectory(
"Dantotsu/Novel/${task.title}" this@NovelDownloaderService,
) MediaType.NOVEL,
if (!directory.exists()) directory.mkdirs() false,
task.title
val file = File(directory, "media.json") ) ?: 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() val gson = GsonBuilder()
.registerTypeAdapter(SChapter::class.java, InstanceCreator<SChapter> { .registerTypeAdapter(SChapter::class.java, InstanceCreator<SChapter> {
SChapterImpl() // Provide an instance of SChapterImpl SChapterImpl() // Provide an instance of SChapterImpl
@ -372,33 +380,47 @@ class NovelDownloaderService : Service() {
val jsonString = gson.toJson(media) val jsonString = gson.toJson(media)
withContext(Dispatchers.Main) { 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( withContext(
Dispatchers.IO Dispatchers.IO
) { ) {
var connection: HttpURLConnection? = null var connection: HttpURLConnection? = null
println("Downloading url $url") Logger.log("Downloading url $url")
try { try {
connection = URL(url).openConnection() as HttpURLConnection connection = URL(url).openConnection() as HttpURLConnection
connection.connect() connection.connect()
if (connection.responseCode != HttpURLConnection.HTTP_OK) { if (connection.responseCode != HttpURLConnection.HTTP_OK) {
throw Exception("Server returned HTTP ${connection.responseCode} ${connection.responseMessage}") throw Exception("Server returned HTTP ${connection.responseCode} ${connection.responseMessage}")
} }
directory.findFile(name)?.forceDelete(this@NovelDownloaderService)
val file = File(directory, name) val file =
FileOutputStream(file).use { output -> 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 -> connection.inputStream.use { input ->
input.copyTo(output) input.copyTo(output)
} }
} }
return@withContext file.absolutePath return@withContext file.uri.toString()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {

View file

@ -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<Download>,
notMetRequirements: Int
): Notification =
DownloadNotificationHelper(this, "download_service").buildProgressNotification(
this,
R.drawable.mono,
null,
null,
downloads,
notMetRequirements
)
}

View file

@ -53,140 +53,6 @@ import java.util.concurrent.Executors
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
object Helper { 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<StandaloneDatabaseProvider>()
val dataSourceFactory = DataSource.Factory {
//val dataSource: HttpDataSource = OkHttpDataSource.Factory(okHttpClient).createDataSource()
val networkHelper = Injekt.get<NetworkHelper>()
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) @OptIn(UnstableApi::class)
fun startAnimeDownloadService( fun startAnimeDownloadService(
context: Context, context: Context,
@ -225,15 +91,6 @@ object Helper {
.setTitle("Download Exists") .setTitle("Download Exists")
.setMessage("A download for this episode already exists. Do you want to overwrite it?") .setMessage("A download for this episode already exists. Do you want to overwrite it?")
.setPositiveButton("Yes") { _, _ -> .setPositiveButton("Yes") { _, _ ->
DownloadService.sendRemoveDownload(
context,
ExoplayerDownloadService::class.java,
PrefManager.getAnimeDownloadPreferences().getString(
animeDownloadTask.getTaskName(),
""
) ?: "",
false
)
PrefManager.getAnimeDownloadPreferences().edit() PrefManager.getAnimeDownloadPreferences().edit()
.remove(animeDownloadTask.getTaskName()) .remove(animeDownloadTask.getTaskName())
.apply() .apply()
@ -243,7 +100,7 @@ object Helper {
episode, episode,
MediaType.ANIME MediaType.ANIME
) )
) ) {
AnimeServiceDataSingleton.downloadQueue.offer(animeDownloadTask) AnimeServiceDataSingleton.downloadQueue.offer(animeDownloadTask)
if (!AnimeServiceDataSingleton.isServiceRunning) { if (!AnimeServiceDataSingleton.isServiceRunning) {
val intent = Intent(context, AnimeDownloaderService::class.java) val intent = Intent(context, AnimeDownloaderService::class.java)
@ -251,6 +108,7 @@ object Helper {
AnimeServiceDataSingleton.isServiceRunning = true AnimeServiceDataSingleton.isServiceRunning = true
} }
} }
}
.setNegativeButton("No") { _, _ -> } .setNegativeButton("No") { _, _ -> }
.show() .show()
} else { } else {
@ -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<StandaloneDatabaseProvider>()
simpleCache = SimpleCache(downloadDirectory, NoOpCacheEvictor(), database)
simpleCache!!
} else {
simpleCache!!
}
}
private fun isNotificationPermissionGranted(context: Context): Boolean { private fun isNotificationPermissionGranted(context: Context): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return ActivityCompat.checkSelfPermission( return ActivityCompat.checkSelfPermission(

View file

@ -13,6 +13,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.ImageView import android.widget.ImageView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
@ -53,6 +54,7 @@ import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.snackString import ani.dantotsu.snackString
import ani.dantotsu.statusBarHeight import ani.dantotsu.statusBarHeight
import ani.dantotsu.themes.ThemeManager import ani.dantotsu.themes.ThemeManager
import ani.dantotsu.util.LauncherWrapper
import com.flaviofaria.kenburnsview.RandomTransitionGenerator import com.flaviofaria.kenburnsview.RandomTransitionGenerator
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -66,7 +68,7 @@ import kotlin.math.abs
class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedListener { class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedListener {
lateinit var launcher: LauncherWrapper
lateinit var binding: ActivityMediaBinding lateinit var binding: ActivityMediaBinding
private val scope = lifecycleScope private val scope = lifecycleScope
private val model: MediaDetailsViewModel by viewModels() private val model: MediaDetailsViewModel by viewModels()
@ -92,6 +94,9 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
onBackPressedDispatcher.onBackPressed() onBackPressedDispatcher.onBackPressed()
return return
} }
val contract = ActivityResultContracts.OpenDocumentTree()
launcher = LauncherWrapper(this, contract)
mediaSingleton = null mediaSingleton = null
ThemeManager(this).applyTheme(MediaSingleton.bitmap) ThemeManager(this).applyTheme(MediaSingleton.bitmap)
MediaSingleton.bitmap = null MediaSingleton.bitmap = null

View file

@ -5,6 +5,7 @@ import ani.dantotsu.download.DownloadedType
import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.parsers.SubtitleType import ani.dantotsu.parsers.SubtitleType
import ani.dantotsu.snackString import ani.dantotsu.snackString
import com.anggrayudi.storage.file.openOutputStream
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -51,21 +52,17 @@ class SubtitleDownloader {
downloadedType: DownloadedType downloadedType: DownloadedType
) { ) {
try { try {
val directory = DownloadsManager.getDirectory( val directory = DownloadsManager.getSubDirectory(
context, context,
downloadedType.type, downloadedType.type,
false,
downloadedType.title, downloadedType.title,
downloadedType.chapter downloadedType.chapter
) ) ?: throw Exception("Could not create directory")
if (!directory.exists()) { //just in case
directory.mkdirs()
}
val type = loadSubtitleType(url) val type = loadSubtitleType(url)
val subtiteFile = File(directory, "subtitle.${type}") directory.findFile("subtitle.${type}")?.delete()
if (subtiteFile.exists()) { val subtitleFile = directory.createFile("*/*", "subtitle.${type}")
subtiteFile.delete() ?: throw Exception("Could not create subtitle file")
}
subtiteFile.createNewFile()
val client = Injekt.get<NetworkHelper>().client val client = Injekt.get<NetworkHelper>().client
val request = Request.Builder().url(url).build() val request = Request.Builder().url(url).build()
@ -77,7 +74,8 @@ class SubtitleDownloader {
} }
reponse.body.byteStream().use { input -> 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) input.copyTo(output)
} }
} }

View file

@ -14,6 +14,7 @@ import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.Toast import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity
import androidx.cardview.widget.CardView import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.math.MathUtils import androidx.core.math.MathUtils
@ -34,8 +35,8 @@ import ani.dantotsu.R
import ani.dantotsu.databinding.FragmentAnimeWatchBinding import ani.dantotsu.databinding.FragmentAnimeWatchBinding
import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadedType
import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.download.DownloadsManager.Companion.findValidName
import ani.dantotsu.download.anime.AnimeDownloaderService import ani.dantotsu.download.anime.AnimeDownloaderService
import ani.dantotsu.download.video.ExoplayerDownloadService
import ani.dantotsu.dp import ani.dantotsu.dp
import ani.dantotsu.media.Media import ani.dantotsu.media.Media
import ani.dantotsu.media.MediaDetailsActivity 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.PrefManager
import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.snackString 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 com.google.android.material.appbar.AppBarLayout
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
@ -422,7 +425,19 @@ class AnimeWatchFragment : Fragment() {
} }
fun onAnimeEpisodeDownloadClick(i: String) { fun onAnimeEpisodeDownloadClick(i: String) {
activity?.let{
if (!hasDirAccess(it)) {
(it as MediaDetailsActivity).accessAlertDialog(it.launcher) { success ->
if (success) {
model.onEpisodeClick(media, i, requireActivity().supportFragmentManager, isDownload = true) 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) { fun onAnimeEpisodeStopDownloadClick(i: String) {
@ -442,9 +457,10 @@ class AnimeWatchFragment : Fragment() {
i, i,
MediaType.ANIME MediaType.ANIME
) )
) ) {
episodeAdapter.purgeDownload(i) episodeAdapter.purgeDownload(i)
} }
}
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
fun onAnimeEpisodeRemoveDownloadClick(i: String) { fun onAnimeEpisodeRemoveDownloadClick(i: String) {
@ -454,21 +470,16 @@ class AnimeWatchFragment : Fragment() {
i, i,
MediaType.ANIME MediaType.ANIME
) )
) ) {
val taskName = AnimeDownloaderService.AnimeDownloadTask.getTaskName(media.mainName(), i) val taskName = AnimeDownloaderService.AnimeDownloadTask.getTaskName(media.mainName(), i)
val id = PrefManager.getAnimeDownloadPreferences().getString( val id = PrefManager.getAnimeDownloadPreferences().getString(
taskName, taskName,
"" ""
) ?: "" ) ?: ""
PrefManager.getAnimeDownloadPreferences().edit().remove(taskName).apply() PrefManager.getAnimeDownloadPreferences().edit().remove(taskName).apply()
DownloadService.sendRemoveDownload(
requireContext(),
ExoplayerDownloadService::class.java,
id,
true
)
episodeAdapter.deleteDownload(i) episodeAdapter.deleteDownload(i)
} }
}
private val downloadStatusReceiver = object : BroadcastReceiver() { private val downloadStatusReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
@ -531,7 +542,7 @@ class AnimeWatchFragment : Fragment() {
episodeAdapter.updateType(style ?: PrefManager.getVal(PrefName.AnimeDefaultView)) episodeAdapter.updateType(style ?: PrefManager.getVal(PrefName.AnimeDefaultView))
episodeAdapter.notifyItemRangeInserted(0, arr.size) episodeAdapter.notifyItemRangeInserted(0, arr.size)
for (download in downloadManager.animeDownloadedTypes) { for (download in downloadManager.animeDownloadedTypes) {
if (download.title == media.mainName()) { if (download.title == media.mainName().findValidName()) {
episodeAdapter.stopDownload(download.chapter) episodeAdapter.stopDownload(download.chapter)
} }
} }

View file

@ -10,7 +10,6 @@ import androidx.annotation.OptIn
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.coroutineScope import androidx.lifecycle.coroutineScope
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.offline.DownloadIndex
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import ani.dantotsu.R import ani.dantotsu.R
import ani.dantotsu.connections.updateProgress import ani.dantotsu.connections.updateProgress
@ -18,10 +17,12 @@ import ani.dantotsu.currContext
import ani.dantotsu.databinding.ItemEpisodeCompactBinding import ani.dantotsu.databinding.ItemEpisodeCompactBinding
import ani.dantotsu.databinding.ItemEpisodeGridBinding import ani.dantotsu.databinding.ItemEpisodeGridBinding
import ani.dantotsu.databinding.ItemEpisodeListBinding 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.anime.AnimeDownloaderService
import ani.dantotsu.download.video.Helper
import ani.dantotsu.media.Media import ani.dantotsu.media.Media
import ani.dantotsu.media.MediaNameAdapter import ani.dantotsu.media.MediaNameAdapter
import ani.dantotsu.media.MediaType
import ani.dantotsu.setAnimation import ani.dantotsu.setAnimation
import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefManager
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
@ -56,15 +57,7 @@ class EpisodeAdapter(
var arr: List<Episode> = arrayListOf(), var arr: List<Episode> = arrayListOf(),
var offlineMode: Boolean var offlineMode: Boolean
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
val context = fragment.requireContext()
private lateinit var index: DownloadIndex
init {
if (offlineMode) {
index = Helper.downloadManager(fragment.requireContext()).downloadIndex
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return (when (viewType) { return (when (viewType) {
@ -248,17 +241,8 @@ class EpisodeAdapter(
// Find the position of the chapter and notify only that item // Find the position of the chapter and notify only that item
val position = arr.indexOfFirst { it.number == episodeNumber } val position = arr.indexOfFirst { it.number == episodeNumber }
if (position != -1) { if (position != -1) {
val taskName = AnimeDownloaderService.AnimeDownloadTask.getTaskName(
media.mainName(),
episodeNumber
)
val id = PrefManager.getAnimeDownloadPreferences().getString(
taskName,
""
) ?: ""
val size = try { val size = try {
val download = index.getDownload(id) bytesToHuman(getDirSize(context, MediaType.ANIME, media.mainName(), episodeNumber))
bytesToHuman(download?.bytesDownloaded ?: 0)
} catch (e: Exception) { } catch (e: Exception) {
null null
} }

View file

@ -104,7 +104,7 @@ import ani.dantotsu.connections.discord.RPC
import ani.dantotsu.connections.updateProgress import ani.dantotsu.connections.updateProgress
import ani.dantotsu.databinding.ActivityExoplayerBinding import ani.dantotsu.databinding.ActivityExoplayerBinding
import ani.dantotsu.defaultHeaders import ani.dantotsu.defaultHeaders
import ani.dantotsu.download.video.Helper import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory
import ani.dantotsu.dp import ani.dantotsu.dp
import ani.dantotsu.getCurrentBrightnessValue import ani.dantotsu.getCurrentBrightnessValue
import ani.dantotsu.hideSystemBars import ani.dantotsu.hideSystemBars
@ -114,6 +114,7 @@ import ani.dantotsu.logError
import ani.dantotsu.media.Media import ani.dantotsu.media.Media
import ani.dantotsu.media.MediaDetailsViewModel import ani.dantotsu.media.MediaDetailsViewModel
import ani.dantotsu.media.MediaNameAdapter import ani.dantotsu.media.MediaNameAdapter
import ani.dantotsu.media.MediaType
import ani.dantotsu.media.SubtitleDownloader import ani.dantotsu.media.SubtitleDownloader
import ani.dantotsu.okHttpClient import ani.dantotsu.okHttpClient
import ani.dantotsu.others.AniSkip import ani.dantotsu.others.AniSkip
@ -394,7 +395,8 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
isCastApiAvailable = GoogleApiAvailability.getInstance() isCastApiAvailable = GoogleApiAvailability.getInstance()
.isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS .isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
try { try {
castContext = CastContext.getSharedInstance(this, Executors.newSingleThreadExecutor()).result castContext =
CastContext.getSharedInstance(this, Executors.newSingleThreadExecutor()).result
castPlayer = CastPlayer(castContext!!) castPlayer = CastPlayer(castContext!!)
castPlayer!!.setSessionAvailabilityListener(this) castPlayer!!.setSessionAvailabilityListener(this)
} catch (e: Exception) { } catch (e: Exception) {
@ -453,12 +455,14 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
} }
rotation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE rotation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
} }
in 225..315 -> { in 225..315 -> {
if (rotation != ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) { if (rotation != ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) {
exoRotate.visibility = View.VISIBLE exoRotate.visibility = View.VISIBLE
} }
rotation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE rotation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
} }
in 315..360, in 0..45 -> { in 315..360, in 0..45 -> {
if (rotation != ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) { if (rotation != ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) {
exoRotate.visibility = View.VISIBLE exoRotate.visibility = View.VISIBLE
@ -476,7 +480,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
requestedOrientation = rotation requestedOrientation = rotation
it.visibility = View.GONE it.visibility = View.GONE
} }
} }
setupSubFormatting(playerView) setupSubFormatting(playerView)
@ -1089,10 +1093,12 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
"nothing" -> mutableListOf( "nothing" -> mutableListOf(
RPC.Link(getString(R.string.view_anime), media.shareLink ?: ""), RPC.Link(getString(R.string.view_anime), media.shareLink ?: ""),
) )
"dantotsu" -> mutableListOf( "dantotsu" -> mutableListOf(
RPC.Link(getString(R.string.view_anime), media.shareLink ?: ""), RPC.Link(getString(R.string.view_anime), media.shareLink ?: ""),
RPC.Link("Watch on Dantotsu", getString(R.string.dantotsu)) RPC.Link("Watch on Dantotsu", getString(R.string.dantotsu))
) )
"anilist" -> { "anilist" -> {
val userId = PrefManager.getVal<String>(PrefName.AnilistUserId) val userId = PrefManager.getVal<String>(PrefName.AnilistUserId)
val anilistLink = "https://anilist.co/user/$userId/" val anilistLink = "https://anilist.co/user/$userId/"
@ -1101,6 +1107,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
RPC.Link("View My AniList", anilistLink) RPC.Link("View My AniList", anilistLink)
) )
} }
else -> mutableListOf() else -> mutableListOf()
} }
val presence = RPC.createPresence( val presence = RPC.createPresence(
@ -1113,7 +1120,12 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
ep.number ep.number
), ),
state = "Episode : ${ep.number}/${media.anime?.totalEpisodes ?: "??"}", 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), smallImage = RPC.Link("Dantotsu", Discord.small_Image),
buttons = buttons buttons = buttons
) )
@ -1161,7 +1173,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
if (PrefManager.getVal(PrefName.Cast)) { if (PrefManager.getVal(PrefName.Cast)) {
playerView.findViewById<CustomCastButton>(R.id.exo_cast).apply { playerView.findViewById<CustomCastButton>(R.id.exo_cast).apply {
visibility = View.VISIBLE visibility = View.VISIBLE
if(PrefManager.getVal(PrefName.UseInternalCast)) { if (PrefManager.getVal(PrefName.UseInternalCast)) {
try { try {
CastButtonFactory.setUpMediaRouteButton(context, this) CastButtonFactory.setUpMediaRouteButton(context, this)
dialogFactory = CustomCastThemeFactory() dialogFactory = CustomCastThemeFactory()
@ -1324,7 +1336,11 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
) )
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val list = (PrefManager.getNullableCustomVal("continueAnimeList", listOf<Int>(), List::class.java) as List<Int>).toMutableList() val list = (PrefManager.getNullableCustomVal(
"continueAnimeList",
listOf<Int>(),
List::class.java
) as List<Int>).toMutableList()
if (list.contains(media.id)) list.remove(media.id) if (list.contains(media.id)) list.remove(media.id)
list.add(media.id) list.add(media.id)
PrefManager.setCustomVal("continueAnimeList", list) PrefManager.setCustomVal("continueAnimeList", list)
@ -1418,7 +1434,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
} }
val dafuckDataSourceFactory = DefaultDataSource.Factory(this) val dafuckDataSourceFactory = DefaultDataSource.Factory(this)
cacheFactory = CacheDataSource.Factory().apply { cacheFactory = CacheDataSource.Factory().apply {
setCache(Helper.getSimpleCache(this@ExoplayerView)) setCache(VideoCache.getInstance(this@ExoplayerView))
if (ext.server.offline) { if (ext.server.offline) {
setUpstreamDataSourceFactory(dafuckDataSourceFactory) setUpstreamDataSourceFactory(dafuckDataSourceFactory)
} else { } else {
@ -1435,15 +1451,28 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
val downloadedMediaItem = if (ext.server.offline) { val downloadedMediaItem = if (ext.server.offline) {
val key = ext.server.name val key = ext.server.name
downloadId = PrefManager.getAnimeDownloadPreferences() val titleName = ext.server.name.split("/").first()
.getString(key, null) val episodeName = ext.server.name.split("/").last()
if (downloadId != null) {
Helper.downloadManager(this) val directory = getSubDirectory(this, MediaType.ANIME, false, titleName, episodeName)
.downloadIndex.getDownload(downloadId!!)?.request?.toMediaItem() 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 { } else {
snackString("Download not found") snackString("File not found")
null null
} }
} else {
snackString("Directory not found")
null
}
} else null } else null
mediaItem = if (downloadedMediaItem == null) { mediaItem = if (downloadedMediaItem == null) {
@ -1818,7 +1847,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
if (!functionstarted && !disappeared && PrefManager.getVal(PrefName.AutoHideTimeStamps)) { if (!functionstarted && !disappeared && PrefManager.getVal(PrefName.AutoHideTimeStamps)) {
disappearSkip() disappearSkip()
} else if (!PrefManager.getVal<Boolean>(PrefName.AutoHideTimeStamps)){ } else if (!PrefManager.getVal<Boolean>(PrefName.AutoHideTimeStamps)) {
skipTimeButton.visibility = View.VISIBLE skipTimeButton.visibility = View.VISIBLE
exoSkip.visibility = View.GONE exoSkip.visibility = View.GONE
skipTimeText.text = new.skipType.getType() skipTimeText.text = new.skipType.getType()
@ -2157,11 +2186,16 @@ class CustomCastButton : MediaRouteButton {
fun setCastCallback(castCallback: () -> Unit) { fun setCastCallback(castCallback: () -> Unit) {
this.castCallback = castCallback this.castCallback = castCallback
} }
constructor(context: Context) : super(context) constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) 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 { override fun performClick(): Boolean {
return if (PrefManager.getVal(PrefName.UseInternalCast)) { return if (PrefManager.getVal(PrefName.UseInternalCast)) {

View file

@ -16,6 +16,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.cardview.widget.CardView import androidx.cardview.widget.CardView
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -34,6 +35,7 @@ import ani.dantotsu.R
import ani.dantotsu.databinding.FragmentAnimeWatchBinding import ani.dantotsu.databinding.FragmentAnimeWatchBinding
import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadedType
import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.download.DownloadsManager.Companion.findValidName
import ani.dantotsu.download.manga.MangaDownloaderService import ani.dantotsu.download.manga.MangaDownloaderService
import ani.dantotsu.download.manga.MangaServiceDataSingleton import ani.dantotsu.download.manga.MangaServiceDataSingleton
import ani.dantotsu.dp 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.PrefManager
import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.snackString 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 com.google.android.material.appbar.AppBarLayout
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
@ -190,7 +194,7 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener {
) )
for (download in downloadManager.mangaDownloadedTypes) { for (download in downloadManager.mangaDownloadedTypes) {
if (download.title == media.mainName()) { if (download.title == media.mainName().findValidName()) {
chapterAdapter.stopDownload(download.chapter) chapterAdapter.stopDownload(download.chapter)
} }
} }
@ -434,16 +438,17 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener {
} }
fun onMangaChapterDownloadClick(i: String) { fun onMangaChapterDownloadClick(i: String) {
activity?.let {
if (!isNotificationPermissionGranted()) { if (!isNotificationPermissionGranted()) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ActivityCompat.requestPermissions( ActivityCompat.requestPermissions(
requireActivity(), it,
arrayOf(Manifest.permission.POST_NOTIFICATIONS), arrayOf(Manifest.permission.POST_NOTIFICATIONS),
1 1
) )
} }
} }
fun continueDownload() {
model.continueMedia = false model.continueMedia = false
media.manga?.chapters?.get(i)?.let { chapter -> media.manga?.chapters?.get(i)?.let { chapter ->
val parser = val parser =
@ -481,6 +486,19 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener {
} }
} }
} }
if (!hasDirAccess(it)) {
(it as MediaDetailsActivity).accessAlertDialog(it.launcher) { success ->
if (success) {
continueDownload()
} else {
snackString("Permission is required to download")
}
}
} else {
continueDownload()
}
}
}
private fun isNotificationPermissionGranted(): Boolean { private fun isNotificationPermissionGranted(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@ -500,9 +518,10 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener {
i, i,
MediaType.MANGA MediaType.MANGA
) )
) ) {
chapterAdapter.deleteDownload(i) chapterAdapter.deleteDownload(i)
} }
}
fun onMangaChapterStopDownloadClick(i: String) { fun onMangaChapterStopDownloadClick(i: String) {
val cancelIntent = Intent().apply { val cancelIntent = Intent().apply {
@ -518,9 +537,10 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener {
i, i,
MediaType.MANGA MediaType.MANGA
) )
) ) {
chapterAdapter.purgeDownload(i) chapterAdapter.purgeDownload(i)
} }
}
private val downloadStatusReceiver = object : BroadcastReceiver() { private val downloadStatusReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {

View file

@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.net.Uri
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
@ -176,6 +177,10 @@ abstract class BaseImageAdapter(
it.load(localFile.absoluteFile) it.load(localFile.absoluteFile)
.skipMemoryCache(true) .skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE)
} else if (link.url.startsWith("content://")) {
it.load(Uri.parse(link.url))
.skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE)
} else { } else {
mangaCache.get(link.url)?.let { imageData -> mangaCache.get(link.url)?.let { imageData ->
val bitmap = imageData.fetchAndProcessImage( val bitmap = imageData.fetchAndProcessImage(
@ -186,6 +191,7 @@ abstract class BaseImageAdapter(
.skipMemoryCache(true) .skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE)
} }
} }
} }
?.let { ?.let {

View file

@ -20,6 +20,7 @@ import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import ani.dantotsu.currContext
import ani.dantotsu.databinding.FragmentAnimeWatchBinding import ani.dantotsu.databinding.FragmentAnimeWatchBinding
import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadedType
import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.DownloadsManager
@ -94,16 +95,12 @@ class NovelReadFragment : Fragment(),
) )
) )
) { ) {
val file = File( try {
context?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), val directory =
"${DownloadsManager.novelLocation}/${media.mainName()}/${novel.name}/0.epub" DownloadsManager.getSubDirectory(context?:currContext()!!, MediaType.NOVEL, false, novel.name)
) val file = directory?.findFile(novel.name)
if (!file.exists()) return false if (file?.exists() == false) return false
val fileUri = FileProvider.getUriForFile( val fileUri = file?.uri ?: return false
requireContext(),
"${requireContext().packageName}.provider",
file
)
val intent = Intent(context, NovelReaderActivity::class.java).apply { val intent = Intent(context, NovelReaderActivity::class.java).apply {
action = Intent.ACTION_VIEW action = Intent.ACTION_VIEW
setDataAndType(fileUri, "application/epub+zip") setDataAndType(fileUri, "application/epub+zip")
@ -111,6 +108,10 @@ class NovelReadFragment : Fragment(),
} }
startActivity(intent) startActivity(intent)
return true return true
} catch (e: Exception) {
Logger.log(e)
return false
}
} else { } else {
return false return false
} }
@ -135,7 +136,7 @@ class NovelReadFragment : Fragment(),
novel.name, novel.name,
MediaType.NOVEL MediaType.NOVEL
) )
) ) {}
} }
private val downloadStatusReceiver = object : BroadcastReceiver() { private val downloadStatusReceiver = object : BroadcastReceiver() {

View file

@ -46,11 +46,11 @@ class CommentNotificationTask : Task {
) )
notifications = notifications =
notifications?.filter { it.type != 3 || it.notificationId > recentGlobal } notifications?.filter { !it.type.isGlobal() || it.notificationId > recentGlobal }
?.toMutableList() ?.toMutableList()
val newRecentGlobal = val newRecentGlobal =
notifications?.filter { it.type == 3 }?.maxOfOrNull { it.notificationId } notifications?.filter { it.type.isGlobal() }?.maxOfOrNull { it.notificationId }
if (newRecentGlobal != null) { if (newRecentGlobal != null) {
PrefManager.setVal(PrefName.RecentGlobalNotification, newRecentGlobal) PrefManager.setVal(PrefName.RecentGlobalNotification, newRecentGlobal)
} }
@ -313,4 +313,6 @@ class CommentNotificationTask : Task {
null null
} }
} }
private fun Int?.isGlobal() = this == 3 || this == 420
} }

View file

@ -36,16 +36,8 @@ object Download {
} }
private fun getDownloadDir(context: Context): File { private fun getDownloadDir(context: Context): File {
val direct: File val direct = File("storage/emulated/0/${Environment.DIRECTORY_DOWNLOADS}/Dantotsu/")
if (PrefManager.getVal(PrefName.SdDl)) {
val arrayOfFiles = ContextCompat.getExternalFilesDirs(context, null)
val parentDirectory = arrayOfFiles[1].toString()
direct = File(parentDirectory)
if (!direct.exists()) direct.mkdirs() if (!direct.exists()) direct.mkdirs()
} else {
direct = File("storage/emulated/0/${Environment.DIRECTORY_DOWNLOADS}/Dantotsu/")
if (!direct.exists()) direct.mkdirs()
}
return direct return direct
} }
@ -96,49 +88,7 @@ object Download {
when (PrefManager.getVal(PrefName.DownloadManager) as Int) { when (PrefManager.getVal(PrefName.DownloadManager) as Int) {
1 -> oneDM(context, file, notif ?: fileName) 1 -> oneDM(context, file, notif ?: fileName)
2 -> adm(context, file, fileName, folder) 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())
}
} }
} }

View file

@ -12,7 +12,6 @@ import ani.dantotsu.BottomSheetDialogFragment
import ani.dantotsu.FileUrl import ani.dantotsu.FileUrl
import ani.dantotsu.R import ani.dantotsu.R
import ani.dantotsu.databinding.BottomSheetImageBinding 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.loadBitmap
import ani.dantotsu.media.manga.mangareader.BaseImageAdapter.Companion.loadBitmapOld import ani.dantotsu.media.manga.mangareader.BaseImageAdapter.Companion.loadBitmapOld
import ani.dantotsu.media.manga.mangareader.BaseImageAdapter.Companion.mergeBitmap import ani.dantotsu.media.manga.mangareader.BaseImageAdapter.Companion.mergeBitmap
@ -22,6 +21,7 @@ import ani.dantotsu.setSafeOnClickListener
import ani.dantotsu.shareImage import ani.dantotsu.shareImage
import ani.dantotsu.snackString import ani.dantotsu.snackString
import ani.dantotsu.toast import ani.dantotsu.toast
import ani.dantotsu.util.StoragePermissions.Companion.downloadsPermission
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.ImageSource
import kotlinx.coroutines.launch import kotlinx.coroutines.launch

View file

@ -1,9 +1,12 @@
package ani.dantotsu.parsers package ani.dantotsu.parsers
import android.app.Application
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import ani.dantotsu.currContext import ani.dantotsu.currContext
import ani.dantotsu.download.DownloadsManager 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.MediaType
import ani.dantotsu.media.MediaNameAdapter import ani.dantotsu.media.MediaNameAdapter
import ani.dantotsu.tryWithSuspend import ani.dantotsu.tryWithSuspend
@ -18,6 +21,7 @@ import java.util.Locale
class OfflineAnimeParser : AnimeParser() { class OfflineAnimeParser : AnimeParser() {
private val downloadManager = Injekt.get<DownloadsManager>() private val downloadManager = Injekt.get<DownloadsManager>()
private val context = Injekt.get<Application>()
override val name = "Offline" override val name = "Offline"
override val saveName = "Offline" override val saveName = "Offline"
@ -29,22 +33,19 @@ class OfflineAnimeParser : AnimeParser() {
extra: Map<String, String>?, extra: Map<String, String>?,
sAnime: SAnime sAnime: SAnime
): List<Episode> { ): List<Episode> {
val directory = File( val directory = getSubDirectory(context, MediaType.ANIME, false, animeLink)
currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"${DownloadsManager.animeLocation}/$animeLink"
)
//get all of the folder names and add them to the list //get all of the folder names and add them to the list
val episodes = mutableListOf<Episode>() val episodes = mutableListOf<Episode>()
if (directory.exists()) { if (directory?.exists() == true) {
directory.listFiles()?.forEach { directory.listFiles().forEach {
//put the title and episdode number in the extra data //put the title and episdode number in the extra data
val extraData = mutableMapOf<String, String>() val extraData = mutableMapOf<String, String>()
extraData["title"] = animeLink extraData["title"] = animeLink
extraData["episode"] = it.name extraData["episode"] = it.name!!
if (it.isDirectory) { if (it.isDirectory) {
val episode = Episode( val episode = Episode(
it.name, it.name!!,
"$animeLink - ${it.name}", getTaskName(animeLink,it.name!!),
it.name, it.name,
null, null,
null, null,
@ -131,18 +132,19 @@ class OfflineVideoExtractor(val videoServer: VideoServer) : VideoExtractor() {
private fun getSubtitle(title: String, episode: String): List<Subtitle>? { private fun getSubtitle(title: String, episode: String): List<Subtitle>? {
currContext()?.let { currContext()?.let {
DownloadsManager.getDirectory( DownloadsManager.getSubDirectory(
it, it,
MediaType.ANIME, MediaType.ANIME,
false,
title, title,
episode episode
).listFiles()?.forEach { file -> )?.listFiles()?.forEach { file ->
if (file.name.contains("subtitle")) { if (file.name?.contains("subtitle") == true) {
return listOf( return listOf(
Subtitle( Subtitle(
"Downloaded Subtitle", "Downloaded Subtitle",
Uri.fromFile(file).toString(), file.uri.toString(),
determineSubtitletype(file.absolutePath) determineSubtitletype(file.name ?: "")
) )
) )
} }

View file

@ -1,9 +1,12 @@
package ani.dantotsu.parsers package ani.dantotsu.parsers
import android.app.Application
import android.os.Environment import android.os.Environment
import ani.dantotsu.currContext import ani.dantotsu.currContext
import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory
import ani.dantotsu.media.MediaNameAdapter import ani.dantotsu.media.MediaNameAdapter
import ani.dantotsu.media.MediaType
import ani.dantotsu.util.Logger import ani.dantotsu.util.Logger
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
@ -14,6 +17,7 @@ import java.io.File
class OfflineMangaParser : MangaParser() { class OfflineMangaParser : MangaParser() {
private val downloadManager = Injekt.get<DownloadsManager>() private val downloadManager = Injekt.get<DownloadsManager>()
private val context = Injekt.get<Application>()
override val hostUrl: String = "Offline" override val hostUrl: String = "Offline"
override val name: String = "Offline" override val name: String = "Offline"
@ -23,17 +27,14 @@ class OfflineMangaParser : MangaParser() {
extra: Map<String, String>?, extra: Map<String, String>?,
sManga: SManga sManga: SManga
): List<MangaChapter> { ): List<MangaChapter> {
val directory = File( val directory = getSubDirectory(context, MediaType.MANGA, false, mangaLink)
currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"Dantotsu/Manga/$mangaLink"
)
//get all of the folder names and add them to the list //get all of the folder names and add them to the list
val chapters = mutableListOf<MangaChapter>() val chapters = mutableListOf<MangaChapter>()
if (directory.exists()) { if (directory?.exists() == true) {
directory.listFiles()?.forEach { directory.listFiles().forEach {
if (it.isDirectory) { if (it.isDirectory) {
val chapter = MangaChapter( val chapter = MangaChapter(
it.name, it.name!!,
"$mangaLink/${it.name}", "$mangaLink/${it.name}",
it.name, it.name,
null, null,
@ -50,16 +51,15 @@ class OfflineMangaParser : MangaParser() {
} }
override suspend fun loadImages(chapterLink: String, sChapter: SChapter): List<MangaImage> { override suspend fun loadImages(chapterLink: String, sChapter: SChapter): List<MangaImage> {
val directory = File( val title = chapterLink.split("/").first()
currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), val chapter = chapterLink.split("/").last()
"Dantotsu/Manga/$chapterLink" val directory = getSubDirectory(context, MediaType.MANGA, false, title, chapter)
)
val images = mutableListOf<MangaImage>() val images = mutableListOf<MangaImage>()
val imageNumberRegex = Regex("""(\d+)\.jpg$""") val imageNumberRegex = Regex("""(\d+)\.jpg$""")
if (directory.exists()) { if (directory?.exists() == true) {
directory.listFiles()?.forEach { directory.listFiles().forEach {
if (it.isFile) { if (it.isFile) {
val image = MangaImage(it.absolutePath, false, null) val image = MangaImage(it.uri.toString(), false, null)
images.add(image) images.add(image)
} }
} }

View file

@ -1,9 +1,12 @@
package ani.dantotsu.parsers package ani.dantotsu.parsers
import android.app.Application
import android.os.Environment import android.os.Environment
import ani.dantotsu.currContext import ani.dantotsu.currContext
import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory
import ani.dantotsu.media.MediaNameAdapter import ani.dantotsu.media.MediaNameAdapter
import ani.dantotsu.media.MediaType
import me.xdrop.fuzzywuzzy.FuzzySearch import me.xdrop.fuzzywuzzy.FuzzySearch
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -11,6 +14,7 @@ import java.io.File
class OfflineNovelParser : NovelParser() { class OfflineNovelParser : NovelParser() {
private val downloadManager = Injekt.get<DownloadsManager>() private val downloadManager = Injekt.get<DownloadsManager>()
private val context = Injekt.get<Application>()
override val hostUrl: String = "Offline" override val hostUrl: String = "Offline"
override val name: String = "Offline" override val name: String = "Offline"
@ -21,19 +25,16 @@ class OfflineNovelParser : NovelParser() {
override suspend fun loadBook(link: String, extra: Map<String, String>?): Book { override suspend fun loadBook(link: String, extra: Map<String, String>?): Book {
//link should be a directory //link should be a directory
val directory = File( val directory = getSubDirectory(context, MediaType.NOVEL, false, link)
currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"Dantotsu/Novel/$link"
)
val chapters = mutableListOf<Book>() val chapters = mutableListOf<Book>()
if (directory.exists()) { if (directory?.exists() == true) {
directory.listFiles()?.forEach { directory.listFiles().forEach {
if (it.isDirectory) { if (it.isDirectory) {
val chapter = Book( val chapter = Book(
it.name, it.name?:"Unknown",
it.absolutePath + "/cover.jpg", it.uri.toString(),
null, null,
listOf(it.absolutePath + "/0.epub") listOf(it.uri.toString())
) )
chapters.add(chapter) chapters.add(chapter)
} }
@ -60,20 +61,16 @@ class OfflineNovelParser : NovelParser() {
val returnList: MutableList<ShowResponse> = mutableListOf() val returnList: MutableList<ShowResponse> = mutableListOf()
for (title in returnTitles) { for (title in returnTitles) {
//need to search the subdirectories for the ShowResponses //need to search the subdirectories for the ShowResponses
val directory = File( val directory = getSubDirectory(context, MediaType.NOVEL, false, title)
currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"Dantotsu/Novel/$title"
)
val names = mutableListOf<String>() val names = mutableListOf<String>()
if (directory.exists()) { if (directory?.exists() == true) {
directory.listFiles()?.forEach { directory.listFiles().forEach {
if (it.isDirectory) { if (it.isDirectory) {
names.add(it.name) names.add(it.name?: "Unknown")
} }
} }
} }
val cover = val cover = directory?.findFile("cover.jpg")?.uri.toString()
currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.absolutePath + "/Dantotsu/Novel/$title/cover.jpg"
names.forEach { names.forEach {
returnList.add(ShowResponse(it, it, cover)) returnList.add(ShowResponse(it, it, cover))
} }

View file

@ -5,6 +5,7 @@ import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Build.BRAND import android.os.Build.BRAND
import android.os.Build.DEVICE import android.os.Build.DEVICE
@ -52,8 +53,6 @@ import ani.dantotsu.databinding.ActivitySettingsMangaBinding
import ani.dantotsu.databinding.ActivitySettingsNotificationsBinding import ani.dantotsu.databinding.ActivitySettingsNotificationsBinding
import ani.dantotsu.databinding.ActivitySettingsThemeBinding import ani.dantotsu.databinding.ActivitySettingsThemeBinding
import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.download.video.ExoplayerDownloadService
import ani.dantotsu.downloadsPermission
import ani.dantotsu.initActivity import ani.dantotsu.initActivity
import ani.dantotsu.loadImage import ani.dantotsu.loadImage
import ani.dantotsu.media.MediaType import ani.dantotsu.media.MediaType
@ -82,7 +81,10 @@ import ani.dantotsu.startMainActivity
import ani.dantotsu.statusBarHeight import ani.dantotsu.statusBarHeight
import ani.dantotsu.themes.ThemeManager import ani.dantotsu.themes.ThemeManager
import ani.dantotsu.toast import ani.dantotsu.toast
import ani.dantotsu.util.LauncherWrapper
import ani.dantotsu.util.Logger 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.bottomsheet.BottomSheetDialog
import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputEditText
import eltos.simpledialogfragment.SimpleDialog import eltos.simpledialogfragment.SimpleDialog
@ -91,7 +93,9 @@ import eltos.simpledialogfragment.color.SimpleColorDialog
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
import io.noties.markwon.Markwon import io.noties.markwon.Markwon
import io.noties.markwon.SoftBreakAddsNewLinePlugin import io.noties.markwon.SoftBreakAddsNewLinePlugin
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@ -104,6 +108,7 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene
override fun handleOnBackPressed() = startMainActivity(this@SettingsActivity) override fun handleOnBackPressed() = startMainActivity(this@SettingsActivity)
} }
lateinit var binding: ActivitySettingsBinding lateinit var binding: ActivitySettingsBinding
lateinit var launcher: LauncherWrapper
private lateinit var bindingAccounts: ActivitySettingsAccountsBinding private lateinit var bindingAccounts: ActivitySettingsAccountsBinding
private lateinit var bindingTheme: ActivitySettingsThemeBinding private lateinit var bindingTheme: ActivitySettingsThemeBinding
private lateinit var bindingExtensions: ActivitySettingsExtensionsBinding private lateinit var bindingExtensions: ActivitySettingsExtensionsBinding
@ -115,6 +120,7 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene
private val extensionInstaller = Injekt.get<BasePreferences>().extensionInstaller() private val extensionInstaller = Injekt.get<BasePreferences>().extensionInstaller()
private var cursedCounter = 0 private var cursedCounter = 0
@kotlin.OptIn(DelicateCoroutinesApi::class)
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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.text = getString(R.string.version_current, BuildConfig.VERSION_NAME)
binding.settingsVersion.setOnLongClickListener { binding.settingsVersion.setOnLongClickListener {
@ -457,11 +465,6 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene
.setPositiveButton(R.string.yes) { dialog, _ -> .setPositiveButton(R.string.yes) { dialog, _ ->
val downloadsManager = Injekt.get<DownloadsManager>() val downloadsManager = Injekt.get<DownloadsManager>()
downloadsManager.purgeDownloads(MediaType.ANIME) downloadsManager.purgeDownloads(MediaType.ANIME)
DownloadService.sendRemoveAllDownloads(
this@SettingsActivity,
ExoplayerDownloadService::class.java,
false
)
dialog.dismiss() dialog.dismiss()
} }
.setNegativeButton(R.string.no) { dialog, _ -> .setNegativeButton(R.string.no) { dialog, _ ->
@ -724,20 +727,6 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene
restartApp(binding.root) 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.isChecked = PrefManager.getVal(PrefName.ContinueMedia)
settingsContinueMedia.setOnCheckedChangeListener { _, isChecked -> settingsContinueMedia.setOnCheckedChangeListener { _, isChecked ->
PrefManager.setVal(PrefName.ContinueMedia, isChecked) PrefManager.setVal(PrefName.ContinueMedia, isChecked)
@ -757,6 +746,44 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene
PrefManager.setVal(PrefName.AdultOnly, isChecked) PrefManager.setVal(PrefName.AdultOnly, isChecked)
restartApp(binding.root) 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<String>(PrefName.DownloadsDir)
launcher.registerForCallback { success ->
if (success) {
toast(getString(R.string.please_wait))
val newUri = PrefManager.getVal<String>(PrefName.DownloadsDir)
GlobalScope.launch(Dispatchers.IO) {
Injekt.get<DownloadsManager>().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<Int>(PrefName.DefaultStartUpTab)) { var previousStart: View = when (PrefManager.getVal<Int>(PrefName.DefaultStartUpTab)) {
0 -> uiSettingsAnime 0 -> uiSettingsAnime
1 -> uiSettingsHome 1 -> uiSettingsHome

View file

@ -13,7 +13,6 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files
OfflineView(Pref(Location.General, Int::class, 0)), OfflineView(Pref(Location.General, Int::class, 0)),
DownloadManager(Pref(Location.General, Int::class, 0)), DownloadManager(Pref(Location.General, Int::class, 0)),
NSFWExtension(Pref(Location.General, Boolean::class, true)), NSFWExtension(Pref(Location.General, Boolean::class, true)),
SdDl(Pref(Location.General, Boolean::class, false)),
ContinueMedia(Pref(Location.General, Boolean::class, true)), ContinueMedia(Pref(Location.General, Boolean::class, true)),
SearchSources(Pref(Location.General, Boolean::class, true)), SearchSources(Pref(Location.General, Boolean::class, true)),
RecentlyListOnly(Pref(Location.General, Boolean::class, false)), 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)), RecentGlobalNotification(Pref(Location.Irrelevant, Int::class, 0)),
CommentNotificationStore(Pref(Location.Irrelevant, List::class, listOf<CommentStore>())), CommentNotificationStore(Pref(Location.Irrelevant, List::class, listOf<CommentStore>())),
UnreadCommentNotifications(Pref(Location.Irrelevant, Int::class, 0)), UnreadCommentNotifications(Pref(Location.Irrelevant, Int::class, 0)),
DownloadsDir(Pref(Location.Irrelevant, String::class, "")),
//Protected //Protected
DiscordToken(Pref(Location.Protected, String::class, "")), DiscordToken(Pref(Location.Protected, String::class, "")),

View file

@ -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<String>(PrefName.DownloadsDir)
return hasDirAccess(context, path)
}
fun AppCompatActivity.accessAlertDialog(launcher: LauncherWrapper,
force: Boolean = false,
complete: (Boolean) -> Unit
) {
if ((PrefManager.getVal<String>(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<Uri?>
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)
}
}

View file

@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.Video import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper 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.asObservableSuccess
import eu.kanade.tachiyomi.network.newCachelessCallWithProgress import eu.kanade.tachiyomi.network.newCachelessCallWithProgress
import okhttp3.Headers import okhttp3.Headers
@ -69,7 +70,7 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
* Headers builder for requests. Implementations can override this method for custom headers. * Headers builder for requests. Implementations can override this method for custom headers.
*/ */
protected open fun headersBuilder() = Headers.Builder().apply { protected open fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", network.defaultUserAgentProvider()) add("User-Agent", defaultUserAgentProvider())
} }
/** /**

View file

@ -89,5 +89,7 @@ class NetworkHelper(
responseParser = Mapper responseParser = Mapper
) )
companion object {
fun defaultUserAgentProvider() = PrefManager.getVal<String>(PrefName.DefaultUserAgent) fun defaultUserAgentProvider() = PrefManager.getVal<String>(PrefName.DefaultUserAgent)
}
} }

View file

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper 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.asObservableSuccess
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.newCachelessCallWithProgress 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. * Headers builder for requests. Implementations can override this method for custom headers.
*/ */
protected open fun headersBuilder() = Headers.Builder().apply { protected open fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", network.defaultUserAgentProvider()) add("User-Agent", defaultUserAgentProvider())
} }
/** /**

View file

@ -191,27 +191,6 @@
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
android:background="?android:attr/listDivider" /> android:background="?android:attr/listDivider" />
<!--TODO: Add support for SD card-->
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/settingsDownloadInSd"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="false"
android:drawableStart="@drawable/ic_round_sd_card_24"
android:drawablePadding="16dp"
android:elegantTextHeight="true"
android:enabled="false"
android:fontFamily="@font/poppins_bold"
android:minHeight="64dp"
android:text="@string/downloadInSd"
android:textAlignment="viewStart"
android:textColor="?attr/colorOnBackground"
android:visibility="gone"
app:cornerRadius="0dp"
app:drawableTint="?attr/colorPrimary"
app:showText="false"
app:thumbTint="@color/button_switch_track" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -317,5 +296,29 @@
app:drawableTint="?attr/colorPrimary" app:drawableTint="?attr/colorPrimary"
app:showText="false" app:showText="false"
app:thumbTint="@color/button_switch_track" /> app:thumbTint="@color/button_switch_track" />
<Button
android:id="@+id/settingsDownloadLocation"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginStart="-31dp"
android:layout_marginEnd="-31dp"
android:background="@drawable/ui_bg"
android:backgroundTint="?attr/colorSecondary"
android:backgroundTintMode="src_atop"
android:fontFamily="@font/poppins_bold"
android:insetTop="0dp"
android:insetBottom="0dp"
android:paddingStart="31dp"
android:paddingEnd="31dp"
android:text="@string/change_download_location"
android:textAlignment="viewStart"
android:textAllCaps="false"
android:textColor="?attr/colorOnBackground"
app:cornerRadius="0dp"
app:icon="@drawable/ic_round_source_24"
app:iconPadding="16dp"
app:iconSize="24dp"
app:iconTint="?attr/colorPrimary" />
</ani.dantotsu.others.Xpandable> </ani.dantotsu.others.Xpandable>
</merge> </merge>

View file

@ -748,7 +748,9 @@
<string name="follows_you">Follows you</string> <string name="follows_you">Follows you</string>
<string name="mutual">Mutual</string> <string name="mutual">Mutual</string>
<string name="success">Success</string> <string name="success">Success</string>
<string name="error">Some error occurred</string>
<string name="error_msg">Error: %1$s</string>
<string name="please_wait">Please wait</string>
<string name="upcoming">Upcoming</string> <string name="upcoming">Upcoming</string>
<string name="no_shows_to_display">No shows to display</string> <string name="no_shows_to_display">No shows to display</string>
<string name="extension_name">Extension Name</string> <string name="extension_name">Extension Name</string>
@ -866,6 +868,11 @@ Non quae tempore quo provident laudantium qui illo dolor vel quia dolor et exerc
<string name="trending_manhwa">Trending Manhwa</string> <string name="trending_manhwa">Trending Manhwa</string>
<string name="liked_by">Liked By</string> <string name="liked_by">Liked By</string>
<string name="adult_only_content">Adult only content</string> <string name="adult_only_content">Adult only content</string>
<string name="dir_error">Your path could not be set</string>
<string name="dir_access">Downloads access</string>
<string name="dir_access_msg">Please choose a directory to save your downloads</string>
<string name="change_download_location">Change Download Location</string>
<string name="download_location_msg">Are you sure you want to change the download location?\nOld downloads may no longer be accessible.</string>
<string name="report">Report</string> <string name="report">Report</string>
<string name="ban">Ban</string> <string name="ban">Ban</string>
</resources> </resources>