fix: novel extension installing
This commit is contained in:
parent
3622d91886
commit
e475cc5c01
9 changed files with 193 additions and 621 deletions
|
@ -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
|
||||
}
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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://"
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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 {
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 -> ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue