mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa1954ceef | |||
| 13aa49726a | |||
| 20bab7d056 | |||
| cbf7ca0181 | |||
| b7477080d2 | |||
| ac5bc27581 | |||
| 748551af2a | |||
| 9ce41bc8d0 | |||
| 8cf542e201 | |||
| 88950843b3 | |||
| 4a08058322 | |||
| 7b76ba1539 | |||
| 6492278e7d | |||
| 9de9440160 | |||
| 372af6cf47 | |||
| 29d08c8554 | |||
| cfeceabe5b | |||
| a51f609a92 | |||
| 15a655f196 | |||
| c6525f1caa | |||
| e147fdd77e | |||
| 6a8ac0bfaa | |||
| 772bff6bc0 | |||
| b6b04054b9 | |||
| 1ea794459c | |||
| c27f5e4096 | |||
| 8469f17b4c | |||
| 8e4ad54de1 | |||
| 6139696714 |
@@ -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);
|
||||
|
||||
+2
-1
@@ -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("&", "&").replace("\"", """)
|
||||
|
||||
@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\""
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-14
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -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))
|
||||
}
|
||||
|
||||
+33
-13
@@ -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) {
|
||||
|
||||
+4
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -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");
|
||||
|
||||
-5
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
<string name="retry">Retry</string>
|
||||
<string name="install_failed_device_installer_broken">Failed to start system installer. Your device’s 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 4–32 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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Submodule app/src/stable/assets/sources/mixcloud updated: 0bbe4c63f4...1b801553b3
Submodule app/src/stable/assets/sources/patreon updated: 6880b30b71...6b3b6d25e5
Submodule app/src/stable/assets/sources/youtube updated: 781564715c...47c5b3b894
@@ -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" />
|
||||
|
||||
Submodule app/src/unstable/assets/sources/mixcloud updated: 0bbe4c63f4...1b801553b3
Submodule app/src/unstable/assets/sources/patreon updated: 6880b30b71...6b3b6d25e5
Submodule app/src/unstable/assets/sources/youtube updated: 781564715c...47c5b3b894
+1
-1
Submodule dep/futopay updated: 6857c8f0bc...3996582852
Reference in New Issue
Block a user