Light novel support
This commit is contained in:
parent
32f918450a
commit
c7bc1ffe9e
39 changed files with 2537 additions and 91 deletions
|
@ -21,7 +21,7 @@ android {
|
||||||
minSdk 23
|
minSdk 23
|
||||||
targetSdk 34
|
targetSdk 34
|
||||||
versionCode ((System.currentTimeMillis() / 60000).toInteger())
|
versionCode ((System.currentTimeMillis() / 60000).toInteger())
|
||||||
versionName "1.0.0-beta03i"
|
versionName "1.0.0-beta03i-2"
|
||||||
signingConfig signingConfigs.debug
|
signingConfig signingConfigs.debug
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,7 +64,7 @@ dependencies {
|
||||||
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||||
implementation 'com.google.code.gson:gson:2.8.9'
|
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 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0'
|
||||||
implementation 'androidx.preference:preference:1.2.1'
|
implementation 'androidx.preference:preference:1.2.1'
|
||||||
|
|
||||||
|
|
|
@ -277,6 +277,10 @@
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<service android:name=".download.novel.NovelDownloaderService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<service android:name=".connections.discord.DiscordService"
|
<service android:name=".connections.discord.DiscordService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
|
@ -7,6 +7,7 @@ import android.content.Context
|
||||||
import android.content.res.ColorStateList
|
import android.content.res.ColorStateList
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import android.util.LongSparseArray
|
import android.util.LongSparseArray
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import androidx.annotation.ColorInt
|
import androidx.annotation.ColorInt
|
||||||
|
@ -14,9 +15,13 @@ import androidx.multidex.MultiDex
|
||||||
import androidx.multidex.MultiDexApplication
|
import androidx.multidex.MultiDexApplication
|
||||||
import ani.dantotsu.aniyomi.anime.custom.AppModule
|
import ani.dantotsu.aniyomi.anime.custom.AppModule
|
||||||
import ani.dantotsu.aniyomi.anime.custom.PreferenceModule
|
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.others.DisabledReports
|
||||||
import ani.dantotsu.parsers.AnimeSources
|
import ani.dantotsu.parsers.AnimeSources
|
||||||
import ani.dantotsu.parsers.MangaSources
|
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.DynamicColors
|
||||||
import com.google.android.material.color.HarmonizedColorAttributes
|
import com.google.android.material.color.HarmonizedColorAttributes
|
||||||
import com.google.android.material.color.HarmonizedColors
|
import com.google.android.material.color.HarmonizedColors
|
||||||
|
@ -36,6 +41,7 @@ import logcat.LogcatLogger
|
||||||
import tachiyomi.core.util.system.logcat
|
import tachiyomi.core.util.system.logcat
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
|
import java.io.File
|
||||||
import java.lang.reflect.Field
|
import java.lang.reflect.Field
|
||||||
|
|
||||||
|
|
||||||
|
@ -43,6 +49,7 @@ import java.lang.reflect.Field
|
||||||
class App : MultiDexApplication() {
|
class App : MultiDexApplication() {
|
||||||
private lateinit var animeExtensionManager: AnimeExtensionManager
|
private lateinit var animeExtensionManager: AnimeExtensionManager
|
||||||
private lateinit var mangaExtensionManager: MangaExtensionManager
|
private lateinit var mangaExtensionManager: MangaExtensionManager
|
||||||
|
private lateinit var novelExtensionManager: NovelExtensionManager
|
||||||
override fun attachBaseContext(base: Context?) {
|
override fun attachBaseContext(base: Context?) {
|
||||||
super.attachBaseContext(base)
|
super.attachBaseContext(base)
|
||||||
MultiDex.install(this)
|
MultiDex.install(this)
|
||||||
|
@ -65,11 +72,12 @@ class App : MultiDexApplication() {
|
||||||
registerActivityLifecycleCallbacks(mFTActivityLifecycleCallbacks)
|
registerActivityLifecycleCallbacks(mFTActivityLifecycleCallbacks)
|
||||||
|
|
||||||
Firebase.crashlytics.setCrashlyticsCollectionEnabled(!DisabledReports)
|
Firebase.crashlytics.setCrashlyticsCollectionEnabled(!DisabledReports)
|
||||||
initializeNetwork(baseContext)
|
|
||||||
|
|
||||||
Injekt.importModule(AppModule(this))
|
Injekt.importModule(AppModule(this))
|
||||||
Injekt.importModule(PreferenceModule(this))
|
Injekt.importModule(PreferenceModule(this))
|
||||||
|
|
||||||
|
initializeNetwork(baseContext)
|
||||||
|
|
||||||
setupNotificationChannels()
|
setupNotificationChannels()
|
||||||
if (!LogcatLogger.isInstalled) {
|
if (!LogcatLogger.isInstalled) {
|
||||||
LogcatLogger.install(AndroidLogcatLogger(LogPriority.VERBOSE))
|
LogcatLogger.install(AndroidLogcatLogger(LogPriority.VERBOSE))
|
||||||
|
@ -77,6 +85,7 @@ class App : MultiDexApplication() {
|
||||||
|
|
||||||
animeExtensionManager = Injekt.get()
|
animeExtensionManager = Injekt.get()
|
||||||
mangaExtensionManager = Injekt.get()
|
mangaExtensionManager = Injekt.get()
|
||||||
|
novelExtensionManager = Injekt.get()
|
||||||
|
|
||||||
val animeScope = CoroutineScope(Dispatchers.Default)
|
val animeScope = CoroutineScope(Dispatchers.Default)
|
||||||
animeScope.launch {
|
animeScope.launch {
|
||||||
|
@ -90,6 +99,12 @@ class App : MultiDexApplication() {
|
||||||
logger("Manga Extensions: ${mangaExtensionManager.installedExtensionsFlow.first()}")
|
logger("Manga Extensions: ${mangaExtensionManager.installedExtensionsFlow.first()}")
|
||||||
MangaSources.init(mangaExtensionManager.installedExtensionsFlow)
|
MangaSources.init(mangaExtensionManager.installedExtensionsFlow)
|
||||||
}
|
}
|
||||||
|
val novelScope = CoroutineScope(Dispatchers.Default)
|
||||||
|
novelScope.launch {
|
||||||
|
novelExtensionManager.findAvailableExtensions()
|
||||||
|
logger("Novel Extensions: ${novelExtensionManager.installedExtensionsFlow.first()}")
|
||||||
|
NovelSources.init(novelExtensionManager.installedExtensionsFlow)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -35,6 +35,7 @@ import androidx.core.view.updateLayoutParams
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.FragmentManager
|
import androidx.fragment.app.FragmentManager
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.ViewModelProvider.NewInstanceFactory.Companion.instance
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||||
import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager
|
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.subcriptions.Subscription.Companion.startSubscription
|
||||||
import ani.dantotsu.themes.ThemeManager
|
import ani.dantotsu.themes.ThemeManager
|
||||||
import ani.dantotsu.others.LangSet
|
import ani.dantotsu.others.LangSet
|
||||||
|
import ani.dantotsu.parsers.NovelInterface
|
||||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
|
import dalvik.system.PathClassLoader
|
||||||
import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager
|
import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager
|
||||||
import io.noties.markwon.Markwon
|
import io.noties.markwon.Markwon
|
||||||
import io.noties.markwon.SoftBreakAddsNewLinePlugin
|
import io.noties.markwon.SoftBreakAddsNewLinePlugin
|
||||||
|
@ -73,7 +76,11 @@ import nl.joery.animatedbottombar.AnimatedBottomBar
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.FileOutputStream
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
|
import java.nio.channels.FileChannel
|
||||||
|
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
@ -83,6 +90,8 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private var uiSettings = UserInterfaceSettings()
|
private var uiSettings = UserInterfaceSettings()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
ThemeManager(this).applyTheme()
|
ThemeManager(this).applyTheme()
|
||||||
LangSet.setLocale(this)
|
LangSet.setLocale(this)
|
||||||
|
|
|
@ -8,6 +8,7 @@ import ani.dantotsu.others.webview.WebViewBottomDialog
|
||||||
import com.lagradost.nicehttp.Requests
|
import com.lagradost.nicehttp.Requests
|
||||||
import com.lagradost.nicehttp.ResponseParser
|
import com.lagradost.nicehttp.ResponseParser
|
||||||
import com.lagradost.nicehttp.addGenericDns
|
import com.lagradost.nicehttp.addGenericDns
|
||||||
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
@ -17,6 +18,8 @@ import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.serializer
|
import kotlinx.serialization.serializer
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.PrintWriter
|
import java.io.PrintWriter
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
|
@ -25,41 +28,30 @@ import java.util.concurrent.*
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
import kotlin.reflect.KFunction
|
import kotlin.reflect.KFunction
|
||||||
|
|
||||||
val defaultHeaders = mapOf(
|
lateinit var defaultHeaders: Map<String, String>
|
||||||
"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 okHttpClient: OkHttpClient
|
lateinit var okHttpClient: OkHttpClient
|
||||||
lateinit var client: Requests
|
lateinit var client: Requests
|
||||||
|
|
||||||
fun initializeNetwork(context: Context) {
|
fun initializeNetwork(context: Context) {
|
||||||
val dns = loadData<Int>("settings_dns")
|
|
||||||
cache = Cache(
|
val networkHelper = Injekt.get<NetworkHelper>()
|
||||||
File(context.cacheDir, "http_cache"),
|
|
||||||
5 * 1024L * 1024L // 5 MiB
|
defaultHeaders = mapOf(
|
||||||
|
"User-Agent" to
|
||||||
|
Injekt.get<NetworkHelper>().defaultUserAgentProvider()
|
||||||
|
.format(Build.VERSION.RELEASE, Build.MODEL)
|
||||||
)
|
)
|
||||||
okHttpClient = OkHttpClient.Builder()
|
|
||||||
.followRedirects(true)
|
okHttpClient = networkHelper.client
|
||||||
.followSslRedirects(true)
|
|
||||||
.cache(cache)
|
|
||||||
.apply {
|
|
||||||
when (dns) {
|
|
||||||
1 -> addGoogleDns()
|
|
||||||
2 -> addCloudFlareDns()
|
|
||||||
3 -> addAdGuardDns()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
client = Requests(
|
client = Requests(
|
||||||
okHttpClient,
|
networkHelper.client,
|
||||||
defaultHeaders,
|
defaultHeaders,
|
||||||
defaultCacheTime = 6,
|
defaultCacheTime = 6,
|
||||||
defaultCacheTimeUnit = TimeUnit.HOURS,
|
defaultCacheTimeUnit = TimeUnit.HOURS,
|
||||||
responseParser = Mapper
|
responseParser = Mapper
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
object Mapper : ResponseParser {
|
object Mapper : ResponseParser {
|
||||||
|
|
|
@ -24,6 +24,7 @@ import uy.kohesive.injekt.api.addSingleton
|
||||||
import uy.kohesive.injekt.api.addSingletonFactory
|
import uy.kohesive.injekt.api.addSingletonFactory
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import ani.dantotsu.download.DownloadsManager
|
import ani.dantotsu.download.DownloadsManager
|
||||||
|
import ani.dantotsu.parsers.novel.NovelExtensionManager
|
||||||
|
|
||||||
class AppModule(val app: Application) : InjektModule {
|
class AppModule(val app: Application) : InjektModule {
|
||||||
override fun InjektRegistrar.registerInjectables() {
|
override fun InjektRegistrar.registerInjectables() {
|
||||||
|
@ -35,6 +36,7 @@ class AppModule(val app: Application) : InjektModule {
|
||||||
|
|
||||||
addSingletonFactory { AnimeExtensionManager(app) }
|
addSingletonFactory { AnimeExtensionManager(app) }
|
||||||
addSingletonFactory { MangaExtensionManager(app) }
|
addSingletonFactory { MangaExtensionManager(app) }
|
||||||
|
addSingletonFactory { NovelExtensionManager(app) }
|
||||||
|
|
||||||
addSingletonFactory<AnimeSourceManager> { AndroidAnimeSourceManager(app, get()) }
|
addSingletonFactory<AnimeSourceManager> { AndroidAnimeSourceManager(app, get()) }
|
||||||
addSingletonFactory<MangaSourceManager> { AndroidMangaSourceManager(app, get()) }
|
addSingletonFactory<MangaSourceManager> { AndroidMangaSourceManager(app, get()) }
|
||||||
|
|
|
@ -18,6 +18,8 @@ class DownloadsManager(private val context: Context) {
|
||||||
get() = downloadsList.filter { it.type == Download.Type.MANGA }
|
get() = downloadsList.filter { it.type == Download.Type.MANGA }
|
||||||
val animeDownloads: List<Download>
|
val animeDownloads: List<Download>
|
||||||
get() = downloadsList.filter { it.type == Download.Type.ANIME }
|
get() = downloadsList.filter { it.type == Download.Type.ANIME }
|
||||||
|
val novelDownloads: List<Download>
|
||||||
|
get() = downloadsList.filter { it.type == Download.Type.NOVEL }
|
||||||
|
|
||||||
private fun saveDownloads() {
|
private fun saveDownloads() {
|
||||||
val jsonString = gson.toJson(downloadsList)
|
val jsonString = gson.toJson(downloadsList)
|
||||||
|
@ -45,11 +47,17 @@ class DownloadsManager(private val context: Context) {
|
||||||
saveDownloads()
|
saveDownloads()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun queryDownload(download: Download): Boolean {
|
||||||
|
return downloadsList.contains(download)
|
||||||
|
}
|
||||||
|
|
||||||
private fun removeDirectory(download: Download) {
|
private fun removeDirectory(download: Download) {
|
||||||
val directory = if (download.type == Download.Type.MANGA){
|
val directory = if (download.type == Download.Type.MANGA){
|
||||||
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Manga/${download.title}/${download.chapter}")
|
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}")
|
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
|
// 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
|
fun exportDownloads(download: Download) { //copies to the downloads folder available to the user
|
||||||
val directory = if (download.type == Download.Type.MANGA){
|
val directory = if (download.type == Download.Type.MANGA){
|
||||||
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Manga/${download.title}/${download.chapter}")
|
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}")
|
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}")
|
val destination = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/${download.title}/${download.chapter}")
|
||||||
if (directory.exists()) {
|
if (directory.exists()) {
|
||||||
|
@ -87,8 +97,10 @@ class DownloadsManager(private val context: Context) {
|
||||||
fun purgeDownloads(type: Download.Type){
|
fun purgeDownloads(type: Download.Type){
|
||||||
val directory = if (type == Download.Type.MANGA){
|
val directory = if (type == Download.Type.MANGA){
|
||||||
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Manga")
|
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Manga")
|
||||||
} else {
|
} else if (type == Download.Type.ANIME) {
|
||||||
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Anime")
|
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Anime")
|
||||||
|
} else {
|
||||||
|
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Novel")
|
||||||
}
|
}
|
||||||
if (directory.exists()) {
|
if (directory.exists()) {
|
||||||
val deleted = directory.deleteRecursively()
|
val deleted = directory.deleteRecursively()
|
||||||
|
@ -105,11 +117,18 @@ class DownloadsManager(private val context: Context) {
|
||||||
saveDownloads()
|
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 {
|
data class Download(val title: String, val chapter: String, val type: Type) : Serializable {
|
||||||
enum class Type {
|
enum class Type {
|
||||||
MANGA,
|
MANGA,
|
||||||
ANIME
|
ANIME,
|
||||||
|
NOVEL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -91,9 +91,9 @@ class MangaDownloaderService : Service() {
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
ServiceDataSingleton.downloadQueue.clear()
|
MangaServiceDataSingleton.downloadQueue.clear()
|
||||||
downloadJobs.clear()
|
downloadJobs.clear()
|
||||||
ServiceDataSingleton.isServiceRunning = false
|
MangaServiceDataSingleton.isServiceRunning = false
|
||||||
unregisterReceiver(cancelReceiver)
|
unregisterReceiver(cancelReceiver)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -114,8 +114,8 @@ class MangaDownloaderService : Service() {
|
||||||
|
|
||||||
private fun processQueue() {
|
private fun processQueue() {
|
||||||
CoroutineScope(Dispatchers.Default).launch {
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
while (ServiceDataSingleton.downloadQueue.isNotEmpty()) {
|
while (MangaServiceDataSingleton.downloadQueue.isNotEmpty()) {
|
||||||
val task = ServiceDataSingleton.downloadQueue.poll()
|
val task = MangaServiceDataSingleton.downloadQueue.poll()
|
||||||
if (task != null) {
|
if (task != null) {
|
||||||
val job = launch { download(task) }
|
val job = launch { download(task) }
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
|
@ -127,7 +127,7 @@ class MangaDownloaderService : Service() {
|
||||||
}
|
}
|
||||||
updateNotification() // Update the notification after each task is completed
|
updateNotification() // Update the notification after each task is completed
|
||||||
}
|
}
|
||||||
if (ServiceDataSingleton.downloadQueue.isEmpty()) {
|
if (MangaServiceDataSingleton.downloadQueue.isEmpty()) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
stopSelf() // Stop the service when the queue is empty
|
stopSelf() // Stop the service when the queue is empty
|
||||||
}
|
}
|
||||||
|
@ -141,7 +141,7 @@ class MangaDownloaderService : Service() {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
downloadJobs[chapter]?.cancel()
|
downloadJobs[chapter]?.cancel()
|
||||||
downloadJobs.remove(chapter)
|
downloadJobs.remove(chapter)
|
||||||
ServiceDataSingleton.downloadQueue.removeAll { it.chapter == chapter }
|
MangaServiceDataSingleton.downloadQueue.removeAll { it.chapter == chapter }
|
||||||
updateNotification() // Update the notification after cancellation
|
updateNotification() // Update the notification after cancellation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -149,7 +149,7 @@ class MangaDownloaderService : Service() {
|
||||||
|
|
||||||
private fun updateNotification() {
|
private fun updateNotification() {
|
||||||
// Update the notification to reflect the current state of the queue
|
// 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) {
|
val text = if (pendingDownloads > 0) {
|
||||||
"Pending downloads: $pendingDownloads"
|
"Pending downloads: $pendingDownloads"
|
||||||
} else {
|
} else {
|
||||||
|
@ -381,7 +381,7 @@ class MangaDownloaderService : Service() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
object ServiceDataSingleton {
|
object MangaServiceDataSingleton {
|
||||||
var imageData: List<ImageData> = listOf()
|
var imageData: List<ImageData> = listOf()
|
||||||
var sourceMedia: Media? = null
|
var sourceMedia: Media? = null
|
||||||
var downloadQueue: Queue<MangaDownloaderService.DownloadTask> = ConcurrentLinkedQueue()
|
var downloadQueue: Queue<MangaDownloaderService.DownloadTask> = ConcurrentLinkedQueue()
|
||||||
|
|
|
@ -0,0 +1,434 @@
|
||||||
|
package ani.dantotsu.download.novel
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import ani.dantotsu.R
|
||||||
|
import ani.dantotsu.download.Download
|
||||||
|
import ani.dantotsu.download.DownloadsManager
|
||||||
|
import ani.dantotsu.logger
|
||||||
|
import ani.dantotsu.media.Media
|
||||||
|
import ani.dantotsu.media.novel.NovelReadFragment
|
||||||
|
import ani.dantotsu.snackString
|
||||||
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import com.google.gson.InstanceCreator
|
||||||
|
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||||
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapterImpl
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.Request
|
||||||
|
import okio.buffer
|
||||||
|
import okio.sink
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
import java.io.BufferedInputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.Queue
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
|
||||||
|
class NovelDownloaderService : Service() {
|
||||||
|
|
||||||
|
private lateinit var notificationManager: NotificationManagerCompat
|
||||||
|
private lateinit var builder: NotificationCompat.Builder
|
||||||
|
private val downloadsManager: DownloadsManager = Injekt.get<DownloadsManager>()
|
||||||
|
|
||||||
|
private val downloadJobs = mutableMapOf<String, Job>()
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private var isCurrentlyProcessing = false
|
||||||
|
|
||||||
|
val networkHelper = Injekt.get<NetworkHelper>()
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? {
|
||||||
|
// This is only required for bound services.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
notificationManager = NotificationManagerCompat.from(this)
|
||||||
|
builder = NotificationCompat.Builder(this, Notifications.CHANNEL_DOWNLOADER_PROGRESS).apply {
|
||||||
|
setContentTitle("Novel Download Progress")
|
||||||
|
setSmallIcon(R.drawable.ic_round_download_24)
|
||||||
|
priority = NotificationCompat.PRIORITY_DEFAULT
|
||||||
|
setOnlyAlertOnce(true)
|
||||||
|
setProgress(0, 0, false)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(NOTIFICATION_ID, builder.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
}else{
|
||||||
|
startForeground(NOTIFICATION_ID, builder.build())
|
||||||
|
}
|
||||||
|
ContextCompat.registerReceiver(this, cancelReceiver, IntentFilter(ACTION_CANCEL_DOWNLOAD), ContextCompat.RECEIVER_EXPORTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
NovelServiceDataSingleton.downloadQueue.clear()
|
||||||
|
downloadJobs.clear()
|
||||||
|
NovelServiceDataSingleton.isServiceRunning = false
|
||||||
|
unregisterReceiver(cancelReceiver)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
snackString("Download started")
|
||||||
|
val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
serviceScope.launch {
|
||||||
|
mutex.withLock {
|
||||||
|
if (!isCurrentlyProcessing) {
|
||||||
|
isCurrentlyProcessing = true
|
||||||
|
processQueue()
|
||||||
|
isCurrentlyProcessing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Service.START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processQueue() {
|
||||||
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
while (NovelServiceDataSingleton.downloadQueue.isNotEmpty()) {
|
||||||
|
val task = NovelServiceDataSingleton.downloadQueue.poll()
|
||||||
|
if (task != null) {
|
||||||
|
val job = launch { download(task) }
|
||||||
|
mutex.withLock {
|
||||||
|
downloadJobs[task.chapter] = job
|
||||||
|
}
|
||||||
|
job.join() // Wait for the job to complete before continuing to the next task
|
||||||
|
mutex.withLock {
|
||||||
|
downloadJobs.remove(task.chapter)
|
||||||
|
}
|
||||||
|
updateNotification() // Update the notification after each task is completed
|
||||||
|
}
|
||||||
|
if (NovelServiceDataSingleton.downloadQueue.isEmpty()) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
stopSelf() // Stop the service when the queue is empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelDownload(chapter: String) {
|
||||||
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
mutex.withLock {
|
||||||
|
downloadJobs[chapter]?.cancel()
|
||||||
|
downloadJobs.remove(chapter)
|
||||||
|
NovelServiceDataSingleton.downloadQueue.removeAll { it.chapter == chapter }
|
||||||
|
updateNotification() // Update the notification after cancellation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification() {
|
||||||
|
// Update the notification to reflect the current state of the queue
|
||||||
|
val pendingDownloads = NovelServiceDataSingleton.downloadQueue.size
|
||||||
|
val text = if (pendingDownloads > 0) {
|
||||||
|
"Pending downloads: $pendingDownloads"
|
||||||
|
} else {
|
||||||
|
"All downloads completed"
|
||||||
|
}
|
||||||
|
builder.setContentText(text)
|
||||||
|
if (ActivityCompat.checkSelfPermission(
|
||||||
|
this,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun isEpubFile(urlString: String): Boolean {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(urlString)
|
||||||
|
.head()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
networkHelper.client.newCall(request).execute().use { response ->
|
||||||
|
val contentType = response.header("Content-Type")
|
||||||
|
val contentDisposition = response.header("Content-Disposition")
|
||||||
|
|
||||||
|
logger("Content-Type: $contentType")
|
||||||
|
logger("Content-Disposition: $contentDisposition")
|
||||||
|
|
||||||
|
// Return true if the Content-Type or Content-Disposition indicates an EPUB file
|
||||||
|
contentType == "application/epub+zip" ||
|
||||||
|
(contentDisposition?.contains(".epub") == true)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger("Error checking file type: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun download(task: DownloadTask) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val notifi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
this@NovelDownloaderService,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastDownloadStarted(task.originalLink)
|
||||||
|
|
||||||
|
if (notifi) {
|
||||||
|
builder.setContentText("Downloading ${task.title} - ${task.chapter}")
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEpubFile(task.downloadLink)) {
|
||||||
|
logger("Download link is not an .epub file")
|
||||||
|
broadcastDownloadFailed(task.originalLink)
|
||||||
|
snackString("Download link is not an .epub file")
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the download
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(task.downloadLink)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
networkHelper.downloadClient.newCall(request).execute().use { response ->
|
||||||
|
// Ensure the response is successful and has a body
|
||||||
|
if (!response.isSuccessful || response.body == null) {
|
||||||
|
throw IOException("Failed to download file: ${response.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val file = File(
|
||||||
|
this@NovelDownloaderService.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
||||||
|
"Dantotsu/Novel/${task.title}/${task.chapter}/0.epub"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create directories if they don't exist
|
||||||
|
file.parentFile?.takeIf { !it.exists() }?.mkdirs()
|
||||||
|
|
||||||
|
// Overwrite existing file
|
||||||
|
if (file.exists()) file.delete()
|
||||||
|
|
||||||
|
//download cover
|
||||||
|
task.coverUrl?.let {
|
||||||
|
file.parentFile?.let { it1 -> downloadImage(it, it1, "cover.jpg") }
|
||||||
|
}
|
||||||
|
|
||||||
|
val sink = file.sink().buffer()
|
||||||
|
val responseBody = response.body
|
||||||
|
val totalBytes = responseBody.contentLength()
|
||||||
|
var downloadedBytes = 0L
|
||||||
|
|
||||||
|
val notificationUpdateInterval = 1024 * 1024 // 1 MB
|
||||||
|
val broadcastUpdateInterval = 1024 * 256 // 256 KB
|
||||||
|
var lastNotificationUpdate = 0L
|
||||||
|
var lastBroadcastUpdate = 0L
|
||||||
|
|
||||||
|
responseBody.source().use { source ->
|
||||||
|
while (true) {
|
||||||
|
val read = source.read(sink.buffer, 8192)
|
||||||
|
if (read == -1L) break
|
||||||
|
downloadedBytes += read
|
||||||
|
sink.emit()
|
||||||
|
|
||||||
|
// Update progress at intervals
|
||||||
|
if (downloadedBytes - lastNotificationUpdate >= notificationUpdateInterval) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val progress = (downloadedBytes * 100 / totalBytes).toInt()
|
||||||
|
builder.setProgress(100, progress, false)
|
||||||
|
if (notifi) {
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, builder.build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastNotificationUpdate = downloadedBytes
|
||||||
|
}
|
||||||
|
if (downloadedBytes - lastBroadcastUpdate >= broadcastUpdateInterval) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val progress = (downloadedBytes * 100 / totalBytes).toInt()
|
||||||
|
logger("Download progress: $progress")
|
||||||
|
broadcastDownloadProgress(task.originalLink, progress)
|
||||||
|
}
|
||||||
|
lastBroadcastUpdate = downloadedBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sink.close()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger("Exception while downloading .epub: ${e.message}")
|
||||||
|
snackString("Exception while downloading .epub: ${e.message}")
|
||||||
|
FirebaseCrashlytics.getInstance().recordException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update notification for download completion
|
||||||
|
builder.setContentText("${task.title} - ${task.chapter} Download complete")
|
||||||
|
.setProgress(0, 0, false)
|
||||||
|
if (notifi) {
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMediaInfo(task)
|
||||||
|
downloadsManager.addDownload(Download(task.title, task.chapter, Download.Type.NOVEL))
|
||||||
|
broadcastDownloadFinished(task.originalLink)
|
||||||
|
snackString("${task.title} - ${task.chapter} Download finished")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private fun saveMediaInfo(task: DownloadTask) {
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
val directory = File(
|
||||||
|
getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
||||||
|
"Dantotsu/Novel/${task.title}"
|
||||||
|
)
|
||||||
|
if (!directory.exists()) directory.mkdirs()
|
||||||
|
|
||||||
|
val file = File(directory, "media.json")
|
||||||
|
val gson = GsonBuilder()
|
||||||
|
.registerTypeAdapter(SChapter::class.java, InstanceCreator<SChapter> {
|
||||||
|
SChapterImpl() // Provide an instance of SChapterImpl
|
||||||
|
})
|
||||||
|
.create()
|
||||||
|
val mediaJson = gson.toJson(task.sourceMedia)
|
||||||
|
val media = gson.fromJson(mediaJson, Media::class.java)
|
||||||
|
if (media != null) {
|
||||||
|
media.cover = media.cover?.let { downloadImage(it, directory, "cover.jpg") }
|
||||||
|
media.banner = media.banner?.let { downloadImage(it, directory, "banner.jpg") }
|
||||||
|
|
||||||
|
val jsonString = gson.toJson(media)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
file.writeText(jsonString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private suspend fun downloadImage(url: String, directory: File, name: String): String? = withContext(
|
||||||
|
Dispatchers.IO) {
|
||||||
|
var connection: HttpURLConnection? = null
|
||||||
|
println("Downloading url $url")
|
||||||
|
try {
|
||||||
|
connection = URL(url).openConnection() as HttpURLConnection
|
||||||
|
connection.connect()
|
||||||
|
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
|
||||||
|
throw Exception("Server returned HTTP ${connection.responseCode} ${connection.responseMessage}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val file = File(directory, name)
|
||||||
|
FileOutputStream(file).use { output ->
|
||||||
|
connection.inputStream.use { input ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return@withContext file.absolutePath
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(this@NovelDownloaderService, "Exception while saving ${name}: ${e.message}", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
null
|
||||||
|
} finally {
|
||||||
|
connection?.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun broadcastDownloadStarted(link: String) {
|
||||||
|
val intent = Intent(NovelReadFragment.ACTION_DOWNLOAD_STARTED).apply {
|
||||||
|
putExtra(NovelReadFragment.EXTRA_NOVEL_LINK, link)
|
||||||
|
}
|
||||||
|
sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun broadcastDownloadFinished(link: String) {
|
||||||
|
val intent = Intent(NovelReadFragment.ACTION_DOWNLOAD_FINISHED).apply {
|
||||||
|
putExtra(NovelReadFragment.EXTRA_NOVEL_LINK, link)
|
||||||
|
}
|
||||||
|
sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun broadcastDownloadFailed(link: String) {
|
||||||
|
val intent = Intent(NovelReadFragment.ACTION_DOWNLOAD_FAILED).apply {
|
||||||
|
putExtra(NovelReadFragment.EXTRA_NOVEL_LINK, link)
|
||||||
|
}
|
||||||
|
sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun broadcastDownloadProgress(link: String, progress: Int) {
|
||||||
|
val intent = Intent(NovelReadFragment.ACTION_DOWNLOAD_PROGRESS).apply {
|
||||||
|
putExtra(NovelReadFragment.EXTRA_NOVEL_LINK, link)
|
||||||
|
putExtra("progress", progress)
|
||||||
|
}
|
||||||
|
sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cancelReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action == ACTION_CANCEL_DOWNLOAD) {
|
||||||
|
val chapter = intent.getStringExtra(EXTRA_CHAPTER)
|
||||||
|
chapter?.let {
|
||||||
|
cancelDownload(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
data class DownloadTask(
|
||||||
|
val title: String,
|
||||||
|
val chapter: String,
|
||||||
|
val downloadLink: String,
|
||||||
|
val originalLink: String,
|
||||||
|
val sourceMedia: Media? = null,
|
||||||
|
val coverUrl: String? = null,
|
||||||
|
val retries: Int = 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val NOTIFICATION_ID = 1103
|
||||||
|
const val ACTION_CANCEL_DOWNLOAD = "action_cancel_download"
|
||||||
|
const val EXTRA_CHAPTER = "extra_chapter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object NovelServiceDataSingleton {
|
||||||
|
var sourceMedia: Media? = null
|
||||||
|
var downloadQueue: Queue<NovelDownloaderService.DownloadTask> = ConcurrentLinkedQueue()
|
||||||
|
@Volatile
|
||||||
|
var isServiceRunning: Boolean = false
|
||||||
|
}
|
|
@ -30,7 +30,7 @@ import ani.dantotsu.databinding.FragmentAnimeWatchBinding
|
||||||
import ani.dantotsu.download.Download
|
import ani.dantotsu.download.Download
|
||||||
import ani.dantotsu.download.DownloadsManager
|
import ani.dantotsu.download.DownloadsManager
|
||||||
import ani.dantotsu.download.manga.MangaDownloaderService
|
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.manga.mangareader.ChapterLoaderDialog
|
||||||
import ani.dantotsu.media.Media
|
import ani.dantotsu.media.Media
|
||||||
import ani.dantotsu.media.MediaDetailsActivity
|
import ani.dantotsu.media.MediaDetailsActivity
|
||||||
|
@ -408,15 +408,15 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener {
|
||||||
simultaneousDownloads = 2
|
simultaneousDownloads = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
ServiceDataSingleton.downloadQueue.offer(downloadTask)
|
MangaServiceDataSingleton.downloadQueue.offer(downloadTask)
|
||||||
|
|
||||||
// If the service is not already running, start it
|
// If the service is not already running, start it
|
||||||
if (!ServiceDataSingleton.isServiceRunning) {
|
if (!MangaServiceDataSingleton.isServiceRunning) {
|
||||||
val intent = Intent(context, MangaDownloaderService::class.java)
|
val intent = Intent(context, MangaDownloaderService::class.java)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
ContextCompat.startForegroundService(requireContext(), intent)
|
ContextCompat.startForegroundService(requireContext(), intent)
|
||||||
}
|
}
|
||||||
ServiceDataSingleton.isServiceRunning = true
|
MangaServiceDataSingleton.isServiceRunning = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inform the adapter that the download has started
|
// Inform the adapter that the download has started
|
||||||
|
@ -456,6 +456,7 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener {
|
||||||
}
|
}
|
||||||
private val downloadStatusReceiver = object : BroadcastReceiver() {
|
private val downloadStatusReceiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if(!this@MangaReadFragment::chapterAdapter.isInitialized) return
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
ACTION_DOWNLOAD_STARTED -> {
|
ACTION_DOWNLOAD_STARTED -> {
|
||||||
val chapterNumber = intent.getStringExtra(EXTRA_CHAPTER_NUMBER)
|
val chapterNumber = intent.getStringExtra(EXTRA_CHAPTER_NUMBER)
|
||||||
|
|
|
@ -29,6 +29,14 @@ class BookDialog : BottomSheetDialogFragment() {
|
||||||
private lateinit var novel: ShowResponse
|
private lateinit var novel: ShowResponse
|
||||||
private var source:Int = 0
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
arguments?.let {
|
arguments?.let {
|
||||||
novelName = it.getString("novelName")!!
|
novelName = it.getString("novelName")!!
|
||||||
|
@ -51,7 +59,7 @@ class BookDialog : BottomSheetDialogFragment() {
|
||||||
binding.itemBookTitle.text = it.name
|
binding.itemBookTitle.text = it.name
|
||||||
binding.itemBookDesc.text = it.description
|
binding.itemBookDesc.text = it.description
|
||||||
binding.itemBookImage.loadImage(it.img)
|
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) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
|
|
@ -64,7 +64,7 @@ class NovelReadAdapter(
|
||||||
binding.searchBar.setEndIconOnClickListener { search() }
|
binding.searchBar.setEndIconOnClickListener { search() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount(): Int = 0
|
override fun getItemCount(): Int = 1
|
||||||
|
|
||||||
inner class ViewHolder(val binding: ItemNovelHeaderBinding) : RecyclerView.ViewHolder(binding.root)
|
inner class ViewHolder(val binding: ItemNovelHeaderBinding) : RecyclerView.ViewHolder(binding.root)
|
||||||
}
|
}
|
|
@ -1,12 +1,20 @@
|
||||||
package ani.dantotsu.media.novel
|
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.Bundle
|
||||||
|
import android.os.Environment
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
|
@ -14,16 +22,29 @@ import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.ConcatAdapter
|
import androidx.recyclerview.widget.ConcatAdapter
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import ani.dantotsu.databinding.FragmentAnimeWatchBinding
|
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.loadData
|
||||||
import ani.dantotsu.media.Media
|
import ani.dantotsu.media.Media
|
||||||
import ani.dantotsu.media.MediaDetailsViewModel
|
import ani.dantotsu.media.MediaDetailsViewModel
|
||||||
|
import ani.dantotsu.media.novel.novelreader.NovelReaderActivity
|
||||||
import ani.dantotsu.navBarHeight
|
import ani.dantotsu.navBarHeight
|
||||||
|
import ani.dantotsu.parsers.ShowResponse
|
||||||
import ani.dantotsu.saveData
|
import ani.dantotsu.saveData
|
||||||
import ani.dantotsu.settings.UserInterfaceSettings
|
import ani.dantotsu.settings.UserInterfaceSettings
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
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 var _binding: FragmentAnimeWatchBinding? = null
|
||||||
private val binding get() = _binding!!
|
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) }
|
val uiSettings = loadData("ui_settings", toast = false) ?: UserInterfaceSettings().apply { saveData("ui_settings", this) }
|
||||||
|
|
||||||
|
override fun downloadTrigger(novelDownloadPackage: NovelDownloadPackage) {
|
||||||
|
Log.e("downloadTrigger", novelDownloadPackage.link)
|
||||||
|
val downloadTask = NovelDownloaderService.DownloadTask(
|
||||||
|
title = media.nameMAL ?: media.nameRomaji,
|
||||||
|
chapter = novelDownloadPackage.novelName,
|
||||||
|
downloadLink = novelDownloadPackage.link,
|
||||||
|
originalLink = novelDownloadPackage.originalLink,
|
||||||
|
sourceMedia = media,
|
||||||
|
coverUrl = novelDownloadPackage.coverUrl,
|
||||||
|
retries = 2,
|
||||||
|
)
|
||||||
|
NovelServiceDataSingleton.downloadQueue.offer(downloadTask)
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
|
||||||
|
if (!NovelServiceDataSingleton.isServiceRunning) {
|
||||||
|
val intent = Intent(context, NovelDownloaderService::class.java)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
ContextCompat.startForegroundService(requireContext(), intent)
|
||||||
|
}
|
||||||
|
NovelServiceDataSingleton.isServiceRunning = true
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun downloadedCheckWithStart(novel: ShowResponse): Boolean {
|
||||||
|
val downloadsManager = Injekt.get<DownloadsManager>()
|
||||||
|
if(downloadsManager.queryDownload(Download(media.nameMAL ?: media.nameRomaji, novel.name, Download.Type.NOVEL))) {
|
||||||
|
val file = File(context?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "${DownloadsManager.novelLocation}/${media.nameMAL ?: media.nameRomaji}/${novel.name}/0.epub")
|
||||||
|
if (!file.exists()) return false
|
||||||
|
val fileUri = FileProvider.getUriForFile(requireContext(), "${requireContext().packageName}.provider", file)
|
||||||
|
val intent = Intent(context, NovelReaderActivity::class.java).apply {
|
||||||
|
action = Intent.ACTION_VIEW
|
||||||
|
setDataAndType(fileUri, "application/epub+zip")
|
||||||
|
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun downloadedCheck(novel: ShowResponse): Boolean {
|
||||||
|
val downloadsManager = Injekt.get<DownloadsManager>()
|
||||||
|
return downloadsManager.queryDownload(Download(media.nameMAL ?: media.nameRomaji, novel.name, Download.Type.NOVEL))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteDownload(novel: ShowResponse) {
|
||||||
|
val downloadsManager = Injekt.get<DownloadsManager>()
|
||||||
|
downloadsManager.removeDownload(Download(media.nameMAL ?: media.nameRomaji, novel.name, Download.Type.NOVEL))
|
||||||
|
}
|
||||||
|
|
||||||
|
private val downloadStatusReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (!this@NovelReadFragment::novelResponseAdapter.isInitialized) return
|
||||||
|
when (intent.action) {
|
||||||
|
ACTION_DOWNLOAD_STARTED -> {
|
||||||
|
val link = intent.getStringExtra(EXTRA_NOVEL_LINK)
|
||||||
|
link?.let {
|
||||||
|
novelResponseAdapter.startDownload(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ACTION_DOWNLOAD_FINISHED -> {
|
||||||
|
val link = intent.getStringExtra(EXTRA_NOVEL_LINK)
|
||||||
|
link?.let {
|
||||||
|
novelResponseAdapter.stopDownload(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ACTION_DOWNLOAD_FAILED -> {
|
||||||
|
val link = intent.getStringExtra(EXTRA_NOVEL_LINK)
|
||||||
|
link?.let {
|
||||||
|
novelResponseAdapter.purgeDownload(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ACTION_DOWNLOAD_PROGRESS -> {
|
||||||
|
val link = intent.getStringExtra(EXTRA_NOVEL_LINK)
|
||||||
|
val progress = intent.getIntExtra("progress", 0)
|
||||||
|
link?.let {
|
||||||
|
novelResponseAdapter.updateDownloadProgress(it, progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var response: List<ShowResponse>? = null
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
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.updatePadding(bottom = binding.animeSourceRecycler.paddingBottom + navBarHeight)
|
||||||
|
|
||||||
binding.animeSourceRecycler.layoutManager = LinearLayoutManager(requireContext())
|
binding.animeSourceRecycler.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
@ -63,7 +179,7 @@ class NovelReadFragment : Fragment() {
|
||||||
val sel = media.selected
|
val sel = media.selected
|
||||||
searchQuery = sel?.server ?: media.name ?: media.nameRomaji
|
searchQuery = sel?.server ?: media.name ?: media.nameRomaji
|
||||||
headerAdapter = NovelReadAdapter(media, this, model.novelSources)
|
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)
|
binding.animeSourceRecycler.adapter = ConcatAdapter(headerAdapter, novelResponseAdapter)
|
||||||
loaded = true
|
loaded = true
|
||||||
Handler(Looper.getMainLooper()).postDelayed({
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
|
@ -74,6 +190,7 @@ class NovelReadFragment : Fragment() {
|
||||||
}
|
}
|
||||||
model.novelResponses.observe(viewLifecycleOwner) {
|
model.novelResponses.observe(viewLifecycleOwner) {
|
||||||
if (it != null) {
|
if (it != null) {
|
||||||
|
response = it
|
||||||
searching = false
|
searching = false
|
||||||
novelResponseAdapter.submitList(it)
|
novelResponseAdapter.submitList(it)
|
||||||
headerAdapter.progress?.visibility = View.GONE
|
headerAdapter.progress?.visibility = View.GONE
|
||||||
|
@ -121,6 +238,7 @@ class NovelReadFragment : Fragment() {
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
model.mangaReadSources?.flushText()
|
model.mangaReadSources?.flushText()
|
||||||
|
requireContext().unregisterReceiver(downloadStatusReceiver)
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -135,4 +253,22 @@ class NovelReadFragment : Fragment() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
state = binding.animeSourceRecycler.layoutManager?.onSaveInstanceState()
|
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)
|
||||||
}
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
package ani.dantotsu.media.novel
|
package ani.dantotsu.media.novel
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
@ -7,16 +8,23 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import ani.dantotsu.databinding.ItemNovelResponseBinding
|
import ani.dantotsu.databinding.ItemNovelResponseBinding
|
||||||
import ani.dantotsu.parsers.ShowResponse
|
import ani.dantotsu.parsers.ShowResponse
|
||||||
import ani.dantotsu.setAnimation
|
import ani.dantotsu.setAnimation
|
||||||
|
import ani.dantotsu.snackString
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.model.GlideUrl
|
import com.bumptech.glide.load.model.GlideUrl
|
||||||
|
|
||||||
class NovelResponseAdapter(val fragment: NovelReadFragment) : RecyclerView.Adapter<NovelResponseAdapter.ViewHolder>() {
|
class NovelResponseAdapter(
|
||||||
|
val fragment: NovelReadFragment,
|
||||||
|
val downloadTriggerCallback: DownloadTriggerCallback,
|
||||||
|
val downloadedCheckCallback: DownloadedCheckCallback
|
||||||
|
) : RecyclerView.Adapter<NovelResponseAdapter.ViewHolder>() {
|
||||||
val list: MutableList<ShowResponse> = mutableListOf()
|
val list: MutableList<ShowResponse> = mutableListOf()
|
||||||
|
|
||||||
inner class ViewHolder(val binding: ItemNovelResponseBinding) : RecyclerView.ViewHolder(binding.root)
|
inner class ViewHolder(val binding: ItemNovelResponseBinding) :
|
||||||
|
RecyclerView.ViewHolder(binding.root)
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
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)
|
return ViewHolder(bind)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,19 +35,128 @@ class NovelResponseAdapter(val fragment: NovelReadFragment) : RecyclerView.Adapt
|
||||||
val novel = list[position]
|
val novel = list[position]
|
||||||
setAnimation(fragment.requireContext(), holder.binding.root, fragment.uiSettings)
|
setAnimation(fragment.requireContext(), holder.binding.root, fragment.uiSettings)
|
||||||
|
|
||||||
val cover = GlideUrl(novel.coverUrl.url){ novel.coverUrl.headers }
|
val cover = GlideUrl(novel.coverUrl.url) { novel.coverUrl.headers }
|
||||||
Glide.with(binding.itemEpisodeImage).load(cover).override(400,0).into(binding.itemEpisodeImage)
|
Glide.with(binding.itemEpisodeImage).load(cover).override(400, 0)
|
||||||
|
.into(binding.itemEpisodeImage)
|
||||||
|
|
||||||
binding.itemEpisodeTitle.text = novel.name
|
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") ?: ""
|
binding.itemEpisodeDesc2.text = novel.extra?.get("1") ?: ""
|
||||||
val desc = novel.extra?.get("2")
|
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.itemEpisodeDesc.text = desc ?: ""
|
||||||
|
|
||||||
binding.root.setOnClickListener {
|
binding.root.setOnClickListener {
|
||||||
BookDialog.newInstance(fragment.novelName, novel, fragment.source)
|
//make sure the file is not downloading
|
||||||
.show(fragment.parentFragmentManager, "dialog")
|
if (activeDownloads.contains(novel.link)) {
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
if (downloadedCheckCallback.downloadedCheckWithStart(novel)) {
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
|
||||||
|
val bookDialog = BookDialog.newInstance(fragment.novelName, novel, fragment.source)
|
||||||
|
|
||||||
|
bookDialog.setCallback(object : BookDialog.Callback {
|
||||||
|
override fun onDownloadTriggered(link: String) {
|
||||||
|
downloadTriggerCallback.downloadTrigger(
|
||||||
|
NovelDownloadPackage(
|
||||||
|
link,
|
||||||
|
novel.coverUrl.url,
|
||||||
|
novel.name,
|
||||||
|
novel.link
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bookDialog.dismiss()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
bookDialog.show(fragment.parentFragmentManager, "dialog")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.root.setOnLongClickListener {
|
||||||
|
downloadedCheckCallback.deleteDownload(novel)
|
||||||
|
deleteDownload(novel.link)
|
||||||
|
snackString("Deleted ${novel.name}")
|
||||||
|
if (binding.itemEpisodeFiller.text.toString().contains("Download", ignoreCase = true)) {
|
||||||
|
binding.itemEpisodeFiller.text = ""
|
||||||
|
}
|
||||||
|
notifyItemChanged(position)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val activeDownloads = mutableSetOf<String>()
|
||||||
|
private val downloadedChapters = mutableSetOf<String>()
|
||||||
|
|
||||||
|
fun startDownload(link: String) {
|
||||||
|
activeDownloads.add(link)
|
||||||
|
val position = list.indexOfFirst { it.link == link }
|
||||||
|
if (position != -1) {
|
||||||
|
list[position].extra?.remove("0")
|
||||||
|
list[position].extra?.set("0", "Downloading: 0%")
|
||||||
|
notifyItemChanged(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopDownload(link: String) {
|
||||||
|
activeDownloads.remove(link)
|
||||||
|
downloadedChapters.add(link)
|
||||||
|
val position = list.indexOfFirst { it.link == link }
|
||||||
|
if (position != -1) {
|
||||||
|
list[position].extra?.remove("0")
|
||||||
|
list[position].extra?.set("0", "Downloaded")
|
||||||
|
notifyItemChanged(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteDownload(link: String) { //TODO:
|
||||||
|
downloadedChapters.remove(link)
|
||||||
|
val position = list.indexOfFirst { it.link == link }
|
||||||
|
if (position != -1) {
|
||||||
|
notifyItemChanged(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun purgeDownload(link: String) {
|
||||||
|
activeDownloads.remove(link)
|
||||||
|
downloadedChapters.remove(link)
|
||||||
|
val position = list.indexOfFirst { it.link == link }
|
||||||
|
if (position != -1) {
|
||||||
|
notifyItemChanged(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDownloadProgress(link: String, progress: Int) {
|
||||||
|
if (!activeDownloads.contains(link)) {
|
||||||
|
activeDownloads.add(link)
|
||||||
|
}
|
||||||
|
val position = list.indexOfFirst { it.link == link }
|
||||||
|
if (position != -1) {
|
||||||
|
list[position].extra?.remove("0")
|
||||||
|
list[position].extra?.set("0", "Downloading: $progress%")
|
||||||
|
Log.d("NovelResponseAdapter", "updateDownloadProgress: $progress, position: $position")
|
||||||
|
notifyItemChanged(position)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,4 +171,11 @@ class NovelResponseAdapter(val fragment: NovelReadFragment) : RecyclerView.Adapt
|
||||||
list.clear()
|
list.clear()
|
||||||
notifyItemRangeRemoved(0, size)
|
notifyItemRangeRemoved(0, size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class NovelDownloadPackage(
|
||||||
|
val link: String,
|
||||||
|
val coverUrl: String,
|
||||||
|
val novelName: String,
|
||||||
|
val originalLink: String
|
||||||
|
)
|
|
@ -9,12 +9,11 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import ani.dantotsu.FileUrl
|
import ani.dantotsu.FileUrl
|
||||||
import ani.dantotsu.copyToClipboard
|
import ani.dantotsu.copyToClipboard
|
||||||
import ani.dantotsu.databinding.ItemUrlBinding
|
import ani.dantotsu.databinding.ItemUrlBinding
|
||||||
import ani.dantotsu.others.Download.download
|
|
||||||
import ani.dantotsu.parsers.Book
|
import ani.dantotsu.parsers.Book
|
||||||
import ani.dantotsu.setSafeOnClickListener
|
import ani.dantotsu.setSafeOnClickListener
|
||||||
import ani.dantotsu.tryWith
|
import ani.dantotsu.tryWith
|
||||||
|
|
||||||
class UrlAdapter(private val urls: List<FileUrl>, val book: Book, val novel: String) :
|
class UrlAdapter(private val urls: List<FileUrl>, val book: Book, val novel: String, val callback: BookDialog.Callback?) :
|
||||||
RecyclerView.Adapter<UrlAdapter.UrlViewHolder>() {
|
RecyclerView.Adapter<UrlAdapter.UrlViewHolder>() {
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UrlViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UrlViewHolder {
|
||||||
|
@ -26,6 +25,7 @@ class UrlAdapter(private val urls: List<FileUrl>, val book: Book, val novel: Str
|
||||||
val binding = holder.binding
|
val binding = holder.binding
|
||||||
val url = urls[position]
|
val url = urls[position]
|
||||||
binding.urlQuality.text = url.url
|
binding.urlQuality.text = url.url
|
||||||
|
binding.urlQuality.maxLines = 4
|
||||||
binding.urlDownload.visibility = View.VISIBLE
|
binding.urlDownload.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,12 +36,14 @@ class UrlAdapter(private val urls: List<FileUrl>, val book: Book, val novel: Str
|
||||||
itemView.setSafeOnClickListener {
|
itemView.setSafeOnClickListener {
|
||||||
tryWith(true) {
|
tryWith(true) {
|
||||||
binding.urlDownload.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
binding.urlDownload.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||||
download(
|
callback?.onDownloadTriggered(book.links[bindingAdapterPosition].url)
|
||||||
|
/*download(
|
||||||
itemView.context,
|
itemView.context,
|
||||||
book,
|
book,
|
||||||
bindingAdapterPosition,
|
bindingAdapterPosition,
|
||||||
novel
|
novel
|
||||||
)
|
)*/
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
itemView.setOnLongClickListener {
|
itemView.setOnLongClickListener {
|
||||||
|
|
|
@ -138,7 +138,7 @@ class NovelReaderActivity : AppCompatActivity(), EbookReaderEventListener {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
LangSet.setLocale(this)
|
LangSet.setLocale(this)
|
||||||
ThemeManager(this).applyTheme()
|
ThemeManager(this).applyTheme()
|
||||||
binding = ActivityNovelReaderBinding.inflate(layoutInflater)
|
binding = ActivityNovelReaderBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
|
||||||
|
|
|
@ -11,30 +11,22 @@ import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import ani.dantotsu.FileUrl
|
import ani.dantotsu.FileUrl
|
||||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
|
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
|
||||||
import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource
|
import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource
|
||||||
import eu.kanade.tachiyomi.network.interceptor.CloudflareBypassException
|
import eu.kanade.tachiyomi.network.interceptor.CloudflareBypassException
|
||||||
import ani.dantotsu.currContext
|
import ani.dantotsu.currContext
|
||||||
import ani.dantotsu.download.manga.MangaDownloaderService
|
|
||||||
import ani.dantotsu.download.manga.ServiceDataSingleton
|
|
||||||
import ani.dantotsu.logger
|
import ani.dantotsu.logger
|
||||||
import ani.dantotsu.media.anime.AnimeNameAdapter
|
import ani.dantotsu.media.anime.AnimeNameAdapter
|
||||||
import ani.dantotsu.media.manga.ImageData
|
import ani.dantotsu.media.manga.ImageData
|
||||||
import ani.dantotsu.media.manga.MangaCache
|
import ani.dantotsu.media.manga.MangaCache
|
||||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
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.SEpisode
|
||||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
|
||||||
import eu.kanade.tachiyomi.animesource.model.AnimesPage
|
import eu.kanade.tachiyomi.animesource.model.AnimesPage
|
||||||
import eu.kanade.tachiyomi.animesource.model.SAnime
|
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||||
import eu.kanade.tachiyomi.animesource.model.Track
|
import eu.kanade.tachiyomi.animesource.model.Track
|
||||||
import eu.kanade.tachiyomi.animesource.model.Video
|
import eu.kanade.tachiyomi.animesource.model.Video
|
||||||
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
|
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.MangasPage
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
@ -49,11 +41,8 @@ import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.OutputStream
|
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
|
|
|
@ -167,7 +167,7 @@ data class ShowResponse(
|
||||||
val total: Int? = null,
|
val total: Int? = null,
|
||||||
|
|
||||||
//In case you want to sent some extra data
|
//In case you want to sent some extra data
|
||||||
val extra : Map<String,String>?=null,
|
val extra : MutableMap<String,String>?=null,
|
||||||
|
|
||||||
//SAnime object from Aniyomi
|
//SAnime object from Aniyomi
|
||||||
val sAnime: SAnime? = null,
|
val sAnime: SAnime? = null,
|
||||||
|
@ -175,7 +175,7 @@ data class ShowResponse(
|
||||||
//SManga object from Aniyomi
|
//SManga object from Aniyomi
|
||||||
val sManga: SManga? = null
|
val sManga: SManga? = null
|
||||||
) : Serializable {
|
) : Serializable {
|
||||||
constructor(name: String, link: String, coverUrl: String, otherNames: List<String> = listOf(), total: Int? = null, extra: Map<String, String>?=null)
|
constructor(name: String, link: String, coverUrl: String, otherNames: List<String> = listOf(), total: Int? = null, extra: MutableMap<String, String>?=null)
|
||||||
: this(name, link, FileUrl(coverUrl), otherNames, total, extra)
|
: this(name, link, FileUrl(coverUrl), otherNames, total, extra)
|
||||||
|
|
||||||
constructor(name: String, link: String, coverUrl: String, otherNames: List<String> = listOf(), total: Int? = null)
|
constructor(name: String, link: String, coverUrl: String, otherNames: List<String> = listOf(), total: Int? = null)
|
||||||
|
|
8
app/src/main/java/ani/dantotsu/parsers/NovelInterface.kt
Normal file
8
app/src/main/java/ani/dantotsu/parsers/NovelInterface.kt
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
package ani.dantotsu.parsers
|
||||||
|
import com.lagradost.nicehttp.Requests
|
||||||
|
|
||||||
|
|
||||||
|
interface NovelInterface {
|
||||||
|
suspend fun search(query: String, client: Requests): List<ShowResponse>
|
||||||
|
suspend fun loadBook(link: String, extra: Map<String, String>?, client: Requests): Book
|
||||||
|
}
|
|
@ -1,9 +1,32 @@
|
||||||
package ani.dantotsu.parsers
|
package ani.dantotsu.parsers
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import ani.dantotsu.Lazier
|
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() {
|
object NovelSources : NovelReadSources() {
|
||||||
override val list: List<Lazier<BaseParser>> = lazyList(
|
override var list: List<Lazier<BaseParser>> = emptyList()
|
||||||
)
|
|
||||||
|
suspend fun init(fromExtensions: StateFlow<List<NovelExtension.Installed>>) {
|
||||||
|
// Initialize with the first value from StateFlow
|
||||||
|
val initialExtensions = fromExtensions.first()
|
||||||
|
list = createParsersFromExtensions(initialExtensions)
|
||||||
|
|
||||||
|
// Update as StateFlow emits new values
|
||||||
|
fromExtensions.collect { extensions ->
|
||||||
|
list = createParsersFromExtensions(extensions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createParsersFromExtensions(extensions: List<NovelExtension.Installed>): List<Lazier<BaseParser>> {
|
||||||
|
Log.d("NovelSources", "createParsersFromExtensions")
|
||||||
|
Log.d("NovelSources", extensions.toString())
|
||||||
|
return extensions.map { extension ->
|
||||||
|
val name = extension.name
|
||||||
|
Lazier({ DynamicNovelParser(extension) }, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
41
app/src/main/java/ani/dantotsu/parsers/novel/NovelAdapter.kt
Normal file
41
app/src/main/java/ani/dantotsu/parsers/novel/NovelAdapter.kt
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
package ani.dantotsu.parsers.novel
|
||||||
|
|
||||||
|
import ani.dantotsu.FileUrl
|
||||||
|
import ani.dantotsu.parsers.Book
|
||||||
|
import ani.dantotsu.parsers.NovelInterface
|
||||||
|
import ani.dantotsu.parsers.NovelParser
|
||||||
|
import ani.dantotsu.parsers.ShowResponse
|
||||||
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
class NovelAdapter {
|
||||||
|
}
|
||||||
|
|
||||||
|
class DynamicNovelParser(extension: NovelExtension.Installed) : NovelParser() {
|
||||||
|
override val volumeRegex = Regex("vol\\.? (\\d+(\\.\\d+)?)|volume (\\d+(\\.\\d+)?)", RegexOption.IGNORE_CASE)
|
||||||
|
var extension: NovelExtension.Installed
|
||||||
|
val client = Injekt.get<NetworkHelper>().requestClient
|
||||||
|
init {
|
||||||
|
this.extension = extension
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun search(query: String): List<ShowResponse> {
|
||||||
|
val source = extension.sources.firstOrNull()
|
||||||
|
if (source is NovelInterface) {
|
||||||
|
return source.search(query, client)
|
||||||
|
} else {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun loadBook(link: String, extra: Map<String, String>?): Book {
|
||||||
|
val source = extension.sources.firstOrNull()
|
||||||
|
if (source is NovelInterface) {
|
||||||
|
return source.loadBook(link, extra, client)
|
||||||
|
} else {
|
||||||
|
return Book("", "", "", emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
package ani.dantotsu.parsers.novel
|
||||||
|
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import ani.dantotsu.parsers.NovelInterface
|
||||||
|
|
||||||
|
sealed class NovelExtension {
|
||||||
|
abstract val name: String
|
||||||
|
abstract val pkgName: String
|
||||||
|
abstract val versionName: String
|
||||||
|
abstract val versionCode: Long
|
||||||
|
|
||||||
|
data class Installed(
|
||||||
|
override val name: String,
|
||||||
|
override val pkgName: String,
|
||||||
|
override val versionName: String,
|
||||||
|
override val versionCode: Long,
|
||||||
|
val sources: List<NovelInterface>,
|
||||||
|
val icon: Drawable?,
|
||||||
|
val hasUpdate: Boolean = false,
|
||||||
|
val isObsolete: Boolean = false,
|
||||||
|
val isUnofficial: Boolean = false,
|
||||||
|
) : NovelExtension()
|
||||||
|
|
||||||
|
data class Available(
|
||||||
|
override val name: String,
|
||||||
|
override val pkgName: String,
|
||||||
|
override val versionName: String,
|
||||||
|
override val versionCode: Long,
|
||||||
|
val sources: List<AvailableNovelSources>,
|
||||||
|
val iconUrl: String,
|
||||||
|
) : NovelExtension()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AvailableNovelSources(
|
||||||
|
val id: Long,
|
||||||
|
val lang: String,
|
||||||
|
val name: String,
|
||||||
|
val baseUrl: String,
|
||||||
|
) {
|
||||||
|
fun toNovelSourceData(): NovelSourceData {
|
||||||
|
return NovelSourceData(
|
||||||
|
id = this.id,
|
||||||
|
lang = this.lang,
|
||||||
|
name = this.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class NovelSourceData(
|
||||||
|
val id: Long,
|
||||||
|
val lang: String,
|
||||||
|
val name: String,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val isMissingInfo: Boolean = name.isBlank() || lang.isBlank()
|
||||||
|
}
|
|
@ -0,0 +1,178 @@
|
||||||
|
package ani.dantotsu.parsers.novel
|
||||||
|
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import ani.dantotsu.currContext
|
||||||
|
import ani.dantotsu.logger
|
||||||
|
import eu.kanade.tachiyomi.extension.ExtensionUpdateNotifier
|
||||||
|
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
|
||||||
|
import eu.kanade.tachiyomi.extension.anime.model.AnimeLoadResult
|
||||||
|
import eu.kanade.tachiyomi.extension.anime.util.AnimeExtensionLoader
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
|
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||||
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import logcat.LogPriority
|
||||||
|
import tachiyomi.core.util.lang.withIOContext
|
||||||
|
import tachiyomi.core.util.system.logcat
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.util.Date
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
|
||||||
|
class NovelExtensionGithubApi {
|
||||||
|
|
||||||
|
private val networkService: NetworkHelper by injectLazy()
|
||||||
|
private val novelExtensionManager: NovelExtensionManager by injectLazy()
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
private val lastExtCheck: Long = currContext()?.getSharedPreferences("Dantotsu", Context.MODE_PRIVATE)?.getLong("last_ext_check", 0)?:0
|
||||||
|
|
||||||
|
private var requiresFallbackSource = false
|
||||||
|
|
||||||
|
suspend fun findExtensions(): List<NovelExtension.Available> {
|
||||||
|
return withIOContext {
|
||||||
|
val githubResponse = if (requiresFallbackSource) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
networkService.client
|
||||||
|
.newCall(GET("${REPO_URL_PREFIX}index.min.json"))
|
||||||
|
.awaitSuccess()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
logcat(LogPriority.ERROR, e) { "Failed to get extensions from GitHub" }
|
||||||
|
requiresFallbackSource = true
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = githubResponse ?: run {
|
||||||
|
logger("using fallback source")
|
||||||
|
networkService.client
|
||||||
|
.newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json"))
|
||||||
|
.awaitSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
logger("response: $response")
|
||||||
|
|
||||||
|
val extensions = with(json) {
|
||||||
|
response
|
||||||
|
.parseAs<List<NovelExtensionJsonObject>>()
|
||||||
|
.toExtensions()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity check - a small number of extensions probably means something broke
|
||||||
|
// with the repo generator
|
||||||
|
/*if (extensions.size < 10) { //TODO: uncomment when more extensions are added
|
||||||
|
throw Exception()
|
||||||
|
}*/
|
||||||
|
logger("extensions: $extensions")
|
||||||
|
extensions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun checkForUpdates(context: Context, fromAvailableExtensionList: Boolean = false): List<AnimeExtension.Installed>? {
|
||||||
|
// Limit checks to once a day at most
|
||||||
|
if (fromAvailableExtensionList && Date().time < lastExtCheck + 1.days.inWholeMilliseconds) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val extensions = if (fromAvailableExtensionList) {
|
||||||
|
novelExtensionManager.availableExtensionsFlow.value
|
||||||
|
} else {
|
||||||
|
findExtensions().also { context.getSharedPreferences("Dantotsu", Context.MODE_PRIVATE)?.edit()?.putLong("last_ext_check", Date().time)?.apply() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val installedExtensions = NovelExtensionLoader.loadExtensions(context)
|
||||||
|
.filterIsInstance<AnimeLoadResult.Success>()
|
||||||
|
.map { it.extension }
|
||||||
|
|
||||||
|
val extensionsWithUpdate = mutableListOf<AnimeExtension.Installed>()
|
||||||
|
for (installedExt in installedExtensions) {
|
||||||
|
val pkgName = installedExt.pkgName
|
||||||
|
val availableExt = extensions.find { it.pkgName == pkgName } ?: continue
|
||||||
|
|
||||||
|
val hasUpdatedVer = availableExt.versionCode > installedExt.versionCode
|
||||||
|
val hasUpdate = installedExt.isUnofficial.not() && (hasUpdatedVer)
|
||||||
|
if (hasUpdate) {
|
||||||
|
extensionsWithUpdate.add(installedExt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extensionsWithUpdate.isNotEmpty()) {
|
||||||
|
ExtensionUpdateNotifier(context).promptUpdates(extensionsWithUpdate.map { it.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
return extensionsWithUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<NovelExtensionJsonObject>.toExtensions(): List<NovelExtension.Available> {
|
||||||
|
return mapNotNull { extension ->
|
||||||
|
val sources = extension.sources?.map { source ->
|
||||||
|
NovelExtensionSourceJsonObject(
|
||||||
|
source.id,
|
||||||
|
source.lang,
|
||||||
|
source.name,
|
||||||
|
source.baseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val iconUrl = "${REPO_URL_PREFIX}icons/${extension.pkg}.png"
|
||||||
|
NovelExtension.Available(
|
||||||
|
extension.name,
|
||||||
|
extension.pkg,
|
||||||
|
extension.apk,
|
||||||
|
extension.code,
|
||||||
|
sources?.toSources() ?: emptyList(),
|
||||||
|
iconUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<NovelExtensionSourceJsonObject>.toSources(): List<AvailableNovelSources> {
|
||||||
|
return map { source ->
|
||||||
|
AvailableNovelSources(
|
||||||
|
source.id,
|
||||||
|
source.lang,
|
||||||
|
source.name,
|
||||||
|
source.baseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getApkUrl(extension: NovelExtension.Available): String {
|
||||||
|
return "${getUrlPrefix()}apk/${extension.pkgName}.apk"
|
||||||
|
}
|
||||||
|
private fun getUrlPrefix(): String {
|
||||||
|
return if (requiresFallbackSource) {
|
||||||
|
FALLBACK_REPO_URL_PREFIX
|
||||||
|
} else {
|
||||||
|
REPO_URL_PREFIX
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/dannovels/novel-extensions/main/"
|
||||||
|
private const val FALLBACK_REPO_URL_PREFIX = "https://gcore.jsdelivr.net/gh/dannovels/novel-extensions@latest/"
|
||||||
|
@Serializable
|
||||||
|
private data class NovelExtensionJsonObject(
|
||||||
|
val name: String,
|
||||||
|
val pkg: String,
|
||||||
|
val apk: String,
|
||||||
|
val lang: String,
|
||||||
|
val code: Long,
|
||||||
|
val version: String,
|
||||||
|
val nsfw: Int,
|
||||||
|
val hasReadme: Int = 0,
|
||||||
|
val hasChangelog: Int = 0,
|
||||||
|
val sources: List<NovelExtensionSourceJsonObject>?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NovelExtensionSourceJsonObject(
|
||||||
|
val id: Long,
|
||||||
|
val lang: String,
|
||||||
|
val name: String,
|
||||||
|
val baseUrl: String,
|
||||||
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -0,0 +1,367 @@
|
||||||
|
package ani.dantotsu.parsers.novel
|
||||||
|
|
||||||
|
import android.app.DownloadManager
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import ani.dantotsu.snackString
|
||||||
|
import com.jakewharton.rxrelay.PublishRelay
|
||||||
|
import eu.kanade.tachiyomi.extension.InstallStep
|
||||||
|
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||||
|
import logcat.LogPriority
|
||||||
|
import rx.Observable
|
||||||
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
|
import tachiyomi.core.util.system.logcat
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.nio.channels.FileChannel
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The installer which installs, updates and uninstalls the extensions.
|
||||||
|
*
|
||||||
|
* @param context The application context.
|
||||||
|
*/
|
||||||
|
internal class NovelExtensionInstaller(private val context: Context) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The system's download manager
|
||||||
|
*/
|
||||||
|
private val downloadManager = context.getSystemService<DownloadManager>()!!
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The broadcast receiver which listens to download completion events.
|
||||||
|
*/
|
||||||
|
private val downloadReceiver = DownloadCompletionReceiver()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The currently requested downloads, with the package name (unique id) as key, and the id
|
||||||
|
* returned by the download manager.
|
||||||
|
*/
|
||||||
|
private val activeDownloads = hashMapOf<String, Long>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay used to notify the installation step of every download.
|
||||||
|
*/
|
||||||
|
private val downloadsRelay = PublishRelay.create<Pair<Long, InstallStep>>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the given extension to the downloads queue and returns an observable containing its
|
||||||
|
* step in the installation process.
|
||||||
|
*
|
||||||
|
* @param url The url of the apk.
|
||||||
|
* @param extension The extension to install.
|
||||||
|
*/
|
||||||
|
fun downloadAndInstall(url: String, extension: NovelExtension) = Observable.defer {
|
||||||
|
val pkgName = extension.pkgName
|
||||||
|
|
||||||
|
val oldDownload = activeDownloads[pkgName]
|
||||||
|
if (oldDownload != null) {
|
||||||
|
deleteDownload(pkgName)
|
||||||
|
}
|
||||||
|
|
||||||
|
val sourcePath = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
|
||||||
|
//if the file is already downloaded, remove it
|
||||||
|
val fileToDelete = File("$sourcePath/${url.toUri().lastPathSegment}")
|
||||||
|
if (fileToDelete.exists()) {
|
||||||
|
if (fileToDelete.delete()) {
|
||||||
|
Log.i("Install APK", "APK file deleted successfully.")
|
||||||
|
} else {
|
||||||
|
Log.e("Install APK", "Failed to delete APK file.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e("Install APK", "APK file not found.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the receiver after removing (and unregistering) the previous download
|
||||||
|
downloadReceiver.register()
|
||||||
|
|
||||||
|
val downloadUri = url.toUri()
|
||||||
|
val request = DownloadManager.Request(downloadUri)
|
||||||
|
.setTitle(extension.name)
|
||||||
|
.setMimeType(NovelExtensionInstaller.APK_MIME)
|
||||||
|
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadUri.lastPathSegment)
|
||||||
|
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||||
|
val id = downloadManager.enqueue(request)
|
||||||
|
activeDownloads[pkgName] = id
|
||||||
|
|
||||||
|
downloadsRelay.filter { it.first == id }
|
||||||
|
.map { it.second }
|
||||||
|
// Poll download status
|
||||||
|
.mergeWith(pollStatus(id))
|
||||||
|
// Stop when the application is installed or errors
|
||||||
|
.takeUntil { it.isCompleted() }
|
||||||
|
// Always notify on main thread
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
// Always remove the download when unsubscribed
|
||||||
|
.doOnUnsubscribe { deleteDownload(pkgName) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an observable that polls the given download id for its status every second, as the
|
||||||
|
* manager doesn't have any notification system. It'll stop once the download finishes.
|
||||||
|
*
|
||||||
|
* @param id The id of the download to poll.
|
||||||
|
*/
|
||||||
|
private fun pollStatus(id: Long): Observable<InstallStep> {
|
||||||
|
val query = DownloadManager.Query().setFilterById(id)
|
||||||
|
|
||||||
|
return Observable.interval(0, 1, TimeUnit.SECONDS)
|
||||||
|
// Get the current download status
|
||||||
|
.map {
|
||||||
|
downloadManager.query(query).use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||||
|
} else {
|
||||||
|
DownloadManager.STATUS_FAILED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ignore duplicate results
|
||||||
|
.distinctUntilChanged()
|
||||||
|
// Stop polling when the download fails or finishes
|
||||||
|
.takeUntil { it == DownloadManager.STATUS_SUCCESSFUL || it == DownloadManager.STATUS_FAILED }
|
||||||
|
// Map to our model
|
||||||
|
.flatMap { status ->
|
||||||
|
when (status) {
|
||||||
|
DownloadManager.STATUS_PENDING -> Observable.just(InstallStep.Pending)
|
||||||
|
DownloadManager.STATUS_RUNNING -> Observable.just(InstallStep.Downloading)
|
||||||
|
DownloadManager.STATUS_SUCCESSFUL -> Observable.just(InstallStep.Installing)
|
||||||
|
else -> Observable.empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun installApk(downloadId: Long, uri: Uri, context: Context, pkgName: String) : InstallStep {
|
||||||
|
val sourcePath = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.absolutePath + "/" + uri.lastPathSegment
|
||||||
|
val destinationPath = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/$pkgName.apk"
|
||||||
|
|
||||||
|
val destinationPathDirectory = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/"
|
||||||
|
val destinationPathDirectoryFile = File(destinationPathDirectory)
|
||||||
|
|
||||||
|
|
||||||
|
// Check if source path is obtained correctly
|
||||||
|
if (sourcePath == null) {
|
||||||
|
Log.e("Install APK", "Source APK path not found.")
|
||||||
|
downloadsRelay.call(downloadId to InstallStep.Error)
|
||||||
|
return InstallStep.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the destination directory if it doesn't exist
|
||||||
|
val destinationDir = File(destinationPath).parentFile
|
||||||
|
if (destinationDir?.exists() == false) {
|
||||||
|
destinationDir.mkdirs()
|
||||||
|
}
|
||||||
|
if(destinationDir?.setWritable(true) == false) {
|
||||||
|
Log.e("Install APK", "Failed to set destinationDir to writable.")
|
||||||
|
downloadsRelay.call(downloadId to InstallStep.Error)
|
||||||
|
return InstallStep.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the file to the new location
|
||||||
|
copyFileToInternalStorage(sourcePath, destinationPath)
|
||||||
|
Log.i("Install APK", "APK moved to $destinationPath")
|
||||||
|
downloadsRelay.call(downloadId to InstallStep.Installed)
|
||||||
|
return InstallStep.Installed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels extension install and remove from download manager and installer.
|
||||||
|
*/
|
||||||
|
fun cancelInstall(pkgName: String) {
|
||||||
|
val downloadId = activeDownloads.remove(pkgName) ?: return
|
||||||
|
downloadManager.remove(downloadId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun uninstallApk(pkgName: String, context: Context) {
|
||||||
|
val apkPath = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/$pkgName.apk"
|
||||||
|
val fileToDelete = File(apkPath)
|
||||||
|
//give write permission to the file
|
||||||
|
if (fileToDelete.exists() && !fileToDelete.canWrite()) {
|
||||||
|
Log.i("Uninstall APK", "File is not writable. Giving write permission.")
|
||||||
|
val a = fileToDelete.setWritable(true)
|
||||||
|
Log.i("Uninstall APK", "Success: $a")
|
||||||
|
}
|
||||||
|
//set the directory to writable
|
||||||
|
val destinationDir = File(apkPath).parentFile
|
||||||
|
if (destinationDir?.exists() == false) {
|
||||||
|
destinationDir.mkdirs()
|
||||||
|
}
|
||||||
|
val s = destinationDir?.setWritable(true)
|
||||||
|
Log.i("Uninstall APK", "Success destinationDir: $s")
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
try {
|
||||||
|
Files.delete(fileToDelete.toPath())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Uninstall APK", "Failed to delete APK file.")
|
||||||
|
Log.e("Uninstall APK", e.toString())
|
||||||
|
snackString("Failed to delete APK file.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (fileToDelete.exists()) {
|
||||||
|
if (fileToDelete.delete()) {
|
||||||
|
Log.i("Uninstall APK", "APK file deleted successfully.")
|
||||||
|
snackString("APK file deleted successfully.")
|
||||||
|
} else {
|
||||||
|
Log.e("Uninstall APK", "Failed to delete APK file.")
|
||||||
|
snackString("Failed to delete APK file.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e("Uninstall APK", "APK file not found.")
|
||||||
|
snackString("APK file not found.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyFileToInternalStorage(sourcePath: String, destinationPath: String) {
|
||||||
|
val source = File(sourcePath)
|
||||||
|
val destination = File(destinationPath)
|
||||||
|
destination.setWritable(true)
|
||||||
|
|
||||||
|
var inputChannel: FileChannel? = null
|
||||||
|
var outputChannel: FileChannel? = null
|
||||||
|
try {
|
||||||
|
inputChannel = FileInputStream(source).channel
|
||||||
|
outputChannel = FileOutputStream(destination).channel
|
||||||
|
inputChannel.transferTo(0, inputChannel.size(), outputChannel)
|
||||||
|
destination.setWritable(false)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
} finally {
|
||||||
|
inputChannel?.close()
|
||||||
|
outputChannel?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i("File Copy", "File copied to internal storage.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getRealPathFromURI(context: Context, contentUri: Uri): String? {
|
||||||
|
var cursor: Cursor? = null
|
||||||
|
try {
|
||||||
|
val proj = arrayOf(MediaStore.Images.Media.DATA)
|
||||||
|
cursor = context.contentResolver.query(contentUri, proj, null, null, null)
|
||||||
|
val columnIndex = cursor?.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
|
||||||
|
if (cursor != null && cursor.moveToFirst() && columnIndex != null) {
|
||||||
|
return cursor.getString(columnIndex)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cursor?.close()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the step of the installation of an extension.
|
||||||
|
*
|
||||||
|
* @param downloadId The id of the download.
|
||||||
|
* @param step New install step.
|
||||||
|
*/
|
||||||
|
fun updateInstallStep(downloadId: Long, step: InstallStep) {
|
||||||
|
downloadsRelay.call(downloadId to step)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the download for the given package name.
|
||||||
|
*
|
||||||
|
* @param pkgName The package name of the download to delete.
|
||||||
|
*/
|
||||||
|
private fun deleteDownload(pkgName: String) {
|
||||||
|
val downloadId = activeDownloads.remove(pkgName)
|
||||||
|
if (downloadId != null) {
|
||||||
|
downloadManager.remove(downloadId)
|
||||||
|
}
|
||||||
|
if (activeDownloads.isEmpty()) {
|
||||||
|
downloadReceiver.unregister()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receiver that listens to download status events.
|
||||||
|
*/
|
||||||
|
private inner class DownloadCompletionReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this receiver is currently registered.
|
||||||
|
*/
|
||||||
|
private var isRegistered = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers this receiver if it's not already.
|
||||||
|
*/
|
||||||
|
fun register() {
|
||||||
|
if (isRegistered) return
|
||||||
|
isRegistered = true
|
||||||
|
|
||||||
|
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
||||||
|
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_EXPORTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregisters this receiver if it's not already.
|
||||||
|
*/
|
||||||
|
fun unregister() {
|
||||||
|
if (!isRegistered) return
|
||||||
|
isRegistered = false
|
||||||
|
|
||||||
|
context.unregisterReceiver(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a download event is received. It looks for the download in the current active
|
||||||
|
* downloads and notifies its installation step.
|
||||||
|
*/
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0) ?: return
|
||||||
|
|
||||||
|
// Avoid events for downloads we didn't request
|
||||||
|
if (id !in activeDownloads.values) return
|
||||||
|
|
||||||
|
val uri = downloadManager.getUriForDownloadedFile(id)
|
||||||
|
|
||||||
|
// Set next installation step
|
||||||
|
if (uri == null) {
|
||||||
|
logcat(LogPriority.ERROR) { "Couldn't locate downloaded APK" }
|
||||||
|
downloadsRelay.call(id to InstallStep.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val query = DownloadManager.Query().setFilterById(id)
|
||||||
|
downloadManager.query(query).use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val localUri = cursor.getString(
|
||||||
|
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI),
|
||||||
|
).removePrefix(FILE_SCHEME)
|
||||||
|
val pkgName = extractPkgNameFromUri(localUri)
|
||||||
|
installApk(id, File(localUri).getUriCompat(context), context, pkgName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractPkgNameFromUri(localUri: String): String {
|
||||||
|
val uri = Uri.parse(localUri)
|
||||||
|
val path = uri.path
|
||||||
|
val pkgName = path?.substring(path.lastIndexOf('/') + 1)?.removeSuffix(".apk")
|
||||||
|
Log.i("Install APK", "Package name: $pkgName")
|
||||||
|
return pkgName ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val APK_MIME = "application/vnd.android.package-archive"
|
||||||
|
const val EXTRA_DOWNLOAD_ID = "NovelExtensionInstaller.extra.DOWNLOAD_ID"
|
||||||
|
const val FILE_SCHEME = "file://"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,129 @@
|
||||||
|
package ani.dantotsu.parsers.novel
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.util.Log
|
||||||
|
import ani.dantotsu.logger
|
||||||
|
import ani.dantotsu.parsers.NovelInterface
|
||||||
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
|
import dalvik.system.PathClassLoader
|
||||||
|
import eu.kanade.tachiyomi.extension.anime.util.AnimeExtensionLoader
|
||||||
|
import eu.kanade.tachiyomi.util.lang.Hash
|
||||||
|
import tachiyomi.core.util.system.logcat
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
internal object NovelExtensionLoader {
|
||||||
|
|
||||||
|
private const val officialSignature =
|
||||||
|
"a3061edb369278749b8e8de810d440d38e96417bbd67bbdfc5d9d9ed475ce4a5" //dan's key
|
||||||
|
|
||||||
|
fun loadExtensions(context: Context): List<NovelLoadResult> {
|
||||||
|
val installDir = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/"
|
||||||
|
val results = mutableListOf<NovelLoadResult>()
|
||||||
|
//the number of files
|
||||||
|
Log.e("NovelExtensionLoader", "Loading extensions from $installDir")
|
||||||
|
Log.e("NovelExtensionLoader", "Loading extensions from ${File(installDir).listFiles()?.size}")
|
||||||
|
File(installDir).setWritable(false)
|
||||||
|
File(installDir).listFiles()?.forEach {
|
||||||
|
//set the file to read only
|
||||||
|
it.setWritable(false)
|
||||||
|
Log.e("NovelExtensionLoader", "Loading extension ${it.name}")
|
||||||
|
val extension = loadExtension(context, it)
|
||||||
|
if (extension is NovelLoadResult.Success) {
|
||||||
|
results.add(extension)
|
||||||
|
} else {
|
||||||
|
logger("Failed to load extension ${it.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to load an extension from the given package name. It checks if the extension
|
||||||
|
* contains the required feature flag before trying to load it.
|
||||||
|
*/
|
||||||
|
fun loadExtensionFromPkgName(context: Context, pkgName: String): NovelLoadResult {
|
||||||
|
val path = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/$pkgName.apk"
|
||||||
|
//make /extensions/novel read only
|
||||||
|
context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/".let {
|
||||||
|
File(it).setWritable(false)
|
||||||
|
File(it).setReadable(true)
|
||||||
|
}
|
||||||
|
val pkgInfo = try {
|
||||||
|
context.packageManager.getPackageArchiveInfo(path, 0)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
// Unlikely, but the package may have been uninstalled at this point
|
||||||
|
logger("Failed to load extension $pkgName")
|
||||||
|
return NovelLoadResult.Error(Exception("Failed to load extension"))
|
||||||
|
}
|
||||||
|
return loadExtension(context, File(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadExtension(context: Context, file: File): NovelLoadResult {
|
||||||
|
val packageInfo = context.packageManager.getPackageArchiveInfo(file.absolutePath, 0)
|
||||||
|
?: return NovelLoadResult.Error(Exception("Failed to load extension"))
|
||||||
|
val appInfo = packageInfo.applicationInfo
|
||||||
|
?: return NovelLoadResult.Error(Exception("Failed to load Extension Info"))
|
||||||
|
appInfo.sourceDir = file.absolutePath;
|
||||||
|
appInfo.publicSourceDir = file.absolutePath;
|
||||||
|
|
||||||
|
val signatureHash = getSignatureHash(packageInfo)
|
||||||
|
|
||||||
|
if (signatureHash == null || signatureHash != officialSignature) {
|
||||||
|
logger("Package ${packageInfo.packageName} isn't signed")
|
||||||
|
logger("signatureHash: $signatureHash")
|
||||||
|
//return NovelLoadResult.Error(Exception("Extension not signed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val extension = NovelExtension.Installed(
|
||||||
|
packageInfo.applicationInfo?.loadLabel(context.packageManager)?.toString() ?:
|
||||||
|
return NovelLoadResult.Error(Exception("Failed to load Extension Info")),
|
||||||
|
packageInfo.packageName
|
||||||
|
?: return NovelLoadResult.Error(Exception("Failed to load Extension Info")),
|
||||||
|
packageInfo.versionName ?: "",
|
||||||
|
packageInfo.versionCode.toLong() ?: 0,
|
||||||
|
loadSources(context, file,
|
||||||
|
packageInfo.applicationInfo?.loadLabel(context.packageManager)?.toString()!!
|
||||||
|
),
|
||||||
|
packageInfo.applicationInfo?.loadIcon(context.packageManager)
|
||||||
|
)
|
||||||
|
|
||||||
|
return NovelLoadResult.Success(extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSignatureHash(pkgInfo: PackageInfo): String? {
|
||||||
|
val signatures = pkgInfo.signatures
|
||||||
|
return if (signatures != null && signatures.isNotEmpty()) {
|
||||||
|
Hash.sha256(signatures.first().toByteArray())
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSources(context: Context, file: File, className: String): List<NovelInterface> {
|
||||||
|
return try {
|
||||||
|
Log.e("NovelExtensionLoader", "isFileWritable: ${file.canWrite()}")
|
||||||
|
if (file.canWrite()) {
|
||||||
|
val a = file.setWritable(false)
|
||||||
|
Log.e("NovelExtensionLoader", "success: $a")
|
||||||
|
}
|
||||||
|
Log.e("NovelExtensionLoader", "isFileWritable: ${file.canWrite()}")
|
||||||
|
val classLoader = PathClassLoader(file.absolutePath, null, context.classLoader)
|
||||||
|
val className = "some.random.novelextensions.${className.lowercase(Locale.getDefault())}.$className"
|
||||||
|
val loadedClass = classLoader.loadClass(className)
|
||||||
|
val instance = loadedClass.newInstance()
|
||||||
|
val novelInterfaceInstance = instance as? NovelInterface
|
||||||
|
listOfNotNull(novelInterfaceInstance)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
FirebaseCrashlytics.getInstance().recordException(e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class NovelLoadResult {
|
||||||
|
data class Success(val extension: NovelExtension.Installed) : NovelLoadResult()
|
||||||
|
data class Error(val error: Exception) : NovelLoadResult()
|
||||||
|
}
|
|
@ -0,0 +1,243 @@
|
||||||
|
package ani.dantotsu.parsers.novel
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.os.Build
|
||||||
|
import ani.dantotsu.logger
|
||||||
|
import ani.dantotsu.snackString
|
||||||
|
import eu.kanade.tachiyomi.extension.InstallStep
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import rx.Observable
|
||||||
|
import tachiyomi.core.util.lang.withUIContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class NovelExtensionManager(private val context: Context) {
|
||||||
|
var isInitialized = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API where all the available Novel extensions can be found.
|
||||||
|
*/
|
||||||
|
private val api = NovelExtensionGithubApi()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The installer which installs, updates and uninstalls the Novel extensions.
|
||||||
|
*/
|
||||||
|
private val installer by lazy { NovelExtensionInstaller(context) }
|
||||||
|
|
||||||
|
private val iconMap = mutableMapOf<String, Drawable>()
|
||||||
|
|
||||||
|
private val _installedNovelExtensionsFlow =
|
||||||
|
MutableStateFlow(emptyList<NovelExtension.Installed>())
|
||||||
|
val installedExtensionsFlow = _installedNovelExtensionsFlow.asStateFlow()
|
||||||
|
|
||||||
|
private val _availableNovelExtensionsFlow =
|
||||||
|
MutableStateFlow(emptyList<NovelExtension.Available>())
|
||||||
|
val availableExtensionsFlow = _availableNovelExtensionsFlow.asStateFlow()
|
||||||
|
|
||||||
|
private var availableNovelExtensionsSourcesData: Map<Long, NovelSourceData> = emptyMap()
|
||||||
|
|
||||||
|
private fun setupAvailableNovelExtensionsSourcesDataMap(novelExtensions: List<NovelExtension.Available>) {
|
||||||
|
if (novelExtensions.isEmpty()) return
|
||||||
|
availableNovelExtensionsSourcesData = novelExtensions
|
||||||
|
.flatMap { ext -> ext.sources.map { it.toNovelSourceData() } }
|
||||||
|
.associateBy { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSourceData(id: Long) = availableNovelExtensionsSourcesData[id]
|
||||||
|
|
||||||
|
init {
|
||||||
|
initNovelExtensions()
|
||||||
|
val path = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/"
|
||||||
|
NovelExtensionFileObserver(NovelInstallationListener(),path).register()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initNovelExtensions() {
|
||||||
|
val novelExtensions = NovelExtensionLoader.loadExtensions(context)
|
||||||
|
|
||||||
|
_installedNovelExtensionsFlow.value = novelExtensions
|
||||||
|
.filterIsInstance<NovelLoadResult.Success>()
|
||||||
|
.map { it.extension }
|
||||||
|
|
||||||
|
isInitialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the available manga extensions in the [api] and updates [availableExtensions].
|
||||||
|
*/
|
||||||
|
suspend fun findAvailableExtensions() {
|
||||||
|
val extensions: List<NovelExtension.Available> = try {
|
||||||
|
api.findExtensions()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger("Error finding extensions: ${e.message}")
|
||||||
|
withUIContext { snackString("Failed to get Novel extensions list") }
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
_availableNovelExtensionsFlow.value = extensions
|
||||||
|
updatedInstalledNovelExtensionsStatuses(extensions)
|
||||||
|
setupAvailableNovelExtensionsSourcesDataMap(extensions)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatedInstalledNovelExtensionsStatuses(availableNovelExtensions: List<NovelExtension.Available>) {
|
||||||
|
if (availableNovelExtensions.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val mutInstalledNovelExtensions = _installedNovelExtensionsFlow.value.toMutableList()
|
||||||
|
var hasChanges = false
|
||||||
|
|
||||||
|
for ((index, installedExt) in mutInstalledNovelExtensions.withIndex()) {
|
||||||
|
val pkgName = installedExt.pkgName
|
||||||
|
val availableExt = availableNovelExtensions.find { it.pkgName == pkgName }
|
||||||
|
|
||||||
|
if (availableExt == null && !installedExt.isObsolete) {
|
||||||
|
mutInstalledNovelExtensions[index] = installedExt.copy(isObsolete = true)
|
||||||
|
hasChanges = true
|
||||||
|
} else if (availableExt != null) {
|
||||||
|
val hasUpdate = installedExt.updateExists(availableExt)
|
||||||
|
|
||||||
|
if (installedExt.hasUpdate != hasUpdate) {
|
||||||
|
mutInstalledNovelExtensions[index] = installedExt.copy(hasUpdate = hasUpdate)
|
||||||
|
hasChanges = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasChanges) {
|
||||||
|
_installedNovelExtensionsFlow.value = mutInstalledNovelExtensions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an observable of the installation process for the given novel extension. It will complete
|
||||||
|
* once the novel extension is installed or throws an error. The process will be canceled if
|
||||||
|
* unsubscribed before its completion.
|
||||||
|
*
|
||||||
|
* @param extension The anime extension to be installed.
|
||||||
|
*/
|
||||||
|
fun installExtension(extension: NovelExtension.Available): Observable<InstallStep> {
|
||||||
|
return installer.downloadAndInstall(api.getApkUrl(extension), extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an observable of the installation process for the given anime extension. It will complete
|
||||||
|
* once the anime extension is updated or throws an error. The process will be canceled if
|
||||||
|
* unsubscribed before its completion.
|
||||||
|
*
|
||||||
|
* @param extension The anime extension to be updated.
|
||||||
|
*/
|
||||||
|
fun updateExtension(extension: NovelExtension.Installed): Observable<InstallStep> {
|
||||||
|
val availableExt = _availableNovelExtensionsFlow.value.find { it.pkgName == extension.pkgName }
|
||||||
|
?: return Observable.empty()
|
||||||
|
return installExtension(availableExt)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelInstallUpdateExtension(extension: NovelExtension) {
|
||||||
|
installer.cancelInstall(extension.pkgName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets to "installing" status of an novel extension installation.
|
||||||
|
*
|
||||||
|
* @param downloadId The id of the download.
|
||||||
|
*/
|
||||||
|
fun setInstalling(downloadId: Long) {
|
||||||
|
installer.updateInstallStep(downloadId, InstallStep.Installing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateInstallStep(downloadId: Long, step: InstallStep) {
|
||||||
|
installer.updateInstallStep(downloadId, step)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uninstalls the novel extension that matches the given package name.
|
||||||
|
*
|
||||||
|
* @param pkgName The package name of the application to uninstall.
|
||||||
|
*/
|
||||||
|
fun uninstallExtension(pkgName: String, context: Context) {
|
||||||
|
installer.uninstallApk(pkgName, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the given novel extension in this and the source managers.
|
||||||
|
*
|
||||||
|
* @param extension The anime extension to be registered.
|
||||||
|
*/
|
||||||
|
private fun registerNewExtension(extension: NovelExtension.Installed) {
|
||||||
|
_installedNovelExtensionsFlow.value += extension
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the given updated novel extension in this and the source managers previously removing
|
||||||
|
* the outdated ones.
|
||||||
|
*
|
||||||
|
* @param extension The anime extension to be registered.
|
||||||
|
*/
|
||||||
|
private fun registerUpdatedExtension(extension: NovelExtension.Installed) {
|
||||||
|
val mutInstalledNovelExtensions = _installedNovelExtensionsFlow.value.toMutableList()
|
||||||
|
val oldNovelExtension = mutInstalledNovelExtensions.find { it.pkgName == extension.pkgName }
|
||||||
|
if (oldNovelExtension != null) {
|
||||||
|
mutInstalledNovelExtensions -= oldNovelExtension
|
||||||
|
}
|
||||||
|
mutInstalledNovelExtensions += extension
|
||||||
|
_installedNovelExtensionsFlow.value = mutInstalledNovelExtensions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregisters the novel extension in this and the source managers given its package name. Note this
|
||||||
|
* method is called for every uninstalled application in the system.
|
||||||
|
*
|
||||||
|
* @param pkgName The package name of the uninstalled application.
|
||||||
|
*/
|
||||||
|
private fun unregisterNovelExtension(pkgName: String) {
|
||||||
|
val installedNovelExtension = _installedNovelExtensionsFlow.value.find { it.pkgName == pkgName }
|
||||||
|
if (installedNovelExtension != null) {
|
||||||
|
_installedNovelExtensionsFlow.value -= installedNovelExtension
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listener which receives events of the novel extensions being installed, updated or removed.
|
||||||
|
*/
|
||||||
|
private inner class NovelInstallationListener : NovelExtensionFileObserver.Listener {
|
||||||
|
|
||||||
|
override fun onExtensionFileCreated(file: File) {
|
||||||
|
NovelExtensionLoader.loadExtension(context, file).let {
|
||||||
|
if (it is NovelLoadResult.Success) {
|
||||||
|
registerNewExtension(it.extension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onExtensionFileDeleted(file: File) {
|
||||||
|
val pkgName = file.nameWithoutExtension
|
||||||
|
unregisterNovelExtension(pkgName)
|
||||||
|
}
|
||||||
|
override fun onExtensionFileModified(file: File) {
|
||||||
|
NovelExtensionLoader.loadExtension(context, file).let {
|
||||||
|
if (it is NovelLoadResult.Success) {
|
||||||
|
registerUpdatedExtension(it.extension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AnimeExtension method to set the update field of an installed anime extension.
|
||||||
|
*/
|
||||||
|
private fun NovelExtension.Installed.withUpdateCheck(): NovelExtension.Installed {
|
||||||
|
return if (updateExists()) {
|
||||||
|
copy(hasUpdate = true)
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun NovelExtension.Installed.updateExists(availableNovelExtension: NovelExtension.Available? = null): Boolean {
|
||||||
|
val availableExt = availableNovelExtension ?: _availableNovelExtensionsFlow.value.find { it.pkgName == pkgName }
|
||||||
|
if (isUnofficial || availableExt == null) return false
|
||||||
|
|
||||||
|
return (availableExt.versionCode > versionCode)
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,6 +17,7 @@ import ani.dantotsu.settings.paging.AnimeExtensionAdapter
|
||||||
import ani.dantotsu.settings.paging.AnimeExtensionsViewModel
|
import ani.dantotsu.settings.paging.AnimeExtensionsViewModel
|
||||||
import ani.dantotsu.settings.paging.AnimeExtensionsViewModelFactory
|
import ani.dantotsu.settings.paging.AnimeExtensionsViewModelFactory
|
||||||
import ani.dantotsu.settings.paging.OnAnimeInstallClickListener
|
import ani.dantotsu.settings.paging.OnAnimeInstallClickListener
|
||||||
|
import ani.dantotsu.snackString
|
||||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||||
import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager
|
import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager
|
||||||
|
@ -101,6 +102,7 @@ class AnimeExtensionsFragment : Fragment(),
|
||||||
.setContentText("Error: ${error.message}")
|
.setContentText("Error: ${error.message}")
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
notificationManager.notify(1, builder.build())
|
notificationManager.notify(1, builder.build())
|
||||||
|
snackString("Installation failed: ${error.message}")
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
val builder = NotificationCompat.Builder(
|
val builder = NotificationCompat.Builder(
|
||||||
|
@ -113,6 +115,7 @@ class AnimeExtensionsFragment : Fragment(),
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
notificationManager.notify(1, builder.build())
|
notificationManager.notify(1, builder.build())
|
||||||
viewModel.invalidatePager()
|
viewModel.invalidatePager()
|
||||||
|
snackString("Extension installed")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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("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("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("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 {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
|
|
@ -40,7 +40,7 @@ class ExtensionsActivity : AppCompatActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
LangSet.setLocale(this)
|
LangSet.setLocale(this)
|
||||||
ThemeManager(this).applyTheme()
|
ThemeManager(this).applyTheme()
|
||||||
binding = ActivityExtensionsBinding.inflate(layoutInflater)
|
binding = ActivityExtensionsBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
|
||||||
|
@ -49,7 +49,7 @@ ThemeManager(this).applyTheme()
|
||||||
val viewPager = findViewById<ViewPager2>(R.id.viewPager)
|
val viewPager = findViewById<ViewPager2>(R.id.viewPager)
|
||||||
|
|
||||||
viewPager.adapter = object : FragmentStateAdapter(this) {
|
viewPager.adapter = object : FragmentStateAdapter(this) {
|
||||||
override fun getItemCount(): Int = 4
|
override fun getItemCount(): Int = 6
|
||||||
|
|
||||||
override fun createFragment(position: Int): Fragment {
|
override fun createFragment(position: Int): Fragment {
|
||||||
return when (position) {
|
return when (position) {
|
||||||
|
@ -57,24 +57,45 @@ ThemeManager(this).applyTheme()
|
||||||
1 -> AnimeExtensionsFragment()
|
1 -> AnimeExtensionsFragment()
|
||||||
2 -> InstalledMangaExtensionsFragment()
|
2 -> InstalledMangaExtensionsFragment()
|
||||||
3 -> MangaExtensionsFragment()
|
3 -> MangaExtensionsFragment()
|
||||||
|
4 -> InstalledNovelExtensionsFragment()
|
||||||
|
5 -> NovelExtensionsFragment()
|
||||||
else -> AnimeExtensionsFragment()
|
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 ->
|
TabLayoutMediator(tabLayout, viewPager) { tab, position ->
|
||||||
tab.text = when (position) {
|
tab.text = when (position) {
|
||||||
0 -> "Installed Anime"
|
0 -> "Installed Anime"
|
||||||
1 -> "Available Anime"
|
1 -> "Available Anime"
|
||||||
2 -> "Installed Manga"
|
2 -> "Installed Manga"
|
||||||
3 -> "Available Manga"
|
3 -> "Available Manga"
|
||||||
|
4 -> "Installed Novels"
|
||||||
|
5 -> "Available Novels"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}.attach()
|
}.attach()
|
||||||
|
|
||||||
|
|
||||||
val searchView: AutoCompleteTextView = findViewById(R.id.searchViewText)
|
|
||||||
|
|
||||||
searchView.addTextChangedListener(object : TextWatcher {
|
searchView.addTextChangedListener(object : TextWatcher {
|
||||||
override fun afterTextChanged(s: Editable?) {
|
override fun afterTextChanged(s: Editable?) {
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,7 @@ import ani.dantotsu.loadData
|
||||||
import ani.dantotsu.others.LanguageMapper
|
import ani.dantotsu.others.LanguageMapper
|
||||||
import ani.dantotsu.saveData
|
import ani.dantotsu.saveData
|
||||||
import ani.dantotsu.settings.extensionprefs.AnimeSourcePreferencesFragment
|
import ani.dantotsu.settings.extensionprefs.AnimeSourcePreferencesFragment
|
||||||
|
import ani.dantotsu.snackString
|
||||||
import com.google.android.material.tabs.TabLayout
|
import com.google.android.material.tabs.TabLayout
|
||||||
import com.google.android.material.textfield.TextInputLayout
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
|
@ -157,6 +158,7 @@ class InstalledAnimeExtensionsFragment : Fragment() {
|
||||||
.setContentText("Error: ${error.message}")
|
.setContentText("Error: ${error.message}")
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
notificationManager.notify(1, builder.build())
|
notificationManager.notify(1, builder.build())
|
||||||
|
snackString("Update failed: ${error.message}")
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
val builder = NotificationCompat.Builder(
|
val builder = NotificationCompat.Builder(
|
||||||
|
@ -168,10 +170,12 @@ class InstalledAnimeExtensionsFragment : Fragment() {
|
||||||
.setContentText("The extension has been successfully updated.")
|
.setContentText("The extension has been successfully updated.")
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
notificationManager.notify(1, builder.build())
|
notificationManager.notify(1, builder.build())
|
||||||
|
snackString("Extension updated")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
animeExtensionManager.uninstallExtension(pkg.pkgName)
|
animeExtensionManager.uninstallExtension(pkg.pkgName)
|
||||||
|
snackString("Extension uninstalled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, skipIcons
|
}, skipIcons
|
||||||
|
|
|
@ -26,6 +26,7 @@ import ani.dantotsu.databinding.FragmentMangaExtensionsBinding
|
||||||
import ani.dantotsu.loadData
|
import ani.dantotsu.loadData
|
||||||
import ani.dantotsu.settings.extensionprefs.MangaSourcePreferencesFragment
|
import ani.dantotsu.settings.extensionprefs.MangaSourcePreferencesFragment
|
||||||
import ani.dantotsu.others.LanguageMapper
|
import ani.dantotsu.others.LanguageMapper
|
||||||
|
import ani.dantotsu.snackString
|
||||||
import com.google.android.material.tabs.TabLayout
|
import com.google.android.material.tabs.TabLayout
|
||||||
import com.google.android.material.textfield.TextInputLayout
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
|
@ -137,6 +138,7 @@ class InstalledMangaExtensionsFragment : Fragment() {
|
||||||
.setContentText("Error: ${error.message}")
|
.setContentText("Error: ${error.message}")
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
notificationManager.notify(1, builder.build())
|
notificationManager.notify(1, builder.build())
|
||||||
|
snackString("Update failed: ${error.message}")
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
val builder = NotificationCompat.Builder(
|
val builder = NotificationCompat.Builder(
|
||||||
|
@ -148,10 +150,12 @@ class InstalledMangaExtensionsFragment : Fragment() {
|
||||||
.setContentText("The extension has been successfully updated.")
|
.setContentText("The extension has been successfully updated.")
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
notificationManager.notify(1, builder.build())
|
notificationManager.notify(1, builder.build())
|
||||||
|
snackString("Extension updated")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
mangaExtensionManager.uninstallExtension(pkg.pkgName)
|
mangaExtensionManager.uninstallExtension(pkg.pkgName)
|
||||||
|
snackString("Extension uninstalled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, skipIcons)
|
}, skipIcons)
|
||||||
|
|
|
@ -0,0 +1,211 @@
|
||||||
|
package ani.dantotsu.settings
|
||||||
|
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
|
import ani.dantotsu.R
|
||||||
|
import ani.dantotsu.currContext
|
||||||
|
import ani.dantotsu.databinding.FragmentMangaExtensionsBinding
|
||||||
|
import ani.dantotsu.databinding.FragmentNovelExtensionsBinding
|
||||||
|
import ani.dantotsu.loadData
|
||||||
|
import ani.dantotsu.others.LanguageMapper
|
||||||
|
import ani.dantotsu.parsers.novel.NovelExtension
|
||||||
|
import ani.dantotsu.parsers.novel.NovelExtensionManager
|
||||||
|
import ani.dantotsu.snackString
|
||||||
|
import com.google.android.material.tabs.TabLayout
|
||||||
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
|
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
class InstalledNovelExtensionsFragment : Fragment() {
|
||||||
|
private var _binding: FragmentNovelExtensionsBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private lateinit var extensionsRecyclerView: RecyclerView
|
||||||
|
val skipIcons = loadData("skip_extension_icons") ?: false
|
||||||
|
private val novelExtensionManager: NovelExtensionManager = Injekt.get()
|
||||||
|
private val extensionsAdapter = NovelExtensionsAdapter({ pkg ->
|
||||||
|
Toast.makeText(requireContext(), "Source is not configurable", Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
},
|
||||||
|
{ pkg ->
|
||||||
|
if (isAdded) { // Check if the fragment is currently added to its activity
|
||||||
|
val context = requireContext() // Store context in a variable
|
||||||
|
val notificationManager =
|
||||||
|
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager // Initialize NotificationManager once
|
||||||
|
|
||||||
|
if (pkg.hasUpdate) {
|
||||||
|
novelExtensionManager.updateExtension(pkg)
|
||||||
|
.observeOn(AndroidSchedulers.mainThread()) // Observe on main thread
|
||||||
|
.subscribe(
|
||||||
|
{ installStep ->
|
||||||
|
val builder = NotificationCompat.Builder(
|
||||||
|
context,
|
||||||
|
Notifications.CHANNEL_DOWNLOADER_PROGRESS
|
||||||
|
)
|
||||||
|
.setSmallIcon(R.drawable.ic_round_sync_24)
|
||||||
|
.setContentTitle("Updating extension")
|
||||||
|
.setContentText("Step: $installStep")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
notificationManager.notify(1, builder.build())
|
||||||
|
},
|
||||||
|
{ error ->
|
||||||
|
FirebaseCrashlytics.getInstance().recordException(error)
|
||||||
|
Log.e("NovelExtensionsAdapter", "Error: ", error) // Log the error
|
||||||
|
val builder = NotificationCompat.Builder(
|
||||||
|
context,
|
||||||
|
Notifications.CHANNEL_DOWNLOADER_ERROR
|
||||||
|
)
|
||||||
|
.setSmallIcon(R.drawable.ic_round_info_24)
|
||||||
|
.setContentTitle("Update failed: ${error.message}")
|
||||||
|
.setContentText("Error: ${error.message}")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
notificationManager.notify(1, builder.build())
|
||||||
|
snackString("Update failed: ${error.message}")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
val builder = NotificationCompat.Builder(
|
||||||
|
context,
|
||||||
|
Notifications.CHANNEL_DOWNLOADER_PROGRESS
|
||||||
|
)
|
||||||
|
.setSmallIcon(androidx.media3.ui.R.drawable.exo_ic_check)
|
||||||
|
.setContentTitle("Update complete")
|
||||||
|
.setContentText("The extension has been successfully updated.")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
notificationManager.notify(1, builder.build())
|
||||||
|
snackString("Extension updated")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
novelExtensionManager.uninstallExtension(pkg.pkgName, currContext()?:context)
|
||||||
|
snackString("Extension uninstalled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, skipIcons)
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentNovelExtensionsBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
|
extensionsRecyclerView = binding.allNovelExtensionsRecyclerView
|
||||||
|
extensionsRecyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
extensionsRecyclerView.adapter = extensionsAdapter
|
||||||
|
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
novelExtensionManager.installedExtensionsFlow.collect { extensions ->
|
||||||
|
extensionsAdapter.updateData(extensions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val extensionsRecyclerView: RecyclerView = binding.allNovelExtensionsRecyclerView
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView();_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private class NovelExtensionsAdapter(
|
||||||
|
private val onSettingsClicked: (NovelExtension.Installed) -> Unit,
|
||||||
|
private val onUninstallClicked: (NovelExtension.Installed) -> Unit,
|
||||||
|
skipIcons: Boolean
|
||||||
|
) : ListAdapter<NovelExtension.Installed, NovelExtensionsAdapter.ViewHolder>(
|
||||||
|
DIFF_CALLBACK_INSTALLED
|
||||||
|
) {
|
||||||
|
|
||||||
|
val skipIcons = skipIcons
|
||||||
|
|
||||||
|
fun updateData(newExtensions: List<NovelExtension.Installed>) {
|
||||||
|
Log.d("NovelExtensionsAdapter", "updateData: $newExtensions")
|
||||||
|
submitList(newExtensions) // Use submitList instead of manual list handling
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
val view = LayoutInflater.from(parent.context)
|
||||||
|
.inflate(R.layout.item_extension, parent, false)
|
||||||
|
Log.d("NovelExtensionsAdapter", "onCreateViewHolder: $view")
|
||||||
|
return ViewHolder(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
|
val extension = getItem(position) // Use getItem() from ListAdapter
|
||||||
|
val nsfw = ""
|
||||||
|
val lang = LanguageMapper.mapLanguageCodeToName("all")
|
||||||
|
holder.extensionNameTextView.text = extension.name
|
||||||
|
holder.extensionVersionTextView.text = "$lang ${extension.versionName} $nsfw"
|
||||||
|
if (!skipIcons) {
|
||||||
|
holder.extensionIconImageView.setImageDrawable(extension.icon)
|
||||||
|
}
|
||||||
|
if (extension.hasUpdate) {
|
||||||
|
holder.closeTextView.setImageResource(R.drawable.ic_round_sync_24)
|
||||||
|
} else {
|
||||||
|
holder.closeTextView.setImageResource(R.drawable.ic_round_delete_24)
|
||||||
|
}
|
||||||
|
holder.closeTextView.setOnClickListener {
|
||||||
|
onUninstallClicked(extension)
|
||||||
|
}
|
||||||
|
holder.settingsImageView.setOnClickListener {
|
||||||
|
onSettingsClicked(extension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
|
val extensionNameTextView: TextView = view.findViewById(R.id.extensionNameTextView)
|
||||||
|
val extensionVersionTextView: TextView = view.findViewById(R.id.extensionVersionTextView)
|
||||||
|
val settingsImageView: ImageView = view.findViewById(R.id.settingsImageView)
|
||||||
|
val extensionIconImageView: ImageView = view.findViewById(R.id.extensionIconImageView)
|
||||||
|
val closeTextView: ImageView = view.findViewById(R.id.closeTextView)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val DIFF_CALLBACK_INSTALLED =
|
||||||
|
object : DiffUtil.ItemCallback<NovelExtension.Installed>() {
|
||||||
|
override fun areItemsTheSame(
|
||||||
|
oldItem: NovelExtension.Installed,
|
||||||
|
newItem: NovelExtension.Installed
|
||||||
|
): Boolean {
|
||||||
|
return oldItem.pkgName == newItem.pkgName
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(
|
||||||
|
oldItem: NovelExtension.Installed,
|
||||||
|
newItem: NovelExtension.Installed
|
||||||
|
): Boolean {
|
||||||
|
return oldItem == newItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -26,6 +26,7 @@ import ani.dantotsu.settings.paging.MangaExtensionAdapter
|
||||||
import ani.dantotsu.settings.paging.MangaExtensionsViewModel
|
import ani.dantotsu.settings.paging.MangaExtensionsViewModel
|
||||||
import ani.dantotsu.settings.paging.MangaExtensionsViewModelFactory
|
import ani.dantotsu.settings.paging.MangaExtensionsViewModelFactory
|
||||||
import ani.dantotsu.settings.paging.OnMangaInstallClickListener
|
import ani.dantotsu.settings.paging.OnMangaInstallClickListener
|
||||||
|
import ani.dantotsu.snackString
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
|
||||||
|
@ -104,6 +105,7 @@ class MangaExtensionsFragment : Fragment(),
|
||||||
.setContentText("Error: ${error.message}")
|
.setContentText("Error: ${error.message}")
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
notificationManager.notify(1, builder.build())
|
notificationManager.notify(1, builder.build())
|
||||||
|
snackString("Installation failed: ${error.message}")
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
val builder = NotificationCompat.Builder(
|
val builder = NotificationCompat.Builder(
|
||||||
|
@ -116,6 +118,7 @@ class MangaExtensionsFragment : Fragment(),
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
notificationManager.notify(1, builder.build())
|
notificationManager.notify(1, builder.build())
|
||||||
viewModel.invalidatePager()
|
viewModel.invalidatePager()
|
||||||
|
snackString("Extension installed")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,174 @@
|
||||||
|
package ani.dantotsu.settings.paging
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.paging.Pager
|
||||||
|
import androidx.paging.PagingConfig
|
||||||
|
import androidx.paging.PagingData
|
||||||
|
import androidx.paging.PagingDataAdapter
|
||||||
|
import androidx.paging.PagingSource
|
||||||
|
import androidx.paging.PagingState
|
||||||
|
import androidx.paging.cachedIn
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import ani.dantotsu.databinding.ItemExtensionAllBinding
|
||||||
|
import ani.dantotsu.loadData
|
||||||
|
import ani.dantotsu.others.LanguageMapper
|
||||||
|
import ani.dantotsu.parsers.novel.NovelExtension
|
||||||
|
import ani.dantotsu.parsers.novel.NovelExtensionManager
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
|
||||||
|
|
||||||
|
class NovelExtensionsViewModelFactory(
|
||||||
|
private val novelExtensionManager: NovelExtensionManager
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
return NovelExtensionsViewModel(novelExtensionManager) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NovelExtensionsViewModel(
|
||||||
|
private val novelExtensionManager: NovelExtensionManager
|
||||||
|
) : ViewModel() {
|
||||||
|
private val searchQuery = MutableStateFlow("")
|
||||||
|
private var currentPagingSource: NovelExtensionPagingSource? = null
|
||||||
|
|
||||||
|
fun setSearchQuery(query: String) {
|
||||||
|
searchQuery.value = query
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invalidatePager() {
|
||||||
|
currentPagingSource?.invalidate()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
val pagerFlow: Flow<PagingData<NovelExtension.Available>> = searchQuery.flatMapLatest { query ->
|
||||||
|
Pager(
|
||||||
|
PagingConfig(
|
||||||
|
pageSize = 15,
|
||||||
|
initialLoadSize = 15,
|
||||||
|
prefetchDistance = 15
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
NovelExtensionPagingSource(
|
||||||
|
novelExtensionManager.availableExtensionsFlow,
|
||||||
|
novelExtensionManager.installedExtensionsFlow,
|
||||||
|
searchQuery
|
||||||
|
).also { currentPagingSource = it }
|
||||||
|
}.flow
|
||||||
|
}.cachedIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class NovelExtensionPagingSource(
|
||||||
|
private val availableExtensionsFlow: StateFlow<List<NovelExtension.Available>>,
|
||||||
|
private val installedExtensionsFlow: StateFlow<List<NovelExtension.Installed>>,
|
||||||
|
private val searchQuery: StateFlow<String>
|
||||||
|
) : PagingSource<Int, NovelExtension.Available>() {
|
||||||
|
|
||||||
|
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, NovelExtension.Available> {
|
||||||
|
val position = params.key ?: 0
|
||||||
|
val installedExtensions = installedExtensionsFlow.first().map { it.pkgName }.toSet()
|
||||||
|
val availableExtensions = availableExtensionsFlow.first().filterNot { it.pkgName in installedExtensions }
|
||||||
|
val query = searchQuery.first()
|
||||||
|
val isNsfwEnabled: Boolean = loadData("NFSWExtension") ?: true
|
||||||
|
val filteredExtensions = if (query.isEmpty()) {
|
||||||
|
availableExtensions
|
||||||
|
} else {
|
||||||
|
availableExtensions.filter { it.name.contains(query, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
val filternfsw = filteredExtensions
|
||||||
|
/*val filternfsw = if(isNsfwEnabled) { currently not implemented
|
||||||
|
filteredExtensions
|
||||||
|
} else {
|
||||||
|
filteredExtensions.filterNot { it.isNsfw }
|
||||||
|
}*/
|
||||||
|
return try {
|
||||||
|
val sublist = filternfsw.subList(
|
||||||
|
fromIndex = position,
|
||||||
|
toIndex = (position + params.loadSize).coerceAtMost(filternfsw.size)
|
||||||
|
)
|
||||||
|
LoadResult.Page(
|
||||||
|
data = sublist,
|
||||||
|
prevKey = if (position == 0) null else position - params.loadSize,
|
||||||
|
nextKey = if (position + params.loadSize >= filternfsw.size) null else position + params.loadSize
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LoadResult.Error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getRefreshKey(state: PagingState<Int, NovelExtension.Available>): Int? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NovelExtensionAdapter(private val clickListener: OnNovelInstallClickListener) :
|
||||||
|
PagingDataAdapter<NovelExtension.Available, NovelExtensionAdapter.NovelExtensionViewHolder>(
|
||||||
|
DIFF_CALLBACK
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val skipIcons = loadData("skip_extension_icons") ?: false
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<NovelExtension.Available>() {
|
||||||
|
override fun areItemsTheSame(oldItem: NovelExtension.Available, newItem: NovelExtension.Available): Boolean {
|
||||||
|
return oldItem.pkgName == newItem.pkgName
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(oldItem: NovelExtension.Available, newItem: NovelExtension.Available): Boolean {
|
||||||
|
return oldItem == newItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NovelExtensionViewHolder {
|
||||||
|
val binding = ItemExtensionAllBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
|
return NovelExtensionViewHolder(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: NovelExtensionViewHolder, position: Int) {
|
||||||
|
val extension = getItem(position)
|
||||||
|
if (extension != null) {
|
||||||
|
if (!skipIcons) {
|
||||||
|
Glide.with(holder.itemView.context)
|
||||||
|
.load(extension.iconUrl)
|
||||||
|
.into(holder.extensionIconImageView)
|
||||||
|
}
|
||||||
|
holder.bind(extension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class NovelExtensionViewHolder(private val binding: ItemExtensionAllBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
init {
|
||||||
|
binding.closeTextView.setOnClickListener {
|
||||||
|
val extension = getItem(bindingAdapterPosition)
|
||||||
|
if (extension != null) {
|
||||||
|
clickListener.onInstallClick(extension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val extensionIconImageView: ImageView = binding.extensionIconImageView
|
||||||
|
fun bind(extension: NovelExtension.Available) {
|
||||||
|
val nsfw = ""
|
||||||
|
val lang= LanguageMapper.mapLanguageCodeToName("all")
|
||||||
|
binding.extensionNameTextView.text = extension.name
|
||||||
|
binding.extensionVersionTextView.text = "$lang ${extension.versionName} $nsfw"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnNovelInstallClickListener {
|
||||||
|
fun onInstallClick(pkg: NovelExtension.Available)
|
||||||
|
}
|
|
@ -1,11 +1,10 @@
|
||||||
package eu.kanade.tachiyomi.network
|
package eu.kanade.tachiyomi.network
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import eu.kanade.tachiyomi.network.AndroidCookieJar
|
import android.os.Build
|
||||||
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE
|
import ani.dantotsu.Mapper
|
||||||
import eu.kanade.tachiyomi.network.PREF_DOH_GOOGLE
|
import ani.dantotsu.defaultHeaders
|
||||||
import eu.kanade.tachiyomi.network.dohCloudflare
|
import com.lagradost.nicehttp.Requests
|
||||||
import eu.kanade.tachiyomi.network.dohGoogle
|
|
||||||
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
|
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
|
||||||
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
|
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
|
||||||
import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor
|
import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor
|
||||||
|
@ -32,13 +31,13 @@ class NetworkHelper(
|
||||||
CloudflareInterceptor(context, cookieJar, ::defaultUserAgentProvider)
|
CloudflareInterceptor(context, cookieJar, ::defaultUserAgentProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val baseClientBuilder: OkHttpClient.Builder
|
private fun baseClientBuilder(callTimout: Int = 2): OkHttpClient.Builder
|
||||||
get() {
|
{
|
||||||
val builder = OkHttpClient.Builder()
|
val builder = OkHttpClient.Builder()
|
||||||
.cookieJar(cookieJar)
|
.cookieJar(cookieJar)
|
||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.callTimeout(2, TimeUnit.MINUTES)
|
.callTimeout(callTimout.toLong(), TimeUnit.MINUTES)
|
||||||
.addInterceptor(UncaughtExceptionInterceptor())
|
.addInterceptor(UncaughtExceptionInterceptor())
|
||||||
.addInterceptor(userAgentInterceptor)
|
.addInterceptor(userAgentInterceptor)
|
||||||
|
|
||||||
|
@ -68,7 +67,10 @@ class NetworkHelper(
|
||||||
return builder
|
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")
|
@Suppress("UNUSED")
|
||||||
val cloudflareClient by lazy {
|
val cloudflareClient by lazy {
|
||||||
|
@ -77,5 +79,17 @@ class NetworkHelper(
|
||||||
.build()
|
.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()
|
fun defaultUserAgentProvider() = preferences.defaultUserAgent().get().trim()
|
||||||
}
|
}
|
||||||
|
|
15
app/src/main/res/layout/fragment_novel_extensions.xml
Normal file
15
app/src/main/res/layout/fragment_novel_extensions.xml
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="32dp"
|
||||||
|
android:paddingEnd="32dp">
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/allNovelExtensionsRecyclerView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
|
@ -33,7 +33,7 @@
|
||||||
android:freezesText="false"
|
android:freezesText="false"
|
||||||
android:inputType="none"
|
android:inputType="none"
|
||||||
android:padding="8dp"
|
android:padding="8dp"
|
||||||
android:text="@string/watch"
|
android:text="@string/read"
|
||||||
android:textAllCaps="true"
|
android:textAllCaps="true"
|
||||||
android:textColor="?android:attr/textColorSecondary"
|
android:textColor="?android:attr/textColorSecondary"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue