Always update dialog option, login as fragment fixes, ongoing cursor crash fix

This commit is contained in:
Kelvin
2025-11-18 01:03:21 +01:00
parent fd835cc54e
commit 0ae6ac2fac
11 changed files with 386 additions and 20 deletions
@@ -66,6 +66,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.LibraryFilesFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibrarySearchFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryVideosFragment
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistFragment
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsFragment
@@ -201,6 +202,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragLibraryFiles: LibraryFilesFragment;
lateinit var _fragSettings: SettingsFragment;
lateinit var _fragDeveloper: DeveloperFragment;
lateinit var _fragLogin: LoginFragment;
lateinit var _fragBrowser: BrowserFragment;
@@ -395,6 +397,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragLibrarySearch = LibrarySearchFragment.newInstance();
_fragSettings = SettingsFragment.newInstance();
_fragDeveloper = DeveloperFragment.newInstance();
_fragLogin = LoginFragment.newInstance();
_fragBrowser = BrowserFragment.newInstance();
@@ -1199,6 +1202,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
inline fun <reified T : Fragment> navigate(parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
val segment = getFragment<T>();
navigate(segment as MainFragment, parameter, withHistory, isBack);
}
/**
* Navigate takes a MainFragment, and makes them the current main visible view
* A parameter can be provided which becomes available in the onShow of said fragment
@@ -1346,6 +1354,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
LibrarySearchFragment::class -> _fragLibrarySearch as T;
SettingsFragment:: class -> _fragSettings as T;
DeveloperFragment::class -> _fragDeveloper as T;
LoginFragment::class -> _fragLogin as T;
else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity");
}
}
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.Dictionary
@Serializable
@@ -27,7 +28,7 @@ class SourcePluginAuthConfig(
val details: String? = null,
val once: Boolean? = true
) {
@Contextual
@Transient
private var _regex: Regex? = null;
fun getRegex(): Regex {
@@ -23,7 +23,7 @@ class SourcePluginConfig(
//Script
val repositoryUrl: String? = null,
val scriptUrl: String = "",
val version: Int = -1,
var version: Int = -1,
val iconUrl: String? = null,
var id: String = UUID.randomUUID().toString(),
@@ -18,6 +18,7 @@ import com.futo.platformplayer.engine.dev.V8RemoteObject
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.gsonStandard
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.serialize
import com.futo.platformplayer.engine.packages.PackageHttp
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateAssets
@@ -268,11 +269,15 @@ class DeveloperEndpoints(private val context: Context) {
context.respondCode(403, "This plugin doesn't support auth");
return;
}
LoginFragment.showLogin(config){
_testPluginVariables.clear();
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
};
/*
LoginActivity.showLogin(StateApp.instance.context, config) {
_testPluginVariables.clear();
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
};
}; */
context.respondCode(200, "Login started");
}
catch(ex: Throwable) {
@@ -48,6 +48,7 @@ class PluginUpdateDialog : AlertDialog {
private lateinit var _buttonCancel1: Button;
private lateinit var _buttonCancel2: Button;
private lateinit var _buttonAlways: LinearLayout;
private lateinit var _buttonUpdate: LinearLayout;
private lateinit var _buttonOk: LinearLayout;
@@ -58,6 +59,7 @@ class PluginUpdateDialog : AlertDialog {
private lateinit var _textProgres: TextView;
private lateinit var _textError: TextView;
private lateinit var _textResult: TextView;
private lateinit var _textChangelogResult: TextView;
private lateinit var _uiChoiceTop: FrameLayout;
private lateinit var _uiProgressTop: FrameLayout;
@@ -89,6 +91,7 @@ class PluginUpdateDialog : AlertDialog {
_buttonCancel1 = findViewById(R.id.button_cancel_1);
_buttonCancel2 = findViewById(R.id.button_cancel_2);
_buttonAlways = findViewById(R.id.button_always);
_buttonUpdate = findViewById(R.id.button_update);
_buttonOk = findViewById(R.id.button_ok);
@@ -99,6 +102,7 @@ class PluginUpdateDialog : AlertDialog {
_textProgres = findViewById(R.id.text_progress);
_textError = findViewById(R.id.text_error);
_textResult = findViewById(R.id.text_result);
_textChangelogResult = findViewById(R.id.text_changelog_result);
_uiChoiceTop = findViewById(R.id.dialog_ui_choice_top);
_uiProgressTop = findViewById(R.id.dialog_ui_progress_top);
@@ -119,17 +123,24 @@ class PluginUpdateDialog : AlertDialog {
val changelog = _newConfig.changelog!![changelogVersion]!!;
if(changelog.size > 1) {
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog.map { " - " + it.trim() }.joinToString("\n");
_textChangelogResult.text = _textChangelog.text;
}
else if(changelog.size == 1) {
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog[0].trim();
_textChangelogResult.text = _textChangelog.text;
}
else
else {
_textChangelog.visibility = View.GONE;
} else
_textChangelog.visibility = View.GONE;
_textChangelogResult.visibility = View.GONE;
}
} else {
_textChangelog.visibility = View.GONE;
_textChangelogResult.visibility = View.GONE;
}
}
catch(ex: Throwable) {
_textChangelog.visibility = View.GONE;
_textChangelogResult.visibility = View.GONE;
Logger.e(TAG, "Invalid changelog? ", ex);
}
@@ -145,6 +156,18 @@ class PluginUpdateDialog : AlertDialog {
_isUpdating = true;
update();
};
_buttonAlways.setOnClickListener {
if (_isUpdating)
return@setOnClickListener;
val plugin = StatePlugins.instance.getPlugin(_oldConfig.id);
if(plugin != null) {
plugin.appSettings.automaticUpdate = true;
StatePlugins.instance.savePlugin(_oldConfig.id);
UIDialogs.appToast("Automatic update enabled, can be disabled in plugin settings");
}
_isUpdating = true;
update();
};
Glide.with(_iconPlugin)
.load(_oldConfig.absoluteIconUrl)
@@ -158,7 +181,8 @@ class PluginUpdateDialog : AlertDialog {
if (_isUpdating)
return;
_isUpdating = true;
update();
update(true);
}
}
}
@@ -167,7 +191,7 @@ class PluginUpdateDialog : AlertDialog {
super.dismiss();
}
private fun update() {
private fun update(automatic: Boolean = false) {
_uiChoiceTop.visibility = View.GONE;
_uiRiskTop.visibility = View.GONE;
_uiChoiceBot.visibility = View.GONE;
@@ -0,0 +1,88 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.provider.MediaStore
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.dp
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.Artist
import com.futo.platformplayer.states.ArtistOrdering
import com.futo.platformplayer.states.FileEntry
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.AnyInsertedAdapterView
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithViews
import com.futo.platformplayer.views.LibrarySection
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.InsertedViewAdapter
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.ArtistTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.LocalVideoTileViewHolder
import com.futo.platformplayer.views.buttons.BigButton
class BaseFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val newView = FragView(this);
view = newView;
return newView;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown();
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = BaseFragment().apply {}
}
class FragView: ConstraintLayout {
val fragment: BaseFragment;
constructor(fragment: BaseFragment) : super(fragment.requireContext()) {
inflate(context, R.layout.fragview_library, this);
this.fragment = fragment;
}
fun onShown() {
}
}
}
@@ -0,0 +1,160 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebView
import android.widget.ImageButton
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.LoginWebViewClient
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.text.matches
class LoginFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val newView = FragView(this);
view = newView;
return newView;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown(parameter ?: throw IllegalArgumentException("No parameter for login"));
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = LoginFragment().apply {}
private var _callback: ((SourceAuth?) -> Unit)? = null;
fun showLogin(config: SourcePluginConfig, callback: ((SourceAuth?) -> Unit)? = null) {
if(_callback != null) _callback?.invoke(null);
_callback = callback;
StateApp.instance.activity?.navigate<LoginFragment>(config, false);
}
}
class FragView: ConstraintLayout {
val fragment: LoginFragment;
private val _webView: WebView;
private val _textUrl: TextView;
private val _buttonClose: ImageButton;
constructor(fragment: LoginFragment) : super(fragment.requireContext()) {
inflate(context, R.layout.activity_login, this);
this.fragment = fragment;
_textUrl = findViewById(R.id.text_url);
_buttonClose = findViewById(R.id.button_close);
_buttonClose.setOnClickListener {
UIDialogs.toast("Login cancelled", false);
fragment.close(true);
}
_webView = findViewById(R.id.web_view);
_webView.settings.javaScriptEnabled = true;
CookieManager.getInstance().setAcceptCookie(true);
}
fun onShown(parameter: Any) {
val config = parameter as? SourcePluginConfig;
val authConfig = if(config != null)
config.authentication ?: throw IllegalStateException("Plugin has no authentication support");
else if(parameter is SourcePluginAuthConfig)
parameter
else throw IllegalStateException("No valid configuration?");
//TODO: Backwards compat removal?
_webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
_webView.settings.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = true;
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig);
webViewClient.onLogin.subscribe { auth ->
_callback?.let {
_callback = null;
it.invoke(auth);
}
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
fragment.close(true);
}catch (ex: Throwable) {
Logger.e(TAG, "Failed to close login", ex);
}
}
};
var isFirstLoad = true;
val loginWarnings = authConfig.loginWarnings?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.Warning>();
val uiMods = authConfig.uiMods?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.UIMod>();
var currentScale = 100;
var currentDesktop = false;
webViewClient.onPageLoaded.subscribe { view, url ->
_textUrl.setText(url ?: "");
if(loginWarnings.size > 0 && url != null) {
synchronized(loginWarnings) {
val warning = loginWarnings.find { url.matches(it.getRegex()) };
if(warning != null) {
if(warning.once == true)
loginWarnings.remove(warning);
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, warning.text ?: "", warning.details ?: "", null, 0,
UIDialogs.Action("Understood", {
}, UIDialogs.ActionStyle.PRIMARY));
}
}
}
if(!isFirstLoad)
return@subscribe;
isFirstLoad = false;
if(!authConfig.loginButton.isNullOrEmpty() && authConfig.loginButton.matches(REGEX_LOGIN_BUTTON)) {
Logger.i(TAG, "Clicking login button [${authConfig.loginButton}]");
//TODO: Find most reliable way to wait for page js to finish
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
}
}
_webView.settings.domStorageEnabled = true;
_webView.webViewClient = webViewClient;
_webView.loadUrl(authConfig.loginUrl);
}
companion object {
private val TAG = "LoginFragment";
private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#:_ ]*");
}
}
}
@@ -453,7 +453,7 @@ class SourceDetailFragment : MainFragment() {
}.apply {
this.alpha = 0.5f;
},*/
if(isEmbedded) BigButton(c, "Reinstall", "Modify the source of this plugin", R.drawable.ic_refresh) {
if(isEmbedded) BigButton(c, "Reinstall", "Reinstall the original version that was embedded with this version of Grayjay", R.drawable.ic_refresh) {
val embeddedConfig = StatePlugins.instance.getEmbeddedPluginConfigFromID(context, config.id);
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, "Are you sure you want to downgrade (${config.version}=>${embeddedConfig?.version})?",
@@ -467,7 +467,29 @@ class SourceDetailFragment : MainFragment() {
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
};
} else null
} else
BigButton(c, "Reinstall", "Reinstall the current version from the remote repository", R.drawable.ic_refresh) {
var newConfig: SourcePluginConfig? = null;
try {
newConfig = StatePlugins.instance.requestConfig(config?.sourceUrl ?: throw IllegalArgumentException("No config"));
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to fetch new plugin config", ex);
}
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, "Are you sure you want to downgrade (${config.version}=>)?",
"This will revert the plugin back to the originally embedded version.\nVersion change: ${config.version}=>${newConfig?.version}", null,
0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Reinstall", {
val url = config.sourceUrl ?: return@Action;
StatePlugins.instance.installPlugin(context, fragment.lifecycleScope, url) {
reloadSource(config.id);
UIDialogs.toast(context, "Plugin reinstalled, may require refresh");
}
}, UIDialogs.ActionStyle.DANGEROUS));
}.apply {
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
};
}
)
_sourceAdvancedButtons.removeAllViews();
@@ -486,7 +508,7 @@ class SourceDetailFragment : MainFragment() {
config.authentication.loginWarning, null, 0,
UIDialogs.Action("Cancel", {}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Login", {
LoginActivity.showLogin(StateApp.instance.context, config) {
LoginFragment.showLogin(config) {//LoginActivity.showLogin(StateApp.instance.context, config) {
try {
StatePlugins.instance.setPluginAuth(config.id, it);
reloadSource(config.id);
@@ -500,7 +522,7 @@ class SourceDetailFragment : MainFragment() {
}, UIDialogs.ActionStyle.PRIMARY))
}
else
LoginActivity.showLogin(StateApp.instance.context, config) {
LoginFragment.showLogin(config) {//LoginActivity.showLogin(StateApp.instance.context, config) {
try {
StatePlugins.instance.setPluginAuth(config.id, it);
reloadSource(config.id);
@@ -157,7 +157,9 @@ class StateLibrary {
query,
null,
MediaStore.Video.Media.DATE_ADDED + " DESC") ?: return EmptyPager();
return cursor.use {
//Ongoing usage of cursor..todo disposal
//return cursor.use {
cursor.moveToFirst();
val list = mutableListOf<IPlatformVideo>()
while(!cursor.isAfterLast && list.size < 10) {
@@ -165,7 +167,7 @@ class StateLibrary {
cursor.moveToNext();
}
return@use AdhocPager<IPlatformContent>({
return AdhocPager<IPlatformContent>({
val list = mutableListOf<IPlatformContent>()
while(!cursor.isAfterLast && list.size < 10) {
list.add(videoFromCursor(cursor));
@@ -173,7 +175,7 @@ class StateLibrary {
}
return@AdhocPager list;
}, list);
}
//}
}
fun getRecentVideos(buckets: List<String>? = null, count: Int = 20): List<IPlatformVideo> {
val videoPager = getVideos(buckets);
@@ -11,6 +11,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment.Companion
import com.futo.platformplayer.logging.Logger
@@ -167,7 +168,7 @@ class StatePlugins {
if(config.authentication == null)
return false;
LoginActivity.showLogin(context, config) {
LoginFragment.showLogin(config) {//LoginActivity.showLogin(context, config) {
try {
StatePlugins.instance.setPluginAuth(config.id, it);
} catch (e: Throwable) {
@@ -300,6 +301,7 @@ class StatePlugins {
StateAssets.readAssetBinRelative(context, assetConfigPath, config.iconUrl);
else null;
//config.version = config.version - 1;
createPlugin(config, script, icon, true);
return true;
}
@@ -317,6 +319,15 @@ class StatePlugins {
installPlugins(context, scope, sourceUrls.drop(1), handler);
}
}
fun requestConfig(sourceUrl: String): SourcePluginConfig {
val configResp = ManagedHttpClient().get(sourceUrl);
if(!configResp.isOk)
throw IllegalStateException("Failed request with ${configResp.code}");
val configJson = configResp.body?.string();
if(configJson.isNullOrEmpty())
throw IllegalStateException("No response");
return SourcePluginConfig.fromJson(configJson, sourceUrl);
}
fun installPlugin(context: Context, scope: CoroutineScope, sourceUrl: String, handler: ((Boolean) -> Unit)? = null) {
scope.launch(Dispatchers.IO) {
val client = ManagedHttpClient();
@@ -329,6 +340,7 @@ class StatePlugins {
if(configJson.isNullOrEmpty())
throw IllegalStateException("No response");
config = SourcePluginConfig.fromJson(configJson, sourceUrl);
//config.version = config.version - 1;
}
catch(ex: SerializationException) {
Logger.e(TAG, "Failed decode config", ex);
@@ -642,6 +654,9 @@ class StatePlugins {
val descriptor = getPlugin(id) ?: throw IllegalArgumentException("Plugin [${id}] does not exist");
descriptor.updateAuth(auth);
_plugins.save(descriptor);
if(auth != null)
UIDialogs.appToast("Plugin ${descriptor?.config?.name} logged in");
}
@Serializable
@@ -147,12 +147,32 @@
android:textColor="@color/colorPrimary"
android:background="@color/transparent" />
<LinearLayout
android:id="@+id/button_always"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_button_accent"
android:layout_marginEnd="10dp"
android:clickable="true">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Always"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="15dp"
android:paddingEnd="15dp"/>
</LinearLayout>
<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:layout_marginEnd="15dp"
android:clickable="true">
<TextView
@@ -164,8 +184,8 @@
android:fontFamily="@font/inter_regular"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="28dp"
android:paddingEnd="28dp"/>
android:paddingStart="20dp"
android:paddingEnd="20dp"/>
</LinearLayout>
</LinearLayout>
</LinearLayout>
@@ -295,6 +315,26 @@
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp" />
<TextView
android:id="@+id/text_changelog_result"
android:layout_width="match_parent"
android:layout_height="80dp"
android:textAlignment="textStart"
android:text="Changelog"
android:textSize="9.5sp"
android:textColor="@color/white"
android:fontFamily="monospace"
android:scrollbars="vertical"
android:background="@color/black"
android:paddingLeft="10dp"
android:paddingRight="10dp"
android:paddingTop="5dp"
android:paddingBottom="5dp"
android:layout_marginTop="10dp"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"