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
|
||||
targetSdk 34
|
||||
versionCode ((System.currentTimeMillis() / 60000).toInteger())
|
||||
versionName "1.0.0-beta03i"
|
||||
versionName "1.0.0-beta03i-2"
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
|
||||
|
@ -64,7 +64,7 @@ dependencies {
|
|||
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation 'com.google.code.gson:gson:2.8.9'
|
||||
implementation 'com.github.Blatzar:NiceHttp:0.4.3'
|
||||
implementation 'com.github.Blatzar:NiceHttp:0.4.4'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0'
|
||||
implementation 'androidx.preference:preference:1.2.1'
|
||||
|
||||
|
|
|
@ -277,6 +277,10 @@
|
|||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service android:name=".download.novel.NovelDownloaderService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service android:name=".connections.discord.DiscordService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
|
|
@ -7,6 +7,7 @@ import android.content.Context
|
|||
import android.content.res.ColorStateList
|
||||
import android.content.res.Resources
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.util.LongSparseArray
|
||||
import android.util.TypedValue
|
||||
import androidx.annotation.ColorInt
|
||||
|
@ -14,9 +15,13 @@ import androidx.multidex.MultiDex
|
|||
import androidx.multidex.MultiDexApplication
|
||||
import ani.dantotsu.aniyomi.anime.custom.AppModule
|
||||
import ani.dantotsu.aniyomi.anime.custom.PreferenceModule
|
||||
import ani.dantotsu.download.Download
|
||||
import ani.dantotsu.download.DownloadsManager
|
||||
import ani.dantotsu.others.DisabledReports
|
||||
import ani.dantotsu.parsers.AnimeSources
|
||||
import ani.dantotsu.parsers.MangaSources
|
||||
import ani.dantotsu.parsers.NovelSources
|
||||
import ani.dantotsu.parsers.novel.NovelExtensionManager
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import com.google.android.material.color.HarmonizedColorAttributes
|
||||
import com.google.android.material.color.HarmonizedColors
|
||||
|
@ -36,6 +41,7 @@ import logcat.LogcatLogger
|
|||
import tachiyomi.core.util.system.logcat
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
import java.lang.reflect.Field
|
||||
|
||||
|
||||
|
@ -43,6 +49,7 @@ import java.lang.reflect.Field
|
|||
class App : MultiDexApplication() {
|
||||
private lateinit var animeExtensionManager: AnimeExtensionManager
|
||||
private lateinit var mangaExtensionManager: MangaExtensionManager
|
||||
private lateinit var novelExtensionManager: NovelExtensionManager
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
MultiDex.install(this)
|
||||
|
@ -65,11 +72,12 @@ class App : MultiDexApplication() {
|
|||
registerActivityLifecycleCallbacks(mFTActivityLifecycleCallbacks)
|
||||
|
||||
Firebase.crashlytics.setCrashlyticsCollectionEnabled(!DisabledReports)
|
||||
initializeNetwork(baseContext)
|
||||
|
||||
Injekt.importModule(AppModule(this))
|
||||
Injekt.importModule(PreferenceModule(this))
|
||||
|
||||
initializeNetwork(baseContext)
|
||||
|
||||
setupNotificationChannels()
|
||||
if (!LogcatLogger.isInstalled) {
|
||||
LogcatLogger.install(AndroidLogcatLogger(LogPriority.VERBOSE))
|
||||
|
@ -77,6 +85,7 @@ class App : MultiDexApplication() {
|
|||
|
||||
animeExtensionManager = Injekt.get()
|
||||
mangaExtensionManager = Injekt.get()
|
||||
novelExtensionManager = Injekt.get()
|
||||
|
||||
val animeScope = CoroutineScope(Dispatchers.Default)
|
||||
animeScope.launch {
|
||||
|
@ -90,6 +99,12 @@ class App : MultiDexApplication() {
|
|||
logger("Manga Extensions: ${mangaExtensionManager.installedExtensionsFlow.first()}")
|
||||
MangaSources.init(mangaExtensionManager.installedExtensionsFlow)
|
||||
}
|
||||
val novelScope = CoroutineScope(Dispatchers.Default)
|
||||
novelScope.launch {
|
||||
novelExtensionManager.findAvailableExtensions()
|
||||
logger("Novel Extensions: ${novelExtensionManager.installedExtensionsFlow.first()}")
|
||||
NovelSources.init(novelExtensionManager.installedExtensionsFlow)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -35,6 +35,7 @@ import androidx.core.view.updateLayoutParams
|
|||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ViewModelProvider.NewInstanceFactory.Companion.instance
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager
|
||||
|
@ -59,7 +60,9 @@ import ani.dantotsu.settings.UserInterfaceSettings
|
|||
import ani.dantotsu.subcriptions.Subscription.Companion.startSubscription
|
||||
import ani.dantotsu.themes.ThemeManager
|
||||
import ani.dantotsu.others.LangSet
|
||||
import ani.dantotsu.parsers.NovelInterface
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import dalvik.system.PathClassLoader
|
||||
import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager
|
||||
import io.noties.markwon.Markwon
|
||||
import io.noties.markwon.SoftBreakAddsNewLinePlugin
|
||||
|
@ -73,7 +76,11 @@ import nl.joery.animatedbottombar.AnimatedBottomBar
|
|||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.Serializable
|
||||
import java.nio.channels.FileChannel
|
||||
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
@ -83,6 +90,8 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
private var uiSettings = UserInterfaceSettings()
|
||||
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
ThemeManager(this).applyTheme()
|
||||
LangSet.setLocale(this)
|
||||
|
|
|
@ -8,6 +8,7 @@ import ani.dantotsu.others.webview.WebViewBottomDialog
|
|||
import com.lagradost.nicehttp.Requests
|
||||
import com.lagradost.nicehttp.ResponseParser
|
||||
import com.lagradost.nicehttp.addGenericDns
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
|
@ -17,6 +18,8 @@ import kotlinx.serialization.json.Json
|
|||
import kotlinx.serialization.serializer
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.Serializable
|
||||
|
@ -25,41 +28,30 @@ import java.util.concurrent.*
|
|||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.KFunction
|
||||
|
||||
val defaultHeaders = mapOf(
|
||||
"User-Agent" to
|
||||
"Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Mobile Safari/537.36"
|
||||
.format(Build.VERSION.RELEASE, Build.MODEL)
|
||||
)
|
||||
lateinit var cache: Cache
|
||||
lateinit var defaultHeaders: Map<String, String>
|
||||
|
||||
lateinit var okHttpClient: OkHttpClient
|
||||
lateinit var client: Requests
|
||||
|
||||
fun initializeNetwork(context: Context) {
|
||||
val dns = loadData<Int>("settings_dns")
|
||||
cache = Cache(
|
||||
File(context.cacheDir, "http_cache"),
|
||||
5 * 1024L * 1024L // 5 MiB
|
||||
|
||||
val networkHelper = Injekt.get<NetworkHelper>()
|
||||
|
||||
defaultHeaders = mapOf(
|
||||
"User-Agent" to
|
||||
Injekt.get<NetworkHelper>().defaultUserAgentProvider()
|
||||
.format(Build.VERSION.RELEASE, Build.MODEL)
|
||||
)
|
||||
okHttpClient = OkHttpClient.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.cache(cache)
|
||||
.apply {
|
||||
when (dns) {
|
||||
1 -> addGoogleDns()
|
||||
2 -> addCloudFlareDns()
|
||||
3 -> addAdGuardDns()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
okHttpClient = networkHelper.client
|
||||
client = Requests(
|
||||
okHttpClient,
|
||||
networkHelper.client,
|
||||
defaultHeaders,
|
||||
defaultCacheTime = 6,
|
||||
defaultCacheTimeUnit = TimeUnit.HOURS,
|
||||
responseParser = Mapper
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
object Mapper : ResponseParser {
|
||||
|
|
|
@ -24,6 +24,7 @@ import uy.kohesive.injekt.api.addSingleton
|
|||
import uy.kohesive.injekt.api.addSingletonFactory
|
||||
import uy.kohesive.injekt.api.get
|
||||
import ani.dantotsu.download.DownloadsManager
|
||||
import ani.dantotsu.parsers.novel.NovelExtensionManager
|
||||
|
||||
class AppModule(val app: Application) : InjektModule {
|
||||
override fun InjektRegistrar.registerInjectables() {
|
||||
|
@ -35,6 +36,7 @@ class AppModule(val app: Application) : InjektModule {
|
|||
|
||||
addSingletonFactory { AnimeExtensionManager(app) }
|
||||
addSingletonFactory { MangaExtensionManager(app) }
|
||||
addSingletonFactory { NovelExtensionManager(app) }
|
||||
|
||||
addSingletonFactory<AnimeSourceManager> { AndroidAnimeSourceManager(app, get()) }
|
||||
addSingletonFactory<MangaSourceManager> { AndroidMangaSourceManager(app, get()) }
|
||||
|
|
|
@ -18,6 +18,8 @@ class DownloadsManager(private val context: Context) {
|
|||
get() = downloadsList.filter { it.type == Download.Type.MANGA }
|
||||
val animeDownloads: List<Download>
|
||||
get() = downloadsList.filter { it.type == Download.Type.ANIME }
|
||||
val novelDownloads: List<Download>
|
||||
get() = downloadsList.filter { it.type == Download.Type.NOVEL }
|
||||
|
||||
private fun saveDownloads() {
|
||||
val jsonString = gson.toJson(downloadsList)
|
||||
|
@ -45,11 +47,17 @@ class DownloadsManager(private val context: Context) {
|
|||
saveDownloads()
|
||||
}
|
||||
|
||||
fun queryDownload(download: Download): Boolean {
|
||||
return downloadsList.contains(download)
|
||||
}
|
||||
|
||||
private fun removeDirectory(download: Download) {
|
||||
val directory = if (download.type == Download.Type.MANGA){
|
||||
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Manga/${download.title}/${download.chapter}")
|
||||
} else {
|
||||
} else if (download.type == Download.Type.ANIME) {
|
||||
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Anime/${download.title}/${download.chapter}")
|
||||
} else {
|
||||
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Novel/${download.title}/${download.chapter}")
|
||||
}
|
||||
|
||||
// Check if the directory exists and delete it recursively
|
||||
|
@ -68,8 +76,10 @@ class DownloadsManager(private val context: Context) {
|
|||
fun exportDownloads(download: Download) { //copies to the downloads folder available to the user
|
||||
val directory = if (download.type == Download.Type.MANGA){
|
||||
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Manga/${download.title}/${download.chapter}")
|
||||
} else {
|
||||
} else if (download.type == Download.Type.ANIME) {
|
||||
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Anime/${download.title}/${download.chapter}")
|
||||
} else {
|
||||
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Novel/${download.title}/${download.chapter}")
|
||||
}
|
||||
val destination = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/${download.title}/${download.chapter}")
|
||||
if (directory.exists()) {
|
||||
|
@ -87,8 +97,10 @@ class DownloadsManager(private val context: Context) {
|
|||
fun purgeDownloads(type: Download.Type){
|
||||
val directory = if (type == Download.Type.MANGA){
|
||||
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Manga")
|
||||
} else {
|
||||
} else if (type == Download.Type.ANIME) {
|
||||
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Anime")
|
||||
} else {
|
||||
File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Novel")
|
||||
}
|
||||
if (directory.exists()) {
|
||||
val deleted = directory.deleteRecursively()
|
||||
|
@ -105,11 +117,18 @@ class DownloadsManager(private val context: Context) {
|
|||
saveDownloads()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val novelLocation = "Dantotsu/Novel"
|
||||
const val mangaLocation = "Dantotsu/Manga"
|
||||
const val animeLocation = "Dantotsu/Anime"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data class Download(val title: String, val chapter: String, val type: Type) : Serializable {
|
||||
enum class Type {
|
||||
MANGA,
|
||||
ANIME
|
||||
ANIME,
|
||||
NOVEL
|
||||
}
|
||||
}
|
||||
|
|
|
@ -91,9 +91,9 @@ class MangaDownloaderService : Service() {
|
|||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
ServiceDataSingleton.downloadQueue.clear()
|
||||
MangaServiceDataSingleton.downloadQueue.clear()
|
||||
downloadJobs.clear()
|
||||
ServiceDataSingleton.isServiceRunning = false
|
||||
MangaServiceDataSingleton.isServiceRunning = false
|
||||
unregisterReceiver(cancelReceiver)
|
||||
}
|
||||
|
||||
|
@ -114,8 +114,8 @@ class MangaDownloaderService : Service() {
|
|||
|
||||
private fun processQueue() {
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
while (ServiceDataSingleton.downloadQueue.isNotEmpty()) {
|
||||
val task = ServiceDataSingleton.downloadQueue.poll()
|
||||
while (MangaServiceDataSingleton.downloadQueue.isNotEmpty()) {
|
||||
val task = MangaServiceDataSingleton.downloadQueue.poll()
|
||||
if (task != null) {
|
||||
val job = launch { download(task) }
|
||||
mutex.withLock {
|
||||
|
@ -127,7 +127,7 @@ class MangaDownloaderService : Service() {
|
|||
}
|
||||
updateNotification() // Update the notification after each task is completed
|
||||
}
|
||||
if (ServiceDataSingleton.downloadQueue.isEmpty()) {
|
||||
if (MangaServiceDataSingleton.downloadQueue.isEmpty()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
stopSelf() // Stop the service when the queue is empty
|
||||
}
|
||||
|
@ -141,7 +141,7 @@ class MangaDownloaderService : Service() {
|
|||
mutex.withLock {
|
||||
downloadJobs[chapter]?.cancel()
|
||||
downloadJobs.remove(chapter)
|
||||
ServiceDataSingleton.downloadQueue.removeAll { it.chapter == chapter }
|
||||
MangaServiceDataSingleton.downloadQueue.removeAll { it.chapter == chapter }
|
||||
updateNotification() // Update the notification after cancellation
|
||||
}
|
||||
}
|
||||
|
@ -149,7 +149,7 @@ class MangaDownloaderService : Service() {
|
|||
|
||||
private fun updateNotification() {
|
||||
// Update the notification to reflect the current state of the queue
|
||||
val pendingDownloads = ServiceDataSingleton.downloadQueue.size
|
||||
val pendingDownloads = MangaServiceDataSingleton.downloadQueue.size
|
||||
val text = if (pendingDownloads > 0) {
|
||||
"Pending downloads: $pendingDownloads"
|
||||
} else {
|
||||
|
@ -381,7 +381,7 @@ class MangaDownloaderService : Service() {
|
|||
}
|
||||
}
|
||||
|
||||
object ServiceDataSingleton {
|
||||
object MangaServiceDataSingleton {
|
||||
var imageData: List<ImageData> = listOf()
|
||||
var sourceMedia: Media? = null
|
||||
var downloadQueue: Queue<MangaDownloaderService.DownloadTask> = ConcurrentLinkedQueue()
|
||||
|
|
|
@ -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.DownloadsManager
|
||||
import ani.dantotsu.download.manga.MangaDownloaderService
|
||||
import ani.dantotsu.download.manga.ServiceDataSingleton
|
||||
import ani.dantotsu.download.manga.MangaServiceDataSingleton
|
||||
import ani.dantotsu.media.manga.mangareader.ChapterLoaderDialog
|
||||
import ani.dantotsu.media.Media
|
||||
import ani.dantotsu.media.MediaDetailsActivity
|
||||
|
@ -408,15 +408,15 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener {
|
|||
simultaneousDownloads = 2
|
||||
)
|
||||
|
||||
ServiceDataSingleton.downloadQueue.offer(downloadTask)
|
||||
MangaServiceDataSingleton.downloadQueue.offer(downloadTask)
|
||||
|
||||
// If the service is not already running, start it
|
||||
if (!ServiceDataSingleton.isServiceRunning) {
|
||||
if (!MangaServiceDataSingleton.isServiceRunning) {
|
||||
val intent = Intent(context, MangaDownloaderService::class.java)
|
||||
withContext(Dispatchers.Main) {
|
||||
ContextCompat.startForegroundService(requireContext(), intent)
|
||||
}
|
||||
ServiceDataSingleton.isServiceRunning = true
|
||||
MangaServiceDataSingleton.isServiceRunning = true
|
||||
}
|
||||
|
||||
// Inform the adapter that the download has started
|
||||
|
@ -456,6 +456,7 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener {
|
|||
}
|
||||
private val downloadStatusReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if(!this@MangaReadFragment::chapterAdapter.isInitialized) return
|
||||
when (intent.action) {
|
||||
ACTION_DOWNLOAD_STARTED -> {
|
||||
val chapterNumber = intent.getStringExtra(EXTRA_CHAPTER_NUMBER)
|
||||
|
|
|
@ -29,6 +29,14 @@ class BookDialog : BottomSheetDialogFragment() {
|
|||
private lateinit var novel: ShowResponse
|
||||
private var source:Int = 0
|
||||
|
||||
interface Callback {
|
||||
fun onDownloadTriggered(link: String)
|
||||
}
|
||||
private var callback: Callback? = null
|
||||
fun setCallback(callback: Callback) {
|
||||
this.callback = callback
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
arguments?.let {
|
||||
novelName = it.getString("novelName")!!
|
||||
|
@ -51,7 +59,7 @@ class BookDialog : BottomSheetDialogFragment() {
|
|||
binding.itemBookTitle.text = it.name
|
||||
binding.itemBookDesc.text = it.description
|
||||
binding.itemBookImage.loadImage(it.img)
|
||||
binding.bookRecyclerView.adapter = UrlAdapter(it.links, it, novelName)
|
||||
binding.bookRecyclerView.adapter = UrlAdapter(it.links, it, novelName, callback)
|
||||
}
|
||||
}
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
|
|
|
@ -64,7 +64,7 @@ class NovelReadAdapter(
|
|||
binding.searchBar.setEndIconOnClickListener { search() }
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = 0
|
||||
override fun getItemCount(): Int = 1
|
||||
|
||||
inner class ViewHolder(val binding: ItemNovelHeaderBinding) : RecyclerView.ViewHolder(binding.root)
|
||||
}
|
|
@ -1,12 +1,20 @@
|
|||
package ani.dantotsu.media.novel
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
|
@ -14,16 +22,29 @@ import androidx.lifecycle.lifecycleScope
|
|||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import ani.dantotsu.databinding.FragmentAnimeWatchBinding
|
||||
import ani.dantotsu.download.Download
|
||||
import ani.dantotsu.download.DownloadsManager
|
||||
import ani.dantotsu.download.novel.NovelDownloaderService
|
||||
import ani.dantotsu.download.novel.NovelServiceDataSingleton
|
||||
import ani.dantotsu.loadData
|
||||
import ani.dantotsu.media.Media
|
||||
import ani.dantotsu.media.MediaDetailsViewModel
|
||||
import ani.dantotsu.media.novel.novelreader.NovelReaderActivity
|
||||
import ani.dantotsu.navBarHeight
|
||||
import ani.dantotsu.parsers.ShowResponse
|
||||
import ani.dantotsu.saveData
|
||||
import ani.dantotsu.settings.UserInterfaceSettings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
|
||||
class NovelReadFragment : Fragment() {
|
||||
class NovelReadFragment : Fragment(),
|
||||
DownloadTriggerCallback,
|
||||
DownloadedCheckCallback {
|
||||
|
||||
private var _binding: FragmentAnimeWatchBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
@ -42,9 +63,104 @@ class NovelReadFragment : Fragment() {
|
|||
|
||||
val uiSettings = loadData("ui_settings", toast = false) ?: UserInterfaceSettings().apply { saveData("ui_settings", this) }
|
||||
|
||||
override fun downloadTrigger(novelDownloadPackage: NovelDownloadPackage) {
|
||||
Log.e("downloadTrigger", novelDownloadPackage.link)
|
||||
val downloadTask = NovelDownloaderService.DownloadTask(
|
||||
title = media.nameMAL ?: media.nameRomaji,
|
||||
chapter = novelDownloadPackage.novelName,
|
||||
downloadLink = novelDownloadPackage.link,
|
||||
originalLink = novelDownloadPackage.originalLink,
|
||||
sourceMedia = media,
|
||||
coverUrl = novelDownloadPackage.coverUrl,
|
||||
retries = 2,
|
||||
)
|
||||
NovelServiceDataSingleton.downloadQueue.offer(downloadTask)
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
|
||||
if (!NovelServiceDataSingleton.isServiceRunning) {
|
||||
val intent = Intent(context, NovelDownloaderService::class.java)
|
||||
withContext(Dispatchers.Main) {
|
||||
ContextCompat.startForegroundService(requireContext(), intent)
|
||||
}
|
||||
NovelServiceDataSingleton.isServiceRunning = true
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override fun downloadedCheckWithStart(novel: ShowResponse): Boolean {
|
||||
val downloadsManager = Injekt.get<DownloadsManager>()
|
||||
if(downloadsManager.queryDownload(Download(media.nameMAL ?: media.nameRomaji, novel.name, Download.Type.NOVEL))) {
|
||||
val file = File(context?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "${DownloadsManager.novelLocation}/${media.nameMAL ?: media.nameRomaji}/${novel.name}/0.epub")
|
||||
if (!file.exists()) return false
|
||||
val fileUri = FileProvider.getUriForFile(requireContext(), "${requireContext().packageName}.provider", file)
|
||||
val intent = Intent(context, NovelReaderActivity::class.java).apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
setDataAndType(fileUri, "application/epub+zip")
|
||||
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
startActivity(intent)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override fun downloadedCheck(novel: ShowResponse): Boolean {
|
||||
val downloadsManager = Injekt.get<DownloadsManager>()
|
||||
return downloadsManager.queryDownload(Download(media.nameMAL ?: media.nameRomaji, novel.name, Download.Type.NOVEL))
|
||||
}
|
||||
|
||||
override fun deleteDownload(novel: ShowResponse) {
|
||||
val downloadsManager = Injekt.get<DownloadsManager>()
|
||||
downloadsManager.removeDownload(Download(media.nameMAL ?: media.nameRomaji, novel.name, Download.Type.NOVEL))
|
||||
}
|
||||
|
||||
private val downloadStatusReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (!this@NovelReadFragment::novelResponseAdapter.isInitialized) return
|
||||
when (intent.action) {
|
||||
ACTION_DOWNLOAD_STARTED -> {
|
||||
val link = intent.getStringExtra(EXTRA_NOVEL_LINK)
|
||||
link?.let {
|
||||
novelResponseAdapter.startDownload(it)
|
||||
}
|
||||
}
|
||||
ACTION_DOWNLOAD_FINISHED -> {
|
||||
val link = intent.getStringExtra(EXTRA_NOVEL_LINK)
|
||||
link?.let {
|
||||
novelResponseAdapter.stopDownload(it)
|
||||
}
|
||||
}
|
||||
ACTION_DOWNLOAD_FAILED -> {
|
||||
val link = intent.getStringExtra(EXTRA_NOVEL_LINK)
|
||||
link?.let {
|
||||
novelResponseAdapter.purgeDownload(it)
|
||||
}
|
||||
}
|
||||
ACTION_DOWNLOAD_PROGRESS -> {
|
||||
val link = intent.getStringExtra(EXTRA_NOVEL_LINK)
|
||||
val progress = intent.getIntExtra("progress", 0)
|
||||
link?.let {
|
||||
novelResponseAdapter.updateDownloadProgress(it, progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var response: List<ShowResponse>? = null
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val intentFilter = IntentFilter().apply {
|
||||
addAction(ACTION_DOWNLOAD_STARTED)
|
||||
addAction(ACTION_DOWNLOAD_FINISHED)
|
||||
addAction(ACTION_DOWNLOAD_FAILED)
|
||||
addAction(ACTION_DOWNLOAD_PROGRESS)
|
||||
}
|
||||
|
||||
ContextCompat.registerReceiver(requireContext(), downloadStatusReceiver, intentFilter ,ContextCompat.RECEIVER_EXPORTED)
|
||||
|
||||
binding.animeSourceRecycler.updatePadding(bottom = binding.animeSourceRecycler.paddingBottom + navBarHeight)
|
||||
|
||||
binding.animeSourceRecycler.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
@ -63,7 +179,7 @@ class NovelReadFragment : Fragment() {
|
|||
val sel = media.selected
|
||||
searchQuery = sel?.server ?: media.name ?: media.nameRomaji
|
||||
headerAdapter = NovelReadAdapter(media, this, model.novelSources)
|
||||
novelResponseAdapter = NovelResponseAdapter(this)
|
||||
novelResponseAdapter = NovelResponseAdapter(this, this, this) // probably a better way to do this but it works
|
||||
binding.animeSourceRecycler.adapter = ConcatAdapter(headerAdapter, novelResponseAdapter)
|
||||
loaded = true
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
|
@ -74,6 +190,7 @@ class NovelReadFragment : Fragment() {
|
|||
}
|
||||
model.novelResponses.observe(viewLifecycleOwner) {
|
||||
if (it != null) {
|
||||
response = it
|
||||
searching = false
|
||||
novelResponseAdapter.submitList(it)
|
||||
headerAdapter.progress?.visibility = View.GONE
|
||||
|
@ -121,6 +238,7 @@ class NovelReadFragment : Fragment() {
|
|||
|
||||
override fun onDestroy() {
|
||||
model.mangaReadSources?.flushText()
|
||||
requireContext().unregisterReceiver(downloadStatusReceiver)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
@ -135,4 +253,22 @@ class NovelReadFragment : Fragment() {
|
|||
super.onPause()
|
||||
state = binding.animeSourceRecycler.layoutManager?.onSaveInstanceState()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_DOWNLOAD_STARTED = "ani.dantotsu.ACTION_DOWNLOAD_STARTED"
|
||||
const val ACTION_DOWNLOAD_FINISHED = "ani.dantotsu.ACTION_DOWNLOAD_FINISHED"
|
||||
const val ACTION_DOWNLOAD_FAILED = "ani.dantotsu.ACTION_DOWNLOAD_FAILED"
|
||||
const val ACTION_DOWNLOAD_PROGRESS = "ani.dantotsu.ACTION_DOWNLOAD_PROGRESS"
|
||||
const val EXTRA_NOVEL_LINK = "extra_novel_link"
|
||||
}
|
||||
}
|
||||
|
||||
interface DownloadTriggerCallback {
|
||||
fun downloadTrigger(novelDownloadPackage: NovelDownloadPackage)
|
||||
}
|
||||
|
||||
interface DownloadedCheckCallback {
|
||||
fun downloadedCheck(novel: ShowResponse): Boolean
|
||||
fun downloadedCheckWithStart(novel: ShowResponse): Boolean
|
||||
fun deleteDownload(novel: ShowResponse)
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package ani.dantotsu.media.novel
|
||||
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
@ -7,16 +8,23 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import ani.dantotsu.databinding.ItemNovelResponseBinding
|
||||
import ani.dantotsu.parsers.ShowResponse
|
||||
import ani.dantotsu.setAnimation
|
||||
import ani.dantotsu.snackString
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
|
||||
class NovelResponseAdapter(val fragment: NovelReadFragment) : RecyclerView.Adapter<NovelResponseAdapter.ViewHolder>() {
|
||||
class NovelResponseAdapter(
|
||||
val fragment: NovelReadFragment,
|
||||
val downloadTriggerCallback: DownloadTriggerCallback,
|
||||
val downloadedCheckCallback: DownloadedCheckCallback
|
||||
) : RecyclerView.Adapter<NovelResponseAdapter.ViewHolder>() {
|
||||
val list: MutableList<ShowResponse> = mutableListOf()
|
||||
|
||||
inner class ViewHolder(val binding: ItemNovelResponseBinding) : RecyclerView.ViewHolder(binding.root)
|
||||
inner class ViewHolder(val binding: ItemNovelResponseBinding) :
|
||||
RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val bind = ItemNovelResponseBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
val bind =
|
||||
ItemNovelResponseBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return ViewHolder(bind)
|
||||
}
|
||||
|
||||
|
@ -28,18 +36,127 @@ class NovelResponseAdapter(val fragment: NovelReadFragment) : RecyclerView.Adapt
|
|||
setAnimation(fragment.requireContext(), holder.binding.root, fragment.uiSettings)
|
||||
|
||||
val cover = GlideUrl(novel.coverUrl.url) { novel.coverUrl.headers }
|
||||
Glide.with(binding.itemEpisodeImage).load(cover).override(400,0).into(binding.itemEpisodeImage)
|
||||
Glide.with(binding.itemEpisodeImage).load(cover).override(400, 0)
|
||||
.into(binding.itemEpisodeImage)
|
||||
|
||||
binding.itemEpisodeTitle.text = novel.name
|
||||
binding.itemEpisodeFiller.text = novel.extra?.get("0") ?: ""
|
||||
binding.itemEpisodeFiller.text =
|
||||
if (downloadedCheckCallback.downloadedCheck(novel)) {
|
||||
"Downloaded"
|
||||
} else {
|
||||
novel.extra?.get("0") ?: ""
|
||||
}
|
||||
if (binding.itemEpisodeFiller.text.contains("Downloading")) {
|
||||
binding.itemEpisodeFiller.setTextColor(
|
||||
fragment.requireContext().getColor(android.R.color.holo_blue_light)
|
||||
)
|
||||
} else if (binding.itemEpisodeFiller.text.contains("Downloaded")) {
|
||||
binding.itemEpisodeFiller.setTextColor(
|
||||
fragment.requireContext().getColor(android.R.color.holo_green_light)
|
||||
)
|
||||
} else {
|
||||
binding.itemEpisodeFiller.setTextColor(
|
||||
fragment.requireContext().getColor(android.R.color.white)
|
||||
)
|
||||
}
|
||||
binding.itemEpisodeDesc2.text = novel.extra?.get("1") ?: ""
|
||||
val desc = novel.extra?.get("2")
|
||||
binding.itemEpisodeDesc.visibility = if (desc != null && desc.trim(' ') != "") View.VISIBLE else View.GONE
|
||||
binding.itemEpisodeDesc.visibility =
|
||||
if (desc != null && desc.trim(' ') != "") View.VISIBLE else View.GONE
|
||||
binding.itemEpisodeDesc.text = desc ?: ""
|
||||
|
||||
binding.root.setOnClickListener {
|
||||
BookDialog.newInstance(fragment.novelName, novel, fragment.source)
|
||||
.show(fragment.parentFragmentManager, "dialog")
|
||||
//make sure the file is not downloading
|
||||
if (activeDownloads.contains(novel.link)) {
|
||||
return@setOnClickListener
|
||||
}
|
||||
if (downloadedCheckCallback.downloadedCheckWithStart(novel)) {
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
val bookDialog = BookDialog.newInstance(fragment.novelName, novel, fragment.source)
|
||||
|
||||
bookDialog.setCallback(object : BookDialog.Callback {
|
||||
override fun onDownloadTriggered(link: String) {
|
||||
downloadTriggerCallback.downloadTrigger(
|
||||
NovelDownloadPackage(
|
||||
link,
|
||||
novel.coverUrl.url,
|
||||
novel.name,
|
||||
novel.link
|
||||
)
|
||||
)
|
||||
bookDialog.dismiss()
|
||||
}
|
||||
})
|
||||
bookDialog.show(fragment.parentFragmentManager, "dialog")
|
||||
|
||||
}
|
||||
|
||||
binding.root.setOnLongClickListener {
|
||||
downloadedCheckCallback.deleteDownload(novel)
|
||||
deleteDownload(novel.link)
|
||||
snackString("Deleted ${novel.name}")
|
||||
if (binding.itemEpisodeFiller.text.toString().contains("Download", ignoreCase = true)) {
|
||||
binding.itemEpisodeFiller.text = ""
|
||||
}
|
||||
notifyItemChanged(position)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private val activeDownloads = mutableSetOf<String>()
|
||||
private val downloadedChapters = mutableSetOf<String>()
|
||||
|
||||
fun startDownload(link: String) {
|
||||
activeDownloads.add(link)
|
||||
val position = list.indexOfFirst { it.link == link }
|
||||
if (position != -1) {
|
||||
list[position].extra?.remove("0")
|
||||
list[position].extra?.set("0", "Downloading: 0%")
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun stopDownload(link: String) {
|
||||
activeDownloads.remove(link)
|
||||
downloadedChapters.add(link)
|
||||
val position = list.indexOfFirst { it.link == link }
|
||||
if (position != -1) {
|
||||
list[position].extra?.remove("0")
|
||||
list[position].extra?.set("0", "Downloaded")
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteDownload(link: String) { //TODO:
|
||||
downloadedChapters.remove(link)
|
||||
val position = list.indexOfFirst { it.link == link }
|
||||
if (position != -1) {
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
}
|
||||
|
||||
fun purgeDownload(link: String) {
|
||||
activeDownloads.remove(link)
|
||||
downloadedChapters.remove(link)
|
||||
val position = list.indexOfFirst { it.link == link }
|
||||
if (position != -1) {
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDownloadProgress(link: String, progress: Int) {
|
||||
if (!activeDownloads.contains(link)) {
|
||||
activeDownloads.add(link)
|
||||
}
|
||||
val position = list.indexOfFirst { it.link == link }
|
||||
if (position != -1) {
|
||||
list[position].extra?.remove("0")
|
||||
list[position].extra?.set("0", "Downloading: $progress%")
|
||||
Log.d("NovelResponseAdapter", "updateDownloadProgress: $progress, position: $position")
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -55,3 +172,10 @@ class NovelResponseAdapter(val fragment: NovelReadFragment) : RecyclerView.Adapt
|
|||
notifyItemRangeRemoved(0, size)
|
||||
}
|
||||
}
|
||||
|
||||
data class NovelDownloadPackage(
|
||||
val link: String,
|
||||
val coverUrl: String,
|
||||
val novelName: String,
|
||||
val originalLink: String
|
||||
)
|
|
@ -9,12 +9,11 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import ani.dantotsu.FileUrl
|
||||
import ani.dantotsu.copyToClipboard
|
||||
import ani.dantotsu.databinding.ItemUrlBinding
|
||||
import ani.dantotsu.others.Download.download
|
||||
import ani.dantotsu.parsers.Book
|
||||
import ani.dantotsu.setSafeOnClickListener
|
||||
import ani.dantotsu.tryWith
|
||||
|
||||
class UrlAdapter(private val urls: List<FileUrl>, val book: Book, val novel: String) :
|
||||
class UrlAdapter(private val urls: List<FileUrl>, val book: Book, val novel: String, val callback: BookDialog.Callback?) :
|
||||
RecyclerView.Adapter<UrlAdapter.UrlViewHolder>() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UrlViewHolder {
|
||||
|
@ -26,6 +25,7 @@ class UrlAdapter(private val urls: List<FileUrl>, val book: Book, val novel: Str
|
|||
val binding = holder.binding
|
||||
val url = urls[position]
|
||||
binding.urlQuality.text = url.url
|
||||
binding.urlQuality.maxLines = 4
|
||||
binding.urlDownload.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
|
@ -36,12 +36,14 @@ class UrlAdapter(private val urls: List<FileUrl>, val book: Book, val novel: Str
|
|||
itemView.setSafeOnClickListener {
|
||||
tryWith(true) {
|
||||
binding.urlDownload.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||
download(
|
||||
callback?.onDownloadTriggered(book.links[bindingAdapterPosition].url)
|
||||
/*download(
|
||||
itemView.context,
|
||||
book,
|
||||
bindingAdapterPosition,
|
||||
novel
|
||||
)
|
||||
)*/
|
||||
|
||||
}
|
||||
}
|
||||
itemView.setOnLongClickListener {
|
||||
|
|
|
@ -11,30 +11,22 @@ import android.os.Build
|
|||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import ani.dantotsu.FileUrl
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
|
||||
import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource
|
||||
import eu.kanade.tachiyomi.network.interceptor.CloudflareBypassException
|
||||
import ani.dantotsu.currContext
|
||||
import ani.dantotsu.download.manga.MangaDownloaderService
|
||||
import ani.dantotsu.download.manga.ServiceDataSingleton
|
||||
import ani.dantotsu.logger
|
||||
import ani.dantotsu.media.anime.AnimeNameAdapter
|
||||
import ani.dantotsu.media.manga.ImageData
|
||||
import ani.dantotsu.media.manga.MangaCache
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
|
||||
import eu.kanade.tachiyomi.animesource.model.SEpisode
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimesPage
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||
import eu.kanade.tachiyomi.animesource.model.Track
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
|
@ -49,11 +41,8 @@ import kotlinx.coroutines.coroutineScope
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.URL
|
||||
import java.net.URLDecoder
|
||||
import uy.kohesive.injekt.Injekt
|
||||
|
|
|
@ -167,7 +167,7 @@ data class ShowResponse(
|
|||
val total: Int? = null,
|
||||
|
||||
//In case you want to sent some extra data
|
||||
val extra : Map<String,String>?=null,
|
||||
val extra : MutableMap<String,String>?=null,
|
||||
|
||||
//SAnime object from Aniyomi
|
||||
val sAnime: SAnime? = null,
|
||||
|
@ -175,7 +175,7 @@ data class ShowResponse(
|
|||
//SManga object from Aniyomi
|
||||
val sManga: SManga? = null
|
||||
) : Serializable {
|
||||
constructor(name: String, link: String, coverUrl: String, otherNames: List<String> = listOf(), total: Int? = null, extra: Map<String, String>?=null)
|
||||
constructor(name: String, link: String, coverUrl: String, otherNames: List<String> = listOf(), total: Int? = null, extra: MutableMap<String, String>?=null)
|
||||
: this(name, link, FileUrl(coverUrl), otherNames, total, extra)
|
||||
|
||||
constructor(name: String, link: String, coverUrl: String, otherNames: List<String> = listOf(), total: Int? = null)
|
||||
|
|
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
|
||||
|
||||
import android.util.Log
|
||||
import ani.dantotsu.Lazier
|
||||
import ani.dantotsu.lazyList
|
||||
import ani.dantotsu.parsers.novel.NovelExtension
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import ani.dantotsu.parsers.novel.DynamicNovelParser
|
||||
|
||||
object NovelSources : NovelReadSources() {
|
||||
override val list: List<Lazier<BaseParser>> = lazyList(
|
||||
)
|
||||
override var list: List<Lazier<BaseParser>> = emptyList()
|
||||
|
||||
suspend fun init(fromExtensions: StateFlow<List<NovelExtension.Installed>>) {
|
||||
// Initialize with the first value from StateFlow
|
||||
val initialExtensions = fromExtensions.first()
|
||||
list = createParsersFromExtensions(initialExtensions)
|
||||
|
||||
// Update as StateFlow emits new values
|
||||
fromExtensions.collect { extensions ->
|
||||
list = createParsersFromExtensions(extensions)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createParsersFromExtensions(extensions: List<NovelExtension.Installed>): List<Lazier<BaseParser>> {
|
||||
Log.d("NovelSources", "createParsersFromExtensions")
|
||||
Log.d("NovelSources", extensions.toString())
|
||||
return extensions.map { extension ->
|
||||
val name = extension.name
|
||||
Lazier({ DynamicNovelParser(extension) }, name)
|
||||
}
|
||||
}
|
||||
}
|
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.AnimeExtensionsViewModelFactory
|
||||
import ani.dantotsu.settings.paging.OnAnimeInstallClickListener
|
||||
import ani.dantotsu.snackString
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager
|
||||
|
@ -101,6 +102,7 @@ class AnimeExtensionsFragment : Fragment(),
|
|||
.setContentText("Error: ${error.message}")
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
notificationManager.notify(1, builder.build())
|
||||
snackString("Installation failed: ${error.message}")
|
||||
},
|
||||
{
|
||||
val builder = NotificationCompat.Builder(
|
||||
|
@ -113,6 +115,7 @@ class AnimeExtensionsFragment : Fragment(),
|
|||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
notificationManager.notify(1, builder.build())
|
||||
viewModel.invalidatePager()
|
||||
snackString("Extension installed")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ class DevelopersDialogFragment : BottomSheetDialogFragment() {
|
|||
Developer("rebelonion","https://avatars.githubusercontent.com/u/87634197?v=4","Owner and Maintainer","https://github.com/rebelonion"),
|
||||
Developer("Wai What", "https://avatars.githubusercontent.com/u/149729762?v=4", "Icon Designer", "https://github.com/WaiWhat"),
|
||||
Developer("Aayush262", "https://avatars.githubusercontent.com/u/99584765?v=4", "Contributor", "https://github.com/aayush2622"),
|
||||
Developer("MarshMeadow", "https://avatars.githubusercontent.com/u/88599122?v=4", "Beta Icon Designer", "https://github.com/MarshMeadow?tab=repositories"),
|
||||
)
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
|
|
|
@ -49,7 +49,7 @@ ThemeManager(this).applyTheme()
|
|||
val viewPager = findViewById<ViewPager2>(R.id.viewPager)
|
||||
|
||||
viewPager.adapter = object : FragmentStateAdapter(this) {
|
||||
override fun getItemCount(): Int = 4
|
||||
override fun getItemCount(): Int = 6
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
return when (position) {
|
||||
|
@ -57,24 +57,45 @@ ThemeManager(this).applyTheme()
|
|||
1 -> AnimeExtensionsFragment()
|
||||
2 -> InstalledMangaExtensionsFragment()
|
||||
3 -> MangaExtensionsFragment()
|
||||
4 -> InstalledNovelExtensionsFragment()
|
||||
5 -> NovelExtensionsFragment()
|
||||
else -> AnimeExtensionsFragment()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
val searchView: AutoCompleteTextView = findViewById(R.id.searchViewText)
|
||||
|
||||
tabLayout.addOnTabSelectedListener(
|
||||
object : TabLayout.OnTabSelectedListener {
|
||||
override fun onTabSelected(tab: TabLayout.Tab) {
|
||||
searchView.setText("")
|
||||
}
|
||||
|
||||
override fun onTabUnselected(tab: TabLayout.Tab) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
override fun onTabReselected(tab: TabLayout.Tab) {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
TabLayoutMediator(tabLayout, viewPager) { tab, position ->
|
||||
tab.text = when (position) {
|
||||
0 -> "Installed Anime"
|
||||
1 -> "Available Anime"
|
||||
2 -> "Installed Manga"
|
||||
3 -> "Available Manga"
|
||||
4 -> "Installed Novels"
|
||||
5 -> "Available Novels"
|
||||
else -> null
|
||||
}
|
||||
}.attach()
|
||||
|
||||
|
||||
val searchView: AutoCompleteTextView = findViewById(R.id.searchViewText)
|
||||
|
||||
searchView.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ import ani.dantotsu.loadData
|
|||
import ani.dantotsu.others.LanguageMapper
|
||||
import ani.dantotsu.saveData
|
||||
import ani.dantotsu.settings.extensionprefs.AnimeSourcePreferencesFragment
|
||||
import ani.dantotsu.snackString
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
|
@ -157,6 +158,7 @@ class InstalledAnimeExtensionsFragment : Fragment() {
|
|||
.setContentText("Error: ${error.message}")
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
notificationManager.notify(1, builder.build())
|
||||
snackString("Update failed: ${error.message}")
|
||||
},
|
||||
{
|
||||
val builder = NotificationCompat.Builder(
|
||||
|
@ -168,10 +170,12 @@ class InstalledAnimeExtensionsFragment : Fragment() {
|
|||
.setContentText("The extension has been successfully updated.")
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
notificationManager.notify(1, builder.build())
|
||||
snackString("Extension updated")
|
||||
}
|
||||
)
|
||||
} else {
|
||||
animeExtensionManager.uninstallExtension(pkg.pkgName)
|
||||
snackString("Extension uninstalled")
|
||||
}
|
||||
}
|
||||
}, skipIcons
|
||||
|
|
|
@ -26,6 +26,7 @@ import ani.dantotsu.databinding.FragmentMangaExtensionsBinding
|
|||
import ani.dantotsu.loadData
|
||||
import ani.dantotsu.settings.extensionprefs.MangaSourcePreferencesFragment
|
||||
import ani.dantotsu.others.LanguageMapper
|
||||
import ani.dantotsu.snackString
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
|
@ -137,6 +138,7 @@ class InstalledMangaExtensionsFragment : Fragment() {
|
|||
.setContentText("Error: ${error.message}")
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
notificationManager.notify(1, builder.build())
|
||||
snackString("Update failed: ${error.message}")
|
||||
},
|
||||
{
|
||||
val builder = NotificationCompat.Builder(
|
||||
|
@ -148,10 +150,12 @@ class InstalledMangaExtensionsFragment : Fragment() {
|
|||
.setContentText("The extension has been successfully updated.")
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
notificationManager.notify(1, builder.build())
|
||||
snackString("Extension updated")
|
||||
}
|
||||
)
|
||||
} else {
|
||||
mangaExtensionManager.uninstallExtension(pkg.pkgName)
|
||||
snackString("Extension uninstalled")
|
||||
}
|
||||
}
|
||||
}, skipIcons)
|
||||
|
|
|
@ -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.MangaExtensionsViewModelFactory
|
||||
import ani.dantotsu.settings.paging.OnMangaInstallClickListener
|
||||
import ani.dantotsu.snackString
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
||||
|
@ -104,6 +105,7 @@ class MangaExtensionsFragment : Fragment(),
|
|||
.setContentText("Error: ${error.message}")
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
notificationManager.notify(1, builder.build())
|
||||
snackString("Installation failed: ${error.message}")
|
||||
},
|
||||
{
|
||||
val builder = NotificationCompat.Builder(
|
||||
|
@ -116,6 +118,7 @@ class MangaExtensionsFragment : Fragment(),
|
|||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
notificationManager.notify(1, builder.build())
|
||||
viewModel.invalidatePager()
|
||||
snackString("Extension installed")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.network.AndroidCookieJar
|
||||
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE
|
||||
import eu.kanade.tachiyomi.network.PREF_DOH_GOOGLE
|
||||
import eu.kanade.tachiyomi.network.dohCloudflare
|
||||
import eu.kanade.tachiyomi.network.dohGoogle
|
||||
import android.os.Build
|
||||
import ani.dantotsu.Mapper
|
||||
import ani.dantotsu.defaultHeaders
|
||||
import com.lagradost.nicehttp.Requests
|
||||
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
|
||||
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
|
||||
import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor
|
||||
|
@ -32,13 +31,13 @@ class NetworkHelper(
|
|||
CloudflareInterceptor(context, cookieJar, ::defaultUserAgentProvider)
|
||||
}
|
||||
|
||||
private val baseClientBuilder: OkHttpClient.Builder
|
||||
get() {
|
||||
private fun baseClientBuilder(callTimout: Int = 2): OkHttpClient.Builder
|
||||
{
|
||||
val builder = OkHttpClient.Builder()
|
||||
.cookieJar(cookieJar)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.callTimeout(2, TimeUnit.MINUTES)
|
||||
.callTimeout(callTimout.toLong(), TimeUnit.MINUTES)
|
||||
.addInterceptor(UncaughtExceptionInterceptor())
|
||||
.addInterceptor(userAgentInterceptor)
|
||||
|
||||
|
@ -68,7 +67,10 @@ class NetworkHelper(
|
|||
return builder
|
||||
}
|
||||
|
||||
val client by lazy { baseClientBuilder.cache(Cache(cacheDir, cacheSize)).build() }
|
||||
|
||||
|
||||
val client by lazy { baseClientBuilder().cache(Cache(cacheDir, cacheSize)).build() }
|
||||
val downloadClient by lazy { baseClientBuilder(20).build() }
|
||||
|
||||
@Suppress("UNUSED")
|
||||
val cloudflareClient by lazy {
|
||||
|
@ -77,5 +79,17 @@ class NetworkHelper(
|
|||
.build()
|
||||
}
|
||||
|
||||
val requestClient = Requests(
|
||||
client,
|
||||
mapOf(
|
||||
"User-Agent" to
|
||||
defaultUserAgentProvider()
|
||||
.format(Build.VERSION.RELEASE, Build.MODEL)
|
||||
),
|
||||
defaultCacheTime = 6,
|
||||
defaultCacheTimeUnit = TimeUnit.HOURS,
|
||||
responseParser = Mapper
|
||||
)
|
||||
|
||||
fun defaultUserAgentProvider() = preferences.defaultUserAgent().get().trim()
|
||||
}
|
||||
|
|
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:inputType="none"
|
||||
android:padding="8dp"
|
||||
android:text="@string/watch"
|
||||
android:text="@string/read"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="?android:attr/textColorSecondary"
|
||||
android:textSize="14sp"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue