fix: novel extension installing

This commit is contained in:
rebelonion 2024-04-21 04:31:24 -05:00
parent 3622d91886
commit e475cc5c01
9 changed files with 193 additions and 621 deletions

View file

@ -1,57 +0,0 @@
package ani.dantotsu.parsers.novel
import android.os.FileObserver
import ani.dantotsu.parsers.novel.FileObserver.fileObserver
import ani.dantotsu.util.Logger
import java.io.File
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?) {
Logger.log("Event: $event")
if (file == null) return
val fullPath = File(path, file)
when (event) {
CREATE -> {
Logger.log("File created: $fullPath")
listener.onExtensionFileCreated(fullPath)
}
DELETE -> {
Logger.log("File deleted: $fullPath")
listener.onExtensionFileDeleted(fullPath)
}
MODIFY -> {
Logger.log("File modified: $fullPath")
listener.onExtensionFileModified(fullPath)
}
}
}
interface Listener {
fun onExtensionFileCreated(file: File)
fun onExtensionFileDeleted(file: File)
fun onExtensionFileModified(file: File)
}
}
object FileObserver {
var fileObserver: FileObserver? = null
}

View file

@ -8,6 +8,7 @@ import ani.dantotsu.util.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.util.ExtensionLoader
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess
@ -87,7 +88,7 @@ class NovelExtensionGithubApi {
}
}
val installedExtensions = NovelExtensionLoader.loadExtensions(context)
val installedExtensions = ExtensionLoader.loadNovelExtensions(context)
.filterIsInstance<AnimeLoadResult.Success>()
.map { it.extension }

View file

@ -1,379 +0,0 @@
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 androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import ani.dantotsu.snackString
import ani.dantotsu.util.Logger
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.extension.InstallStep
import eu.kanade.tachiyomi.util.storage.getUriCompat
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
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<InstallStep> =
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()) {
Logger.log("APK file deleted successfully.")
} else {
Logger.log("Failed to delete APK file.")
}
} else {
Logger.log("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(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"
// Check if source path is obtained correctly
if (!sourcePath.startsWith(FILE_SCHEME)) {
Logger.log("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) {
Logger.log("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)
Logger.log("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()) {
Logger.log("File is not writable. Giving write permission.")
val a = fileToDelete.setWritable(true)
Logger.log("Success: $a")
}
//set the directory to writable
val destinationDir = File(apkPath).parentFile
if (destinationDir?.exists() == false) {
destinationDir.mkdirs()
}
val s = destinationDir?.setWritable(true)
Logger.log("Success destinationDir: $s")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
Files.delete(fileToDelete.toPath())
} catch (e: Exception) {
Logger.log("Failed to delete APK file.")
Logger.log(e)
snackString("Failed to delete APK file.")
}
} else {
if (fileToDelete.exists()) {
if (fileToDelete.delete()) {
Logger.log("APK file deleted successfully.")
snackString("APK file deleted successfully.")
} else {
Logger.log("Failed to delete APK file.")
snackString("Failed to delete APK file.")
}
} else {
Logger.log("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)
//delete the file if it already exists
if (destination.exists()) {
if (destination.delete()) {
Logger.log("File deleted successfully.")
} else {
Logger.log("Failed to delete file.")
}
}
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()
}
Logger.log("File copied to internal storage.")
}
@Suppress("unused")
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) {
Logger.log("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")
Logger.log("Package name: $pkgName")
return pkgName ?: ""
}
}
companion object {
const val APK_MIME = "application/vnd.android.package-archive"
const val FILE_SCHEME = "file://"
}
}

View file

@ -1,154 +0,0 @@
package ani.dantotsu.parsers.novel
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager.GET_SIGNATURES
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.os.Build
import ani.dantotsu.connections.crashlytics.CrashlyticsInterface
import ani.dantotsu.parsers.NovelInterface
import ani.dantotsu.snackString
import ani.dantotsu.util.Logger
import dalvik.system.PathClassLoader
import eu.kanade.tachiyomi.util.lang.Hash
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
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
Logger.log("Loading extensions from $installDir")
Logger.log(
"Loading extensions from ${File(installDir).listFiles()?.size}"
)
File(installDir).setWritable(false)
File(installDir).listFiles()?.forEach {
//set the file to read only
it.setWritable(false)
Logger.log("Loading extension ${it.name}")
val extension = loadExtension(context, it)
if (extension is NovelLoadResult.Success) {
results.add(extension)
} else {
Logger.log("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.
*/
@Suppress("unused")
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)
}
try {
context.packageManager.getPackageArchiveInfo(path, 0)
} catch (error: Exception) {
// Unlikely, but the package may have been uninstalled at this point
Logger.log("Failed to load extension $pkgName")
return NovelLoadResult.Error(Exception("Failed to load extension"))
}
return loadExtension(context, File(path))
}
@Suppress("DEPRECATION")
fun loadExtension(context: Context, file: File): NovelLoadResult {
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
context.packageManager.getPackageArchiveInfo(
file.absolutePath,
GET_SIGNATURES or GET_SIGNING_CERTIFICATES
)
?: return NovelLoadResult.Error(Exception("Failed to load extension"))
} else {
context.packageManager.getPackageArchiveInfo(file.absolutePath, GET_SIGNATURES)
?: 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.contains(officialSignature)) {
Logger.log("Package ${packageInfo.packageName} isn't signed")
Logger.log("signatureHash: $signatureHash")
snackString("Package ${packageInfo.packageName} isn't signed")
//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(),
loadSources(
context, file,
packageInfo.applicationInfo?.loadLabel(context.packageManager)?.toString()!!
),
packageInfo.applicationInfo?.loadIcon(context.packageManager)
)
return NovelLoadResult.Success(extension)
}
@Suppress("DEPRECATION")
private fun getSignatureHash(pkgInfo: PackageInfo): List<String>? {
val signatures =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && pkgInfo.signingInfo != null) {
pkgInfo.signingInfo.apkContentsSigners
} else {
pkgInfo.signatures
}
return if (!signatures.isNullOrEmpty()) {
signatures.map { Hash.sha256(it.toByteArray()) }
} else {
null
}
}
private fun loadSources(context: Context, file: File, className: String): List<NovelInterface> {
return try {
Logger.log("isFileWritable: ${file.canWrite()}")
if (file.canWrite()) {
val a = file.setWritable(false)
Logger.log("success: $a")
}
Logger.log("isFileWritable: ${file.canWrite()}")
val classLoader = PathClassLoader(file.absolutePath, null, context.classLoader)
val extensionClassName =
"some.random.novelextensions.${className.lowercase(Locale.getDefault())}.$className"
val loadedClass = classLoader.loadClass(extensionClassName)
val instance = loadedClass.getDeclaredConstructor().newInstance()
val novelInterfaceInstance = instance as? NovelInterface
listOfNotNull(novelInterfaceInstance)
} catch (e: Exception) {
e.printStackTrace()
Injekt.get<CrashlyticsInterface>().logException(e)
emptyList()
}
}
}
sealed class NovelLoadResult {
data class Success(val extension: NovelExtension.Installed) : NovelLoadResult()
data class Error(val error: Exception) : NovelLoadResult()
}

View file

@ -2,14 +2,17 @@ package ani.dantotsu.parsers.novel
import android.content.Context
import android.graphics.drawable.Drawable
import ani.dantotsu.media.MediaType
import ani.dantotsu.snackString
import ani.dantotsu.util.Logger
import eu.kanade.tachiyomi.extension.InstallStep
import eu.kanade.tachiyomi.extension.util.ExtensionInstallReceiver
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
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
@ -24,7 +27,7 @@ class NovelExtensionManager(private val context: Context) {
/**
* The installer which installs, updates and uninstalls the Novel extensions.
*/
private val installer by lazy { NovelExtensionInstaller(context) }
private val installer by lazy { ExtensionInstaller(context) }
private val iconMap = mutableMapOf<String, Drawable>()
@ -49,12 +52,11 @@ class NovelExtensionManager(private val context: Context) {
init {
initNovelExtensions()
val path = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/"
NovelExtensionFileObserver(NovelInstallationListener(), path).register()
ExtensionInstallReceiver().setNovelListener(NovelInstallationListener()).register(context)
}
private fun initNovelExtensions() {
val novelExtensions = NovelExtensionLoader.loadExtensions(context)
val novelExtensions = ExtensionLoader.loadNovelExtensions(context)
_installedNovelExtensionsFlow.value = novelExtensions
.filterIsInstance<NovelLoadResult.Success>()
@ -117,7 +119,8 @@ class NovelExtensionManager(private val context: Context) {
* @param extension The anime extension to be installed.
*/
fun installExtension(extension: NovelExtension.Available): Observable<InstallStep> {
return installer.downloadAndInstall(api.getApkUrl(extension), extension)
return installer.downloadAndInstall(api.getApkUrl(extension), extension.pkgName,
extension.name, MediaType.NOVEL)
}
/**
@ -157,7 +160,7 @@ class NovelExtensionManager(private val context: Context) {
* @param pkgName The package name of the application to uninstall.
*/
fun uninstallExtension(pkgName: String, context: Context) {
installer.uninstallApk(pkgName, context)
installer.uninstallApk(pkgName)
}
/**
@ -202,28 +205,18 @@ class NovelExtensionManager(private val context: Context) {
/**
* 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.withUpdateCheck())
}
}
private inner class NovelInstallationListener : ExtensionInstallReceiver.NovelListener {
override fun onExtensionInstalled(extension: NovelExtension.Installed) {
registerNewExtension(extension.withUpdateCheck())
}
override fun onExtensionFileDeleted(file: File) {
val pkgName = file.nameWithoutExtension
override fun onExtensionUpdated(extension: NovelExtension.Installed) {
registerUpdatedExtension(extension.withUpdateCheck())
}
override fun onPackageUninstalled(pkgName: String) {
unregisterNovelExtension(pkgName)
}
override fun onExtensionFileModified(file: File) {
NovelExtensionLoader.loadExtension(context, file).let {
if (it is NovelLoadResult.Success) {
registerUpdatedExtension(it.extension.withUpdateCheck())
}
}
}
}
/**

View file

@ -0,0 +1,7 @@
package ani.dantotsu.parsers.novel
sealed class NovelLoadResult {
data class Success(val extension: NovelExtension.Installed) : NovelLoadResult()
data class Error(val error: Exception) : NovelLoadResult()
}

View file

@ -6,6 +6,8 @@ import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import ani.dantotsu.media.MediaType
import ani.dantotsu.parsers.novel.NovelExtension
import ani.dantotsu.parsers.novel.NovelLoadResult
import ani.dantotsu.util.Logger
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
import eu.kanade.tachiyomi.extension.anime.model.AnimeLoadResult
@ -28,6 +30,7 @@ internal class ExtensionInstallReceiver : BroadcastReceiver() {
private var animeListener: AnimeListener? = null
private var mangaListener: MangaListener? = null
private var novelListener: NovelListener? = null
private var type: MediaType? = null
/**
@ -50,6 +53,12 @@ internal class ExtensionInstallReceiver : BroadcastReceiver() {
return this
}
fun setNovelListener(listener: NovelListener): ExtensionInstallReceiver {
this.type = MediaType.NOVEL
novelListener = listener
return this
}
/**
* Called when one of the events of the [filter] is received. When the package is an extension,
* it's loaded in background and it notifies the [listener] when finished.
@ -92,6 +101,16 @@ internal class ExtensionInstallReceiver : BroadcastReceiver() {
}
}
MediaType.NOVEL -> {
when (val result = getNovelExtensionFromIntent(context, intent)) {
is NovelLoadResult.Success -> novelListener?.onExtensionInstalled(
result.extension
)
else -> {}
}
}
else -> {}
}
}
@ -120,6 +139,16 @@ internal class ExtensionInstallReceiver : BroadcastReceiver() {
}
}
MediaType.NOVEL -> {
when (val result = getNovelExtensionFromIntent(context, intent)) {
is NovelLoadResult.Success -> novelListener?.onExtensionUpdated(
result.extension
)
else -> {}
}
}
else -> {}
}
}
@ -139,6 +168,10 @@ internal class ExtensionInstallReceiver : BroadcastReceiver() {
mangaListener?.onPackageUninstalled(pkgName)
}
MediaType.NOVEL -> {
novelListener?.onPackageUninstalled(pkgName)
}
else -> {}
}
}
@ -188,6 +221,23 @@ internal class ExtensionInstallReceiver : BroadcastReceiver() {
}.await()
}
@OptIn(DelicateCoroutinesApi::class)
private suspend fun getNovelExtensionFromIntent(
context: Context,
intent: Intent?
): NovelLoadResult {
val pkgName = getPackageNameFromIntent(intent)
if (pkgName == null) {
Logger.log("Package name not found")
return NovelLoadResult.Error(Exception("Package name not found"))
}
return GlobalScope.async(Dispatchers.Default, CoroutineStart.DEFAULT) {
ExtensionLoader.loadNovelExtensionFromPkgName(
context,
pkgName,
)
}.await()
}
/**
* Listener that receives extension installation events.
@ -206,6 +256,12 @@ internal class ExtensionInstallReceiver : BroadcastReceiver() {
fun onPackageUninstalled(pkgName: String)
}
interface NovelListener {
fun onExtensionInstalled(extension: NovelExtension.Installed)
fun onExtensionUpdated(extension: NovelExtension.Installed)
fun onPackageUninstalled(pkgName: String)
}
companion object {
/**

View file

@ -87,6 +87,8 @@ class ExtensionInstaller(private val context: Context) {
downloadUri.lastPathSegment
)
.setDescription(type.asText())
.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE)
.setAllowedOverRoaming(true)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
val id = downloadManager.enqueue(request)

View file

@ -6,6 +6,9 @@ import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.pm.PackageInfoCompat
import ani.dantotsu.media.MediaType
import ani.dantotsu.parsers.NovelInterface
import ani.dantotsu.parsers.novel.NovelExtension
import ani.dantotsu.parsers.novel.NovelLoadResult
import ani.dantotsu.util.Logger
import dalvik.system.PathClassLoader
import eu.kanade.domain.source.service.SourcePreferences
@ -24,6 +27,7 @@ import eu.kanade.tachiyomi.util.system.getApplicationIcon
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import uy.kohesive.injekt.injectLazy
import java.util.Locale
/**
* Class that handles the loading of the extensions. Supports two kinds of extensions:
@ -77,6 +81,10 @@ internal object ExtensionLoader {
private const val officialSignatureManga =
"7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23"
//dan's key
private const val officialSignature =
"a3061edb369278749b8e8de810d440d38e96417bbd67bbdfc5d9d9ed475ce4a5"
/**
* List of the trusted signatures.
*/
@ -133,6 +141,28 @@ internal object ExtensionLoader {
}
}
fun loadNovelExtensions(context: Context): List<NovelLoadResult> {
val pkgManager = context.packageManager
val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pkgManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(PACKAGE_FLAGS.toLong()))
} else {
pkgManager.getInstalledPackages(PACKAGE_FLAGS)
}
val extPkgs = installedPkgs.filter { isPackageAnExtension(MediaType.NOVEL, it) }
if (extPkgs.isEmpty()) return emptyList()
// Load each extension concurrently and wait for completion
return runBlocking {
val deferred = extPkgs.map {
async { loadNovelExtension(context, it.packageName, it) }
}
deferred.map { it.await() }
}
}
/**
* 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.
@ -167,6 +197,21 @@ internal object ExtensionLoader {
return loadMangaExtension(context, pkgName, pkgInfo)
}
fun loadNovelExtensionFromPkgName(context: Context, pkgName: String): NovelLoadResult {
val pkgInfo = try {
context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS)
} catch (error: PackageManager.NameNotFoundException) {
// Unlikely, but the package may have been uninstalled at this point
Logger.log(error)
return NovelLoadResult.Error(error)
}
if (!isPackageAnExtension(MediaType.NOVEL, pkgInfo)) {
Logger.log("Tried to load a package that wasn't a extension ($pkgName)")
return NovelLoadResult.Error(Exception("Tried to load a package that wasn't a extension ($pkgName)"))
}
return loadNovelExtension(context, pkgName, pkgInfo)
}
/**
* Loads an extension given its package name.
*
@ -400,17 +445,75 @@ internal object ExtensionLoader {
return MangaLoadResult.Success(extension)
}
private fun loadNovelExtension(
context: Context,
pkgName: String,
pkgInfo: PackageInfo
): NovelLoadResult {
val pkgManager = context.packageManager
val appInfo = try {
pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA)
} catch (error: PackageManager.NameNotFoundException) {
// Unlikely, but the package may have been uninstalled at this point
Logger.log(error)
return NovelLoadResult.Error(error)
}
val extName =
pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ")
val versionName = pkgInfo.versionName
val versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo)
if (versionName.isNullOrEmpty()) {
Logger.log("Missing versionName for extension $extName")
return NovelLoadResult.Error(Exception("Missing versionName for extension $extName"))
}
val signatureHash = getSignatureHash(pkgInfo)
val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader)
val novelInterfaceInstance = try {
val className = appInfo.loadLabel(context.packageManager).toString()
val extensionClassName =
"some.random.novelextensions.${className.lowercase(Locale.getDefault())}.$className"
val loadedClass = classLoader.loadClass(extensionClassName)
val instance = loadedClass.getDeclaredConstructor().newInstance()
instance as? NovelInterface
} catch (e: Throwable) {
Logger.log("Extension load error: $extName")
return NovelLoadResult.Error(e as Exception)
}
val extension = NovelExtension.Installed(
name = extName,
pkgName = pkgName,
versionName = versionName,
versionCode = versionCode,
sources = listOfNotNull(novelInterfaceInstance),
isUnofficial = signatureHash != officialSignatureManga,
icon = context.getApplicationIcon(pkgName),
)
return NovelLoadResult.Success(extension)
}
/**
* Returns true if the given package is an extension.
*
* @param pkgInfo The package info of the application.
*/
private fun isPackageAnExtension(type: MediaType, pkgInfo: PackageInfo): Boolean {
return pkgInfo.reqFeatures.orEmpty().any {
it.name == when (type) {
MediaType.ANIME -> ANIME_PACKAGE
MediaType.MANGA -> MANGA_PACKAGE
else -> ""
return if (type == MediaType.NOVEL) {
pkgInfo.packageName.startsWith("some.random")
} else {
pkgInfo.reqFeatures.orEmpty().any {
it.name == when (type) {
MediaType.ANIME -> ANIME_PACKAGE
MediaType.MANGA -> MANGA_PACKAGE
else -> ""
}
}
}
}