Plugin auto-update support and prompting

This commit is contained in:
Kelvin 2024-05-15 21:26:44 +02:00
parent b4fddbe26a
commit d44df42727
30 changed files with 801 additions and 167 deletions

View file

@ -18,6 +18,7 @@ import android.widget.Toast
import androidx.core.content.ContextCompat
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.dialogs.AutomaticBackupDialog
@ -31,12 +32,17 @@ import com.futo.platformplayer.dialogs.ConnectedCastingDialog
import com.futo.platformplayer.dialogs.ImportDialog
import com.futo.platformplayer.dialogs.ImportOptionsDialog
import com.futo.platformplayer.dialogs.MigrateDialog
import com.futo.platformplayer.dialogs.PluginUpdateDialog
import com.futo.platformplayer.dialogs.ProgressDialog
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView
import kotlinx.coroutines.CoroutineScope
@ -184,6 +190,14 @@ class UIDialogs {
dialog.show();
}
fun showPluginUpdateDialog(context: Context, oldConfig: SourcePluginConfig, newConfig: SourcePluginConfig) {
val dialog = PluginUpdateDialog(context, oldConfig, newConfig);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
}
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action) {
val builder = AlertDialog.Builder(context);
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
@ -269,22 +283,48 @@ class UIDialogs {
}, UIDialogs.ActionStyle.PRIMARY)
);
}
fun showGeneralRetryErrorDialog(context: Context, msg: String, ex: Throwable? = null, retryAction: (() -> Unit)? = null, closeAction: (() -> Unit)? = null) {
fun showGeneralRetryErrorDialog(context: Context, msg: String, ex: Throwable? = null, retryAction: (() -> Unit)? = null, closeAction: (() -> Unit)? = null, mainFragment: MainFragment? = null) {
val pluginConfig = if(ex is PluginException) ex.config else null;
val pluginInfo = if(ex is PluginException)
"\nPlugin [${ex.config.name}]" else "";
showDialog(context,
R.drawable.ic_error_pred,
"${msg}${pluginInfo}",
(if(ex != null ) "${ex.message}" else ""),
if(ex is PluginException) ex.code else null,
0,
UIDialogs.Action(context.getString(R.string.retry), {
retryAction?.invoke();
}, UIDialogs.ActionStyle.PRIMARY),
UIDialogs.Action(context.getString(R.string.close), {
closeAction?.invoke()
}, UIDialogs.ActionStyle.NONE)
);
var exMsg = if(ex != null ) "${ex.message}" else "";
if(pluginConfig != null && pluginConfig is SourcePluginConfig && StatePlugins.instance.hasUpdateAvailable(pluginConfig))
exMsg += "\n\nAn update is available"
if(mainFragment != null && pluginConfig != null && pluginConfig is SourcePluginConfig && StatePlugins.instance.hasUpdateAvailable(pluginConfig))
showDialog(context,
R.drawable.ic_error_pred,
"${msg}${pluginInfo}",
exMsg,
if(ex is PluginException) ex.code else null,
1,
UIDialogs.Action(context.getString(R.string.update), {
mainFragment.navigate<SourceDetailFragment>(pluginConfig);
if(mainFragment is VideoDetailFragment)
mainFragment.minimizeVideoDetail();
}, UIDialogs.ActionStyle.ACCENT),
UIDialogs.Action(context.getString(R.string.close), {
closeAction?.invoke()
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action(context.getString(R.string.retry), {
retryAction?.invoke();
}, UIDialogs.ActionStyle.PRIMARY)
);
else
showDialog(context,
R.drawable.ic_error_pred,
"${msg}${pluginInfo}",
exMsg,
if(ex is PluginException) ex.code else null,
0,
UIDialogs.Action(context.getString(R.string.close), {
closeAction?.invoke()
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action(context.getString(R.string.retry), {
retryAction?.invoke();
}, UIDialogs.ActionStyle.PRIMARY)
);
}
fun showSingleButtonDialog(context: Context, icon: Int, text: String, buttonText: String, action: (() -> Unit)) {

View file

@ -224,7 +224,7 @@ class AddSourceActivity : AppCompatActivity() {
val isNew = !StatePlatform.instance.getAvailableClients().any { it.id == config.id };
StatePlugins.instance.installPlugin(this, lifecycleScope, config, script) {
if(it) {
StatePlatform.instance.clearUpdateAvailable(config)
StatePlugins.instance.clearUpdateAvailable(config)
if(isNew)
lifecycleScope.launch {
StatePlatform.instance.enableClient(listOf(config.id));

View file

@ -46,6 +46,7 @@ import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.engine.exceptions.PluginEngineException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptValidationException
import com.futo.platformplayer.logging.Logger
@ -56,6 +57,7 @@ import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.lang.Exception
import java.time.OffsetDateTime
import kotlin.reflect.full.findAnnotations
import kotlin.reflect.jvm.kotlinFunction

View file

@ -80,6 +80,41 @@ class SourcePluginConfig(
return _allowUrlsLowerVal!!;
};
fun isLowRiskUpdate(oldScript: String, newConfig: SourcePluginConfig, newScript: String): Boolean{
//All urls should already be allowed
for(url in newConfig.allowUrls) {
if(!allowUrls.contains(url))
return false;
}
//All packages should already be allowed
for(pack in newConfig.packages) {
if(!packages.contains(pack))
return false;
}
//Developer Submit Url should be same or empty
if(!newConfig.developerSubmitUrl.isNullOrEmpty() && developerSubmitUrl != newConfig.developerSubmitUrl)
return false;
//Should have a public key
if(scriptPublicKey.isNullOrEmpty() || scriptSignature.isNullOrEmpty())
return false;
//Should be same public key
if(scriptPublicKey != newConfig.scriptPublicKey)
return false;
//Old signature should be valid
if(!validate(oldScript))
return false;
//New signature should be valid
if(!newConfig.validate(newScript))
return false;
return true;
}
fun getWarnings(scriptToCheck: String? = null) : List<Pair<String,String>> {
val list = mutableListOf<Pair<String,String>>();

View file

@ -91,8 +91,10 @@ class SourcePluginDescriptor {
@Serializable
class AppPluginSettings {
@FormField(R.string.check_for_updates_setting, FieldForm.TOGGLE, R.string.check_for_updates_setting_description, 0)
@FormField(R.string.check_for_updates_setting, FieldForm.TOGGLE, R.string.check_for_updates_setting_description, -1)
var checkForUpdates: Boolean = true;
@FormField(R.string.automatic_update_setting, FieldForm.TOGGLE, R.string.automatic_update_setting_description, 0)
var automaticUpdate: Boolean = false;
@FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2)
var tabEnabled = TabEnabled();

View file

@ -0,0 +1,253 @@
package com.futo.platformplayer.dialogs
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.Animatable
import android.media.MediaCas.PluginDescriptor
import android.net.Uri
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableString
import android.text.method.ScrollingMovementMethod
import android.text.style.ForegroundColorSpan
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.widget.Button
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.isVisible
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.AddSourceActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.assume
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.stores.v2.ManagedStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class PluginUpdateDialog : AlertDialog {
companion object {
private val TAG = "PluginUpdateDialog";
}
private val _context: Context;
private lateinit var _buttonCancel1: Button;
private lateinit var _buttonCancel2: Button;
private lateinit var _buttonUpdate: LinearLayout;
private lateinit var _buttonOk: LinearLayout;
private lateinit var _buttonInstall: LinearLayout;
private lateinit var _textPlugin: TextView;
private lateinit var _textProgres: TextView;
private lateinit var _textError: TextView;
private lateinit var _textResult: TextView;
private lateinit var _uiChoiceTop: FrameLayout;
private lateinit var _uiProgressTop: FrameLayout;
private lateinit var _uiRiskTop: FrameLayout;
private lateinit var _uiChoiceBot: LinearLayout;
private lateinit var _uiResultBot: LinearLayout;
private lateinit var _uiRiskBot: LinearLayout;
private lateinit var _uiProgressBot: LinearLayout;
private lateinit var _iconPlugin: ImageView;
private lateinit var _updateSpinner: ImageView;
private var _isUpdating = false;
private val _oldConfig: SourcePluginConfig;
private val _newConfig: SourcePluginConfig;
constructor(context: Context, oldConfig: SourcePluginConfig, newConfig: SourcePluginConfig): super(context) {
_context = context;
_oldConfig = oldConfig;
_newConfig = newConfig;
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_plugin_update, null));
_buttonCancel1 = findViewById(R.id.button_cancel_1);
_buttonCancel2 = findViewById(R.id.button_cancel_2);
_buttonUpdate = findViewById(R.id.button_update);
_buttonOk = findViewById(R.id.button_ok);
_buttonInstall = findViewById(R.id.button_install);
_textPlugin = findViewById(R.id.text_plugin);
_textProgres = findViewById(R.id.text_progress);
_textError = findViewById(R.id.text_error);
_textResult = findViewById(R.id.text_result);
_uiChoiceTop = findViewById(R.id.dialog_ui_choice_top);
_uiProgressTop = findViewById(R.id.dialog_ui_progress_top);
_uiRiskTop = findViewById(R.id.dialog_ui_risk_top);
_uiChoiceBot = findViewById(R.id.dialog_ui_bottom_choice);
_uiResultBot = findViewById(R.id.dialog_ui_bottom_result);
_uiRiskBot = findViewById(R.id.dialog_ui_bottom_risk);
_uiProgressBot = findViewById(R.id.dialog_ui_bottom_progress);
_updateSpinner = findViewById(R.id.update_spinner);
_iconPlugin = findViewById(R.id.icon_plugin);
_buttonCancel1.setOnClickListener {
dismiss();
};
_buttonCancel2.setOnClickListener {
dismiss();
};
_buttonUpdate.setOnClickListener {
if (_isUpdating)
return@setOnClickListener;
_isUpdating = true;
update();
};
Glide.with(_iconPlugin)
.load(_oldConfig.absoluteIconUrl)
.fallback(R.drawable.ic_sources)
.into(_iconPlugin);
_textPlugin.text = _oldConfig.name;
val descriptor = StatePlugins.instance.getPlugin(_oldConfig.id);
if(descriptor != null) {
if(descriptor.appSettings.automaticUpdate) {
if (_isUpdating)
return;
_isUpdating = true;
update();
}
}
}
override fun dismiss() {
super.dismiss();
}
private fun update() {
_uiChoiceTop.visibility = View.GONE;
_uiRiskTop.visibility = View.GONE;
_uiChoiceBot.visibility = View.GONE;
_uiResultBot.visibility = View.GONE;
_uiRiskBot.visibility = View.GONE;
_uiProgressTop.visibility = View.VISIBLE;
_uiProgressBot.visibility = View.VISIBLE;
setCancelable(false);
setCanceledOnTouchOutside(false);
Logger.i(TAG, "Keep screen on set import")
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
_updateSpinner.drawable?.assume<Animatable>()?.start();
val scope = StateApp.instance.scopeOrNull;
scope?.launch(Dispatchers.IO) {
try {
val client = ManagedHttpClient();
val script = StatePlugins.instance.getScript(_oldConfig.id) ?: "";
val newScript = client.get(_newConfig.absoluteScriptUrl)?.body?.string();
if(newScript.isNullOrEmpty())
throw IllegalStateException("No script found");
if(_oldConfig.isLowRiskUpdate(script, _newConfig, newScript)){
StatePlugins.instance.installPluginBackground(context, StateApp.instance.scope, _newConfig, newScript,
{ text: String, progress: Double ->
_textProgres.setText(text);
},
{ ex ->
if(ex == null) {
StatePlugins.instance.clearUpdateAvailable(_newConfig);
_iconPlugin.setImageResource(R.drawable.ic_check);
_textError.visibility = View.GONE;
_textResult.visibility = View.VISIBLE;
}
else {
_iconPlugin.setImageResource(R.drawable.ic_error_pred);
_textError.text = ex.message + "\n\nYou can retry inside the sources tab";
_textError.visibility = View.VISIBLE;
_textResult.visibility = View.GONE;
}
try {
_buttonOk.setOnClickListener {
dismiss();
}
_uiProgressTop.visibility = View.GONE;
_uiProgressBot.visibility = View.GONE;
_uiChoiceTop.visibility = View.VISIBLE;
_uiResultBot.visibility = View.VISIBLE;
} catch (e: Throwable) {
Logger.e(TAG, "Failed to update UI.", e)
} finally {
Logger.i(TAG, "Keep screen on unset update")
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
});
}
else {
withContext(Dispatchers.Main) {
try {
_buttonInstall.setOnClickListener {
dismiss();
val intent = Intent(_context, AddSourceActivity::class.java).apply {
data = Uri.parse(_newConfig.sourceUrl)
};
_context.startActivity(intent);
}
_uiProgressTop.visibility = View.GONE;
_uiProgressBot.visibility = View.GONE;
_uiRiskTop.visibility = View.VISIBLE;
_uiRiskBot.visibility = View.VISIBLE;
} catch (e: Throwable) {
Logger.e(TAG, "Failed to update UI.", e)
} finally {
Logger.i(TAG, "Keep screen on unset update")
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to update.", e);
withContext(Dispatchers.Main) {
_buttonOk.setOnClickListener {
dismiss();
}
_iconPlugin.setImageResource(R.drawable.ic_error_pred);
_textResult.visibility = View.GONE;
_uiProgressTop.visibility = View.GONE;
_uiProgressBot.visibility = View.GONE;
_uiChoiceTop.visibility = View.VISIBLE;
_uiResultBot.visibility = View.VISIBLE;
_textError.visibility = View.VISIBLE;
_textError.text = e.message + "\n\nYou can retry inside the sources tab"
}
}
}
}
}

View file

@ -316,7 +316,7 @@ class PackageHttp: V8Package {
return result
}
/*private fun logRequest(method: String, url: String, headers: Map<String, String> = HashMap(), body: String?) {
private fun logRequest(method: String, url: String, headers: Map<String, String> = HashMap(), body: String?) {
Logger.v(TAG) {
val stringBuilder = StringBuilder();
stringBuilder.appendLine("HTTP request (useAuth = )");
@ -333,7 +333,7 @@ class PackageHttp: V8Package {
return@v stringBuilder.toString();
};
}*/
}
/*private fun logResponse(method: String, url: String, responseCode: Int? = null, responseHeaders: Map<String, List<String>> = HashMap(), responseBody: String? = null) {
Logger.v(TAG) {

View file

@ -152,7 +152,7 @@ class ChannelFragment : MainFragment() {
}
.exception<Throwable> {
Logger.e(TAG, "Failed to load channel.", it);
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadChannel() });
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadChannel() }, null, fragment);
}
val tabs: TabLayout = findViewById(R.id.tabs);

View file

@ -99,7 +99,7 @@ class ContentSearchResultsFragment : MainFragment() {
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
.exception<Throwable> {
Logger.w(TAG, "Failed to load results.", it);
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() }, null, fragment);
}
setPreviewsEnabled(Settings.instance.search.previewFeedItems);

View file

@ -60,7 +60,7 @@ class CreatorSearchResultsFragment : MainFragment() {
.exception<ScriptCaptchaRequiredException> { }
.exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load results.", it);
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() }, null, fragment);
}
}

View file

@ -144,7 +144,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
Logger.w(TAG, "Failed to load next page.", it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
loadNextPage();
});
}, null, fragment);
//UIDialogs.showDataRetryDialog(layoutInflater, it.message, { loadNextPage() });
};

View file

@ -174,7 +174,7 @@ class HistoryFragment : MainFragment() {
Logger.w(TAG, "Failed to load next page.", it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
loadNextPage();
});
}, null, fragment);
};
}

View file

@ -126,10 +126,10 @@ class HomeFragment : MainFragment() {
Logger.w(TAG, "Failed to load channel.", it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_get_home), it, {
loadResults()
}) {
}, {
finishRefreshLayoutLoader();
setLoading(false);
};
}, fragment);
};
setPreviewsEnabled(Settings.instance.home.previewFeedItems);

View file

@ -146,7 +146,7 @@ class PlaylistFragment : MainFragment() {
.exception<Throwable> {
Logger.w(TAG, "Failed to load playlist.", it);
val c = context ?: return@exception;
UIDialogs.showGeneralRetryErrorDialog(c, context.getString(R.string.failed_to_load_playlist), it, ::fetchPlaylist);
UIDialogs.showGeneralRetryErrorDialog(c, context.getString(R.string.failed_to_load_playlist), it, ::fetchPlaylist, null, fragment);
};
}

View file

@ -69,7 +69,7 @@ class PlaylistSearchResultsFragment : MainFragment() {
.success { loadedResult(it); }
.exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load results.", it);
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() }, null, fragment);
}
}

View file

@ -162,7 +162,7 @@ class PostDetailFragment : MainFragment {
.success { setPostDetails(it) }
.exception<Throwable> {
Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>(StateApp.instance.scopeGetter, { PolycentricCache.instance.getProfileAsync(it) })

View file

@ -262,7 +262,7 @@ class SubscriptionsFeedFragment : MainFragment() {
.exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load channel.", it);
if(it !is CancellationException)
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults(true) });
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults(true) }, null, fragment);
else {
finishRefreshLayoutLoader();
setLoading(false);

View file

@ -40,7 +40,7 @@ class SuggestionsFragment : MainFragment {
.success { suggestions -> updateSuggestions(suggestions, false) }
.exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load suggestions.", it);
UIDialogs.showGeneralRetryErrorDialog(requireContext(), it.message ?: "", it, { loadSuggestions() });
UIDialogs.showGeneralRetryErrorDialog(requireContext(), it.message ?: "", it, { loadSuggestions() }, null, this);
};
constructor(): super() {

View file

@ -2476,7 +2476,7 @@ class VideoDetailView : ConstraintLayout {
Logger.w(TAG, "exception<ScriptImplementationException>", it)
if (!nextVideo()) {
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, ::fetchVideo);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, ::fetchVideo, null, fragment);
} else {
StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_INVALIDVIDEO", context.getString(R.string.invalid_video), context.getString(
R.string.there_was_an_invalid_video_in_your_queue_videoname_by_authorname_playback_was_skipped).replace("{videoName}", video?.name ?: "").replace("{authorName}", video?.author?.name ?: ""), AnnouncementType.SESSION)
@ -2512,7 +2512,7 @@ class VideoDetailView : ConstraintLayout {
_retryJob = null;
_liveTryJob?.cancel();
_liveTryJob = null;
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, ::fetchVideo);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, ::fetchVideo, null, fragment);
}
}
.exception<Throwable> {
@ -2524,7 +2524,7 @@ class VideoDetailView : ConstraintLayout {
_retryJob = null;
_liveTryJob?.cancel();
_liveTryJob = null;
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, ::fetchVideo);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, ::fetchVideo, null, fragment);
}
} else TaskHandler(IPlatformVideoDetails::class.java, {fragment.lifecycleScope});

View file

@ -571,18 +571,22 @@ class StateApp {
StateAnnouncement.instance.deleteAnnouncement("plugin-update")
scopeOrNull?.launch(Dispatchers.IO) {
val updateAvailable = StatePlatform.instance.checkForUpdates()
val updateAvailable = StatePlugins.instance.checkForUpdates()
withContext(Dispatchers.Main) {
if (updateAvailable.isNotEmpty()) {
UIDialogs.appToast(
ToastView.Toast(updateAvailable
.map { " - " + it.name }
.map { " - " + it.first.name }
.joinToString("\n"),
true,
null,
"Plugin updates available"
));
for(update in updateAvailable)
if(StatePlatform.instance.isClientEnabled(update.first.id))
UIDialogs.showPluginUpdateDialog(context, update.first, update.second);
}
}
}

View file

@ -80,7 +80,6 @@ class StatePlatform {
private val _clientsLock = Object();
private val _availableClients : ArrayList<IPlatformClient> = ArrayList();
private val _enabledClients : ArrayList<IPlatformClient> = ArrayList();
private var _updatesAvailableMap: HashSet<String> = hashSetOf();
//ClientPools are used to isolate plugin usage of certain components from others
//This prevents for example a background task like subscriptions from blocking a user from opening a video
@ -925,66 +924,7 @@ class StatePlatform {
}
}
fun hasUpdateAvailable(c: SourcePluginConfig): Boolean {
val updatesAvailableMap = _updatesAvailableMap
synchronized(updatesAvailableMap) {
return updatesAvailableMap.contains(c.id)
}
}
suspend fun checkForUpdates(): List<SourcePluginConfig> = withContext(Dispatchers.IO) {
var configs = mutableListOf<SourcePluginConfig>()
val updatesAvailableFor = hashSetOf<String>()
for (availableClient in getAvailableClients().filter { it is JSClient && it.descriptor.appSettings.checkForUpdates }) {
if (availableClient !is JSClient) {
continue
}
if (checkForUpdates(availableClient.config)) {
configs.add(availableClient.config);
updatesAvailableFor.add(availableClient.config.id)
}
}
_updatesAvailableMap = updatesAvailableFor
return@withContext configs;
}
fun clearUpdateAvailable(c: SourcePluginConfig) {
val updatesAvailableMap = _updatesAvailableMap
synchronized(updatesAvailableMap) {
updatesAvailableMap.remove(c.id)
}
}
private suspend fun checkForUpdates(c: SourcePluginConfig): Boolean = withContext(Dispatchers.IO) {
val sourceUrl = c.sourceUrl ?: return@withContext false;
Logger.i(TAG, "Check for source updates '${c.name}'.");
try {
val client = ManagedHttpClient();
val response = client.get(sourceUrl);
Logger.i(TAG, "Downloading source config '$sourceUrl'.");
if (!response.isOk || response.body == null) {
return@withContext false;
}
val configJson = response.body.string();
Logger.i(TAG, "Downloaded source config ($sourceUrl):\n${configJson}");
val config = SourcePluginConfig.fromJson(configJson);
if (config.version <= c.version) {
return@withContext false;
}
Logger.i(TAG, "Update is available (config.version=${config.version}, source.config.version=${c.version}).");
return@withContext true;
} catch (e: Throwable) {
Logger.e(TAG, "Failed to check for updates.", e);
return@withContext false;
}
}
companion object {
private var _instance : StatePlatform? = null;

View file

@ -43,6 +43,7 @@ class StatePlugins {
private var _embeddedSourcesDefault: List<String>? = null
private var _sourcesUnderConstruction: Map<String, ImageVariable>? = null
private var _updatesAvailableMap: HashSet<String> = hashSetOf();
fun getPluginIconOrNull(id: String): ImageVariable? {
if(iconsDir.hasIcon(id))
@ -55,6 +56,70 @@ class StatePlugins {
.load();
}
suspend fun checkForUpdates(): List<Pair<SourcePluginConfig, SourcePluginConfig>> = withContext(Dispatchers.IO) {
var configs = mutableListOf<Pair<SourcePluginConfig, SourcePluginConfig>>()
val updatesAvailableFor = hashSetOf<String>()
for (availableClient in StatePlatform.instance.getAvailableClients().filter { it is JSClient && it.descriptor.appSettings.checkForUpdates }) {
if (availableClient !is JSClient) {
continue
}
val newConfig = checkForUpdates(availableClient.config);
if (newConfig != null) {
configs.add(Pair(availableClient.config, newConfig));
updatesAvailableFor.add(availableClient.config.id)
}
}
_updatesAvailableMap = updatesAvailableFor
return@withContext configs;
}
private suspend fun checkForUpdates(c: SourcePluginConfig): SourcePluginConfig? = withContext(Dispatchers.IO) {
val sourceUrl = c.sourceUrl ?: return@withContext null;
Logger.i(TAG, "Check for source updates '${c.name}'.");
try {
val client = ManagedHttpClient();
val response = client.get(sourceUrl);
Logger.i(TAG, "Downloading source config '$sourceUrl'.");
if (!response.isOk || response.body == null) {
return@withContext null;
}
val configJson = response.body.string();
Logger.i(TAG, "Downloaded source config ($sourceUrl):\n${configJson}");
val config = SourcePluginConfig.fromJson(configJson);
if (config.version <= c.version) {
return@withContext null;
}
Logger.i(TAG, "Update is available (config.version=${config.version}, source.config.version=${c.version}).");
return@withContext config;
} catch (e: Throwable) {
Logger.e(TAG, "Failed to check for updates.", e);
return@withContext null;
}
}
fun hasUpdateAvailable(c: SourcePluginConfig): Boolean {
val updatesAvailableMap = _updatesAvailableMap
synchronized(updatesAvailableMap) {
return updatesAvailableMap.contains(c.id)
}
}
fun clearUpdateAvailable(c: SourcePluginConfig) {
val updatesAvailableMap = _updatesAvailableMap
synchronized(updatesAvailableMap) {
updatesAvailableMap.remove(c.id)
}
}
fun loginPlugin(context: Context, id: String, afterLogin: ()->Unit): Boolean {
val descriptor = getPlugin(id) ?: return false;
val config = descriptor.config;
@ -353,6 +418,49 @@ class StatePlugins {
else verifyCanInstall();
}
fun installPluginBackground(context: Context, scope: CoroutineScope, config: SourcePluginConfig, script: String, onProgress: (text: String, progress: Double)->Unit, onConcluded: (ex: Throwable?)->Unit) {
scope.launch(Dispatchers.IO) {
val client = ManagedHttpClient();
try {
withContext(Dispatchers.Main) {
onProgress.invoke("Validating script", 0.25);
}
val tempDescriptor = SourcePluginDescriptor(config);
val plugin = JSClient(context, tempDescriptor, null, script);
plugin.validate();
withContext(Dispatchers.Main) {
onProgress.invoke("Downloading Icon", 0.5);
}
val icon = config.absoluteIconUrl?.let { absIconUrl ->
withContext(Dispatchers.Main) {
onProgress.invoke("Saving plugin", 0.75);
}
val iconResp = client.get(absIconUrl);
if(iconResp.isOk)
return@let iconResp.body?.byteStream()?.use { it.readBytes() };
return@let null;
}
val installEx = StatePlugins.instance.createPlugin(config, script, icon, true);
if(installEx != null)
throw installEx;
StatePlatform.instance.updateAvailableClients(context);
withContext(Dispatchers.Main) {
onProgress.invoke("Finished", 1.0)
onConcluded.invoke(null);
}
} catch (ex: Exception) {
Logger.e(TAG, ex.message ?: "null", ex);
withContext(Dispatchers.Main) {
onConcluded.invoke(ex);
}
}
}
}
fun getPlugin(id: String): SourcePluginDescriptor? {
if(id == StateDeveloper.DEV_ID)
throw IllegalStateException("Attempted to retrieve a persistent developer plugin, this is not allowed");

View file

@ -10,6 +10,7 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
class DisabledSourceView : LinearLayout {
private val _root: LinearLayout;
@ -37,7 +38,7 @@ class DisabledSourceView : LinearLayout {
_textSource.text = client.name;
if (client is JSClient && StatePlatform.instance.hasUpdateAvailable(client.config)) {
if (client is JSClient && StatePlugins.instance.hasUpdateAvailable(client.config)) {
_textSourceSubtitle.text = context.getString(R.string.update_available_exclamation)
_textSourceSubtitle.setTextColor(context.getColor(R.color.light_blue_400))
_textSourceSubtitle.typeface = resources.getFont(R.font.inter_regular)

View file

@ -13,6 +13,7 @@ import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
class EnabledSourceViewHolder : ViewHolder {
private val _imageSource: ImageView;
@ -61,7 +62,7 @@ class EnabledSourceViewHolder : ViewHolder {
_textSource.text = client.name
if (client is JSClient && StatePlatform.instance.hasUpdateAvailable(client.config)) {
if (client is JSClient && StatePlugins.instance.hasUpdateAvailable(client.config)) {
_textSourceSubtitle.text = itemView.context.getString(R.string.update_available_exclamation)
_textSourceSubtitle.setTextColor(itemView.context.getColor(R.color.light_blue_400))
_textSourceSubtitle.typeface = itemView.resources.getFont(R.font.inter_regular)

View file

@ -0,0 +1,315 @@
<?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:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:background="@color/gray_1d">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:paddingTop="30dp"
android:paddingBottom="30dp">
<FrameLayout
android:id="@+id/dialog_ui_choice_top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="visible">
<ImageView
android:id="@+id/icon_plugin"
android:layout_width="100dp"
android:layout_height="100dp"
app:srcCompat="@drawable/ic_sources" />
</FrameLayout>
<FrameLayout
android:id="@+id/dialog_ui_risk_top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone">
<ImageView
android:layout_width="100dp"
android:layout_height="100dp"
app:srcCompat="@drawable/ic_warning_yellow" />
</FrameLayout>
<FrameLayout
android:id="@+id/dialog_ui_progress_top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone">
<ImageView
android:id="@+id/update_spinner"
android:layout_width="100dp"
android:layout_height="100dp"
app:srcCompat="@drawable/ic_update_animated"
android:visibility="visible" />
</FrameLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Plugin Update"
android:textSize="15sp"
android:textColor="@color/white"
android:fontFamily="@font/inter_extra_light"
android:layout_marginTop="15dp"
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp" />
<TextView
android:id="@+id/text_plugin"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Some Plugin Name"
android:textSize="18sp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp" />
<LinearLayout
android:id="@+id/dialog_ui_bottom_choice"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="visible"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="center"
android:text="A new update is available.\nWould you like to update this plugin?"
android:textSize="14sp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
android:layout_marginTop="10dp"
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="center"
android:text="Updates may be critical to functionality"
android:textSize="13sp"
android:textColor="@color/pastel_red"
android:fontFamily="@font/inter_regular"
android:layout_marginTop="10dp"
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginTop="28dp">
<Space
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<Button
android:id="@+id/button_cancel_1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/cancel"
android:textSize="14dp"
android:fontFamily="@font/inter_regular"
android:textColor="@color/colorPrimary"
android:background="@color/transparent" />
<LinearLayout
android:id="@+id/button_update"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_button_primary"
android:layout_marginEnd="28dp"
android:clickable="true">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Update"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="28dp"
android:paddingEnd="28dp"/>
</LinearLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/dialog_ui_bottom_progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:id="@+id/text_progress"
android:layout_height="wrap_content"
android:textAlignment="center"
android:text="This plugin has modified its permissions"
android:textSize="14sp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
android:layout_marginTop="10dp"
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/dialog_ui_bottom_risk"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="center"
android:text="This plugin has modified its permissions"
android:textSize="14sp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
android:layout_marginTop="10dp"
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="center"
android:text="Make sure you read the installation screen"
android:textSize="13sp"
android:textColor="@color/pastel_red"
android:fontFamily="@font/inter_regular"
android:layout_marginTop="10dp"
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginTop="28dp">
<Space
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<Button
android:id="@+id/button_cancel_2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/cancel"
android:textSize="14dp"
android:fontFamily="@font/inter_regular"
android:textColor="@color/colorPrimary"
android:background="@color/transparent" />
<LinearLayout
android:id="@+id/button_install"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_button_primary"
android:layout_marginEnd="28dp"
android:clickable="true">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Reinstall"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="28dp"
android:paddingEnd="28dp"/>
</LinearLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/dialog_ui_bottom_result"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:orientation="vertical">
<TextView
android:id="@+id/text_error"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="center"
android:text=""
android:textSize="13sp"
android:textColor="@color/pastel_red"
android:fontFamily="@font/inter_regular"
android:layout_marginTop="10dp"
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp" />
<TextView
android:id="@+id/text_result"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="center"
android:text="Succesfully updated plugin."
android:textSize="13sp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
android:layout_marginTop="10dp"
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginTop="28dp">
<Space
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<LinearLayout
android:id="@+id/button_ok"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_button_primary"
android:layout_marginEnd="28dp"
android:clickable="true">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Ok"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="28dp"
android:paddingEnd="28dp"/>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>

View file

@ -489,6 +489,8 @@
<string name="visibility">Visibility</string>
<string name="check_for_updates_setting">Check for updates</string>
<string name="check_for_updates_setting_description">If a plugin should be checked for updates on startup</string>
<string name="automatic_update_setting">Automatic Update</string>
<string name="automatic_update_setting_description">Update automatically on boot if no permissions changed and plugin is enabled</string>
<string name="allow_developer_submit">Allow Developer Submissions</string>
<string name="allow_developer_submit_description">Allows the developer to send data to their server, be careful as this might include sensitive data.</string>
<string name="allow_developer_submit_warning">Make sure you trust the developer. They may gain access to sensitive data. Only enable this when you are trying to help the developer fix a bug.</string>

View file

@ -1,24 +0,0 @@
{
"name": "Testing",
"description": "Just for testing.",
"author": "FUTO",
"authorUrl": "https://futo.org",
"platformUrl": "https://odysee.com",
"sourceUrl": "https://plugins.grayjay.app/Test/TestConfig.json",
"repositoryUrl": "https://futo.org",
"scriptUrl": "./TestScript.js",
"version": 31,
"iconUrl": "./odysee.png",
"id": "1c05bfc3-08b9-42d0-93d3-6d52e0fd34d8",
"scriptSignature": "",
"scriptPublicKey": "",
"packages": ["Http"],
"allowEval": false,
"allowUrls": [],
"supportedClaimTypes": []
}

View file

@ -1,45 +0,0 @@
var config = {};
//Source Methods
source.enable = function(conf){
config = conf ?? {};
//log(config);
}
source.getHome = function() {
return new ContentPager([
source.getContentDetails("whatever")
]);
};
//Video
source.isContentDetailsUrl = function(url) {
return REGEX_DETAILS_URL.test(url)
};
source.getContentDetails = function(url) {
return new PlatformVideoDetails({
id: new PlatformID("Test", "Something", config.id),
name: "Test Video",
thumbnails: new Thumbnails([]),
author: new PlatformAuthorLink(new PlatformID("Test", "TestID", config.id),
"TestAuthor",
"None",
""),
datetime: parseInt(new Date().getTime() / 1000),
duration: 0,
viewCount: 0,
url: "",
isLive: false,
description: "",
rating: new RatingLikes(0),
video: new VideoSourceDescriptor([
new HLSSource({
name: "HLS",
url: "",
duration: 0,
priority: true
})
])
});
};
log("LOADED");

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

@ -1 +1 @@
Subproject commit cac27408440f5586c1c68d846456792041403d35
Subproject commit d1058f0b6ccf8cbebe4eed2afba145899e6dba00