From c7bc1ffe9e48f1163c745be1ad4fe3c39b8c5e2b Mon Sep 17 00:00:00 2001
From: Finnley Somdahl <87634197+rebelonion@users.noreply.github.com>
Date: Thu, 30 Nov 2023 03:41:45 -0600
Subject: [PATCH] Light novel support
---
app/build.gradle | 4 +-
app/src/main/AndroidManifest.xml | 4 +
app/src/main/java/ani/dantotsu/App.kt | 17 +-
.../main/java/ani/dantotsu/MainActivity.kt | 9 +
app/src/main/java/ani/dantotsu/Network.kt | 38 +-
.../aniyomi/anime/custom/InjektModules.kt | 2 +
.../ani/dantotsu/download/DownloadsManager.kt | 27 +-
.../download/manga/MangaDownloaderService.kt | 16 +-
.../download/novel/NovelDownloaderService.kt | 434 ++++++++++++++++++
.../dantotsu/media/manga/MangaReadFragment.kt | 9 +-
.../ani/dantotsu/media/novel/BookDialog.kt | 10 +-
.../dantotsu/media/novel/NovelReadAdapter.kt | 2 +-
.../dantotsu/media/novel/NovelReadFragment.kt | 140 +++++-
.../media/novel/NovelResponseAdapter.kt | 144 +++++-
.../ani/dantotsu/media/novel/UrlAdapter.kt | 10 +-
.../novel/novelreader/NovelReaderActivity.kt | 2 +-
.../ani/dantotsu/parsers/AniyomiAdapter.kt | 11 -
.../java/ani/dantotsu/parsers/BaseParser.kt | 4 +-
.../ani/dantotsu/parsers/NovelInterface.kt | 8 +
.../java/ani/dantotsu/parsers/NovelSources.kt | 29 +-
.../dantotsu/parsers/novel/NovelAdapter.kt | 41 ++
.../dantotsu/parsers/novel/NovelExtension.kt | 56 +++
.../parsers/novel/NovelExtensionGithubApi.kt | 178 +++++++
.../novel/NovelExtensionInstallReceiver.kt | 80 ++++
.../parsers/novel/NovelExtensionInstaller.kt | 367 +++++++++++++++
.../parsers/novel/NovelExtensionLoader.kt | 129 ++++++
.../parsers/novel/NovelExtensionManager.kt | 243 ++++++++++
.../settings/AnimeExtensionsFragment.kt | 3 +
.../settings/DevelopersDialogFragment.kt | 1 +
.../dantotsu/settings/ExtensionsActivity.kt | 29 +-
.../InstalledAnimeExtensionsFragment.kt | 4 +
.../InstalledMangaExtensionsFragment.kt | 4 +
.../InstalledNovelExtensionsFragment.kt | 211 +++++++++
.../settings/MangaExtensionsFragment.kt | 3 +
.../settings/NovelExtensionsFragment.kt | 136 ++++++
.../settings/paging/NovelPagingSource.kt | 174 +++++++
.../kanade/tachiyomi/network/NetworkHelper.kt | 32 +-
.../res/layout/fragment_novel_extensions.xml | 15 +
app/src/main/res/layout/item_novel_header.xml | 2 +-
39 files changed, 2537 insertions(+), 91 deletions(-)
create mode 100644 app/src/main/java/ani/dantotsu/download/novel/NovelDownloaderService.kt
create mode 100644 app/src/main/java/ani/dantotsu/parsers/NovelInterface.kt
create mode 100644 app/src/main/java/ani/dantotsu/parsers/novel/NovelAdapter.kt
create mode 100644 app/src/main/java/ani/dantotsu/parsers/novel/NovelExtension.kt
create mode 100644 app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionGithubApi.kt
create mode 100644 app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionInstallReceiver.kt
create mode 100644 app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionInstaller.kt
create mode 100644 app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionLoader.kt
create mode 100644 app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionManager.kt
create mode 100644 app/src/main/java/ani/dantotsu/settings/InstalledNovelExtensionsFragment.kt
create mode 100644 app/src/main/java/ani/dantotsu/settings/NovelExtensionsFragment.kt
create mode 100644 app/src/main/java/ani/dantotsu/settings/paging/NovelPagingSource.kt
create mode 100644 app/src/main/res/layout/fragment_novel_extensions.xml
diff --git a/app/build.gradle b/app/build.gradle
index 43f69176..77d114c0 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7551c02c..a77c0d31 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -277,6 +277,10 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
+
+
diff --git a/app/src/main/java/ani/dantotsu/App.kt b/app/src/main/java/ani/dantotsu/App.kt
index 71cc5af2..3c5b5616 100644
--- a/app/src/main/java/ani/dantotsu/App.kt
+++ b/app/src/main/java/ani/dantotsu/App.kt
@@ -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)
+ }
}
diff --git a/app/src/main/java/ani/dantotsu/MainActivity.kt b/app/src/main/java/ani/dantotsu/MainActivity.kt
index 8896df2a..90805d8d 100644
--- a/app/src/main/java/ani/dantotsu/MainActivity.kt
+++ b/app/src/main/java/ani/dantotsu/MainActivity.kt
@@ -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)
diff --git a/app/src/main/java/ani/dantotsu/Network.kt b/app/src/main/java/ani/dantotsu/Network.kt
index 58c9d6fc..d53f33c9 100644
--- a/app/src/main/java/ani/dantotsu/Network.kt
+++ b/app/src/main/java/ani/dantotsu/Network.kt
@@ -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
lateinit var okHttpClient: OkHttpClient
lateinit var client: Requests
fun initializeNetwork(context: Context) {
- val dns = loadData("settings_dns")
- cache = Cache(
- File(context.cacheDir, "http_cache"),
- 5 * 1024L * 1024L // 5 MiB
+
+ val networkHelper = Injekt.get()
+
+ defaultHeaders = mapOf(
+ "User-Agent" to
+ Injekt.get().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 {
diff --git a/app/src/main/java/ani/dantotsu/aniyomi/anime/custom/InjektModules.kt b/app/src/main/java/ani/dantotsu/aniyomi/anime/custom/InjektModules.kt
index ef2af1e9..1f5778ad 100644
--- a/app/src/main/java/ani/dantotsu/aniyomi/anime/custom/InjektModules.kt
+++ b/app/src/main/java/ani/dantotsu/aniyomi/anime/custom/InjektModules.kt
@@ -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 { AndroidAnimeSourceManager(app, get()) }
addSingletonFactory { AndroidMangaSourceManager(app, get()) }
diff --git a/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt b/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt
index a9981088..23b50034 100644
--- a/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt
+++ b/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt
@@ -18,6 +18,8 @@ class DownloadsManager(private val context: Context) {
get() = downloadsList.filter { it.type == Download.Type.MANGA }
val animeDownloads: List
get() = downloadsList.filter { it.type == Download.Type.ANIME }
+ val novelDownloads: List
+ 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
}
}
diff --git a/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt b/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt
index cdcf6b4c..99460325 100644
--- a/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt
+++ b/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt
@@ -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 = listOf()
var sourceMedia: Media? = null
var downloadQueue: Queue = ConcurrentLinkedQueue()
diff --git a/app/src/main/java/ani/dantotsu/download/novel/NovelDownloaderService.kt b/app/src/main/java/ani/dantotsu/download/novel/NovelDownloaderService.kt
new file mode 100644
index 00000000..ef1a3c11
--- /dev/null
+++ b/app/src/main/java/ani/dantotsu/download/novel/NovelDownloaderService.kt
@@ -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()
+
+ private val downloadJobs = mutableMapOf()
+ private val mutex = Mutex()
+ private var isCurrentlyProcessing = false
+
+ val networkHelper = Injekt.get()
+
+ 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 {
+ 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 = ConcurrentLinkedQueue()
+ @Volatile
+ var isServiceRunning: Boolean = false
+}
\ No newline at end of file
diff --git a/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt b/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt
index 4adc7f0a..4a62648e 100644
--- a/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt
+++ b/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt
@@ -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)
diff --git a/app/src/main/java/ani/dantotsu/media/novel/BookDialog.kt b/app/src/main/java/ani/dantotsu/media/novel/BookDialog.kt
index 68c49cc2..467321dc 100644
--- a/app/src/main/java/ani/dantotsu/media/novel/BookDialog.kt
+++ b/app/src/main/java/ani/dantotsu/media/novel/BookDialog.kt
@@ -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) {
diff --git a/app/src/main/java/ani/dantotsu/media/novel/NovelReadAdapter.kt b/app/src/main/java/ani/dantotsu/media/novel/NovelReadAdapter.kt
index dd7a79a1..09c67510 100644
--- a/app/src/main/java/ani/dantotsu/media/novel/NovelReadAdapter.kt
+++ b/app/src/main/java/ani/dantotsu/media/novel/NovelReadAdapter.kt
@@ -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)
}
\ No newline at end of file
diff --git a/app/src/main/java/ani/dantotsu/media/novel/NovelReadFragment.kt b/app/src/main/java/ani/dantotsu/media/novel/NovelReadFragment.kt
index e6c62b24..515d2294 100644
--- a/app/src/main/java/ani/dantotsu/media/novel/NovelReadFragment.kt
+++ b/app/src/main/java/ani/dantotsu/media/novel/NovelReadFragment.kt
@@ -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()
+ 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()
+ return downloadsManager.queryDownload(Download(media.nameMAL ?: media.nameRomaji, novel.name, Download.Type.NOVEL))
+ }
+
+ override fun deleteDownload(novel: ShowResponse) {
+ val downloadsManager = Injekt.get()
+ 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? = 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)
}
\ No newline at end of file
diff --git a/app/src/main/java/ani/dantotsu/media/novel/NovelResponseAdapter.kt b/app/src/main/java/ani/dantotsu/media/novel/NovelResponseAdapter.kt
index 51ec9de6..49fcb5d0 100644
--- a/app/src/main/java/ani/dantotsu/media/novel/NovelResponseAdapter.kt
+++ b/app/src/main/java/ani/dantotsu/media/novel/NovelResponseAdapter.kt
@@ -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() {
+class NovelResponseAdapter(
+ val fragment: NovelReadFragment,
+ val downloadTriggerCallback: DownloadTriggerCallback,
+ val downloadedCheckCallback: DownloadedCheckCallback
+) : RecyclerView.Adapter() {
val list: MutableList = 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()
+ private val downloadedChapters = mutableSetOf()
+
+ 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)
}
}
@@ -54,4 +171,11 @@ class NovelResponseAdapter(val fragment: NovelReadFragment) : RecyclerView.Adapt
list.clear()
notifyItemRangeRemoved(0, size)
}
-}
\ No newline at end of file
+}
+
+data class NovelDownloadPackage(
+ val link: String,
+ val coverUrl: String,
+ val novelName: String,
+ val originalLink: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/ani/dantotsu/media/novel/UrlAdapter.kt b/app/src/main/java/ani/dantotsu/media/novel/UrlAdapter.kt
index 4ef86301..7caa1b5c 100644
--- a/app/src/main/java/ani/dantotsu/media/novel/UrlAdapter.kt
+++ b/app/src/main/java/ani/dantotsu/media/novel/UrlAdapter.kt
@@ -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, val book: Book, val novel: String) :
+class UrlAdapter(private val urls: List, val book: Book, val novel: String, val callback: BookDialog.Callback?) :
RecyclerView.Adapter() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UrlViewHolder {
@@ -26,6 +25,7 @@ class UrlAdapter(private val urls: List, 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, 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 {
diff --git a/app/src/main/java/ani/dantotsu/media/novel/novelreader/NovelReaderActivity.kt b/app/src/main/java/ani/dantotsu/media/novel/novelreader/NovelReaderActivity.kt
index e57edcaa..442714ca 100644
--- a/app/src/main/java/ani/dantotsu/media/novel/novelreader/NovelReaderActivity.kt
+++ b/app/src/main/java/ani/dantotsu/media/novel/novelreader/NovelReaderActivity.kt
@@ -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)
diff --git a/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt b/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt
index 7078595d..239290c9 100644
--- a/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt
+++ b/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt
@@ -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
diff --git a/app/src/main/java/ani/dantotsu/parsers/BaseParser.kt b/app/src/main/java/ani/dantotsu/parsers/BaseParser.kt
index e281207b..798d9c77 100644
--- a/app/src/main/java/ani/dantotsu/parsers/BaseParser.kt
+++ b/app/src/main/java/ani/dantotsu/parsers/BaseParser.kt
@@ -167,7 +167,7 @@ data class ShowResponse(
val total: Int? = null,
//In case you want to sent some extra data
- val extra : Map?=null,
+ val extra : MutableMap?=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 = listOf(), total: Int? = null, extra: Map?=null)
+ constructor(name: String, link: String, coverUrl: String, otherNames: List = listOf(), total: Int? = null, extra: MutableMap?=null)
: this(name, link, FileUrl(coverUrl), otherNames, total, extra)
constructor(name: String, link: String, coverUrl: String, otherNames: List = listOf(), total: Int? = null)
diff --git a/app/src/main/java/ani/dantotsu/parsers/NovelInterface.kt b/app/src/main/java/ani/dantotsu/parsers/NovelInterface.kt
new file mode 100644
index 00000000..a57bc283
--- /dev/null
+++ b/app/src/main/java/ani/dantotsu/parsers/NovelInterface.kt
@@ -0,0 +1,8 @@
+package ani.dantotsu.parsers
+import com.lagradost.nicehttp.Requests
+
+
+interface NovelInterface {
+ suspend fun search(query: String, client: Requests): List
+ suspend fun loadBook(link: String, extra: Map?, client: Requests): Book
+}
\ No newline at end of file
diff --git a/app/src/main/java/ani/dantotsu/parsers/NovelSources.kt b/app/src/main/java/ani/dantotsu/parsers/NovelSources.kt
index fb5445dd..225887b5 100644
--- a/app/src/main/java/ani/dantotsu/parsers/NovelSources.kt
+++ b/app/src/main/java/ani/dantotsu/parsers/NovelSources.kt
@@ -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> = lazyList(
- )
+ override var list: List> = emptyList()
+
+ suspend fun init(fromExtensions: StateFlow>) {
+ // 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): List> {
+ Log.d("NovelSources", "createParsersFromExtensions")
+ Log.d("NovelSources", extensions.toString())
+ return extensions.map { extension ->
+ val name = extension.name
+ Lazier({ DynamicNovelParser(extension) }, name)
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/ani/dantotsu/parsers/novel/NovelAdapter.kt b/app/src/main/java/ani/dantotsu/parsers/novel/NovelAdapter.kt
new file mode 100644
index 00000000..87bab797
--- /dev/null
+++ b/app/src/main/java/ani/dantotsu/parsers/novel/NovelAdapter.kt
@@ -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().requestClient
+ init {
+ this.extension = extension
+ }
+
+ override suspend fun search(query: String): List {
+ 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?): Book {
+ val source = extension.sources.firstOrNull()
+ if (source is NovelInterface) {
+ return source.loadBook(link, extra, client)
+ } else {
+ return Book("", "", "", emptyList())
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtension.kt b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtension.kt
new file mode 100644
index 00000000..42595aaf
--- /dev/null
+++ b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtension.kt
@@ -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,
+ 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,
+ 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()
+}
diff --git a/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionGithubApi.kt b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionGithubApi.kt
new file mode 100644
index 00000000..2354a311
--- /dev/null
+++ b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionGithubApi.kt
@@ -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 {
+ 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>()
+ .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? {
+ // 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()
+ .map { it.extension }
+
+ val extensionsWithUpdate = mutableListOf()
+ 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.toExtensions(): List {
+ 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.toSources(): List {
+ 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?,
+)
+
+@Serializable
+private data class NovelExtensionSourceJsonObject(
+ val id: Long,
+ val lang: String,
+ val name: String,
+ val baseUrl: String,
+)
+
diff --git a/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionInstallReceiver.kt b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionInstallReceiver.kt
new file mode 100644
index 00000000..f1f52083
--- /dev/null
+++ b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionInstallReceiver.kt
@@ -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
+}
\ No newline at end of file
diff --git a/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionInstaller.kt b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionInstaller.kt
new file mode 100644
index 00000000..41864bfb
--- /dev/null
+++ b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionInstaller.kt
@@ -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()!!
+
+ /**
+ * 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()
+
+ /**
+ * Relay used to notify the installation step of every download.
+ */
+ private val downloadsRelay = PublishRelay.create>()
+
+ /**
+ * 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 {
+ 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://"
+ }
+}
diff --git a/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionLoader.kt b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionLoader.kt
new file mode 100644
index 00000000..01b60867
--- /dev/null
+++ b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionLoader.kt
@@ -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 {
+ val installDir = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/"
+ val results = mutableListOf()
+ //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 {
+ 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()
+}
\ No newline at end of file
diff --git a/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionManager.kt b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionManager.kt
new file mode 100644
index 00000000..72c694b0
--- /dev/null
+++ b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionManager.kt
@@ -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()
+
+ private val _installedNovelExtensionsFlow =
+ MutableStateFlow(emptyList())
+ val installedExtensionsFlow = _installedNovelExtensionsFlow.asStateFlow()
+
+ private val _availableNovelExtensionsFlow =
+ MutableStateFlow(emptyList())
+ val availableExtensionsFlow = _availableNovelExtensionsFlow.asStateFlow()
+
+ private var availableNovelExtensionsSourcesData: Map = emptyMap()
+
+ private fun setupAvailableNovelExtensionsSourcesDataMap(novelExtensions: List) {
+ 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()
+ .map { it.extension }
+
+ isInitialized = true
+ }
+
+ /**
+ * Finds the available manga extensions in the [api] and updates [availableExtensions].
+ */
+ suspend fun findAvailableExtensions() {
+ val extensions: List = 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) {
+ 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 {
+ 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 {
+ 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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ani/dantotsu/settings/AnimeExtensionsFragment.kt b/app/src/main/java/ani/dantotsu/settings/AnimeExtensionsFragment.kt
index 2ff5ef23..c706d6cf 100644
--- a/app/src/main/java/ani/dantotsu/settings/AnimeExtensionsFragment.kt
+++ b/app/src/main/java/ani/dantotsu/settings/AnimeExtensionsFragment.kt
@@ -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")
}
)
}
diff --git a/app/src/main/java/ani/dantotsu/settings/DevelopersDialogFragment.kt b/app/src/main/java/ani/dantotsu/settings/DevelopersDialogFragment.kt
index 7a999083..0ddd0227 100644
--- a/app/src/main/java/ani/dantotsu/settings/DevelopersDialogFragment.kt
+++ b/app/src/main/java/ani/dantotsu/settings/DevelopersDialogFragment.kt
@@ -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 {
diff --git a/app/src/main/java/ani/dantotsu/settings/ExtensionsActivity.kt b/app/src/main/java/ani/dantotsu/settings/ExtensionsActivity.kt
index 3d8a155b..90d7656f 100644
--- a/app/src/main/java/ani/dantotsu/settings/ExtensionsActivity.kt
+++ b/app/src/main/java/ani/dantotsu/settings/ExtensionsActivity.kt
@@ -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(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?) {
}
diff --git a/app/src/main/java/ani/dantotsu/settings/InstalledAnimeExtensionsFragment.kt b/app/src/main/java/ani/dantotsu/settings/InstalledAnimeExtensionsFragment.kt
index 535bc876..ee7cfa1c 100644
--- a/app/src/main/java/ani/dantotsu/settings/InstalledAnimeExtensionsFragment.kt
+++ b/app/src/main/java/ani/dantotsu/settings/InstalledAnimeExtensionsFragment.kt
@@ -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
diff --git a/app/src/main/java/ani/dantotsu/settings/InstalledMangaExtensionsFragment.kt b/app/src/main/java/ani/dantotsu/settings/InstalledMangaExtensionsFragment.kt
index bf9b865a..756dae37 100644
--- a/app/src/main/java/ani/dantotsu/settings/InstalledMangaExtensionsFragment.kt
+++ b/app/src/main/java/ani/dantotsu/settings/InstalledMangaExtensionsFragment.kt
@@ -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)
diff --git a/app/src/main/java/ani/dantotsu/settings/InstalledNovelExtensionsFragment.kt b/app/src/main/java/ani/dantotsu/settings/InstalledNovelExtensionsFragment.kt
new file mode 100644
index 00000000..dbbc8871
--- /dev/null
+++ b/app/src/main/java/ani/dantotsu/settings/InstalledNovelExtensionsFragment.kt
@@ -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(
+ DIFF_CALLBACK_INSTALLED
+ ) {
+
+ val skipIcons = skipIcons
+
+ fun updateData(newExtensions: List) {
+ 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() {
+ 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
+ }
+ }
+ }
+ }
+
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/ani/dantotsu/settings/MangaExtensionsFragment.kt b/app/src/main/java/ani/dantotsu/settings/MangaExtensionsFragment.kt
index a949bd86..04e7bc88 100644
--- a/app/src/main/java/ani/dantotsu/settings/MangaExtensionsFragment.kt
+++ b/app/src/main/java/ani/dantotsu/settings/MangaExtensionsFragment.kt
@@ -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")
}
)
}
diff --git a/app/src/main/java/ani/dantotsu/settings/NovelExtensionsFragment.kt b/app/src/main/java/ani/dantotsu/settings/NovelExtensionsFragment.kt
new file mode 100644
index 00000000..03bcebd2
--- /dev/null
+++ b/app/src/main/java/ani/dantotsu/settings/NovelExtensionsFragment.kt
@@ -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
+ }
+
+
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/ani/dantotsu/settings/paging/NovelPagingSource.kt b/app/src/main/java/ani/dantotsu/settings/paging/NovelPagingSource.kt
new file mode 100644
index 00000000..88649863
--- /dev/null
+++ b/app/src/main/java/ani/dantotsu/settings/paging/NovelPagingSource.kt
@@ -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 create(modelClass: Class): 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> = 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>,
+ private val installedExtensionsFlow: StateFlow>,
+ private val searchQuery: StateFlow
+) : PagingSource() {
+
+ override suspend fun load(params: LoadParams): LoadResult {
+ 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? {
+ return null
+ }
+}
+
+class NovelExtensionAdapter(private val clickListener: OnNovelInstallClickListener) :
+ PagingDataAdapter(
+ DIFF_CALLBACK
+ ) {
+
+ private val skipIcons = loadData("skip_extension_icons") ?: false
+
+ companion object {
+ private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() {
+ 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)
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt
index b0efde9f..fa56778b 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt
@@ -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()
}
diff --git a/app/src/main/res/layout/fragment_novel_extensions.xml b/app/src/main/res/layout/fragment_novel_extensions.xml
new file mode 100644
index 00000000..9c39cfb0
--- /dev/null
+++ b/app/src/main/res/layout/fragment_novel_extensions.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_novel_header.xml b/app/src/main/res/layout/item_novel_header.xml
index 3483ee43..3e920bd8 100644
--- a/app/src/main/res/layout/item_novel_header.xml
+++ b/app/src/main/res/layout/item_novel_header.xml
@@ -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"