Light novel support
This commit is contained in:
parent
32f918450a
commit
c7bc1ffe9e
39 changed files with 2537 additions and 91 deletions
|
@ -11,30 +11,22 @@ import android.os.Build
|
|||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import ani.dantotsu.FileUrl
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
|
||||
import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource
|
||||
import eu.kanade.tachiyomi.network.interceptor.CloudflareBypassException
|
||||
import ani.dantotsu.currContext
|
||||
import ani.dantotsu.download.manga.MangaDownloaderService
|
||||
import ani.dantotsu.download.manga.ServiceDataSingleton
|
||||
import ani.dantotsu.logger
|
||||
import ani.dantotsu.media.anime.AnimeNameAdapter
|
||||
import ani.dantotsu.media.manga.ImageData
|
||||
import ani.dantotsu.media.manga.MangaCache
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
|
||||
import eu.kanade.tachiyomi.animesource.model.SEpisode
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimesPage
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||
import eu.kanade.tachiyomi.animesource.model.Track
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
|
@ -49,11 +41,8 @@ import kotlinx.coroutines.coroutineScope
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.URL
|
||||
import java.net.URLDecoder
|
||||
import uy.kohesive.injekt.Injekt
|
||||
|
|
|
@ -167,7 +167,7 @@ data class ShowResponse(
|
|||
val total: Int? = null,
|
||||
|
||||
//In case you want to sent some extra data
|
||||
val extra : Map<String,String>?=null,
|
||||
val extra : MutableMap<String,String>?=null,
|
||||
|
||||
//SAnime object from Aniyomi
|
||||
val sAnime: SAnime? = null,
|
||||
|
@ -175,7 +175,7 @@ data class ShowResponse(
|
|||
//SManga object from Aniyomi
|
||||
val sManga: SManga? = null
|
||||
) : Serializable {
|
||||
constructor(name: String, link: String, coverUrl: String, otherNames: List<String> = listOf(), total: Int? = null, extra: Map<String, String>?=null)
|
||||
constructor(name: String, link: String, coverUrl: String, otherNames: List<String> = listOf(), total: Int? = null, extra: MutableMap<String, String>?=null)
|
||||
: this(name, link, FileUrl(coverUrl), otherNames, total, extra)
|
||||
|
||||
constructor(name: String, link: String, coverUrl: String, otherNames: List<String> = listOf(), total: Int? = null)
|
||||
|
|
8
app/src/main/java/ani/dantotsu/parsers/NovelInterface.kt
Normal file
8
app/src/main/java/ani/dantotsu/parsers/NovelInterface.kt
Normal file
|
@ -0,0 +1,8 @@
|
|||
package ani.dantotsu.parsers
|
||||
import com.lagradost.nicehttp.Requests
|
||||
|
||||
|
||||
interface NovelInterface {
|
||||
suspend fun search(query: String, client: Requests): List<ShowResponse>
|
||||
suspend fun loadBook(link: String, extra: Map<String, String>?, client: Requests): Book
|
||||
}
|
|
@ -1,9 +1,32 @@
|
|||
package ani.dantotsu.parsers
|
||||
|
||||
import android.util.Log
|
||||
import ani.dantotsu.Lazier
|
||||
import ani.dantotsu.lazyList
|
||||
import ani.dantotsu.parsers.novel.NovelExtension
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import ani.dantotsu.parsers.novel.DynamicNovelParser
|
||||
|
||||
object NovelSources : NovelReadSources() {
|
||||
override val list: List<Lazier<BaseParser>> = lazyList(
|
||||
)
|
||||
override var list: List<Lazier<BaseParser>> = emptyList()
|
||||
|
||||
suspend fun init(fromExtensions: StateFlow<List<NovelExtension.Installed>>) {
|
||||
// Initialize with the first value from StateFlow
|
||||
val initialExtensions = fromExtensions.first()
|
||||
list = createParsersFromExtensions(initialExtensions)
|
||||
|
||||
// Update as StateFlow emits new values
|
||||
fromExtensions.collect { extensions ->
|
||||
list = createParsersFromExtensions(extensions)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createParsersFromExtensions(extensions: List<NovelExtension.Installed>): List<Lazier<BaseParser>> {
|
||||
Log.d("NovelSources", "createParsersFromExtensions")
|
||||
Log.d("NovelSources", extensions.toString())
|
||||
return extensions.map { extension ->
|
||||
val name = extension.name
|
||||
Lazier({ DynamicNovelParser(extension) }, name)
|
||||
}
|
||||
}
|
||||
}
|
41
app/src/main/java/ani/dantotsu/parsers/novel/NovelAdapter.kt
Normal file
41
app/src/main/java/ani/dantotsu/parsers/novel/NovelAdapter.kt
Normal file
|
@ -0,0 +1,41 @@
|
|||
package ani.dantotsu.parsers.novel
|
||||
|
||||
import ani.dantotsu.FileUrl
|
||||
import ani.dantotsu.parsers.Book
|
||||
import ani.dantotsu.parsers.NovelInterface
|
||||
import ani.dantotsu.parsers.NovelParser
|
||||
import ani.dantotsu.parsers.ShowResponse
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class NovelAdapter {
|
||||
}
|
||||
|
||||
class DynamicNovelParser(extension: NovelExtension.Installed) : NovelParser() {
|
||||
override val volumeRegex = Regex("vol\\.? (\\d+(\\.\\d+)?)|volume (\\d+(\\.\\d+)?)", RegexOption.IGNORE_CASE)
|
||||
var extension: NovelExtension.Installed
|
||||
val client = Injekt.get<NetworkHelper>().requestClient
|
||||
init {
|
||||
this.extension = extension
|
||||
}
|
||||
|
||||
override suspend fun search(query: String): List<ShowResponse> {
|
||||
val source = extension.sources.firstOrNull()
|
||||
if (source is NovelInterface) {
|
||||
return source.search(query, client)
|
||||
} else {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun loadBook(link: String, extra: Map<String, String>?): Book {
|
||||
val source = extension.sources.firstOrNull()
|
||||
if (source is NovelInterface) {
|
||||
return source.loadBook(link, extra, client)
|
||||
} else {
|
||||
return Book("", "", "", emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
package ani.dantotsu.parsers.novel
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import ani.dantotsu.parsers.NovelInterface
|
||||
|
||||
sealed class NovelExtension {
|
||||
abstract val name: String
|
||||
abstract val pkgName: String
|
||||
abstract val versionName: String
|
||||
abstract val versionCode: Long
|
||||
|
||||
data class Installed(
|
||||
override val name: String,
|
||||
override val pkgName: String,
|
||||
override val versionName: String,
|
||||
override val versionCode: Long,
|
||||
val sources: List<NovelInterface>,
|
||||
val icon: Drawable?,
|
||||
val hasUpdate: Boolean = false,
|
||||
val isObsolete: Boolean = false,
|
||||
val isUnofficial: Boolean = false,
|
||||
) : NovelExtension()
|
||||
|
||||
data class Available(
|
||||
override val name: String,
|
||||
override val pkgName: String,
|
||||
override val versionName: String,
|
||||
override val versionCode: Long,
|
||||
val sources: List<AvailableNovelSources>,
|
||||
val iconUrl: String,
|
||||
) : NovelExtension()
|
||||
}
|
||||
|
||||
data class AvailableNovelSources(
|
||||
val id: Long,
|
||||
val lang: String,
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
) {
|
||||
fun toNovelSourceData(): NovelSourceData {
|
||||
return NovelSourceData(
|
||||
id = this.id,
|
||||
lang = this.lang,
|
||||
name = this.name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class NovelSourceData(
|
||||
val id: Long,
|
||||
val lang: String,
|
||||
val name: String,
|
||||
) {
|
||||
|
||||
val isMissingInfo: Boolean = name.isBlank() || lang.isBlank()
|
||||
}
|
|
@ -0,0 +1,178 @@
|
|||
package ani.dantotsu.parsers.novel
|
||||
|
||||
|
||||
import android.content.Context
|
||||
import ani.dantotsu.currContext
|
||||
import ani.dantotsu.logger
|
||||
import eu.kanade.tachiyomi.extension.ExtensionUpdateNotifier
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeLoadResult
|
||||
import eu.kanade.tachiyomi.extension.anime.util.AnimeExtensionLoader
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.lang.withIOContext
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.Date
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
class NovelExtensionGithubApi {
|
||||
|
||||
private val networkService: NetworkHelper by injectLazy()
|
||||
private val novelExtensionManager: NovelExtensionManager by injectLazy()
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val lastExtCheck: Long = currContext()?.getSharedPreferences("Dantotsu", Context.MODE_PRIVATE)?.getLong("last_ext_check", 0)?:0
|
||||
|
||||
private var requiresFallbackSource = false
|
||||
|
||||
suspend fun findExtensions(): List<NovelExtension.Available> {
|
||||
return withIOContext {
|
||||
val githubResponse = if (requiresFallbackSource) {
|
||||
null
|
||||
} else {
|
||||
try {
|
||||
networkService.client
|
||||
.newCall(GET("${REPO_URL_PREFIX}index.min.json"))
|
||||
.awaitSuccess()
|
||||
} catch (e: Throwable) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to get extensions from GitHub" }
|
||||
requiresFallbackSource = true
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val response = githubResponse ?: run {
|
||||
logger("using fallback source")
|
||||
networkService.client
|
||||
.newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json"))
|
||||
.awaitSuccess()
|
||||
}
|
||||
|
||||
logger("response: $response")
|
||||
|
||||
val extensions = with(json) {
|
||||
response
|
||||
.parseAs<List<NovelExtensionJsonObject>>()
|
||||
.toExtensions()
|
||||
}
|
||||
|
||||
// Sanity check - a small number of extensions probably means something broke
|
||||
// with the repo generator
|
||||
/*if (extensions.size < 10) { //TODO: uncomment when more extensions are added
|
||||
throw Exception()
|
||||
}*/
|
||||
logger("extensions: $extensions")
|
||||
extensions
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkForUpdates(context: Context, fromAvailableExtensionList: Boolean = false): List<AnimeExtension.Installed>? {
|
||||
// Limit checks to once a day at most
|
||||
if (fromAvailableExtensionList && Date().time < lastExtCheck + 1.days.inWholeMilliseconds) {
|
||||
return null
|
||||
}
|
||||
|
||||
val extensions = if (fromAvailableExtensionList) {
|
||||
novelExtensionManager.availableExtensionsFlow.value
|
||||
} else {
|
||||
findExtensions().also { context.getSharedPreferences("Dantotsu", Context.MODE_PRIVATE)?.edit()?.putLong("last_ext_check", Date().time)?.apply() }
|
||||
}
|
||||
|
||||
val installedExtensions = NovelExtensionLoader.loadExtensions(context)
|
||||
.filterIsInstance<AnimeLoadResult.Success>()
|
||||
.map { it.extension }
|
||||
|
||||
val extensionsWithUpdate = mutableListOf<AnimeExtension.Installed>()
|
||||
for (installedExt in installedExtensions) {
|
||||
val pkgName = installedExt.pkgName
|
||||
val availableExt = extensions.find { it.pkgName == pkgName } ?: continue
|
||||
|
||||
val hasUpdatedVer = availableExt.versionCode > installedExt.versionCode
|
||||
val hasUpdate = installedExt.isUnofficial.not() && (hasUpdatedVer)
|
||||
if (hasUpdate) {
|
||||
extensionsWithUpdate.add(installedExt)
|
||||
}
|
||||
}
|
||||
|
||||
if (extensionsWithUpdate.isNotEmpty()) {
|
||||
ExtensionUpdateNotifier(context).promptUpdates(extensionsWithUpdate.map { it.name })
|
||||
}
|
||||
|
||||
return extensionsWithUpdate
|
||||
}
|
||||
|
||||
private fun List<NovelExtensionJsonObject>.toExtensions(): List<NovelExtension.Available> {
|
||||
return mapNotNull { extension ->
|
||||
val sources = extension.sources?.map { source ->
|
||||
NovelExtensionSourceJsonObject(
|
||||
source.id,
|
||||
source.lang,
|
||||
source.name,
|
||||
source.baseUrl,
|
||||
)
|
||||
}
|
||||
val iconUrl = "${REPO_URL_PREFIX}icons/${extension.pkg}.png"
|
||||
NovelExtension.Available(
|
||||
extension.name,
|
||||
extension.pkg,
|
||||
extension.apk,
|
||||
extension.code,
|
||||
sources?.toSources() ?: emptyList(),
|
||||
iconUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<NovelExtensionSourceJsonObject>.toSources(): List<AvailableNovelSources> {
|
||||
return map { source ->
|
||||
AvailableNovelSources(
|
||||
source.id,
|
||||
source.lang,
|
||||
source.name,
|
||||
source.baseUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getApkUrl(extension: NovelExtension.Available): String {
|
||||
return "${getUrlPrefix()}apk/${extension.pkgName}.apk"
|
||||
}
|
||||
private fun getUrlPrefix(): String {
|
||||
return if (requiresFallbackSource) {
|
||||
FALLBACK_REPO_URL_PREFIX
|
||||
} else {
|
||||
REPO_URL_PREFIX
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/dannovels/novel-extensions/main/"
|
||||
private const val FALLBACK_REPO_URL_PREFIX = "https://gcore.jsdelivr.net/gh/dannovels/novel-extensions@latest/"
|
||||
@Serializable
|
||||
private data class NovelExtensionJsonObject(
|
||||
val name: String,
|
||||
val pkg: String,
|
||||
val apk: String,
|
||||
val lang: String,
|
||||
val code: Long,
|
||||
val version: String,
|
||||
val nsfw: Int,
|
||||
val hasReadme: Int = 0,
|
||||
val hasChangelog: Int = 0,
|
||||
val sources: List<NovelExtensionSourceJsonObject>?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class NovelExtensionSourceJsonObject(
|
||||
val id: Long,
|
||||
val lang: String,
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
)
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package ani.dantotsu.parsers.novel
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.os.FileObserver
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.ContextCompat
|
||||
import ani.dantotsu.parsers.novel.FileObserver.fileObserver
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
|
||||
import eu.kanade.tachiyomi.extension.anime.model.AnimeLoadResult
|
||||
import eu.kanade.tachiyomi.extension.anime.util.AnimeExtensionLoader
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.async
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.lang.launchNow
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import java.io.File
|
||||
import java.lang.Exception
|
||||
|
||||
|
||||
class NovelExtensionFileObserver(private val listener: Listener, private val path: String) : FileObserver(path, CREATE or DELETE or MOVED_FROM or MOVED_TO or MODIFY) {
|
||||
|
||||
init {
|
||||
fileObserver = this
|
||||
}
|
||||
/**
|
||||
* Starts observing the file changes in the directory.
|
||||
*/
|
||||
fun register() {
|
||||
startWatching()
|
||||
}
|
||||
|
||||
|
||||
override fun onEvent(event: Int, file: String?) {
|
||||
Log.e("NovelExtensionFileObserver", "Event: $event")
|
||||
if (file == null) return
|
||||
|
||||
val fullPath = File(path, file)
|
||||
|
||||
when (event) {
|
||||
CREATE -> {
|
||||
Log.e("NovelExtensionFileObserver", "File created: $fullPath")
|
||||
listener.onExtensionFileCreated(fullPath)
|
||||
}
|
||||
DELETE -> {
|
||||
Log.e("NovelExtensionFileObserver", "File deleted: $fullPath")
|
||||
listener.onExtensionFileDeleted(fullPath)
|
||||
}
|
||||
MODIFY -> {
|
||||
Log.e("NovelExtensionFileObserver", "File modified: $fullPath")
|
||||
listener.onExtensionFileModified(fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the extension from the file.
|
||||
*
|
||||
* @param file The file name of the extension.
|
||||
*/
|
||||
//private suspend fun loadExtensionFromFile(file: String): String {
|
||||
// return file
|
||||
//}
|
||||
|
||||
interface Listener {
|
||||
fun onExtensionFileCreated(file: File)
|
||||
fun onExtensionFileDeleted(file: File)
|
||||
fun onExtensionFileModified(file: File)
|
||||
}
|
||||
}
|
||||
|
||||
object FileObserver {
|
||||
var fileObserver: FileObserver? = null
|
||||
}
|
|
@ -0,0 +1,367 @@
|
|||
package ani.dantotsu.parsers.novel
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.net.toUri
|
||||
import ani.dantotsu.snackString
|
||||
import com.jakewharton.rxrelay.PublishRelay
|
||||
import eu.kanade.tachiyomi.extension.InstallStep
|
||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||
import logcat.LogPriority
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The installer which installs, updates and uninstalls the extensions.
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
internal class NovelExtensionInstaller(private val context: Context) {
|
||||
|
||||
/**
|
||||
* The system's download manager
|
||||
*/
|
||||
private val downloadManager = context.getSystemService<DownloadManager>()!!
|
||||
|
||||
/**
|
||||
* The broadcast receiver which listens to download completion events.
|
||||
*/
|
||||
private val downloadReceiver = DownloadCompletionReceiver()
|
||||
|
||||
/**
|
||||
* The currently requested downloads, with the package name (unique id) as key, and the id
|
||||
* returned by the download manager.
|
||||
*/
|
||||
private val activeDownloads = hashMapOf<String, Long>()
|
||||
|
||||
/**
|
||||
* Relay used to notify the installation step of every download.
|
||||
*/
|
||||
private val downloadsRelay = PublishRelay.create<Pair<Long, InstallStep>>()
|
||||
|
||||
/**
|
||||
* Adds the given extension to the downloads queue and returns an observable containing its
|
||||
* step in the installation process.
|
||||
*
|
||||
* @param url The url of the apk.
|
||||
* @param extension The extension to install.
|
||||
*/
|
||||
fun downloadAndInstall(url: String, extension: NovelExtension) = Observable.defer {
|
||||
val pkgName = extension.pkgName
|
||||
|
||||
val oldDownload = activeDownloads[pkgName]
|
||||
if (oldDownload != null) {
|
||||
deleteDownload(pkgName)
|
||||
}
|
||||
|
||||
val sourcePath = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
|
||||
//if the file is already downloaded, remove it
|
||||
val fileToDelete = File("$sourcePath/${url.toUri().lastPathSegment}")
|
||||
if (fileToDelete.exists()) {
|
||||
if (fileToDelete.delete()) {
|
||||
Log.i("Install APK", "APK file deleted successfully.")
|
||||
} else {
|
||||
Log.e("Install APK", "Failed to delete APK file.")
|
||||
}
|
||||
} else {
|
||||
Log.e("Install APK", "APK file not found.")
|
||||
}
|
||||
|
||||
// Register the receiver after removing (and unregistering) the previous download
|
||||
downloadReceiver.register()
|
||||
|
||||
val downloadUri = url.toUri()
|
||||
val request = DownloadManager.Request(downloadUri)
|
||||
.setTitle(extension.name)
|
||||
.setMimeType(NovelExtensionInstaller.APK_MIME)
|
||||
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadUri.lastPathSegment)
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
val id = downloadManager.enqueue(request)
|
||||
activeDownloads[pkgName] = id
|
||||
|
||||
downloadsRelay.filter { it.first == id }
|
||||
.map { it.second }
|
||||
// Poll download status
|
||||
.mergeWith(pollStatus(id))
|
||||
// Stop when the application is installed or errors
|
||||
.takeUntil { it.isCompleted() }
|
||||
// Always notify on main thread
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
// Always remove the download when unsubscribed
|
||||
.doOnUnsubscribe { deleteDownload(pkgName) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable that polls the given download id for its status every second, as the
|
||||
* manager doesn't have any notification system. It'll stop once the download finishes.
|
||||
*
|
||||
* @param id The id of the download to poll.
|
||||
*/
|
||||
private fun pollStatus(id: Long): Observable<InstallStep> {
|
||||
val query = DownloadManager.Query().setFilterById(id)
|
||||
|
||||
return Observable.interval(0, 1, TimeUnit.SECONDS)
|
||||
// Get the current download status
|
||||
.map {
|
||||
downloadManager.query(query).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||
} else {
|
||||
DownloadManager.STATUS_FAILED
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ignore duplicate results
|
||||
.distinctUntilChanged()
|
||||
// Stop polling when the download fails or finishes
|
||||
.takeUntil { it == DownloadManager.STATUS_SUCCESSFUL || it == DownloadManager.STATUS_FAILED }
|
||||
// Map to our model
|
||||
.flatMap { status ->
|
||||
when (status) {
|
||||
DownloadManager.STATUS_PENDING -> Observable.just(InstallStep.Pending)
|
||||
DownloadManager.STATUS_RUNNING -> Observable.just(InstallStep.Downloading)
|
||||
DownloadManager.STATUS_SUCCESSFUL -> Observable.just(InstallStep.Installing)
|
||||
else -> Observable.empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun installApk(downloadId: Long, uri: Uri, context: Context, pkgName: String) : InstallStep {
|
||||
val sourcePath = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.absolutePath + "/" + uri.lastPathSegment
|
||||
val destinationPath = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/$pkgName.apk"
|
||||
|
||||
val destinationPathDirectory = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/"
|
||||
val destinationPathDirectoryFile = File(destinationPathDirectory)
|
||||
|
||||
|
||||
// Check if source path is obtained correctly
|
||||
if (sourcePath == null) {
|
||||
Log.e("Install APK", "Source APK path not found.")
|
||||
downloadsRelay.call(downloadId to InstallStep.Error)
|
||||
return InstallStep.Error
|
||||
}
|
||||
|
||||
// Create the destination directory if it doesn't exist
|
||||
val destinationDir = File(destinationPath).parentFile
|
||||
if (destinationDir?.exists() == false) {
|
||||
destinationDir.mkdirs()
|
||||
}
|
||||
if(destinationDir?.setWritable(true) == false) {
|
||||
Log.e("Install APK", "Failed to set destinationDir to writable.")
|
||||
downloadsRelay.call(downloadId to InstallStep.Error)
|
||||
return InstallStep.Error
|
||||
}
|
||||
|
||||
// Copy the file to the new location
|
||||
copyFileToInternalStorage(sourcePath, destinationPath)
|
||||
Log.i("Install APK", "APK moved to $destinationPath")
|
||||
downloadsRelay.call(downloadId to InstallStep.Installed)
|
||||
return InstallStep.Installed
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels extension install and remove from download manager and installer.
|
||||
*/
|
||||
fun cancelInstall(pkgName: String) {
|
||||
val downloadId = activeDownloads.remove(pkgName) ?: return
|
||||
downloadManager.remove(downloadId)
|
||||
}
|
||||
|
||||
fun uninstallApk(pkgName: String, context: Context) {
|
||||
val apkPath = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/$pkgName.apk"
|
||||
val fileToDelete = File(apkPath)
|
||||
//give write permission to the file
|
||||
if (fileToDelete.exists() && !fileToDelete.canWrite()) {
|
||||
Log.i("Uninstall APK", "File is not writable. Giving write permission.")
|
||||
val a = fileToDelete.setWritable(true)
|
||||
Log.i("Uninstall APK", "Success: $a")
|
||||
}
|
||||
//set the directory to writable
|
||||
val destinationDir = File(apkPath).parentFile
|
||||
if (destinationDir?.exists() == false) {
|
||||
destinationDir.mkdirs()
|
||||
}
|
||||
val s = destinationDir?.setWritable(true)
|
||||
Log.i("Uninstall APK", "Success destinationDir: $s")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
try {
|
||||
Files.delete(fileToDelete.toPath())
|
||||
} catch (e: Exception) {
|
||||
Log.e("Uninstall APK", "Failed to delete APK file.")
|
||||
Log.e("Uninstall APK", e.toString())
|
||||
snackString("Failed to delete APK file.")
|
||||
}
|
||||
} else {
|
||||
if (fileToDelete.exists()) {
|
||||
if (fileToDelete.delete()) {
|
||||
Log.i("Uninstall APK", "APK file deleted successfully.")
|
||||
snackString("APK file deleted successfully.")
|
||||
} else {
|
||||
Log.e("Uninstall APK", "Failed to delete APK file.")
|
||||
snackString("Failed to delete APK file.")
|
||||
}
|
||||
} else {
|
||||
Log.e("Uninstall APK", "APK file not found.")
|
||||
snackString("APK file not found.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyFileToInternalStorage(sourcePath: String, destinationPath: String) {
|
||||
val source = File(sourcePath)
|
||||
val destination = File(destinationPath)
|
||||
destination.setWritable(true)
|
||||
|
||||
var inputChannel: FileChannel? = null
|
||||
var outputChannel: FileChannel? = null
|
||||
try {
|
||||
inputChannel = FileInputStream(source).channel
|
||||
outputChannel = FileOutputStream(destination).channel
|
||||
inputChannel.transferTo(0, inputChannel.size(), outputChannel)
|
||||
destination.setWritable(false)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
inputChannel?.close()
|
||||
outputChannel?.close()
|
||||
}
|
||||
|
||||
Log.i("File Copy", "File copied to internal storage.")
|
||||
}
|
||||
|
||||
private fun getRealPathFromURI(context: Context, contentUri: Uri): String? {
|
||||
var cursor: Cursor? = null
|
||||
try {
|
||||
val proj = arrayOf(MediaStore.Images.Media.DATA)
|
||||
cursor = context.contentResolver.query(contentUri, proj, null, null, null)
|
||||
val columnIndex = cursor?.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
|
||||
if (cursor != null && cursor.moveToFirst() && columnIndex != null) {
|
||||
return cursor.getString(columnIndex)
|
||||
}
|
||||
} finally {
|
||||
cursor?.close()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the step of the installation of an extension.
|
||||
*
|
||||
* @param downloadId The id of the download.
|
||||
* @param step New install step.
|
||||
*/
|
||||
fun updateInstallStep(downloadId: Long, step: InstallStep) {
|
||||
downloadsRelay.call(downloadId to step)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the download for the given package name.
|
||||
*
|
||||
* @param pkgName The package name of the download to delete.
|
||||
*/
|
||||
private fun deleteDownload(pkgName: String) {
|
||||
val downloadId = activeDownloads.remove(pkgName)
|
||||
if (downloadId != null) {
|
||||
downloadManager.remove(downloadId)
|
||||
}
|
||||
if (activeDownloads.isEmpty()) {
|
||||
downloadReceiver.unregister()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver that listens to download status events.
|
||||
*/
|
||||
private inner class DownloadCompletionReceiver : BroadcastReceiver() {
|
||||
|
||||
/**
|
||||
* Whether this receiver is currently registered.
|
||||
*/
|
||||
private var isRegistered = false
|
||||
|
||||
/**
|
||||
* Registers this receiver if it's not already.
|
||||
*/
|
||||
fun register() {
|
||||
if (isRegistered) return
|
||||
isRegistered = true
|
||||
|
||||
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
||||
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_EXPORTED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters this receiver if it's not already.
|
||||
*/
|
||||
fun unregister() {
|
||||
if (!isRegistered) return
|
||||
isRegistered = false
|
||||
|
||||
context.unregisterReceiver(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a download event is received. It looks for the download in the current active
|
||||
* downloads and notifies its installation step.
|
||||
*/
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0) ?: return
|
||||
|
||||
// Avoid events for downloads we didn't request
|
||||
if (id !in activeDownloads.values) return
|
||||
|
||||
val uri = downloadManager.getUriForDownloadedFile(id)
|
||||
|
||||
// Set next installation step
|
||||
if (uri == null) {
|
||||
logcat(LogPriority.ERROR) { "Couldn't locate downloaded APK" }
|
||||
downloadsRelay.call(id to InstallStep.Error)
|
||||
return
|
||||
}
|
||||
|
||||
val query = DownloadManager.Query().setFilterById(id)
|
||||
downloadManager.query(query).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val localUri = cursor.getString(
|
||||
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI),
|
||||
).removePrefix(FILE_SCHEME)
|
||||
val pkgName = extractPkgNameFromUri(localUri)
|
||||
installApk(id, File(localUri).getUriCompat(context), context, pkgName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractPkgNameFromUri(localUri: String): String {
|
||||
val uri = Uri.parse(localUri)
|
||||
val path = uri.path
|
||||
val pkgName = path?.substring(path.lastIndexOf('/') + 1)?.removeSuffix(".apk")
|
||||
Log.i("Install APK", "Package name: $pkgName")
|
||||
return pkgName ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val APK_MIME = "application/vnd.android.package-archive"
|
||||
const val EXTRA_DOWNLOAD_ID = "NovelExtensionInstaller.extra.DOWNLOAD_ID"
|
||||
const val FILE_SCHEME = "file://"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,129 @@
|
|||
package ani.dantotsu.parsers.novel
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.util.Log
|
||||
import ani.dantotsu.logger
|
||||
import ani.dantotsu.parsers.NovelInterface
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import dalvik.system.PathClassLoader
|
||||
import eu.kanade.tachiyomi.extension.anime.util.AnimeExtensionLoader
|
||||
import eu.kanade.tachiyomi.util.lang.Hash
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
internal object NovelExtensionLoader {
|
||||
|
||||
private const val officialSignature =
|
||||
"a3061edb369278749b8e8de810d440d38e96417bbd67bbdfc5d9d9ed475ce4a5" //dan's key
|
||||
|
||||
fun loadExtensions(context: Context): List<NovelLoadResult> {
|
||||
val installDir = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/"
|
||||
val results = mutableListOf<NovelLoadResult>()
|
||||
//the number of files
|
||||
Log.e("NovelExtensionLoader", "Loading extensions from $installDir")
|
||||
Log.e("NovelExtensionLoader", "Loading extensions from ${File(installDir).listFiles()?.size}")
|
||||
File(installDir).setWritable(false)
|
||||
File(installDir).listFiles()?.forEach {
|
||||
//set the file to read only
|
||||
it.setWritable(false)
|
||||
Log.e("NovelExtensionLoader", "Loading extension ${it.name}")
|
||||
val extension = loadExtension(context, it)
|
||||
if (extension is NovelLoadResult.Success) {
|
||||
results.add(extension)
|
||||
} else {
|
||||
logger("Failed to load extension ${it.name}")
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to load an extension from the given package name. It checks if the extension
|
||||
* contains the required feature flag before trying to load it.
|
||||
*/
|
||||
fun loadExtensionFromPkgName(context: Context, pkgName: String): NovelLoadResult {
|
||||
val path = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/$pkgName.apk"
|
||||
//make /extensions/novel read only
|
||||
context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/".let {
|
||||
File(it).setWritable(false)
|
||||
File(it).setReadable(true)
|
||||
}
|
||||
val pkgInfo = try {
|
||||
context.packageManager.getPackageArchiveInfo(path, 0)
|
||||
} catch (error: Exception) {
|
||||
// Unlikely, but the package may have been uninstalled at this point
|
||||
logger("Failed to load extension $pkgName")
|
||||
return NovelLoadResult.Error(Exception("Failed to load extension"))
|
||||
}
|
||||
return loadExtension(context, File(path))
|
||||
}
|
||||
|
||||
fun loadExtension(context: Context, file: File): NovelLoadResult {
|
||||
val packageInfo = context.packageManager.getPackageArchiveInfo(file.absolutePath, 0)
|
||||
?: return NovelLoadResult.Error(Exception("Failed to load extension"))
|
||||
val appInfo = packageInfo.applicationInfo
|
||||
?: return NovelLoadResult.Error(Exception("Failed to load Extension Info"))
|
||||
appInfo.sourceDir = file.absolutePath;
|
||||
appInfo.publicSourceDir = file.absolutePath;
|
||||
|
||||
val signatureHash = getSignatureHash(packageInfo)
|
||||
|
||||
if (signatureHash == null || signatureHash != officialSignature) {
|
||||
logger("Package ${packageInfo.packageName} isn't signed")
|
||||
logger("signatureHash: $signatureHash")
|
||||
//return NovelLoadResult.Error(Exception("Extension not signed"))
|
||||
}
|
||||
|
||||
val extension = NovelExtension.Installed(
|
||||
packageInfo.applicationInfo?.loadLabel(context.packageManager)?.toString() ?:
|
||||
return NovelLoadResult.Error(Exception("Failed to load Extension Info")),
|
||||
packageInfo.packageName
|
||||
?: return NovelLoadResult.Error(Exception("Failed to load Extension Info")),
|
||||
packageInfo.versionName ?: "",
|
||||
packageInfo.versionCode.toLong() ?: 0,
|
||||
loadSources(context, file,
|
||||
packageInfo.applicationInfo?.loadLabel(context.packageManager)?.toString()!!
|
||||
),
|
||||
packageInfo.applicationInfo?.loadIcon(context.packageManager)
|
||||
)
|
||||
|
||||
return NovelLoadResult.Success(extension)
|
||||
}
|
||||
|
||||
private fun getSignatureHash(pkgInfo: PackageInfo): String? {
|
||||
val signatures = pkgInfo.signatures
|
||||
return if (signatures != null && signatures.isNotEmpty()) {
|
||||
Hash.sha256(signatures.first().toByteArray())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSources(context: Context, file: File, className: String): List<NovelInterface> {
|
||||
return try {
|
||||
Log.e("NovelExtensionLoader", "isFileWritable: ${file.canWrite()}")
|
||||
if (file.canWrite()) {
|
||||
val a = file.setWritable(false)
|
||||
Log.e("NovelExtensionLoader", "success: $a")
|
||||
}
|
||||
Log.e("NovelExtensionLoader", "isFileWritable: ${file.canWrite()}")
|
||||
val classLoader = PathClassLoader(file.absolutePath, null, context.classLoader)
|
||||
val className = "some.random.novelextensions.${className.lowercase(Locale.getDefault())}.$className"
|
||||
val loadedClass = classLoader.loadClass(className)
|
||||
val instance = loadedClass.newInstance()
|
||||
val novelInterfaceInstance = instance as? NovelInterface
|
||||
listOfNotNull(novelInterfaceInstance)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
FirebaseCrashlytics.getInstance().recordException(e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class NovelLoadResult {
|
||||
data class Success(val extension: NovelExtension.Installed) : NovelLoadResult()
|
||||
data class Error(val error: Exception) : NovelLoadResult()
|
||||
}
|
|
@ -0,0 +1,243 @@
|
|||
package ani.dantotsu.parsers.novel
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import ani.dantotsu.logger
|
||||
import ani.dantotsu.snackString
|
||||
import eu.kanade.tachiyomi.extension.InstallStep
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import rx.Observable
|
||||
import tachiyomi.core.util.lang.withUIContext
|
||||
import java.io.File
|
||||
|
||||
class NovelExtensionManager(private val context: Context) {
|
||||
var isInitialized = false
|
||||
private set
|
||||
|
||||
|
||||
/**
|
||||
* API where all the available Novel extensions can be found.
|
||||
*/
|
||||
private val api = NovelExtensionGithubApi()
|
||||
|
||||
/**
|
||||
* The installer which installs, updates and uninstalls the Novel extensions.
|
||||
*/
|
||||
private val installer by lazy { NovelExtensionInstaller(context) }
|
||||
|
||||
private val iconMap = mutableMapOf<String, Drawable>()
|
||||
|
||||
private val _installedNovelExtensionsFlow =
|
||||
MutableStateFlow(emptyList<NovelExtension.Installed>())
|
||||
val installedExtensionsFlow = _installedNovelExtensionsFlow.asStateFlow()
|
||||
|
||||
private val _availableNovelExtensionsFlow =
|
||||
MutableStateFlow(emptyList<NovelExtension.Available>())
|
||||
val availableExtensionsFlow = _availableNovelExtensionsFlow.asStateFlow()
|
||||
|
||||
private var availableNovelExtensionsSourcesData: Map<Long, NovelSourceData> = emptyMap()
|
||||
|
||||
private fun setupAvailableNovelExtensionsSourcesDataMap(novelExtensions: List<NovelExtension.Available>) {
|
||||
if (novelExtensions.isEmpty()) return
|
||||
availableNovelExtensionsSourcesData = novelExtensions
|
||||
.flatMap { ext -> ext.sources.map { it.toNovelSourceData() } }
|
||||
.associateBy { it.id }
|
||||
}
|
||||
|
||||
fun getSourceData(id: Long) = availableNovelExtensionsSourcesData[id]
|
||||
|
||||
init {
|
||||
initNovelExtensions()
|
||||
val path = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/"
|
||||
NovelExtensionFileObserver(NovelInstallationListener(),path).register()
|
||||
}
|
||||
|
||||
private fun initNovelExtensions() {
|
||||
val novelExtensions = NovelExtensionLoader.loadExtensions(context)
|
||||
|
||||
_installedNovelExtensionsFlow.value = novelExtensions
|
||||
.filterIsInstance<NovelLoadResult.Success>()
|
||||
.map { it.extension }
|
||||
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the available manga extensions in the [api] and updates [availableExtensions].
|
||||
*/
|
||||
suspend fun findAvailableExtensions() {
|
||||
val extensions: List<NovelExtension.Available> = try {
|
||||
api.findExtensions()
|
||||
} catch (e: Exception) {
|
||||
logger("Error finding extensions: ${e.message}")
|
||||
withUIContext { snackString("Failed to get Novel extensions list") }
|
||||
emptyList()
|
||||
}
|
||||
|
||||
_availableNovelExtensionsFlow.value = extensions
|
||||
updatedInstalledNovelExtensionsStatuses(extensions)
|
||||
setupAvailableNovelExtensionsSourcesDataMap(extensions)
|
||||
}
|
||||
|
||||
private fun updatedInstalledNovelExtensionsStatuses(availableNovelExtensions: List<NovelExtension.Available>) {
|
||||
if (availableNovelExtensions.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val mutInstalledNovelExtensions = _installedNovelExtensionsFlow.value.toMutableList()
|
||||
var hasChanges = false
|
||||
|
||||
for ((index, installedExt) in mutInstalledNovelExtensions.withIndex()) {
|
||||
val pkgName = installedExt.pkgName
|
||||
val availableExt = availableNovelExtensions.find { it.pkgName == pkgName }
|
||||
|
||||
if (availableExt == null && !installedExt.isObsolete) {
|
||||
mutInstalledNovelExtensions[index] = installedExt.copy(isObsolete = true)
|
||||
hasChanges = true
|
||||
} else if (availableExt != null) {
|
||||
val hasUpdate = installedExt.updateExists(availableExt)
|
||||
|
||||
if (installedExt.hasUpdate != hasUpdate) {
|
||||
mutInstalledNovelExtensions[index] = installedExt.copy(hasUpdate = hasUpdate)
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasChanges) {
|
||||
_installedNovelExtensionsFlow.value = mutInstalledNovelExtensions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the installation process for the given novel extension. It will complete
|
||||
* once the novel extension is installed or throws an error. The process will be canceled if
|
||||
* unsubscribed before its completion.
|
||||
*
|
||||
* @param extension The anime extension to be installed.
|
||||
*/
|
||||
fun installExtension(extension: NovelExtension.Available): Observable<InstallStep> {
|
||||
return installer.downloadAndInstall(api.getApkUrl(extension), extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the installation process for the given anime extension. It will complete
|
||||
* once the anime extension is updated or throws an error. The process will be canceled if
|
||||
* unsubscribed before its completion.
|
||||
*
|
||||
* @param extension The anime extension to be updated.
|
||||
*/
|
||||
fun updateExtension(extension: NovelExtension.Installed): Observable<InstallStep> {
|
||||
val availableExt = _availableNovelExtensionsFlow.value.find { it.pkgName == extension.pkgName }
|
||||
?: return Observable.empty()
|
||||
return installExtension(availableExt)
|
||||
}
|
||||
|
||||
fun cancelInstallUpdateExtension(extension: NovelExtension) {
|
||||
installer.cancelInstall(extension.pkgName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets to "installing" status of an novel extension installation.
|
||||
*
|
||||
* @param downloadId The id of the download.
|
||||
*/
|
||||
fun setInstalling(downloadId: Long) {
|
||||
installer.updateInstallStep(downloadId, InstallStep.Installing)
|
||||
}
|
||||
|
||||
fun updateInstallStep(downloadId: Long, step: InstallStep) {
|
||||
installer.updateInstallStep(downloadId, step)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstalls the novel extension that matches the given package name.
|
||||
*
|
||||
* @param pkgName The package name of the application to uninstall.
|
||||
*/
|
||||
fun uninstallExtension(pkgName: String, context: Context) {
|
||||
installer.uninstallApk(pkgName, context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the given novel extension in this and the source managers.
|
||||
*
|
||||
* @param extension The anime extension to be registered.
|
||||
*/
|
||||
private fun registerNewExtension(extension: NovelExtension.Installed) {
|
||||
_installedNovelExtensionsFlow.value += extension
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the given updated novel extension in this and the source managers previously removing
|
||||
* the outdated ones.
|
||||
*
|
||||
* @param extension The anime extension to be registered.
|
||||
*/
|
||||
private fun registerUpdatedExtension(extension: NovelExtension.Installed) {
|
||||
val mutInstalledNovelExtensions = _installedNovelExtensionsFlow.value.toMutableList()
|
||||
val oldNovelExtension = mutInstalledNovelExtensions.find { it.pkgName == extension.pkgName }
|
||||
if (oldNovelExtension != null) {
|
||||
mutInstalledNovelExtensions -= oldNovelExtension
|
||||
}
|
||||
mutInstalledNovelExtensions += extension
|
||||
_installedNovelExtensionsFlow.value = mutInstalledNovelExtensions
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters the novel extension in this and the source managers given its package name. Note this
|
||||
* method is called for every uninstalled application in the system.
|
||||
*
|
||||
* @param pkgName The package name of the uninstalled application.
|
||||
*/
|
||||
private fun unregisterNovelExtension(pkgName: String) {
|
||||
val installedNovelExtension = _installedNovelExtensionsFlow.value.find { it.pkgName == pkgName }
|
||||
if (installedNovelExtension != null) {
|
||||
_installedNovelExtensionsFlow.value -= installedNovelExtension
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener which receives events of the novel extensions being installed, updated or removed.
|
||||
*/
|
||||
private inner class NovelInstallationListener : NovelExtensionFileObserver.Listener {
|
||||
|
||||
override fun onExtensionFileCreated(file: File) {
|
||||
NovelExtensionLoader.loadExtension(context, file).let {
|
||||
if (it is NovelLoadResult.Success) {
|
||||
registerNewExtension(it.extension)
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onExtensionFileDeleted(file: File) {
|
||||
val pkgName = file.nameWithoutExtension
|
||||
unregisterNovelExtension(pkgName)
|
||||
}
|
||||
override fun onExtensionFileModified(file: File) {
|
||||
NovelExtensionLoader.loadExtension(context, file).let {
|
||||
if (it is NovelLoadResult.Success) {
|
||||
registerUpdatedExtension(it.extension)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AnimeExtension method to set the update field of an installed anime extension.
|
||||
*/
|
||||
private fun NovelExtension.Installed.withUpdateCheck(): NovelExtension.Installed {
|
||||
return if (updateExists()) {
|
||||
copy(hasUpdate = true)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
private fun NovelExtension.Installed.updateExists(availableNovelExtension: NovelExtension.Available? = null): Boolean {
|
||||
val availableExt = availableNovelExtension ?: _availableNovelExtensionsFlow.value.find { it.pkgName == pkgName }
|
||||
if (isUnofficial || availableExt == null) return false
|
||||
|
||||
return (availableExt.versionCode > versionCode)
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue