offline novel

This commit is contained in:
Finnley Somdahl 2023-12-03 22:18:06 -06:00
parent 111fb16266
commit 3ded6ba87a
18 changed files with 512 additions and 212 deletions

View file

@ -48,6 +48,32 @@ class DownloadsManager(private val context: Context) {
saveDownloads() saveDownloads()
} }
fun removeMedia(title: String, type: Download.Type) {
val subDirectory = if (type == Download.Type.MANGA) {
"Manga"
} else if (type == Download.Type.ANIME) {
"Anime"
} else {
"Novel"
}
val directory = File(
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"Dantotsu/$subDirectory/$title"
)
if (directory.exists()) {
val deleted = directory.deleteRecursively()
if (deleted) {
Toast.makeText(context, "Successfully deleted", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Failed to delete directory", Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(context, "Directory does not exist", Toast.LENGTH_SHORT).show()
}
downloadsList.removeAll { it.title == title }
saveDownloads()
}
fun queryDownload(download: Download): Boolean { fun queryDownload(download: Download): Boolean {
return downloadsList.contains(download) return downloadsList.contains(download)
} }

View file

@ -20,6 +20,7 @@ import androidx.core.content.ContextCompat
import ani.dantotsu.R import ani.dantotsu.R
import ani.dantotsu.download.Download import ani.dantotsu.download.Download
import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.logger
import ani.dantotsu.media.Media import ani.dantotsu.media.Media
import ani.dantotsu.media.manga.ImageData import ani.dantotsu.media.manga.ImageData
import ani.dantotsu.media.manga.MangaReadFragment.Companion.ACTION_DOWNLOAD_FAILED import ani.dantotsu.media.manga.MangaReadFragment.Companion.ACTION_DOWNLOAD_FAILED
@ -175,74 +176,90 @@ class MangaDownloaderService : Service() {
} }
suspend fun download(task: DownloadTask) { suspend fun download(task: DownloadTask) {
withContext(Dispatchers.Main) { try {
val notifi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { withContext(Dispatchers.Main) {
ContextCompat.checkSelfPermission( val notifi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
this@MangaDownloaderService, ContextCompat.checkSelfPermission(
Manifest.permission.POST_NOTIFICATIONS this@MangaDownloaderService,
) == PackageManager.PERMISSION_GRANTED Manifest.permission.POST_NOTIFICATIONS
} else { ) == PackageManager.PERMISSION_GRANTED
true } else {
} true
val deferredList = mutableListOf<Deferred<Bitmap?>>()
builder.setContentText("Downloading ${task.title} - ${task.chapter}")
if (notifi) {
notificationManager.notify(NOTIFICATION_ID, builder.build())
}
// Loop through each ImageData object from the task
var farthest = 0
for ((index, image) in task.imageData.withIndex()) {
// Limit the number of simultaneous downloads from the task
if (deferredList.size >= task.simultaneousDownloads) {
// Wait for all deferred to complete and clear the list
deferredList.awaitAll()
deferredList.clear()
} }
// Download the image and add to deferred list val deferredList = mutableListOf<Deferred<Bitmap?>>()
val deferred = async(Dispatchers.IO) { builder.setContentText("Downloading ${task.title} - ${task.chapter}")
var bitmap: Bitmap? = null if (notifi) {
var retryCount = 0 notificationManager.notify(NOTIFICATION_ID, builder.build())
}
while (bitmap == null && retryCount < task.retries) { // Loop through each ImageData object from the task
bitmap = image.fetchAndProcessImage( var farthest = 0
image.page, for ((index, image) in task.imageData.withIndex()) {
image.source, // Limit the number of simultaneous downloads from the task
this@MangaDownloaderService if (deferredList.size >= task.simultaneousDownloads) {
// Wait for all deferred to complete and clear the list
deferredList.awaitAll()
deferredList.clear()
}
// Download the image and add to deferred list
val deferred = async(Dispatchers.IO) {
var bitmap: Bitmap? = null
var retryCount = 0
while (bitmap == null && retryCount < task.retries) {
bitmap = image.fetchAndProcessImage(
image.page,
image.source,
this@MangaDownloaderService
)
retryCount++
}
// Cache the image if successful
if (bitmap != null) {
saveToDisk("$index.jpg", bitmap, task.title, task.chapter)
}
farthest++
builder.setProgress(task.imageData.size, farthest, false)
broadcastDownloadProgress(
task.chapter,
farthest * 100 / task.imageData.size
) )
retryCount++ if (notifi) {
notificationManager.notify(NOTIFICATION_ID, builder.build())
}
bitmap
} }
// Cache the image if successful deferredList.add(deferred)
if (bitmap != null) {
saveToDisk("$index.jpg", bitmap, task.title, task.chapter)
}
farthest++
builder.setProgress(task.imageData.size, farthest, false)
broadcastDownloadProgress(task.chapter, farthest * 100 / task.imageData.size)
if (notifi) {
notificationManager.notify(NOTIFICATION_ID, builder.build())
}
bitmap
} }
deferredList.add(deferred) // Wait for any remaining deferred to complete
deferredList.awaitAll()
builder.setContentText("${task.title} - ${task.chapter} Download complete")
.setProgress(0, 0, false)
notificationManager.notify(NOTIFICATION_ID, builder.build())
saveMediaInfo(task)
downloadsManager.addDownload(
Download(
task.title,
task.chapter,
Download.Type.MANGA
)
)
broadcastDownloadFinished(task.chapter)
snackString("${task.title} - ${task.chapter} Download finished")
} }
} catch (e: Exception) {
// Wait for any remaining deferred to complete logger("Exception while downloading file: ${e.message}")
deferredList.awaitAll() snackString("Exception while downloading file: ${e.message}")
FirebaseCrashlytics.getInstance().recordException(e)
builder.setContentText("${task.title} - ${task.chapter} Download complete") broadcastDownloadFailed(task.chapter)
.setProgress(0, 0, false)
notificationManager.notify(NOTIFICATION_ID, builder.build())
saveMediaInfo(task)
downloadsManager.addDownload(Download(task.title, task.chapter, Download.Type.MANGA))
broadcastDownloadFinished(task.chapter)
snackString("${task.title} - ${task.chapter} Download finished")
} }
} }

View file

@ -13,10 +13,12 @@ import ani.dantotsu.R
class OfflineMangaAdapter( class OfflineMangaAdapter(
private val context: Context, private val context: Context,
private val items: List<OfflineMangaModel> private var items: List<OfflineMangaModel>,
private val searchListener: OfflineMangaSearchListener
) : BaseAdapter() { ) : BaseAdapter() {
private val inflater: LayoutInflater = private val inflater: LayoutInflater =
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
private var originalItems: List<OfflineMangaModel> = items
override fun getCount(): Int { override fun getCount(): Int {
return items.size return items.size
@ -54,4 +56,22 @@ class OfflineMangaAdapter(
} }
return view return view
} }
fun onSearchQuery(query: String) {
// Implement the filtering logic here, for example:
items = if (query.isEmpty()) {
// Return the original list if the query is empty
originalItems
} else {
// Filter the list based on the query
originalItems.filter { it.title.contains(query, ignoreCase = true) }
}
notifyDataSetChanged() // Notify the adapter that the data set has changed
}
fun setItems(items: List<OfflineMangaModel>) {
this.items = items
this.originalItems = items
notifyDataSetChanged()
}
} }

View file

@ -7,11 +7,14 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment import android.os.Environment
import android.text.Editable
import android.text.TextWatcher
import android.util.TypedValue import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.OvershootInterpolator import android.view.animation.OvershootInterpolator
import android.widget.AutoCompleteTextView
import android.widget.GridView import android.widget.GridView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.cardview.widget.CardView import androidx.cardview.widget.CardView
@ -41,7 +44,7 @@ import java.io.File
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
class OfflineMangaFragment : Fragment() { class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener {
private val downloadManager = Injekt.get<DownloadsManager>() private val downloadManager = Injekt.get<DownloadsManager>()
private var downloads: List<OfflineMangaModel> = listOf() private var downloads: List<OfflineMangaModel> = listOf()
private lateinit var gridView: GridView private lateinit var gridView: GridView
@ -79,15 +82,29 @@ class OfflineMangaFragment : Fragment() {
materialCardView.setCardBackgroundColor((color and 0x00FFFFFF) or 0x28000000.toInt()) materialCardView.setCardBackgroundColor((color and 0x00FFFFFF) or 0x28000000.toInt())
} }
val searchView = view.findViewById<AutoCompleteTextView>(R.id.animeSearchBarText)
searchView.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
onSearchQuery(s.toString())
}
})
gridView = view.findViewById(R.id.gridView) gridView = view.findViewById(R.id.gridView)
getDownloads() getDownloads()
adapter = OfflineMangaAdapter(requireContext(), downloads) adapter = OfflineMangaAdapter(requireContext(), downloads, this)
gridView.adapter = adapter gridView.adapter = adapter
gridView.setOnItemClickListener { parent, view, position, id -> gridView.setOnItemClickListener { parent, view, position, id ->
// Get the OfflineMangaModel that was clicked // Get the OfflineMangaModel that was clicked
val item = adapter.getItem(position) as OfflineMangaModel val item = adapter.getItem(position) as OfflineMangaModel
val media = val media =
downloadManager.mangaDownloads.filter { it.title == item.title }.firstOrNull() downloadManager.mangaDownloads.firstOrNull { it.title == item.title }
?: downloadManager.novelDownloads.firstOrNull { it.title == item.title }
media?.let { media?.let {
startActivity( startActivity(
Intent(requireContext(), MediaDetailsActivity::class.java) Intent(requireContext(), MediaDetailsActivity::class.java)
@ -99,9 +116,37 @@ class OfflineMangaFragment : Fragment() {
} }
} }
gridView.setOnItemLongClickListener { parent, view, position, id ->
// Get the OfflineMangaModel that was clicked
val item = adapter.getItem(position) as OfflineMangaModel
val type: Download.Type = if (downloadManager.mangaDownloads.any { it.title == item.title }) {
Download.Type.MANGA
} else {
Download.Type.NOVEL
}
// Alert dialog to confirm deletion
val builder = androidx.appcompat.app.AlertDialog.Builder(requireContext(), R.style.DialogTheme)
builder.setTitle("Delete ${item.title}?")
builder.setMessage("Are you sure you want to delete ${item.title}?")
builder.setPositiveButton("Yes") { _, _ ->
downloadManager.removeMedia(item.title, type)
getDownloads()
adapter.setItems(downloads)
}
builder.setNegativeButton("No") { _, _ ->
// Do nothing
}
builder.show()
true
}
return view return view
} }
override fun onSearchQuery(query: String) {
adapter.onSearchQuery(query)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
var height = statusBarHeight var height = statusBarHeight
@ -164,24 +209,42 @@ class OfflineMangaFragment : Fragment() {
} }
private fun getDownloads() { private fun getDownloads() {
val titles = downloadManager.mangaDownloads.map { it.title }.distinct() downloads = listOf()
val newDownloads = mutableListOf<OfflineMangaModel>() val mangaTitles = downloadManager.mangaDownloads.map { it.title }.distinct()
for (title in titles) { val newMangaDownloads = mutableListOf<OfflineMangaModel>()
for (title in mangaTitles) {
val _downloads = downloadManager.mangaDownloads.filter { it.title == title } val _downloads = downloadManager.mangaDownloads.filter { it.title == title }
val download = _downloads.first() val download = _downloads.first()
val offlineMangaModel = loadOfflineMangaModel(download) val offlineMangaModel = loadOfflineMangaModel(download)
newDownloads += offlineMangaModel newMangaDownloads += offlineMangaModel
} }
downloads = newDownloads downloads = newMangaDownloads
val novelTitles = downloadManager.novelDownloads.map { it.title }.distinct()
val newNovelDownloads = mutableListOf<OfflineMangaModel>()
for (title in novelTitles) {
val _downloads = downloadManager.novelDownloads.filter { it.title == title }
val download = _downloads.first()
val offlineMangaModel = loadOfflineMangaModel(download)
newNovelDownloads += offlineMangaModel
}
downloads += newNovelDownloads
} }
private fun getMedia(download: Download): Media? { private fun getMedia(download: Download): Media? {
val type = if (download.type == Download.Type.MANGA) {
"Manga"
} else if (download.type == Download.Type.ANIME) {
"Anime"
} else {
"Novel"
}
val directory = File( val directory = File(
currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"Dantotsu/Manga/${download.title}" "Dantotsu/$type/${download.title}"
) )
//load media.json and convert to media class with gson //load media.json and convert to media class with gson
try { return try {
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
@ -189,19 +252,26 @@ class OfflineMangaFragment : Fragment() {
.create() .create()
val media = File(directory, "media.json") val media = File(directory, "media.json")
val mediaJson = media.readText() val mediaJson = media.readText()
return gson.fromJson(mediaJson, Media::class.java) gson.fromJson(mediaJson, Media::class.java)
} catch (e: Exception) { } catch (e: Exception) {
logger("Error loading media.json: ${e.message}") logger("Error loading media.json: ${e.message}")
logger(e.printStackTrace()) logger(e.printStackTrace())
FirebaseCrashlytics.getInstance().recordException(e) FirebaseCrashlytics.getInstance().recordException(e)
return null null
} }
} }
private fun loadOfflineMangaModel(download: Download): OfflineMangaModel { private fun loadOfflineMangaModel(download: Download): OfflineMangaModel {
val type = if (download.type == Download.Type.MANGA) {
"Manga"
} else if (download.type == Download.Type.ANIME) {
"Anime"
} else {
"Novel"
}
val directory = File( val directory = File(
currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"Dantotsu/Manga/${download.title}" "Dantotsu/$type/${download.title}"
) )
//load media.json and convert to media class with gson //load media.json and convert to media class with gson
try { try {
@ -215,8 +285,8 @@ class OfflineMangaFragment : Fragment() {
null null
} }
val title = mediaModel.nameMAL ?: "unknown" val title = mediaModel.nameMAL ?: "unknown"
val score = if (mediaModel.userScore != 0) mediaModel.userScore.toString() else val score = ((if (mediaModel.userScore == 0) (mediaModel.meanScore
if (mediaModel.meanScore == null) "?" else mediaModel.meanScore.toString() ?: 0) else mediaModel.userScore) / 10.0).toString()
val isOngoing = false val isOngoing = false
val isUserScored = mediaModel.userScore != 0 val isUserScored = mediaModel.userScore != 0
return OfflineMangaModel(title, score, isOngoing, isUserScored, coverUri) return OfflineMangaModel(title, score, isOngoing, isUserScored, coverUri)
@ -227,4 +297,8 @@ class OfflineMangaFragment : Fragment() {
return OfflineMangaModel("unknown", "0", false, false, null) return OfflineMangaModel("unknown", "0", false, false, null)
} }
} }
}
interface OfflineMangaSearchListener {
fun onSearchQuery(query: String)
} }

