Light novel support

This commit is contained in:
Finnley Somdahl 2023-11-30 03:41:45 -06:00
parent 32f918450a
commit c7bc1ffe9e
39 changed files with 2537 additions and 91 deletions

View file

@ -21,7 +21,7 @@ android {
minSdk 23
targetSdk 34
versionCode ((System.currentTimeMillis() / 60000).toInteger())
versionName "1.0.0-beta03i"
versionName "1.0.0-beta03i-2"
signingConfig signingConfigs.debug
}
@ -64,7 +64,7 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'com.google.code.gson:gson:2.8.9'
implementation 'com.github.Blatzar:NiceHttp:0.4.3'
implementation 'com.github.Blatzar:NiceHttp:0.4.4'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0'
implementation 'androidx.preference:preference:1.2.1'

View file

@ -277,6 +277,10 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<service android:name=".download.novel.NovelDownloaderService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service android:name=".connections.discord.DiscordService"
android:exported="false"
android:foregroundServiceType="dataSync" />

View file

@ -7,6 +7,7 @@ import android.content.Context
import android.content.res.ColorStateList
import android.content.res.Resources
import android.os.Bundle
import android.util.Log
import android.util.LongSparseArray
import android.util.TypedValue
import androidx.annotation.ColorInt
@ -14,9 +15,13 @@ import androidx.multidex.MultiDex
import androidx.multidex.MultiDexApplication
import ani.dantotsu.aniyomi.anime.custom.AppModule
import ani.dantotsu.aniyomi.anime.custom.PreferenceModule
import ani.dantotsu.download.Download
import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.others.DisabledReports
import ani.dantotsu.parsers.AnimeSources
import ani.dantotsu.parsers.MangaSources
import ani.dantotsu.parsers.NovelSources
import ani.dantotsu.parsers.novel.NovelExtensionManager
import com.google.android.material.color.DynamicColors
import com.google.android.material.color.HarmonizedColorAttributes
import com.google.android.material.color.HarmonizedColors
@ -36,6 +41,7 @@ import logcat.LogcatLogger
import tachiyomi.core.util.system.logcat
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import java.lang.reflect.Field
@ -43,6 +49,7 @@ import java.lang.reflect.Field
class App : MultiDexApplication() {
private lateinit var animeExtensionManager: AnimeExtensionManager
private lateinit var mangaExtensionManager: MangaExtensionManager
private lateinit var novelExtensionManager: NovelExtensionManager
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
MultiDex.install(this)
@ -65,11 +72,12 @@ class App : MultiDexApplication() {
registerActivityLifecycleCallbacks(mFTActivityLifecycleCallbacks)
Firebase.crashlytics.setCrashlyticsCollectionEnabled(!DisabledReports)
initializeNetwork(baseContext)
Injekt.importModule(AppModule(this))
Injekt.importModule(PreferenceModule(this))
initializeNetwork(baseContext)
setupNotificationChannels()
if (!LogcatLogger.isInstalled) {
LogcatLogger.install(AndroidLogcatLogger(LogPriority.VERBOSE))
@ -77,6 +85,7 @@ class App : MultiDexApplication() {
animeExtensionManager = Injekt.get()
mangaExtensionManager = Injekt.get()
novelExtensionManager = Injekt.get()
val animeScope = CoroutineScope(Dispatchers.Default)
animeScope.launch {
@ -90,6 +99,12 @@ class App : MultiDexApplication() {
logger("Manga Extensions: ${mangaExtensionManager.installedExtensionsFlow.first()}")
MangaSources.init(mangaExtensionManager.installedExtensionsFlow)
}
val novelScope = CoroutineScope(Dispatchers.Default)
novelScope.launch {
novelExtensionManager.findAvailableExtensions()
logger("Novel Extensions: ${novelExtensionManager.installedExtensionsFlow.first()}")
NovelSources.init(novelExtensionManager.installedExtensionsFlow)
}
}

View file

@ -35,6 +35,7 @@ import androidx.core.view.updateLayoutParams
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider.NewInstanceFactory.Companion.instance
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager
@ -59,7 +60,9 @@ import ani.dantotsu.settings.UserInterfaceSettings
import ani.dantotsu.subcriptions.Subscription.Companion.startSubscription
import ani.dantotsu.themes.ThemeManager
import ani.dantotsu.others.LangSet
import ani.dantotsu.parsers.NovelInterface
import com.google.firebase.crashlytics.FirebaseCrashlytics
import dalvik.system.PathClassLoader
import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager
import io.noties.markwon.Markwon
import io.noties.markwon.SoftBreakAddsNewLinePlugin
@ -73,7 +76,11 @@ import nl.joery.animatedbottombar.AnimatedBottomBar
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.Serializable
import java.nio.channels.FileChannel
class MainActivity : AppCompatActivity() {
@ -83,6 +90,8 @@ class MainActivity : AppCompatActivity() {
private var uiSettings = UserInterfaceSettings()
override fun onCreate(savedInstanceState: Bundle?) {
ThemeManager(this).applyTheme()
LangSet.setLocale(this)

View file

@ -8,6 +8,7 @@ import ani.dantotsu.others.webview.WebViewBottomDialog
import com.lagradost.nicehttp.Requests
import com.lagradost.nicehttp.ResponseParser
import com.lagradost.nicehttp.addGenericDns
import eu.kanade.tachiyomi.network.NetworkHelper
import kotlinx.coroutines.*
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.ExperimentalSerializationApi
@ -17,6 +18,8 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import okhttp3.Cache
import okhttp3.OkHttpClient
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import java.io.PrintWriter
import java.io.Serializable
@ -25,41 +28,30 @@ import java.util.concurrent.*
import kotlin.reflect.KClass
import kotlin.reflect.KFunction
val defaultHeaders = mapOf(
"User-Agent" to
"Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Mobile Safari/537.36"
.format(Build.VERSION.RELEASE, Build.MODEL)
)
lateinit var cache: Cache
lateinit var defaultHeaders: Map<String, String>
lateinit var okHttpClient: OkHttpClient
lateinit var client: Requests
fun initializeNetwork(context: Context) {
val dns = loadData<Int>("settings_dns")
cache = Cache(
File(context.cacheDir, "http_cache"),
5 * 1024L * 1024L // 5 MiB
val networkHelper = Injekt.get<NetworkHelper>()
defaultHeaders = mapOf(
"User-Agent" to
Injekt.get<NetworkHelper>().defaultUserAgentProvider()
.format(Build.VERSION.RELEASE, Build.MODEL)
)
okHttpClient = OkHttpClient.Builder()
.followRedirects(true)
.followSslRedirects(true)
.cache(cache)
.apply {
when (dns) {
1 -> addGoogleDns()
2 -> addCloudFlareDns()
3 -> addAdGuardDns()
}
}
.build()
okHttpClient = networkHelper.client
client = Requests(
okHttpClient,
networkHelper.client,
defaultHeaders,
defaultCacheTime = 6,
defaultCacheTimeUnit = TimeUnit.HOURS,
responseParser = Mapper
)
}
object Mapper : ResponseParser {

View file

@ -24,6 +24,7 @@ import uy.kohesive.injekt.api.addSingleton
import uy.kohesive.injekt.api.addSingletonFactory
import uy.kohesive.injekt.api.get
import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.parsers.novel.NovelExtensionManager
class AppModule(val app: Application) : InjektModule {
override fun InjektRegistrar.registerInjectables() {
@ -35,6 +36,7 @@ class AppModule(val app: Application) : InjektModule {
addSingletonFactory { AnimeExtensionManager(app) }
addSingletonFactory { MangaExtensionManager(app) }
addSingletonFactory { NovelExtensionManager(app) }
addSingletonFactory<AnimeSourceManager> { AndroidAnimeSourceManager(app, get()) }
addSingletonFactory<MangaSourceManager> { AndroidMangaSourceManager(app, get()) }

View file

@ -18,6 +18,8 @@ class DownloadsManager(private val context: Context) {
get() = downloadsList.filter { it.type == Download.Type.MANGA }
val animeDownloads: List<Download>
get() = downloadsList.filter { it.type == Download.Type.ANIME }
val novelDownloads: List<Download>
get() = downloadsList.filter { it.type == Download.Type.NOVEL }
private fun saveDownloads() {
val jsonString = gson.toJson(downloadsList)
@ -45,11 +47,17 @@ class DownloadsManager(private val context: Context) {
saveDownloads()
}
fun queryDownload(download: Download): Boolean {
return downloadsList.contains(download)
}
private fun removeDirectory(download: Download) {
val directory = if (download.type == Download.Type.MANGA){
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Manga/${download.title}/${download.chapter}")
} else {
} else if (download.type == Download.Type.ANIME) {
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Anime/${download.title}/${download.chapter}")
} else {
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Novel/${download.title}/${download.chapter}")
}
// Check if the directory exists and delete it recursively
@ -68,8 +76,10 @@ class DownloadsManager(private val context: Context) {
fun exportDownloads(download: Download) { //copies to the downloads folder available to the user
val directory = if (download.type == Download.Type.MANGA){
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Manga/${download.title}/${download.chapter}")
} else {
} else if (download.type == Download.Type.ANIME) {
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Anime/${download.title}/${download.chapter}")
} else {
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Novel/${download.title}/${download.chapter}")
}
val destination = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/${download.title}/${download.chapter}")
if (directory.exists()) {
@ -87,8 +97,10 @@ class DownloadsManager(private val context: Context) {
fun purgeDownloads(type: Download.Type){
val directory = if (type == Download.Type.MANGA){
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Manga")
} else {
} else if (type == Download.Type.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()
@ -105,11 +117,18 @@ class DownloadsManager(private val context: Context) {
saveDownloads()
}
companion object {
const val novelLocation = "Dantotsu/Novel"
const val mangaLocation = "Dantotsu/Manga"
const val animeLocation = "Dantotsu/Anime"
}
}
data class Download(val title: String, val chapter: String, val type: Type) : Serializable {
enum class Type {
MANGA,
ANIME
ANIME,
NOVEL
}
}

View file

@ -91,9 +91,9 @@ class MangaDownloaderService : Service() {
override fun onDestroy() {
super.onDestroy()
ServiceDataSingleton.downloadQueue.clear()
MangaServiceDataSingleton.downloadQueue.clear()
downloadJobs.clear()
ServiceDataSingleton.isServiceRunning = false
MangaServiceDataSingleton.isServiceRunning = false
unregisterReceiver(cancelReceiver)
}
@ -114,8 +114,8 @@ class MangaDownloaderService : Service() {
private fun processQueue() {
CoroutineScope(Dispatchers.Default).launch {
while (ServiceDataSingleton.downloadQueue.isNotEmpty()) {
val task = ServiceDataSingleton.downloadQueue.poll()
while (MangaServiceDataSingleton.downloadQueue.isNotEmpty()) {
val task = MangaServiceDataSingleton.downloadQueue.poll()
if (task != null) {
val job = launch { download(task) }
mutex.withLock {
@ -127,7 +127,7 @@ class MangaDownloaderService : Service() {
}
updateNotification() // Update the notification after each task is completed
}
if (ServiceDataSingleton.downloadQueue.isEmpty()) {
if (MangaServiceDataSingleton.downloadQueue.isEmpty()) {
withContext(Dispatchers.Main) {
stopSelf() // Stop the service when the queue is empty
}
@ -141,7 +141,7 @@ class MangaDownloaderService : Service() {
mutex.withLock {
downloadJobs[chapter]?.cancel()
downloadJobs.remove(chapter)
ServiceDataSingleton.downloadQueue.removeAll { it.chapter == chapter }
MangaServiceDataSingleton.downloadQueue.removeAll { it.chapter == chapter }
updateNotification() // Update the notification after cancellation
}
}
@ -149,7 +149,7 @@ class MangaDownloaderService : Service() {
private fun updateNotification() {
// Update the notification to reflect the current state of the queue
val pendingDownloads = ServiceDataSingleton.downloadQueue.size
val pendingDownloads = MangaServiceDataSingleton.downloadQueue.size
val text = if (pendingDownloads > 0) {
"Pending downloads: $pendingDownloads"
} else {
@ -381,7 +381,7 @@ class MangaDownloaderService : Service() {
}
}
object ServiceDataSingleton {
object MangaServiceDataSingleton {
var imageData: List<ImageData> = listOf()
var sourceMedia: Media? = null
var downloadQueue: Queue<MangaDownloaderService.DownloadTask> = ConcurrentLinkedQueue()

View file

@ -0,0 +1,434 @@
package ani.dantotsu.download.novel
import android.Manifest
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.graphics.Bitmap
import android.os.Build
import android.os.Environment
import android.os.IBinder
import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import ani.dantotsu.R
import ani.dantotsu.download.Download
import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.logger
import ani.dantotsu.media.Media
import ani.dantotsu.media.novel.NovelReadFragment
import ani.dantotsu.snackString
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.gson.GsonBuilder
import com.google.gson.InstanceCreator
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SChapterImpl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.Request
import okio.buffer
import okio.sink
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.BufferedInputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.util.Queue
import java.util.concurrent.ConcurrentLinkedQueue
class NovelDownloaderService : Service() {
private lateinit var notificationManager: NotificationManagerCompat
private lateinit var builder: NotificationCompat.Builder
private val downloadsManager: DownloadsManager = Injekt.get<DownloadsManager>()
private val downloadJobs = mutableMapOf<String, Job>()
private val mutex = Mutex()
private var isCurrentlyProcessing = false
val networkHelper = Injekt.get<NetworkHelper>()
override fun onBind(intent: Intent?): IBinder? {
// This is only required for bound services.
return null
}
override fun onCreate() {
super.onCreate()
notificationManager = NotificationManagerCompat.from(this)
builder = NotificationCompat.Builder(this, Notifications.CHANNEL_DOWNLOADER_PROGRESS).apply {
setContentTitle("Novel Download Progress")
setSmallIcon(R.drawable.ic_round_download_24)
priority = NotificationCompat.PRIORITY_DEFAULT
setOnlyAlertOnce(true)
setProgress(0, 0, false)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, builder.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
}else{
startForeground(NOTIFICATION_ID, builder.build())
}
ContextCompat.registerReceiver(this, cancelReceiver, IntentFilter(ACTION_CANCEL_DOWNLOAD), ContextCompat.RECEIVER_EXPORTED)
}
override fun onDestroy() {
super.onDestroy()
NovelServiceDataSingleton.downloadQueue.clear()
downloadJobs.clear()
NovelServiceDataSingleton.isServiceRunning = false
unregisterReceiver(cancelReceiver)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
snackString("Download started")
val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
serviceScope.launch {
mutex.withLock {
if (!isCurrentlyProcessing) {
isCurrentlyProcessing = true
processQueue()
isCurrentlyProcessing = false
}
}
}
return Service.START_NOT_STICKY
}
private fun processQueue() {
CoroutineScope(Dispatchers.Default).launch {
while (NovelServiceDataSingleton.downloadQueue.isNotEmpty()) {
val task = NovelServiceDataSingleton.downloadQueue.poll()
if (task != null) {
val job = launch { download(task) }
mutex.withLock {
downloadJobs[task.chapter] = job
}
job.join() // Wait for the job to complete before continuing to the next task
mutex.withLock {
downloadJobs.remove(task.chapter)
}
updateNotification() // Update the notification after each task is completed
}
if (NovelServiceDataSingleton.downloadQueue.isEmpty()) {
withContext(Dispatchers.Main) {
stopSelf() // Stop the service when the queue is empty
}
}
}
}
}
fun cancelDownload(chapter: String) {
CoroutineScope(Dispatchers.Default).launch {
mutex.withLock {
downloadJobs[chapter]?.cancel()
downloadJobs.remove(chapter)
NovelServiceDataSingleton.downloadQueue.removeAll { it.chapter == chapter }
updateNotification() // Update the notification after cancellation
}
}
}
private fun updateNotification() {
// Update the notification to reflect the current state of the queue
val pendingDownloads = NovelServiceDataSingleton.downloadQueue.size
val text = if (pendingDownloads > 0) {
"Pending downloads: $pendingDownloads"
} else {
"All downloads completed"
}
builder.setContentText(text)
if (ActivityCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return
}
notificationManager.notify(NOTIFICATION_ID, builder.build())
}
suspend fun isEpubFile(urlString: String): Boolean {
return withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.url(urlString)
.head()
.build()
networkHelper.client.newCall(request).execute().use { response ->
val contentType = response.header("Content-Type")
val contentDisposition = response.header("Content-Disposition")
logger("Content-Type: $contentType")
logger("Content-Disposition: $contentDisposition")
// Return true if the Content-Type or Content-Disposition indicates an EPUB file
contentType == "application/epub+zip" ||
(contentDisposition?.contains(".epub") == true)
}
} catch (e: Exception) {
logger("Error checking file type: ${e.message}")
false
}
}
}
suspend fun download(task: DownloadTask) {
withContext(Dispatchers.Main) {
val notifi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.checkSelfPermission(
this@NovelDownloaderService,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
} else {
true
}
broadcastDownloadStarted(task.originalLink)
if (notifi) {
builder.setContentText("Downloading ${task.title} - ${task.chapter}")
notificationManager.notify(NOTIFICATION_ID, builder.build())
}
if (!isEpubFile(task.downloadLink)) {
logger("Download link is not an .epub file")
broadcastDownloadFailed(task.originalLink)
snackString("Download link is not an .epub file")
return@withContext
}
// Start the download
withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.url(task.downloadLink)
.build()
networkHelper.downloadClient.newCall(request).execute().use { response ->
// Ensure the response is successful and has a body
if (!response.isSuccessful || response.body == null) {
throw IOException("Failed to download file: ${response.message}")
}
val file = File(
this@NovelDownloaderService.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"Dantotsu/Novel/${task.title}/${task.chapter}/0.epub"
)
// Create directories if they don't exist
file.parentFile?.takeIf { !it.exists() }?.mkdirs()
// Overwrite existing file
if (file.exists()) file.delete()
//download cover
task.coverUrl?.let {
file.parentFile?.let { it1 -> downloadImage(it, it1, "cover.jpg") }
}
val sink = file.sink().buffer()
val responseBody = response.body
val totalBytes = responseBody.contentLength()
var downloadedBytes = 0L
val notificationUpdateInterval = 1024 * 1024 // 1 MB
val broadcastUpdateInterval = 1024 * 256 // 256 KB
var lastNotificationUpdate = 0L
var lastBroadcastUpdate = 0L
responseBody.source().use { source ->
while (true) {
val read = source.read(sink.buffer, 8192)
if (read == -1L) break
downloadedBytes += read
sink.emit()
// Update progress at intervals
if (downloadedBytes - lastNotificationUpdate >= notificationUpdateInterval) {
withContext(Dispatchers.Main) {
val progress = (downloadedBytes * 100 / totalBytes).toInt()
builder.setProgress(100, progress, false)
if (notifi) {
notificationManager.notify(NOTIFICATION_ID, builder.build())
}
}
lastNotificationUpdate = downloadedBytes
}
if (downloadedBytes - lastBroadcastUpdate >= broadcastUpdateInterval) {
withContext(Dispatchers.Main) {
val progress = (downloadedBytes * 100 / totalBytes).toInt()
logger("Download progress: $progress")
broadcastDownloadProgress(task.originalLink, progress)
}
lastBroadcastUpdate = downloadedBytes
}
}
}
sink.close()
}
} 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
builder.setContentText("${task.title} - ${task.chapter} Download complete")
.setProgress(0, 0, false)
if (notifi) {
notificationManager.notify(NOTIFICATION_ID, builder.build())
}
saveMediaInfo(task)
downloadsManager.addDownload(Download(task.title, task.chapter, Download.Type.NOVEL))
broadcastDownloadFinished(task.originalLink)
snackString("${task.title} - ${task.chapter} Download finished")
}
}
private fun saveMediaInfo(task: DownloadTask) {
GlobalScope.launch(Dispatchers.IO) {
val directory = File(
getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"Dantotsu/Novel/${task.title}"
)
if (!directory.exists()) directory.mkdirs()
val file = File(directory, "media.json")
val gson = GsonBuilder()
.registerTypeAdapter(SChapter::class.java, InstanceCreator<SChapter> {
SChapterImpl() // Provide an instance of SChapterImpl
})
.create()
val mediaJson = gson.toJson(task.sourceMedia)
val media = gson.fromJson(mediaJson, Media::class.java)
if (media != null) {
media.cover = media.cover?.let { downloadImage(it, directory, "cover.jpg") }
media.banner = media.banner?.let { downloadImage(it, directory, "banner.jpg") }
val jsonString = gson.toJson(media)
withContext(Dispatchers.Main) {
file.writeText(jsonString)
}
}
}
}
private suspend fun downloadImage(url: String, directory: File, name: String): String? = withContext(
Dispatchers.IO) {
var connection: HttpURLConnection? = null
println("Downloading url $url")
try {
connection = URL(url).openConnection() as HttpURLConnection
connection.connect()
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
throw Exception("Server returned HTTP ${connection.responseCode} ${connection.responseMessage}")
}
val file = File(directory, name)
FileOutputStream(file).use { output ->
connection.inputStream.use { input ->
input.copyTo(output)
}
}
return@withContext file.absolutePath
} catch (e: Exception) {
e.printStackTrace()
withContext(Dispatchers.Main) {
Toast.makeText(this@NovelDownloaderService, "Exception while saving ${name}: ${e.message}", Toast.LENGTH_LONG).show()
}
null
} finally {
connection?.disconnect()
}
}
private fun broadcastDownloadStarted(link: String) {
val intent = Intent(NovelReadFragment.ACTION_DOWNLOAD_STARTED).apply {
putExtra(NovelReadFragment.EXTRA_NOVEL_LINK, link)
}
sendBroadcast(intent)
}
private fun broadcastDownloadFinished(link: String) {
val intent = Intent(NovelReadFragment.ACTION_DOWNLOAD_FINISHED).apply {
putExtra(NovelReadFragment.EXTRA_NOVEL_LINK, link)
}
sendBroadcast(intent)
}
private fun broadcastDownloadFailed(link: String) {
val intent = Intent(NovelReadFragment.ACTION_DOWNLOAD_FAILED).apply {
putExtra(NovelReadFragment.EXTRA_NOVEL_LINK, link)
}
sendBroadcast(intent)
}
private fun broadcastDownloadProgress(link: String, progress: Int) {
val intent = Intent(NovelReadFragment.ACTION_DOWNLOAD_PROGRESS).apply {
putExtra(NovelReadFragment.EXTRA_NOVEL_LINK, link)
putExtra("progress", progress)
}
sendBroadcast(intent)
}
private val cancelReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == ACTION_CANCEL_DOWNLOAD) {
val chapter = intent.getStringExtra(EXTRA_CHAPTER)
chapter?.let {
cancelDownload(it)
}
}
}
}
data class DownloadTask(
val title: String,
val chapter: String,
val downloadLink: String,
val originalLink: String,
val sourceMedia: Media? = null,
val coverUrl: String? = null,
val retries: Int = 2,
)
companion object {
private const val NOTIFICATION_ID = 1103
const val ACTION_CANCEL_DOWNLOAD = "action_cancel_download"
const val EXTRA_CHAPTER = "extra_chapter"
}
}
object NovelServiceDataSingleton {
var sourceMedia: Media? = null
var downloadQueue: Queue<NovelDownloaderService.DownloadTask> = ConcurrentLinkedQueue()
@Volatile
var isServiceRunning: Boolean = false
}

View file

@ -30,7 +30,7 @@ import ani.dantotsu.databinding.FragmentAnimeWatchBinding
import ani.dantotsu.download.Download
import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.download.manga.MangaDownloaderService
import ani.dantotsu.download.manga.ServiceDataSingleton
import ani.dantotsu.download.manga.MangaServiceDataSingleton
import ani.dantotsu.media.manga.mangareader.ChapterLoaderDialog
import ani.dantotsu.media.Media
import ani.dantotsu.media.MediaDetailsActivity
@ -408,15 +408,15 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener {
simultaneousDownloads = 2
)
ServiceDataSingleton.downloadQueue.offer(downloadTask)
MangaServiceDataSingleton.downloadQueue.offer(downloadTask)
// If the service is not already running, start it
if (!ServiceDataSingleton.isServiceRunning) {
if (!MangaServiceDataSingleton.isServiceRunning) {
val intent = Intent(context, MangaDownloaderService::class.java)
withContext(Dispatchers.Main) {
ContextCompat.startForegroundService(requireContext(), intent)
}
ServiceDataSingleton.isServiceRunning = true
MangaServiceDataSingleton.isServiceRunning = true
}
// Inform the adapter that the download has started
@ -456,6 +456,7 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener {
}
private val downloadStatusReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if(!this@MangaReadFragment::chapterAdapter.isInitialized) return
when (intent.action) {
ACTION_DOWNLOAD_STARTED -> {
val chapterNumber = intent.getStringExtra(EXTRA_CHAPTER_NUMBER)

View file

@ -29,6 +29,14 @@ class BookDialog : BottomSheetDialogFragment() {
private lateinit var novel: ShowResponse
private var source:Int = 0
interface Callback {
fun onDownloadTriggered(link: String)
}
private var callback: Callback? = null
fun setCallback(callback: Callback) {
this.callback = callback
}
override fun onCreate(savedInstanceState: Bundle?) {
arguments?.let {
novelName = it.getString("novelName")!!
@ -51,7 +59,7 @@ class BookDialog : BottomSheetDialogFragment() {
binding.itemBookTitle.text = it.name
binding.itemBookDesc.text = it.description
binding.itemBookImage.loadImage(it.img)
binding.bookRecyclerView.adapter = UrlAdapter(it.links, it, novelName)
binding.bookRecyclerView.adapter = UrlAdapter(it.links, it, novelName, callback)
}
}
lifecycleScope.launch(Dispatchers.IO) {

View file

@ -64,7 +64,7 @@ class NovelReadAdapter(
binding.searchBar.setEndIconOnClickListener { search() }
}
override fun getItemCount(): Int = 0
override fun getItemCount(): Int = 1
inner class ViewHolder(val binding: ItemNovelHeaderBinding) : RecyclerView.ViewHolder(binding.root)
}

View file

@ -1,12 +1,20 @@
package ani.dantotsu.media.novel
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.os.Parcelable
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@ -14,16 +22,29 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import ani.dantotsu.databinding.FragmentAnimeWatchBinding
import ani.dantotsu.download.Download
import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.download.novel.NovelDownloaderService
import ani.dantotsu.download.novel.NovelServiceDataSingleton
import ani.dantotsu.loadData
import ani.dantotsu.media.Media
import ani.dantotsu.media.MediaDetailsViewModel
import ani.dantotsu.media.novel.novelreader.NovelReaderActivity
import ani.dantotsu.navBarHeight
import ani.dantotsu.parsers.ShowResponse
import ani.dantotsu.saveData
import ani.dantotsu.settings.UserInterfaceSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
class NovelReadFragment : Fragment() {
class NovelReadFragment : Fragment(),
DownloadTriggerCallback,
DownloadedCheckCallback {
private var _binding: FragmentAnimeWatchBinding? = null
private val binding get() = _binding!!
@ -42,9 +63,104 @@ class NovelReadFragment : Fragment() {
val uiSettings = loadData("ui_settings", toast = false) ?: UserInterfaceSettings().apply { saveData("ui_settings", this) }
override fun downloadTrigger(novelDownloadPackage: NovelDownloadPackage) {
Log.e("downloadTrigger", novelDownloadPackage.link)
val downloadTask = NovelDownloaderService.DownloadTask(
title = media.nameMAL ?: media.nameRomaji,
chapter = novelDownloadPackage.novelName,
downloadLink = novelDownloadPackage.link,
originalLink = novelDownloadPackage.originalLink,
sourceMedia = media,
coverUrl = novelDownloadPackage.coverUrl,
retries = 2,
)
NovelServiceDataSingleton.downloadQueue.offer(downloadTask)
CoroutineScope(Dispatchers.IO).launch {
if (!NovelServiceDataSingleton.isServiceRunning) {
val intent = Intent(context, NovelDownloaderService::class.java)
withContext(Dispatchers.Main) {
ContextCompat.startForegroundService(requireContext(), intent)
}
NovelServiceDataSingleton.isServiceRunning = true
}
}
}
override fun downloadedCheckWithStart(novel: ShowResponse): Boolean {
val downloadsManager = Injekt.get<DownloadsManager>()
if(downloadsManager.queryDownload(Download(media.nameMAL ?: media.nameRomaji, novel.name, Download.Type.NOVEL))) {
val file = File(context?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "${DownloadsManager.novelLocation}/${media.nameMAL ?: media.nameRomaji}/${novel.name}/0.epub")
if (!file.exists()) return false
val fileUri = FileProvider.getUriForFile(requireContext(), "${requireContext().packageName}.provider", file)
val intent = Intent(context, NovelReaderActivity::class.java).apply {
action = Intent.ACTION_VIEW
setDataAndType(fileUri, "application/epub+zip")
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
}
startActivity(intent)
return true
} else {
return false
}
}
override fun downloadedCheck(novel: ShowResponse): Boolean {
val downloadsManager = Injekt.get<DownloadsManager>()
return downloadsManager.queryDownload(Download(media.nameMAL ?: media.nameRomaji, novel.name, Download.Type.NOVEL))
}
override fun deleteDownload(novel: ShowResponse) {
val downloadsManager = Injekt.get<DownloadsManager>()
downloadsManager.removeDownload(Download(media.nameMAL ?: media.nameRomaji, novel.name, Download.Type.NOVEL))
}
private val downloadStatusReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (!this@NovelReadFragment::novelResponseAdapter.isInitialized) return
when (intent.action) {
ACTION_DOWNLOAD_STARTED -> {
val link = intent.getStringExtra(EXTRA_NOVEL_LINK)
link?.let {
novelResponseAdapter.startDownload(it)
}
}
ACTION_DOWNLOAD_FINISHED -> {
val link = intent.getStringExtra(EXTRA_NOVEL_LINK)
link?.let {
novelResponseAdapter.stopDownload(it)
}
}
ACTION_DOWNLOAD_FAILED -> {
val link = intent.getStringExtra(EXTRA_NOVEL_LINK)
link?.let {
novelResponseAdapter.purgeDownload(it)
}
}
ACTION_DOWNLOAD_PROGRESS -> {
val link = intent.getStringExtra(EXTRA_NOVEL_LINK)
val progress = intent.getIntExtra("progress", 0)
link?.let {
novelResponseAdapter.updateDownloadProgress(it, progress)
}
}
}
}
}
var response: List<ShowResponse>? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val intentFilter = IntentFilter().apply {
addAction(ACTION_DOWNLOAD_STARTED)
addAction(ACTION_DOWNLOAD_FINISHED)
addAction(ACTION_DOWNLOAD_FAILED)
addAction(ACTION_DOWNLOAD_PROGRESS)
}
ContextCompat.registerReceiver(requireContext(), downloadStatusReceiver, intentFilter ,ContextCompat.RECEIVER_EXPORTED)
binding.animeSourceRecycler.updatePadding(bottom = binding.animeSourceRecycler.paddingBottom + navBarHeight)
binding.animeSourceRecycler.layoutManager = LinearLayoutManager(requireContext())
@ -63,7 +179,7 @@ class NovelReadFragment : Fragment() {
val sel = media.selected
searchQuery = sel?.server ?: media.name ?: media.nameRomaji
headerAdapter = NovelReadAdapter(media, this, model.novelSources)
novelResponseAdapter = NovelResponseAdapter(this)
novelResponseAdapter = NovelResponseAdapter(this, this, this) // probably a better way to do this but it works
binding.animeSourceRecycler.adapter = ConcatAdapter(headerAdapter, novelResponseAdapter)
loaded = true
Handler(Looper.getMainLooper()).postDelayed({
@ -74,6 +190,7 @@ class NovelReadFragment : Fragment() {
}
model.novelResponses.observe(viewLifecycleOwner) {
if (it != null) {
response = it
searching = false
novelResponseAdapter.submitList(it)
headerAdapter.progress?.visibility = View.GONE
@ -121,6 +238,7 @@ class NovelReadFragment : Fragment() {
override fun onDestroy() {
model.mangaReadSources?.flushText()
requireContext().unregisterReceiver(downloadStatusReceiver)
super.onDestroy()
}
@ -135,4 +253,22 @@ class NovelReadFragment : Fragment() {
super.onPause()
state = binding.animeSourceRecycler.layoutManager?.onSaveInstanceState()
}
companion object {
const val ACTION_DOWNLOAD_STARTED = "ani.dantotsu.ACTION_DOWNLOAD_STARTED"
const val ACTION_DOWNLOAD_FINISHED = "ani.dantotsu.ACTION_DOWNLOAD_FINISHED"
const val ACTION_DOWNLOAD_FAILED = "ani.dantotsu.ACTION_DOWNLOAD_FAILED"
const val ACTION_DOWNLOAD_PROGRESS = "ani.dantotsu.ACTION_DOWNLOAD_PROGRESS"
const val EXTRA_NOVEL_LINK = "extra_novel_link"
}
}
interface DownloadTriggerCallback {
fun downloadTrigger(novelDownloadPackage: NovelDownloadPackage)
}
interface DownloadedCheckCallback {
fun downloadedCheck(novel: ShowResponse): Boolean
fun downloadedCheckWithStart(novel: ShowResponse): Boolean
fun deleteDownload(novel: ShowResponse)
}

View file

@ -1,5 +1,6 @@
package ani.dantotsu.media.novel
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@ -7,16 +8,23 @@ import androidx.recyclerview.widget.RecyclerView
import ani.dantotsu.databinding.ItemNovelResponseBinding
import ani.dantotsu.parsers.ShowResponse
import ani.dantotsu.setAnimation
import ani.dantotsu.snackString
import com.bumptech.glide.Glide
import com.bumptech.glide.load.model.GlideUrl
class NovelResponseAdapter(val fragment: NovelReadFragment) : RecyclerView.Adapter<NovelResponseAdapter.ViewHolder>() {
class NovelResponseAdapter(
val fragment: NovelReadFragment,
val downloadTriggerCallback: DownloadTriggerCallback,
val downloadedCheckCallback: DownloadedCheckCallback
) : RecyclerView.Adapter<NovelResponseAdapter.ViewHolder>() {
val list: MutableList<ShowResponse> = mutableListOf()
inner class ViewHolder(val binding: ItemNovelResponseBinding) : RecyclerView.ViewHolder(binding.root)
inner class ViewHolder(val binding: ItemNovelResponseBinding) :
RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val bind = ItemNovelResponseBinding.inflate(LayoutInflater.from(parent.context), parent, false)
val bind =
ItemNovelResponseBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(bind)
}
@ -27,19 +35,128 @@ class NovelResponseAdapter(val fragment: NovelReadFragment) : RecyclerView.Adapt
val novel = list[position]
setAnimation(fragment.requireContext(), holder.binding.root, fragment.uiSettings)
val cover = GlideUrl(novel.coverUrl.url){ novel.coverUrl.headers }
Glide.with(binding.itemEpisodeImage).load(cover).override(400,0).into(binding.itemEpisodeImage)
val cover = GlideUrl(novel.coverUrl.url) { novel.coverUrl.headers }
Glide.with(binding.itemEpisodeImage).load(cover).override(400, 0)
.into(binding.itemEpisodeImage)
binding.itemEpisodeTitle.text = novel.name
binding.itemEpisodeFiller.text = novel.extra?.get("0") ?: ""
binding.itemEpisodeFiller.text =
if (downloadedCheckCallback.downloadedCheck(novel)) {
"Downloaded"
} else {
novel.extra?.get("0") ?: ""
}
if (binding.itemEpisodeFiller.text.contains("Downloading")) {
binding.itemEpisodeFiller.setTextColor(
fragment.requireContext().getColor(android.R.color.holo_blue_light)
)
} else if (binding.itemEpisodeFiller.text.contains("Downloaded")) {
binding.itemEpisodeFiller.setTextColor(
fragment.requireContext().getColor(android.R.color.holo_green_light)
)
} else {
binding.itemEpisodeFiller.setTextColor(
fragment.requireContext().getColor(android.R.color.white)
)
}
binding.itemEpisodeDesc2.text = novel.extra?.get("1") ?: ""
val desc = novel.extra?.get("2")
binding.itemEpisodeDesc.visibility = if (desc != null && desc.trim(' ') != "") View.VISIBLE else View.GONE
binding.itemEpisodeDesc.visibility =
if (desc != null && desc.trim(' ') != "") View.VISIBLE else View.GONE
binding.itemEpisodeDesc.text = desc ?: ""
binding.root.setOnClickListener {
BookDialog.newInstance(fragment.novelName, novel, fragment.source)
.show(fragment.parentFragmentManager, "dialog")
//make sure the file is not downloading
if (activeDownloads.contains(novel.link)) {
return@setOnClickListener
}
if (downloadedCheckCallback.downloadedCheckWithStart(novel)) {
return@setOnClickListener
}
val bookDialog = BookDialog.newInstance(fragment.novelName, novel, fragment.source)
bookDialog.setCallback(object : BookDialog.Callback {
override fun onDownloadTriggered(link: String) {
downloadTriggerCallback.downloadTrigger(
NovelDownloadPackage(
link,
novel.coverUrl.url,
novel.name,
novel.link
)
)
bookDialog.dismiss()
}
})
bookDialog.show(fragment.parentFragmentManager, "dialog")
}
binding.root.setOnLongClickListener {
downloadedCheckCallback.deleteDownload(novel)
deleteDownload(novel.link)
snackString("Deleted ${novel.name}")
if (binding.itemEpisodeFiller.text.toString().contains("Download", ignoreCase = true)) {
binding.itemEpisodeFiller.text = ""
}
notifyItemChanged(position)
true
}
}
private val activeDownloads = mutableSetOf<String>()
private val downloadedChapters = mutableSetOf<String>()
fun startDownload(link: String) {
activeDownloads.add(link)
val position = list.indexOfFirst { it.link == link }
if (position != -1) {
list[position].extra?.remove("0")
list[position].extra?.set("0", "Downloading: 0%")
notifyItemChanged(position)
}
}
fun stopDownload(link: String) {
activeDownloads.remove(link)
downloadedChapters.add(link)
val position = list.indexOfFirst { it.link == link }
if (position != -1) {
list[position].extra?.remove("0")
list[position].extra?.set("0", "Downloaded")
notifyItemChanged(position)
}
}
fun deleteDownload(link: String) { //TODO:
downloadedChapters.remove(link)
val position = list.indexOfFirst { it.link == link }
if (position != -1) {
notifyItemChanged(position)
}
}
fun purgeDownload(link: String) {
activeDownloads.remove(link)
downloadedChapters.remove(link)
val position = list.indexOfFirst { it.link == link }
if (position != -1) {
notifyItemChanged(position)
}
}
fun updateDownloadProgress(link: String, progress: Int) {
if (!activeDownloads.contains(link)) {
activeDownloads.add(link)
}
val position = list.indexOfFirst { it.link == link }
if (position != -1) {
list[position].extra?.remove("0")
list[position].extra?.set("0", "Downloading: $progress%")
Log.d("NovelResponseAdapter", "updateDownloadProgress: $progress, position: $position")
notifyItemChanged(position)
}
}
@ -55,3 +172,10 @@ class NovelResponseAdapter(val fragment: NovelReadFragment) : RecyclerView.Adapt
notifyItemRangeRemoved(0, size)
}
}
data class NovelDownloadPackage(
val link: String,
val coverUrl: String,
val novelName: String,
val originalLink: String
)

View file

@ -9,12 +9,11 @@ import androidx.recyclerview.widget.RecyclerView
import ani.dantotsu.FileUrl
import ani.dantotsu.copyToClipboard
import ani.dantotsu.databinding.ItemUrlBinding
import ani.dantotsu.others.Download.download
import ani.dantotsu.parsers.Book
import ani.dantotsu.setSafeOnClickListener
import ani.dantotsu.tryWith
class UrlAdapter(private val urls: List<FileUrl>, val book: Book, val novel: String) :
class UrlAdapter(private val urls: List<FileUrl>, val book: Book, val novel: String, val callback: BookDialog.Callback?) :
RecyclerView.Adapter<UrlAdapter.UrlViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UrlViewHolder {
@ -26,6 +25,7 @@ class UrlAdapter(private val urls: List<FileUrl>, val book: Book, val novel: Str
val binding = holder.binding
val url = urls[position]
binding.urlQuality.text = url.url
binding.urlQuality.maxLines = 4
binding.urlDownload.visibility = View.VISIBLE
}
@ -36,12 +36,14 @@ class UrlAdapter(private val urls: List<FileUrl>, val book: Book, val novel: Str
itemView.setSafeOnClickListener {
tryWith(true) {
binding.urlDownload.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
download(
callback?.onDownloadTriggered(book.links[bindingAdapterPosition].url)
/*download(
itemView.context,
book,
bindingAdapterPosition,
novel
)
)*/
}
}
itemView.setOnLongClickListener {

View file

@ -138,7 +138,7 @@ class NovelReaderActivity : AppCompatActivity(), EbookReaderEventListener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
LangSet.setLocale(this)
ThemeManager(this).applyTheme()
ThemeManager(this).applyTheme()
binding = ActivityNovelReaderBinding.inflate(layoutInflater)
setContentView(binding.root)

View file

@ -11,30 +11,22 @@ import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.widget.Toast
import androidx.core.content.ContextCompat
import ani.dantotsu.FileUrl
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource
import eu.kanade.tachiyomi.network.interceptor.CloudflareBypassException
import ani.dantotsu.currContext
import ani.dantotsu.download.manga.MangaDownloaderService
import ani.dantotsu.download.manga.ServiceDataSingleton
import ani.dantotsu.logger
import ani.dantotsu.media.anime.AnimeNameAdapter
import ani.dantotsu.media.manga.ImageData
import ani.dantotsu.media.manga.MangaCache
import com.google.firebase.crashlytics.FirebaseCrashlytics
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimesPage
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
@ -49,11 +41,8 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream
import java.net.URL
import java.net.URLDecoder
import uy.kohesive.injekt.Injekt

View file

@ -167,7 +167,7 @@ data class ShowResponse(
val total: Int? = null,
//In case you want to sent some extra data
val extra : Map<String,String>?=null,
val extra : MutableMap<String,String>?=null,
//SAnime object from Aniyomi
val sAnime: SAnime? = null,
@ -175,7 +175,7 @@ data class ShowResponse(
//SManga object from Aniyomi
val sManga: SManga? = null
) : Serializable {
constructor(name: String, link: String, coverUrl: String, otherNames: List<String> = listOf(), total: Int? = null, extra: Map<String, String>?=null)
constructor(name: String, link: String, coverUrl: String, otherNames: List<String> = listOf(), total: Int? = null, extra: MutableMap<String, String>?=null)
: this(name, link, FileUrl(coverUrl), otherNames, total, extra)
constructor(name: String, link: String, coverUrl: String, otherNames: List<String> = listOf(), total: Int? = null)

View file

@ -0,0 +1,8 @@
package ani.dantotsu.parsers
import com.lagradost.nicehttp.Requests
interface NovelInterface {
suspend fun search(query: String, client: Requests): List<ShowResponse>
suspend fun loadBook(link: String, extra: Map<String, String>?, client: Requests): Book
}

View file

@ -1,9 +1,32 @@
package ani.dantotsu.parsers
import android.util.Log
import ani.dantotsu.Lazier
import ani.dantotsu.lazyList
import ani.dantotsu.parsers.novel.NovelExtension
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import ani.dantotsu.parsers.novel.DynamicNovelParser
object NovelSources : NovelReadSources() {
override val list: List<Lazier<BaseParser>> = lazyList(
)
override var list: List<Lazier<BaseParser>> = emptyList()
suspend fun init(fromExtensions: StateFlow<List<NovelExtension.Installed>>) {
// Initialize with the first value from StateFlow
val initialExtensions = fromExtensions.first()
list = createParsersFromExtensions(initialExtensions)
// Update as StateFlow emits new values
fromExtensions.collect { extensions ->
list = createParsersFromExtensions(extensions)
}
}
private fun createParsersFromExtensions(extensions: List<NovelExtension.Installed>): List<Lazier<BaseParser>> {
Log.d("NovelSources", "createParsersFromExtensions")
Log.d("NovelSources", extensions.toString())
return extensions.map { extension ->
val name = extension.name
Lazier({ DynamicNovelParser(extension) }, name)
}
}
}

View file

@ -0,0 +1,41 @@
package ani.dantotsu.parsers.novel
import ani.dantotsu.FileUrl
import ani.dantotsu.parsers.Book
import ani.dantotsu.parsers.NovelInterface
import ani.dantotsu.parsers.NovelParser
import ani.dantotsu.parsers.ShowResponse
import eu.kanade.tachiyomi.network.NetworkHelper
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class NovelAdapter {
}
class DynamicNovelParser(extension: NovelExtension.Installed) : NovelParser() {
override val volumeRegex = Regex("vol\\.? (\\d+(\\.\\d+)?)|volume (\\d+(\\.\\d+)?)", RegexOption.IGNORE_CASE)
var extension: NovelExtension.Installed
val client = Injekt.get<NetworkHelper>().requestClient
init {
this.extension = extension
}
override suspend fun search(query: String): List<ShowResponse> {
val source = extension.sources.firstOrNull()
if (source is NovelInterface) {
return source.search(query, client)
} else {
return emptyList()
}
}
override suspend fun loadBook(link: String, extra: Map<String, String>?): Book {
val source = extension.sources.firstOrNull()
if (source is NovelInterface) {
return source.loadBook(link, extra, client)
} else {
return Book("", "", "", emptyList())
}
}
}

View file

@ -0,0 +1,56 @@
package ani.dantotsu.parsers.novel
import android.graphics.drawable.Drawable
import ani.dantotsu.parsers.NovelInterface
sealed class NovelExtension {
abstract val name: String
abstract val pkgName: String
abstract val versionName: String
abstract val versionCode: Long
data class Installed(
override val name: String,
override val pkgName: String,
override val versionName: String,
override val versionCode: Long,
val sources: List<NovelInterface>,
val icon: Drawable?,
val hasUpdate: Boolean = false,
val isObsolete: Boolean = false,
val isUnofficial: Boolean = false,
) : NovelExtension()
data class Available(
override val name: String,
override val pkgName: String,
override val versionName: String,
override val versionCode: Long,
val sources: List<AvailableNovelSources>,
val iconUrl: String,
) : NovelExtension()
}
data class AvailableNovelSources(
val id: Long,
val lang: String,
val name: String,
val baseUrl: String,
) {
fun toNovelSourceData(): NovelSourceData {
return NovelSourceData(
id = this.id,
lang = this.lang,
name = this.name,
)
}
}
data class NovelSourceData(
val id: Long,
val lang: String,
val name: String,
) {
val isMissingInfo: Boolean = name.isBlank() || lang.isBlank()
}

View file

@ -0,0 +1,178 @@
package ani.dantotsu.parsers.novel
import android.content.Context
import ani.dantotsu.currContext
import ani.dantotsu.logger
import eu.kanade.tachiyomi.extension.ExtensionUpdateNotifier
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
import eu.kanade.tachiyomi.extension.anime.model.AnimeLoadResult
import eu.kanade.tachiyomi.extension.anime.util.AnimeExtensionLoader
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import logcat.LogPriority
import tachiyomi.core.util.lang.withIOContext
import tachiyomi.core.util.system.logcat
import uy.kohesive.injekt.injectLazy
import java.util.Date
import kotlin.time.Duration.Companion.days
class NovelExtensionGithubApi {
private val networkService: NetworkHelper by injectLazy()
private val novelExtensionManager: NovelExtensionManager by injectLazy()
private val json: Json by injectLazy()
private val lastExtCheck: Long = currContext()?.getSharedPreferences("Dantotsu", Context.MODE_PRIVATE)?.getLong("last_ext_check", 0)?:0
private var requiresFallbackSource = false
suspend fun findExtensions(): List<NovelExtension.Available> {
return withIOContext {
val githubResponse = if (requiresFallbackSource) {
null
} else {
try {
networkService.client
.newCall(GET("${REPO_URL_PREFIX}index.min.json"))
.awaitSuccess()
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Failed to get extensions from GitHub" }
requiresFallbackSource = true
null
}
}
val response = githubResponse ?: run {
logger("using fallback source")
networkService.client
.newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json"))
.awaitSuccess()
}
logger("response: $response")
val extensions = with(json) {
response
.parseAs<List<NovelExtensionJsonObject>>()
.toExtensions()
}
// Sanity check - a small number of extensions probably means something broke
// with the repo generator
/*if (extensions.size < 10) { //TODO: uncomment when more extensions are added
throw Exception()
}*/
logger("extensions: $extensions")
extensions
}
}
suspend fun checkForUpdates(context: Context, fromAvailableExtensionList: Boolean = false): List<AnimeExtension.Installed>? {
// Limit checks to once a day at most
if (fromAvailableExtensionList && Date().time < lastExtCheck + 1.days.inWholeMilliseconds) {
return null
}
val extensions = if (fromAvailableExtensionList) {
novelExtensionManager.availableExtensionsFlow.value
} else {
findExtensions().also { context.getSharedPreferences("Dantotsu", Context.MODE_PRIVATE)?.edit()?.putLong("last_ext_check", Date().time)?.apply() }
}
val installedExtensions = NovelExtensionLoader.loadExtensions(context)
.filterIsInstance<AnimeLoadResult.Success>()
.map { it.extension }
val extensionsWithUpdate = mutableListOf<AnimeExtension.Installed>()
for (installedExt in installedExtensions) {
val pkgName = installedExt.pkgName
val availableExt = extensions.find { it.pkgName == pkgName } ?: continue
val hasUpdatedVer = availableExt.versionCode > installedExt.versionCode
val hasUpdate = installedExt.isUnofficial.not() && (hasUpdatedVer)
if (hasUpdate) {
extensionsWithUpdate.add(installedExt)
}
}
if (extensionsWithUpdate.isNotEmpty()) {
ExtensionUpdateNotifier(context).promptUpdates(extensionsWithUpdate.map { it.name })
}
return extensionsWithUpdate
}
private fun List<NovelExtensionJsonObject>.toExtensions(): List<NovelExtension.Available> {
return mapNotNull { extension ->
val sources = extension.sources?.map { source ->
NovelExtensionSourceJsonObject(
source.id,
source.lang,
source.name,
source.baseUrl,
)
}
val iconUrl = "${REPO_URL_PREFIX}icons/${extension.pkg}.png"
NovelExtension.Available(
extension.name,
extension.pkg,
extension.apk,
extension.code,
sources?.toSources() ?: emptyList(),
iconUrl,
)
}
}
private fun List<NovelExtensionSourceJsonObject>.toSources(): List<AvailableNovelSources> {
return map { source ->
AvailableNovelSources(
source.id,
source.lang,
source.name,
source.baseUrl,
)
}
}
fun getApkUrl(extension: NovelExtension.Available): String {
return "${getUrlPrefix()}apk/${extension.pkgName}.apk"
}
private fun getUrlPrefix(): String {
return if (requiresFallbackSource) {
FALLBACK_REPO_URL_PREFIX
} else {
REPO_URL_PREFIX
}
}
}
private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/dannovels/novel-extensions/main/"
private const val FALLBACK_REPO_URL_PREFIX = "https://gcore.jsdelivr.net/gh/dannovels/novel-extensions@latest/"
@Serializable
private data class NovelExtensionJsonObject(
val name: String,
val pkg: String,
val apk: String,
val lang: String,
val code: Long,
val version: String,
val nsfw: Int,
val hasReadme: Int = 0,
val hasChangelog: Int = 0,
val sources: List<NovelExtensionSourceJsonObject>?,
)
@Serializable
private data class NovelExtensionSourceJsonObject(
val id: Long,
val lang: String,
val name: String,
val baseUrl: String,
)

View file

@ -0,0 +1,80 @@
package ani.dantotsu.parsers.novel
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.FileObserver
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import ani.dantotsu.parsers.novel.FileObserver.fileObserver
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
import eu.kanade.tachiyomi.extension.anime.model.AnimeLoadResult
import eu.kanade.tachiyomi.extension.anime.util.AnimeExtensionLoader
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import logcat.LogPriority
import tachiyomi.core.util.lang.launchNow
import tachiyomi.core.util.system.logcat
import java.io.File
import java.lang.Exception
class NovelExtensionFileObserver(private val listener: Listener, private val path: String) : FileObserver(path, CREATE or DELETE or MOVED_FROM or MOVED_TO or MODIFY) {
init {
fileObserver = this
}
/**
* Starts observing the file changes in the directory.
*/
fun register() {
startWatching()
}
override fun onEvent(event: Int, file: String?) {
Log.e("NovelExtensionFileObserver", "Event: $event")
if (file == null) return
val fullPath = File(path, file)
when (event) {
CREATE -> {
Log.e("NovelExtensionFileObserver", "File created: $fullPath")
listener.onExtensionFileCreated(fullPath)
}
DELETE -> {
Log.e("NovelExtensionFileObserver", "File deleted: $fullPath")
listener.onExtensionFileDeleted(fullPath)
}
MODIFY -> {
Log.e("NovelExtensionFileObserver", "File modified: $fullPath")
listener.onExtensionFileModified(fullPath)
}
}
}
/**
* Loads the extension from the file.
*
* @param file The file name of the extension.
*/
//private suspend fun loadExtensionFromFile(file: String): String {
// return file
//}
interface Listener {
fun onExtensionFileCreated(file: File)
fun onExtensionFileDeleted(file: File)
fun onExtensionFileModified(file: File)
}
}
object FileObserver {
var fileObserver: FileObserver? = null
}

View file

@ -0,0 +1,367 @@
package ani.dantotsu.parsers.novel
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import ani.dantotsu.snackString
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.extension.InstallStep
import eu.kanade.tachiyomi.util.storage.getUriCompat
import logcat.LogPriority
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import tachiyomi.core.util.system.logcat
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.channels.FileChannel
import java.nio.file.Files
import java.util.concurrent.TimeUnit
/**
* The installer which installs, updates and uninstalls the extensions.
*
* @param context The application context.
*/
internal class NovelExtensionInstaller(private val context: Context) {
/**
* The system's download manager
*/
private val downloadManager = context.getSystemService<DownloadManager>()!!
/**
* The broadcast receiver which listens to download completion events.
*/
private val downloadReceiver = DownloadCompletionReceiver()
/**
* The currently requested downloads, with the package name (unique id) as key, and the id
* returned by the download manager.
*/
private val activeDownloads = hashMapOf<String, Long>()
/**
* Relay used to notify the installation step of every download.
*/
private val downloadsRelay = PublishRelay.create<Pair<Long, InstallStep>>()
/**
* Adds the given extension to the downloads queue and returns an observable containing its
* step in the installation process.
*
* @param url The url of the apk.
* @param extension The extension to install.
*/
fun downloadAndInstall(url: String, extension: NovelExtension) = Observable.defer {
val pkgName = extension.pkgName
val oldDownload = activeDownloads[pkgName]
if (oldDownload != null) {
deleteDownload(pkgName)
}
val sourcePath = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
//if the file is already downloaded, remove it
val fileToDelete = File("$sourcePath/${url.toUri().lastPathSegment}")
if (fileToDelete.exists()) {
if (fileToDelete.delete()) {
Log.i("Install APK", "APK file deleted successfully.")
} else {
Log.e("Install APK", "Failed to delete APK file.")
}
} else {
Log.e("Install APK", "APK file not found.")
}
// Register the receiver after removing (and unregistering) the previous download
downloadReceiver.register()
val downloadUri = url.toUri()
val request = DownloadManager.Request(downloadUri)
.setTitle(extension.name)
.setMimeType(NovelExtensionInstaller.APK_MIME)
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadUri.lastPathSegment)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
val id = downloadManager.enqueue(request)
activeDownloads[pkgName] = id
downloadsRelay.filter { it.first == id }
.map { it.second }
// Poll download status
.mergeWith(pollStatus(id))
// Stop when the application is installed or errors
.takeUntil { it.isCompleted() }
// Always notify on main thread
.observeOn(AndroidSchedulers.mainThread())
// Always remove the download when unsubscribed
.doOnUnsubscribe { deleteDownload(pkgName) }
}
/**
* Returns an observable that polls the given download id for its status every second, as the
* manager doesn't have any notification system. It'll stop once the download finishes.
*
* @param id The id of the download to poll.
*/
private fun pollStatus(id: Long): Observable<InstallStep> {
val query = DownloadManager.Query().setFilterById(id)
return Observable.interval(0, 1, TimeUnit.SECONDS)
// Get the current download status
.map {
downloadManager.query(query).use { cursor ->
if (cursor.moveToFirst()) {
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
} else {
DownloadManager.STATUS_FAILED
}
}
}
// Ignore duplicate results
.distinctUntilChanged()
// Stop polling when the download fails or finishes
.takeUntil { it == DownloadManager.STATUS_SUCCESSFUL || it == DownloadManager.STATUS_FAILED }
// Map to our model
.flatMap { status ->
when (status) {
DownloadManager.STATUS_PENDING -> Observable.just(InstallStep.Pending)
DownloadManager.STATUS_RUNNING -> Observable.just(InstallStep.Downloading)
DownloadManager.STATUS_SUCCESSFUL -> Observable.just(InstallStep.Installing)
else -> Observable.empty()
}
}
}
fun installApk(downloadId: Long, uri: Uri, context: Context, pkgName: String) : InstallStep {
val sourcePath = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.absolutePath + "/" + uri.lastPathSegment
val destinationPath = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/$pkgName.apk"
val destinationPathDirectory = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/"
val destinationPathDirectoryFile = File(destinationPathDirectory)
// Check if source path is obtained correctly
if (sourcePath == null) {
Log.e("Install APK", "Source APK path not found.")
downloadsRelay.call(downloadId to InstallStep.Error)
return InstallStep.Error
}
// Create the destination directory if it doesn't exist
val destinationDir = File(destinationPath).parentFile
if (destinationDir?.exists() == false) {
destinationDir.mkdirs()
}
if(destinationDir?.setWritable(true) == false) {
Log.e("Install APK", "Failed to set destinationDir to writable.")
downloadsRelay.call(downloadId to InstallStep.Error)
return InstallStep.Error
}
// Copy the file to the new location
copyFileToInternalStorage(sourcePath, destinationPath)
Log.i("Install APK", "APK moved to $destinationPath")
downloadsRelay.call(downloadId to InstallStep.Installed)
return InstallStep.Installed
}
/**
* Cancels extension install and remove from download manager and installer.
*/
fun cancelInstall(pkgName: String) {
val downloadId = activeDownloads.remove(pkgName) ?: return
downloadManager.remove(downloadId)
}
fun uninstallApk(pkgName: String, context: Context) {
val apkPath = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/$pkgName.apk"
val fileToDelete = File(apkPath)
//give write permission to the file
if (fileToDelete.exists() && !fileToDelete.canWrite()) {
Log.i("Uninstall APK", "File is not writable. Giving write permission.")
val a = fileToDelete.setWritable(true)
Log.i("Uninstall APK", "Success: $a")
}
//set the directory to writable
val destinationDir = File(apkPath).parentFile
if (destinationDir?.exists() == false) {
destinationDir.mkdirs()
}
val s = destinationDir?.setWritable(true)
Log.i("Uninstall APK", "Success destinationDir: $s")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
Files.delete(fileToDelete.toPath())
} catch (e: Exception) {
Log.e("Uninstall APK", "Failed to delete APK file.")
Log.e("Uninstall APK", e.toString())
snackString("Failed to delete APK file.")
}
} else {
if (fileToDelete.exists()) {
if (fileToDelete.delete()) {
Log.i("Uninstall APK", "APK file deleted successfully.")
snackString("APK file deleted successfully.")
} else {
Log.e("Uninstall APK", "Failed to delete APK file.")
snackString("Failed to delete APK file.")
}
} else {
Log.e("Uninstall APK", "APK file not found.")
snackString("APK file not found.")
}
}
}
private fun copyFileToInternalStorage(sourcePath: String, destinationPath: String) {
val source = File(sourcePath)
val destination = File(destinationPath)
destination.setWritable(true)
var inputChannel: FileChannel? = null
var outputChannel: FileChannel? = null
try {
inputChannel = FileInputStream(source).channel
outputChannel = FileOutputStream(destination).channel
inputChannel.transferTo(0, inputChannel.size(), outputChannel)
destination.setWritable(false)
} catch (e: Exception) {
e.printStackTrace()
} finally {
inputChannel?.close()
outputChannel?.close()
}
Log.i("File Copy", "File copied to internal storage.")
}
private fun getRealPathFromURI(context: Context, contentUri: Uri): String? {
var cursor: Cursor? = null
try {
val proj = arrayOf(MediaStore.Images.Media.DATA)
cursor = context.contentResolver.query(contentUri, proj, null, null, null)
val columnIndex = cursor?.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
if (cursor != null && cursor.moveToFirst() && columnIndex != null) {
return cursor.getString(columnIndex)
}
} finally {
cursor?.close()
}
return null
}
/**
* Sets the step of the installation of an extension.
*
* @param downloadId The id of the download.
* @param step New install step.
*/
fun updateInstallStep(downloadId: Long, step: InstallStep) {
downloadsRelay.call(downloadId to step)
}
/**
* Deletes the download for the given package name.
*
* @param pkgName The package name of the download to delete.
*/
private fun deleteDownload(pkgName: String) {
val downloadId = activeDownloads.remove(pkgName)
if (downloadId != null) {
downloadManager.remove(downloadId)
}
if (activeDownloads.isEmpty()) {
downloadReceiver.unregister()
}
}
/**
* Receiver that listens to download status events.
*/
private inner class DownloadCompletionReceiver : BroadcastReceiver() {
/**
* Whether this receiver is currently registered.
*/
private var isRegistered = false
/**
* Registers this receiver if it's not already.
*/
fun register() {
if (isRegistered) return
isRegistered = true
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_EXPORTED)
}
/**
* Unregisters this receiver if it's not already.
*/
fun unregister() {
if (!isRegistered) return
isRegistered = false
context.unregisterReceiver(this)
}
/**
* Called when a download event is received. It looks for the download in the current active
* downloads and notifies its installation step.
*/
override fun onReceive(context: Context, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0) ?: return
// Avoid events for downloads we didn't request
if (id !in activeDownloads.values) return
val uri = downloadManager.getUriForDownloadedFile(id)
// Set next installation step
if (uri == null) {
logcat(LogPriority.ERROR) { "Couldn't locate downloaded APK" }
downloadsRelay.call(id to InstallStep.Error)
return
}
val query = DownloadManager.Query().setFilterById(id)
downloadManager.query(query).use { cursor ->
if (cursor.moveToFirst()) {
val localUri = cursor.getString(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI),
).removePrefix(FILE_SCHEME)
val pkgName = extractPkgNameFromUri(localUri)
installApk(id, File(localUri).getUriCompat(context), context, pkgName)
}
}
}
private fun extractPkgNameFromUri(localUri: String): String {
val uri = Uri.parse(localUri)
val path = uri.path
val pkgName = path?.substring(path.lastIndexOf('/') + 1)?.removeSuffix(".apk")
Log.i("Install APK", "Package name: $pkgName")
return pkgName ?: ""
}
}
companion object {
const val APK_MIME = "application/vnd.android.package-archive"
const val EXTRA_DOWNLOAD_ID = "NovelExtensionInstaller.extra.DOWNLOAD_ID"
const val FILE_SCHEME = "file://"
}
}

View file

@ -0,0 +1,129 @@
package ani.dantotsu.parsers.novel
import android.content.Context
import android.content.pm.PackageInfo
import android.util.Log
import ani.dantotsu.logger
import ani.dantotsu.parsers.NovelInterface
import com.google.firebase.crashlytics.FirebaseCrashlytics
import dalvik.system.PathClassLoader
import eu.kanade.tachiyomi.extension.anime.util.AnimeExtensionLoader
import eu.kanade.tachiyomi.util.lang.Hash
import tachiyomi.core.util.system.logcat
import java.io.File
import java.util.Locale
internal object NovelExtensionLoader {
private const val officialSignature =
"a3061edb369278749b8e8de810d440d38e96417bbd67bbdfc5d9d9ed475ce4a5" //dan's key
fun loadExtensions(context: Context): List<NovelLoadResult> {
val installDir = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/"
val results = mutableListOf<NovelLoadResult>()
//the number of files
Log.e("NovelExtensionLoader", "Loading extensions from $installDir")
Log.e("NovelExtensionLoader", "Loading extensions from ${File(installDir).listFiles()?.size}")
File(installDir).setWritable(false)
File(installDir).listFiles()?.forEach {
//set the file to read only
it.setWritable(false)
Log.e("NovelExtensionLoader", "Loading extension ${it.name}")
val extension = loadExtension(context, it)
if (extension is NovelLoadResult.Success) {
results.add(extension)
} else {
logger("Failed to load extension ${it.name}")
}
}
return results
}
/**
* Attempts to load an extension from the given package name. It checks if the extension
* contains the required feature flag before trying to load it.
*/
fun loadExtensionFromPkgName(context: Context, pkgName: String): NovelLoadResult {
val path = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/$pkgName.apk"
//make /extensions/novel read only
context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/".let {
File(it).setWritable(false)
File(it).setReadable(true)
}
val pkgInfo = try {
context.packageManager.getPackageArchiveInfo(path, 0)
} catch (error: Exception) {
// Unlikely, but the package may have been uninstalled at this point
logger("Failed to load extension $pkgName")
return NovelLoadResult.Error(Exception("Failed to load extension"))
}
return loadExtension(context, File(path))
}
fun loadExtension(context: Context, file: File): NovelLoadResult {
val packageInfo = context.packageManager.getPackageArchiveInfo(file.absolutePath, 0)
?: return NovelLoadResult.Error(Exception("Failed to load extension"))
val appInfo = packageInfo.applicationInfo
?: return NovelLoadResult.Error(Exception("Failed to load Extension Info"))
appInfo.sourceDir = file.absolutePath;
appInfo.publicSourceDir = file.absolutePath;
val signatureHash = getSignatureHash(packageInfo)
if (signatureHash == null || signatureHash != officialSignature) {
logger("Package ${packageInfo.packageName} isn't signed")
logger("signatureHash: $signatureHash")
//return NovelLoadResult.Error(Exception("Extension not signed"))
}
val extension = NovelExtension.Installed(
packageInfo.applicationInfo?.loadLabel(context.packageManager)?.toString() ?:
return NovelLoadResult.Error(Exception("Failed to load Extension Info")),
packageInfo.packageName
?: return NovelLoadResult.Error(Exception("Failed to load Extension Info")),
packageInfo.versionName ?: "",
packageInfo.versionCode.toLong() ?: 0,
loadSources(context, file,
packageInfo.applicationInfo?.loadLabel(context.packageManager)?.toString()!!
),
packageInfo.applicationInfo?.loadIcon(context.packageManager)
)
return NovelLoadResult.Success(extension)
}
private fun getSignatureHash(pkgInfo: PackageInfo): String? {
val signatures = pkgInfo.signatures
return if (signatures != null && signatures.isNotEmpty()) {
Hash.sha256(signatures.first().toByteArray())
} else {
null
}
}
private fun loadSources(context: Context, file: File, className: String): List<NovelInterface> {
return try {
Log.e("NovelExtensionLoader", "isFileWritable: ${file.canWrite()}")
if (file.canWrite()) {
val a = file.setWritable(false)
Log.e("NovelExtensionLoader", "success: $a")
}
Log.e("NovelExtensionLoader", "isFileWritable: ${file.canWrite()}")
val classLoader = PathClassLoader(file.absolutePath, null, context.classLoader)
val className = "some.random.novelextensions.${className.lowercase(Locale.getDefault())}.$className"
val loadedClass = classLoader.loadClass(className)
val instance = loadedClass.newInstance()
val novelInterfaceInstance = instance as? NovelInterface
listOfNotNull(novelInterfaceInstance)
} catch (e: Exception) {
e.printStackTrace()
FirebaseCrashlytics.getInstance().recordException(e)
emptyList()
}
}
}
sealed class NovelLoadResult {
data class Success(val extension: NovelExtension.Installed) : NovelLoadResult()
data class Error(val error: Exception) : NovelLoadResult()
}

View file

@ -0,0 +1,243 @@
package ani.dantotsu.parsers.novel
import android.content.Context
import android.graphics.drawable.Drawable
import android.os.Build
import ani.dantotsu.logger
import ani.dantotsu.snackString
import eu.kanade.tachiyomi.extension.InstallStep
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import rx.Observable
import tachiyomi.core.util.lang.withUIContext
import java.io.File
class NovelExtensionManager(private val context: Context) {
var isInitialized = false
private set
/**
* API where all the available Novel extensions can be found.
*/
private val api = NovelExtensionGithubApi()
/**
* The installer which installs, updates and uninstalls the Novel extensions.
*/
private val installer by lazy { NovelExtensionInstaller(context) }
private val iconMap = mutableMapOf<String, Drawable>()
private val _installedNovelExtensionsFlow =
MutableStateFlow(emptyList<NovelExtension.Installed>())
val installedExtensionsFlow = _installedNovelExtensionsFlow.asStateFlow()
private val _availableNovelExtensionsFlow =
MutableStateFlow(emptyList<NovelExtension.Available>())
val availableExtensionsFlow = _availableNovelExtensionsFlow.asStateFlow()
private var availableNovelExtensionsSourcesData: Map<Long, NovelSourceData> = emptyMap()
private fun setupAvailableNovelExtensionsSourcesDataMap(novelExtensions: List<NovelExtension.Available>) {
if (novelExtensions.isEmpty()) return
availableNovelExtensionsSourcesData = novelExtensions
.flatMap { ext -> ext.sources.map { it.toNovelSourceData() } }
.associateBy { it.id }
}
fun getSourceData(id: Long) = availableNovelExtensionsSourcesData[id]
init {
initNovelExtensions()
val path = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/"
NovelExtensionFileObserver(NovelInstallationListener(),path).register()
}
private fun initNovelExtensions() {
val novelExtensions = NovelExtensionLoader.loadExtensions(context)
_installedNovelExtensionsFlow.value = novelExtensions
.filterIsInstance<NovelLoadResult.Success>()
.map { it.extension }
isInitialized = true
}
/**
* Finds the available manga extensions in the [api] and updates [availableExtensions].
*/
suspend fun findAvailableExtensions() {
val extensions: List<NovelExtension.Available> = try {
api.findExtensions()
} catch (e: Exception) {
logger("Error finding extensions: ${e.message}")
withUIContext { snackString("Failed to get Novel extensions list") }
emptyList()
}
_availableNovelExtensionsFlow.value = extensions
updatedInstalledNovelExtensionsStatuses(extensions)
setupAvailableNovelExtensionsSourcesDataMap(extensions)
}
private fun updatedInstalledNovelExtensionsStatuses(availableNovelExtensions: List<NovelExtension.Available>) {
if (availableNovelExtensions.isEmpty()) {
return
}
val mutInstalledNovelExtensions = _installedNovelExtensionsFlow.value.toMutableList()
var hasChanges = false
for ((index, installedExt) in mutInstalledNovelExtensions.withIndex()) {
val pkgName = installedExt.pkgName
val availableExt = availableNovelExtensions.find { it.pkgName == pkgName }
if (availableExt == null && !installedExt.isObsolete) {
mutInstalledNovelExtensions[index] = installedExt.copy(isObsolete = true)
hasChanges = true
} else if (availableExt != null) {
val hasUpdate = installedExt.updateExists(availableExt)
if (installedExt.hasUpdate != hasUpdate) {
mutInstalledNovelExtensions[index] = installedExt.copy(hasUpdate = hasUpdate)
hasChanges = true
}
}
}
if (hasChanges) {
_installedNovelExtensionsFlow.value = mutInstalledNovelExtensions
}
}
/**
* Returns an observable of the installation process for the given novel extension. It will complete
* once the novel extension is installed or throws an error. The process will be canceled if
* unsubscribed before its completion.
*
* @param extension The anime extension to be installed.
*/
fun installExtension(extension: NovelExtension.Available): Observable<InstallStep> {
return installer.downloadAndInstall(api.getApkUrl(extension), extension)
}
/**
* Returns an observable of the installation process for the given anime extension. It will complete
* once the anime extension is updated or throws an error. The process will be canceled if
* unsubscribed before its completion.
*
* @param extension The anime extension to be updated.
*/
fun updateExtension(extension: NovelExtension.Installed): Observable<InstallStep> {
val availableExt = _availableNovelExtensionsFlow.value.find { it.pkgName == extension.pkgName }
?: return Observable.empty()
return installExtension(availableExt)
}
fun cancelInstallUpdateExtension(extension: NovelExtension) {
installer.cancelInstall(extension.pkgName)
}
/**
* Sets to "installing" status of an novel extension installation.
*
* @param downloadId The id of the download.
*/
fun setInstalling(downloadId: Long) {
installer.updateInstallStep(downloadId, InstallStep.Installing)
}
fun updateInstallStep(downloadId: Long, step: InstallStep) {
installer.updateInstallStep(downloadId, step)
}
/**
* Uninstalls the novel extension that matches the given package name.
*
* @param pkgName The package name of the application to uninstall.
*/
fun uninstallExtension(pkgName: String, context: Context) {
installer.uninstallApk(pkgName, context)
}
/**
* Registers the given novel extension in this and the source managers.
*
* @param extension The anime extension to be registered.
*/
private fun registerNewExtension(extension: NovelExtension.Installed) {
_installedNovelExtensionsFlow.value += extension
}
/**
* Registers the given updated novel extension in this and the source managers previously removing
* the outdated ones.
*
* @param extension The anime extension to be registered.
*/
private fun registerUpdatedExtension(extension: NovelExtension.Installed) {
val mutInstalledNovelExtensions = _installedNovelExtensionsFlow.value.toMutableList()
val oldNovelExtension = mutInstalledNovelExtensions.find { it.pkgName == extension.pkgName }
if (oldNovelExtension != null) {
mutInstalledNovelExtensions -= oldNovelExtension
}
mutInstalledNovelExtensions += extension
_installedNovelExtensionsFlow.value = mutInstalledNovelExtensions
}
/**
* Unregisters the novel extension in this and the source managers given its package name. Note this
* method is called for every uninstalled application in the system.
*
* @param pkgName The package name of the uninstalled application.
*/
private fun unregisterNovelExtension(pkgName: String) {
val installedNovelExtension = _installedNovelExtensionsFlow.value.find { it.pkgName == pkgName }
if (installedNovelExtension != null) {
_installedNovelExtensionsFlow.value -= installedNovelExtension
}
}
/**
* Listener which receives events of the novel extensions being installed, updated or removed.
*/
private inner class NovelInstallationListener : NovelExtensionFileObserver.Listener {
override fun onExtensionFileCreated(file: File) {
NovelExtensionLoader.loadExtension(context, file).let {
if (it is NovelLoadResult.Success) {
registerNewExtension(it.extension)
}
}
}
override fun onExtensionFileDeleted(file: File) {
val pkgName = file.nameWithoutExtension
unregisterNovelExtension(pkgName)
}
override fun onExtensionFileModified(file: File) {
NovelExtensionLoader.loadExtension(context, file).let {
if (it is NovelLoadResult.Success) {
registerUpdatedExtension(it.extension)
}
}
}
}
/**
* AnimeExtension method to set the update field of an installed anime extension.
*/
private fun NovelExtension.Installed.withUpdateCheck(): NovelExtension.Installed {
return if (updateExists()) {
copy(hasUpdate = true)
} else {
this
}
}
private fun NovelExtension.Installed.updateExists(availableNovelExtension: NovelExtension.Available? = null): Boolean {
val availableExt = availableNovelExtension ?: _availableNovelExtensionsFlow.value.find { it.pkgName == pkgName }
if (isUnofficial || availableExt == null) return false
return (availableExt.versionCode > versionCode)
}
}

View file

@ -17,6 +17,7 @@ import ani.dantotsu.settings.paging.AnimeExtensionAdapter
import ani.dantotsu.settings.paging.AnimeExtensionsViewModel
import ani.dantotsu.settings.paging.AnimeExtensionsViewModelFactory
import ani.dantotsu.settings.paging.OnAnimeInstallClickListener
import ani.dantotsu.snackString
import com.google.firebase.crashlytics.FirebaseCrashlytics
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager
@ -101,6 +102,7 @@ class AnimeExtensionsFragment : Fragment(),
.setContentText("Error: ${error.message}")
.setPriority(NotificationCompat.PRIORITY_HIGH)
notificationManager.notify(1, builder.build())
snackString("Installation failed: ${error.message}")
},
{
val builder = NotificationCompat.Builder(
@ -113,6 +115,7 @@ class AnimeExtensionsFragment : Fragment(),
.setPriority(NotificationCompat.PRIORITY_LOW)
notificationManager.notify(1, builder.build())
viewModel.invalidatePager()
snackString("Extension installed")
}
)
}

View file

@ -16,6 +16,7 @@ class DevelopersDialogFragment : BottomSheetDialogFragment() {
Developer("rebelonion","https://avatars.githubusercontent.com/u/87634197?v=4","Owner and Maintainer","https://github.com/rebelonion"),
Developer("Wai What", "https://avatars.githubusercontent.com/u/149729762?v=4", "Icon Designer", "https://github.com/WaiWhat"),
Developer("Aayush262", "https://avatars.githubusercontent.com/u/99584765?v=4", "Contributor", "https://github.com/aayush2622"),
Developer("MarshMeadow", "https://avatars.githubusercontent.com/u/88599122?v=4", "Beta Icon Designer", "https://github.com/MarshMeadow?tab=repositories"),
)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {

View file

@ -40,7 +40,7 @@ class ExtensionsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
LangSet.setLocale(this)
ThemeManager(this).applyTheme()
ThemeManager(this).applyTheme()
binding = ActivityExtensionsBinding.inflate(layoutInflater)
setContentView(binding.root)
@ -49,7 +49,7 @@ ThemeManager(this).applyTheme()
val viewPager = findViewById<ViewPager2>(R.id.viewPager)
viewPager.adapter = object : FragmentStateAdapter(this) {
override fun getItemCount(): Int = 4
override fun getItemCount(): Int = 6
override fun createFragment(position: Int): Fragment {
return when (position) {
@ -57,24 +57,45 @@ ThemeManager(this).applyTheme()
1 -> AnimeExtensionsFragment()
2 -> InstalledMangaExtensionsFragment()
3 -> MangaExtensionsFragment()
4 -> InstalledNovelExtensionsFragment()
5 -> NovelExtensionsFragment()
else -> AnimeExtensionsFragment()
}
}
}
val searchView: AutoCompleteTextView = findViewById(R.id.searchViewText)
tabLayout.addOnTabSelectedListener(
object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
searchView.setText("")
}
override fun onTabUnselected(tab: TabLayout.Tab) {
// Do nothing
}
override fun onTabReselected(tab: TabLayout.Tab) {
// Do nothing
}
}
)
TabLayoutMediator(tabLayout, viewPager) { tab, position ->
tab.text = when (position) {
0 -> "Installed Anime"
1 -> "Available Anime"
2 -> "Installed Manga"
3 -> "Available Manga"
4 -> "Installed Novels"
5 -> "Available Novels"
else -> null
}
}.attach()
val searchView: AutoCompleteTextView = findViewById(R.id.searchViewText)
searchView.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
}

View file

@ -26,6 +26,7 @@ import ani.dantotsu.loadData
import ani.dantotsu.others.LanguageMapper
import ani.dantotsu.saveData
import ani.dantotsu.settings.extensionprefs.AnimeSourcePreferencesFragment
import ani.dantotsu.snackString
import com.google.android.material.tabs.TabLayout
import com.google.android.material.textfield.TextInputLayout
import com.google.firebase.crashlytics.FirebaseCrashlytics
@ -157,6 +158,7 @@ class InstalledAnimeExtensionsFragment : Fragment() {
.setContentText("Error: ${error.message}")
.setPriority(NotificationCompat.PRIORITY_HIGH)
notificationManager.notify(1, builder.build())
snackString("Update failed: ${error.message}")
},
{
val builder = NotificationCompat.Builder(
@ -168,10 +170,12 @@ class InstalledAnimeExtensionsFragment : Fragment() {
.setContentText("The extension has been successfully updated.")
.setPriority(NotificationCompat.PRIORITY_LOW)
notificationManager.notify(1, builder.build())
snackString("Extension updated")
}
)
} else {
animeExtensionManager.uninstallExtension(pkg.pkgName)
snackString("Extension uninstalled")
}
}
}, skipIcons

View file

@ -26,6 +26,7 @@ import ani.dantotsu.databinding.FragmentMangaExtensionsBinding
import ani.dantotsu.loadData
import ani.dantotsu.settings.extensionprefs.MangaSourcePreferencesFragment
import ani.dantotsu.others.LanguageMapper
import ani.dantotsu.snackString
import com.google.android.material.tabs.TabLayout
import com.google.android.material.textfield.TextInputLayout
import com.google.firebase.crashlytics.FirebaseCrashlytics
@ -137,6 +138,7 @@ class InstalledMangaExtensionsFragment : Fragment() {
.setContentText("Error: ${error.message}")
.setPriority(NotificationCompat.PRIORITY_HIGH)
notificationManager.notify(1, builder.build())
snackString("Update failed: ${error.message}")
},
{
val builder = NotificationCompat.Builder(
@ -148,10 +150,12 @@ class InstalledMangaExtensionsFragment : Fragment() {
.setContentText("The extension has been successfully updated.")
.setPriority(NotificationCompat.PRIORITY_LOW)
notificationManager.notify(1, builder.build())
snackString("Extension updated")
}
)
} else {
mangaExtensionManager.uninstallExtension(pkg.pkgName)
snackString("Extension uninstalled")
}
}
}, skipIcons)

View file

@ -0,0 +1,211 @@
package ani.dantotsu.settings
import android.app.AlertDialog
import android.app.NotificationManager
import android.content.Context
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import ani.dantotsu.R
import ani.dantotsu.currContext
import ani.dantotsu.databinding.FragmentMangaExtensionsBinding
import ani.dantotsu.databinding.FragmentNovelExtensionsBinding
import ani.dantotsu.loadData
import ani.dantotsu.others.LanguageMapper
import ani.dantotsu.parsers.novel.NovelExtension
import ani.dantotsu.parsers.novel.NovelExtensionManager
import ani.dantotsu.snackString
import com.google.android.material.tabs.TabLayout
import com.google.android.material.textfield.TextInputLayout
import com.google.firebase.crashlytics.FirebaseCrashlytics
import eu.kanade.tachiyomi.data.notification.Notifications
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import rx.android.schedulers.AndroidSchedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class InstalledNovelExtensionsFragment : Fragment() {
private var _binding: FragmentNovelExtensionsBinding? = null
private val binding get() = _binding!!
private lateinit var extensionsRecyclerView: RecyclerView
val skipIcons = loadData("skip_extension_icons") ?: false
private val novelExtensionManager: NovelExtensionManager = Injekt.get()
private val extensionsAdapter = NovelExtensionsAdapter({ pkg ->
Toast.makeText(requireContext(), "Source is not configurable", Toast.LENGTH_SHORT)
.show()
},
{ pkg ->
if (isAdded) { // Check if the fragment is currently added to its activity
val context = requireContext() // Store context in a variable
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager // Initialize NotificationManager once
if (pkg.hasUpdate) {
novelExtensionManager.updateExtension(pkg)
.observeOn(AndroidSchedulers.mainThread()) // Observe on main thread
.subscribe(
{ installStep ->
val builder = NotificationCompat.Builder(
context,
Notifications.CHANNEL_DOWNLOADER_PROGRESS
)
.setSmallIcon(R.drawable.ic_round_sync_24)
.setContentTitle("Updating extension")
.setContentText("Step: $installStep")
.setPriority(NotificationCompat.PRIORITY_LOW)
notificationManager.notify(1, builder.build())
},
{ error ->
FirebaseCrashlytics.getInstance().recordException(error)
Log.e("NovelExtensionsAdapter", "Error: ", error) // Log the error
val builder = NotificationCompat.Builder(
context,
Notifications.CHANNEL_DOWNLOADER_ERROR
)
.setSmallIcon(R.drawable.ic_round_info_24)
.setContentTitle("Update failed: ${error.message}")
.setContentText("Error: ${error.message}")
.setPriority(NotificationCompat.PRIORITY_HIGH)
notificationManager.notify(1, builder.build())
snackString("Update failed: ${error.message}")
},
{
val builder = NotificationCompat.Builder(
context,
Notifications.CHANNEL_DOWNLOADER_PROGRESS
)
.setSmallIcon(androidx.media3.ui.R.drawable.exo_ic_check)
.setContentTitle("Update complete")
.setContentText("The extension has been successfully updated.")
.setPriority(NotificationCompat.PRIORITY_LOW)
notificationManager.notify(1, builder.build())
snackString("Extension updated")
}
)
} else {
novelExtensionManager.uninstallExtension(pkg.pkgName, currContext()?:context)
snackString("Extension uninstalled")
}
}
}, skipIcons)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentNovelExtensionsBinding.inflate(inflater, container, false)
extensionsRecyclerView = binding.allNovelExtensionsRecyclerView
extensionsRecyclerView.layoutManager = LinearLayoutManager(requireContext())
extensionsRecyclerView.adapter = extensionsAdapter
lifecycleScope.launch {
novelExtensionManager.installedExtensionsFlow.collect { extensions ->
extensionsAdapter.updateData(extensions)
}
}
val extensionsRecyclerView: RecyclerView = binding.allNovelExtensionsRecyclerView
return binding.root
}
override fun onResume() {
super.onResume()
}
override fun onDestroyView() {
super.onDestroyView();_binding = null
}
private class NovelExtensionsAdapter(
private val onSettingsClicked: (NovelExtension.Installed) -> Unit,
private val onUninstallClicked: (NovelExtension.Installed) -> Unit,
skipIcons: Boolean
) : ListAdapter<NovelExtension.Installed, NovelExtensionsAdapter.ViewHolder>(
DIFF_CALLBACK_INSTALLED
) {
val skipIcons = skipIcons
fun updateData(newExtensions: List<NovelExtension.Installed>) {
Log.d("NovelExtensionsAdapter", "updateData: $newExtensions")
submitList(newExtensions) // Use submitList instead of manual list handling
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_extension, parent, false)
Log.d("NovelExtensionsAdapter", "onCreateViewHolder: $view")
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val extension = getItem(position) // Use getItem() from ListAdapter
val nsfw = ""
val lang = LanguageMapper.mapLanguageCodeToName("all")
holder.extensionNameTextView.text = extension.name
holder.extensionVersionTextView.text = "$lang ${extension.versionName} $nsfw"
if (!skipIcons) {
holder.extensionIconImageView.setImageDrawable(extension.icon)
}
if (extension.hasUpdate) {
holder.closeTextView.setImageResource(R.drawable.ic_round_sync_24)
} else {
holder.closeTextView.setImageResource(R.drawable.ic_round_delete_24)
}
holder.closeTextView.setOnClickListener {
onUninstallClicked(extension)
}
holder.settingsImageView.setOnClickListener {
onSettingsClicked(extension)
}
}
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val extensionNameTextView: TextView = view.findViewById(R.id.extensionNameTextView)
val extensionVersionTextView: TextView = view.findViewById(R.id.extensionVersionTextView)
val settingsImageView: ImageView = view.findViewById(R.id.settingsImageView)
val extensionIconImageView: ImageView = view.findViewById(R.id.extensionIconImageView)
val closeTextView: ImageView = view.findViewById(R.id.closeTextView)
}
companion object {
val DIFF_CALLBACK_INSTALLED =
object : DiffUtil.ItemCallback<NovelExtension.Installed>() {
override fun areItemsTheSame(
oldItem: NovelExtension.Installed,
newItem: NovelExtension.Installed
): Boolean {
return oldItem.pkgName == newItem.pkgName
}
override fun areContentsTheSame(
oldItem: NovelExtension.Installed,
newItem: NovelExtension.Installed
): Boolean {
return oldItem == newItem
}
}
}
}
}

View file

@ -26,6 +26,7 @@ import ani.dantotsu.settings.paging.MangaExtensionAdapter
import ani.dantotsu.settings.paging.MangaExtensionsViewModel
import ani.dantotsu.settings.paging.MangaExtensionsViewModelFactory
import ani.dantotsu.settings.paging.OnMangaInstallClickListener
import ani.dantotsu.snackString
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
@ -104,6 +105,7 @@ class MangaExtensionsFragment : Fragment(),
.setContentText("Error: ${error.message}")
.setPriority(NotificationCompat.PRIORITY_HIGH)
notificationManager.notify(1, builder.build())
snackString("Installation failed: ${error.message}")
},
{
val builder = NotificationCompat.Builder(
@ -116,6 +118,7 @@ class MangaExtensionsFragment : Fragment(),
.setPriority(NotificationCompat.PRIORITY_LOW)
notificationManager.notify(1, builder.build())
viewModel.invalidatePager()
snackString("Extension installed")
}
)
}

View file

@ -0,0 +1,136 @@
package ani.dantotsu.settings
import android.app.NotificationManager
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.app.NotificationCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import ani.dantotsu.R
import ani.dantotsu.databinding.FragmentNovelExtensionsBinding
import ani.dantotsu.logger
import ani.dantotsu.parsers.novel.FileObserver.fileObserver
import ani.dantotsu.parsers.novel.NovelExtension
import ani.dantotsu.parsers.novel.NovelExtensionManager
import ani.dantotsu.settings.paging.NovelExtensionAdapter
import ani.dantotsu.settings.paging.NovelExtensionsViewModel
import ani.dantotsu.settings.paging.NovelExtensionsViewModelFactory
import ani.dantotsu.settings.paging.OnNovelInstallClickListener
import ani.dantotsu.snackString
import com.google.firebase.crashlytics.FirebaseCrashlytics
import eu.kanade.tachiyomi.data.notification.Notifications
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.observeOn
import kotlinx.coroutines.flow.subscribe
import kotlinx.coroutines.launch
import rx.android.schedulers.AndroidSchedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class NovelExtensionsFragment : Fragment(),
SearchQueryHandler, OnNovelInstallClickListener {
private var _binding: FragmentNovelExtensionsBinding? = null
private val binding get() = _binding!!
private val viewModel: NovelExtensionsViewModel by viewModels {
NovelExtensionsViewModelFactory(novelExtensionManager)
}
private val adapter by lazy {
NovelExtensionAdapter(this)
}
private val novelExtensionManager: NovelExtensionManager = Injekt.get()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentNovelExtensionsBinding.inflate(inflater, container, false)
binding.allNovelExtensionsRecyclerView.isNestedScrollingEnabled = false
binding.allNovelExtensionsRecyclerView.adapter = adapter
binding.allNovelExtensionsRecyclerView.layoutManager = LinearLayoutManager(context)
(binding.allNovelExtensionsRecyclerView.layoutManager as LinearLayoutManager).isItemPrefetchEnabled = true
lifecycleScope.launch {
viewModel.pagerFlow.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
viewModel.invalidatePager() // Force a refresh of the pager
return binding.root
}
override fun updateContentBasedOnQuery(query: String?) {
viewModel.setSearchQuery(query ?: "")
}
override fun onInstallClick(pkg: NovelExtension.Available) {
if (isAdded) { // Check if the fragment is currently added to its activity
val context = requireContext()
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Start the installation process
novelExtensionManager.installExtension(pkg)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ installStep ->
val builder = NotificationCompat.Builder(
context,
Notifications.CHANNEL_DOWNLOADER_PROGRESS
)
.setSmallIcon(R.drawable.ic_round_sync_24)
.setContentTitle("Installing extension")
.setContentText("Step: $installStep")
.setPriority(NotificationCompat.PRIORITY_LOW)
notificationManager.notify(1, builder.build())
},
{ error ->
FirebaseCrashlytics.getInstance().recordException(error)
val builder = NotificationCompat.Builder(
context,
Notifications.CHANNEL_DOWNLOADER_ERROR
)
.setSmallIcon(R.drawable.ic_round_info_24)
.setContentTitle("Installation failed: ${error.message}")
.setContentText("Error: ${error.message}")
.setPriority(NotificationCompat.PRIORITY_HIGH)
notificationManager.notify(1, builder.build())
snackString("Installation failed: ${error.message}")
},
{
val builder = NotificationCompat.Builder(
context,
Notifications.CHANNEL_DOWNLOADER_PROGRESS
)
.setSmallIcon(R.drawable.ic_round_download_24)
.setContentTitle("Installation complete")
.setContentText("The extension has been successfully installed.")
.setPriority(NotificationCompat.PRIORITY_LOW)
notificationManager.notify(1, builder.build())
viewModel.invalidatePager()
snackString("Extension installed")
}
)
}
}
override fun onDestroyView() {
super.onDestroyView();_binding = null
}
}

View file

@ -0,0 +1,174 @@
package ani.dantotsu.settings.paging
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import androidx.paging.PagingSource
import androidx.paging.PagingState
import androidx.paging.cachedIn
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import ani.dantotsu.databinding.ItemExtensionAllBinding
import ani.dantotsu.loadData
import ani.dantotsu.others.LanguageMapper
import ani.dantotsu.parsers.novel.NovelExtension
import ani.dantotsu.parsers.novel.NovelExtensionManager
import com.bumptech.glide.Glide
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
class NovelExtensionsViewModelFactory(
private val novelExtensionManager: NovelExtensionManager
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return NovelExtensionsViewModel(novelExtensionManager) as T
}
}
class NovelExtensionsViewModel(
private val novelExtensionManager: NovelExtensionManager
) : ViewModel() {
private val searchQuery = MutableStateFlow("")
private var currentPagingSource: NovelExtensionPagingSource? = null
fun setSearchQuery(query: String) {
searchQuery.value = query
}
fun invalidatePager() {
currentPagingSource?.invalidate()
}
@OptIn(ExperimentalCoroutinesApi::class)
val pagerFlow: Flow<PagingData<NovelExtension.Available>> = searchQuery.flatMapLatest { query ->
Pager(
PagingConfig(
pageSize = 15,
initialLoadSize = 15,
prefetchDistance = 15
)
) {
NovelExtensionPagingSource(
novelExtensionManager.availableExtensionsFlow,
novelExtensionManager.installedExtensionsFlow,
searchQuery
).also { currentPagingSource = it }
}.flow
}.cachedIn(viewModelScope)
}
class NovelExtensionPagingSource(
private val availableExtensionsFlow: StateFlow<List<NovelExtension.Available>>,
private val installedExtensionsFlow: StateFlow<List<NovelExtension.Installed>>,
private val searchQuery: StateFlow<String>
) : PagingSource<Int, NovelExtension.Available>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, NovelExtension.Available> {
val position = params.key ?: 0
val installedExtensions = installedExtensionsFlow.first().map { it.pkgName }.toSet()
val availableExtensions = availableExtensionsFlow.first().filterNot { it.pkgName in installedExtensions }
val query = searchQuery.first()
val isNsfwEnabled: Boolean = loadData("NFSWExtension") ?: true
val filteredExtensions = if (query.isEmpty()) {
availableExtensions
} else {
availableExtensions.filter { it.name.contains(query, ignoreCase = true) }
}
val filternfsw = filteredExtensions
/*val filternfsw = if(isNsfwEnabled) { currently not implemented
filteredExtensions
} else {
filteredExtensions.filterNot { it.isNsfw }
}*/
return try {
val sublist = filternfsw.subList(
fromIndex = position,
toIndex = (position + params.loadSize).coerceAtMost(filternfsw.size)
)
LoadResult.Page(
data = sublist,
prevKey = if (position == 0) null else position - params.loadSize,
nextKey = if (position + params.loadSize >= filternfsw.size) null else position + params.loadSize
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, NovelExtension.Available>): Int? {
return null
}
}
class NovelExtensionAdapter(private val clickListener: OnNovelInstallClickListener) :
PagingDataAdapter<NovelExtension.Available, NovelExtensionAdapter.NovelExtensionViewHolder>(
DIFF_CALLBACK
) {
private val skipIcons = loadData("skip_extension_icons") ?: false
companion object {
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<NovelExtension.Available>() {
override fun areItemsTheSame(oldItem: NovelExtension.Available, newItem: NovelExtension.Available): Boolean {
return oldItem.pkgName == newItem.pkgName
}
override fun areContentsTheSame(oldItem: NovelExtension.Available, newItem: NovelExtension.Available): Boolean {
return oldItem == newItem
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NovelExtensionViewHolder {
val binding = ItemExtensionAllBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return NovelExtensionViewHolder(binding)
}
override fun onBindViewHolder(holder: NovelExtensionViewHolder, position: Int) {
val extension = getItem(position)
if (extension != null) {
if (!skipIcons) {
Glide.with(holder.itemView.context)
.load(extension.iconUrl)
.into(holder.extensionIconImageView)
}
holder.bind(extension)
}
}
inner class NovelExtensionViewHolder(private val binding: ItemExtensionAllBinding) : RecyclerView.ViewHolder(binding.root) {
init {
binding.closeTextView.setOnClickListener {
val extension = getItem(bindingAdapterPosition)
if (extension != null) {
clickListener.onInstallClick(extension)
}
}
}
val extensionIconImageView: ImageView = binding.extensionIconImageView
fun bind(extension: NovelExtension.Available) {
val nsfw = ""
val lang= LanguageMapper.mapLanguageCodeToName("all")
binding.extensionNameTextView.text = extension.name
binding.extensionVersionTextView.text = "$lang ${extension.versionName} $nsfw"
}
}
}
interface OnNovelInstallClickListener {
fun onInstallClick(pkg: NovelExtension.Available)
}

View file

@ -1,11 +1,10 @@
package eu.kanade.tachiyomi.network
import android.content.Context
import eu.kanade.tachiyomi.network.AndroidCookieJar
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE
import eu.kanade.tachiyomi.network.PREF_DOH_GOOGLE
import eu.kanade.tachiyomi.network.dohCloudflare
import eu.kanade.tachiyomi.network.dohGoogle
import android.os.Build
import ani.dantotsu.Mapper
import ani.dantotsu.defaultHeaders
import com.lagradost.nicehttp.Requests
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor
@ -32,13 +31,13 @@ class NetworkHelper(
CloudflareInterceptor(context, cookieJar, ::defaultUserAgentProvider)
}
private val baseClientBuilder: OkHttpClient.Builder
get() {
private fun baseClientBuilder(callTimout: Int = 2): OkHttpClient.Builder
{
val builder = OkHttpClient.Builder()
.cookieJar(cookieJar)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.callTimeout(2, TimeUnit.MINUTES)
.callTimeout(callTimout.toLong(), TimeUnit.MINUTES)
.addInterceptor(UncaughtExceptionInterceptor())
.addInterceptor(userAgentInterceptor)
@ -68,7 +67,10 @@ class NetworkHelper(
return builder
}
val client by lazy { baseClientBuilder.cache(Cache(cacheDir, cacheSize)).build() }
val client by lazy { baseClientBuilder().cache(Cache(cacheDir, cacheSize)).build() }
val downloadClient by lazy { baseClientBuilder(20).build() }
@Suppress("UNUSED")
val cloudflareClient by lazy {
@ -77,5 +79,17 @@ class NetworkHelper(
.build()
}
val requestClient = Requests(
client,
mapOf(
"User-Agent" to
defaultUserAgentProvider()
.format(Build.VERSION.RELEASE, Build.MODEL)
),
defaultCacheTime = 6,
defaultCacheTimeUnit = TimeUnit.HOURS,
responseParser = Mapper
)
fun defaultUserAgentProvider() = preferences.defaultUserAgent().get().trim()
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="32dp"
android:paddingEnd="32dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/allNovelExtensionsRecyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>

View file

@ -33,7 +33,7 @@
android:freezesText="false"
android:inputType="none"
android:padding="8dp"
android:text="@string/watch"
android:text="@string/read"
android:textAllCaps="true"
android:textColor="?android:attr/textColorSecondary"
android:textSize="14sp"