Light novel support

This commit is contained in:
Finnley Somdahl 2023-11-30 03:41:45 -06:00
parent 32f918450a
commit c7bc1ffe9e
39 changed files with 2537 additions and 91 deletions

View file

@ -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

View file

@ -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)

View 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
}

View file

@ -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)
}
}
}

View 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())
}
}
}

View file

@ -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()
}

View file

@ -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,
)

View file

@ -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
}

View file

@ -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://"
}
}

View 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()
}

View file

@ -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)
}
}