View file

@ -174,7 +174,7 @@ class NovelDownloaderService : Service() {
notificationManager.notify(NOTIFICATION_ID, builder.build()) notificationManager.notify(NOTIFICATION_ID, builder.build())
} }
suspend fun isEpubFile(urlString: String): Boolean { private suspend fun isEpubFile(urlString: String): Boolean {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val request = Request.Builder() val request = Request.Builder()
@ -200,122 +200,150 @@ class NovelDownloaderService : Service() {
} }
} }
private fun isAlreadyDownloaded(urlString: String): Boolean {
return urlString.contains("file://")
}
suspend fun download(task: DownloadTask) { suspend fun download(task: DownloadTask) {
withContext(Dispatchers.Main) { try {
val notifi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { withContext(Dispatchers.Main) {
ContextCompat.checkSelfPermission( val notifi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
this@NovelDownloaderService, ContextCompat.checkSelfPermission(
Manifest.permission.POST_NOTIFICATIONS this@NovelDownloaderService,
) == PackageManager.PERMISSION_GRANTED Manifest.permission.POST_NOTIFICATIONS
} else { ) == PackageManager.PERMISSION_GRANTED
true } else {
} true
}
broadcastDownloadStarted(task.originalLink) broadcastDownloadStarted(task.originalLink)
if (notifi) { if (notifi) {
builder.setContentText("Downloading ${task.title} - ${task.chapter}") builder.setContentText("Downloading ${task.title} - ${task.chapter}")
notificationManager.notify(NOTIFICATION_ID, builder.build()) notificationManager.notify(NOTIFICATION_ID, builder.build())
} }
if (!isEpubFile(task.downloadLink)) { if (!isEpubFile(task.downloadLink)) {
logger("Download link is not an .epub file") if (isAlreadyDownloaded(task.originalLink)) {
broadcastDownloadFailed(task.originalLink) logger("Already downloaded")
snackString("Download link is not an .epub file") broadcastDownloadFinished(task.originalLink)
return@withContext snackString("Already downloaded")
} return@withContext
}
logger("Download link is not an .epub file")
broadcastDownloadFailed(task.originalLink)
snackString("Download link is not an .epub file")
return@withContext
}
// Start the download // Start the download
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val request = Request.Builder() val request = Request.Builder()
.url(task.downloadLink) .url(task.downloadLink)
.build() .build()
networkHelper.downloadClient.newCall(request).execute().use { response -> networkHelper.downloadClient.newCall(request).execute().use { response ->
// Ensure the response is successful and has a body // Ensure the response is successful and has a body
if (!response.isSuccessful || response.body == null) { if (!response.isSuccessful || response.body == null) {
throw IOException("Failed to download file: ${response.message}") throw IOException("Failed to download file: ${response.message}")
} }
val file = File( val file = File(
this@NovelDownloaderService.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), this@NovelDownloaderService.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"Dantotsu/Novel/${task.title}/${task.chapter}/0.epub" "Dantotsu/Novel/${task.title}/${task.chapter}/0.epub"
) )
// Create directories if they don't exist // Create directories if they don't exist
file.parentFile?.takeIf { !it.exists() }?.mkdirs() file.parentFile?.takeIf { !it.exists() }?.mkdirs()
// Overwrite existing file // Overwrite existing file
if (file.exists()) file.delete() 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 sink = file.sink().buffer() val sink = file.sink().buffer()
val responseBody = response.body val responseBody = response.body
val totalBytes = responseBody.contentLength() val totalBytes = responseBody.contentLength()
var downloadedBytes = 0L var downloadedBytes = 0L
val notificationUpdateInterval = 1024 * 1024 // 1 MB val notificationUpdateInterval = 1024 * 1024 // 1 MB
val broadcastUpdateInterval = 1024 * 256 // 256 KB val broadcastUpdateInterval = 1024 * 256 // 256 KB
var lastNotificationUpdate = 0L var lastNotificationUpdate = 0L
var lastBroadcastUpdate = 0L var lastBroadcastUpdate = 0L
responseBody.source().use { source -> responseBody.source().use { source ->
while (true) { while (true) {
val read = source.read(sink.buffer, 8192) val read = source.read(sink.buffer, 8192)
if (read == -1L) break if (read == -1L) break
downloadedBytes += read downloadedBytes += read
sink.emit() sink.emit()
// Update progress at intervals // Update progress at intervals
if (downloadedBytes - lastNotificationUpdate >= notificationUpdateInterval) { if (downloadedBytes - lastNotificationUpdate >= notificationUpdateInterval) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val progress = (downloadedBytes * 100 / totalBytes).toInt() val progress =
builder.setProgress(100, progress, false) (downloadedBytes * 100 / totalBytes).toInt()
if (notifi) { builder.setProgress(100, progress, false)
notificationManager.notify( if (notifi) {
NOTIFICATION_ID, notificationManager.notify(
builder.build() NOTIFICATION_ID,
) builder.build()
)
}
} }
lastNotificationUpdate = downloadedBytes
} }
lastNotificationUpdate = downloadedBytes if (downloadedBytes - lastBroadcastUpdate >= broadcastUpdateInterval) {
} withContext(Dispatchers.Main) {
if (downloadedBytes - lastBroadcastUpdate >= broadcastUpdateInterval) { val progress =
withContext(Dispatchers.Main) { (downloadedBytes * 100 / totalBytes).toInt()
val progress = (downloadedBytes * 100 / totalBytes).toInt() logger("Download progress: $progress")
logger("Download progress: $progress") broadcastDownloadProgress(task.originalLink, progress)
broadcastDownloadProgress(task.originalLink, progress) }
lastBroadcastUpdate = downloadedBytes
} }
lastBroadcastUpdate = downloadedBytes
} }
} }
sink.close()
//if the file is smaller than 95% of totalBytes, it means the download was interrupted
if (file.length() < totalBytes * 0.95) {
throw IOException("Failed to download file: ${response.message}")
}
} }
} catch (e: Exception) {
sink.close() logger("Exception while downloading .epub inside request: ${e.message}")
throw e
} }
} catch (e: Exception) {
logger("Exception while downloading .epub: ${e.message}")
snackString("Exception while downloading .epub: ${e.message}")
FirebaseCrashlytics.getInstance().recordException(e)
} }
}
// Update notification for download completion // Update notification for download completion
builder.setContentText("${task.title} - ${task.chapter} Download complete") builder.setContentText("${task.title} - ${task.chapter} Download complete")
.setProgress(0, 0, false) .setProgress(0, 0, false)
if (notifi) { if (notifi) {
notificationManager.notify(NOTIFICATION_ID, builder.build()) notificationManager.notify(NOTIFICATION_ID, builder.build())
} }
saveMediaInfo(task) saveMediaInfo(task)
downloadsManager.addDownload(Download(task.title, task.chapter, Download.Type.NOVEL)) downloadsManager.addDownload(
broadcastDownloadFinished(task.originalLink) Download(
snackString("${task.title} - ${task.chapter} Download finished") task.title,
task.chapter,
Download.Type.NOVEL
)
)
broadcastDownloadFinished(task.originalLink)
snackString("${task.title} - ${task.chapter} Download finished")
}
} catch (e: Exception) {
logger("Exception while downloading .epub: ${e.message}")
snackString("Exception while downloading .epub: ${e.message}")
FirebaseCrashlytics.getInstance().recordException(e)
broadcastDownloadFailed(task.originalLink)
} }
} }

View file

@ -58,9 +58,12 @@ class MediaDetailsViewModel : ViewModel() {
it it
} }
if (isDownload) { if (isDownload) {
data.sourceIndex = when (media.anime != null) { data.sourceIndex = if (media.anime != null) {
true -> AnimeSources.list.size - 1 AnimeSources.list.size - 1
else -> MangaSources.list.size - 1 } else if (media.format == "MANGA" || media.format == "ONE_SHOT") {
MangaSources.list.size - 1
} else {
NovelSources.list.size - 1
} }
} }
return data return data

View file

@ -247,7 +247,8 @@ class NovelReadFragment : Fragment(),
headerAdapter.progress?.visibility = View.VISIBLE headerAdapter.progress?.visibility = View.VISIBLE
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
if (auto || query == "") model.autoSearchNovels(media) if (auto || query == "") model.autoSearchNovels(media)
else model.searchNovels(query, source) //else model.searchNovels(query, source)
else model.autoSearchNovels(media) //testing
} }
searching = true searching = true
if (save) { if (save) {

View file

@ -1,10 +1,12 @@
package ani.dantotsu.media.novel package ani.dantotsu.media.novel
import android.util.Log import android.util.Log
import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import ani.dantotsu.R
import ani.dantotsu.databinding.ItemNovelResponseBinding import ani.dantotsu.databinding.ItemNovelResponseBinding
import ani.dantotsu.parsers.ShowResponse import ani.dantotsu.parsers.ShowResponse
import ani.dantotsu.setAnimation import ani.dantotsu.setAnimation
@ -39,6 +41,10 @@ class NovelResponseAdapter(
Glide.with(binding.itemEpisodeImage).load(cover).override(400, 0) Glide.with(binding.itemEpisodeImage).load(cover).override(400, 0)
.into(binding.itemEpisodeImage) .into(binding.itemEpisodeImage)
val typedValue = TypedValue()
fragment.requireContext().theme?.resolveAttribute(com.google.android.material.R.attr.colorOnBackground, typedValue, true)
val color = typedValue.data
binding.itemEpisodeTitle.text = novel.name binding.itemEpisodeTitle.text = novel.name
binding.itemEpisodeFiller.text = binding.itemEpisodeFiller.text =
if (downloadedCheckCallback.downloadedCheck(novel)) { if (downloadedCheckCallback.downloadedCheck(novel)) {
@ -55,9 +61,7 @@ class NovelResponseAdapter(
fragment.requireContext().getColor(android.R.color.holo_green_light) fragment.requireContext().getColor(android.R.color.holo_green_light)
) )
} else { } else {
binding.itemEpisodeFiller.setTextColor( binding.itemEpisodeFiller.setTextColor(color)
fragment.requireContext().getColor(android.R.color.white)
)
} }
binding.itemEpisodeDesc2.text = novel.extra?.get("1") ?: "" binding.itemEpisodeDesc2.text = novel.extra?.get("1") ?: ""
val desc = novel.extra?.get("2") val desc = novel.extra?.get("2")
@ -94,13 +98,21 @@ class NovelResponseAdapter(
} }
binding.root.setOnLongClickListener { binding.root.setOnLongClickListener {
downloadedCheckCallback.deleteDownload(novel) val builder = androidx.appcompat.app.AlertDialog.Builder(fragment.requireContext(), R.style.DialogTheme)
deleteDownload(novel.link) builder.setTitle("Delete ${novel.name}?")
snackString("Deleted ${novel.name}") builder.setMessage("Are you sure you want to delete ${novel.name}?")
if (binding.itemEpisodeFiller.text.toString().contains("Download", ignoreCase = true)) { builder.setPositiveButton("Yes") { _, _ ->
binding.itemEpisodeFiller.text = "" downloadedCheckCallback.deleteDownload(novel)
deleteDownload(novel.link)
snackString("Deleted ${novel.name}")
if (binding.itemEpisodeFiller.text.toString().contains("Download", ignoreCase = true)) {
binding.itemEpisodeFiller.text = ""
}
} }
notifyItemChanged(position) builder.setNegativeButton("No") { _, _ ->
// Do nothing
}
builder.show()
true true
} }
} }
@ -134,6 +146,8 @@ class NovelResponseAdapter(
downloadedChapters.remove(link) downloadedChapters.remove(link)
val position = list.indexOfFirst { it.link == link } val position = list.indexOfFirst { it.link == link }
if (position != -1) { if (position != -1) {
list[position].extra?.remove("0")
list[position].extra?.set("0", "")
notifyItemChanged(position) notifyItemChanged(position)
} }
} }
@ -143,6 +157,8 @@ class NovelResponseAdapter(
downloadedChapters.remove(link) downloadedChapters.remove(link)
val position = list.indexOfFirst { it.link == link } val position = list.indexOfFirst { it.link == link }
if (position != -1) { if (position != -1) {
list[position].extra?.remove("0")
list[position].extra?.set("0", "Failed")
notifyItemChanged(position) notifyItemChanged(position)
} }
} }

View file

@ -31,8 +31,20 @@ abstract class NovelParser : BaseParser() {
} }
suspend fun sortedSearch(mediaObj: Media): List<ShowResponse> { suspend fun sortedSearch(mediaObj: Media): List<ShowResponse> {
val query = mediaObj.name ?: mediaObj.nameRomaji //val query = mediaObj.name ?: mediaObj.nameRomaji
return search(query).sortByVolume(query) //return search(query).sortByVolume(query)
val results: List<ShowResponse>
return if(mediaObj.name != null) {
val query = mediaObj.name
results = search(query).sortByVolume(query)
results.ifEmpty {
val q = mediaObj.nameRomaji
search(q).sortByVolume(q)
}
} else {
val query = mediaObj.nameRomaji
search(query).sortByVolume(query)
}
} }
} }

View file

@ -13,11 +13,17 @@ object NovelSources : NovelReadSources() {
suspend fun init(fromExtensions: StateFlow<List<NovelExtension.Installed>>) { suspend fun init(fromExtensions: StateFlow<List<NovelExtension.Installed>>) {
// Initialize with the first value from StateFlow // Initialize with the first value from StateFlow
val initialExtensions = fromExtensions.first() val initialExtensions = fromExtensions.first()
list = createParsersFromExtensions(initialExtensions) list = createParsersFromExtensions(initialExtensions) + Lazier(
{ OfflineNovelParser() },
"Downloaded"
)
// Update as StateFlow emits new values // Update as StateFlow emits new values
fromExtensions.collect { extensions -> fromExtensions.collect { extensions ->
list = createParsersFromExtensions(extensions) list = createParsersFromExtensions(extensions) + Lazier(
{ OfflineNovelParser() },
"Downloaded"
)
} }
} }

View file

@ -0,0 +1,86 @@
package ani.dantotsu.parsers
import android.os.Environment
import ani.dantotsu.currContext
import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.logger
import ani.dantotsu.media.manga.MangaNameAdapter
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import me.xdrop.fuzzywuzzy.FuzzySearch
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
class OfflineNovelParser: NovelParser() {
private val downloadManager = Injekt.get<DownloadsManager>()
override val hostUrl: String = "Offline"
override val name: String = "Offline"
override val saveName: String = "Offline"
override val volumeRegex =
Regex("vol\\.? (\\d+(\\.\\d+)?)|volume (\\d+(\\.\\d+)?)", RegexOption.IGNORE_CASE)
override suspend fun loadBook(link: String, extra: Map<String, String>?): Book {
//link should be a directory
val directory = File(
currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"Dantotsu/Novel/$link"
)
val chapters = mutableListOf<Book>()
if (directory.exists()) {
directory.listFiles()?.forEach {
if (it.isDirectory) {
val chapter = Book(
it.name,
it.absolutePath + "/cover.jpg",
null,
listOf(it.absolutePath + "/0.epub")
)
chapters.add(chapter)
}
}
chapters.sortBy { MangaNameAdapter.findChapterNumber(it.name) }
return chapters.first()
}
return Book(
"error",
"",
null,
listOf("error")
)
}
override suspend fun search(query: String): List<ShowResponse> {
val titles = downloadManager.novelDownloads.map { it.title }.distinct()
val returnTitles: MutableList<String> = mutableListOf()
for (title in titles) {
if (FuzzySearch.ratio(title.lowercase(), query.lowercase()) > 80) {
returnTitles.add(title)
}
}
val returnList: MutableList<ShowResponse> = mutableListOf()
for (title in returnTitles) {
//need to search the subdirectories for the ShowResponses
val directory = File(
currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"Dantotsu/Novel/$title"
)
val names = mutableListOf<String>()
if (directory.exists()) {
directory.listFiles()?.forEach {
if (it.isDirectory) {
names.add(it.name)
}
}
}
val cover = currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.absolutePath + "/Dantotsu/Novel/$title/cover.jpg"
names.forEach {
returnList.add(ShowResponse(it, it, cover))
}
}
return returnList
}
}

View file

@ -41,6 +41,7 @@ class ExtensionsActivity : AppCompatActivity() {
val tabLayout = findViewById<TabLayout>(R.id.tabLayout) val tabLayout = findViewById<TabLayout>(R.id.tabLayout)
val viewPager = findViewById<ViewPager2>(R.id.viewPager) val viewPager = findViewById<ViewPager2>(R.id.viewPager)
viewPager.offscreenPageLimit = 1
viewPager.adapter = object : FragmentStateAdapter(this) { viewPager.adapter = object : FragmentStateAdapter(this) {
override fun getItemCount(): Int = 6 override fun getItemCount(): Int = 6
@ -65,13 +66,24 @@ class ExtensionsActivity : AppCompatActivity() {
object : TabLayout.OnTabSelectedListener { object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) { override fun onTabSelected(tab: TabLayout.Tab) {
searchView.setText("") searchView.setText("")
searchView.clearFocus()
tabLayout.clearFocus()
viewPager.updateLayoutParams<ViewGroup.LayoutParams> {
height = ViewGroup.LayoutParams.MATCH_PARENT
}
} }
override fun onTabUnselected(tab: TabLayout.Tab) { override fun onTabUnselected(tab: TabLayout.Tab) {
// Do nothing viewPager.updateLayoutParams<ViewGroup.LayoutParams> {
height = ViewGroup.LayoutParams.MATCH_PARENT
}
tabLayout.clearFocus()
} }
override fun onTabReselected(tab: TabLayout.Tab) { override fun onTabReselected(tab: TabLayout.Tab) {
viewPager.updateLayoutParams<ViewGroup.LayoutParams> {
height = ViewGroup.LayoutParams.MATCH_PARENT
}
// Do nothing // Do nothing
} }
} }

View file

@ -11,6 +11,7 @@ import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.view.updateLayoutParams
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil

View file

@ -3,6 +3,7 @@ package ani.dantotsu.settings
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -58,6 +59,7 @@ class NovelExtensionsFragment : Fragment(),
lifecycleScope.launch { lifecycleScope.launch {
viewModel.pagerFlow.collectLatest { pagingData -> viewModel.pagerFlow.collectLatest { pagingData ->
Log.d("NovelExtensionsFragment", "collectLatest")
adapter.submitData(pagingData) adapter.submitData(pagingData)
} }
} }

View file

@ -54,7 +54,7 @@ class AnimeSourcePreferencesFragment : PreferenceFragmentCompat() {
pref.isIconSpaceReserved = false pref.isIconSpaceReserved = false
if (pref is DialogPreference) { if (pref is DialogPreference) {
pref.dialogTitle = pref.title pref.dialogTitle = pref.title
//println("pref.dialogTitle: ${pref.dialogTitle}") //println("pref.dialogTitle: ${pref.dialogTitle}") //TODO: could be useful for dub/sub selection
} }
/*for (entry in sharedPreferences.all.entries) { /*for (entry in sharedPreferences.all.entries) {
Log.d("Preferences", "Key: ${entry.key}, Value: ${entry.value}") Log.d("Preferences", "Key: ${entry.key}, Value: ${entry.value}")

View file

@ -25,6 +25,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
@ -53,7 +54,13 @@ class NovelExtensionsViewModel(
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val pagerFlow: Flow<PagingData<NovelExtension.Available>> = searchQuery.flatMapLatest { query -> val pagerFlow: Flow<PagingData<NovelExtension.Available>> = combine(
novelExtensionManager.availableExtensionsFlow,
novelExtensionManager.installedExtensionsFlow,
searchQuery
) { available, installed, query ->
Triple(available, installed, query)
}.flatMapLatest { (available, installed, query) ->
Pager( Pager(
PagingConfig( PagingConfig(
pageSize = 15, pageSize = 15,
@ -61,28 +68,24 @@ class NovelExtensionsViewModel(
prefetchDistance = 15 prefetchDistance = 15
) )
) { ) {
NovelExtensionPagingSource( NovelExtensionPagingSource(available, installed, query)
novelExtensionManager.availableExtensionsFlow,
novelExtensionManager.installedExtensionsFlow,
searchQuery
).also { currentPagingSource = it }
}.flow }.flow
}.cachedIn(viewModelScope) }.cachedIn(viewModelScope)
} }
class NovelExtensionPagingSource( class NovelExtensionPagingSource(
private val availableExtensionsFlow: StateFlow<List<NovelExtension.Available>>, private val availableExtensionsFlow: List<NovelExtension.Available>,
private val installedExtensionsFlow: StateFlow<List<NovelExtension.Installed>>, private val installedExtensionsFlow: List<NovelExtension.Installed>,
private val searchQuery: StateFlow<String> private val searchQuery: String
) : PagingSource<Int, NovelExtension.Available>() { ) : PagingSource<Int, NovelExtension.Available>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, NovelExtension.Available> { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, NovelExtension.Available> {
val position = params.key ?: 0 val position = params.key ?: 0
val installedExtensions = installedExtensionsFlow.first().map { it.pkgName }.toSet() val installedExtensions = installedExtensionsFlow.map { it.pkgName }.toSet()
val availableExtensions = val availableExtensions =
availableExtensionsFlow.first().filterNot { it.pkgName in installedExtensions } availableExtensionsFlow.filterNot { it.pkgName in installedExtensions }
val query = searchQuery.first() val query = searchQuery
val isNsfwEnabled: Boolean = loadData("NFSWExtension") ?: true val isNsfwEnabled: Boolean = loadData("NFSWExtension") ?: true
val filteredExtensions = if (query.isEmpty()) { val filteredExtensions = if (query.isEmpty()) {
availableExtensions availableExtensions

View file

@ -9,7 +9,7 @@
<LinearLayout <LinearLayout
android:id="@+id/settingsContainer" android:id="@+id/settingsContainer"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:orientation="vertical"> android:orientation="vertical">
<LinearLayout <LinearLayout
@ -134,18 +134,10 @@
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<androidx.viewpager2.widget.ViewPager2 <androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager" android:id="@+id/viewPager"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1"/> android:layout_weight="1" />
<FrameLayout
android:id="@+id/fragmentExtensionsContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone">
</FrameLayout>
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>

View file

@ -50,9 +50,10 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:focusable="false"
android:fontFamily="@font/poppins_bold" android:fontFamily="@font/poppins_bold"
android:inputType="none" android:imeOptions="actionSearch"
android:inputType="textPersonName"
android:selectAllOnFocus="true"
android:padding="8dp" android:padding="8dp"
android:textSize="14sp" android:textSize="14sp"
tools:ignore="LabelFor,TextContrastCheck" /> tools:ignore="LabelFor,TextContrastCheck" />