Revamped backend and added auto updating

This commit is contained in:
Blatzar 2022-08-07 18:01:32 +02:00
parent 8f3176d2cf
commit 89936c2bd6
9 changed files with 216 additions and 111 deletions

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.lagradost.cloudstream3">
xmlns:tools="http://schemas.android.com/tools"
package="com.lagradost.cloudstream3">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <!-- I dont remember, probs has to do with downloads -->
<uses-permission android:name="android.permission.INTERNET" /> <!-- unless you only use cs3 as a player for downloaded stuff, you need this -->
@ -12,41 +12,41 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <!-- Used for player vertical slide-->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <!--Used for app update-->
<!--<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> not used atm, but code exist that requires it that are not run-->
<!-- <permission android:name="android.permission.QUERY_ALL_PACKAGES" /> &lt;!&ndash; Used for getting if vlc is installed &ndash;&gt;-->
<!-- <permission android:name="android.permission.QUERY_ALL_PACKAGES" /> &lt;!&ndash; Used for getting if vlc is installed &ndash;&gt;-->
<!-- Fixes android tv fuckery -->
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
android:name="android.software.leanback"
android:required="false" />
<!--TODO https://stackoverflow.com/questions/41799732/chromecast-button-not-visible-in-android-->
<application
android:name=".AcraApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:banner="@mipmap/ic_banner"
android:label="@string/app_name"
android:usesCleartextTraffic="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:fullBackupContent="@xml/backup_descriptor"
android:appCategory="video"
tools:targetApi="o">
android:name=".AcraApplication"
android:allowBackup="true"
android:appCategory="video"
android:banner="@mipmap/ic_banner"
android:fullBackupContent="@xml/backup_descriptor"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
tools:targetApi="o">
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.lagradost.cloudstream3.utils.CastOptionsProvider" />
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.lagradost.cloudstream3.utils.CastOptionsProvider" />
<activity
android:name=".ui.player.DownloadedPlayerActivity"
android:screenOrientation="userLandscape"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:resizeableActivity="true"
android:supportsPictureInPicture="true"
android:exported="true">
android:name=".ui.player.DownloadedPlayerActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:exported="true"
android:resizeableActivity="true"
android:screenOrientation="userLandscape"
android:supportsPictureInPicture="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@ -57,18 +57,18 @@
<!-- I dont think this label can be translated, but idk -->
<intent-filter android:label="@string/play_with_app_name">
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="*/*"/>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<activity
android:exported="true"
android:name=".MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:resizeableActivity="true"
android:supportsPictureInPicture="true">
android:name=".MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:exported="true"
android:resizeableActivity="true"
android:supportsPictureInPicture="true">
<intent-filter android:exported="true">
<action android:name="android.intent.action.MAIN" />
@ -83,35 +83,39 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="cloudstreamapp" />
<data
android:host="cs.repo"
android:pathPrefix="/"
android:scheme="https" />
</intent-filter>
</activity>
<receiver
android:name=".receivers.VideoDownloadRestartReceiver"
android:enabled="false"
android:exported="true">
android:name=".receivers.VideoDownloadRestartReceiver"
android:enabled="false"
android:exported="true">
<intent-filter android:exported="true">
<action android:name="restart_service" />
</intent-filter>
</receiver>
<service
android:name=".services.VideoDownloadService"
android:enabled="true"
android:exported="false" />
android:name=".services.VideoDownloadService"
android:enabled="true"
android:exported="false" />
<activity
android:exported="false"
android:name=".ui.ControllerActivity" />
android:name=".ui.ControllerActivity"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:enabled="true"
android:grantUriPermissions="true">
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:enabled="true"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
</manifest>

View file

@ -420,6 +420,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
override fun onCreate(savedInstanceState: Bundle?) {
app.initClient(this)
PluginManager.updateAllOnlinePlugins(applicationContext)
PluginManager.loadAllLocalPlugins(applicationContext)
PluginManager.loadAllOnlinePlugins(applicationContext)

View file

@ -16,6 +16,7 @@ abstract class Plugin {
class Manifest {
var name: String? = null
var pluginClassName: String? = null
var pluginVersion: Int? = null
}
var resources: Resources? = null

View file

@ -17,6 +17,11 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.apmap
import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
@ -27,13 +32,22 @@ import java.util.*
const val PLUGINS_KEY = "PLUGINS_KEY"
const val PLUGINS_KEY_LOCAL = "PLUGINS_KEY_LOCAL"
// Data class for internal storage
data class PluginData(
@JsonProperty("name") val name: String,
@JsonProperty("internalName") val internalName: String,
@JsonProperty("url") val url: String?,
@JsonProperty("isOnline") val isOnline: Boolean,
@JsonProperty("filePath") val filePath: String,
@JsonProperty("version") val version: Int,
)
// This is used as a placeholder / not set version
const val PLUGIN_VERSION_NOT_SET = Int.MIN_VALUE
// This always updates
const val PLUGIN_VERSION_ALWAYS_UPDATE = -1
object PluginManager {
// Prevent multiple writes at once
val lock = Mutex()
@ -80,22 +94,62 @@ object PluginManager {
private val LOCAL_PLUGINS_PATH =
Environment.getExternalStorageDirectory().absolutePath + "/Cloudstream3/plugins"
// Maps filepath to plugin
private val plugins: MutableMap<String, Plugin> =
LinkedHashMap<String, Plugin>()
private val classLoaders: MutableMap<PathClassLoader, Plugin> =
HashMap<PathClassLoader, Plugin>()
private val failedToLoad: MutableMap<File, Any> = LinkedHashMap()
var loadedLocalPlugins = false
private var loadedLocalPlugins = false
private val gson = Gson()
private fun maybeLoadPlugin(context: Context, file: File) {
val name = file.name
if (file.extension == "zip" || file.extension == "cs3") {
loadPlugin(context, file, PluginData(name, null, false, file.absolutePath))
loadPlugin(
context,
file,
PluginData(name, null, false, file.absolutePath, PLUGIN_VERSION_NOT_SET)
)
}
}
/**
* Needs to be run before other plugin loading because plugin loading can not be overwritten
**/
fun updateAllOnlinePlugins(context: Context) {
val urls = getKey<Array<RepositoryData>>(REPOSITORIES_KEY) ?: emptyArray()
val onlinePlugins = urls.toList().apmap {
getRepoPlugins(it.url)?.toList() ?: emptyList()
}.flatten()
// Iterates over all offline plugins, compares to remote repo and returns the plugins which are outdated
val outdatedPlugins = getPluginsOnline().map { savedData ->
onlinePlugins.filter { onlineData -> savedData.internalName == onlineData.second.internalName }
.mapNotNull { onlineData ->
val isOutdated =
onlineData.second.apiVersion != savedData.version || onlineData.second.version == PLUGIN_VERSION_ALWAYS_UPDATE
if (isOutdated) savedData to onlineData else null
}
}.flatten()
println("Outdated plugins: $outdatedPlugins")
outdatedPlugins.apmap {
downloadAndLoadPlugin(
context,
it.second.second.url,
it.first.internalName,
it.second.first
)
}
println("Plugin update done!")
}
fun loadAllOnlinePlugins(context: Context) {
File(context.filesDir, ONLINE_PLUGINS_FOLDER).listFiles()?.sortedBy { it.name }
?.forEach { file ->
@ -130,12 +184,12 @@ object PluginManager {
* */
private fun loadPlugin(context: Context, file: File, data: PluginData): Boolean {
val fileName = file.nameWithoutExtension
setPluginData(data)
val filePath = file.absolutePath
println("Loading plugin: $data")
//logger.info("Loading plugin: " + fileName);
return try {
val loader = PathClassLoader(file.absolutePath, context.classLoader)
val loader = PathClassLoader(filePath, context.classLoader)
var manifest: Plugin.Manifest
loader.getResourceAsStream("manifest.json").use { stream ->
if (stream == null) {
@ -150,15 +204,21 @@ object PluginManager {
)
}
}
val name: String = manifest.name ?: "NO NAME"
val version: Int = manifest.pluginVersion ?: PLUGIN_VERSION_NOT_SET
val pluginClass: Class<*> =
loader.loadClass(manifest.pluginClassName) as Class<out Plugin?>
val pluginInstance: Plugin =
pluginClass.newInstance() as Plugin
if (plugins.containsKey(name)) {
if (plugins.containsKey(filePath)) {
println("Plugin with name $name already exists")
return false
return true
}
// Sets with the proper version
setPluginData(data.copy(version = version))
pluginInstance.__filename = fileName
if (pluginInstance.needsResources) {
// based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk
@ -172,9 +232,10 @@ object PluginManager {
context.resources.configuration
)
}
plugins[name] = pluginInstance
plugins[filePath] = pluginInstance
classLoaders[loader] = pluginInstance
pluginInstance.load(context)
println("Loaded plugin ${data.internalName} successfully")
true
} catch (e: Throwable) {
failedToLoad[file] = e
@ -188,12 +249,24 @@ object PluginManager {
}
}
suspend fun downloadPlugin(context: Context, pluginUrl: String, name: String): Boolean {
val file = downloadPluginToFile(context, pluginUrl, name)
suspend fun downloadAndLoadPlugin(
context: Context,
pluginUrl: String,
internalName: String,
repositoryUrl: String
): Boolean {
val folderName = (sanitizeFilename(
repositoryUrl,
true
) + "." + repositoryUrl.hashCode()) // Guaranteed unique
val fileName = (sanitizeFilename(internalName, true) + "." + internalName.hashCode())
println("Downloading plugin: $pluginUrl to $folderName/$fileName")
// The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names
val file = downloadPluginToFile(context, pluginUrl, fileName, folderName)
return loadPlugin(
context,
file ?: return false,
PluginData(name, pluginUrl, true, file.absolutePath)
PluginData(internalName, pluginUrl, true, file.absolutePath, PLUGIN_VERSION_NOT_SET)
)
}

View file

@ -3,16 +3,13 @@ package com.lagradost.cloudstream3.plugins
import android.content.Context
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.PROVIDER_STATUS_OK
import com.lagradost.cloudstream3.apmap
import com.lagradost.cloudstream3.apmapIndexed
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.BufferedInputStream
@ -36,17 +33,28 @@ data class Repository(
* 3: Beta only
* */
data class SitePlugin(
// Url to the .cs3 file
@JsonProperty("url") val url: String,
// Status to remotely disable the provider
@JsonProperty("status") val status: Int,
// Integer over 0, any change of this will trigger an auto update
@JsonProperty("version") val version: Int,
// Unused currently, used to make the api backwards compatible?
// Set to 1
@JsonProperty("apiVersion") val apiVersion: Int,
// Name to be shown in app
@JsonProperty("name") val name: String,
// Name to be referenced internally. Separate to make name and url changes possible
@JsonProperty("internalName") val internalName: String,
@JsonProperty("authors") val authors: List<String>,
@JsonProperty("description") val description: String?,
// Might be used to go directly to the plugin repo in the future
@JsonProperty("repositoryUrl") val repositoryUrl: String?,
// These types are yet to be mapped and used, ignore for now
@JsonProperty("tvTypes") val tvTypes: List<String>?,
@JsonProperty("language") val language: String?,
@JsonProperty("iconUrl") val iconUrl: String?,
// Set to true to get an 18+ symbol next to the plugin
@JsonProperty("isAdult") val isAdult: Boolean?,
)
@ -69,21 +77,32 @@ object RepositoryManager {
return tryParseJson<Array<SitePlugin>>(response.text)?.toList() ?: emptyList()
}
suspend fun getRepoPlugins(repositoryUrl: String): List<SitePlugin>? {
/**
* Gets all plugins from repositories and pairs them with the repository url
* */
suspend fun getRepoPlugins(repositoryUrl: String): List<Pair<String, SitePlugin>>? {
val repo = parseRepository(repositoryUrl) ?: return null
return repo.pluginLists.apmap {
parsePlugins(it)
return repo.pluginLists.apmapIndexed { index, url ->
parsePlugins(url).map {
repo.pluginLists[index] to it
}
}.flatten()
}
suspend fun downloadPluginToFile(context: Context, pluginUrl: String, name: String): File? {
suspend fun downloadPluginToFile(context: Context, pluginUrl: String, fileName: String, folder: String): File? {
return suspendSafeApiCall {
val extensionsDir = File(context.filesDir, ONLINE_PLUGINS_FOLDER)
if (!extensionsDir.exists())
extensionsDir.mkdirs()
val newFile = File(extensionsDir, "$name.${pluginUrl.hashCode()}.cs3")
if (newFile.exists()) return@suspendSafeApiCall newFile
val newDir = File(extensionsDir, folder)
newDir.mkdirs()
val newFile = File(newDir, "${fileName}.cs3")
// Overwrite if exists
if (newFile.exists()) {
newFile.delete()
}
newFile.createNewFile()
val body = app.get(pluginUrl).okhttpResponse.body
@ -95,21 +114,20 @@ object RepositoryManager {
// Don't want to read before we write in another thread
private val repoLock = Mutex()
suspend fun addRepository(repository: RepositoryData) {
repoLock.withLock {
val currentRepos = getKey<Array<RepositoryData>>(REPOSITORIES_KEY) ?: emptyArray()
// No duplicates
if (currentRepos.any { it.url == repository.url }) return
setKey(REPOSITORIES_KEY, currentRepos + repository)
}
repoLock.withLock {
val currentRepos = getKey<Array<RepositoryData>>(REPOSITORIES_KEY) ?: emptyArray()
// No duplicates
setKey(REPOSITORIES_KEY, (currentRepos + repository).distinctBy { it.url })
}
}
suspend fun removeRepository(repository: RepositoryData) {
repoLock.withLock {
val currentRepos = getKey<Array<RepositoryData>>(REPOSITORIES_KEY) ?: emptyArray()
// No duplicates
val newRepos = currentRepos.filter { it.url != repository.url }
setKey(REPOSITORIES_KEY, newRepos)
}
repoLock.withLock {
val currentRepos = getKey<Array<RepositoryData>>(REPOSITORIES_KEY) ?: emptyArray()
// No duplicates
val newRepos = currentRepos.filter { it.url != repository.url }
setKey(REPOSITORIES_KEY, newRepos)
}
}
private fun write(stream: InputStream, output: OutputStream) {

View file

@ -25,7 +25,7 @@ class ExtensionsViewModel : ViewModel() {
_repositories.postValue(urls)
}
suspend fun getPlugins(repositoryUrl: String): List<SitePlugin> {
suspend fun getPlugins(repositoryUrl: String): List<Pair<String, SitePlugin>> {
return RepositoryManager.getRepoPlugins(repositoryUrl) ?: emptyList()
}
}

View file

@ -12,8 +12,8 @@ import com.lagradost.cloudstream3.plugins.SitePlugin
import kotlinx.android.synthetic.main.repository_item.view.*
class PluginAdapter(
var plugins: List<SitePlugin>,
val iconClickCallback: PluginAdapter.(plugin: SitePlugin, isDownloaded: Boolean) -> Unit
var plugins: List<Pair<String, SitePlugin>>,
val iconClickCallback: PluginAdapter.(repositoryUrl: String, plugin: SitePlugin, isDownloaded: Boolean) -> Unit
) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
@ -23,9 +23,10 @@ class PluginAdapter(
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val (repositoryUrl, plugin) = plugins[position]
when (holder) {
is PluginViewHolder -> {
holder.bind(plugins[position])
holder.bind(repositoryUrl, plugin)
}
}
}
@ -44,7 +45,8 @@ class PluginAdapter(
RecyclerView.ViewHolder(itemView) {
fun bind(
plugin: SitePlugin
repositoryUrl: String,
plugin: SitePlugin,
) {
val isDownloaded = storedPlugins.any { it.url == plugin.url }
@ -56,7 +58,7 @@ class PluginAdapter(
itemView.action_button?.setImageResource(drawableInt)
itemView.action_button?.setOnClickListener {
iconClickCallback.invoke(this@PluginAdapter, plugin, isDownloaded)
iconClickCallback.invoke(this@PluginAdapter, repositoryUrl, plugin, isDownloaded)
}
itemView.main_text?.text = plugin.name

View file

@ -10,7 +10,6 @@ import androidx.fragment.app.activityViewModels
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.plugins.RepositoryManager
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
@ -44,29 +43,35 @@ class PluginsFragment : Fragment() {
ioSafe {
val plugins = extensionViewModel.getPlugins(url)
main {
repo_recycler_view?.adapter = PluginAdapter(plugins) { plugin, isDownloaded ->
ioSafe {
val (success, message) = if (isDownloaded) {
PluginManager.deletePlugin(view.context, plugin.url, plugin.name) to R.string.plugin_deleted
} else {
PluginManager.downloadPlugin(
view.context,
plugin.url,
plugin.name
) to R.string.plugin_loaded
}
repo_recycler_view?.adapter =
PluginAdapter(plugins) { repositoryUrl, plugin, isDownloaded ->
ioSafe {
val (success, message) = if (isDownloaded) {
PluginManager.deletePlugin(
view.context,
plugin.url,
plugin.name
) to R.string.plugin_deleted
} else {
PluginManager.downloadAndLoadPlugin(
view.context,
plugin.url,
plugin.name,
repositoryUrl
) to R.string.plugin_loaded
}
println("Success: $success")
if (success) {
main {
showToast(activity, message, Toast.LENGTH_SHORT)
this@PluginAdapter.reloadStoredPlugins()
// Dirty and needs a fix
repo_recycler_view?.adapter?.notifyDataSetChanged()
println("Success: $success")
if (success) {
main {
showToast(activity, message, Toast.LENGTH_SHORT)
this@PluginAdapter.reloadStoredPlugins()
// Dirty and needs a fix
repo_recycler_view?.adapter?.notifyDataSetChanged()
}
}
}
}
}
}
}
}

View file

@ -426,11 +426,12 @@ object VideoDownloadManager {
}
private const val reservedChars = "|\\?*<\":>+[]/\'"
fun sanitizeFilename(name: String): String {
fun sanitizeFilename(name: String, removeSpaces: Boolean= false): String {
var tempName = name
for (c in reservedChars) {
tempName = tempName.replace(c, ' ')
}
if (removeSpaces) tempName = tempName.replace(" ", "")
return tempName.replace(" ", " ").trim(' ')
}