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