Added basic extension management

This commit is contained in:
Blatzar 2022-08-07 01:43:39 +02:00
parent e10cb7b0a3
commit 8c0e07decb
15 changed files with 804 additions and 136 deletions

View file

@ -39,7 +39,6 @@ import com.lagradost.cloudstream3.CommonActivity.onUserLeaveHint
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.CommonActivity.updateLocale import com.lagradost.cloudstream3.CommonActivity.updateLocale
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.network.initClient import com.lagradost.cloudstream3.network.initClient
import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis
@ -52,16 +51,12 @@ import com.lagradost.cloudstream3.ui.result.ResultFragment
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isEmulatorSettings import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isEmulatorSettings
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
import com.lagradost.cloudstream3.ui.settings.SettingsGeneral
import com.lagradost.cloudstream3.ui.setup.HAS_DONE_SETUP_KEY import com.lagradost.cloudstream3.ui.setup.HAS_DONE_SETUP_KEY
import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable
import com.lagradost.cloudstream3.utils.AppUtils.loadCache import com.lagradost.cloudstream3.utils.AppUtils.loadCache
import com.lagradost.cloudstream3.utils.AppUtils.loadResult import com.lagradost.cloudstream3.utils.AppUtils.loadResult
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import com.lagradost.cloudstream3.utils.BackupUtils.setUpBackup import com.lagradost.cloudstream3.utils.BackupUtils.setUpBackup
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStore
import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.removeKey import com.lagradost.cloudstream3.utils.DataStore.removeKey
import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.DataStore.setKey
@ -76,20 +71,14 @@ import com.lagradost.cloudstream3.utils.UIHelper.getResourceColor
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.requestRW import com.lagradost.cloudstream3.utils.UIHelper.requestRW
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
import com.lagradost.nicehttp.Requests import com.lagradost.nicehttp.Requests
import com.lagradost.nicehttp.ResponseParser import com.lagradost.nicehttp.ResponseParser
import kotlinx.android.synthetic.main.activity_main.* import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.fragment_result_swipe.* import kotlinx.android.synthetic.main.fragment_result_swipe.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.NewPipe
import java.io.File import java.io.File
import kotlin.concurrent.thread import kotlin.concurrent.thread
import kotlin.reflect.KClass import kotlin.reflect.KClass
import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.plugins.RepositoryParser
const val VLC_PACKAGE = "org.videolan.vlc" const val VLC_PACKAGE = "org.videolan.vlc"
@ -402,7 +391,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
app.initClient(this) app.initClient(this)
PluginManager.loadAllPlugins(applicationContext) PluginManager.loadAllLocalPlugins(applicationContext)
PluginManager.loadAllOnlinePlugins(applicationContext)
// ioSafe { // ioSafe {
// val plugins = // val plugins =
// RepositoryParser.getRepoPlugins("https://raw.githubusercontent.com/recloudstream/TestPlugin/master/repo.json") // RepositoryParser.getRepoPlugins("https://raw.githubusercontent.com/recloudstream/TestPlugin/master/repo.json")

View file

@ -3,26 +3,113 @@ package com.lagradost.cloudstream3.plugins
import android.content.Context import android.content.Context
import dalvik.system.PathClassLoader import dalvik.system.PathClassLoader
import com.google.gson.Gson import com.google.gson.Gson
import com.lagradost.cloudstream3.plugins.PluginManager
import android.content.res.AssetManager import android.content.res.AssetManager
import android.content.res.Resources import android.content.res.Resources
import android.os.Environment import android.os.Environment
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.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDER
import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File import java.io.File
import java.io.InputStreamReader import java.io.InputStreamReader
import java.util.* import java.util.*
// Different keys for local and not since local can be removed at any time without app knowing, hence the local are getting rebuilt on every app start
const val PLUGINS_KEY = "PLUGINS_KEY"
const val PLUGINS_KEY_LOCAL = "PLUGINS_KEY_LOCAL"
data class PluginData(
@JsonProperty("name") val name: String,
@JsonProperty("url") val url: String?,
@JsonProperty("isOnline") val isOnline: Boolean,
@JsonProperty("filePath") val filePath: String,
)
object PluginManager { object PluginManager {
private val PLUGINS_PATH = // Prevent multiple writes at once
val lock = Mutex()
/**
* Store data about the plugin for fetching later
* */
private fun setPluginData(data: PluginData) {
ioSafe {
lock.withLock {
if (data.isOnline) {
val plugins = getPluginsOnline()
setKey(PLUGINS_KEY, plugins + data)
} else {
val plugins = getPluginsLocal()
setKey(PLUGINS_KEY_LOCAL, plugins + data)
}
}
}
}
private fun deletePluginData(data: PluginData) {
ioSafe {
lock.withLock {
if (data.isOnline) {
val plugins = getPluginsOnline().filter { it.url != data.url }
setKey(PLUGINS_KEY, plugins)
} else {
val plugins = getPluginsLocal().filter { it.filePath != data.filePath }
setKey(PLUGINS_KEY_LOCAL, plugins + data)
}
}
}
}
fun getPluginsOnline(): Array<PluginData> {
return getKey(PLUGINS_KEY) ?: emptyArray()
}
fun getPluginsLocal(): Array<PluginData> {
return getKey(PLUGINS_KEY_LOCAL) ?: emptyArray()
}
private val LOCAL_PLUGINS_PATH =
Environment.getExternalStorageDirectory().absolutePath + "/Cloudstream3/plugins" Environment.getExternalStorageDirectory().absolutePath + "/Cloudstream3/plugins"
private val plugins: MutableMap<String, Plugin> = private val plugins: MutableMap<String, Plugin> =
LinkedHashMap<String, Plugin>() LinkedHashMap<String, Plugin>()
private val classLoaders: MutableMap<PathClassLoader, Plugin> = private val classLoaders: MutableMap<PathClassLoader, Plugin> =
HashMap<PathClassLoader, Plugin>() HashMap<PathClassLoader, Plugin>()
private val failedToLoad: MutableMap<File, Any> = LinkedHashMap() private val failedToLoad: MutableMap<File, Any> = LinkedHashMap()
var loadedPlugins = false var loadedLocalPlugins = false
private val gson = Gson() private val gson = Gson()
fun loadAllPlugins(context: Context) {
val dir = File(PLUGINS_PATH) 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))
} else if (name != "oat") { // Some roms create this
if (file.isDirectory) {
// Utils.showToast(String.format("Found directory %s in your plugins folder. DO NOT EXTRACT PLUGIN ZIPS!", name), true);
} else if (name == "classes.dex" || name.endsWith(".json")) {
// Utils.showToast(String.format("Found extracted plugin file %s in your plugins folder. DO NOT EXTRACT PLUGIN ZIPS!", name), true);
}
// rmrf(f);
}
}
fun loadAllOnlinePlugins(context: Context) {
File(context.filesDir, ONLINE_PLUGINS_FOLDER).listFiles()?.sortedBy { it.name }
?.forEach { file ->
maybeLoadPlugin(context, file)
}
}
fun loadAllLocalPlugins(context: Context) {
val dir = File(LOCAL_PLUGINS_PATH)
removeKey(PLUGINS_KEY_LOCAL)
if (!dir.exists()) { if (!dir.exists()) {
val res = dir.mkdirs() val res = dir.mkdirs()
if (!res) { if (!res) {
@ -30,39 +117,36 @@ object PluginManager {
return return
} }
} }
val sortedPlugins = dir.listFiles() val sortedPlugins = dir.listFiles()
// Always sort plugins alphabetically for reproducible results // Always sort plugins alphabetically for reproducible results
sortedPlugins?.sortedBy { it.name }?.forEach { file -> sortedPlugins?.sortedBy { it.name }?.forEach { file ->
val name = file.name maybeLoadPlugin(context, file)
if (file.extension == "zip" || file.extension == "cs3") {
loadPlugin(context, file)
} else if (name != "oat") { // Some roms create this
if (file.isDirectory) {
// Utils.showToast(String.format("Found directory %s in your plugins folder. DO NOT EXTRACT PLUGIN ZIPS!", name), true);
} else if (name == "classes.dex" || name.endsWith(".json")) {
// Utils.showToast(String.format("Found extracted plugin file %s in your plugins folder. DO NOT EXTRACT PLUGIN ZIPS!", name), true);
}
// rmrf(f);
}
} }
loadedPlugins = true loadedLocalPlugins = true
//if (!PluginManager.failedToLoad.isEmpty()) //if (!PluginManager.failedToLoad.isEmpty())
//Utils.showToast("Some plugins failed to load."); //Utils.showToast("Some plugins failed to load.");
} }
fun loadPlugin(context: Context, file: File) { /**
* @return True if successful, false if not
* */
private fun loadPlugin(context: Context, file: File, data: PluginData): Boolean {
val fileName = file.nameWithoutExtension val fileName = file.nameWithoutExtension
setPluginData(data)
println("Loading plugin: $data")
//logger.info("Loading plugin: " + fileName); //logger.info("Loading plugin: " + fileName);
try { return try {
val loader = PathClassLoader(file.absolutePath, context.classLoader) val loader = PathClassLoader(file.absolutePath, context.classLoader)
var manifest: Plugin.Manifest var manifest: Plugin.Manifest
loader.getResourceAsStream("manifest.json").use { stream -> loader.getResourceAsStream("manifest.json").use { stream ->
if (stream == null) { if (stream == null) {
failedToLoad[file] = "No manifest found" failedToLoad[file] = "No manifest found"
//logger.error("Failed to load plugin " + fileName + ": No manifest found", null); //logger.error("Failed to load plugin " + fileName + ": No manifest found", null);
return return false
} }
InputStreamReader(stream).use { reader -> InputStreamReader(stream).use { reader ->
manifest = gson.fromJson( manifest = gson.fromJson(
@ -76,10 +160,10 @@ object PluginManager {
loader.loadClass(manifest.pluginClassName) as Class<out Plugin?> loader.loadClass(manifest.pluginClassName) as Class<out Plugin?>
val pluginInstance: Plugin = val pluginInstance: Plugin =
pluginClass.newInstance() as Plugin pluginClass.newInstance() as Plugin
if (plugins.containsKey(name)) { // if (plugins.containsKey(name)) {
//logger.error("Plugin with name " + name + " already exists", null); //logger.error("Plugin with name " + name + " already exists", null);
return // return false
} // }
pluginInstance.__filename = fileName pluginInstance.__filename = fileName
if (pluginInstance.needsResources) { if (pluginInstance.needsResources) {
// based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk // based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk
@ -96,10 +180,29 @@ object PluginManager {
plugins[name] = pluginInstance plugins[name] = pluginInstance
classLoaders[loader] = pluginInstance classLoaders[loader] = pluginInstance
pluginInstance.load(context) pluginInstance.load(context)
true
} catch (e: Throwable) { } catch (e: Throwable) {
failedToLoad[file] = e failedToLoad[file] = e
e.printStackTrace() e.printStackTrace()
//logger.error("Failed to load plugin " + fileName + ":\n", e); //logger.error("Failed to load plugin " + fileName + ":\n", e);
false
} }
} }
suspend fun downloadPlugin(context: Context, pluginUrl: String, name: String): Boolean {
val file = downloadPluginToFile(context, pluginUrl, name)
return loadPlugin(
context,
file ?: return false,
PluginData(name, pluginUrl, true, file.absolutePath)
)
}
fun deletePlugin(context: Context, pluginUrl: String, name: String): Boolean {
val data = getPluginsOnline()
.firstOrNull { it.url == pluginUrl }
?: return false
deletePluginData(data)
return File(data.filePath).delete()
}
} }

View file

@ -2,10 +2,18 @@ package com.lagradost.cloudstream3.plugins
import android.content.Context import android.content.Context
import com.fasterxml.jackson.annotation.JsonProperty 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.apmap import com.lagradost.cloudstream3.apmap
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall 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.AppUtils.tryParseJson
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
@ -33,7 +41,9 @@ data class SitePlugin(
) )
object RepositoryParser { object RepositoryManager {
const val ONLINE_PLUGINS_FOLDER = "Extensions"
private suspend fun parseRepository(url: String): Repository? { private suspend fun parseRepository(url: String): Repository? {
return suspendSafeApiCall { return suspendSafeApiCall {
// Take manifestVersion and such into account later // Take manifestVersion and such into account later
@ -56,19 +66,29 @@ object RepositoryParser {
}.filterNotNull().flatten() }.filterNotNull().flatten()
} }
private suspend fun downloadSiteTemp(context: Context, pluginUrl: String, name: String): File? { suspend fun downloadPluginToFile(context: Context, pluginUrl: String, name: String): File? {
return suspendSafeApiCall { return suspendSafeApiCall {
val dir = context.cacheDir val extensionsDir = File(context.filesDir, ONLINE_PLUGINS_FOLDER)
val file = File.createTempFile(name, ".cs3", dir) if (!extensionsDir.exists())
extensionsDir.mkdirs()
val newFile = File(extensionsDir, "$name.${pluginUrl.hashCode()}.cs3")
if (newFile.exists()) return@suspendSafeApiCall newFile
newFile.createNewFile()
val body = app.get(pluginUrl).okhttpResponse.body val body = app.get(pluginUrl).okhttpResponse.body
write(body.byteStream(), file.outputStream()) write(body.byteStream(), newFile.outputStream())
file newFile
} }
} }
suspend fun loadSiteTemp(context: Context, pluginUrl: String, name: String) { // Don't want to read before we write in another thread
val file = downloadSiteTemp(context, pluginUrl, name) private val repoLock = Mutex()
PluginManager.loadPlugin(context, file ?: return) suspend fun addRepository(repository: RepositoryData) {
repoLock.withLock {
val currentRepos = getKey<List<RepositoryData>>(REPOSITORIES_KEY) ?: emptyList()
setKey(REPOSITORIES_KEY, currentRepos + repository)
}
} }
private fun write(stream: InputStream, output: OutputStream) { private fun write(stream: InputStream, output: OutputStream) {

View file

@ -11,6 +11,7 @@ import android.view.ViewGroup
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
@ -137,6 +138,7 @@ class SettingsFragment : Fragment() {
Pair(settings_ui, R.id.action_navigation_settings_to_navigation_settings_ui), Pair(settings_ui, R.id.action_navigation_settings_to_navigation_settings_ui),
Pair(settings_lang, R.id.action_navigation_settings_to_navigation_settings_lang), Pair(settings_lang, R.id.action_navigation_settings_to_navigation_settings_lang),
Pair(settings_updates, R.id.action_navigation_settings_to_navigation_settings_updates), Pair(settings_updates, R.id.action_navigation_settings_to_navigation_settings_updates),
Pair(settings_extensions, R.id.action_navigation_settings_to_navigation_settings_extensions),
).forEach { (view, navigationId) -> ).forEach { (view, navigationId) ->
view?.apply { view?.apply {
setOnClickListener { setOnClickListener {

View file

@ -0,0 +1,93 @@
package com.lagradost.cloudstream3.ui.settings.extensions
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.plugins.RepositoryManager
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import kotlinx.android.synthetic.main.add_repo_input.*
import kotlinx.android.synthetic.main.add_repo_input.apply_btt
import kotlinx.android.synthetic.main.add_repo_input.cancel_btt
import kotlinx.android.synthetic.main.fragment_extensions.*
import kotlinx.android.synthetic.main.stream_input.*
class ExtensionsFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
return inflater.inflate(R.layout.fragment_extensions, container, false)
}
private val extensionViewModel: ExtensionsViewModel by activityViewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
context?.fixPaddingStatusbar(extensions_root)
observe(extensionViewModel.repositories) {
// Kinda cheap to do this instead of updates
repo_recycler_view?.adapter = RepoAdapter(it) {
findNavController().navigate(
R.id.navigation_settings_extensions_to_navigation_settings_plugins,
Bundle().apply {
putString(PLUGINS_BUNDLE_NAME, it.name)
putString(PLUGINS_BUNDLE_URL, it.url)
})
}
}
add_repo_button?.setOnClickListener {
val builder =
AlertDialog.Builder(context ?: return@setOnClickListener, R.style.AlertDialogCustom)
.setView(R.layout.add_repo_input)
val dialog = builder.create()
dialog.show()
(activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?)?.primaryClip?.getItemAt(
0
)?.text?.toString()?.let { copy ->
dialog.repo_url_input?.setText(copy)
}
// dialog.text2?.text = provider.name
dialog.apply_btt?.setOnClickListener secondListener@{
val name = dialog.repo_name_input?.text?.toString()
val url = dialog.repo_url_input?.text?.toString()
if (url.isNullOrBlank() || name.isNullOrBlank()) {
showToast(activity, R.string.error_invalid_data, Toast.LENGTH_SHORT)
return@secondListener
}
ioSafe {
val newRepo = RepositoryData(name, url)
RepositoryManager.addRepository(newRepo)
extensionViewModel.loadRepositories()
}
dialog.dismissSafe(activity)
}
dialog.cancel_btt?.setOnClickListener {
dialog.dismissSafe(activity)
}
}
extensionViewModel.loadRepositories()
}
}

View file

@ -0,0 +1,31 @@
package com.lagradost.cloudstream3.ui.settings.extensions
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.plugins.RepositoryManager
import com.lagradost.cloudstream3.plugins.SitePlugin
data class RepositoryData(
@JsonProperty("name") val name: String,
@JsonProperty("url") val url: String
)
const val REPOSITORIES_KEY = "REPOSITORIES_KEY"
class ExtensionsViewModel : ViewModel() {
private val _repositories = MutableLiveData<Array<RepositoryData>>()
val repositories: LiveData<Array<RepositoryData>> = _repositories
fun loadRepositories() {
// Crashes weirdly with List<RepositoryData>
val urls = getKey<Array<RepositoryData>>(REPOSITORIES_KEY) ?: emptyArray()
_repositories.postValue(urls)
}
suspend fun getPlugins(repositoryUrl: String): List<SitePlugin> {
return RepositoryManager.getRepoPlugins(repositoryUrl) ?: emptyList()
}
}

View file

@ -0,0 +1,66 @@
package com.lagradost.cloudstream3.ui.settings.extensions
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.plugins.PluginData
import com.lagradost.cloudstream3.plugins.PluginManager
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
) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return PluginViewHolder(
LayoutInflater.from(parent.context).inflate(R.layout.repository_item, parent, false)
)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is PluginViewHolder -> {
holder.bind(plugins[position])
}
}
}
override fun getItemCount(): Int {
return plugins.size
}
private var storedPlugins: Array<PluginData> = reloadStoredPlugins()
fun reloadStoredPlugins(): Array<PluginData> {
return PluginManager.getPluginsOnline().also { storedPlugins = it }
}
inner class PluginViewHolder(itemView: View) :
RecyclerView.ViewHolder(itemView) {
fun bind(
plugin: SitePlugin
) {
val isDownloaded = storedPlugins.any { it.url == plugin.url }
println("ISOWNLOADED $isDownloaded ${storedPlugins.map { it.url }} ||||| ${plugin.url}")
val drawableInt = if (isDownloaded)
R.drawable.ic_baseline_delete_outline_24
else R.drawable.netflix_download
itemView.action_button.setImageResource(drawableInt)
itemView.action_button?.setOnClickListener {
iconClickCallback.invoke(this@PluginAdapter, plugin, isDownloaded)
}
itemView.main_text?.text = plugin.name
itemView.sub_text?.text = plugin.description
}
}
}

View file

@ -0,0 +1,83 @@
package com.lagradost.cloudstream3.ui.settings.extensions
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
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
import kotlinx.android.synthetic.main.fragment_extensions.*
const val PLUGINS_BUNDLE_NAME = "name"
const val PLUGINS_BUNDLE_URL = "url"
class PluginsFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
return inflater.inflate(R.layout.fragment_extensions, container, false)
}
private val extensionViewModel: ExtensionsViewModel by activityViewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
context?.fixPaddingStatusbar(extensions_root)
val name = arguments?.getString(PLUGINS_BUNDLE_NAME)
val url = arguments?.getString(PLUGINS_BUNDLE_URL)
if (url == null) {
activity?.onBackPressed()
return
}
ioSafe {
val plugins = extensionViewModel.getPlugins(url)
println("GET PLUGINS $plugins")
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
}
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()
}
}
}
}
}
}
}
companion object {
fun newInstance(name: String, url: String): Bundle {
return Bundle().apply {
putString(PLUGINS_BUNDLE_NAME, name)
putString(PLUGINS_BUNDLE_URL, url)
}
}
}
}

View file

@ -0,0 +1,46 @@
package com.lagradost.cloudstream3.ui.settings.extensions
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.ui.settings.AccountClickCallback
import kotlinx.android.synthetic.main.repository_item.view.*
class RepoAdapter(
private val repositories: Array<RepositoryData>,
val clickCallback: (RepositoryData) -> Unit
) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return RepoViewHolder(
LayoutInflater.from(parent.context).inflate(R.layout.repository_item, parent, false)
)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is RepoViewHolder -> {
holder.bind(repositories[position])
}
}
}
override fun getItemCount(): Int {
return repositories.size
}
inner class RepoViewHolder(itemView: View) :
RecyclerView.ViewHolder(itemView) {
fun bind(
repositoryData: RepositoryData
) {
itemView.setOnClickListener {
clickCallback(repositoryData)
}
itemView.main_text?.text = repositoryData.name
itemView.sub_text?.text = repositoryData.url
}
}
}

View file

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/text1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_rowWeight="1"
android:layout_gravity="center_vertical"
android:layout_marginTop="20dp"
android:layout_marginBottom="10dp"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:text="@string/add_repository"
android:textColor="?attr/textColor"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/text2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_rowWeight="1"
android:layout_gravity="center_vertical"
android:layout_marginBottom="10dp"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:textColor="?attr/grayTextColor"
android:textSize="15sp"
android:visibility="gone"
tools:text="Gogoanime" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="10dp"
android:layout_marginBottom="60dp"
android:orientation="vertical">
<EditText
android:id="@+id/repo_name_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="username"
android:hint="@string/repository_name_hint"
android:inputType="text"
android:nextFocusLeft="@id/apply_btt"
android:nextFocusRight="@id/cancel_btt"
android:nextFocusDown="@id/site_url_input"
android:requiresFadingEdge="vertical"
android:textColorHint="?attr/grayTextColor"
tools:ignore="LabelFor" />
<EditText
android:id="@+id/repo_url_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/repository_url_hint"
android:inputType="textUri"
android:nextFocusLeft="@id/apply_btt"
android:nextFocusRight="@id/cancel_btt"
android:nextFocusUp="@id/site_name_input"
android:nextFocusDown="@id/site_lang_input"
android:requiresFadingEdge="vertical"
android:textColorHint="?attr/grayTextColor"
tools:ignore="LabelFor" />
</LinearLayout>
<LinearLayout
android:id="@+id/apply_btt_holder"
android:layout_width="match_parent"
android:layout_height="60dp"
android:layout_gravity="bottom"
android:layout_marginTop="-60dp"
android:gravity="bottom|end"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/apply_btt"
style="@style/WhiteButton"
android:layout_width="wrap_content"
android:layout_gravity="center_vertical|end"
android:text="@string/add_repository" />
<com.google.android.material.button.MaterialButton
android:id="@+id/cancel_btt"
style="@style/BlackButton"
android:layout_width="wrap_content"
android:layout_gravity="center_vertical|end"
android:text="@string/sort_cancel" />
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/extensions_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/repo_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager">
</androidx.recyclerview.widget.RecyclerView>
</LinearLayout>
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/add_repo_button"
style="@style/ExtendedFloatingActionButton"
android:text="@string/add_repository"
android:textColor="?attr/textColor"
app:icon="@drawable/ic_baseline_add_24"
tools:ignore="ContentDescription" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -1,111 +1,117 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:background="?attr/primaryBlackBackground" android:layout_height="match_parent"
android:layout_height="match_parent"> android:background="?attr/primaryBlackBackground">
<ScrollView <ScrollView
app:layout_constraintTop_toTopOf="parent" android:layout_width="match_parent"
app:layout_constraintBottom_toBottomOf="parent" android:layout_height="wrap_content"
android:layout_width="match_parent" app:layout_constraintBottom_toBottomOf="parent"
android:layout_height="wrap_content"> app:layout_constraintTop_toTopOf="parent">
<LinearLayout <LinearLayout
android:orientation="vertical" android:layout_width="match_parent"
android:gravity="center_vertical" android:layout_height="wrap_content"
android:layout_width="match_parent" android:gravity="center_vertical"
android:layout_height="wrap_content"> android:orientation="vertical">
<LinearLayout <LinearLayout
android:visibility="gone" android:id="@+id/settings_profile"
tools:visibility="visible" android:layout_width="match_parent"
android:id="@+id/settings_profile" android:layout_height="wrap_content"
android:padding="20dp" android:orientation="horizontal"
android:orientation="horizontal" android:padding="20dp"
android:layout_width="match_parent" android:visibility="gone"
android:layout_height="wrap_content"> tools:visibility="visible">
<androidx.cardview.widget.CardView <androidx.cardview.widget.CardView
app:cardCornerRadius="25dp" android:layout_width="50dp"
android:layout_width="50dp" android:layout_height="50dp"
android:layout_height="50dp"> app:cardCornerRadius="25dp">
<ImageView <ImageView
android:id="@+id/settings_profile_pic" android:id="@+id/settings_profile_pic"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:ignore="ContentDescription" /> tools:ignore="ContentDescription" />
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>
<TextView <TextView
android:id="@+id/settings_profile_text" android:id="@+id/settings_profile_text"
android:textSize="18sp"
android:textStyle="normal"
android:paddingStart="10dp"
android:paddingEnd="10dp"
tools:text="Hello world"
android:layout_gravity="center_vertical"
android:gravity="center_vertical"
android:textColor="?attr/textColor"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<TextView
android:nextFocusDown="@id/settings_player"
android:id="@+id/settings_general"
style="@style/SettingsItem"
android:text="@string/category_general" />
<TextView
android:nextFocusUp="@id/settings_general"
android:nextFocusDown="@id/settings_lang"
android:id="@+id/settings_player"
style="@style/SettingsItem"
android:text="@string/category_player" />
<TextView
android:nextFocusUp="@id/settings_player"
android:nextFocusDown="@id/settings_ui"
android:id="@+id/settings_lang"
style="@style/SettingsItem"
android:text="@string/category_preferred_media_and_lang" />
<TextView
android:nextFocusUp="@id/settings_lang"
android:nextFocusDown="@id/settings_updates"
android:id="@+id/settings_ui"
style="@style/SettingsItem"
android:text="@string/category_ui" />
<TextView
android:nextFocusUp="@id/settings_ui"
android:nextFocusDown="@id/settings_credits"
android:id="@+id/settings_updates"
style="@style/SettingsItem"
android:text="@string/category_updates" />
<TextView
android:nextFocusUp="@id/settings_updates"
android:id="@+id/settings_credits"
style="@style/SettingsItem"
android:text="@string/category_account" />
<TextView
android:padding="10dp"
android:gravity="center"
android:layout_gravity="center"
android:textColor="?attr/textColor"
android:text="@string/app_version"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" /> android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:gravity="center_vertical"
android:paddingStart="10dp"
android:paddingEnd="10dp"
android:textColor="?attr/textColor"
android:textSize="18sp"
android:textStyle="normal"
tools:text="Hello world" />
</LinearLayout>
<TextView
android:id="@+id/settings_general"
style="@style/SettingsItem"
android:nextFocusDown="@id/settings_player"
android:text="@string/category_general" />
<TextView
android:id="@+id/settings_player"
style="@style/SettingsItem"
android:nextFocusUp="@id/settings_general"
android:nextFocusDown="@id/settings_lang"
android:text="@string/category_player" />
<TextView
android:id="@+id/settings_lang"
style="@style/SettingsItem"
android:nextFocusUp="@id/settings_player"
android:nextFocusDown="@id/settings_ui"
android:text="@string/category_preferred_media_and_lang" />
<TextView
android:id="@+id/settings_ui"
style="@style/SettingsItem"
android:nextFocusUp="@id/settings_lang"
android:nextFocusDown="@id/settings_updates"
android:text="@string/category_ui" />
<TextView
android:id="@+id/settings_updates"
style="@style/SettingsItem"
android:nextFocusUp="@id/settings_ui"
android:nextFocusDown="@id/settings_credits"
android:text="@string/category_updates" />
<TextView
android:id="@+id/settings_credits"
style="@style/SettingsItem"
android:nextFocusUp="@id/settings_updates"
android:text="@string/category_account" />
<TextView
android:id="@+id/settings_extensions"
style="@style/SettingsItem"
android:nextFocusUp="@id/settings_updates"
android:text="@string/extensions" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:padding="10dp"
android:text="@string/app_version"
android:textColor="?attr/textColor" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="20dp">
<LinearLayout
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/main_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
tools:text="Test repository" />
<TextView
android:id="@+id/sub_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?attr/grayTextColor"
android:textSize="12sp"
tools:text="https://github.com/..." />
</LinearLayout>
<ImageView
android:id="@+id/action_button"
android:layout_gravity="center_vertical"
android:layout_width="wrap_content"
tools:src="@drawable/ic_baseline_add_24"
android:layout_height="wrap_content">
</ImageView>
</LinearLayout>

View file

@ -128,6 +128,41 @@
app:popEnterAnim="@anim/enter_anim" app:popEnterAnim="@anim/enter_anim"
app:popExitAnim="@anim/exit_anim" /> app:popExitAnim="@anim/exit_anim" />
<fragment
android:id="@+id/navigation_settings_extensions"
android:name="com.lagradost.cloudstream3.ui.settings.extensions.ExtensionsFragment"
android:label="@string/title_settings"
app:enterAnim="@anim/enter_anim"
app:exitAnim="@anim/exit_anim"
app:popEnterAnim="@anim/enter_anim"
app:popExitAnim="@anim/exit_anim">
<action
android:id="@+id/navigation_settings_extensions_to_navigation_settings_plugins"
app:destination="@id/navigation_settings_plugins"
app:enterAnim="@anim/enter_anim"
app:exitAnim="@anim/exit_anim"
app:popEnterAnim="@anim/enter_anim"
app:popExitAnim="@anim/exit_anim">
<argument
android:name="name"
android:defaultValue="@null"
app:argType="string" />
<argument
android:name="url"
android:defaultValue="@null"
app:argType="string" />
</action>
</fragment>
<fragment
android:id="@+id/navigation_settings_plugins"
android:name="com.lagradost.cloudstream3.ui.settings.extensions.PluginsFragment"
android:label="@string/title_settings"
app:enterAnim="@anim/enter_anim"
app:exitAnim="@anim/exit_anim"
app:popEnterAnim="@anim/enter_anim"
app:popExitAnim="@anim/exit_anim" />
<fragment <fragment
android:id="@+id/navigation_settings_lang" android:id="@+id/navigation_settings_lang"
android:name="com.lagradost.cloudstream3.ui.settings.SettingsLang" android:name="com.lagradost.cloudstream3.ui.settings.SettingsLang"
@ -306,6 +341,13 @@
app:exitAnim="@anim/exit_anim" app:exitAnim="@anim/exit_anim"
app:popEnterAnim="@anim/enter_anim" app:popEnterAnim="@anim/enter_anim"
app:popExitAnim="@anim/exit_anim" /> app:popExitAnim="@anim/exit_anim" />
<action
android:id="@+id/action_navigation_settings_to_navigation_settings_extensions"
app:destination="@id/navigation_settings_extensions"
app:enterAnim="@anim/enter_anim"
app:exitAnim="@anim/exit_anim"
app:popEnterAnim="@anim/enter_anim"
app:popExitAnim="@anim/exit_anim" />
</fragment> </fragment>
<fragment <fragment

View file

@ -569,4 +569,10 @@
<string name="crash_reporting_title">Crash reporting</string> <string name="crash_reporting_title">Crash reporting</string>
<string name="preferred_media_subtext">What do you want to see</string> <string name="preferred_media_subtext">What do you want to see</string>
<string name="setup_done">Done</string> <string name="setup_done">Done</string>
<string name="extensions">Extensions</string>
<string name="add_repository">Add repository</string>
<string name="repository_name_hint">Repository name</string>
<string name="repository_url_hint">Repository url</string>
<string name="plugin_loaded">Plugin Loaded</string>
<string name="plugin_deleted">Plugin Deleted</string>
</resources> </resources>