Compare commits

...

29 Commits

Author SHA1 Message Date
Koen J fa1954ceef Fixes. 2026-02-16 11:35:29 +01:00
Koen J 13aa49726a Improved WaitTillLoaded. 2026-02-15 14:46:03 +01:00
Koen J 20bab7d056 Updated submodules. 2026-02-15 11:34:25 +01:00
Koen J cbf7ca0181 Fixes to make it less detectable. 2026-02-15 11:26:10 +01:00
Koen J b7477080d2 Add scripts on load. 2026-02-13 14:05:37 +01:00
Koen J ac5bc27581 Package browser wip 2026-02-13 13:40:00 +01:00
Koen J 748551af2a Added support for injecting scripts on bootup. 2026-02-13 12:20:30 +01:00
Koen J 9ce41bc8d0 Fixed issue where media session was not properly restarted after reopening the app after closing pip. 2026-02-11 11:10:57 +01:00
Koen J 8cf542e201 Improved auto backup flow. 2026-02-10 15:15:32 +01:00
Koen J 88950843b3 Fixed artwork not updating when in audio only. 2026-02-10 15:08:43 +01:00
Koen J 4a08058322 Run import on IO. 2026-02-10 14:48:03 +01:00
Koen J 7b76ba1539 Fixed resume after non manual pause. 2026-02-10 13:24:21 +01:00
Koen J 6492278e7d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2026-02-10 12:16:04 +01:00
Koen J 9de9440160 Reworked automatic backups. 2026-02-10 12:15:34 +01:00
Koen 372af6cf47 Merge branch 'jf/futopay-2.0' into 'master'
Jf/futopay 2.0

See merge request videostreaming/grayjay!147
2026-02-09 16:02:55 +00:00
Justin Fowler 29d08c8554 Jf/futopay 2.0 2026-02-09 16:02:55 +00:00
Koen J cfeceabe5b Added italian to audio_languages. 2026-02-09 10:18:27 +01:00
koen-futo a51f609a92 Merge pull request #3028 from goodness-from-me-forks/goodness-from-me-patch-1
Add www.twitch.tv and m.twitch.tv to intent urls
2026-02-09 10:12:17 +01:00
Koen 15a655f196 Edit StateAnnouncement.kt 2026-02-07 09:45:35 +00:00
Kelvin c6525f1caa Clear cookies after login 2026-02-06 17:20:10 +01:00
Kelvin e147fdd77e Empty view for notifs, back on toggle off 2026-02-02 20:12:30 +01:00
Kelvin 6a8ac0bfaa Refs 2026-02-02 18:46:01 +01:00
Kelvin 772bff6bc0 Browser package fixes, advanced settings for plugin support 2026-02-02 18:41:51 +01:00
Kelvin b6b04054b9 Clear cookies on startup & after login 2026-01-31 21:20:31 +01:00
Kelvin 1ea794459c refs 2026-01-31 19:27:57 +01:00
Kelvin c27f5e4096 Cleanup fixes, v8 locking 2026-01-31 19:23:32 +01:00
Kelvin 8469f17b4c Fix threading for callbacks from browser 2026-01-31 13:15:09 +01:00
goodness-from-me 8e4ad54de1 Apply the same changes to unstable 2026-01-22 16:31:10 +00:00
goodness-from-me 6139696714 Add www.twitch.tv and m.twitch.tv to intent urls
Streams tend to post www.twitch.tv link in Telegram channels when stream is live. Also m.twitch.tv is a valid link too.
2026-01-22 16:29:42 +00:00
35 changed files with 1202 additions and 565 deletions
+1
View File
@@ -206,6 +206,7 @@ dependencies {
implementation 'com.google.zxing:core:3.5.3'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'androidx.webkit:webkit:1.15.0'
//Protobuf
implementation 'com.google.protobuf:protobuf-javalite:4.33.0'
@@ -6,6 +6,7 @@ import android.content.Intent
import android.net.Uri
import android.webkit.CookieManager
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.ManageTabsActivity
import com.futo.platformplayer.activities.PolycentricHomeActivity
@@ -400,10 +401,11 @@ class Settings : FragmentedStorageFileJson() {
8 -> "id";
9 -> "hi";
10 -> "ar";
11 -> "tu";
11 -> "tr";
12 -> "ru";
13 -> "pt";
14 -> "zh";
15 -> "it";
else -> null
}
}
@@ -796,6 +798,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
var checkDisabledPluginsForUpdates: Boolean = false;
@AdvancedField
@FormField(R.string.clear_cookies_after_login, FieldForm.TOGGLE, R.string.clear_cookies_after_login_desc, 0)
var clearCookiesAfterLogin: Boolean = true;
@AdvancedField
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
var clearCookiesOnLogout: Boolean = true;
@@ -805,6 +810,12 @@ class Settings : FragmentedStorageFileJson() {
val cookieManager: CookieManager = CookieManager.getInstance();
cookieManager.removeAllCookies(null);
}
fun shouldClearWebviewCookies(): Boolean {
return clearCookiesAfterLogin;
}
/*@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
fun reinstallEmbedded() {
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
@@ -952,18 +963,31 @@ class Settings : FragmentedStorageFileJson() {
class Backup {
@Serializable(with = OffsetDateTimeSerializer::class)
var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN;
var didAskAutoBackup: Boolean = true;
var didAskAutoBackup: Boolean = false;
var autoBackupEnabled: Boolean = false
var autoBackupPassword: String? = null;
fun shouldAutomaticBackup() = autoBackupPassword != null;
fun shouldAutomaticBackup() = autoBackupEnabled
@FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0)
val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day";
@FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
fun configureAutomaticBackup() {
UIDialogs.showAutomaticBackupDialog(StateApp.instance.activity!!, autoBackupPassword != null) {
SettingsFragment.currentView?.reloadSettings();
};
StateApp.instance.activity?.let { activity ->
if(!Settings.instance.storage.isStorageMainValid(activity)) {
UIDialogs.toast("Missing general directory")
StateApp.instance.changeExternalGeneralDirectory(activity) {
UIDialogs.showAutomaticBackupDialog(activity) {
SettingsFragment.currentView?.reloadSettings()
}
};
}
else {
UIDialogs.showAutomaticBackupDialog(activity) {
SettingsFragment.currentView?.reloadSettings()
}
}
}
}
@FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
fun restoreAutomaticBackup() {
@@ -1047,7 +1071,6 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
var polycentricLocalCache: Boolean = true;
var showPrivacyModeDialog: Boolean = true;
}
@@ -19,6 +19,7 @@ import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -165,27 +166,42 @@ class UIDialogs {
dialog.show()
}
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
val dialogAction: ()->Unit = {
val dialog = AutomaticBackupDialog(context);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog); onClosed?.invoke() };
dialog.show();
};
if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck)
UIDialogs.showDialog(context, R.drawable.ic_move_up, context.getString(R.string.an_old_backup_is_available), context.getString(R.string.would_you_like_to_restore_this_backup), null, 0,
UIDialogs.Action(context.getString(R.string.cancel), {}), //To nothing
UIDialogs.Action(context.getString(R.string.override), {
dialogAction();
fun showAutomaticBackupDialog(context: Context, onClosed: (() -> Unit)? = null) {
val dialogAction: () -> Unit = {
val dialog = AutomaticBackupDialog(context)
registerDialogOpened(dialog)
dialog.setOnDismissListener {
registerDialogClosed(dialog)
onClosed?.invoke()
}
dialog.show()
}
if (!Settings.instance.backup.autoBackupEnabled && StateBackup.hasAutomaticBackup()) {
UIDialogs.showDialog(
context,
R.drawable.ic_move_up,
context.getString(R.string.an_old_backup_is_available),
context.getString(R.string.would_you_like_to_restore_this_backup),
null,
0,
UIDialogs.Action(context.getString(R.string.cancel), {}),
UIDialogs.Action(context.getString(R.string.continue_anyway), {
dialogAction()
}, UIDialogs.ActionStyle.DANGEROUS),
UIDialogs.Action(context.getString(R.string.restore), {
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
val scope = (context as? androidx.lifecycle.LifecycleOwner)?.lifecycleScope
?: StateApp.instance.scopeOrNull
?: StateApp.instance.scope
UIDialogs.showAutomaticRestoreDialog(context, scope)
}, UIDialogs.ActionStyle.PRIMARY)
);
else {
dialogAction();
)
} else {
dialogAction()
}
}
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
val dialog = AutomaticRestoreDialog(context, scope);
registerDialogOpened(dialog);
@@ -235,7 +235,8 @@ class SourcePluginConfig(
val variable: String? = null,
val dependency: String? = null,
val warningDialog: String? = null,
val options: List<String>? = null
val options: List<String>? = null,
val isAdvanced: Boolean? = null
) {
val variableOrName: String get() = variable ?: name;
}
@@ -1,9 +1,13 @@
package com.futo.platformplayer.dialogs
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.*
@@ -17,82 +21,79 @@ import com.google.android.material.button.MaterialButton
class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
private lateinit var _buttonStart: LinearLayout;
private lateinit var _buttonStop: LinearLayout;
private lateinit var _buttonCancel: ImageButton;
private lateinit var _editPassword: EditText;
private lateinit var _editPassword2: EditText;
private lateinit var _inputMethodManager: InputMethodManager;
private lateinit var _buttonStart: LinearLayout
private lateinit var _buttonStop: LinearLayout
private lateinit var _buttonCancel: ImageButton
private lateinit var _imm: InputMethodManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null));
super.onCreate(savedInstanceState)
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null))
_buttonCancel = findViewById(R.id.button_cancel);
_buttonStop = findViewById(R.id.button_stop);
_buttonStart = findViewById(R.id.button_start);
_editPassword = findViewById(R.id.edit_password);
_editPassword2 = findViewById(R.id.edit_password2);
_buttonCancel = findViewById(R.id.button_cancel)
_buttonStop = findViewById(R.id.button_stop)
_buttonStart = findViewById(R.id.button_start)
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
_imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
_buttonStart.visibility = if (Settings.instance.backup.autoBackupEnabled) View.GONE else View.VISIBLE
_buttonStop.visibility = if (Settings.instance.backup.autoBackupEnabled) View.VISIBLE else View.GONE
_buttonCancel.setOnClickListener {
clearFocus();
dismiss();
};
_buttonStop.setOnClickListener {
clearFocus();
dismiss();
Settings.instance.backup.autoBackupPassword = null;
Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save();
dismiss()
}
UIDialogs.toast(context, "AutoBackup disabled");
_buttonStop.setOnClickListener {
dismiss()
Settings.instance.backup.autoBackupEnabled = false
Settings.instance.backup.autoBackupPassword = null
Settings.instance.backup.didAskAutoBackup = true
Settings.instance.save()
UIDialogs.toast(context, context.getString(R.string.automatic_backup_disabled))
}
_buttonStart.setOnClickListener {
val p1 = _editPassword.text.toString();
val p2 = _editPassword2.text.toString();
if(!(p1?.equals(p2) ?: false)) {
UIDialogs.toast(context, "Password fields do not match, confirm that you typed it correctly.");
return@setOnClickListener;
dismiss()
Logger.i(TAG, "Enable AutoBackup (unencrypted)")
val activity = StateApp.instance.activity as? Activity
if (activity == null) {
UIDialogs.toast(context, "No activity available")
return@setOnClickListener
}
val pbytes = _editPassword.text.toString().toByteArray();
if(pbytes.size < 4 || pbytes.size > 32) {
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and smaller than 32 bytes", false);
return@setOnClickListener;
}
clearFocus();
dismiss();
dismiss()
Logger.i(TAG, "Set AutoBackupPassword");
Settings.instance.backup.autoBackupPassword = _editPassword.text.toString();
Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save();
Logger.i(TAG, "Enable AutoBackup")
Settings.instance.backup.autoBackupPassword = null
Settings.instance.backup.didAskAutoBackup = true
Settings.instance.save()
UIDialogs.toast(context, "AutoBackup enabled");
UIDialogs.toast(context, "AutoBackup enabled")
try {
StateBackup.startAutomaticBackup(true);
StateBackup.startAutomaticBackup(true)
} catch (ex: Throwable) {
Logger.e(TAG, "Forced automatic backup failed", ex)
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message)
}
catch(ex: Throwable) {
Logger.e(TAG, "Forced automatic backup failed", ex);
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message);
Settings.instance.backup.autoBackupEnabled = true
Settings.instance.backup.autoBackupPassword = null
Settings.instance.backup.didAskAutoBackup = true
Settings.instance.save()
UIDialogs.toast(context, context.getString(R.string.automatic_backup_enabled))
try {
StateBackup.startAutomaticBackup(true)
} catch (ex: Throwable) {
Logger.e(TAG, "Forced automatic backup failed", ex)
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message)
}
};
}
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
}
private fun clearFocus() {
_editPassword.clearFocus();
currentFocus?.let { _inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) };
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
}
companion object {
private val TAG = "AutomaticBackupDialog";
private const val TAG = "AutomaticBackupDialog"
}
}
@@ -3,87 +3,155 @@ package com.futo.platformplayer.dialogs
import android.app.AlertDialog
import android.content.Context
import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.*
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.*
import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import userpackage.Protocol
import java.time.OffsetDateTime
import kotlinx.coroutines.withContext
class AutomaticRestoreDialog(context: Context, private val scope: CoroutineScope) : AlertDialog(context) {
class AutomaticRestoreDialog(context: Context, val scope: CoroutineScope) : AlertDialog(context) {
private lateinit var _buttonStart: LinearLayout;
private lateinit var _buttonCancel: MaterialButton;
private lateinit var _editPassword: EditText;
private lateinit var _inputMethodManager: InputMethodManager;
private lateinit var _buttonStart: LinearLayout
private lateinit var _buttonCancel: MaterialButton
private lateinit var _textReason: TextView
private lateinit var _editPassword: EditText
private lateinit var _passwordContainer: LinearLayout
private lateinit var _icon: ImageView
private lateinit var _progress: ProgressBar
private lateinit var _textStart: TextView
private lateinit var _imm: InputMethodManager
private var _needsPassword: Boolean = true
private var _detectJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup_restore, null));
super.onCreate(savedInstanceState)
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup_restore, null))
_buttonCancel = findViewById(R.id.button_cancel);
_buttonStart = findViewById(R.id.button_start);
_editPassword = findViewById(R.id.edit_password);
_buttonCancel = findViewById(R.id.button_cancel)
_buttonStart = findViewById(R.id.button_start)
_editPassword = findViewById(R.id.edit_password)
_textReason = findViewById(R.id.text_reason)
_passwordContainer = findViewById(R.id.password_container)
_icon = findViewById(R.id.image_icon)
_progress = findViewById(R.id.progress_restore)
_textStart = findViewById(R.id.text_start)
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
_imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
_needsPassword = true
applyMode(needsPassword = true)
setBusy(true, labelRes = R.string.checking_backup, lockCancel = false)
_buttonCancel.setOnClickListener {
clearFocus();
dismiss();
};
clearFocus()
dismiss()
}
_buttonStart.setOnClickListener { onStartClicked() }
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
}
_buttonStart.setOnClickListener {
val pbytes = _editPassword.text.toString().toByteArray();
if(pbytes.size < 4 || pbytes.size > 32) {
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and less than 32 bytes", false);
return@setOnClickListener;
override fun onStart() {
super.onStart()
_detectJob?.cancel()
_detectJob = scope.launch(Dispatchers.Main) {
val needs = try {
StateBackup.requiresPasswordForAutomaticBackup(context)
} catch (_: Throwable) {
true
}
clearFocus();
if (!isShowing) return@launch
_needsPassword = needs
applyMode(needsPassword = needs)
setBusy(false)
}
}
override fun onStop() {
_detectJob?.cancel()
_detectJob = null
super.onStop()
}
private fun applyMode(needsPassword: Boolean) {
_textStart.setText(R.string.restore)
if (needsPassword) {
_icon.setImageResource(R.drawable.ic_lock)
_passwordContainer.visibility = View.VISIBLE
_editPassword.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
_textReason.setText(R.string.it_appears_an_automatic_backup_exists_on_your_device_if_you_would_like_to_restore_enter_your_backup_password)
} else {
_icon.setImageResource(R.drawable.ic_move_up)
_passwordContainer.visibility = View.GONE
_editPassword.setText("")
_textReason.setText(R.string.automatic_backup_found_no_password)
}
}
private fun onStartClicked() {
val password = _editPassword.text?.toString() ?: ""
if (_needsPassword) {
val pbytes = password.toByteArray()
if (pbytes.size < 4 || pbytes.size > 32) {
_editPassword.error = context.getString(R.string.backup_password_length_error)
_editPassword.requestFocus()
return
}
}
clearFocus()
setBusy(true, labelRes = R.string.restoring, lockCancel = true)
scope.launch(Dispatchers.IO) {
try {
StateBackup.restoreAutomaticBackup(context, scope, _editPassword.text.toString(), true);
dismiss();
StateBackup.restoreAutomaticBackup(context, scope, if (_needsPassword) password else "", true)
withContext(Dispatchers.Main) {
if (isShowing) dismiss()
}
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to restore automatic backup", ex)
withContext(Dispatchers.Main) {
if (!isShowing) return@withContext
setBusy(false)
UIDialogs.showGeneralErrorDialog(context, "Restore failed", ex)
}
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to restore automatic backup", ex);
//UIDialogs.toast(context, "Restore failed due to:\n" + ex.message);
UIDialogs.showGeneralErrorDialog(context, "Restore failed", ex);
}
};
}
}
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
private fun setBusy(busy: Boolean, labelRes: Int = R.string.restore, lockCancel: Boolean = busy) {
_progress.visibility = if (busy) View.VISIBLE else View.GONE
_buttonCancel.isEnabled = !lockCancel
_buttonStart.isEnabled = !busy
_editPassword.isEnabled = !busy && _needsPassword
_buttonStart.alpha = if (busy) 0.6f else 1.0f
_textStart.setText(labelRes)
}
private fun clearFocus() {
_editPassword.clearFocus();
currentFocus?.let { _inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) };
_editPassword.clearFocus()
currentFocus?.let { _imm.hideSoftInputFromWindow(it.windowToken, 0) }
}
companion object {
private val TAG = "AutomaticRestoreDialog";
private const val TAG = "AutomaticRestoreDialog"
}
}
}
@@ -1,14 +1,24 @@
package com.futo.platformplayer.engine.packages
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.net.Uri
import android.os.Looper
import android.util.Log
import android.webkit.ConsoleMessage
import android.webkit.CookieManager
import android.webkit.JavascriptInterface
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.collection.emptyLongSet
import androidx.webkit.ScriptHandler
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.caoccao.javet.annotations.V8Function
import com.caoccao.javet.utils.JavetResourceUtils
import com.caoccao.javet.values.reference.V8ValueFunction
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -22,19 +32,40 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
import java.nio.charset.Charset
import androidx.core.net.toUri
class PackageBrowser: V8Package {
val useAddDocumentStartJavaScript = true
override val name: String get() = "Browser";
override val variableName: String = "browser";
@Volatile private var _loadToken: String? = null
@Volatile private var _expectedMainUrl: String? = null
private val _json = Json { };
@Transient
private val _pageLoadScriptRefs = ConcurrentHashMap<String, ScriptHandler>()
@Transient
private val _pageLoadScriptsFallback = ConcurrentHashMap<String, String>()
@Transient
private var _readySemaphore: Semaphore? = null;
@Transient
private val _callbacks = mutableMapOf<String, (String?)->Unit>();
@Transient
private val _interop = JSInterop(this);
@Transient
private var _browser: WebView? = null;
private val browser: WebView get() {
if(_browser == null)
@@ -42,54 +73,187 @@ class PackageBrowser: V8Package {
return _browser!!;
}
@Volatile
private var _userAgent: String = ""
private val http = OkHttpClient.Builder()
.followRedirects(false)
.followSslRedirects(false)
.build()
constructor(v8Plugin: V8Plugin): super(v8Plugin) {
}
@V8Function
fun initialize() {
if(_browser == null){
StateApp.instance.scope.launch(Dispatchers.Main) {
_browser = WebView(StateApp.instance.contextOrNull ?: return@launch);
_browser?.settings?.javaScriptEnabled = true;
_browser?.settings?.blockNetworkImage = false;
_browser?.settings?.blockNetworkLoads = false;
_browser?.settings?.allowContentAccess = false;
_browser?.settings?.allowFileAccess = false;
//_browser?.settings?.useWideViewPort = true;
//_browser?.settings?.loadWithOverviewMode = true;
_browser?.webViewClient = object : WebViewClient() {
override fun onPageCommitVisible(view: WebView?, url: String?) {
super.onPageCommitVisible(view, url)
_readySemaphore?.release();
_readySemaphore = null;
Logger.i("PackageBrowser", "Browser loaded");
if (_browser != null) return
onMainBlocking {
_browser = WebView(StateApp.instance.contextOrNull ?: return@onMainBlocking);
_userAgent = _browser?.settings?.userAgentString.orEmpty()
_browser?.settings?.javaScriptEnabled = true;
_browser?.settings?.blockNetworkImage = false;
_browser?.settings?.blockNetworkLoads = false;
_browser?.settings?.allowContentAccess = false;
_browser?.settings?.allowFileAccess = false;
//_browser?.settings?.useWideViewPort = true;
//_browser?.settings?.loadWithOverviewMode = true;
_browser?.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
if (view == null || request == null) return null
if (useAddDocumentStartJavaScript && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return null
if (!request.isForMainFrame) return null
if (!request.method.equals("GET", ignoreCase = true)) return null
val url = request.url?.toString() ?: return null
Log.i("PackageBrowser", "shouldInterceptRequest: " + url)
val scheme = request.url?.scheme ?: return null
if (scheme != "http" && scheme != "https") return null
val scripts = _pageLoadScriptsFallback.values.toList()
if (scripts.isEmpty()) return null
return try {
val cookie = request.requestHeaders["Cookie"] ?: runCatching { CookieManager.getInstance().getCookie(url) }.getOrNull()
val ua = request.requestHeaders["User-Agent"] ?: _userAgent
val okReq = Request.Builder()
.url(url)
.get()
.header("User-Agent", ua)
.apply { if (!cookie.isNullOrEmpty()) header("Cookie", cookie) }
.build()
http.newCall(okReq).execute().use { resp ->
val code = resp.code
val reason = resp.message.ifBlank { "OK" }
if (code in 300..399) return null
val contentType = resp.header("Content-Type") ?: ""
val isHtml =
contentType.startsWith("text/html", ignoreCase = true) ||
contentType.startsWith("application/xhtml+xml", ignoreCase = true)
if (!isHtml) return null
val bodyBytes = resp.body.bytes()
val charset = charsetFromContentType(contentType) ?: Charsets.UTF_8
val html = bodyBytes.toString(charset)
val cspHeader = resp.header("Content-Security-Policy")
?: resp.header("Content-Security-Policy-Report-Only")
val nonce = extractNonceFromCsp(cspHeader) ?: extractNonceFromHtml(html)
val injected = injectIntoHead(html, scripts.joinToString("\n"), nonce)
val outBytes = injected.toByteArray(charset)
val headers = resp.headers.toMultimap()
.mapValues { it.value.joinToString(",") }
.toMutableMap()
headers.remove("Content-Length")
val cookieMgr = CookieManager.getInstance()
resp.headers.values("Set-Cookie").forEach { sc ->
try { cookieMgr.setCookie(url, sc) } catch (_: Throwable) {}
}
try { cookieMgr.flush() } catch (_: Throwable) {}
WebResourceResponse("text/html", charset.name(), code, reason, headers, ByteArrayInputStream(outBytes))
}
} catch (_: Throwable) {
null
}
}
_browser?.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
if(consoleMessage?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
val msg = "Browser Error:${consoleMessage?.message()} [${consoleMessage?.lineNumber()}]" ?: ""
Logger.e("PackageBrowser", msg);
if(_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
StateDeveloper.instance.logDevException(StateDeveloper.instance.currentDevID ?: "", msg)
}
else {
val msg = "Browser Log:" + consoleMessage?.message() ?: "";
Logger.e("PackageBrowser", msg);
if(_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", msg);
}
return super.onConsoleMessage(consoleMessage)
}
override fun onPageCommitVisible(view: WebView?, url: String?) {
super.onPageCommitVisible(view, url)
Logger.i("PackageBrowser", "Browser loaded (commit visible): $url")
releaseReadyIfCurrent(url)
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
Logger.i("PackageBrowser", "Browser loaded (finished): $url")
releaseReadyIfCurrent(url)
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
return false;
}
}
_browser?.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
val raw = consoleMessage?.message().orEmpty()
val normalized = raw.trim().let { s ->
if (s.length >= 2 && s.first() == '"' && s.last() == '"') {
s.substring(1, s.length - 1)
} else s
}
if (normalized.startsWith(CONSOLE_BRIDGE_PREFIX)) {
val payload = normalized.substring(CONSOLE_BRIDGE_PREFIX.length)
if (handleConsoleBridgeMessage(payload)) return true
}
if (consoleMessage?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
val emsg = "Browser Error:${consoleMessage.message()} [${consoleMessage.lineNumber()}]"
Logger.e("PackageBrowser", emsg)
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
StateDeveloper.instance.logDevException(StateDeveloper.instance.currentDevID ?: "", emsg)
} else {
val imsg = "Browser Log:${consoleMessage?.message()}"
Logger.i("PackageBrowser", imsg)
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", imsg)
}
return super.onConsoleMessage(consoleMessage)
}
_browser?.addJavascriptInterface(_interop, "__GJ");
}
return;
}
val bootstrap = """
(() => {
try {
if (window.__GJ) return;
const PREFIX = ${CONSOLE_BRIDGE_PREFIX.quoteForJs()};
const emit = (obj) => {
try {
console.info(PREFIX + JSON.stringify(obj));
} catch (_) {}
};
Object.defineProperty(window, "__GJ", {
value: {
callback: (id, result) => {
try {
const r = (typeof result === "string")
? result
: (() => { try { return JSON.stringify(result); } catch (_) { return String(result); } })();
emit({ t: "cb", id: String(id), result: r });
} catch (_) {}
},
log: (msg) => {
try { emit({ t: "log", msg: String(msg) }); } catch (_) {}
}
},
enumerable: false,
configurable: false,
writable: false
});
} catch (_) {}
})();
""".trimIndent()
addScriptOnLoad(bootstrap)
}
@V8Function
fun deinitialize() {
_browser?.destroy();
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
_browser?.destroy();
}
_browser = null;
}
@@ -126,13 +290,28 @@ class PackageBrowser: V8Package {
@V8Function
fun load(url: String) {
Logger.i("PackageBrowser", "Browser loading url [${url}]");
_readySemaphore = Semaphore(1, 1);
Logger.i("PackageBrowser", "Browser loading url [$url]")
val token = UUID.randomUUID().toString()
_loadToken = token
_expectedMainUrl = url
_readySemaphore = Semaphore(1, acquiredPermits = 1)
StateApp.instance.scope.launch(Dispatchers.Main) {
browser.loadUrl(url);
try { browser.loadUrl(url) }
catch (t: Throwable) { Logger.e("PackageBrowser", "loadUrl failed", t) }
}
}
private fun releaseReadyIfCurrent(url: String?) {
if (url == null) return
val expected = _expectedMainUrl
if (url.trimEnd('/') != expected?.trimEnd('/')) return
_readySemaphore?.release()
_readySemaphore = null
_expectedMainUrl = null
}
@V8Function
fun run(js: String, callbackId: String? = null, callback: V8ValueFunction? = null) {
waitTillLoaded();
@@ -140,18 +319,27 @@ class PackageBrowser: V8Package {
if(callbackId != null && callback != null) {
synchronized(_callbacks) {
_callbacks.put(callbackId, {
funcClone?.callVoid(null, arrayOf(it));
_plugin.busy {
funcClone?.callVoid(null, arrayOf(it));
}
if (!_plugin.isStopped)
JavetResourceUtils.safeClose(funcClone);
});
}
}
StateApp.instance.scope.launch(Dispatchers.Main) {
try {
Logger.i("PackageBrowser", "Browser running JS with callback [${callbackId}]\n${(if(js.length > 200) (js.substring(0, 200) + "...") else js)})");
browser.evaluateJavascript(js, object : ValueCallback<String> {
override fun onReceiveValue(value: String?) {
Logger.i("PackageBrowser", "Browser run finished");
}
})
try {
Logger.i("PackageBrowser", "Browser running JS with callback [${callbackId}]\n${(if(js.length > 200) (js.substring(0, 200) + "...") else js)})");
browser.evaluateJavascript(js, object : ValueCallback<String> {
override fun onReceiveValue(value: String?) {
Logger.i("PackageBrowser", "Browser run finished");
}
})
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Browser running failed: " + ex.message, ex);
}
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Failed to invoke browser", ex);
@@ -168,30 +356,182 @@ class PackageBrowser: V8Package {
browser.evaluateJavascript(js, object : ValueCallback<String> {
override fun onReceiveValue(value: String?) {
Logger.i("PackageBrowser", "Browser run returned: " + (value ?: ""));
funcClone?.callVoid(null, arrayOf(value));
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
Logger.i("PackageBrowser", "Invoking V8 with result (${funcClone != null})");
try {
_plugin.busy {
if (value != null) {
val json = _json.decodeFromString<String>(value);
funcClone?.callVoid(null, arrayOf(json));
} else
funcClone?.callVoid(null, arrayOf((null as String?)));
}
if (!_plugin.isStopped)
JavetResourceUtils.safeClose(funcClone);
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Browser Failed to callback: " + ex.message, ex);
}
}
}
})
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Failed to invoke browser", ex);
Logger.e("PackageBrowser", "Browser Failed to invoke browser", ex);
}
}
}
class JSInterop(private val pack: PackageBrowser) {
@V8Function
fun addScriptOnLoad(js: String): String {
require(js.isNotBlank()) { "Script must be non-empty." }
@JavascriptInterface
fun callback(id: String, result: String) {
Logger.i("PackageBrowser", "Browser Callback [${id}]: ${result}");
val callback = synchronized(pack._callbacks) { pack._callbacks.remove(id); };
if(callback != null)
callback.invoke(result);
val id = UUID.randomUUID().toString()
onMainBlocking {
if (useAddDocumentStartJavaScript && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
val ref = WebViewCompat.addDocumentStartJavaScript(browser, js, setOf("*"))
_pageLoadScriptRefs[id] = ref
} else {
_pageLoadScriptsFallback[id] = js
}
}
@JavascriptInterface
fun log(msg: String) {
Logger.i("PackageBrowser", "Log: " + msg);
Logger.i("PackageBrowser", "addScriptOnLoad() registered (id=$id)")
return id
}
@SuppressLint("RequiresFeature")
@V8Function
fun removeScriptOnLoad(identifier: String): Boolean {
if (identifier.isBlank()) return false
val ref = _pageLoadScriptRefs.remove(identifier)
val removedFallback = _pageLoadScriptsFallback.remove(identifier) != null
if (ref != null) {
onMainBlocking {
try { ref.remove() } catch (_: Throwable) {}
}
Logger.i("PackageBrowser", "removeScriptOnLoad() removed (id=$identifier)")
return true
}
if (removedFallback) {
Logger.i("PackageBrowser", "removeScriptOnLoad() removed fallback (id=$identifier)")
return true
}
return false
}
@SuppressLint("RequiresFeature")
@V8Function
fun clearScriptsOnLoad() {
val refs = _pageLoadScriptRefs.values.toList()
_pageLoadScriptRefs.clear()
_pageLoadScriptsFallback.clear()
onMainBlocking {
for (r in refs) {
try { r.remove() } catch (_: Throwable) {}
}
}
Logger.i("PackageBrowser", "clearScriptsOnLoad() cleared")
}
private fun charsetFromContentType(ct: String): Charset? {
val m = Regex("(?i)charset=([\\w\\-]+)").find(ct) ?: return null
val name = m.groupValues.getOrNull(1)?.trim().orEmpty()
return runCatching { Charset.forName(name) }.getOrNull()
}
private fun injectIntoHead(html: String, js: String, nonce: String?): String {
val nonceAttr = nonce?.let { " nonce=\"${escapeHtmlAttr(it)}\"" } ?: ""
val tag = "<script$nonceAttr>\n$js\n</script>\n"
val head = Regex("(?i)<head[^>]*>").find(html)
if (head != null) {
val i = head.range.last + 1
return buildString(html.length + tag.length + 8) {
append(html, 0, i)
append('\n')
append(tag)
append(html, i, html.length)
}
}
return tag + html
}
private fun <T> onMainBlocking(block: () -> T): T {
return if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else runBlocking {
withContext(Dispatchers.Main) { block() }
}
}
private fun extractNonceFromCsp(csp: String?): String? {
if (csp.isNullOrBlank()) return null
val m = Regex("(?i)'nonce-([^'\\s;]+)'").find(csp) ?: return null
return m.groupValues[1]
}
private fun extractNonceFromHtml(html: String): String? {
val m = Regex("(?i)<script[^>]*\\snonce\\s*=\\s*['\"]([^'\"]+)['\"][^>]*>").find(html)
return m?.groupValues?.get(1)
}
private fun escapeHtmlAttr(s: String): String =
s.replace("&", "&amp;").replace("\"", "&quot;")
@Serializable
private data class ConsoleBridgeMsg(
val t: String,
val id: String? = null,
val result: String? = null,
val msg: String? = null
)
private fun handleConsoleBridgeMessage(payload: String): Boolean {
Logger.i("PackageBrowser", "handleConsoleBridgeMessage: " + payload)
val parsed = runCatching { _json.decodeFromString<ConsoleBridgeMsg>(payload) }.getOrNull()
?: return false
when (parsed.t) {
"cb" -> {
val id = parsed.id ?: return true
val res = parsed.result
val cb = synchronized(_callbacks) { _callbacks.remove(id) } ?: return true
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { cb.invoke(res) }
return true
}
"log" -> {
val text = parsed.msg.orEmpty()
Logger.i("PackageBrowser", "Browser Log: $text")
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID) {
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", text)
}
return true
}
else -> return true
}
}
private companion object {
private const val CONSOLE_BRIDGE_PREFIX = "__GJ__:"
private fun String.quoteForJs(): String {
val s = this
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
return "\"$s\""
}
}
}
@@ -1,8 +1,6 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -16,6 +14,8 @@ import com.futo.futopay.formatMoney
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StatePayment
import com.futo.platformplayer.views.overlays.LoaderOverlay
@@ -68,8 +68,11 @@ class BuyFragment : MainFragment() {
_paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, _, exception ->
if(success) {
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0, UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY));
_fragment.close(true);
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0,
UIDialogs.Action("Ok", {
(fragment.activity as? MainActivity)?.navigate<SettingsFragment>(withHistory = false);
}, UIDialogs.ActionStyle.PRIMARY)
);
}
else {
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.payment_failed), exception);
@@ -89,16 +92,19 @@ class BuyFragment : MainFragment() {
fragment.lifecycleScope.launch(Dispatchers.IO) {
//Calling this function will cache first call
try {
val currencies = StatePayment.instance.getAvailableCurrencies("grayjay");
val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay");
val country = StatePayment.instance.getPaymentCountryFromIP(true)?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } };
// TODO: Restore multi-currency support when payment backend supports it
// val currencies = StatePayment.instance.getAvailableCurrencies("grayjay");
// val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay");
// val country = StatePayment.instance.getPaymentCountryFromIP(true)?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
// val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } };
// if(currency != null && prices.containsKey(currency.id)) {
// val price = prices[currency.id]!!;
// _buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax);
// }
if(currency != null && prices.containsKey(currency.id)) {
val price = prices[currency.id]!!;
withContext(Dispatchers.Main) {
_buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax);
}
val priceCents = StatePayment.instance.getPolarProductPrice(PaymentConfigurations.PolarConfig.PRODUCT_SLUG)
withContext(Dispatchers.Main) {
_buttonBuyText.text = formatMoney("US", "usd", priceCents) + context.getString(R.string.plus_tax)
}
}
catch(ex: Throwable) {
@@ -165,4 +171,4 @@ class BuyFragment : MainFragment() {
fun newInstance() = BuyFragment().apply {}
private val TAG = "BuyFragment"
}
}
}
@@ -309,13 +309,14 @@ class SourceDetailFragment : MainFragment() {
BigButton(c, context.getString(R.string.logout), context.getString(R.string.sign_out_of_the_platform), R.drawable.ic_logout) {
logoutSource();
},
if(!Settings.instance.plugins.shouldClearWebviewCookies())
BigButton(c, "Logout without Clear", "Logout but keep the browser cookies.\nThis allows for quick re-logging.", R.drawable.ic_logout) {
logoutSource(false);
}.apply {
this.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
};
}
} else null
)
);
@@ -518,6 +519,17 @@ class SourceDetailFragment : MainFragment() {
}
Logger.e(TAG, "Failed to set plugin authentication (loginSource, loginWarning)", e)
}
finally {
if(Settings.instance.plugins.shouldClearWebviewCookies()) {
try {
val cookieManager: CookieManager =
CookieManager.getInstance();
cookieManager.removeAllCookies(null);
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to clear cookies", ex);
}
}
}
};
}, UIDialogs.ActionStyle.PRIMARY))
}
@@ -216,6 +216,7 @@ class VideoDetailView : ConstraintLayout {
private val _playerProgress: PlayerControlView;
private val _timeBar: TimeBar;
private var _upNext: UpNextView;
private var _artworkTarget: CustomTarget<Bitmap>? = null
private val rootView: ConstraintLayout;
@@ -882,6 +883,9 @@ class VideoDetailView : ConstraintLayout {
};
onClose.subscribe {
_artworkTarget?.let { Glide.with(context).clear(it) }
_artworkTarget = null
_player.setArtwork(null)
checkAndRemoveWatchLater();
_lastVideoSource = null;
_lastAudioSource = null;
@@ -1195,6 +1199,7 @@ class VideoDetailView : ConstraintLayout {
else if(_didStop) {
_didStop = false;
Logger.i(TAG, "loadCurrentVideo _lastPosition=${lastPositionMilliseconds}");
StatePlayer.instance.startOrUpdateMediaSession(context, video);
loadCurrentVideo(lastPositionMilliseconds);
handlePause();
}
@@ -1264,6 +1269,9 @@ class VideoDetailView : ConstraintLayout {
fun onDestroy() {
Logger.i(TAG, "onDestroy");
_destroyed = true;
_artworkTarget?.let { Glide.with(context).clear(it) }
_artworkTarget = null
_player.setArtwork(null)
_taskLoadVideo.cancel();
_commentsList.cancel();
_player.clear();
@@ -2053,19 +2061,31 @@ class VideoDetailView : ConstraintLayout {
_player.switchToVideoMode()
isAudioOnlyUserAction = false;
} else {
val thumbnail = video.thumbnails.getHQThumbnail();
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
.into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
_player.setArtwork(BitmapDrawable(resources, resource));
}
override fun onLoadCleared(placeholder: Drawable?) {
_player.setArtwork(null);
}
});
else
_player.setArtwork(null);
_artworkTarget?.let { Glide.with(context).clear(it) }
_artworkTarget = null
val thumbnail = video.thumbnails.getHQThumbnail()
val showArtwork = _player.isAudioMode || isAudioOnlyUserAction || (videoSource == null)
if (showArtwork && !thumbnail.isNullOrBlank()) {
val target = object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
_player.setArtwork(BitmapDrawable(resources, resource))
}
override fun onLoadCleared(placeholder: Drawable?) {
_player.setArtwork(null)
}
}
_artworkTarget = target
Glide.with(context)
.asBitmap()
.load(thumbnail)
.withMaxSizePx()
.into(target)
} else {
_player.setArtwork(null)
}
}
fragment.lifecycleScope.launch(Dispatchers.Main) {
@@ -90,7 +90,10 @@ class GeneralTopBarFragment : TopFragment() {
updateNotifCount();
_buttonNotifs?.setOnClickListener {
navigate<NotificationOverlayView.Frag>();
if(currentMain is NotificationOverlayView.Frag)
closeSegment();
else
navigate<NotificationOverlayView.Frag>();
}
buttonSearch.setOnClickListener {
@@ -456,26 +456,19 @@ class MediaPlaybackService : Service() {
val audioFocusLossDuration = _audioFocusLossTime_ms?.let { System.currentTimeMillis() - it }
_audioFocusLossTime_ms = null
Log.i(TAG, "Audio focus gained (restartPlaybackAfterLoss = ${Settings.instance.playback.restartPlaybackAfterLoss}, _audioFocusLossTime_ms = $_audioFocusLossTime_ms, audioFocusLossDuration = ${audioFocusLossDuration})");
if (Settings.instance.playback.restartPlaybackAfterLoss == 1) {
if (audioFocusLossDuration != null && audioFocusLossDuration < 1000 * 10) {
MediaControlReceiver.onPlayReceived.emit()
}
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 2) {
if (audioFocusLossDuration != null && audioFocusLossDuration < 1000 * 30) {
MediaControlReceiver.onPlayReceived.emit()
}
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 3) {
MediaControlReceiver.onPlayReceived.emit()
if (audioFocusLossDuration == null) return@OnAudioFocusChangeListener
when (Settings.instance.playback.restartPlaybackAfterLoss) {
1 -> if (audioFocusLossDuration < 10_000) MediaControlReceiver.onPlayReceived.emit()
2 -> if (audioFocusLossDuration < 30_000) MediaControlReceiver.onPlayReceived.emit()
3 -> MediaControlReceiver.onPlayReceived.emit()
}
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
_audioFocusLossTime_ms = if (isPlaying) {
System.currentTimeMillis()
} else {
null
}
val wasPlaying = isPlaying
_audioFocusLossTime_ms = if (wasPlaying) System.currentTimeMillis() else null
_hasFocus = false;
_isTransientLoss = true;
@@ -488,11 +481,8 @@ class MediaPlaybackService : Service() {
_isTransientLoss = true;
}
AudioManager.AUDIOFOCUS_LOSS -> {
_audioFocusLossTime_ms = if (isPlaying) {
System.currentTimeMillis()
} else {
null
}
val wasPlaying = isPlaying
_audioFocusLossTime_ms = if (wasPlaying) System.currentTimeMillis() else null
MediaControlReceiver.onPauseReceived.emit();
abandonAudioFocus();
@@ -137,7 +137,7 @@ class StateAnnouncement {
registerAnnouncementSession(SessionAnnouncement(
"updated-plugin-" + UUID.randomUUID().toString(),
"${newConfig.name} updated to v${newConfig.version}!",
"You have succesfully been updater to v${newConfig.version}.",
"You have succesfully been updated to v${newConfig.version}.",
AnnouncementType.SESSION,
null, "updates", null, null,
null, null,null,
@@ -13,9 +13,12 @@ import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.DocumentsContract
import android.util.DisplayMetrics
import android.util.Log
import android.webkit.CookieManager
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
@@ -307,49 +310,45 @@ class StateApp {
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit) {
return requestDirectoryAccess(activity, name, purpose, path, handle, false);
}
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit, skipDialog: Boolean = false)
{
if(activity is Context)
{
if(skipDialog) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
if(path != null)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?) -> Unit, skipDialog: Boolean = false) {
if (Looper.myLooper() != Looper.getMainLooper()) {
Handler(Looper.getMainLooper()).post {
requestDirectoryAccess(activity, name, purpose, path, handle, skipDialog)
}
return
}
if (activity is Context) {
if (skipDialog) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (path != null) intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path)
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
activity.launchForResult(intent, 99) {
if(it.resultCode == Activity.RESULT_OK) {
handle(it.data?.data);
}
else
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
};
}
else {
if (it.resultCode == Activity.RESULT_OK) handle(it.data?.data)
else UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted")
}
} else {
UIDialogs.showDialog(activity, R.drawable.ic_security, "Directory required for\n${name}", "Please select a directory for ${name}.\n${purpose}".trim(), null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Ok", {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
if(path != null)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (path != null) intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path)
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
activity.launchForResult(intent, 99) {
if(it.resultCode == Activity.RESULT_OK) {
handle(it.data?.data);
}
else
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
};
}, UIDialogs.ActionStyle.PRIMARY));
if (it.resultCode == Activity.RESULT_OK) handle(it.data?.data)
else UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted")
}
}, UIDialogs.ActionStyle.PRIMARY)
)
}
}
}
@@ -448,6 +447,17 @@ class StateApp {
_cacheDirectory?.let { ApiMethods.initCache(it) };
}
if(Settings.instance.plugins.shouldClearWebviewCookies()) {
try {
Logger.i(TAG, "Clearing cookies on startup");
val cookieManager: CookieManager =
CookieManager.getInstance();
cookieManager.removeAllCookies(null);
} catch (ex: Throwable) {
Logger.e(SourceDetailFragment.Companion.TAG, "Failed to clear cookies", ex);
}
}
Logger.i(TAG, "MainApp Starting: Initializing [ModerationsManager]");
ModerationsManager.initialize(context);
@@ -660,9 +670,7 @@ class StateApp {
scheduleBackgroundWork(context, interval != 0, interval);
Logger.i(TAG, "MainApp Started: Initialize [AutoBackup]");
Settings.instance.backup.didAskAutoBackup = true; //Some users have issues with it
if(!Settings.instance.backup.didAskAutoBackup && !Settings.instance.backup.shouldAutomaticBackup()) {
/*
StateAnnouncement.instance.registerAnnouncement("backup", "Set Automatic Backup", "Configure daily backups of your data to restore in case of catastrophic failure.", AnnouncementType.SESSION, null, null, "Configure", {
if(context is IWithResultLauncher && !Settings.instance.storage.isStorageMainValid(context)) {
UIDialogs.toast("Missing general directory");
@@ -679,7 +687,6 @@ class StateApp {
Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save();
});
*/
}
else if(Settings.instance.backup.didAskAutoBackup && Settings.instance.backup.shouldAutomaticBackup() && !Settings.instance.storage.isStorageMainValid(context)) {
if(context is IWithResultLauncher) {
@@ -49,6 +49,33 @@ class StateBackup {
private val _autoBackupLock = Object();
private val AUTO_MAGIC = byteArrayOf(
0x11.toByte(), 0x22.toByte(), 0x33.toByte(), 0x44.toByte()
)
private val AUTO_MARKER = byteArrayOf(
'G'.code.toByte(), 'J'.code.toByte()
)
private const val AUTO_FORMAT_VERSION: Byte = 1
private const val FLAG_ENCRYPTED: Byte = 0x01
private fun ByteArray.startsWithZipSignature(): Boolean =
this.size >= 2 && this[0] == 0x50.toByte() && this[1] == 0x4B.toByte()
private fun ByteArray.hasAutoMagic(): Boolean =
this.size >= 4 &&
this[0] == AUTO_MAGIC[0] &&
this[1] == AUTO_MAGIC[1] &&
this[2] == AUTO_MAGIC[2] &&
this[3] == AUTO_MAGIC[3]
private fun ByteArray.hasNewAutoHeader(): Boolean =
this.size >= 8 &&
this.hasAutoMagic() &&
this[4] == AUTO_MARKER[0] &&
this[5] == AUTO_MARKER[1]
private fun getAutomaticBackupDocumentFiles(context: Context, create: Boolean = false): Pair<DocumentFile?, DocumentFile?> {
if(!Settings.instance.storage.isStorageMainValid(context))
return Pair(null, null);
@@ -76,14 +103,13 @@ class StateBackup {
);
}
private fun getAutomaticBackupPassword(customPassword: String? = null): String {
val password = customPassword ?: Settings.instance.backup.autoBackupPassword ?: "";
val pbytes = password.toByteArray();
if(pbytes.size < 4 || pbytes.size > 32)
throw IllegalStateException("Automatic backup passwords should atleast be 4 character and smaller than 32");
return password;
private fun requireLegacyBackupPassword(password: String): String {
val pbytes = password.toByteArray()
if (pbytes.size < 4 || pbytes.size > 32)
throw IllegalStateException("Password must be at least 4 bytes and smaller than 32 bytes")
return password
}
fun hasAutomaticBackup(): Boolean {
val context = StateApp.instance.contextOrNull ?: return false;
if(!Settings.instance.storage.isStorageMainValid(context))
@@ -106,8 +132,6 @@ class StateBackup {
val data = export();
val zip = data.asZip();
//Prepend some magic bytes to identify everything version 1 and up
val encryptedZip = byteArrayOf(0x11, 0x22, 0x33, 0x44, GPasswordEncryptionProvider.version.toByte()) + GPasswordEncryptionProvider.instance.encrypt(zip, getAutomaticBackupPassword());
if(!Settings.instance.storage.isStorageMainValid(context)) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
UIDialogs.toast("Missing permissions for auto-backup, please set the external general directory in settings");
@@ -118,7 +142,8 @@ class StateBackup {
val exportFile = backupFiles.first;
if (exportFile?.exists() == true && backupFiles.second != null)
exportFile.copyTo(context, backupFiles.second!!);
exportFile!!.writeBytes(context, encryptedZip);
val backupBytes = AUTO_MAGIC + AUTO_MARKER + byteArrayOf(AUTO_FORMAT_VERSION, 0x00.toByte()) + zip
exportFile!!.writeBytes(context, backupBytes)
Settings.instance.backup.lastAutoBackupTime = OffsetDateTime.now(); //OffsetDateTime.now();
Settings.instance.save();
@@ -137,69 +162,105 @@ class StateBackup {
//TODO: This goes has recently changed to use DocumentFiles and DocumentTree, and might need additional checks/edgecases covered.
fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false) {
if(ifExists && !hasAutomaticBackup()) {
Logger.i(TAG, "No AutoBackup exists, not restoring");
return;
if (ifExists && !hasAutomaticBackup()) {
Logger.i(TAG, "No AutoBackup exists, not restoring")
return
}
Logger.i(TAG, "Starting AutoBackup restore");
synchronized(_autoBackupLock) {
Logger.i(TAG, "Starting AutoBackup restore")
var permissionRequest: Pair<IWithResultLauncher, android.net.Uri?>? = null
val backupBytesEncrypted: ByteArray? = synchronized(_autoBackupLock) {
val backupFiles = getAutomaticBackupDocumentFiles(StateApp.instance.context, false)
val backupFiles = getAutomaticBackupDocumentFiles(StateApp.instance.context, false);
fun read(doc: DocumentFile?): ByteArray? = doc?.readBytes(context)
try {
if (backupFiles.first?.exists() != true)
throw IllegalStateException("Backup file does not exist");
throw IllegalStateException("Backup file does not exist")
val backupBytesEncrypted = backupFiles.first!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.first?.uri}]");
importEncryptedZipBytes(context, scope, backupBytesEncrypted, password);
Logger.i(TAG, "Finished AutoBackup restore");
}
catch (exSec: FileNotFoundException) {
Logger.e(TAG, "Failed to access backup file", exSec);
val activity = if(StateApp.instance.activity != null)
StateApp.instance.activity
else if(StateApp.instance.isMainActive)
StateApp.instance.contextOrNull;
else null;
if(activity != null) {
if(activity is IWithResultLauncher)
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", "Allows restoring of a backup", backupFiles.first?.parentFile?.uri) {
if(it != null) {
val customFiles = StateBackup.getAutomaticBackupDocumentFiles(activity);
if(customFiles.first != null && customFiles.first!!.isFile && customFiles.first!!.exists() && customFiles.first!!.canRead())
restoreAutomaticBackup(context, scope, password, ifExists);
}
};
read(backupFiles.first) ?: throw IllegalStateException("Could not read stream of [${backupFiles.first?.uri}]")
} catch (ex: Throwable) {
if (ex is FileNotFoundException || ex is SecurityException) {
val activity = (StateApp.instance.activity as? IWithResultLauncher)
?: (if (StateApp.instance.isMainActive) StateApp.instance.contextOrNull as? IWithResultLauncher else null)
if (activity != null) {
permissionRequest = Pair(activity, backupFiles.first?.parentFile?.uri)
return@synchronized null
}
}
// Otherwise, try the .old file
if (backupFiles.second?.exists() == true) {
read(backupFiles.second) ?: throw IllegalStateException("Could not read stream of [${backupFiles.second?.uri}]")
} else {
throw ex
}
}
catch (ex: Throwable) {
Logger.e(TAG, "Failed main AutoBackup restore", ex)
if (backupFiles.second?.exists() != true)
throw ex;
val backupBytesEncrypted = backupFiles.second!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.second?.uri}]");
importEncryptedZipBytes(context, scope, backupBytesEncrypted, password);
Logger.i(TAG, "Finished AutoBackup restore");
}
}
if (backupBytesEncrypted == null && permissionRequest != null) {
val (activity, initialUri) = permissionRequest
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", "Allows restoring of a backup", initialUri) { uri ->
if (uri != null) {
scope.launch(Dispatchers.IO) {
restoreAutomaticBackup(context, scope, password, ifExists)
}
}
}
return
}
importEncryptedZipBytes(context, scope, backupBytesEncrypted!!, password)
Logger.i(TAG, "Finished AutoBackup restore")
}
private fun importEncryptedZipBytes(context: Context, scope: CoroutineScope, backupBytesEncrypted: ByteArray, password: String) {
val backupBytes: ByteArray;
//Check magic bytes indicating version 1 and up
if (backupBytesEncrypted[0] == 0x11.toByte() && backupBytesEncrypted[1] == 0x22.toByte() && backupBytesEncrypted[2] == 0x33.toByte() && backupBytesEncrypted[3] == 0x44.toByte()) {
val version = backupBytesEncrypted[4].toInt();
if (version != GPasswordEncryptionProvider.version) {
throw Exception("Invalid encryption version");
}
backupBytes = GPasswordEncryptionProvider.instance.decrypt(backupBytesEncrypted.sliceArray(IntRange(5, backupBytesEncrypted.size - 1)), getAutomaticBackupPassword(password))
} else {
//Else its a version 0
backupBytes = GPasswordEncryptionProviderV0(getAutomaticBackupPassword(password).padStart(32, '9')).decrypt(backupBytesEncrypted);
if (backupBytesEncrypted.startsWithZipSignature()) {
importZipBytes(context, scope, backupBytesEncrypted)
return
}
importZipBytes(context, scope, backupBytes);
// New unencrypted header (magic + "GJ" + format + flags)
if (backupBytesEncrypted.hasNewAutoHeader()) {
val formatVersion = backupBytesEncrypted[6].toInt()
val flags = backupBytesEncrypted[7].toInt()
var offset = 8
if (formatVersion != AUTO_FORMAT_VERSION.toInt()) {
throw IllegalStateException("Unsupported backup format version: $formatVersion")
}
val isEncrypted = (flags and FLAG_ENCRYPTED.toInt()) != 0
if (!isEncrypted) {
val zipBytes = backupBytesEncrypted.copyOfRange(offset, backupBytesEncrypted.size)
importZipBytes(context, scope, zipBytes)
return
}
throw IllegalStateException("Encrypted backups with new header are not supported")
}
// Old encrypted v1+ header (magic + providerVersion + ciphertext)
if (backupBytesEncrypted.hasAutoMagic()) {
if (backupBytesEncrypted.size < 6) {
throw IllegalStateException("Invalid backup: too small")
}
val version = backupBytesEncrypted[4].toInt()
if (version != GPasswordEncryptionProvider.version) {
throw Exception("Invalid encryption version")
}
val ciphertext = backupBytesEncrypted.copyOfRange(5, backupBytesEncrypted.size)
val plaintextZip = GPasswordEncryptionProvider.instance.decrypt(ciphertext, requireLegacyBackupPassword(password))
importZipBytes(context, scope, plaintextZip)
return
}
// Old encrypted v0 (no magic)
val plaintextZip = GPasswordEncryptionProviderV0(requireLegacyBackupPassword(password).padStart(32, '9')).decrypt(backupBytesEncrypted)
importZipBytes(context, scope, plaintextZip)
}
fun saveExternalBackup(activity: IWithResultLauncher) {
@@ -234,6 +295,47 @@ class StateBackup {
}
}
suspend fun requiresPasswordForAutomaticBackup(context: Context): Boolean = withContext(Dispatchers.IO) {
val files = getAutomaticBackupDocumentFiles(context, create = false)
// Prefer main, fallback to .old
val doc = when {
files.first?.exists() == true -> files.first
files.second?.exists() == true -> files.second
else -> return@withContext true // if nothing exists, keep old behavior
} ?: return@withContext true
val header = try {
context.contentResolver.openInputStream(doc.uri)?.use { input ->
val buf = ByteArray(16)
val n = input.read(buf)
if (n <= 0) ByteArray(0) else buf.copyOf(n)
} ?: return@withContext true
} catch (_: Throwable) {
return@withContext true
}
// Raw zip ("PK") => not encrypted
if (header.size >= 2 && header[0] == 0x50.toByte() && header[1] == 0x4B.toByte()) {
return@withContext false
}
// New unencrypted header (magic + "GJ" + format + flags)
if (header.size >= 8 && header[0] == AUTO_MAGIC[0] && header[1] == AUTO_MAGIC[1] && header[2] == AUTO_MAGIC[2] && header[3] == AUTO_MAGIC[3] && header[4] == AUTO_MARKER[0] && header[5] == AUTO_MARKER[1]) {
val flags = header[7].toInt()
val isEncrypted = (flags and FLAG_ENCRYPTED.toInt()) != 0
return@withContext isEncrypted
}
// Old encrypted v1+ header (magic + providerVersion + ciphertext) => needs password
if (header.size >= 5 && header[0] == AUTO_MAGIC[0] && header[1] == AUTO_MAGIC[1] && header[2] == AUTO_MAGIC[2] && header[3] == AUTO_MAGIC[3]) {
return@withContext true
}
// Otherwise assume legacy v0 encrypted (no magic) => needs password
return@withContext true
}
fun export(): ExportStructure {
val exportInfo = mapOf(
Pair("version", "1")
@@ -303,186 +405,172 @@ class StateBackup {
var doEnablePlugins = false;
var doImportStores = false;
Logger.i(TAG, "Starting import choices");
UIDialogs.multiShowDialog(context, {
Logger.i(TAG, "Starting import");
if(!doImport)
return@multiShowDialog;
val enabledBefore = StatePlatform.instance.getEnabledClients().map { it.id };
val onConclusion = {
scope.launch(Dispatchers.Main) {
UIDialogs.multiShowDialog(context, {
Logger.i(TAG, "Starting import");
if (!doImport)
return@multiShowDialog;
val enabledBefore = StatePlatform.instance.getEnabledClients().map { it.id };
val onConclusion = {
scope.launch(Dispatchers.IO) {
StatePlatform.instance.selectClients(*enabledBefore.toTypedArray());
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_update_success_251dp, "Import has finished", null, null,0, UIDialogs.Action("Ok", {}));
}
}
};
//TODO: Probably restructure this to be less nested
scope.launch(Dispatchers.IO) {
StatePlatform.instance.selectClients(*enabledBefore.toTypedArray());
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_update_success_251dp,
"Import has finished", null, null, 0, UIDialogs.Action("Ok", {}));
}
}
};
//TODO: Probably restructure this to be less nested
scope.launch(Dispatchers.IO) {
try {
if (doImportSettings && export.settings != null) {
Logger.i(TAG, "Importing settings");
try {
Settings.replace(export.settings);
}
catch(ex: Throwable) {
UIDialogs.toast(context, "Failed to import settings\n(" + ex.message + ")");
}
}
val afterPluginInstalls = {
scope.launch(Dispatchers.IO) {
if (doEnablePlugins) {
val availableClients = StatePlatform.instance.getEnabledClients().toMutableList();
availableClients.addAll(StatePlatform.instance.getAvailableClients().filter { !availableClients.contains(it) });
Logger.i(TAG, "Import enabling plugins [${availableClients.map{it.name}.joinToString(", ")}]");
StatePlatform.instance.updateAvailableClients(context, false);
StatePlatform.instance.selectClients(*availableClients.map { it.id }.toTypedArray());
try {
if (doImportSettings && export.settings != null) {
Logger.i(TAG, "Importing settings");
try {
Settings.replace(export.settings);
} catch (ex: Throwable) {
UIDialogs.toast(
context,
"Failed to import settings\n(" + ex.message + ")"
);
}
if(doImportPluginSettings) {
for(settings in export.pluginSettings) {
Logger.i(TAG, "Importing Plugin settings [${settings.key}]");
StatePlugins.instance.setPluginSettings(settings.key, settings.value);
}
val afterPluginInstalls = {
scope.launch(Dispatchers.IO) {
if (doEnablePlugins) {
val availableClients = StatePlatform.instance.getEnabledClients().toMutableList();
availableClients.addAll(StatePlatform.instance.getAvailableClients().filter { !availableClients.contains(it) });
Logger.i(TAG, "Import enabling plugins [${availableClients.map { it.name }.joinToString(", ")}]");
StatePlatform.instance.updateAvailableClients(context, false);
StatePlatform.instance.selectClients(*availableClients.map { it.id }.toTypedArray());
}
}
val toAwait = export.stores.map { it.key }.toMutableList();
if(doImportStores) {
for(store in export.stores) {
Logger.i(TAG, "Importing store [${store.key}]");
if(store.key == "history") {
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import History", "Would you like to import history?", null, 0,
UIDialogs.Action("No", {
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Yes", {
for(historyStr in store.value) {
try {
val histObj = HistoryVideo.fromReconString(historyStr) { url ->
return@fromReconString export.cache?.videos?.firstOrNull { it.url == url };
if (doImportPluginSettings) {
for (settings in export.pluginSettings) {
Logger.i(TAG, "Importing Plugin settings [${settings.key}]");
StatePlugins.instance.setPluginSettings(settings.key, settings.value);
}
}
val toAwait = export.stores.map { it.key }.toMutableList();
if (doImportStores) {
for (store in export.stores) {
Logger.i(TAG, "Importing store [${store.key}]");
if (store.key == "history") {
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import History", "Would you like to import history?", null, 0,
UIDialogs.Action("No", {
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Yes", {
scope.launch(Dispatchers.IO) {
for (historyStr in store.value) {
try {
val histObj = HistoryVideo.fromReconString(historyStr) { url -> return@fromReconString export.cache?.videos?.firstOrNull { it.url == url }; }
val hist = StateHistory.instance.getHistoryByVideo(histObj.video, true, histObj.date);
if (hist != null)
StateHistory.instance.updateHistoryPosition(histObj.video, hist, true, histObj.position, histObj.date, false, histObj.playlistId);
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to import subscription group", ex);
}
}
val hist = StateHistory.instance.getHistoryByVideo(histObj.video, true, histObj.date);
if(hist != null)
StateHistory.instance.updateHistoryPosition(histObj.video, hist, true, histObj.position, histObj.date, false, histObj.playlistId);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to import subscription group", ex);
}, UIDialogs.ActionStyle.PRIMARY)
)
}
} else if (store.key == "subscription_groups") {
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import Subscription Groups", "Would you like to import subscription groups?\nExisting groups with the same id will be overridden!", null, 0,
UIDialogs.Action("No", {
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Yes", {
scope.launch(Dispatchers.IO) {
for (groupStr in store.value) {
try {
val group = Json.decodeFromString<SubscriptionGroup>(groupStr);
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
if (existing != null)
StateSubscriptionGroups.instance.deleteSubscriptionGroup(existing.id, false);
StateSubscriptionGroups.instance.updateSubscriptionGroup(group);
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to import subscription group", ex);
}
}
}
}, UIDialogs.ActionStyle.PRIMARY)
)
}
} else {
val relevantStore = availableStores.find { it.name == store.key };
if (relevantStore == null) {
Logger.w(TAG, "Unknown store [${store.key}] import");
continue;
}
withContext(Dispatchers.Main) {
UIDialogs.showImportDialog(context, relevantStore, store.key, store.value, export.cache) {
synchronized(toAwait) {
toAwait.remove(store.key);
if (toAwait.isEmpty())
onConclusion();
}
}, UIDialogs.ActionStyle.PRIMARY))
}
}
else if(store.key == "subscription_groups") {
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import Subscription Groups", "Would you like to import subscription groups?\nExisting groups with the same id will be overridden!", null, 0,
UIDialogs.Action("No", {
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Yes", {
for(groupStr in store.value) {
try {
val group = Json.decodeFromString<SubscriptionGroup>(groupStr);
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
if(existing != null)
StateSubscriptionGroups.instance.deleteSubscriptionGroup(existing.id, false);
StateSubscriptionGroups.instance.updateSubscriptionGroup(group);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to import subscription group", ex);
}
}
}, UIDialogs.ActionStyle.PRIMARY))
}
}
else {
val relevantStore = availableStores.find { it.name == store.key };
if (relevantStore == null) {
Logger.w(TAG, "Unknown store [${store.key}] import");
continue;
}
withContext(Dispatchers.Main) {
UIDialogs.showImportDialog(context, relevantStore, store.key, store.value, export.cache) {
synchronized(toAwait) {
toAwait.remove(store.key);
if(toAwait.isEmpty())
onConclusion();
}
};
};
}
}
}
}
}
}
}
if (doImportPlugins) {
Logger.i(TAG, "Importing plugins");
StatePlugins.instance.installPlugins(context, scope, unknownPlugins.map { it.value }) {
if (doImportPlugins) {
Logger.i(TAG, "Importing plugins");
StatePlugins.instance.installPlugins(context, scope, unknownPlugins.map { it.value }) {
afterPluginInstalls();
}
} else
afterPluginInstalls();
}
} catch (ex: Throwable) {
Logger.e(TAG, "Import failed", ex);
UIDialogs.showGeneralErrorDialog(context, "Import failed", ex);
}
else
afterPluginInstalls();
}
catch(ex: Throwable) {
Logger.e(TAG, "Import failed", ex);
UIDialogs.showGeneralErrorDialog(context, "Import failed", ex);
}
}
},
UIDialogs.Descriptor(R.drawable.ic_move_up,
"Do you want to import data?",
"Several dialogs will follow asking individual parts",
"Settings: ${export.settings != null}\n" +
"Plugins: ${unknownPlugins.size}\n" +
"Plugin Settings: ${export.pluginSettings.size}\n" +
export.stores.map { "${it.key}: ${it.value.size}" }.joinToString("\n").trim()
, 1,
UIDialogs.Action("Import", {
doImport = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("Cancel", { doImport = false})
),
if(export.settings != null) UIDialogs.Descriptor(R.drawable.ic_settings,
"Would you like to import settings",
"These are the settings that configure how your app works",
null, 0,
UIDialogs.Action("Yes", {
doImportSettings = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport } else null,
if(unknownPlugins.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources,
"Would you like to import plugins?",
"Your import contains the following plugins",
unknownPlugins.map { it.value }.joinToString("\n"), 1,
UIDialogs.Action("Yes", {
doImportPlugins = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport } else null,
if(export.pluginSettings.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources,
"Would you like to import plugin settings?",
null, null, 1,
UIDialogs.Action("Yes", {
doImportPluginSettings = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport } else null,
UIDialogs.Descriptor(R.drawable.ic_sources,
"Would you like to enable all plugins?",
"Enabling all plugins ensures all required plugins are available during import",
null, 0,
UIDialogs.Action("Yes", {
doEnablePlugins = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport },
if(export.stores.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_move_up,
"Would you like to import stores",
"Stores contain playlists, watch later, subscriptions, etc",
null, 0,
UIDialogs.Action("Yes", {
doImportStores = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport } else null
);
},
UIDialogs.Descriptor(R.drawable.ic_move_up, "Do you want to import data?", "Several dialogs will follow asking individual parts",
"Settings: ${export.settings != null}\n" +
"Plugins: ${unknownPlugins.size}\n" +
"Plugin Settings: ${export.pluginSettings.size}\n" +
export.stores.map { "${it.key}: ${it.value.size}" }.joinToString("\n").trim(),
1,
UIDialogs.Action("Import", {
doImport = true;
}, UIDialogs.ActionStyle.PRIMARY),
UIDialogs.Action("Cancel", { doImport = false })
),
if (export.settings != null) UIDialogs.Descriptor(R.drawable.ic_settings, "Would you like to import settings", "These are the settings that configure how your app works", null, 0,
UIDialogs.Action("Yes", {
doImportSettings = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport } else null,
if (unknownPlugins.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources, "Would you like to import plugins?", "Your import contains the following plugins", unknownPlugins.map { it.value }.joinToString("\n"), 1,
UIDialogs.Action("Yes", {
doImportPlugins = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport } else null,
if (export.pluginSettings.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources, "Would you like to import plugin settings?", null, null, 1,
UIDialogs.Action("Yes", {
doImportPluginSettings = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport } else null,
UIDialogs.Descriptor(R.drawable.ic_sources, "Would you like to enable all plugins?", "Enabling all plugins ensures all required plugins are available during import", null, 0,
UIDialogs.Action("Yes", {
doEnablePlugins = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport },
if (export.stores.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_move_up, "Would you like to import stores", "Stores contain playlists, watch later, subscriptions, etc", null, 0,
UIDialogs.Action("Yes", {
doImportStores = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport } else null
);
}
}
fun importTxt(context: MainActivity, text: String, allowFailure: Boolean = false): Boolean {
@@ -483,9 +483,9 @@ class StateDownloads {
var localVideos = StateDownloads.instance.getDownloadedVideosPlaylist(playlistId);
if(playlist != null) {
val missing = playlist.videos
.filter { vid -> !localVideos.any { it.id.value == null || it.id.value == vid.id.value } }
.map { getCachedVideo(it.id) }
.filterNotNull();
.filter { vid -> !localVideos.any { it.id.value == null || it.id.value == vid.id.value } }
.map { getCachedVideo(it.id) }
.filterNotNull();
if(missing.size > 0)
localVideos = localVideos + missing;
};
@@ -500,7 +500,6 @@ class StateDownloads {
for (video in localVideos) {
withContext(Dispatchers.Main) {
it.setText("Exporting videos...(${i}/${localVideos.size})");
//it.setProgress(i.toDouble() / localVideos.size);
}
try {
@@ -18,7 +18,14 @@ class StatePayment : PaymentState(if(!isTestingPayment) VERIFICATION_PUBLIC_KEY
}
companion object {
private val VERIFICATION_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzJqqETLa42xw4AfbNOLQolMdMiGgg8DAC4RXEcH4/gytLhaqp1XsjiiMkADi1C7sDtGj6kOuAuQkqXQKpZ2dJSZsO+GPyop6DmgfAM6MQgOgFUpwsb3Lt3SvskJcls8MeOC+jg+GjjcuJI8qOfYevj4/7wAOpqzAwocTYnJivlK5nrC+qNtUC2HZX93OVu69aU5yvA1SQe9GiiU7vBld+CbzHxTcABCK/THu/BpLtGx0M7W3HNMKK1Z79dopCL9ZZWbWdkGDY8Zf39Gn/WVrs5elBvPzU+AfNYty77vx2r+sKgyohlbz4KVYpnw8HfawKcwuRE/GUyD3F2hUcXy8dQIDAQAB";
private val VERIFICATION_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzJqqETLa42xw4AfbNOLQ" +
"olMdMiGgg8DAC4RXEcH4/gytLhaqp1XsjiiMkADi1C7sDtGj6kOuAuQkqXQKpZ2d" +
"JSZsO+GPyop6DmgfAM6MQgOgFUpwsb3Lt3SvskJcls8MeOC+jg+GjjcuJI8qOfYe" +
"vj4/7wAOpqzAwocTYnJivlK5nrC+qNtUC2HZX93OVu69aU5yvA1SQe9GiiU7vBld" +
"+CbzHxTcABCK/THu/BpLtGx0M7W3HNMKK1Z79dopCL9ZZWbWdkGDY8Zf39Gn/WVr" +
"s5elBvPzU+AfNYty77vx2r+sKgyohlbz4KVYpnw8HfawKcwuRE/GUyD3F2hUcXy8" +
"dQIDAQAB"
private val VERIFICATION_PUBLIC_KEY_TESTING = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqyDuxsRtD5gmBoLCNoZa" +
"XSRTwyUxgzcPHzLZkvomXVSQqzD+3aOKngcTKAZ83rm4GvoyMlBukxQMLShannSx" +
"k8GQGTCT7VStQKNc4lKVER5ASB6aEaypaFMIYI3rXN1xLF1LqY/j7cu5GgMsvAuU" +
@@ -34,4 +41,4 @@ class StatePayment : PaymentState(if(!isTestingPayment) VERIFICATION_PUBLIC_KEY
return _instance!!;
};
}
}
}
@@ -50,7 +50,7 @@ class FieldForm : LinearLayout {
}
}
fun updateSettingsVisibility(group: GroupField? = null) {
fun updateSettingsVisibility(group: GroupField? = null, allowEmptyGroups: Boolean = false) {
val settings = group?.getFields() ?: _fields;
val query = _editSearch.text.toString().lowercase();
@@ -58,7 +58,8 @@ class FieldForm : LinearLayout {
val isGroupMatch = query.isEmpty() || group?.searchContent?.lowercase()?.contains(query) == true;
for(field in settings) {
if(field is GroupField) {
updateSettingsVisibility(field);
if(!allowEmptyGroups)
updateSettingsVisibility(field);
} else if(field is View && field.descriptor != null) {
if(field.isAdvanced && !_showAdvancedSettings)
{
@@ -73,15 +74,21 @@ class FieldForm : LinearLayout {
}
}
}
else if(field is View) {
if(field.isAdvanced && !_showAdvancedSettings)
field.visibility = View.GONE;
else
field.visibility = VISIBLE;
}
}
if(group != null) {
group.visibility = if (groupVisible) View.VISIBLE else View.GONE;
}
}
fun setShowAdvancedSettings(show: Boolean) {
fun setShowAdvancedSettings(show: Boolean, allowEmptyGroups: Boolean = false) {
_showAdvancedSettings = show;
updateSettingsVisibility();
updateSettingsVisibility(null, allowEmptyGroups);
}
fun setSearchQuery(query: String) {
_editSearch.setText(query);
@@ -141,7 +148,9 @@ class FieldForm : LinearLayout {
}
fun fromPluginSettings(settings: List<SourcePluginConfig.Setting>, values: HashMap<String, String?>, groupTitle: String? = null, groupDescription: String? = null) {
_fieldsContainer.removeAllViews();
val newFields = getFieldsFromPluginSettings(context, settings, values);
val newFields = getFieldsFromPluginSettings(context, settings, values, {
setShowAdvancedSettings(it, true);
});
if (newFields.isEmpty()) {
return;
}
@@ -157,6 +166,7 @@ class FieldForm : LinearLayout {
_fieldsContainer.addView(v);
}
_fields = newFields.map { it.second };
updateSettingsVisibility(null, true);
} else {
for(field in newFields) {
finalizePluginSettingField(field.first, field.second, newFields);
@@ -164,6 +174,8 @@ class FieldForm : LinearLayout {
val group = GroupField(context, groupTitle, groupDescription)
.withFields(newFields.map { it.second });
_fieldsContainer.addView(group as View);
_fields = newFields.map { it.second };
updateSettingsVisibility(null, true);
}
}
private fun finalizePluginSettingField(setting: SourcePluginConfig.Setting, field: IField, others: List<Pair<SourcePluginConfig.Setting, IField>>) {
@@ -234,7 +246,7 @@ class FieldForm : LinearLayout {
private val _json = Json;
fun getFieldsFromPluginSettings(context: Context, settings: List<SourcePluginConfig.Setting>, values: HashMap<String, String?>): List<Pair<SourcePluginConfig.Setting, IField>> {
fun getFieldsFromPluginSettings(context: Context, settings: List<SourcePluginConfig.Setting>, values: HashMap<String, String?>, onAdvancedChanged: ((newVal: Boolean)->Unit)? = null): List<Pair<SourcePluginConfig.Setting, IField>> {
val fields = mutableListOf<Pair<SourcePluginConfig.Setting, IField>>()
for(setting in settings) {
@@ -243,6 +255,7 @@ class FieldForm : LinearLayout {
val field = when(setting.type.lowercase()) {
"header" -> {
val groupField = GroupField(context, setting.name, setting.description);
groupField.isAdvanced = (setting.isAdvanced ?: false);
groupField;
}
"boolean" -> {
@@ -252,6 +265,7 @@ class FieldForm : LinearLayout {
field.onChanged.subscribe { _, v, _ ->
values[setting.variableOrName] = _json.encodeToString (v == 1 || v == true);
}
field.isAdvanced = (setting.isAdvanced ?: false);
field;
}
"dropdown" -> {
@@ -261,6 +275,7 @@ class FieldForm : LinearLayout {
field.onChanged.subscribe { _, v, _ ->
values[setting.variableOrName] = v.toString();
}
field.isAdvanced = (setting.isAdvanced ?: false);
field;
}
else null;
@@ -272,6 +287,17 @@ class FieldForm : LinearLayout {
fields.add(Pair(setting, field));
}
}
if(onAdvancedChanged != null && settings.any { it.isAdvanced == true }) {
val setting = SourcePluginConfig.Setting("Show Advanced", "See advanced settings, which may be counter productive to change", "boolean", "false");
val field = ToggleField(context).withValue(setting.name, setting.description, false);
field.onChanged.subscribe { field, new, old ->
onAdvancedChanged?.invoke(new as Boolean);
}
fields.add(Pair(setting, field));
}
return fields;
}
@@ -24,6 +24,7 @@ import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.AnyAdapterView
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.NoResultsView
import com.futo.platformplayer.views.adapters.AnyAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -31,22 +32,29 @@ import kotlinx.coroutines.launch
class NotificationOverlayView: ConstraintLayout {
lateinit var recycler: RecyclerView;
lateinit var emptyView: NoResultsView;
var adapterNotifications: AnyAdapterView<Announcement, ViewHolder>;
constructor(context: Context) : super(context) {
inflate(context, R.layout.overlay_notifications, this)
recycler = findViewById<RecyclerView>(R.id.container_notifications);
emptyView = findViewById<NoResultsView>(R.id.no_results);
adapterNotifications = recycler.asAny<Announcement, ViewHolder>(RecyclerView.VERTICAL, false, {
});
emptyView.setText("Nothing to see here", "You don't have any notifications", R.drawable.ic_notifications)
}
fun onShown(parameter: Any?) {
val announcements = StateAnnouncement.instance.getVisibleAnnouncements();
adapterNotifications.adapter.setData(announcements);
if(announcements.any())
emptyView.isVisible = false;
else
emptyView.isVisible = true;
StateAnnouncement.instance.onAnnouncementChanged.subscribe(this) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
Logger.i("NotificationOverlayView", "Announcements Changed");
@@ -3,15 +3,10 @@ package com.futo.platformplayer.views.overlays.slideup
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.RelativeLayout
import android.widget.TextView
import com.futo.platformplayer.R
import org.w3c.dom.Text
class SlideUpMenuTextInput : LinearLayout {
private lateinit var _root: LinearLayout;
@@ -41,7 +41,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/set_a_password_for_your_daily_backup"
android:text="@string/enable_daily_backup"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
@@ -54,7 +54,7 @@
android:layout_width="match_parent"
android:textColor="#AAAAAA"
android:fontFamily="@font/inter_regular"
android:text="@string/set_a_password_used_to_encrypt_your_daily_backup_that_is_written_to_external_storage"
android:text="@string/automatic_backup_unencrypted_explanation"
android:textAlignment="center"
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp"
@@ -62,26 +62,6 @@
android:layout_height="wrap_content">
</TextView>
<EditText
android:id="@+id/edit_password"
android:layout_width="match_parent"
android:layout_marginLeft="30dp"
android:layout_marginTop="10dp"
android:layout_marginRight="30dp"
android:layout_height="wrap_content"
android:singleLine="true"
android:inputType="textPassword"
android:hint="@string/backup_password" />
<EditText
android:id="@+id/edit_password2"
android:layout_width="match_parent"
android:layout_marginLeft="30dp"
android:layout_marginTop="10dp"
android:layout_marginRight="30dp"
android:layout_height="wrap_content"
android:singleLine="true"
android:inputType="textPassword"
android:hint="@string/repeat_password" />
<LinearLayout
android:layout_width="match_parent"
@@ -107,7 +87,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/stop"
android:text="@string/disable"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
@@ -128,7 +108,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/start"
android:text="@string/enable"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
@@ -4,7 +4,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:background="@color/gray_1d">
@@ -13,9 +13,11 @@
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:paddingTop="40dp">
android:paddingTop="40dp"
android:paddingBottom="24dp">
<ImageView
android:id="@+id/image_icon"
android:layout_width="80dp"
android:layout_height="80dp"
app:srcCompat="@drawable/ic_lock" />
@@ -31,42 +33,57 @@
android:layout_marginStart="30dp"
android:textAlignment="center"
android:layout_marginEnd="30dp" />
<TextView
android:id="@+id/text_reason"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#AAAAAA"
android:fontFamily="@font/inter_regular"
android:text="@string/it_appears_an_automatic_backup_exists_on_your_device_if_you_would_like_to_restore_enter_your_backup_password"
android:textAlignment="center"
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp"
android:textSize="10dp"
android:layout_height="wrap_content">
android:textSize="10dp" />
</TextView>
<EditText
android:id="@+id/edit_password"
<LinearLayout
android:id="@+id/password_container"
android:layout_width="match_parent"
android:layout_marginLeft="30dp"
android:layout_marginTop="10dp"
android:layout_marginRight="30dp"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:singleLine="true"
android:hint="@string/backup_password" />
android:orientation="vertical">
<EditText
android:id="@+id/edit_password"
android:layout_width="match_parent"
android:layout_marginLeft="30dp"
android:layout_marginTop="10dp"
android:layout_marginRight="30dp"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:singleLine="true"
android:hint="@string/backup_password" />
</LinearLayout>
<ProgressBar
android:id="@+id/progress_restore"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:visibility="gone"
style="?android:attr/progressBarStyleLarge" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginTop="28dp"
android:layout_marginBottom="28dp">
android:layout_marginTop="28dp">
<Space
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<Button
android:id="@+id/button_cancel"
android:layout_width="wrap_content"
@@ -77,6 +94,7 @@
android:fontFamily="@font/inter_regular"
android:textColor="@color/colorPrimary"
android:background="@color/transparent" />
<LinearLayout
android:id="@+id/button_start"
android:layout_width="wrap_content"
@@ -86,6 +104,7 @@
android:clickable="true">
<TextView
android:id="@+id/text_start"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/restore"
@@ -99,4 +118,4 @@
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
@@ -17,6 +17,11 @@
android:layout_width="match_parent"
android:layout_height="1px"
android:background="#181818" />
<com.futo.platformplayer.views.NoResultsView
android:id="@+id/no_results"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/container_notifications"
app:layout_constraintTop_toBottomOf="@id/separator"
+1
View File
@@ -1064,6 +1064,7 @@
<item>Russo</item>
<item>Portoghese</item>
<item>Cinese</item>
<item>Italiano</item>
</string-array>
<string-array name="casting_device_type_array" translatable="false">
<item>FCast</item>
+1
View File
@@ -1017,6 +1017,7 @@
<item>Rusça</item>
<item>Portekizce</item>
<item>Çince</item>
<item>İtalyanca</item>
</string-array>
<string-array name="casting_device_type_array" translatable="false">
<item>FCast</item>
+13 -1
View File
@@ -27,6 +27,11 @@
<string name="retry">Retry</string>
<string name="install_failed_device_installer_broken">Failed to start system installer. Your devices ROM is not compatible with automatic updates.</string>
<string name="cancel">Cancel</string>
<string name="enable_daily_backup">Manage daily backup</string>
<string name="automatic_backup_unencrypted_explanation">Enable or disable your automatic backups here</string>
<string name="continue_anyway">Continue anyway</string>
<string name="automatic_backup_disabled">Automatic backup disabled</string>
<string name="automatic_backup_enabled">Automatic backup enabled</string>
<string name="failed_to_retrieve_data_are_you_connected">Failed to retrieve data, are you connected?</string>
<string name="settings">Settings</string>
<string name="history">History</string>
@@ -338,6 +343,8 @@
<string name="clear_the_external_storage_for_download_files">Clear the external storage for download files</string>
<string name="change_the_external_storage_for_download_files">Change the external storage for download files</string>
<string name="clear_cookies">Clear Cookies</string>
<string name="clear_cookies_after_login">Clear Cookies after Login</string>
<string name="clear_cookies_after_login_desc">Deletes all cookies on the webview after login, this may be required for certain plugins to function properly.</string>
<string name="clear_cookies_on_logout">Clear Cookies on Logout</string>
<string name="test_background_worker">Test Background Worker</string>
<string name="test_background_worker_description"></string>
@@ -535,7 +542,7 @@
<string name="restart_playback_when_gaining_connectivity_after_a_loss">Restart playback when gaining connectivity after a loss</string>
<string name="chapter_update_fps_title">Chapter Update FPS</string>
<string name="chapter_update_fps_description">Change accuracy of chapter updating, higher might cost more performance</string>
<string name="set_automatic_backup">Set Automatic Backup</string>
<string name="set_automatic_backup">Configure Automatic Backup</string>
<string name="shortly_after_opening_the_app_start_fetching_subscriptions">Shortly after opening the app, start fetching subscriptions</string>
<string name="show_faq">Show FAQ</string>
<string name="show_issues">Show Issues</string>
@@ -805,6 +812,10 @@
<string name="not_yet_available_retrying_in_time_s">Not yet available, retrying in {time}s</string>
<string name="failed_to_retry_for_live_stream">Failed to retry for live stream</string>
<string name="this_app_is_in_development_please_submit_bug_reports_and_understand_that_many_features_are_incomplete">This app is in development. Please submit bug reports and understand that many features are incomplete.</string>
<string name="automatic_backup_found_no_password">Automatic backup found. No password is required to restore.</string>
<string name="checking_backup">Checking backup...</string>
<string name="backup_password_length_error">Password must be 432 bytes.</string>
<string name="restoring">Restoring...</string>
<string name="please_use_at_least_1_character">Please use at least 1 character</string>
<string name="are_you_sure_you_want_to_delete_this_video">Are you sure you want to delete this video?</string>
<string name="tap_to_open">Tap to open</string>
@@ -1109,6 +1120,7 @@
<item>Russian</item>
<item>Portuguese</item>
<item>Chinese</item>
<item>Italian</item>
</string-array>
<string-array name="casting_device_type_array" translatable="false">
<item>FCast</item>
+6 -2
View File
@@ -19,7 +19,7 @@
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="youtu.be" />
<data android:host="www.you.be" />
<data android:host="www.youtu.be" />
<data android:host="youtube.com" />
<data android:host="www.youtube.com" />
<data android:host="m.youtube.com" />
@@ -31,6 +31,8 @@
<data android:host="patreon.com" />
<data android:host="soundcloud.com" />
<data android:host="twitch.tv" />
<data android:host="www.twitch.tv" />
<data android:host="m.twitch.tv" />
<data android:host="bilibili.com" />
<data android:host="bilibili.tv" />
<data android:host="dailymotion.com" />
@@ -51,7 +53,7 @@
<data android:mimeType="text/plain" />
<data android:host="youtu.be" />
<data android:host="www.you.be" />
<data android:host="www.youtu.be" />
<data android:host="youtube.com" />
<data android:host="www.youtube.com" />
<data android:host="m.youtube.com" />
@@ -63,6 +65,8 @@
<data android:host="patreon.com" />
<data android:host="soundcloud.com" />
<data android:host="twitch.tv" />
<data android:host="www.twitch.tv" />
<data android:host="m.twitch.tv" />
<data android:host="bilibili.com" />
<data android:host="bilibili.tv" />
<data android:host="dailymotion.com" />
+6 -2
View File
@@ -29,7 +29,7 @@
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="youtu.be" />
<data android:host="www.you.be" />
<data android:host="www.youtu.be" />
<data android:host="youtube.com" />
<data android:host="www.youtube.com" />
<data android:host="m.youtube.com" />
@@ -41,6 +41,8 @@
<data android:host="patreon.com" />
<data android:host="soundcloud.com" />
<data android:host="twitch.tv" />
<data android:host="www.twitch.tv" />
<data android:host="m.twitch.tv" />
<data android:host="bilibili.com" />
<data android:host="bilibili.tv" />
<data android:host="dailymotion.com" />
@@ -61,7 +63,7 @@
<data android:mimeType="text/plain" />
<data android:host="youtu.be" />
<data android:host="www.you.be" />
<data android:host="www.youtu.be" />
<data android:host="youtube.com" />
<data android:host="www.youtube.com" />
<data android:host="m.youtube.com" />
@@ -73,6 +75,8 @@
<data android:host="patreon.com" />
<data android:host="soundcloud.com" />
<data android:host="twitch.tv" />
<data android:host="www.twitch.tv" />
<data android:host="m.twitch.tv" />
<data android:host="bilibili.com" />
<data android:host="bilibili.tv" />
<data android:host="dailymotion.com" />