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 | |
|---|---|---|---|
| 748551af2a | |||
| 9ce41bc8d0 | |||
| 8cf542e201 | |||
| 88950843b3 | |||
| 4a08058322 | |||
| 7b76ba1539 | |||
| 6492278e7d | |||
| 9de9440160 | |||
| 372af6cf47 | |||
| 29d08c8554 | |||
| cfeceabe5b | |||
| a51f609a92 | |||
| 15a655f196 | |||
| c6525f1caa | |||
| e147fdd77e | |||
| 6a8ac0bfaa | |||
| 772bff6bc0 | |||
| b6b04054b9 | |||
| 1ea794459c | |||
| c27f5e4096 | |||
| 8469f17b4c | |||
| 067abc415b | |||
| d692533f20 | |||
| 31a6ea0f39 | |||
| 5ba2f2be75 | |||
| 8e4ad54de1 | |||
| 6139696714 | |||
| 8536861e09 | |||
| 71262da3c2 |
@@ -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);
|
||||
|
||||
@@ -7,6 +7,10 @@ import android.os.IBinder
|
||||
import android.os.SystemClock
|
||||
import com.futo.platformplayer.UIDialogs.ActionStyle
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.SessionAnnouncement
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateUpdate
|
||||
import kotlinx.coroutines.*
|
||||
@@ -14,6 +18,7 @@ import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
class UpdateDownloadService : Service() {
|
||||
|
||||
@@ -85,13 +90,16 @@ class UpdateDownloadService : Service() {
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean) {
|
||||
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean, onProgress: ((Int) -> Unit)? = null) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val force = progress == 100 && !indeterminate
|
||||
|
||||
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
|
||||
lastProgressUpdateElapsedMs = now
|
||||
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate)
|
||||
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate);
|
||||
|
||||
if(onProgress != null)
|
||||
onProgress.invoke(progress);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +107,7 @@ class UpdateDownloadService : Service() {
|
||||
val apkFile = StateUpdate.getApkFile(this, version)
|
||||
val partialFile = StateUpdate.getPartialApkFile(this, version)
|
||||
|
||||
var announcement: SessionAnnouncement? = null;
|
||||
try {
|
||||
if (apkFile.exists() && apkFile.length() > 0L) {
|
||||
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
|
||||
@@ -106,6 +115,14 @@ class UpdateDownloadService : Service() {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
announcement = StateAnnouncement.instance.registerLoading("Downloading new version [${version}]", "New version is being downloaded..",
|
||||
ImageVariable.fromResource(R.drawable.foreground));
|
||||
}
|
||||
catch(ex: Exception){
|
||||
Logger.e(TAG, "Failed to set progress announcement", ex);
|
||||
}
|
||||
|
||||
var backoffMs = INITIAL_BACKOFF_MS
|
||||
|
||||
for (attempt in 0 until MAX_RETRIES) {
|
||||
@@ -115,7 +132,13 @@ class UpdateDownloadService : Service() {
|
||||
}
|
||||
|
||||
try {
|
||||
performDownload(StateUpdate.APK_URL, partialFile, version)
|
||||
performDownload(StateUpdate.APK_URL, partialFile, version, {
|
||||
try {
|
||||
if (announcement != null)
|
||||
announcement?.setProgress(it);
|
||||
}
|
||||
catch(ex: Throwable) {}
|
||||
})
|
||||
|
||||
if (!cancelRequested) {
|
||||
if (apkFile.exists()) {
|
||||
@@ -145,6 +168,12 @@ class UpdateDownloadService : Service() {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
if (announcement != null) {
|
||||
StateAnnouncement.instance.closeAnnouncement(announcement.id);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable){}
|
||||
isDownloading = false
|
||||
cancelRequested = false
|
||||
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||
@@ -152,7 +181,7 @@ class UpdateDownloadService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun performDownload(url: String, partialFile: File, version: Int) {
|
||||
private fun performDownload(url: String, partialFile: File, version: Int, onProgress: ((Int)->Unit)? = null) {
|
||||
var startOffset = if (partialFile.exists()) partialFile.length() else 0L
|
||||
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
|
||||
|
||||
@@ -204,7 +233,7 @@ class UpdateDownloadService : Service() {
|
||||
progress > 100 -> 100
|
||||
else -> progress
|
||||
}
|
||||
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false)
|
||||
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false, onProgress)
|
||||
}
|
||||
} else {
|
||||
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
|
||||
@@ -250,6 +279,18 @@ class UpdateDownloadService : Service() {
|
||||
UpdateNotificationManager.cancelAll(ctx)
|
||||
UpdateInstaller.startInstall(ctx, version, apkFile)
|
||||
}, ActionStyle.PRIMARY, true));
|
||||
|
||||
try {
|
||||
StateAnnouncement.instance.registerAnnouncement("install-update-apk", "Grayjay v${version} is ready!", "You can now install the new Grayjay version.",
|
||||
AnnouncementType.SESSION,
|
||||
OffsetDateTime.now(), "update", "Install", {
|
||||
UpdateNotificationManager.cancelAll(ctx)
|
||||
UpdateInstaller.startInstall(ctx, version, apkFile)
|
||||
});
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
|
||||
updateDownloadedDialog = null
|
||||
|
||||
@@ -110,6 +110,7 @@ import com.futo.platformplayer.stores.StringStorage
|
||||
import com.futo.platformplayer.stores.SubscriptionStorage
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.views.ToastView
|
||||
import com.futo.platformplayer.views.notification.NotificationOverlayView
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.google.gson.JsonParser
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
@@ -201,6 +202,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var _fragLibraryVideos: LibraryVideosFragment;
|
||||
lateinit var _fragLibrarySearch: LibrarySearchFragment;
|
||||
lateinit var _fragLibraryFiles: LibraryFilesFragment;
|
||||
lateinit var _fragNotifications: NotificationOverlayView.Frag;
|
||||
lateinit var _fragSettings: SettingsFragment;
|
||||
lateinit var _fragDeveloper: DeveloperFragment;
|
||||
lateinit var _fragLogin: LoginFragment;
|
||||
@@ -389,6 +391,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragLibraryVideos = LibraryVideosFragment.newInstance();
|
||||
_fragLibraryFiles = LibraryFilesFragment.newInstance();
|
||||
_fragLibrarySearch = LibrarySearchFragment.newInstance();
|
||||
_fragNotifications = NotificationOverlayView.Frag();
|
||||
_fragSettings = SettingsFragment.newInstance();
|
||||
_fragDeveloper = DeveloperFragment.newInstance();
|
||||
_fragLogin = LoginFragment.newInstance();
|
||||
@@ -538,6 +541,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragLibrarySearch.topBar = _fragTopBarSearch;
|
||||
_fragSettings.topBar = _fragTopBarNavigation;
|
||||
_fragDeveloper.topBar = _fragTopBarNavigation;
|
||||
_fragNotifications.topBar = _fragTopBarGeneral;
|
||||
|
||||
_fragBrowser.topBar = _fragTopBarNavigation;
|
||||
|
||||
@@ -1368,6 +1372,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
LibraryVideosFragment::class -> _fragLibraryVideos as T;
|
||||
LibraryFilesFragment::class -> _fragLibraryFiles as T;
|
||||
LibrarySearchFragment::class -> _fragLibrarySearch as T;
|
||||
NotificationOverlayView.Frag::class -> _fragNotifications as T;
|
||||
SettingsFragment:: class -> _fragSettings as T;
|
||||
DeveloperFragment::class -> _fragDeveloper as T;
|
||||
LoginFragment::class -> _fragLogin as T;
|
||||
|
||||
+13
-1
@@ -61,6 +61,11 @@ class SourcePluginConfig(
|
||||
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
|
||||
val absoluteScriptUrl: String get() = resolveAbsoluteUrl(scriptUrl, sourceUrl)!!;
|
||||
|
||||
fun isOfficialAuthor(): Boolean {
|
||||
return scriptSignature != null &&
|
||||
scriptPublicKey == "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsoFJU4AReDyUnSQI9A99UjLCwkY8OH+1o8cdtf2EjSb+fO2qmP8MGMTAvfvgmq5d2QBJE2XHRkRO3JKcTlcc1j0WlOlU8P9W272DYCeX6oYaavpKNqGKoGEuodp9wtiyNwyH46++JfpU/uIUacZbZKkHv9gIGchmNvpKYZQjFd/8pUqXGpcXZP54tGSC9PLcY+5TozZThK7Oy1+3YEf1bZ44UinRYYATbLk/wNuAfsupvlt6nxZOcJhABhdo9V+gY0FE6Ayg5+1cd1noWhnRtLF+sPdEr3z8Nt15JEK5a/524t25FMhwz8yKxlGW5qW3QLJHSUgLQncL6a1zlZ1s8QIDAQAB"
|
||||
}
|
||||
|
||||
private fun resolveAbsoluteUrl(url: String?, sourceUrl: String?): String? {
|
||||
if(url == null)
|
||||
return null;
|
||||
@@ -165,6 +170,12 @@ class SourcePluginConfig(
|
||||
"Unrestricted Http Header access",
|
||||
"Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests."
|
||||
))
|
||||
if(packagesOptional.contains("Browser") || packages.contains("Browser")) {
|
||||
list.add(Pair(
|
||||
"Browser Interop",
|
||||
"This plugin requires webbrowser interop. May access urls outside of the restricted urls. This will only work for official plugins and during development builds."
|
||||
))
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
@@ -224,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.caoccao.javet.values.reference.IV8ValuePromise
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.caoccao.javet.values.reference.V8ValuePromise
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.engine.exceptions.NoInternetException
|
||||
@@ -34,6 +36,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptTimeoutException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
||||
import com.futo.platformplayer.engine.internal.V8Converter
|
||||
import com.futo.platformplayer.engine.packages.PackageBridge
|
||||
import com.futo.platformplayer.engine.packages.PackageBrowser
|
||||
import com.futo.platformplayer.engine.packages.PackageDOMParser
|
||||
import com.futo.platformplayer.engine.packages.PackageHttp
|
||||
import com.futo.platformplayer.engine.packages.PackageHttpImp
|
||||
@@ -44,6 +47,7 @@ import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateAssets
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import com.futo.platformplayer.toList
|
||||
import com.futo.platformplayer.toV8ValueBlocking
|
||||
import com.futo.platformplayer.toV8ValueAsync
|
||||
@@ -218,6 +222,9 @@ class V8Plugin {
|
||||
if(pack is PackageHttp) {
|
||||
pack.cleanup();
|
||||
}
|
||||
else if(pack is PackageBrowser) {
|
||||
pack.deinitialize();
|
||||
}
|
||||
}
|
||||
|
||||
_runtime?.let {
|
||||
@@ -387,6 +394,18 @@ class V8Plugin {
|
||||
"HttpImp" -> PackageHttpImp(this, config)
|
||||
"Utilities" -> PackageUtilities(this, config)
|
||||
"JSDOM" -> PackageJSDOM(this, config)
|
||||
"Browser" -> {
|
||||
val isOfficial = (config is SourcePluginConfig && config.isOfficialAuthor());
|
||||
|
||||
if(BuildConfig.DEBUG)
|
||||
PackageBrowser(this)
|
||||
else if(isOfficial)
|
||||
PackageBrowser(this)
|
||||
else if(config is SourcePluginConfig && config.id == StateDeveloper.DEV_ID)
|
||||
PackageBrowser(this)
|
||||
else
|
||||
throw IllegalArgumentException("Browser is only allowed for debug and official plugins due to security");
|
||||
};
|
||||
else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,6 +105,11 @@ class PackageBridge : V8Package {
|
||||
)
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun hasPackage(str: String): Boolean {
|
||||
return _plugin.getPackages().any { it.name == str };
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun dispose(value: V8Value) {
|
||||
Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package com.futo.platformplayer.engine.packages
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Looper
|
||||
import android.webkit.ConsoleMessage
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.ValueCallback
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.collection.emptyLongSet
|
||||
import androidx.webkit.ScriptHandler
|
||||
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
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class PackageBrowser: V8Package {
|
||||
override val name: String get() = "Browser";
|
||||
override val variableName: String = "browser";
|
||||
|
||||
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)
|
||||
throw IllegalStateException("Browser not initialized");
|
||||
return _browser!!;
|
||||
}
|
||||
|
||||
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 onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
|
||||
// Best-effort fallback. Not equivalent, but as early as WebView exposes.
|
||||
val scripts = _pageLoadScriptsFallback.values.toList()
|
||||
for (s in scripts) {
|
||||
try { view?.evaluateJavascript(s, null) } catch (_: Throwable) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageCommitVisible(view: WebView?, url: String?) {
|
||||
super.onPageCommitVisible(view, url)
|
||||
_readySemaphore?.release();
|
||||
_readySemaphore = null;
|
||||
Logger.i("PackageBrowser", "Browser loaded");
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_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)
|
||||
}
|
||||
}
|
||||
_browser?.addJavascriptInterface(_interop, "__GJ");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@V8Function
|
||||
fun deinitialize() {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
_browser?.destroy();
|
||||
}
|
||||
_browser = null;
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun getCurrentUrl(): String? {
|
||||
return browser.url;
|
||||
}
|
||||
@V8Function
|
||||
fun waitTillLoaded(timeout: Int = 1000): Boolean {
|
||||
val acquired = _readySemaphore?.let {
|
||||
if(!it.tryAcquire()) {
|
||||
Logger.i("PackageBrowser", "Waiting for browser to be ready");
|
||||
if(!runBlocking {
|
||||
try {
|
||||
return@runBlocking withTimeout(timeout.toLong(), {
|
||||
it.acquire()
|
||||
return@withTimeout true;
|
||||
});
|
||||
}
|
||||
catch(ex: TimeoutCancellationException) {
|
||||
return@runBlocking false;
|
||||
}
|
||||
}) return@let false;
|
||||
}
|
||||
it.release();
|
||||
return@let true;
|
||||
} ?: true;
|
||||
if(acquired)
|
||||
Logger.i("PackageBrowser", "Browser is ready");
|
||||
else
|
||||
Logger.i("PackageBrowser", "Browser failed wait ready");
|
||||
return acquired;
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun load(url: String) {
|
||||
Logger.i("PackageBrowser", "Browser loading url [${url}]");
|
||||
_readySemaphore = Semaphore(1, 1);
|
||||
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
browser.loadUrl(url);
|
||||
} catch(ex: Throwable) {}
|
||||
}
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun run(js: String, callbackId: String? = null, callback: V8ValueFunction? = null) {
|
||||
waitTillLoaded();
|
||||
val funcClone = callback?.toClone<V8ValueFunction>()
|
||||
if(callbackId != null && callback != null) {
|
||||
synchronized(_callbacks) {
|
||||
_callbacks.put(callbackId, {
|
||||
_plugin.busy {
|
||||
funcClone?.callVoid(null, arrayOf(it));
|
||||
}
|
||||
if (!_plugin.isStopped)
|
||||
JavetResourceUtils.safeClose(funcClone);
|
||||
});
|
||||
}
|
||||
}
|
||||
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@V8Function
|
||||
fun runWithReturn(js: String, callback: V8ValueFunction? = null) {
|
||||
waitTillLoaded();
|
||||
val funcClone = callback?.toClone<V8ValueFunction>()
|
||||
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
Logger.i("PackageBrowser", "Browser running JS with callback [sync]\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 returned: " + (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", "Browser Failed to invoke browser", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun addScriptOnLoad(js: String): String {
|
||||
require(js.isNotBlank()) { "Script must be non-empty." }
|
||||
|
||||
val id = UUID.randomUUID().toString()
|
||||
|
||||
onMainBlocking {
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
|
||||
val ref = WebViewCompat.addDocumentStartJavaScript(browser, js, setOf("*"))
|
||||
_pageLoadScriptRefs[id] = ref
|
||||
} else {
|
||||
_pageLoadScriptsFallback[id] = js
|
||||
}
|
||||
}
|
||||
|
||||
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 <T> onMainBlocking(block: () -> T): T {
|
||||
return if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
block()
|
||||
} else runBlocking {
|
||||
withContext(Dispatchers.Main) { block() }
|
||||
}
|
||||
}
|
||||
|
||||
class JSInterop(private val pack: PackageBrowser) {
|
||||
|
||||
@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) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
callback.invoke(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun log(msg: String) {
|
||||
Logger.i("PackageBrowser", "Log: " + msg);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -10,6 +10,7 @@ import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Spinner
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -21,6 +22,7 @@ class BrowserFragment : MainFragment() {
|
||||
override val isTab: Boolean = false;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var _root: LinearLayout? = null;
|
||||
private var _webview: WebView? = null;
|
||||
private val _webviewWithoutHandling = object: WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
@@ -31,6 +33,7 @@ class BrowserFragment : MainFragment() {
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = inflater.inflate(R.layout.fragment_browser, container, false);
|
||||
_root = view.findViewById<LinearLayout>(R.id.root);
|
||||
_webview = view.findViewById<WebView?>(R.id.webview).apply {
|
||||
this.webViewClient = _webviewWithoutHandling;
|
||||
this.settings.javaScriptEnabled = true;
|
||||
@@ -43,7 +46,12 @@ class BrowserFragment : MainFragment() {
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack)
|
||||
|
||||
if(parameter is String) {
|
||||
if(parameter is WebView) {
|
||||
_root?.removeView(_webview);
|
||||
_root?.addView(parameter);
|
||||
_webview = parameter;
|
||||
}
|
||||
else if(parameter is String) {
|
||||
_webview?.webViewClient = _webviewWithoutHandling;
|
||||
_webview?.loadUrl(parameter);
|
||||
}
|
||||
|
||||
+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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
private val _progressBar: ProgressBar;
|
||||
private val _spinnerSortBy: Spinner;
|
||||
private val _containerSortBy: LinearLayout;
|
||||
private val _announcementView: AnnouncementView;
|
||||
//private val _announcementView: AnnouncementView;
|
||||
private val _tagsView: TagsView;
|
||||
private val _textCentered: TextView;
|
||||
private val _emptyPagerContainer: FrameLayout;
|
||||
@@ -87,7 +87,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
_textCentered = findViewById(R.id.text_centered);
|
||||
_emptyPagerContainer = findViewById(R.id.empty_pager_container);
|
||||
_progressBar = findViewById(R.id.progress_bar);
|
||||
_announcementView = findViewById(R.id.announcement_view)
|
||||
//_announcementView = findViewById(R.id.announcement_view)
|
||||
_progressBar.inactiveColor = Color.TRANSPARENT;
|
||||
|
||||
_swipeRefresh = findViewById(R.id.swipe_refresh);
|
||||
@@ -192,7 +192,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
}
|
||||
|
||||
protected fun showAnnouncementView() {
|
||||
_announcementView.visibility = View.VISIBLE
|
||||
//_announcementView.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
|
||||
|
||||
+2
-2
@@ -266,7 +266,7 @@ class LibraryFragment : MainFragment() {
|
||||
});
|
||||
|
||||
if(this.allowMusic) {
|
||||
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount);
|
||||
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount)
|
||||
adapterArtists.setData(artists);
|
||||
if (artists.size == 0)
|
||||
sectionArtists.setEmpty(
|
||||
@@ -289,7 +289,7 @@ class LibraryFragment : MainFragment() {
|
||||
}
|
||||
|
||||
if(this.allowMusic) {
|
||||
val albums = StateLibrary.instance.getAlbums();
|
||||
val albums = StateLibrary.instance.getAlbums()
|
||||
adapterAlbums.setData(albums);
|
||||
if (albums.size == 0)
|
||||
sectionAlbums.setEmpty("No albums", "No albums were found on your device", -1);
|
||||
|
||||
+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) {
|
||||
|
||||
+52
@@ -7,6 +7,9 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
|
||||
@@ -17,18 +20,54 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragmentData
|
||||
import com.futo.platformplayer.models.SearchType
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.views.casting.CastButton
|
||||
import com.futo.platformplayer.views.notification.NotificationOverlayView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class GeneralTopBarFragment : TopFragment() {
|
||||
private var _buttonSearch: ImageButton? = null;
|
||||
private var _buttonCast: CastButton? = null;
|
||||
|
||||
private var _buttonNotifs: ConstraintLayout? = null;
|
||||
private var _buttonNotifIcon: ImageView? = null;
|
||||
private var _buttonNotifCount: TextView? = null;
|
||||
|
||||
init {
|
||||
StateAnnouncement.instance.onAnnouncementChanged.subscribe {
|
||||
lifecycleScope?.launch(Dispatchers.Main) {
|
||||
updateNotifCount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNotifCount() {
|
||||
val currentAnnouncements = StateAnnouncement.instance.getVisibleAnnouncements();
|
||||
if(currentAnnouncements.any())
|
||||
_buttonNotifCount?.let {
|
||||
it.text = currentAnnouncements.size.toString();
|
||||
it.visibility = View.VISIBLE;
|
||||
}
|
||||
else
|
||||
_buttonNotifCount?.let {
|
||||
it.text = currentAnnouncements.size.toString();
|
||||
it.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShown(parameter: Any?) {
|
||||
if(currentMain is CreatorsFragment) {
|
||||
_buttonSearch?.setImageResource(R.drawable.ic_person_search_300w);
|
||||
} else {
|
||||
_buttonSearch?.setImageResource(R.drawable.ic_search_300w);
|
||||
}
|
||||
if(currentMain is NotificationOverlayView.Frag) {
|
||||
_buttonNotifIcon?.setImageResource(R.drawable.ic_notifications_filled)
|
||||
}
|
||||
else {
|
||||
_buttonNotifIcon?.setImageResource(R.drawable.ic_notifications)
|
||||
}
|
||||
}
|
||||
override fun onHide() {
|
||||
|
||||
@@ -44,6 +83,19 @@ class GeneralTopBarFragment : TopFragment() {
|
||||
val buttonSearch: ImageButton = view.findViewById(R.id.button_search);
|
||||
_buttonCast = view.findViewById(R.id.button_cast);
|
||||
|
||||
_buttonNotifs = view.findViewById(R.id.button_notifs);
|
||||
_buttonNotifIcon = view.findViewById(R.id.button_notifs_icon);
|
||||
_buttonNotifCount = view.findViewById(R.id.button_notifs_count);
|
||||
|
||||
updateNotifCount();
|
||||
|
||||
_buttonNotifs?.setOnClickListener {
|
||||
if(currentMain is NotificationOverlayView.Frag)
|
||||
closeSegment();
|
||||
else
|
||||
navigate<NotificationOverlayView.Frag>();
|
||||
}
|
||||
|
||||
buttonSearch.setOnClickListener {
|
||||
if(currentMain is CreatorsFragment) {
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.CREATOR));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
package com.futo.platformplayer.states
|
||||
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.dialogs.PluginUpdateDialog
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringHashSetStorage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -110,6 +119,48 @@ class StateAnnouncement {
|
||||
onAnnouncementChanged.emit();
|
||||
}
|
||||
|
||||
//Special Announcements
|
||||
fun registerPluginUpdate(oldConfig: SourcePluginConfig, newConfig: SourcePluginConfig): SessionAnnouncement {
|
||||
val announcement = SessionAnnouncement(
|
||||
"update-plugin-" + UUID.randomUUID().toString(),
|
||||
"${newConfig.name} update v${newConfig.version} available!",
|
||||
"An update is available to upgrade from ${oldConfig.version} to ${newConfig.version}.",
|
||||
AnnouncementType.SESSION,
|
||||
null, "updates", "Update", StateAnnouncement.ACTION_UPDATE_PLUGIN,
|
||||
null, null,oldConfig.id,
|
||||
newConfig?.absoluteIconUrl?.let { ImageVariable.fromUrl(it) }
|
||||
).withExtraAction("Changelog", StateAnnouncement.ACTION_CHANGELOG, oldConfig.id);
|
||||
registerAnnouncementSession(announcement);
|
||||
return announcement;
|
||||
}
|
||||
fun registerPluginUpdated(newConfig: SourcePluginConfig) {
|
||||
registerAnnouncementSession(SessionAnnouncement(
|
||||
"updated-plugin-" + UUID.randomUUID().toString(),
|
||||
"${newConfig.name} updated to v${newConfig.version}!",
|
||||
"You have succesfully been updated to v${newConfig.version}.",
|
||||
AnnouncementType.SESSION,
|
||||
null, "updates", null, null,
|
||||
null, null,null,
|
||||
newConfig?.absoluteIconUrl?.let { ImageVariable.fromUrl(it) }
|
||||
).withExtraAction("Changelog", StateAnnouncement.ACTION_CHANGELOG, newConfig.id));
|
||||
}
|
||||
|
||||
fun registerLoading(title: String, description: String, icon: ImageVariable? = null, customId: String? = null): SessionAnnouncement {
|
||||
val id = "loading-" + UUID.randomUUID().toString();
|
||||
val announcement = SessionAnnouncement(
|
||||
customId ?: id,
|
||||
title,
|
||||
description,
|
||||
AnnouncementType.ONGOING,
|
||||
null, "loading", null, null,
|
||||
null, null,null, icon
|
||||
);
|
||||
registerAnnouncementSession(announcement);
|
||||
return announcement;
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun getVisibleAnnouncements(category: String? = null): List<Announcement> {
|
||||
synchronized(_lock) {
|
||||
if (category != null) {
|
||||
@@ -122,7 +173,9 @@ class StateAnnouncement {
|
||||
}
|
||||
}
|
||||
|
||||
fun closeAnnouncement(id: String) {
|
||||
fun closeAnnouncement(id: String?) {
|
||||
if(id == null)
|
||||
return;
|
||||
val item: Announcement?;
|
||||
synchronized(_lock) {
|
||||
item = _announcementsStore.findItem { it.id == id };
|
||||
@@ -164,6 +217,7 @@ class StateAnnouncement {
|
||||
cancelAction?.invoke(item);
|
||||
}
|
||||
}
|
||||
onAnnouncementChanged?.emit();
|
||||
}
|
||||
|
||||
fun deleteAllAnnouncements() {
|
||||
@@ -194,7 +248,9 @@ class StateAnnouncement {
|
||||
|
||||
onAnnouncementChanged.emit();
|
||||
}
|
||||
fun neverAnnouncement(id: String) {
|
||||
fun neverAnnouncement(id: String?) {
|
||||
if(id == null)
|
||||
return;
|
||||
synchronized(_lock) {
|
||||
val item = _announcementsStore.findItem { it.id == id };
|
||||
if (item != null && !_announcementsNever.contains(id))
|
||||
@@ -208,19 +264,26 @@ class StateAnnouncement {
|
||||
_announcementsNever.save();
|
||||
onAnnouncementChanged.emit();
|
||||
}
|
||||
fun actionAnnouncement(id: String) {
|
||||
fun actionAnnouncement(id: String?, extra: Boolean = false) {
|
||||
if(id == null)
|
||||
return;
|
||||
val item = _announcementsStore.findItem { it.id == id } ?: _sessionAnnouncements[id];
|
||||
if(item != null)
|
||||
actionAnnouncement(item);
|
||||
actionAnnouncement(item, extra);
|
||||
}
|
||||
fun actionAnnouncement(item: Announcement) {
|
||||
fun actionAnnouncement(item: Announcement, extra: Boolean = false) {
|
||||
val actionId = if(!extra) item.actionId else if(item is SessionAnnouncement) item.extraActionId else null;
|
||||
val actionData = if(!extra) item.actionData else if(item is SessionAnnouncement) item.extraActionData else null;
|
||||
|
||||
val action = _sessionActions[item.id];
|
||||
if (action != null) {
|
||||
action(item);
|
||||
} else {
|
||||
when (item.actionId) {
|
||||
when (actionId) {
|
||||
ACTION_NEVER -> neverAnnouncement(item.id);
|
||||
ACTION_SOMETHING -> actionSomething();
|
||||
ACTION_CHANGELOG -> actionChangelog(actionData);
|
||||
ACTION_UPDATE_PLUGIN -> actionUpdatePlugin(item.id, actionData);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,6 +314,84 @@ class StateAnnouncement {
|
||||
|
||||
}
|
||||
|
||||
private fun actionChangelog(id: String?) {
|
||||
if(id == null)
|
||||
return;
|
||||
|
||||
StateApp.instance.contextOrNull?.let { context ->
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
val plugin = StatePlugins.instance.getPlugin(id);
|
||||
if (plugin == null)
|
||||
return@launch
|
||||
val update = StatePlugins.instance.checkForUpdates(plugin.config);
|
||||
if(update == null)
|
||||
return@launch;
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
UIDialogs.showChangelogDialog(context, update.version, update.changelog!!.filterKeys { it.toIntOrNull() != null }
|
||||
.mapKeys { it.key.toInt() }
|
||||
.mapValues { update.getChangelogString(it.key.toString()) ?: "" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun actionUpdatePlugin(notifId: String?, id: String?) {
|
||||
if(id == null)
|
||||
return;
|
||||
val plugin = StatePlugins.instance.getPlugin(id);
|
||||
if (plugin == null)
|
||||
return
|
||||
|
||||
closeAnnouncement(notifId);
|
||||
val loadingAnnouncement = registerLoading("Updating ${plugin.config.name}..", "An update is in progress for ${plugin.config.name}.",
|
||||
if(plugin.config.absoluteIconUrl != null) ImageVariable.fromUrl(plugin.config.absoluteIconUrl!!) else null);
|
||||
|
||||
val loadingId = loadingAnnouncement.id;
|
||||
|
||||
StateApp.instance.contextOrNull?.let { context ->
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val update = StatePlugins.instance.checkForUpdates(plugin.config);
|
||||
if (update == null)
|
||||
return@launch;
|
||||
|
||||
val client = ManagedHttpClient();
|
||||
client.setTimeout(10000);
|
||||
val script = StatePlugins.instance.getScript(plugin.config.id) ?: "";
|
||||
val newScript = client.get(update.absoluteScriptUrl)?.body?.string();
|
||||
if(newScript.isNullOrEmpty())
|
||||
throw IllegalStateException("No script found");
|
||||
|
||||
if(true || plugin.config.isLowRiskUpdate(script, update, newScript)) {
|
||||
StatePlugins.instance.installPluginBackground(context, StateApp.instance.scope, update, newScript,
|
||||
{ text: String, progress: Double -> },
|
||||
{ ex ->
|
||||
if(ex == null) {
|
||||
registerPluginUpdated(update);
|
||||
}
|
||||
else {
|
||||
UIDialogs.appToast("Update for ${update.name} failed\n" + ex.message);
|
||||
}
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
closeAnnouncement(loadingId);
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
closeAnnouncement(loadingId);
|
||||
UIDialogs.showPluginUpdateDialog(context, plugin.config, update);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to trigger update from announcement", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun registerDefaultHandlerAnnouncement() {
|
||||
registerAnnouncement(
|
||||
"default-url-handler",
|
||||
@@ -279,6 +420,8 @@ class StateAnnouncement {
|
||||
|
||||
|
||||
const val ACTION_SOMETHING = "SOMETHING";
|
||||
const val ACTION_CHANGELOG = "CHANGELOG";
|
||||
const val ACTION_UPDATE_PLUGIN = "UPDATE_PLUGIN";
|
||||
const val ACTION_NEVER = "NEVER";
|
||||
private const val TAG = "StateAnnouncement";
|
||||
}
|
||||
@@ -294,7 +437,8 @@ open class Announcement(
|
||||
val time: OffsetDateTime? = null,
|
||||
val category: String? = null,
|
||||
val actionName: String? = null,
|
||||
val actionId: String? = null
|
||||
val actionId: String? = null,
|
||||
val actionData: String? = null
|
||||
);
|
||||
class SessionAnnouncement(
|
||||
id: String,
|
||||
@@ -306,7 +450,9 @@ class SessionAnnouncement(
|
||||
actionName: String? = null,
|
||||
actionId: String? = null,
|
||||
val cancelName: String? = null,
|
||||
val cancelActionId: String? = null
|
||||
val cancelActionId: String? = null,
|
||||
actionData: String? = null,
|
||||
val icon: ImageVariable? = null
|
||||
): Announcement(
|
||||
id= id,
|
||||
title = title,
|
||||
@@ -315,13 +461,40 @@ class SessionAnnouncement(
|
||||
time = time,
|
||||
category = category,
|
||||
actionName = actionName,
|
||||
actionId = actionId
|
||||
);
|
||||
actionId = actionId,
|
||||
actionData = actionData
|
||||
) {
|
||||
var extraActionName: String? = null;
|
||||
var extraActionId: String? = null;
|
||||
var extraActionData: String? = null;
|
||||
|
||||
var extraObj: Any? = null;
|
||||
|
||||
var progress: Double? = null;
|
||||
val onProgressChanged = Event1<SessionAnnouncement>();
|
||||
|
||||
fun withExtraAction(name: String, id: String, data: String? = null): SessionAnnouncement {
|
||||
extraActionName = name;
|
||||
extraActionId = id;
|
||||
extraActionData = data;
|
||||
return this;
|
||||
}
|
||||
|
||||
fun setProgress(progress: Double) {
|
||||
this.progress = progress;
|
||||
onProgressChanged?.emit(this);
|
||||
}
|
||||
fun setProgress(progress: Int) {
|
||||
this.progress = progress.toDouble().div(100);
|
||||
onProgressChanged?.emit(this);
|
||||
}
|
||||
}
|
||||
|
||||
enum class AnnouncementType(val value : Int) {
|
||||
DELETABLE(0), //Close button deletes announcement (generally for actions)
|
||||
RECURRING(1), //Shows up till never is pressed (generally for patchnotes etc)
|
||||
PERMANENT(2), //Shows up until deleted through other means (action)
|
||||
SESSION(3), //Not persistent, only during this session
|
||||
SESSION_RECURRING(4); //Not persistent, only during this session, recurring id
|
||||
SESSION_RECURRING(4), //Not persistent, only during this session, recurring id
|
||||
ONGOING(5);
|
||||
}
|
||||
@@ -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
|
||||
@@ -43,6 +46,7 @@ import com.futo.platformplayer.logging.AndroidLogConsumer
|
||||
import com.futo.platformplayer.logging.FileLogConsumer
|
||||
import com.futo.platformplayer.logging.LogLevel
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.receivers.AudioNoisyReceiver
|
||||
import com.futo.platformplayer.services.DownloadService
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
@@ -306,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)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,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);
|
||||
|
||||
@@ -659,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");
|
||||
@@ -678,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) {
|
||||
@@ -732,8 +740,10 @@ class StateApp {
|
||||
));
|
||||
|
||||
for(update in updateAvailable)
|
||||
if(StatePlatform.instance.isClientEnabled(update.first.id))
|
||||
UIDialogs.showPluginUpdateDialog(context, update.first, update.second);
|
||||
if(StatePlatform.instance.isClientEnabled(update.first.id)) {
|
||||
//UIDialogs.showPluginUpdateDialog(context, update.first, update.second);
|
||||
StateAnnouncement.instance.registerPluginUpdate(update.first, update.second);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!!;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ class StatePlugins {
|
||||
_updatesAvailableMap = updatesAvailableFor
|
||||
return@withContext configs;
|
||||
}
|
||||
private suspend fun checkForUpdates(c: SourcePluginConfig): SourcePluginConfig? = withContext(Dispatchers.IO) {
|
||||
suspend fun checkForUpdates(c: SourcePluginConfig): SourcePluginConfig? = withContext(Dispatchers.IO) {
|
||||
val sourceUrl = c.sourceUrl ?: return@withContext null;
|
||||
|
||||
Logger.i(TAG, "Check for source updates '${c.name}'.");
|
||||
|
||||
@@ -113,7 +113,10 @@ class StateUpdate {
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs();
|
||||
}
|
||||
return File(dir, "app-${DESIRED_ABI}-${version}.apk");
|
||||
val result = File(dir, "app-${DESIRED_ABI}-${version}.apk");
|
||||
//if(result.exists())
|
||||
// result.delete();
|
||||
return result;
|
||||
}
|
||||
|
||||
fun getPartialApkFile(context: Context, version: Int): File {
|
||||
@@ -121,7 +124,10 @@ class StateUpdate {
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs();
|
||||
}
|
||||
return File(dir, "app-${DESIRED_ABI}-${version}.apk.part");
|
||||
val result = File(dir, "app-${DESIRED_ABI}-${version}.apk.part");
|
||||
//if(result.exists())
|
||||
// result.delete();
|
||||
return result;
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
|
||||
@@ -6,12 +6,10 @@ import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.Announcement
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
@@ -162,6 +160,10 @@ class AnnouncementView : LinearLayout {
|
||||
_textClose.visibility = View.VISIBLE;
|
||||
_textNever.visibility = View.VISIBLE;
|
||||
}
|
||||
AnnouncementType.ONGOING -> {
|
||||
_textClose.visibility = View.GONE;
|
||||
_textNever.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
|
||||
if (announcement.time != null) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
package com.futo.platformplayer.views.notification
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.Announcement
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.SessionAnnouncement
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
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
|
||||
|
||||
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");
|
||||
val adapter = adapterNotifications;
|
||||
val announcements = StateAnnouncement.instance.getVisibleAnnouncements();
|
||||
adapter.adapter.setData(announcements);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onResume() {
|
||||
|
||||
}
|
||||
|
||||
fun onPause() {
|
||||
StateAnnouncement.instance.onAnnouncementChanged.remove(this);
|
||||
}
|
||||
|
||||
class ViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<Announcement>(
|
||||
LayoutInflater.from(_viewGroup.context).inflate(
|
||||
R.layout.list_announcement,
|
||||
_viewGroup, false)) {
|
||||
|
||||
protected var _announcement: Announcement? = null;
|
||||
protected val _textName: TextView
|
||||
protected val _textMetadata: TextView;
|
||||
protected val _icon: ImageView;
|
||||
protected val _buttonIgnore: ImageView
|
||||
protected val _buttonNever: LinearLayout
|
||||
protected val _buttonAction: LinearLayout
|
||||
protected val _buttonActionText: TextView
|
||||
protected val _buttonExtra: LinearLayout
|
||||
protected val _buttonExtraText: TextView
|
||||
protected val _loader: LoaderView;
|
||||
protected val _progress: ProgressBar;
|
||||
|
||||
init {
|
||||
_textName = _view.findViewById(R.id.text_name);
|
||||
_textMetadata = _view.findViewById(R.id.text_metadata);
|
||||
_buttonIgnore = _view.findViewById(R.id.button_ignore);
|
||||
_buttonNever = _view.findViewById(R.id.button_never);
|
||||
_buttonAction = _view.findViewById(R.id.button_action);
|
||||
_buttonActionText = _view.findViewById(R.id.button_action_text);
|
||||
_buttonExtra = _view.findViewById(R.id.button_extra);
|
||||
_buttonExtraText = _view.findViewById(R.id.button_extra_text);
|
||||
_icon = _view.findViewById(R.id.icon);
|
||||
_loader = _view.findViewById(R.id.loader);
|
||||
_progress = _view.findViewById(R.id.progress);
|
||||
|
||||
_buttonIgnore.setOnClickListener {
|
||||
_announcement.let {
|
||||
StateAnnouncement.instance.closeAnnouncement(it?.id);
|
||||
}
|
||||
}
|
||||
_buttonNever.setOnClickListener {
|
||||
_announcement.let {
|
||||
StateAnnouncement.instance.neverAnnouncement(it?.id);
|
||||
}
|
||||
}
|
||||
_buttonExtra.setOnClickListener {
|
||||
_announcement.let {
|
||||
StateAnnouncement.instance.actionAnnouncement(it?.id, true)
|
||||
}
|
||||
}
|
||||
_buttonAction.setOnClickListener {
|
||||
_announcement.let {
|
||||
StateAnnouncement.instance.actionAnnouncement(it?.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
override fun bind(value: Announcement) {
|
||||
val oldAnnouncement = _announcement;
|
||||
_announcement = value;
|
||||
|
||||
if(oldAnnouncement is SessionAnnouncement)
|
||||
oldAnnouncement.onProgressChanged.clear();
|
||||
|
||||
_textName.text = value.title;
|
||||
_textMetadata.text = value.msg;
|
||||
|
||||
if(value is SessionAnnouncement) {
|
||||
if(value.icon != null) {
|
||||
value.icon.setImageView(_icon);
|
||||
_icon.visibility = View.VISIBLE;
|
||||
}
|
||||
else
|
||||
_icon.visibility = View.GONE;
|
||||
if(value.extraActionName != null && value.extraActionId != null) {
|
||||
_buttonExtraText.text = value.extraActionName;
|
||||
_buttonExtra.visibility = View.VISIBLE;
|
||||
}
|
||||
else
|
||||
_buttonExtra.visibility = View.GONE;
|
||||
|
||||
if(value.announceType == AnnouncementType.ONGOING) {
|
||||
_buttonIgnore.visibility = View.GONE;
|
||||
}
|
||||
else {
|
||||
_buttonIgnore.visibility = View.VISIBLE;
|
||||
}
|
||||
if(value.progress != null && value.announceType == AnnouncementType.ONGOING) {
|
||||
_progress.isVisible = true;
|
||||
_progress.min = 0;
|
||||
_progress.max = 100;
|
||||
value.onProgressChanged.subscribe {
|
||||
val prog = it.progress;
|
||||
if(prog == 0.toDouble() || prog == 100.toDouble()) {
|
||||
_progress.isIndeterminate = true;
|
||||
}
|
||||
else {
|
||||
_progress.isIndeterminate = false;
|
||||
_progress.setProgress(it.progress?.times(100)?.toInt() ?: 0, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
_progress.isVisible = false;
|
||||
}
|
||||
else {
|
||||
_buttonExtra.visibility = View.GONE;
|
||||
_icon.visibility = View.GONE;
|
||||
_buttonIgnore.visibility = View.VISIBLE;
|
||||
}
|
||||
|
||||
if(value.announceType == AnnouncementType.ONGOING) {
|
||||
_loader.visibility = View.VISIBLE;
|
||||
_loader.start();
|
||||
}
|
||||
else {
|
||||
_loader.visibility = View.GONE;
|
||||
_loader.stop();
|
||||
}
|
||||
|
||||
_buttonNever.visibility =
|
||||
if (value.announceType == AnnouncementType.RECURRING || value.announceType == AnnouncementType.SESSION_RECURRING)
|
||||
View.VISIBLE
|
||||
else
|
||||
View.GONE;
|
||||
|
||||
_buttonAction.visibility =
|
||||
if(value.actionId != null && value.actionName != null)
|
||||
View.VISIBLE;
|
||||
else View.GONE;
|
||||
|
||||
if(value.actionId != null && value.actionName != null) {
|
||||
_buttonActionText.text = value.actionName;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class Frag : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var _view: NotificationOverlayView? = null;
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
_view?.onShown(parameter);
|
||||
}
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = NotificationOverlayView(requireContext());
|
||||
_view = view;
|
||||
return view;
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
super.onDestroyMainView();
|
||||
_view = null;
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
_view?.onResume();
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
_view?.onPause();
|
||||
}
|
||||
}
|
||||
}
|
||||
-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;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#2D63ED" />
|
||||
<corners android:radius="20dp" />
|
||||
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M160,760L160,680L240,680L240,400Q240,317 290,252.5Q340,188 420,168L420,140Q420,115 437.5,97.5Q455,80 480,80Q505,80 522.5,97.5Q540,115 540,140L540,168Q620,188 670,252.5Q720,317 720,400L720,680L800,680L800,760L160,760ZM480,880Q447,880 423.5,856.5Q400,833 400,800L560,800Q560,833 536.5,856.5Q513,880 480,880Z"/>
|
||||
</vector>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
<LinearLayout
|
||||
android:id="@+id/root"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
@@ -11,4 +12,4 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
</LinearLayout>
|
||||
@@ -38,11 +38,12 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!--
|
||||
<com.futo.platformplayer.views.announcements.AnnouncementView
|
||||
android:id="@+id/announcement_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone" />
|
||||
android:visibility="gone" /> -->
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/container_sort_by"
|
||||
|
||||
@@ -46,6 +46,42 @@
|
||||
android:scaleType="fitCenter"
|
||||
app:srcCompat="@drawable/ic_cast_white_25dp" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:id="@+id/button_notifs">
|
||||
<ImageButton
|
||||
android:id="@+id/button_notifs_icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/cd_button_notifs"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingTop="11dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:clickable="false"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:srcCompat="@drawable/ic_notifications" />
|
||||
<TextView
|
||||
android:id="@+id/button_notifs_count"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:text="5"
|
||||
android:textSize="12dp"
|
||||
android:textAlignment="center"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
android:background="@drawable/background_primary_round_20dp"
|
||||
android:layout_marginTop="3dp"
|
||||
android:layout_marginRight="5dp"
|
||||
android:clickable="false"
|
||||
/>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<!--Back Button-->
|
||||
<ImageButton
|
||||
android:id="@+id/button_search"
|
||||
|
||||
@@ -30,11 +30,12 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!--
|
||||
<com.futo.platformplayer.views.announcements.AnnouncementView
|
||||
android:id="@+id/announcement_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone" />
|
||||
android:visibility="gone" /> -->
|
||||
|
||||
<com.futo.platformplayer.views.others.RadioGroupView
|
||||
android:id="@+id/radio_group"
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="0dp"
|
||||
android:layout_marginBottom="0dp"
|
||||
android:id="@+id/root"
|
||||
android:clickable="true"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingLeft="10dp"
|
||||
android:paddingRight="10dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/icon"
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="30dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="@id/text_metadata" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:textSize="13dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
tools:text="Example Artist"
|
||||
android:maxLines="1"
|
||||
app:layout_constraintLeft_toRightOf="@id/icon"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintRight_toLeftOf="@id/button_ignore"
|
||||
android:layout_marginRight="20dp"
|
||||
android:layout_marginTop="10dp"
|
||||
app:layout_constraintBottom_toTopOf="@id/text_metadata"
|
||||
android:layout_marginStart="10dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_metadata"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:textSize="12dp"
|
||||
android:textColor="#888888"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
tools:text="3 videos"
|
||||
android:maxLines="2"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_name"
|
||||
app:layout_constraintLeft_toRightOf="@id/icon"
|
||||
app:layout_constraintRight_toLeftOf="@id/button_ignore"
|
||||
android:layout_marginRight="20dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:layout_marginStart="10dp" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/button_ignore"
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="30dp"
|
||||
android:src="@drawable/ic_close"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="@id/text_metadata" />
|
||||
|
||||
<com.futo.platformplayer.views.LoaderView
|
||||
android:id="@+id/loader"
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="30dp"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="@id/text_metadata" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/container_buttons"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_metadata"
|
||||
app:layout_constraintBottom_toTopOf="@id/separator"
|
||||
android:gravity="center"
|
||||
android:paddingBottom="10dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/button_never"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/background_button_accent"
|
||||
android:layout_marginLeft="5dp"
|
||||
android:layout_marginRight="5dp">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:paddingStart="28dp"
|
||||
android:paddingEnd="28dp"
|
||||
android:text="Never" />
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:id="@+id/button_extra"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/background_button_accent"
|
||||
android:layout_marginLeft="5dp"
|
||||
android:layout_marginRight="5dp">
|
||||
<TextView
|
||||
android:id="@+id/button_extra_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:paddingStart="28dp"
|
||||
android:paddingEnd="28dp"
|
||||
android:text="Extra" />
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:id="@+id/button_action"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="5dp"
|
||||
android:layout_marginRight="5dp"
|
||||
android:background="@drawable/background_button_primary"
|
||||
android:clickable="true">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/button_action_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Action"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:paddingStart="28dp"
|
||||
android:paddingEnd="28dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress"
|
||||
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
android:layout_marginBottom="1dp"
|
||||
android:progressTint="@color/primary"
|
||||
/>
|
||||
|
||||
<View
|
||||
android:id="@+id/separator"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/container_buttons"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1px"
|
||||
android:background="#181818" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
<View
|
||||
android:id="@+id/overlay_slide_up_menu_background"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#C9000000" />
|
||||
|
||||
<View
|
||||
android:id="@+id/separator"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
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"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -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>
|
||||
@@ -896,6 +907,7 @@
|
||||
<string name="cd_creator_thumbnail">Creator thumbnail</string>
|
||||
<string name="cd_button_clear_search">Clear search</string>
|
||||
<string name="cd_button_search">Search</string>
|
||||
<string name="cd_button_notifs">Notifications</string>
|
||||
<string name="cd_search_icon">Search icon</string>
|
||||
<string name="cd_button_back">Back button</string>
|
||||
<string name="cd_app_icon">App icon</string>
|
||||
@@ -1108,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/bilibili updated: b153339c93...9186672f0f
Submodule app/src/stable/assets/sources/dailymotion updated: d95df7dca2...70f625a3bd
Submodule app/src/stable/assets/sources/kick updated: 96503584d9...5cae761620
Submodule app/src/stable/assets/sources/mixcloud updated: 0bbe4c63f4...1b801553b3
Submodule app/src/stable/assets/sources/rumble updated: 3b51471010...34e0a15016
Submodule app/src/stable/assets/sources/soundcloud updated: 49db9e3e15...e785c5d8c9
Submodule app/src/stable/assets/sources/youtube updated: 079dc6e3dc...ffd1b535d0
@@ -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/bilibili updated: b153339c93...9186672f0f
Submodule app/src/unstable/assets/sources/dailymotion updated: d95df7dca2...70f625a3bd
Submodule app/src/unstable/assets/sources/kick updated: 96503584d9...5cae761620
Submodule app/src/unstable/assets/sources/mixcloud updated: 0bbe4c63f4...1b801553b3
Submodule app/src/unstable/assets/sources/rumble updated: 3b51471010...34e0a15016
Submodule app/src/unstable/assets/sources/soundcloud updated: 49db9e3e15...e785c5d8c9
Submodule app/src/unstable/assets/sources/youtube updated: 079dc6e3dc...ffd1b535d0
+1
-1
Submodule dep/futopay updated: 6857c8f0bc...3996582852
Reference in New Issue
Block a user