Downloading extensions from Github Repo. (#1101)
Downloading extensions from Github Repo.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
package eu.kanade.tachiyomi.extension.util
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||
import eu.kanade.tachiyomi.util.launchNow
|
||||
import kotlinx.coroutines.experimental.async
|
||||
|
||||
/**
|
||||
* Broadcast receiver that listens for the system's packages installed, updated or removed, and only
|
||||
* notifies the given [listener] when the package is an extension.
|
||||
*
|
||||
* @param listener The listener that should be notified of extension installation events.
|
||||
*/
|
||||
internal class ExtensionInstallReceiver(private val listener: Listener) :
|
||||
BroadcastReceiver() {
|
||||
|
||||
/**
|
||||
* Registers this broadcast receiver
|
||||
*/
|
||||
fun register(context: Context) {
|
||||
context.registerReceiver(this, filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the intent filter this receiver should subscribe to.
|
||||
*/
|
||||
private val filter get() = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
if (intent == null) return
|
||||
|
||||
when (intent.action) {
|
||||
Intent.ACTION_PACKAGE_ADDED -> {
|
||||
if (!isReplacing(intent)) launchNow {
|
||||
val result = getExtensionFromIntent(context, intent)
|
||||
when (result) {
|
||||
is LoadResult.Success -> listener.onExtensionInstalled(result.extension)
|
||||
is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension)
|
||||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_PACKAGE_REPLACED -> {
|
||||
launchNow {
|
||||
val result = getExtensionFromIntent(context, intent)
|
||||
when (result) {
|
||||
is LoadResult.Success -> listener.onExtensionUpdated(result.extension)
|
||||
// Not needed as a package can't be upgraded if the signature is different
|
||||
is LoadResult.Untrusted -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_PACKAGE_REMOVED -> {
|
||||
if (!isReplacing(intent)) {
|
||||
val pkgName = getPackageNameFromIntent(intent)
|
||||
if (pkgName != null) {
|
||||
listener.onPackageUninstalled(pkgName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this package is performing an update.
|
||||
*
|
||||
* @param intent The intent that triggered the event.
|
||||
*/
|
||||
private fun isReplacing(intent: Intent): Boolean {
|
||||
return intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the extension triggered by the given intent.
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param intent The intent containing the package name of the extension.
|
||||
*/
|
||||
private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): LoadResult {
|
||||
val pkgName = getPackageNameFromIntent(intent) ?:
|
||||
return LoadResult.Error("Package name not found")
|
||||
return async { ExtensionLoader.loadExtensionFromPkgName(context, pkgName) }.await()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the package name of the installed, updated or removed application.
|
||||
*/
|
||||
private fun getPackageNameFromIntent(intent: Intent?): String? {
|
||||
return intent?.data?.encodedSchemeSpecificPart ?: return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener that receives extension installation events.
|
||||
*/
|
||||
interface Listener {
|
||||
fun onExtensionInstalled(extension: Extension.Installed)
|
||||
fun onExtensionUpdated(extension: Extension.Installed)
|
||||
fun onExtensionUntrusted(extension: Extension.Untrusted)
|
||||
fun onPackageUninstalled(pkgName: String)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package eu.kanade.tachiyomi.extension.util
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import com.jakewharton.rxrelay.PublishRelay
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The installer which installs, updates and uninstalls the extensions.
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
internal class ExtensionInstaller(private val context: Context) {
|
||||
|
||||
/**
|
||||
* The system's download manager
|
||||
*/
|
||||
private val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as 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: Extension) = Observable.defer {
|
||||
val pkgName = extension.pkgName
|
||||
|
||||
val oldDownload = activeDownloads[pkgName]
|
||||
if (oldDownload != null) {
|
||||
deleteDownload(pkgName)
|
||||
}
|
||||
|
||||
// Register the receiver after removing (and unregistering) the previous download
|
||||
downloadReceiver.register()
|
||||
|
||||
val request = DownloadManager.Request(Uri.parse(url))
|
||||
.setTitle(extension.name)
|
||||
.setMimeType(APK_MIME)
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
|
||||
val id = downloadManager.enqueue(request)
|
||||
activeDownloads.put(pkgName, id)
|
||||
|
||||
downloadsRelay.filter { it.first == id }
|
||||
.map { it.second }
|
||||
// Poll download status
|
||||
.mergeWith(pollStatus(id))
|
||||
// Force an error if the download takes more than 3 minutes
|
||||
.mergeWith(Observable.timer(3, TimeUnit.MINUTES).map { InstallStep.Error })
|
||||
// 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 ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
else -> Observable.empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an intent to install the extension at the given uri.
|
||||
*
|
||||
* @param uri The uri of the extension to install.
|
||||
*/
|
||||
fun installApk(uri: Uri) {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
.setDataAndType(uri, APK_MIME)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an intent to uninstall the extension by the given package name.
|
||||
*
|
||||
* @param pkgName The package name of the extension to uninstall
|
||||
*/
|
||||
fun uninstallApk(pkgName: String) {
|
||||
val packageUri = Uri.parse("package:$pkgName")
|
||||
val uninstallIntent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, packageUri)
|
||||
context.startActivity(uninstallIntent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an extension is installed, allowing to update its installation step.
|
||||
*
|
||||
* @param pkgName The package name of the installed application.
|
||||
*/
|
||||
fun onApkInstalled(pkgName: String) {
|
||||
val id = activeDownloads[pkgName] ?: return
|
||||
downloadsRelay.call(id to InstallStep.Installed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the download for the given package name.
|
||||
*
|
||||
* @param pkgName The package name of the download to delete.
|
||||
*/
|
||||
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)
|
||||
context.registerReceiver(this, filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
if (uri != null) {
|
||||
downloadsRelay.call(id to InstallStep.Installing)
|
||||
installApk(uri)
|
||||
} else {
|
||||
Timber.e("Couldn't locate downloaded APK")
|
||||
downloadsRelay.call(id to InstallStep.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val APK_MIME = "application/vnd.android.package-archive"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package eu.kanade.tachiyomi.extension.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import dalvik.system.PathClassLoader
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
import eu.kanade.tachiyomi.util.Hash
|
||||
import kotlinx.coroutines.experimental.async
|
||||
import kotlinx.coroutines.experimental.runBlocking
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
/**
|
||||
* Class that handles the loading of the extensions installed in the system.
|
||||
*/
|
||||
@SuppressLint("PackageManagerGetSignatures")
|
||||
internal object ExtensionLoader {
|
||||
|
||||
private const val EXTENSION_FEATURE = "tachiyomi.extension"
|
||||
private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
|
||||
private const val LIB_VERSION_MIN = 1
|
||||
private const val LIB_VERSION_MAX = 1
|
||||
|
||||
private const val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
|
||||
|
||||
/**
|
||||
* List of the trusted signatures.
|
||||
*/
|
||||
var trustedSignatures = mutableSetOf<String>() +
|
||||
Injekt.get<PreferencesHelper>().trustedSignatures().getOrDefault() +
|
||||
// inorichi's key
|
||||
"7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23"
|
||||
|
||||
/**
|
||||
* Return a list of all the installed extensions initialized concurrently.
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
fun loadExtensions(context: Context): List<LoadResult> {
|
||||
val pkgManager = context.packageManager
|
||||
val installedPkgs = pkgManager.getInstalledPackages(PACKAGE_FLAGS)
|
||||
val extPkgs = installedPkgs.filter { isPackageAnExtension(it) }
|
||||
|
||||
if (extPkgs.isEmpty()) return emptyList()
|
||||
|
||||
// Load each extension concurrently and wait for completion
|
||||
return runBlocking {
|
||||
val deferred = extPkgs.map {
|
||||
async { loadExtension(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.
|
||||
*/
|
||||
fun loadExtensionFromPkgName(context: Context, pkgName: String): LoadResult {
|
||||
val pkgInfo = context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS)
|
||||
if (!isPackageAnExtension(pkgInfo)) {
|
||||
return LoadResult.Error("Tried to load a package that wasn't a extension")
|
||||
}
|
||||
return loadExtension(context, pkgName, pkgInfo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an extension given its package name.
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param pkgName The package name of the extension to load.
|
||||
* @param pkgInfo The package info of the extension.
|
||||
*/
|
||||
private fun loadExtension(context: Context, pkgName: String, pkgInfo: PackageInfo): LoadResult {
|
||||
val pkgManager = context.packageManager
|
||||
|
||||
val appInfo = pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA)
|
||||
|
||||
val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ")
|
||||
val versionName = pkgInfo.versionName
|
||||
val versionCode = pkgInfo.versionCode
|
||||
|
||||
// Validate lib version
|
||||
val majorLibVersion = versionName.substringBefore('.').toInt()
|
||||
if (majorLibVersion < LIB_VERSION_MIN || majorLibVersion > LIB_VERSION_MAX) {
|
||||
val exception = Exception("Lib version is $majorLibVersion, while only versions " +
|
||||
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed")
|
||||
Timber.w(exception)
|
||||
return LoadResult.Error(exception)
|
||||
}
|
||||
|
||||
val signatureHash = getSignatureHash(pkgInfo)
|
||||
|
||||
if (signatureHash == null) {
|
||||
return LoadResult.Error("Package $pkgName isn't signed")
|
||||
} else if (signatureHash !in trustedSignatures) {
|
||||
val extension = Extension.Untrusted(extName, pkgName, versionName, versionCode, signatureHash)
|
||||
Timber.w("Extension $pkgName isn't trusted")
|
||||
return LoadResult.Untrusted(extension)
|
||||
}
|
||||
|
||||
val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader)
|
||||
|
||||
val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)
|
||||
.split(";")
|
||||
.map {
|
||||
val sourceClass = it.trim()
|
||||
if (sourceClass.startsWith("."))
|
||||
pkgInfo.packageName + sourceClass
|
||||
else
|
||||
sourceClass
|
||||
}
|
||||
.flatMap {
|
||||
try {
|
||||
val obj = Class.forName(it, false, classLoader).newInstance()
|
||||
when (obj) {
|
||||
is Source -> listOf(obj)
|
||||
is SourceFactory -> obj.createSources()
|
||||
else -> throw Exception("Unknown source class type! ${obj.javaClass}")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Timber.e(e, "Extension load error: $extName.")
|
||||
return LoadResult.Error(e)
|
||||
}
|
||||
}
|
||||
val langs = sources.filterIsInstance<CatalogueSource>()
|
||||
.map { it.lang }
|
||||
.toSet()
|
||||
|
||||
val lang = when (langs.size) {
|
||||
0 -> ""
|
||||
1 -> langs.first()
|
||||
else -> "all"
|
||||
}
|
||||
|
||||
val extension = Extension.Installed(extName, pkgName, versionName, versionCode, sources, lang)
|
||||
return LoadResult.Success(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given package is an extension.
|
||||
*
|
||||
* @param pkgInfo The package info of the application.
|
||||
*/
|
||||
private fun isPackageAnExtension(pkgInfo: PackageInfo): Boolean {
|
||||
return pkgInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the signature hash of the package or null if it's not signed.
|
||||
*
|
||||
* @param pkgInfo The package info of the application.
|
||||
*/
|
||||
private fun getSignatureHash(pkgInfo: PackageInfo): String? {
|
||||
val signatures = pkgInfo.signatures
|
||||
return if (signatures != null && !signatures.isEmpty()) {
|
||||
Hash.sha256(signatures.first().toByteArray())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user