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 | |
|---|---|---|---|
| d1336c711a | |||
| 2a2ed08a3c | |||
| 8a0e49232e | |||
| a8decdb0d9 | |||
| 2609929780 | |||
| 2bcfbf89d3 | |||
| fa1954ceef | |||
| 13aa49726a | |||
| 20bab7d056 | |||
| cbf7ca0181 | |||
| b7477080d2 | |||
| ac5bc27581 | |||
| 748551af2a | |||
| 9ce41bc8d0 | |||
| 8cf542e201 | |||
| 88950843b3 | |||
| 4a08058322 | |||
| 7b76ba1539 | |||
| 6492278e7d | |||
| 9de9440160 | |||
| 372af6cf47 | |||
| 29d08c8554 | |||
| cfeceabe5b | |||
| a51f609a92 | |||
| 15a655f196 | |||
| c6525f1caa | |||
| e147fdd77e | |||
| 8e4ad54de1 | |||
| 6139696714 |
+39
-19
@@ -1,37 +1,57 @@
|
||||
variables:
|
||||
GIT_SUBMODULE_STRATEGY: recursive
|
||||
|
||||
stages:
|
||||
- buildAndDeployApkUnstable
|
||||
- buildAndDeployApkStable
|
||||
- buildAndDeployPlaystore
|
||||
|
||||
buildAndDeployApkUnstable:
|
||||
stage: buildAndDeployApkUnstable
|
||||
stage: build
|
||||
script:
|
||||
- sh deploy-unstable.sh
|
||||
only:
|
||||
- tags
|
||||
except:
|
||||
- ^(dev)
|
||||
when: manual
|
||||
needs: []
|
||||
allow_failure: true
|
||||
artifacts:
|
||||
when: always
|
||||
expire_in: 30 days
|
||||
paths:
|
||||
- app/build/outputs/apk/unstable/release/*.apk
|
||||
|
||||
buildAndDeployApkStable:
|
||||
stage: buildAndDeployApkStable
|
||||
stage: build
|
||||
script:
|
||||
- sh deploy-stable.sh
|
||||
only:
|
||||
- tags
|
||||
except:
|
||||
- branches
|
||||
when: manual
|
||||
needs: []
|
||||
artifacts:
|
||||
when: always
|
||||
expire_in: 30 days
|
||||
paths:
|
||||
- app/build/outputs/apk/stable/release/*.apk
|
||||
|
||||
buildAndDeployPlaystore:
|
||||
stage: buildAndDeployPlaystore
|
||||
stage: deploy
|
||||
script:
|
||||
- sh deploy-playstore.sh
|
||||
- sh build-playstore.sh
|
||||
- bash tools/venv_playstore.sh
|
||||
- . .venv-playstore/bin/activate
|
||||
- python publish_playstore.py --sa /root/grayjay.json --package com.futo.platformplayer.playstore --aab ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab --track production --status completed
|
||||
only:
|
||||
- tags
|
||||
except:
|
||||
- branches
|
||||
when: manual
|
||||
when: on_success
|
||||
needs:
|
||||
- buildAndDeployApkStable
|
||||
artifacts:
|
||||
when: always
|
||||
expire_in: 30 days
|
||||
paths:
|
||||
- app/build/outputs/bundle/playstoreRelease/*.aab
|
||||
|
||||
updateFdroidRepo:
|
||||
stage: deploy
|
||||
only:
|
||||
- tags
|
||||
when: on_success
|
||||
needs:
|
||||
- job: buildAndDeployApkStable
|
||||
artifacts: true
|
||||
script:
|
||||
- python3 update_fdroid_index.py
|
||||
|
||||
@@ -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() {
|
||||
@@ -1048,11 +1072,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;
|
||||
|
||||
|
||||
fun shouldClearWebviewCookies(): Boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -68,11 +68,15 @@ class CaptchaActivity : AppCompatActivity() {
|
||||
intent.getStringExtra("body");
|
||||
else null;
|
||||
|
||||
_webView.settings.userAgentString = captchaConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
|
||||
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
|
||||
if (captchaConfig.userAgent != null)
|
||||
_webView.settings.userAgentString = captchaConfig.userAgent;
|
||||
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
|
||||
val capturedUserAgent = _webView.settings.userAgentString;
|
||||
_webView.settings.useWideViewPort = true;
|
||||
_webView.settings.loadWithOverviewMode = true;
|
||||
|
||||
val webViewClient = if(config != null) CaptchaWebViewClient(config) else CaptchaWebViewClient(captchaConfig);
|
||||
val webViewClient = if(config != null) CaptchaWebViewClient(config, capturedUserAgent) else CaptchaWebViewClient(captchaConfig, capturedUserAgent);
|
||||
webViewClient.onCaptchaFinished.subscribe { captcha ->
|
||||
_callback?.let {
|
||||
_callback = null;
|
||||
|
||||
@@ -61,11 +61,15 @@ class LoginActivity : AppCompatActivity() {
|
||||
else throw IllegalStateException("No valid configuration?");
|
||||
//TODO: Backwards compat removal?
|
||||
|
||||
_webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
|
||||
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
|
||||
if (authConfig.userAgent != null)
|
||||
_webView.settings.userAgentString = authConfig.userAgent;
|
||||
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
|
||||
val capturedUserAgent = _webView.settings.userAgentString;
|
||||
_webView.settings.useWideViewPort = true;
|
||||
_webView.settings.loadWithOverviewMode = true;
|
||||
|
||||
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig);
|
||||
val webViewClient = if(config != null) LoginWebViewClient(config, capturedUserAgent) else LoginWebViewClient(authConfig, capturedUserAgent);
|
||||
|
||||
webViewClient.onLogin.subscribe { auth ->
|
||||
_callback?.let {
|
||||
|
||||
@@ -55,6 +55,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptValidationException
|
||||
import com.futo.platformplayer.engine.packages.PackageBridge
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
@@ -156,6 +157,7 @@ open class JSClient : IPlatformClient {
|
||||
_httpClient = JSHttpClient(this, null, _captcha, config);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
||||
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
|
||||
_plugin.bridge.descriptor = descriptor;
|
||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||
_plugin.withDependency(context, "scripts/source.js");
|
||||
|
||||
@@ -189,6 +191,7 @@ open class JSClient : IPlatformClient {
|
||||
_httpClient = JSHttpClient(this, null, _captcha, config);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
||||
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
|
||||
_plugin.bridge.descriptor = descriptor;
|
||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||
_plugin.withDependency(context, "scripts/source.js");
|
||||
_plugin.withScript(script);
|
||||
|
||||
@@ -5,9 +5,9 @@ import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
|
||||
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf(), val userAgent: String? = null) {
|
||||
override fun toString(): String {
|
||||
return "(headers: '$headers', cookieString: '$cookieMap')";
|
||||
return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
|
||||
}
|
||||
|
||||
fun toEncrypted(): String{
|
||||
@@ -15,23 +15,25 @@ data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? =
|
||||
}
|
||||
|
||||
private fun serialize(): String {
|
||||
return Json.encodeToString(SerializedAuth(cookieMap, headers));
|
||||
return Json.encodeToString(SerializedAuth(cookieMap, headers, userAgent));
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "SourceAuth";
|
||||
private val _json = Json { ignoreUnknownKeys = true };
|
||||
|
||||
fun fromEncrypted(encrypted: String?): SourceAuth? {
|
||||
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
|
||||
}
|
||||
|
||||
private fun deserialize(str: String): SourceAuth {
|
||||
val data = Json.decodeFromString<SerializedAuth>(str);
|
||||
return SourceAuth(data.cookieMap, data.headers);
|
||||
val data = _json.decodeFromString<SerializedAuth>(str);
|
||||
return SourceAuth(data.cookieMap, data.headers, data.userAgent);
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SerializedAuth(val cookieMap: HashMap<String, HashMap<String, String>>?,
|
||||
val headers: Map<String, Map<String, String>> = mapOf())
|
||||
val headers: Map<String, Map<String, String>> = mapOf(),
|
||||
val userAgent: String? = null)
|
||||
}
|
||||
+8
-6
@@ -5,9 +5,9 @@ import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
|
||||
data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf(), val userAgent: String? = null) {
|
||||
override fun toString(): String {
|
||||
return "(headers: '$headers', cookieString: '$cookieMap')";
|
||||
return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
|
||||
}
|
||||
|
||||
fun toEncrypted(): String{
|
||||
@@ -15,23 +15,25 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
|
||||
}
|
||||
|
||||
private fun serialize(): String {
|
||||
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers));
|
||||
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers, userAgent));
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "SourceCaptchaData";
|
||||
private val _json = Json { ignoreUnknownKeys = true };
|
||||
|
||||
fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
|
||||
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
|
||||
}
|
||||
|
||||
fun deserialize(str: String): SourceCaptchaData {
|
||||
val data = Json.decodeFromString<SerializedCaptchaData>(str);
|
||||
return SourceCaptchaData(data.cookieMap, data.headers);
|
||||
val data = _json.decodeFromString<SerializedCaptchaData>(str);
|
||||
return SourceCaptchaData(data.cookieMap, data.headers, data.userAgent);
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SerializedCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>?,
|
||||
val headers: Map<String, Map<String, String>> = mapOf())
|
||||
val headers: Map<String, Map<String, String>> = mapOf(),
|
||||
val userAgent: String? = null)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ class V8Plugin {
|
||||
private val _deps : LinkedHashMap<String, String> = LinkedHashMap();
|
||||
private val _depsPackages : MutableList<V8Package> = mutableListOf();
|
||||
private var _script : String? = null;
|
||||
val bridge: PackageBridge;
|
||||
|
||||
var isStopped = true;
|
||||
val onStopped = Event1<V8Plugin>();
|
||||
@@ -114,7 +115,8 @@ class V8Plugin {
|
||||
this._clientAuth = clientAuth;
|
||||
this.config = config;
|
||||
this._script = script;
|
||||
withDependency(PackageBridge(this, config));
|
||||
bridge = PackageBridge(this, config);
|
||||
withDependency(bridge);
|
||||
|
||||
for(pack in config.packages)
|
||||
withDependency(getPackage(pack)!!);
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClientConstants
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
@@ -36,6 +37,9 @@ class PackageBridge : V8Package {
|
||||
private val _client: ManagedHttpClient
|
||||
@Transient
|
||||
private val _clientAuth: ManagedHttpClient
|
||||
// Set by JSClient after construction to provide access to auth/captcha data
|
||||
@Transient
|
||||
var descriptor: SourcePluginDescriptor? = null
|
||||
|
||||
|
||||
override val name: String get() = "Bridge";
|
||||
@@ -80,6 +84,17 @@ class PackageBridge : V8Package {
|
||||
return "android";
|
||||
}
|
||||
|
||||
// User agent captured during captcha/auth WebView flows, matching Desktop's bridge.captchaUserAgent/bridge.authUserAgent.
|
||||
// Plugins use these to make HTTP requests with the same UA that was used in the WebView.
|
||||
@V8Property
|
||||
fun captchaUserAgent(): String? {
|
||||
return descriptor?.getCaptchaData()?.userAgent
|
||||
}
|
||||
@V8Property
|
||||
fun authUserAgent(): String? {
|
||||
return descriptor?.getAuth()?.userAgent
|
||||
}
|
||||
|
||||
@V8Property
|
||||
fun supportedFeatures(): Array<String> {
|
||||
return arrayOf(
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
package com.futo.platformplayer.engine.packages
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.webkit.ConsoleMessage
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.ValueCallback
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.collection.emptyLongSet
|
||||
import androidx.webkit.ScriptHandler
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import com.caoccao.javet.annotations.V8Function
|
||||
import com.caoccao.javet.utils.JavetResourceUtils
|
||||
import com.caoccao.javet.values.reference.V8ValueFunction
|
||||
@@ -24,22 +32,40 @@ import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.nio.charset.Charset
|
||||
import androidx.core.net.toUri
|
||||
|
||||
class PackageBrowser: V8Package {
|
||||
val useAddDocumentStartJavaScript = true
|
||||
|
||||
override val name: String get() = "Browser";
|
||||
override val variableName: String = "browser";
|
||||
|
||||
@Volatile private var _loadToken: String? = null
|
||||
@Volatile private var _expectedMainUrl: String? = null
|
||||
|
||||
private val _json = Json { };
|
||||
|
||||
@Transient
|
||||
private val _pageLoadScriptRefs = ConcurrentHashMap<String, ScriptHandler>()
|
||||
|
||||
@Transient
|
||||
private val _pageLoadScriptsFallback = ConcurrentHashMap<String, String>()
|
||||
|
||||
@Transient
|
||||
private var _readySemaphore: Semaphore? = null;
|
||||
@Transient
|
||||
private val _callbacks = mutableMapOf<String, (String?)->Unit>();
|
||||
@Transient
|
||||
private val _interop = JSInterop(this);
|
||||
@Transient
|
||||
private var _browser: WebView? = null;
|
||||
private val browser: WebView get() {
|
||||
if(_browser == null)
|
||||
@@ -47,54 +73,181 @@ class PackageBrowser: V8Package {
|
||||
return _browser!!;
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var _userAgent: String = ""
|
||||
private val http = OkHttpClient.Builder()
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build()
|
||||
|
||||
constructor(v8Plugin: V8Plugin): super(v8Plugin) {
|
||||
|
||||
}
|
||||
@V8Function
|
||||
fun initialize() {
|
||||
if(_browser == null){
|
||||
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||
_browser = WebView(StateApp.instance.contextOrNull ?: return@launch);
|
||||
_browser?.settings?.javaScriptEnabled = true;
|
||||
_browser?.settings?.blockNetworkImage = false;
|
||||
_browser?.settings?.blockNetworkLoads = false;
|
||||
_browser?.settings?.allowContentAccess = false;
|
||||
_browser?.settings?.allowFileAccess = false;
|
||||
//_browser?.settings?.useWideViewPort = true;
|
||||
//_browser?.settings?.loadWithOverviewMode = true;
|
||||
_browser?.webViewClient = object : WebViewClient() {
|
||||
override fun onPageCommitVisible(view: WebView?, url: String?) {
|
||||
super.onPageCommitVisible(view, url)
|
||||
_readySemaphore?.release();
|
||||
_readySemaphore = null;
|
||||
Logger.i("PackageBrowser", "Browser loaded");
|
||||
if (_browser != null) return
|
||||
|
||||
onMainBlocking {
|
||||
_browser = WebView(StateApp.instance.contextOrNull ?: return@onMainBlocking);
|
||||
_userAgent = _browser?.settings?.userAgentString.orEmpty()
|
||||
_browser?.settings?.javaScriptEnabled = true;
|
||||
_browser?.settings?.blockNetworkImage = false;
|
||||
_browser?.settings?.blockNetworkLoads = false;
|
||||
_browser?.settings?.allowContentAccess = false;
|
||||
_browser?.settings?.allowFileAccess = false;
|
||||
//_browser?.settings?.useWideViewPort = true;
|
||||
//_browser?.settings?.loadWithOverviewMode = true;
|
||||
_browser?.webViewClient = object : WebViewClient() {
|
||||
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
|
||||
if (view == null || request == null) return null
|
||||
|
||||
if (useAddDocumentStartJavaScript && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return null
|
||||
if (!request.isForMainFrame) return null
|
||||
if (!request.method.equals("GET", ignoreCase = true)) return null
|
||||
|
||||
val url = request.url?.toString() ?: return null
|
||||
Log.i("PackageBrowser", "shouldInterceptRequest: " + url)
|
||||
val scheme = request.url?.scheme ?: return null
|
||||
if (scheme != "http" && scheme != "https") return null
|
||||
|
||||
val scripts = _pageLoadScriptsFallback.values.toList()
|
||||
if (scripts.isEmpty()) return null
|
||||
|
||||
return try {
|
||||
val cookie = request.requestHeaders["Cookie"] ?: runCatching { CookieManager.getInstance().getCookie(url) }.getOrNull()
|
||||
val ua = request.requestHeaders["User-Agent"] ?: _userAgent
|
||||
|
||||
val okReq = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header("User-Agent", ua)
|
||||
.apply { if (!cookie.isNullOrEmpty()) header("Cookie", cookie) }
|
||||
.build()
|
||||
|
||||
http.newCall(okReq).execute().use { resp ->
|
||||
val code = resp.code
|
||||
val reason = resp.message.ifBlank { "OK" }
|
||||
if (code in 300..399) return null
|
||||
|
||||
val contentType = resp.header("Content-Type") ?: ""
|
||||
val isHtml =
|
||||
contentType.startsWith("text/html", ignoreCase = true) ||
|
||||
contentType.startsWith("application/xhtml+xml", ignoreCase = true)
|
||||
|
||||
if (!isHtml) return null
|
||||
|
||||
val bodyBytes = resp.body.bytes()
|
||||
val charset = charsetFromContentType(contentType) ?: Charsets.UTF_8
|
||||
val html = bodyBytes.toString(charset)
|
||||
|
||||
val cspHeader = resp.header("Content-Security-Policy")
|
||||
?: resp.header("Content-Security-Policy-Report-Only")
|
||||
|
||||
val nonce = extractNonceFromCsp(cspHeader) ?: extractNonceFromHtml(html)
|
||||
|
||||
val injected = injectIntoHead(html, scripts.joinToString("\n"), nonce)
|
||||
val outBytes = injected.toByteArray(charset)
|
||||
val headers = resp.headers.toMultimap()
|
||||
.mapValues { it.value.joinToString(",") }
|
||||
.toMutableMap()
|
||||
|
||||
headers.remove("Content-Length")
|
||||
val cookieMgr = CookieManager.getInstance()
|
||||
resp.headers.values("Set-Cookie").forEach { sc ->
|
||||
try { cookieMgr.setCookie(url, sc) } catch (_: Throwable) {}
|
||||
}
|
||||
try { cookieMgr.flush() } catch (_: Throwable) {}
|
||||
|
||||
WebResourceResponse("text/html", charset.name(), code, reason, headers, ByteArrayInputStream(outBytes))
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageCommitVisible(view: WebView?, url: String?) {
|
||||
super.onPageCommitVisible(view, url)
|
||||
Logger.i("PackageBrowser", "Browser loaded (commit visible): $url")
|
||||
releaseReadyIfCurrent(url)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
Logger.i("PackageBrowser", "Browser loaded (finished): $url")
|
||||
releaseReadyIfCurrent(url)
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_browser?.webChromeClient = object : WebChromeClient() {
|
||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
||||
val raw = consoleMessage?.message().orEmpty()
|
||||
|
||||
val normalized = raw.trim().let { s ->
|
||||
if (s.length >= 2 && s.first() == '"' && s.last() == '"') {
|
||||
s.substring(1, s.length - 1)
|
||||
} else s
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
return false;
|
||||
if (normalized.startsWith(CONSOLE_BRIDGE_PREFIX)) {
|
||||
val payload = normalized.substring(CONSOLE_BRIDGE_PREFIX.length)
|
||||
if (handleConsoleBridgeMessage(payload)) return true
|
||||
}
|
||||
}
|
||||
_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)
|
||||
|
||||
if (consoleMessage?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
|
||||
val emsg = "Browser Error:${consoleMessage.message()} [${consoleMessage.lineNumber()}]"
|
||||
Logger.e("PackageBrowser", emsg)
|
||||
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
|
||||
StateDeveloper.instance.logDevException(StateDeveloper.instance.currentDevID ?: "", emsg)
|
||||
} else {
|
||||
val imsg = "Browser Log:${consoleMessage?.message()}"
|
||||
Logger.i("PackageBrowser", imsg)
|
||||
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
|
||||
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", imsg)
|
||||
}
|
||||
|
||||
return super.onConsoleMessage(consoleMessage)
|
||||
}
|
||||
_browser?.addJavascriptInterface(_interop, "__GJ");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
val bootstrap = """
|
||||
(() => {
|
||||
try {
|
||||
if (window.__GJ) return;
|
||||
|
||||
const PREFIX = ${CONSOLE_BRIDGE_PREFIX.quoteForJs()};
|
||||
const emit = (obj) => {
|
||||
try {
|
||||
console.info(PREFIX + JSON.stringify(obj));
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
Object.defineProperty(window, "__GJ", {
|
||||
value: {
|
||||
callback: (id, result) => {
|
||||
try {
|
||||
const r = (typeof result === "string")
|
||||
? result
|
||||
: (() => { try { return JSON.stringify(result); } catch (_) { return String(result); } })();
|
||||
emit({ t: "cb", id: String(id), result: r });
|
||||
} catch (_) {}
|
||||
},
|
||||
log: (msg) => {
|
||||
try { emit({ t: "log", msg: String(msg) }); } catch (_) {}
|
||||
}
|
||||
},
|
||||
enumerable: false,
|
||||
configurable: false,
|
||||
writable: false
|
||||
});
|
||||
} catch (_) {}
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
addScriptOnLoad(bootstrap)
|
||||
}
|
||||
@V8Function
|
||||
fun deinitialize() {
|
||||
@@ -137,15 +290,28 @@ class PackageBrowser: V8Package {
|
||||
|
||||
@V8Function
|
||||
fun load(url: String) {
|
||||
Logger.i("PackageBrowser", "Browser loading url [${url}]");
|
||||
_readySemaphore = Semaphore(1, 1);
|
||||
Logger.i("PackageBrowser", "Browser loading url [$url]")
|
||||
val token = UUID.randomUUID().toString()
|
||||
_loadToken = token
|
||||
_expectedMainUrl = url
|
||||
_readySemaphore = Semaphore(1, acquiredPermits = 1)
|
||||
|
||||
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
browser.loadUrl(url);
|
||||
} catch(ex: Throwable) {}
|
||||
try { browser.loadUrl(url) }
|
||||
catch (t: Throwable) { Logger.e("PackageBrowser", "loadUrl failed", t) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseReadyIfCurrent(url: String?) {
|
||||
if (url == null) return
|
||||
val expected = _expectedMainUrl
|
||||
if (url.trimEnd('/') != expected?.trimEnd('/')) return
|
||||
|
||||
_readySemaphore?.release()
|
||||
_readySemaphore = null
|
||||
_expectedMainUrl = null
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun run(js: String, callbackId: String? = null, callback: V8ValueFunction? = null) {
|
||||
waitTillLoaded();
|
||||
@@ -217,23 +383,155 @@ class PackageBrowser: V8Package {
|
||||
}
|
||||
}
|
||||
|
||||
class JSInterop(private val pack: PackageBrowser) {
|
||||
@V8Function
|
||||
fun addScriptOnLoad(js: String): String {
|
||||
require(js.isNotBlank()) { "Script must be non-empty." }
|
||||
|
||||
@JavascriptInterface
|
||||
fun callback(id: String, result: String) {
|
||||
Logger.i("PackageBrowser", "Browser Callback [${id}]: ${result}");
|
||||
val callback = synchronized(pack._callbacks) { pack._callbacks.remove(id); };
|
||||
if(callback != null) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
callback.invoke(result);
|
||||
}
|
||||
val id = UUID.randomUUID().toString()
|
||||
onMainBlocking {
|
||||
if (useAddDocumentStartJavaScript && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
|
||||
val ref = WebViewCompat.addDocumentStartJavaScript(browser, js, setOf("*"))
|
||||
_pageLoadScriptRefs[id] = ref
|
||||
} else {
|
||||
_pageLoadScriptsFallback[id] = js
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun log(msg: String) {
|
||||
Logger.i("PackageBrowser", "Log: " + msg);
|
||||
Logger.i("PackageBrowser", "addScriptOnLoad() registered (id=$id)")
|
||||
return id
|
||||
}
|
||||
|
||||
@SuppressLint("RequiresFeature")
|
||||
@V8Function
|
||||
fun removeScriptOnLoad(identifier: String): Boolean {
|
||||
if (identifier.isBlank()) return false
|
||||
|
||||
val ref = _pageLoadScriptRefs.remove(identifier)
|
||||
val removedFallback = _pageLoadScriptsFallback.remove(identifier) != null
|
||||
|
||||
if (ref != null) {
|
||||
onMainBlocking {
|
||||
try { ref.remove() } catch (_: Throwable) {}
|
||||
}
|
||||
Logger.i("PackageBrowser", "removeScriptOnLoad() removed (id=$identifier)")
|
||||
return true
|
||||
}
|
||||
|
||||
if (removedFallback) {
|
||||
Logger.i("PackageBrowser", "removeScriptOnLoad() removed fallback (id=$identifier)")
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@SuppressLint("RequiresFeature")
|
||||
@V8Function
|
||||
fun clearScriptsOnLoad() {
|
||||
val refs = _pageLoadScriptRefs.values.toList()
|
||||
_pageLoadScriptRefs.clear()
|
||||
_pageLoadScriptsFallback.clear()
|
||||
|
||||
onMainBlocking {
|
||||
for (r in refs) {
|
||||
try { r.remove() } catch (_: Throwable) {}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i("PackageBrowser", "clearScriptsOnLoad() cleared")
|
||||
}
|
||||
|
||||
private fun charsetFromContentType(ct: String): Charset? {
|
||||
val m = Regex("(?i)charset=([\\w\\-]+)").find(ct) ?: return null
|
||||
val name = m.groupValues.getOrNull(1)?.trim().orEmpty()
|
||||
return runCatching { Charset.forName(name) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun injectIntoHead(html: String, js: String, nonce: String?): String {
|
||||
val nonceAttr = nonce?.let { " nonce=\"${escapeHtmlAttr(it)}\"" } ?: ""
|
||||
val tag = "<script$nonceAttr>\n$js\n</script>\n"
|
||||
|
||||
val head = Regex("(?i)<head[^>]*>").find(html)
|
||||
if (head != null) {
|
||||
val i = head.range.last + 1
|
||||
return buildString(html.length + tag.length + 8) {
|
||||
append(html, 0, i)
|
||||
append('\n')
|
||||
append(tag)
|
||||
append(html, i, html.length)
|
||||
}
|
||||
}
|
||||
return tag + html
|
||||
}
|
||||
|
||||
private fun <T> onMainBlocking(block: () -> T): T {
|
||||
return if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
block()
|
||||
} else runBlocking {
|
||||
withContext(Dispatchers.Main) { block() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractNonceFromCsp(csp: String?): String? {
|
||||
if (csp.isNullOrBlank()) return null
|
||||
val m = Regex("(?i)'nonce-([^'\\s;]+)'").find(csp) ?: return null
|
||||
return m.groupValues[1]
|
||||
}
|
||||
|
||||
private fun extractNonceFromHtml(html: String): String? {
|
||||
val m = Regex("(?i)<script[^>]*\\snonce\\s*=\\s*['\"]([^'\"]+)['\"][^>]*>").find(html)
|
||||
return m?.groupValues?.get(1)
|
||||
}
|
||||
|
||||
private fun escapeHtmlAttr(s: String): String =
|
||||
s.replace("&", "&").replace("\"", """)
|
||||
|
||||
@Serializable
|
||||
private data class ConsoleBridgeMsg(
|
||||
val t: String,
|
||||
val id: String? = null,
|
||||
val result: String? = null,
|
||||
val msg: String? = null
|
||||
)
|
||||
|
||||
|
||||
private fun handleConsoleBridgeMessage(payload: String): Boolean {
|
||||
Logger.i("PackageBrowser", "handleConsoleBridgeMessage: " + payload)
|
||||
|
||||
val parsed = runCatching { _json.decodeFromString<ConsoleBridgeMsg>(payload) }.getOrNull()
|
||||
?: return false
|
||||
|
||||
when (parsed.t) {
|
||||
"cb" -> {
|
||||
val id = parsed.id ?: return true
|
||||
val res = parsed.result
|
||||
|
||||
val cb = synchronized(_callbacks) { _callbacks.remove(id) } ?: return true
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { cb.invoke(res) }
|
||||
return true
|
||||
}
|
||||
"log" -> {
|
||||
val text = parsed.msg.orEmpty()
|
||||
Logger.i("PackageBrowser", "Browser Log: $text")
|
||||
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID) {
|
||||
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", text)
|
||||
}
|
||||
return true
|
||||
}
|
||||
else -> return true
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val CONSOLE_BRIDGE_PREFIX = "__GJ__:"
|
||||
|
||||
private fun String.quoteForJs(): String {
|
||||
val s = this
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
return "\"$s\""
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-14
@@ -1,8 +1,6 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -16,6 +14,8 @@ import com.futo.futopay.formatMoney
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StatePayment
|
||||
import com.futo.platformplayer.views.overlays.LoaderOverlay
|
||||
@@ -68,8 +68,11 @@ class BuyFragment : MainFragment() {
|
||||
|
||||
_paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, _, exception ->
|
||||
if(success) {
|
||||
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0, UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY));
|
||||
_fragment.close(true);
|
||||
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0,
|
||||
UIDialogs.Action("Ok", {
|
||||
(fragment.activity as? MainActivity)?.navigate<SettingsFragment>(withHistory = false);
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
}
|
||||
else {
|
||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.payment_failed), exception);
|
||||
@@ -89,16 +92,19 @@ class BuyFragment : MainFragment() {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
//Calling this function will cache first call
|
||||
try {
|
||||
val currencies = StatePayment.instance.getAvailableCurrencies("grayjay");
|
||||
val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay");
|
||||
val country = StatePayment.instance.getPaymentCountryFromIP(true)?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
|
||||
val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } };
|
||||
// TODO: Restore multi-currency support when payment backend supports it
|
||||
// val currencies = StatePayment.instance.getAvailableCurrencies("grayjay");
|
||||
// val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay");
|
||||
// val country = StatePayment.instance.getPaymentCountryFromIP(true)?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
|
||||
// val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } };
|
||||
// if(currency != null && prices.containsKey(currency.id)) {
|
||||
// val price = prices[currency.id]!!;
|
||||
// _buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax);
|
||||
// }
|
||||
|
||||
if(currency != null && prices.containsKey(currency.id)) {
|
||||
val price = prices[currency.id]!!;
|
||||
withContext(Dispatchers.Main) {
|
||||
_buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax);
|
||||
}
|
||||
val priceCents = StatePayment.instance.getPolarProductPrice(PaymentConfigurations.PolarConfig.PRODUCT_SLUG)
|
||||
withContext(Dispatchers.Main) {
|
||||
_buttonBuyText.text = formatMoney("US", "usd", priceCents) + context.getString(R.string.plus_tax)
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
@@ -165,4 +171,4 @@ class BuyFragment : MainFragment() {
|
||||
fun newInstance() = BuyFragment().apply {}
|
||||
private val TAG = "BuyFragment"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -96,11 +96,15 @@ class LoginFragment : MainFragment() {
|
||||
else throw IllegalStateException("No valid configuration?");
|
||||
//TODO: Backwards compat removal?
|
||||
|
||||
_webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
|
||||
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
|
||||
if (authConfig.userAgent != null)
|
||||
_webView.settings.userAgentString = authConfig.userAgent;
|
||||
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
|
||||
val capturedUserAgent = _webView.settings.userAgentString;
|
||||
_webView.settings.useWideViewPort = true;
|
||||
_webView.settings.loadWithOverviewMode = true;
|
||||
|
||||
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig);
|
||||
val webViewClient = if(config != null) LoginWebViewClient(config, capturedUserAgent) else LoginWebViewClient(authConfig, capturedUserAgent);
|
||||
|
||||
webViewClient.onLogin.subscribe { auth ->
|
||||
_callback?.let {
|
||||
|
||||
+2
-2
@@ -309,7 +309,7 @@ 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.other.shouldClearWebviewCookies())
|
||||
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 {
|
||||
@@ -520,7 +520,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
Logger.e(TAG, "Failed to set plugin authentication (loginSource, loginWarning)", e)
|
||||
}
|
||||
finally {
|
||||
if(Settings.instance.other.shouldClearWebviewCookies()) {
|
||||
if(Settings.instance.plugins.shouldClearWebviewCookies()) {
|
||||
try {
|
||||
val cookieManager: CookieManager =
|
||||
CookieManager.getInstance();
|
||||
|
||||
+33
-13
@@ -216,6 +216,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
private val _playerProgress: PlayerControlView;
|
||||
private val _timeBar: TimeBar;
|
||||
private var _upNext: UpNextView;
|
||||
private var _artworkTarget: CustomTarget<Bitmap>? = null
|
||||
|
||||
private val rootView: ConstraintLayout;
|
||||
|
||||
@@ -882,6 +883,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
};
|
||||
|
||||
onClose.subscribe {
|
||||
_artworkTarget?.let { Glide.with(context).clear(it) }
|
||||
_artworkTarget = null
|
||||
_player.setArtwork(null)
|
||||
checkAndRemoveWatchLater();
|
||||
_lastVideoSource = null;
|
||||
_lastAudioSource = null;
|
||||
@@ -1195,6 +1199,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
else if(_didStop) {
|
||||
_didStop = false;
|
||||
Logger.i(TAG, "loadCurrentVideo _lastPosition=${lastPositionMilliseconds}");
|
||||
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||
loadCurrentVideo(lastPositionMilliseconds);
|
||||
handlePause();
|
||||
}
|
||||
@@ -1264,6 +1269,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
fun onDestroy() {
|
||||
Logger.i(TAG, "onDestroy");
|
||||
_destroyed = true;
|
||||
_artworkTarget?.let { Glide.with(context).clear(it) }
|
||||
_artworkTarget = null
|
||||
_player.setArtwork(null)
|
||||
_taskLoadVideo.cancel();
|
||||
_commentsList.cancel();
|
||||
_player.clear();
|
||||
@@ -2053,19 +2061,31 @@ class VideoDetailView : ConstraintLayout {
|
||||
_player.switchToVideoMode()
|
||||
isAudioOnlyUserAction = false;
|
||||
} else {
|
||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
||||
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
|
||||
Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
|
||||
.into(object: CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
_player.setArtwork(BitmapDrawable(resources, resource));
|
||||
}
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
_player.setArtwork(null);
|
||||
}
|
||||
});
|
||||
else
|
||||
_player.setArtwork(null);
|
||||
_artworkTarget?.let { Glide.with(context).clear(it) }
|
||||
_artworkTarget = null
|
||||
|
||||
val thumbnail = video.thumbnails.getHQThumbnail()
|
||||
val showArtwork = _player.isAudioMode || isAudioOnlyUserAction || (videoSource == null)
|
||||
|
||||
if (showArtwork && !thumbnail.isNullOrBlank()) {
|
||||
val target = object : CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
_player.setArtwork(BitmapDrawable(resources, resource))
|
||||
}
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
_player.setArtwork(null)
|
||||
}
|
||||
}
|
||||
_artworkTarget = target
|
||||
|
||||
Glide.with(context)
|
||||
.asBitmap()
|
||||
.load(thumbnail)
|
||||
.withMaxSizePx()
|
||||
.into(target)
|
||||
} else {
|
||||
_player.setArtwork(null)
|
||||
}
|
||||
}
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
|
||||
+4
-1
@@ -90,7 +90,10 @@ class GeneralTopBarFragment : TopFragment() {
|
||||
updateNotifCount();
|
||||
|
||||
_buttonNotifs?.setOnClickListener {
|
||||
navigate<NotificationOverlayView.Frag>();
|
||||
if(currentMain is NotificationOverlayView.Frag)
|
||||
closeSegment();
|
||||
else
|
||||
navigate<NotificationOverlayView.Frag>();
|
||||
}
|
||||
|
||||
buttonSearch.setOnClickListener {
|
||||
|
||||
@@ -16,13 +16,15 @@ class CaptchaWebViewClient : WebViewClient {
|
||||
|
||||
private val _pluginConfig: SourcePluginConfig?;
|
||||
private val _captchaConfig: SourcePluginCaptchaConfig;
|
||||
private val _userAgent: String?;
|
||||
|
||||
private var _didNotify = false;
|
||||
private val _extractor: WebViewRequirementExtractor;
|
||||
|
||||
constructor(config: SourcePluginConfig) : super() {
|
||||
constructor(config: SourcePluginConfig, userAgent: String? = null) : super() {
|
||||
_pluginConfig = config;
|
||||
_captchaConfig = config.captcha!!;
|
||||
_userAgent = userAgent;
|
||||
_extractor = WebViewRequirementExtractor(
|
||||
config.allowUrls,
|
||||
null,
|
||||
@@ -34,9 +36,10 @@ class CaptchaWebViewClient : WebViewClient {
|
||||
Logger.i(TAG, "Captcha [${config.name}]" +
|
||||
"\nRequired Cookies: ${Serializer.json.encodeToString(config.captcha!!.cookiesToFind)}",);
|
||||
}
|
||||
constructor(captcha: SourcePluginCaptchaConfig) : super() {
|
||||
constructor(captcha: SourcePluginCaptchaConfig, userAgent: String? = null) : super() {
|
||||
_pluginConfig = null;
|
||||
_captchaConfig = captcha;
|
||||
_userAgent = userAgent;
|
||||
_extractor = WebViewRequirementExtractor(
|
||||
null,
|
||||
null,
|
||||
@@ -62,7 +65,8 @@ class CaptchaWebViewClient : WebViewClient {
|
||||
_didNotify = true;
|
||||
onCaptchaFinished.emit(SourceCaptchaData(
|
||||
extracted.cookies,
|
||||
extracted.headers
|
||||
extracted.headers,
|
||||
_userAgent
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -24,23 +24,26 @@ class LoginWebViewClient : WebViewClient {
|
||||
|
||||
private val _pluginConfig: SourcePluginConfig?;
|
||||
private val _authConfig: SourcePluginAuthConfig;
|
||||
private val _userAgent: String?;
|
||||
|
||||
private val _client = ManagedHttpClient();
|
||||
|
||||
val onLogin = Event1<SourceAuth>();
|
||||
val onPageLoaded = Event2<WebView?, String?>()
|
||||
|
||||
constructor(config: SourcePluginConfig) : super() {
|
||||
constructor(config: SourcePluginConfig, userAgent: String? = null) : super() {
|
||||
_pluginConfig = config;
|
||||
_authConfig = config.authentication!!;
|
||||
_userAgent = userAgent;
|
||||
Logger.i(TAG, "Login [${config.name}]" +
|
||||
"\nRequired Headers: ${config.authentication.headersToFind?.joinToString(", ")}" +
|
||||
"\nRequired Domain Headers: ${Serializer.json.encodeToString(config.authentication.domainHeadersToFind)}" +
|
||||
"\nRequired Cookies: ${Serializer.json.encodeToString(config.authentication.cookiesToFind)}",);
|
||||
}
|
||||
constructor(auth: SourcePluginAuthConfig) : super() {
|
||||
constructor(auth: SourcePluginAuthConfig, userAgent: String? = null) : super() {
|
||||
_pluginConfig = null;
|
||||
_authConfig = auth;
|
||||
_userAgent = userAgent;
|
||||
}
|
||||
|
||||
private val headersFoundMap: HashMap<String, HashMap<String, String>> = hashMapOf();
|
||||
@@ -192,13 +195,14 @@ class LoginWebViewClient : WebViewClient {
|
||||
if (urlFound && headersFound && domainHeadersFound && cookiesFound) {
|
||||
onLogin.emit(SourceAuth(
|
||||
cookieMap = cookiesFoundMap,
|
||||
headers = headersFoundMap /*.associate { headerToFind ->
|
||||
headers = headersFoundMap, /*.associate { headerToFind ->
|
||||
headerToFind to headersFoundMap.firstNotNullOf { requestHeader ->
|
||||
if (requestHeader.key.equals(headerToFind, ignoreCase = true))
|
||||
requestHeader.value
|
||||
else null;
|
||||
}
|
||||
} ?: mapOf()*/
|
||||
userAgent = _userAgent
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -456,26 +456,19 @@ class MediaPlaybackService : Service() {
|
||||
|
||||
val audioFocusLossDuration = _audioFocusLossTime_ms?.let { System.currentTimeMillis() - it }
|
||||
_audioFocusLossTime_ms = null
|
||||
|
||||
Log.i(TAG, "Audio focus gained (restartPlaybackAfterLoss = ${Settings.instance.playback.restartPlaybackAfterLoss}, _audioFocusLossTime_ms = $_audioFocusLossTime_ms, audioFocusLossDuration = ${audioFocusLossDuration})");
|
||||
|
||||
if (Settings.instance.playback.restartPlaybackAfterLoss == 1) {
|
||||
if (audioFocusLossDuration != null && audioFocusLossDuration < 1000 * 10) {
|
||||
MediaControlReceiver.onPlayReceived.emit()
|
||||
}
|
||||
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 2) {
|
||||
if (audioFocusLossDuration != null && audioFocusLossDuration < 1000 * 30) {
|
||||
MediaControlReceiver.onPlayReceived.emit()
|
||||
}
|
||||
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 3) {
|
||||
MediaControlReceiver.onPlayReceived.emit()
|
||||
if (audioFocusLossDuration == null) return@OnAudioFocusChangeListener
|
||||
when (Settings.instance.playback.restartPlaybackAfterLoss) {
|
||||
1 -> if (audioFocusLossDuration < 10_000) MediaControlReceiver.onPlayReceived.emit()
|
||||
2 -> if (audioFocusLossDuration < 30_000) MediaControlReceiver.onPlayReceived.emit()
|
||||
3 -> MediaControlReceiver.onPlayReceived.emit()
|
||||
}
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
_audioFocusLossTime_ms = if (isPlaying) {
|
||||
System.currentTimeMillis()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val wasPlaying = isPlaying
|
||||
_audioFocusLossTime_ms = if (wasPlaying) System.currentTimeMillis() else null
|
||||
|
||||
_hasFocus = false;
|
||||
_isTransientLoss = true;
|
||||
@@ -488,11 +481,8 @@ class MediaPlaybackService : Service() {
|
||||
_isTransientLoss = true;
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
_audioFocusLossTime_ms = if (isPlaying) {
|
||||
System.currentTimeMillis()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val wasPlaying = isPlaying
|
||||
_audioFocusLossTime_ms = if (wasPlaying) System.currentTimeMillis() else null
|
||||
|
||||
MediaControlReceiver.onPauseReceived.emit();
|
||||
abandonAudioFocus();
|
||||
|
||||
@@ -137,7 +137,7 @@ class StateAnnouncement {
|
||||
registerAnnouncementSession(SessionAnnouncement(
|
||||
"updated-plugin-" + UUID.randomUUID().toString(),
|
||||
"${newConfig.name} updated to v${newConfig.version}!",
|
||||
"You have succesfully been updater to v${newConfig.version}.",
|
||||
"You have succesfully been updated to v${newConfig.version}.",
|
||||
AnnouncementType.SESSION,
|
||||
null, "updates", null, null,
|
||||
null, null,null,
|
||||
|
||||
@@ -13,6 +13,8 @@ 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
|
||||
@@ -308,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)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,8 +447,9 @@ class StateApp {
|
||||
_cacheDirectory?.let { ApiMethods.initCache(it) };
|
||||
}
|
||||
|
||||
if(Settings.instance.other.shouldClearWebviewCookies()) {
|
||||
if(Settings.instance.plugins.shouldClearWebviewCookies()) {
|
||||
try {
|
||||
Logger.i(TAG, "Clearing cookies on startup");
|
||||
val cookieManager: CookieManager =
|
||||
CookieManager.getInstance();
|
||||
cookieManager.removeAllCookies(null);
|
||||
@@ -671,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");
|
||||
@@ -690,7 +687,6 @@ class StateApp {
|
||||
Settings.instance.backup.didAskAutoBackup = true;
|
||||
Settings.instance.save();
|
||||
});
|
||||
*/
|
||||
}
|
||||
else if(Settings.instance.backup.didAskAutoBackup && Settings.instance.backup.shouldAutomaticBackup() && !Settings.instance.storage.isStorageMainValid(context)) {
|
||||
if(context is IWithResultLauncher) {
|
||||
|
||||
@@ -49,6 +49,33 @@ class StateBackup {
|
||||
|
||||
private val _autoBackupLock = Object();
|
||||
|
||||
private val AUTO_MAGIC = byteArrayOf(
|
||||
0x11.toByte(), 0x22.toByte(), 0x33.toByte(), 0x44.toByte()
|
||||
)
|
||||
|
||||
private val AUTO_MARKER = byteArrayOf(
|
||||
'G'.code.toByte(), 'J'.code.toByte()
|
||||
)
|
||||
|
||||
private const val AUTO_FORMAT_VERSION: Byte = 1
|
||||
private const val FLAG_ENCRYPTED: Byte = 0x01
|
||||
|
||||
private fun ByteArray.startsWithZipSignature(): Boolean =
|
||||
this.size >= 2 && this[0] == 0x50.toByte() && this[1] == 0x4B.toByte()
|
||||
|
||||
private fun ByteArray.hasAutoMagic(): Boolean =
|
||||
this.size >= 4 &&
|
||||
this[0] == AUTO_MAGIC[0] &&
|
||||
this[1] == AUTO_MAGIC[1] &&
|
||||
this[2] == AUTO_MAGIC[2] &&
|
||||
this[3] == AUTO_MAGIC[3]
|
||||
|
||||
private fun ByteArray.hasNewAutoHeader(): Boolean =
|
||||
this.size >= 8 &&
|
||||
this.hasAutoMagic() &&
|
||||
this[4] == AUTO_MARKER[0] &&
|
||||
this[5] == AUTO_MARKER[1]
|
||||
|
||||
private fun getAutomaticBackupDocumentFiles(context: Context, create: Boolean = false): Pair<DocumentFile?, DocumentFile?> {
|
||||
if(!Settings.instance.storage.isStorageMainValid(context))
|
||||
return Pair(null, null);
|
||||
@@ -76,14 +103,13 @@ class StateBackup {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
private fun getAutomaticBackupPassword(customPassword: String? = null): String {
|
||||
val password = customPassword ?: Settings.instance.backup.autoBackupPassword ?: "";
|
||||
val pbytes = password.toByteArray();
|
||||
if(pbytes.size < 4 || pbytes.size > 32)
|
||||
throw IllegalStateException("Automatic backup passwords should atleast be 4 character and smaller than 32");
|
||||
return password;
|
||||
private fun requireLegacyBackupPassword(password: String): String {
|
||||
val pbytes = password.toByteArray()
|
||||
if (pbytes.size < 4 || pbytes.size > 32)
|
||||
throw IllegalStateException("Password must be at least 4 bytes and smaller than 32 bytes")
|
||||
return password
|
||||
}
|
||||
|
||||
fun hasAutomaticBackup(): Boolean {
|
||||
val context = StateApp.instance.contextOrNull ?: return false;
|
||||
if(!Settings.instance.storage.isStorageMainValid(context))
|
||||
@@ -106,8 +132,6 @@ class StateBackup {
|
||||
val data = export();
|
||||
val zip = data.asZip();
|
||||
|
||||
//Prepend some magic bytes to identify everything version 1 and up
|
||||
val encryptedZip = byteArrayOf(0x11, 0x22, 0x33, 0x44, GPasswordEncryptionProvider.version.toByte()) + GPasswordEncryptionProvider.instance.encrypt(zip, getAutomaticBackupPassword());
|
||||
if(!Settings.instance.storage.isStorageMainValid(context)) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
UIDialogs.toast("Missing permissions for auto-backup, please set the external general directory in settings");
|
||||
@@ -118,7 +142,8 @@ class StateBackup {
|
||||
val exportFile = backupFiles.first;
|
||||
if (exportFile?.exists() == true && backupFiles.second != null)
|
||||
exportFile.copyTo(context, backupFiles.second!!);
|
||||
exportFile!!.writeBytes(context, encryptedZip);
|
||||
val backupBytes = AUTO_MAGIC + AUTO_MARKER + byteArrayOf(AUTO_FORMAT_VERSION, 0x00.toByte()) + zip
|
||||
exportFile!!.writeBytes(context, backupBytes)
|
||||
|
||||
Settings.instance.backup.lastAutoBackupTime = OffsetDateTime.now(); //OffsetDateTime.now();
|
||||
Settings.instance.save();
|
||||
@@ -137,69 +162,105 @@ class StateBackup {
|
||||
|
||||
//TODO: This goes has recently changed to use DocumentFiles and DocumentTree, and might need additional checks/edgecases covered.
|
||||
fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false) {
|
||||
if(ifExists && !hasAutomaticBackup()) {
|
||||
Logger.i(TAG, "No AutoBackup exists, not restoring");
|
||||
return;
|
||||
if (ifExists && !hasAutomaticBackup()) {
|
||||
Logger.i(TAG, "No AutoBackup exists, not restoring")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Starting AutoBackup restore");
|
||||
synchronized(_autoBackupLock) {
|
||||
Logger.i(TAG, "Starting AutoBackup restore")
|
||||
var permissionRequest: Pair<IWithResultLauncher, android.net.Uri?>? = null
|
||||
val backupBytesEncrypted: ByteArray? = synchronized(_autoBackupLock) {
|
||||
val backupFiles = getAutomaticBackupDocumentFiles(StateApp.instance.context, false)
|
||||
|
||||
val backupFiles = getAutomaticBackupDocumentFiles(StateApp.instance.context, false);
|
||||
fun read(doc: DocumentFile?): ByteArray? = doc?.readBytes(context)
|
||||
try {
|
||||
if (backupFiles.first?.exists() != true)
|
||||
throw IllegalStateException("Backup file does not exist");
|
||||
throw IllegalStateException("Backup file does not exist")
|
||||
|
||||
val backupBytesEncrypted = backupFiles.first!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.first?.uri}]");
|
||||
importEncryptedZipBytes(context, scope, backupBytesEncrypted, password);
|
||||
Logger.i(TAG, "Finished AutoBackup restore");
|
||||
}
|
||||
catch (exSec: FileNotFoundException) {
|
||||
Logger.e(TAG, "Failed to access backup file", exSec);
|
||||
val activity = if(StateApp.instance.activity != null)
|
||||
StateApp.instance.activity
|
||||
else if(StateApp.instance.isMainActive)
|
||||
StateApp.instance.contextOrNull;
|
||||
else null;
|
||||
if(activity != null) {
|
||||
if(activity is IWithResultLauncher)
|
||||
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", "Allows restoring of a backup", backupFiles.first?.parentFile?.uri) {
|
||||
if(it != null) {
|
||||
val customFiles = StateBackup.getAutomaticBackupDocumentFiles(activity);
|
||||
if(customFiles.first != null && customFiles.first!!.isFile && customFiles.first!!.exists() && customFiles.first!!.canRead())
|
||||
restoreAutomaticBackup(context, scope, password, ifExists);
|
||||
}
|
||||
};
|
||||
read(backupFiles.first) ?: throw IllegalStateException("Could not read stream of [${backupFiles.first?.uri}]")
|
||||
} catch (ex: Throwable) {
|
||||
if (ex is FileNotFoundException || ex is SecurityException) {
|
||||
val activity = (StateApp.instance.activity as? IWithResultLauncher)
|
||||
?: (if (StateApp.instance.isMainActive) StateApp.instance.contextOrNull as? IWithResultLauncher else null)
|
||||
|
||||
if (activity != null) {
|
||||
permissionRequest = Pair(activity, backupFiles.first?.parentFile?.uri)
|
||||
return@synchronized null
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, try the .old file
|
||||
if (backupFiles.second?.exists() == true) {
|
||||
read(backupFiles.second) ?: throw IllegalStateException("Could not read stream of [${backupFiles.second?.uri}]")
|
||||
} else {
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Failed main AutoBackup restore", ex)
|
||||
if (backupFiles.second?.exists() != true)
|
||||
throw ex;
|
||||
|
||||
val backupBytesEncrypted = backupFiles.second!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.second?.uri}]");
|
||||
importEncryptedZipBytes(context, scope, backupBytesEncrypted, password);
|
||||
Logger.i(TAG, "Finished AutoBackup restore");
|
||||
}
|
||||
}
|
||||
|
||||
if (backupBytesEncrypted == null && permissionRequest != null) {
|
||||
val (activity, initialUri) = permissionRequest
|
||||
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", "Allows restoring of a backup", initialUri) { uri ->
|
||||
if (uri != null) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
restoreAutomaticBackup(context, scope, password, ifExists)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
importEncryptedZipBytes(context, scope, backupBytesEncrypted!!, password)
|
||||
Logger.i(TAG, "Finished AutoBackup restore")
|
||||
}
|
||||
|
||||
private fun importEncryptedZipBytes(context: Context, scope: CoroutineScope, backupBytesEncrypted: ByteArray, password: String) {
|
||||
val backupBytes: ByteArray;
|
||||
//Check magic bytes indicating version 1 and up
|
||||
if (backupBytesEncrypted[0] == 0x11.toByte() && backupBytesEncrypted[1] == 0x22.toByte() && backupBytesEncrypted[2] == 0x33.toByte() && backupBytesEncrypted[3] == 0x44.toByte()) {
|
||||
val version = backupBytesEncrypted[4].toInt();
|
||||
if (version != GPasswordEncryptionProvider.version) {
|
||||
throw Exception("Invalid encryption version");
|
||||
}
|
||||
|
||||
backupBytes = GPasswordEncryptionProvider.instance.decrypt(backupBytesEncrypted.sliceArray(IntRange(5, backupBytesEncrypted.size - 1)), getAutomaticBackupPassword(password))
|
||||
} else {
|
||||
//Else its a version 0
|
||||
backupBytes = GPasswordEncryptionProviderV0(getAutomaticBackupPassword(password).padStart(32, '9')).decrypt(backupBytesEncrypted);
|
||||
if (backupBytesEncrypted.startsWithZipSignature()) {
|
||||
importZipBytes(context, scope, backupBytesEncrypted)
|
||||
return
|
||||
}
|
||||
|
||||
importZipBytes(context, scope, backupBytes);
|
||||
// New unencrypted header (magic + "GJ" + format + flags)
|
||||
if (backupBytesEncrypted.hasNewAutoHeader()) {
|
||||
val formatVersion = backupBytesEncrypted[6].toInt()
|
||||
val flags = backupBytesEncrypted[7].toInt()
|
||||
var offset = 8
|
||||
|
||||
if (formatVersion != AUTO_FORMAT_VERSION.toInt()) {
|
||||
throw IllegalStateException("Unsupported backup format version: $formatVersion")
|
||||
}
|
||||
|
||||
val isEncrypted = (flags and FLAG_ENCRYPTED.toInt()) != 0
|
||||
if (!isEncrypted) {
|
||||
val zipBytes = backupBytesEncrypted.copyOfRange(offset, backupBytesEncrypted.size)
|
||||
importZipBytes(context, scope, zipBytes)
|
||||
return
|
||||
}
|
||||
|
||||
throw IllegalStateException("Encrypted backups with new header are not supported")
|
||||
}
|
||||
|
||||
// Old encrypted v1+ header (magic + providerVersion + ciphertext)
|
||||
if (backupBytesEncrypted.hasAutoMagic()) {
|
||||
if (backupBytesEncrypted.size < 6) {
|
||||
throw IllegalStateException("Invalid backup: too small")
|
||||
}
|
||||
|
||||
val version = backupBytesEncrypted[4].toInt()
|
||||
if (version != GPasswordEncryptionProvider.version) {
|
||||
throw Exception("Invalid encryption version")
|
||||
}
|
||||
|
||||
val ciphertext = backupBytesEncrypted.copyOfRange(5, backupBytesEncrypted.size)
|
||||
val plaintextZip = GPasswordEncryptionProvider.instance.decrypt(ciphertext, requireLegacyBackupPassword(password))
|
||||
|
||||
importZipBytes(context, scope, plaintextZip)
|
||||
return
|
||||
}
|
||||
|
||||
// Old encrypted v0 (no magic)
|
||||
val plaintextZip = GPasswordEncryptionProviderV0(requireLegacyBackupPassword(password).padStart(32, '9')).decrypt(backupBytesEncrypted)
|
||||
importZipBytes(context, scope, plaintextZip)
|
||||
}
|
||||
|
||||
fun saveExternalBackup(activity: IWithResultLauncher) {
|
||||
@@ -234,6 +295,47 @@ class StateBackup {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun requiresPasswordForAutomaticBackup(context: Context): Boolean = withContext(Dispatchers.IO) {
|
||||
val files = getAutomaticBackupDocumentFiles(context, create = false)
|
||||
|
||||
// Prefer main, fallback to .old
|
||||
val doc = when {
|
||||
files.first?.exists() == true -> files.first
|
||||
files.second?.exists() == true -> files.second
|
||||
else -> return@withContext true // if nothing exists, keep old behavior
|
||||
} ?: return@withContext true
|
||||
|
||||
val header = try {
|
||||
context.contentResolver.openInputStream(doc.uri)?.use { input ->
|
||||
val buf = ByteArray(16)
|
||||
val n = input.read(buf)
|
||||
if (n <= 0) ByteArray(0) else buf.copyOf(n)
|
||||
} ?: return@withContext true
|
||||
} catch (_: Throwable) {
|
||||
return@withContext true
|
||||
}
|
||||
|
||||
// Raw zip ("PK") => not encrypted
|
||||
if (header.size >= 2 && header[0] == 0x50.toByte() && header[1] == 0x4B.toByte()) {
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
// New unencrypted header (magic + "GJ" + format + flags)
|
||||
if (header.size >= 8 && header[0] == AUTO_MAGIC[0] && header[1] == AUTO_MAGIC[1] && header[2] == AUTO_MAGIC[2] && header[3] == AUTO_MAGIC[3] && header[4] == AUTO_MARKER[0] && header[5] == AUTO_MARKER[1]) {
|
||||
val flags = header[7].toInt()
|
||||
val isEncrypted = (flags and FLAG_ENCRYPTED.toInt()) != 0
|
||||
return@withContext isEncrypted
|
||||
}
|
||||
|
||||
// Old encrypted v1+ header (magic + providerVersion + ciphertext) => needs password
|
||||
if (header.size >= 5 && header[0] == AUTO_MAGIC[0] && header[1] == AUTO_MAGIC[1] && header[2] == AUTO_MAGIC[2] && header[3] == AUTO_MAGIC[3]) {
|
||||
return@withContext true
|
||||
}
|
||||
|
||||
// Otherwise assume legacy v0 encrypted (no magic) => needs password
|
||||
return@withContext true
|
||||
}
|
||||
|
||||
fun export(): ExportStructure {
|
||||
val exportInfo = mapOf(
|
||||
Pair("version", "1")
|
||||
@@ -303,186 +405,172 @@ class StateBackup {
|
||||
var doEnablePlugins = false;
|
||||
var doImportStores = false;
|
||||
Logger.i(TAG, "Starting import choices");
|
||||
UIDialogs.multiShowDialog(context, {
|
||||
Logger.i(TAG, "Starting import");
|
||||
if(!doImport)
|
||||
return@multiShowDialog;
|
||||
val enabledBefore = StatePlatform.instance.getEnabledClients().map { it.id };
|
||||
|
||||
val onConclusion = {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
UIDialogs.multiShowDialog(context, {
|
||||
Logger.i(TAG, "Starting import");
|
||||
if (!doImport)
|
||||
return@multiShowDialog;
|
||||
val enabledBefore = StatePlatform.instance.getEnabledClients().map { it.id };
|
||||
|
||||
val onConclusion = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
StatePlatform.instance.selectClients(*enabledBefore.toTypedArray());
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.showDialog(context, R.drawable.ic_update_success_251dp, "Import has finished", null, null,0, UIDialogs.Action("Ok", {}));
|
||||
}
|
||||
}
|
||||
};
|
||||
//TODO: Probably restructure this to be less nested
|
||||
scope.launch(Dispatchers.IO) {
|
||||
StatePlatform.instance.selectClients(*enabledBefore.toTypedArray());
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.showDialog(context, R.drawable.ic_update_success_251dp,
|
||||
"Import has finished", null, null, 0, UIDialogs.Action("Ok", {}));
|
||||
}
|
||||
}
|
||||
};
|
||||
//TODO: Probably restructure this to be less nested
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (doImportSettings && export.settings != null) {
|
||||
Logger.i(TAG, "Importing settings");
|
||||
try {
|
||||
Settings.replace(export.settings);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
UIDialogs.toast(context, "Failed to import settings\n(" + ex.message + ")");
|
||||
}
|
||||
}
|
||||
|
||||
val afterPluginInstalls = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (doEnablePlugins) {
|
||||
val availableClients = StatePlatform.instance.getEnabledClients().toMutableList();
|
||||
availableClients.addAll(StatePlatform.instance.getAvailableClients().filter { !availableClients.contains(it) });
|
||||
|
||||
Logger.i(TAG, "Import enabling plugins [${availableClients.map{it.name}.joinToString(", ")}]");
|
||||
StatePlatform.instance.updateAvailableClients(context, false);
|
||||
StatePlatform.instance.selectClients(*availableClients.map { it.id }.toTypedArray());
|
||||
try {
|
||||
if (doImportSettings && export.settings != null) {
|
||||
Logger.i(TAG, "Importing settings");
|
||||
try {
|
||||
Settings.replace(export.settings);
|
||||
} catch (ex: Throwable) {
|
||||
UIDialogs.toast(
|
||||
context,
|
||||
"Failed to import settings\n(" + ex.message + ")"
|
||||
);
|
||||
}
|
||||
if(doImportPluginSettings) {
|
||||
for(settings in export.pluginSettings) {
|
||||
Logger.i(TAG, "Importing Plugin settings [${settings.key}]");
|
||||
StatePlugins.instance.setPluginSettings(settings.key, settings.value);
|
||||
}
|
||||
|
||||
val afterPluginInstalls = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (doEnablePlugins) {
|
||||
val availableClients = StatePlatform.instance.getEnabledClients().toMutableList();
|
||||
availableClients.addAll(StatePlatform.instance.getAvailableClients().filter { !availableClients.contains(it) });
|
||||
Logger.i(TAG, "Import enabling plugins [${availableClients.map { it.name }.joinToString(", ")}]");
|
||||
StatePlatform.instance.updateAvailableClients(context, false);
|
||||
StatePlatform.instance.selectClients(*availableClients.map { it.id }.toTypedArray());
|
||||
}
|
||||
}
|
||||
val toAwait = export.stores.map { it.key }.toMutableList();
|
||||
if(doImportStores) {
|
||||
for(store in export.stores) {
|
||||
Logger.i(TAG, "Importing store [${store.key}]");
|
||||
if(store.key == "history") {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import History", "Would you like to import history?", null, 0,
|
||||
UIDialogs.Action("No", {
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action("Yes", {
|
||||
for(historyStr in store.value) {
|
||||
try {
|
||||
val histObj = HistoryVideo.fromReconString(historyStr) { url ->
|
||||
return@fromReconString export.cache?.videos?.firstOrNull { it.url == url };
|
||||
if (doImportPluginSettings) {
|
||||
for (settings in export.pluginSettings) {
|
||||
Logger.i(TAG, "Importing Plugin settings [${settings.key}]");
|
||||
StatePlugins.instance.setPluginSettings(settings.key, settings.value);
|
||||
}
|
||||
}
|
||||
val toAwait = export.stores.map { it.key }.toMutableList();
|
||||
if (doImportStores) {
|
||||
for (store in export.stores) {
|
||||
Logger.i(TAG, "Importing store [${store.key}]");
|
||||
if (store.key == "history") {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import History", "Would you like to import history?", null, 0,
|
||||
UIDialogs.Action("No", {
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action("Yes", {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
for (historyStr in store.value) {
|
||||
try {
|
||||
val histObj = HistoryVideo.fromReconString(historyStr) { url -> return@fromReconString export.cache?.videos?.firstOrNull { it.url == url }; }
|
||||
val hist = StateHistory.instance.getHistoryByVideo(histObj.video, true, histObj.date);
|
||||
if (hist != null)
|
||||
StateHistory.instance.updateHistoryPosition(histObj.video, hist, true, histObj.position, histObj.date, false, histObj.playlistId);
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to import subscription group", ex);
|
||||
}
|
||||
}
|
||||
val hist = StateHistory.instance.getHistoryByVideo(histObj.video, true, histObj.date);
|
||||
if(hist != null)
|
||||
StateHistory.instance.updateHistoryPosition(histObj.video, hist, true, histObj.position, histObj.date, false, histObj.playlistId);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to import subscription group", ex);
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
)
|
||||
}
|
||||
} else if (store.key == "subscription_groups") {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import Subscription Groups", "Would you like to import subscription groups?\nExisting groups with the same id will be overridden!", null, 0,
|
||||
UIDialogs.Action("No", {
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action("Yes", {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
for (groupStr in store.value) {
|
||||
try {
|
||||
val group = Json.decodeFromString<SubscriptionGroup>(groupStr);
|
||||
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
|
||||
if (existing != null)
|
||||
StateSubscriptionGroups.instance.deleteSubscriptionGroup(existing.id, false);
|
||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(group);
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to import subscription group", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val relevantStore = availableStores.find { it.name == store.key };
|
||||
if (relevantStore == null) {
|
||||
Logger.w(TAG, "Unknown store [${store.key}] import");
|
||||
continue;
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.showImportDialog(context, relevantStore, store.key, store.value, export.cache) {
|
||||
synchronized(toAwait) {
|
||||
toAwait.remove(store.key);
|
||||
if (toAwait.isEmpty())
|
||||
onConclusion();
|
||||
}
|
||||
}, UIDialogs.ActionStyle.PRIMARY))
|
||||
}
|
||||
}
|
||||
else if(store.key == "subscription_groups") {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import Subscription Groups", "Would you like to import subscription groups?\nExisting groups with the same id will be overridden!", null, 0,
|
||||
UIDialogs.Action("No", {
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action("Yes", {
|
||||
for(groupStr in store.value) {
|
||||
try {
|
||||
val group = Json.decodeFromString<SubscriptionGroup>(groupStr);
|
||||
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
|
||||
if(existing != null)
|
||||
StateSubscriptionGroups.instance.deleteSubscriptionGroup(existing.id, false);
|
||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(group);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to import subscription group", ex);
|
||||
}
|
||||
}
|
||||
}, UIDialogs.ActionStyle.PRIMARY))
|
||||
}
|
||||
}
|
||||
else {
|
||||
val relevantStore = availableStores.find { it.name == store.key };
|
||||
if (relevantStore == null) {
|
||||
Logger.w(TAG, "Unknown store [${store.key}] import");
|
||||
continue;
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.showImportDialog(context, relevantStore, store.key, store.value, export.cache) {
|
||||
synchronized(toAwait) {
|
||||
toAwait.remove(store.key);
|
||||
if(toAwait.isEmpty())
|
||||
onConclusion();
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (doImportPlugins) {
|
||||
Logger.i(TAG, "Importing plugins");
|
||||
StatePlugins.instance.installPlugins(context, scope, unknownPlugins.map { it.value }) {
|
||||
if (doImportPlugins) {
|
||||
Logger.i(TAG, "Importing plugins");
|
||||
StatePlugins.instance.installPlugins(context, scope, unknownPlugins.map { it.value }) {
|
||||
afterPluginInstalls();
|
||||
}
|
||||
} else
|
||||
afterPluginInstalls();
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Import failed", ex);
|
||||
UIDialogs.showGeneralErrorDialog(context, "Import failed", ex);
|
||||
}
|
||||
else
|
||||
afterPluginInstalls();
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Import failed", ex);
|
||||
UIDialogs.showGeneralErrorDialog(context, "Import failed", ex);
|
||||
}
|
||||
}
|
||||
},
|
||||
UIDialogs.Descriptor(R.drawable.ic_move_up,
|
||||
"Do you want to import data?",
|
||||
"Several dialogs will follow asking individual parts",
|
||||
"Settings: ${export.settings != null}\n" +
|
||||
"Plugins: ${unknownPlugins.size}\n" +
|
||||
"Plugin Settings: ${export.pluginSettings.size}\n" +
|
||||
export.stores.map { "${it.key}: ${it.value.size}" }.joinToString("\n").trim()
|
||||
, 1,
|
||||
UIDialogs.Action("Import", {
|
||||
doImport = true;
|
||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("Cancel", { doImport = false})
|
||||
),
|
||||
if(export.settings != null) UIDialogs.Descriptor(R.drawable.ic_settings,
|
||||
"Would you like to import settings",
|
||||
"These are the settings that configure how your app works",
|
||||
null, 0,
|
||||
UIDialogs.Action("Yes", {
|
||||
doImportSettings = true;
|
||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||
).withCondition { doImport } else null,
|
||||
if(unknownPlugins.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources,
|
||||
"Would you like to import plugins?",
|
||||
"Your import contains the following plugins",
|
||||
unknownPlugins.map { it.value }.joinToString("\n"), 1,
|
||||
UIDialogs.Action("Yes", {
|
||||
doImportPlugins = true;
|
||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||
).withCondition { doImport } else null,
|
||||
if(export.pluginSettings.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources,
|
||||
"Would you like to import plugin settings?",
|
||||
null, null, 1,
|
||||
UIDialogs.Action("Yes", {
|
||||
doImportPluginSettings = true;
|
||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||
).withCondition { doImport } else null,
|
||||
UIDialogs.Descriptor(R.drawable.ic_sources,
|
||||
"Would you like to enable all plugins?",
|
||||
"Enabling all plugins ensures all required plugins are available during import",
|
||||
null, 0,
|
||||
UIDialogs.Action("Yes", {
|
||||
doEnablePlugins = true;
|
||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||
).withCondition { doImport },
|
||||
if(export.stores.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_move_up,
|
||||
"Would you like to import stores",
|
||||
"Stores contain playlists, watch later, subscriptions, etc",
|
||||
null, 0,
|
||||
UIDialogs.Action("Yes", {
|
||||
doImportStores = true;
|
||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||
).withCondition { doImport } else null
|
||||
);
|
||||
},
|
||||
UIDialogs.Descriptor(R.drawable.ic_move_up, "Do you want to import data?", "Several dialogs will follow asking individual parts",
|
||||
"Settings: ${export.settings != null}\n" +
|
||||
"Plugins: ${unknownPlugins.size}\n" +
|
||||
"Plugin Settings: ${export.pluginSettings.size}\n" +
|
||||
export.stores.map { "${it.key}: ${it.value.size}" }.joinToString("\n").trim(),
|
||||
1,
|
||||
UIDialogs.Action("Import", {
|
||||
doImport = true;
|
||||
}, UIDialogs.ActionStyle.PRIMARY),
|
||||
UIDialogs.Action("Cancel", { doImport = false })
|
||||
),
|
||||
if (export.settings != null) UIDialogs.Descriptor(R.drawable.ic_settings, "Would you like to import settings", "These are the settings that configure how your app works", null, 0,
|
||||
UIDialogs.Action("Yes", {
|
||||
doImportSettings = true;
|
||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||
).withCondition { doImport } else null,
|
||||
if (unknownPlugins.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources, "Would you like to import plugins?", "Your import contains the following plugins", unknownPlugins.map { it.value }.joinToString("\n"), 1,
|
||||
UIDialogs.Action("Yes", {
|
||||
doImportPlugins = true;
|
||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||
).withCondition { doImport } else null,
|
||||
if (export.pluginSettings.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources, "Would you like to import plugin settings?", null, null, 1,
|
||||
UIDialogs.Action("Yes", {
|
||||
doImportPluginSettings = true;
|
||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||
).withCondition { doImport } else null,
|
||||
UIDialogs.Descriptor(R.drawable.ic_sources, "Would you like to enable all plugins?", "Enabling all plugins ensures all required plugins are available during import", null, 0,
|
||||
UIDialogs.Action("Yes", {
|
||||
doEnablePlugins = true;
|
||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||
).withCondition { doImport },
|
||||
if (export.stores.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_move_up, "Would you like to import stores", "Stores contain playlists, watch later, subscriptions, etc", null, 0,
|
||||
UIDialogs.Action("Yes", {
|
||||
doImportStores = true;
|
||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||
).withCondition { doImport } else null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fun importTxt(context: MainActivity, text: String, allowFailure: Boolean = false): Boolean {
|
||||
|
||||
@@ -483,9 +483,9 @@ class StateDownloads {
|
||||
var localVideos = StateDownloads.instance.getDownloadedVideosPlaylist(playlistId);
|
||||
if(playlist != null) {
|
||||
val missing = playlist.videos
|
||||
.filter { vid -> !localVideos.any { it.id.value == null || it.id.value == vid.id.value } }
|
||||
.map { getCachedVideo(it.id) }
|
||||
.filterNotNull();
|
||||
.filter { vid -> !localVideos.any { it.id.value == null || it.id.value == vid.id.value } }
|
||||
.map { getCachedVideo(it.id) }
|
||||
.filterNotNull();
|
||||
if(missing.size > 0)
|
||||
localVideos = localVideos + missing;
|
||||
};
|
||||
@@ -500,7 +500,6 @@ class StateDownloads {
|
||||
for (video in localVideos) {
|
||||
withContext(Dispatchers.Main) {
|
||||
it.setText("Exporting videos...(${i}/${localVideos.size})");
|
||||
//it.setProgress(i.toDouble() / localVideos.size);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -18,7 +18,14 @@ class StatePayment : PaymentState(if(!isTestingPayment) VERIFICATION_PUBLIC_KEY
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val VERIFICATION_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzJqqETLa42xw4AfbNOLQolMdMiGgg8DAC4RXEcH4/gytLhaqp1XsjiiMkADi1C7sDtGj6kOuAuQkqXQKpZ2dJSZsO+GPyop6DmgfAM6MQgOgFUpwsb3Lt3SvskJcls8MeOC+jg+GjjcuJI8qOfYevj4/7wAOpqzAwocTYnJivlK5nrC+qNtUC2HZX93OVu69aU5yvA1SQe9GiiU7vBld+CbzHxTcABCK/THu/BpLtGx0M7W3HNMKK1Z79dopCL9ZZWbWdkGDY8Zf39Gn/WVrs5elBvPzU+AfNYty77vx2r+sKgyohlbz4KVYpnw8HfawKcwuRE/GUyD3F2hUcXy8dQIDAQAB";
|
||||
private val VERIFICATION_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzJqqETLa42xw4AfbNOLQ" +
|
||||
"olMdMiGgg8DAC4RXEcH4/gytLhaqp1XsjiiMkADi1C7sDtGj6kOuAuQkqXQKpZ2d" +
|
||||
"JSZsO+GPyop6DmgfAM6MQgOgFUpwsb3Lt3SvskJcls8MeOC+jg+GjjcuJI8qOfYe" +
|
||||
"vj4/7wAOpqzAwocTYnJivlK5nrC+qNtUC2HZX93OVu69aU5yvA1SQe9GiiU7vBld" +
|
||||
"+CbzHxTcABCK/THu/BpLtGx0M7W3HNMKK1Z79dopCL9ZZWbWdkGDY8Zf39Gn/WVr" +
|
||||
"s5elBvPzU+AfNYty77vx2r+sKgyohlbz4KVYpnw8HfawKcwuRE/GUyD3F2hUcXy8" +
|
||||
"dQIDAQAB"
|
||||
|
||||
private val VERIFICATION_PUBLIC_KEY_TESTING = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqyDuxsRtD5gmBoLCNoZa" +
|
||||
"XSRTwyUxgzcPHzLZkvomXVSQqzD+3aOKngcTKAZ83rm4GvoyMlBukxQMLShannSx" +
|
||||
"k8GQGTCT7VStQKNc4lKVER5ASB6aEaypaFMIYI3rXN1xLF1LqY/j7cu5GgMsvAuU" +
|
||||
@@ -34,4 +41,4 @@ class StatePayment : PaymentState(if(!isTestingPayment) VERIFICATION_PUBLIC_KEY
|
||||
return _instance!!;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -24,6 +24,7 @@ import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.AnyAdapterView
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.LoaderView
|
||||
import com.futo.platformplayer.views.NoResultsView
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -31,22 +32,29 @@ import kotlinx.coroutines.launch
|
||||
class NotificationOverlayView: ConstraintLayout {
|
||||
|
||||
lateinit var recycler: RecyclerView;
|
||||
lateinit var emptyView: NoResultsView;
|
||||
var adapterNotifications: AnyAdapterView<Announcement, ViewHolder>;
|
||||
|
||||
constructor(context: Context) : super(context) {
|
||||
inflate(context, R.layout.overlay_notifications, this)
|
||||
|
||||
recycler = findViewById<RecyclerView>(R.id.container_notifications);
|
||||
emptyView = findViewById<NoResultsView>(R.id.no_results);
|
||||
adapterNotifications = recycler.asAny<Announcement, ViewHolder>(RecyclerView.VERTICAL, false, {
|
||||
|
||||
});
|
||||
|
||||
emptyView.setText("Nothing to see here", "You don't have any notifications", R.drawable.ic_notifications)
|
||||
}
|
||||
|
||||
fun onShown(parameter: Any?) {
|
||||
val announcements = StateAnnouncement.instance.getVisibleAnnouncements();
|
||||
adapterNotifications.adapter.setData(announcements);
|
||||
|
||||
if(announcements.any())
|
||||
emptyView.isVisible = false;
|
||||
else
|
||||
emptyView.isVisible = true;
|
||||
|
||||
StateAnnouncement.instance.onAnnouncementChanged.subscribe(this) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
Logger.i("NotificationOverlayView", "Announcements Changed");
|
||||
|
||||
-5
@@ -3,15 +3,10 @@ package com.futo.platformplayer.views.overlays.slideup
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.RelativeLayout
|
||||
import android.widget.TextView
|
||||
import com.futo.platformplayer.R
|
||||
import org.w3c.dom.Text
|
||||
|
||||
class SlideUpMenuTextInput : LinearLayout {
|
||||
private lateinit var _root: LinearLayout;
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/set_a_password_for_your_daily_backup"
|
||||
android:text="@string/enable_daily_backup"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
@@ -54,7 +54,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:textColor="#AAAAAA"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:text="@string/set_a_password_used_to_encrypt_your_daily_backup_that_is_written_to_external_storage"
|
||||
android:text="@string/automatic_backup_unencrypted_explanation"
|
||||
android:textAlignment="center"
|
||||
android:layout_marginStart="30dp"
|
||||
android:layout_marginEnd="30dp"
|
||||
@@ -62,26 +62,6 @@
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
</TextView>
|
||||
<EditText
|
||||
android:id="@+id/edit_password"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_marginLeft="30dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginRight="30dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:inputType="textPassword"
|
||||
android:hint="@string/backup_password" />
|
||||
<EditText
|
||||
android:id="@+id/edit_password2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_marginLeft="30dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginRight="30dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:inputType="textPassword"
|
||||
android:hint="@string/repeat_password" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -107,7 +87,7 @@
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/stop"
|
||||
android:text="@string/disable"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
@@ -128,7 +108,7 @@
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/start"
|
||||
android:text="@string/enable"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:background="@color/gray_1d">
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:paddingTop="40dp">
|
||||
android:paddingTop="40dp"
|
||||
android:paddingBottom="24dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_icon"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="80dp"
|
||||
app:srcCompat="@drawable/ic_lock" />
|
||||
@@ -31,42 +33,57 @@
|
||||
android:layout_marginStart="30dp"
|
||||
android:textAlignment="center"
|
||||
android:layout_marginEnd="30dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_reason"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#AAAAAA"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:text="@string/it_appears_an_automatic_backup_exists_on_your_device_if_you_would_like_to_restore_enter_your_backup_password"
|
||||
android:textAlignment="center"
|
||||
android:layout_marginStart="30dp"
|
||||
android:layout_marginEnd="30dp"
|
||||
android:textSize="10dp"
|
||||
android:layout_height="wrap_content">
|
||||
android:textSize="10dp" />
|
||||
|
||||
</TextView>
|
||||
<EditText
|
||||
android:id="@+id/edit_password"
|
||||
<LinearLayout
|
||||
android:id="@+id/password_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_marginLeft="30dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginRight="30dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:singleLine="true"
|
||||
android:hint="@string/backup_password" />
|
||||
android:orientation="vertical">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/edit_password"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_marginLeft="30dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginRight="30dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:singleLine="true"
|
||||
android:hint="@string/backup_password" />
|
||||
</LinearLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress_restore"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:visibility="gone"
|
||||
style="?android:attr/progressBarStyleLarge" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="28dp"
|
||||
android:layout_marginBottom="28dp">
|
||||
android:layout_marginTop="28dp">
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_cancel"
|
||||
android:layout_width="wrap_content"
|
||||
@@ -77,6 +94,7 @@
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:textColor="@color/colorPrimary"
|
||||
android:background="@color/transparent" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/button_start"
|
||||
android:layout_width="wrap_content"
|
||||
@@ -86,6 +104,7 @@
|
||||
android:clickable="true">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_start"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/restore"
|
||||
@@ -99,4 +118,4 @@
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1px"
|
||||
android:background="#181818" />
|
||||
<com.futo.platformplayer.views.NoResultsView
|
||||
android:id="@+id/no_results"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/container_notifications"
|
||||
app:layout_constraintTop_toBottomOf="@id/separator"
|
||||
|
||||
@@ -1064,6 +1064,7 @@
|
||||
<item>Russo</item>
|
||||
<item>Portoghese</item>
|
||||
<item>Cinese</item>
|
||||
<item>Italiano</item>
|
||||
</string-array>
|
||||
<string-array name="casting_device_type_array" translatable="false">
|
||||
<item>FCast</item>
|
||||
|
||||
@@ -1017,6 +1017,7 @@
|
||||
<item>Rusça</item>
|
||||
<item>Portekizce</item>
|
||||
<item>Çince</item>
|
||||
<item>İtalyanca</item>
|
||||
</string-array>
|
||||
<string-array name="casting_device_type_array" translatable="false">
|
||||
<item>FCast</item>
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
<string name="retry">Retry</string>
|
||||
<string name="install_failed_device_installer_broken">Failed to start system installer. Your device’s ROM is not compatible with automatic updates.</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="enable_daily_backup">Manage daily backup</string>
|
||||
<string name="automatic_backup_unencrypted_explanation">Enable or disable your automatic backups here</string>
|
||||
<string name="continue_anyway">Continue anyway</string>
|
||||
<string name="automatic_backup_disabled">Automatic backup disabled</string>
|
||||
<string name="automatic_backup_enabled">Automatic backup enabled</string>
|
||||
<string name="failed_to_retrieve_data_are_you_connected">Failed to retrieve data, are you connected?</string>
|
||||
<string name="settings">Settings</string>
|
||||
<string name="history">History</string>
|
||||
@@ -338,6 +343,8 @@
|
||||
<string name="clear_the_external_storage_for_download_files">Clear the external storage for download files</string>
|
||||
<string name="change_the_external_storage_for_download_files">Change the external storage for download files</string>
|
||||
<string name="clear_cookies">Clear Cookies</string>
|
||||
<string name="clear_cookies_after_login">Clear Cookies after Login</string>
|
||||
<string name="clear_cookies_after_login_desc">Deletes all cookies on the webview after login, this may be required for certain plugins to function properly.</string>
|
||||
<string name="clear_cookies_on_logout">Clear Cookies on Logout</string>
|
||||
<string name="test_background_worker">Test Background Worker</string>
|
||||
<string name="test_background_worker_description"></string>
|
||||
@@ -535,7 +542,7 @@
|
||||
<string name="restart_playback_when_gaining_connectivity_after_a_loss">Restart playback when gaining connectivity after a loss</string>
|
||||
<string name="chapter_update_fps_title">Chapter Update FPS</string>
|
||||
<string name="chapter_update_fps_description">Change accuracy of chapter updating, higher might cost more performance</string>
|
||||
<string name="set_automatic_backup">Set Automatic Backup</string>
|
||||
<string name="set_automatic_backup">Configure Automatic Backup</string>
|
||||
<string name="shortly_after_opening_the_app_start_fetching_subscriptions">Shortly after opening the app, start fetching subscriptions</string>
|
||||
<string name="show_faq">Show FAQ</string>
|
||||
<string name="show_issues">Show Issues</string>
|
||||
@@ -805,6 +812,10 @@
|
||||
<string name="not_yet_available_retrying_in_time_s">Not yet available, retrying in {time}s</string>
|
||||
<string name="failed_to_retry_for_live_stream">Failed to retry for live stream</string>
|
||||
<string name="this_app_is_in_development_please_submit_bug_reports_and_understand_that_many_features_are_incomplete">This app is in development. Please submit bug reports and understand that many features are incomplete.</string>
|
||||
<string name="automatic_backup_found_no_password">Automatic backup found. No password is required to restore.</string>
|
||||
<string name="checking_backup">Checking backup...</string>
|
||||
<string name="backup_password_length_error">Password must be 4–32 bytes.</string>
|
||||
<string name="restoring">Restoring...</string>
|
||||
<string name="please_use_at_least_1_character">Please use at least 1 character</string>
|
||||
<string name="are_you_sure_you_want_to_delete_this_video">Are you sure you want to delete this video?</string>
|
||||
<string name="tap_to_open">Tap to open</string>
|
||||
@@ -1109,6 +1120,7 @@
|
||||
<item>Russian</item>
|
||||
<item>Portuguese</item>
|
||||
<item>Chinese</item>
|
||||
<item>Italian</item>
|
||||
</string-array>
|
||||
<string-array name="casting_device_type_array" translatable="false">
|
||||
<item>FCast</item>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="youtu.be" />
|
||||
<data android:host="www.you.be" />
|
||||
<data android:host="www.youtu.be" />
|
||||
<data android:host="youtube.com" />
|
||||
<data android:host="www.youtube.com" />
|
||||
<data android:host="m.youtube.com" />
|
||||
@@ -31,6 +31,8 @@
|
||||
<data android:host="patreon.com" />
|
||||
<data android:host="soundcloud.com" />
|
||||
<data android:host="twitch.tv" />
|
||||
<data android:host="www.twitch.tv" />
|
||||
<data android:host="m.twitch.tv" />
|
||||
<data android:host="bilibili.com" />
|
||||
<data android:host="bilibili.tv" />
|
||||
<data android:host="dailymotion.com" />
|
||||
@@ -51,7 +53,7 @@
|
||||
<data android:mimeType="text/plain" />
|
||||
|
||||
<data android:host="youtu.be" />
|
||||
<data android:host="www.you.be" />
|
||||
<data android:host="www.youtu.be" />
|
||||
<data android:host="youtube.com" />
|
||||
<data android:host="www.youtube.com" />
|
||||
<data android:host="m.youtube.com" />
|
||||
@@ -63,6 +65,8 @@
|
||||
<data android:host="patreon.com" />
|
||||
<data android:host="soundcloud.com" />
|
||||
<data android:host="twitch.tv" />
|
||||
<data android:host="www.twitch.tv" />
|
||||
<data android:host="m.twitch.tv" />
|
||||
<data android:host="bilibili.com" />
|
||||
<data android:host="bilibili.tv" />
|
||||
<data android:host="dailymotion.com" />
|
||||
|
||||
Submodule app/src/stable/assets/sources/mixcloud updated: 0bbe4c63f4...1b801553b3
Submodule app/src/stable/assets/sources/patreon updated: 6880b30b71...6b3b6d25e5
Submodule app/src/stable/assets/sources/youtube updated: b9aae557fd...47c5b3b894
@@ -29,7 +29,7 @@
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="youtu.be" />
|
||||
<data android:host="www.you.be" />
|
||||
<data android:host="www.youtu.be" />
|
||||
<data android:host="youtube.com" />
|
||||
<data android:host="www.youtube.com" />
|
||||
<data android:host="m.youtube.com" />
|
||||
@@ -41,6 +41,8 @@
|
||||
<data android:host="patreon.com" />
|
||||
<data android:host="soundcloud.com" />
|
||||
<data android:host="twitch.tv" />
|
||||
<data android:host="www.twitch.tv" />
|
||||
<data android:host="m.twitch.tv" />
|
||||
<data android:host="bilibili.com" />
|
||||
<data android:host="bilibili.tv" />
|
||||
<data android:host="dailymotion.com" />
|
||||
@@ -61,7 +63,7 @@
|
||||
<data android:mimeType="text/plain" />
|
||||
|
||||
<data android:host="youtu.be" />
|
||||
<data android:host="www.you.be" />
|
||||
<data android:host="www.youtu.be" />
|
||||
<data android:host="youtube.com" />
|
||||
<data android:host="www.youtube.com" />
|
||||
<data android:host="m.youtube.com" />
|
||||
@@ -73,6 +75,8 @@
|
||||
<data android:host="patreon.com" />
|
||||
<data android:host="soundcloud.com" />
|
||||
<data android:host="twitch.tv" />
|
||||
<data android:host="www.twitch.tv" />
|
||||
<data android:host="m.twitch.tv" />
|
||||
<data android:host="bilibili.com" />
|
||||
<data android:host="bilibili.tv" />
|
||||
<data android:host="dailymotion.com" />
|
||||
|
||||
Submodule app/src/unstable/assets/sources/mixcloud updated: 0bbe4c63f4...1b801553b3
Submodule app/src/unstable/assets/sources/patreon updated: 6880b30b71...6b3b6d25e5
Submodule app/src/unstable/assets/sources/youtube updated: b9aae557fd...47c5b3b894
@@ -1,5 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
DOCUMENT_ROOT=/var/www/html
|
||||
MAINT_FILE="$DOCUMENT_ROOT/maintenance.file"
|
||||
|
||||
cleanup() {
|
||||
rm -f "$MAINT_FILE"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Sign sources
|
||||
echo "Signing all sources..."
|
||||
@@ -11,12 +19,12 @@ echo "Building content..."
|
||||
|
||||
# Take site offline
|
||||
echo "Taking site offline..."
|
||||
touch $DOCUMENT_ROOT/maintenance.file
|
||||
touch "$MAINT_FILE"
|
||||
|
||||
# Swap over the content
|
||||
echo "Deploying content..."
|
||||
cp ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab $DOCUMENT_ROOT/app-playstore-release.aab
|
||||
aws s3 cp ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab s3://artifacts-grayjay-app/app-playstore-release.aab
|
||||
cp ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab \
|
||||
"$DOCUMENT_ROOT/app-playstore-release.aab"
|
||||
|
||||
# Notify Cloudflare to wipe the CDN cache
|
||||
echo "Purging Cloudflare cache for zone $CLOUDFLARE_ZONE_ID..."
|
||||
@@ -29,4 +37,5 @@ sleep 30
|
||||
|
||||
# Take site back online
|
||||
echo "Bringing site back online..."
|
||||
rm $DOCUMENT_ROOT/maintenance.file
|
||||
rm -f "$MAINT_FILE"
|
||||
trap - EXIT INT TERM
|
||||
+1
-1
Submodule dep/futopay updated: 6857c8f0bc...3996582852
+56
-43
@@ -1,55 +1,68 @@
|
||||
#!/bin/sh
|
||||
DOCUMENT_ROOT=/var/www/html
|
||||
set -eu
|
||||
|
||||
R2_ENDPOINT="https://$CF_R2_ACCOUNT_ID.r2.cloudflarestorage.com"
|
||||
|
||||
r2_cp() {
|
||||
src="$1"
|
||||
key="$2"
|
||||
cache_control="$3"
|
||||
content_type="$4"
|
||||
|
||||
AWS_ACCESS_KEY_ID="$CF_R2_ACCESS_KEY_ID" \
|
||||
AWS_SECRET_ACCESS_KEY="$CF_R2_SECRET_ACCESS_KEY" \
|
||||
AWS_DEFAULT_REGION=auto \
|
||||
aws s3 cp "$src" "s3://$CF_R2_BUCKET/$key" \
|
||||
--endpoint-url "$R2_ENDPOINT" \
|
||||
--only-show-errors \
|
||||
--cache-control "$cache_control" \
|
||||
--content-type "$content_type"
|
||||
}
|
||||
|
||||
upload_apk_latest_and_versioned() {
|
||||
src="$1"
|
||||
filename="$2"
|
||||
|
||||
r2_cp "$src" "$VERSION/$filename" \
|
||||
"public, max-age=31536000, immutable" \
|
||||
"application/vnd.android.package-archive"
|
||||
|
||||
r2_cp "$src" "$filename" \
|
||||
"no-store" \
|
||||
"application/vnd.android.package-archive"
|
||||
}
|
||||
|
||||
# Sign sources
|
||||
echo "Signing all sources..."
|
||||
/usr/bin/bash ./sign-all-sources.sh
|
||||
|
||||
# Build content
|
||||
echo "Building content..."
|
||||
./gradlew --stacktrace assembleStableRelease
|
||||
|
||||
# Take site offline
|
||||
echo "Taking site offline..."
|
||||
touch $DOCUMENT_ROOT/maintenance.file
|
||||
VERSION="$(git describe --tags)"
|
||||
|
||||
# Swap over the content
|
||||
echo "Deploying content..."
|
||||
cp ./app/build/outputs/apk/stable/release/app-stable-x86_64-release.apk $DOCUMENT_ROOT/app-x86_64-release.apk
|
||||
cp ./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk $DOCUMENT_ROOT/app-arm64-v8a-release.apk
|
||||
cp ./app/build/outputs/apk/stable/release/app-stable-armeabi-v7a-release.apk $DOCUMENT_ROOT/app-armeabi-v7a-release.apk
|
||||
cp ./app/build/outputs/apk/stable/release/app-stable-universal-release.apk $DOCUMENT_ROOT/app-universal-release.apk
|
||||
cp ./app/build/outputs/apk/stable/release/app-stable-x86-release.apk $DOCUMENT_ROOT/app-x86-release.apk
|
||||
cp ./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk $DOCUMENT_ROOT/app-release.apk
|
||||
echo "Deploying artifacts to Cloudflare R2..."
|
||||
upload_apk_latest_and_versioned "./app/build/outputs/apk/stable/release/app-stable-x86_64-release.apk" "app-x86_64-release.apk"
|
||||
upload_apk_latest_and_versioned "./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk" "app-arm64-v8a-release.apk"
|
||||
upload_apk_latest_and_versioned "./app/build/outputs/apk/stable/release/app-stable-armeabi-v7a-release.apk" "app-armeabi-v7a-release.apk"
|
||||
upload_apk_latest_and_versioned "./app/build/outputs/apk/stable/release/app-stable-universal-release.apk" "app-universal-release.apk"
|
||||
upload_apk_latest_and_versioned "./app/build/outputs/apk/stable/release/app-stable-x86-release.apk" "app-x86-release.apk"
|
||||
upload_apk_latest_and_versioned "./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk" "app-release.apk"
|
||||
|
||||
VERSION=$(git describe --tags)
|
||||
echo $VERSION > $DOCUMENT_ROOT/version.txt
|
||||
mkdir -p $DOCUMENT_ROOT/changelogs
|
||||
git tag -l --format='%(contents)' $VERSION > $DOCUMENT_ROOT/changelogs/$VERSION
|
||||
tmp_version="$(mktemp)"
|
||||
printf '%s\n' "$VERSION" > "$tmp_version"
|
||||
r2_cp "$tmp_version" "$VERSION/version.txt" \
|
||||
"public, max-age=31536000, immutable" \
|
||||
"text/plain; charset=utf-8"
|
||||
r2_cp "$tmp_version" "version.txt" \
|
||||
"no-store" \
|
||||
"text/plain; charset=utf-8"
|
||||
rm -f "$tmp_version"
|
||||
|
||||
aws s3 cp ./app/build/outputs/apk/stable/release/app-stable-x86_64-release.apk s3://artifacts-grayjay-app/app-x86_64-release.apk
|
||||
aws s3 cp ./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk s3://artifacts-grayjay-app/app-arm64-v8a-release.apk
|
||||
aws s3 cp ./app/build/outputs/apk/stable/release/app-stable-armeabi-v7a-release.apk s3://artifacts-grayjay-app/app-armeabi-v7a-release.apk
|
||||
aws s3 cp ./app/build/outputs/apk/stable/release/app-stable-universal-release.apk s3://artifacts-grayjay-app/app-universal-release.apk
|
||||
aws s3 cp ./app/build/outputs/apk/stable/release/app-stable-x86-release.apk s3://artifacts-grayjay-app/app-x86-release.apk
|
||||
aws s3 cp ./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk s3://artifacts-grayjay-app/app-release.apk
|
||||
tmp_changelog="$(mktemp)"
|
||||
git tag -l --format='%(contents)' "$VERSION" > "$tmp_changelog"
|
||||
r2_cp "$tmp_changelog" "changelogs/$VERSION" \
|
||||
"public, max-age=31536000, immutable" \
|
||||
"text/plain; charset=utf-8"
|
||||
rm -f "$tmp_changelog"
|
||||
|
||||
VERSION=$(git describe --tags)
|
||||
echo $VERSION > ./version.txt
|
||||
git tag -l --format='%(contents)' $VERSION > ./changelog.txt
|
||||
|
||||
aws s3 cp ./version.txt s3://artifacts-grayjay-app/version.txt
|
||||
aws s3 cp ./changelog.txt s3://artifacts-grayjay-app/changelogs/$VERSION
|
||||
|
||||
# Notify Cloudflare to wipe the CDN cache
|
||||
echo "Purging Cloudflare cache for zone $CLOUDFLARE_ZONE_ID..."
|
||||
curl -X POST "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache" \
|
||||
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{"files":["https://releases.grayjay.app/app-x86_64-release.apk", "https://releases.grayjay.app/app-arm64-v8a-release.apk", "https://releases.grayjay.app/app-armeabi-v7a-release.apk", "https://releases.grayjay.app/app-universal-release.apk", "https://releases.grayjay.app/app-x86-release.apk", "https://releases.grayjay.app/app-release.apk", "https://releases.grayjay.app/version.txt"]}'
|
||||
|
||||
sleep 30
|
||||
|
||||
# Take site back online
|
||||
echo "Bringing site back online..."
|
||||
rm $DOCUMENT_ROOT/maintenance.file
|
||||
echo "Done."
|
||||
+50
-36
@@ -1,47 +1,61 @@
|
||||
#!/bin/sh
|
||||
DOCUMENT_ROOT=/var/www/html
|
||||
set -eu
|
||||
|
||||
R2_ENDPOINT="https://$CF_R2_ACCOUNT_ID.r2.cloudflarestorage.com"
|
||||
|
||||
r2_cp() {
|
||||
src="$1"
|
||||
key="$2"
|
||||
cache_control="$3"
|
||||
content_type="$4"
|
||||
|
||||
AWS_ACCESS_KEY_ID="$CF_R2_ACCESS_KEY_ID" \
|
||||
AWS_SECRET_ACCESS_KEY="$CF_R2_SECRET_ACCESS_KEY" \
|
||||
AWS_DEFAULT_REGION=auto \
|
||||
aws s3 cp "$src" "s3://$CF_R2_BUCKET/$key" \
|
||||
--endpoint-url "$R2_ENDPOINT" \
|
||||
--only-show-errors \
|
||||
--cache-control "$cache_control" \
|
||||
--content-type "$content_type"
|
||||
}
|
||||
|
||||
upload_apk_latest_and_versioned() {
|
||||
src="$1"
|
||||
filename="$2"
|
||||
|
||||
r2_cp "$src" "$VERSION/$filename" \
|
||||
"public, max-age=31536000, immutable" \
|
||||
"application/vnd.android.package-archive"
|
||||
|
||||
r2_cp "$src" "$filename" \
|
||||
"no-store" \
|
||||
"application/vnd.android.package-archive"
|
||||
}
|
||||
|
||||
# Sign sources
|
||||
echo "Signing all sources..."
|
||||
/usr/bin/bash ./sign-all-sources.sh
|
||||
|
||||
# Build content
|
||||
echo "Building content..."
|
||||
./gradlew --stacktrace assembleUnstableRelease
|
||||
|
||||
# Take site offline
|
||||
echo "Taking site offline..."
|
||||
touch $DOCUMENT_ROOT/maintenance.file
|
||||
VERSION="$(git describe --tags)"
|
||||
|
||||
# Swap over the content
|
||||
echo "Deploying content..."
|
||||
cp ./app/build/outputs/apk/unstable/release/app-unstable-x86_64-release.apk $DOCUMENT_ROOT/app-x86_64-release-unstable.apk
|
||||
cp ./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk $DOCUMENT_ROOT/app-arm64-v8a-release-unstable.apk
|
||||
cp ./app/build/outputs/apk/unstable/release/app-unstable-armeabi-v7a-release.apk $DOCUMENT_ROOT/app-armeabi-v7a-release-unstable.apk
|
||||
cp ./app/build/outputs/apk/unstable/release/app-unstable-universal-release.apk $DOCUMENT_ROOT/app-universal-release-unstable.apk
|
||||
cp ./app/build/outputs/apk/unstable/release/app-unstable-x86-release.apk $DOCUMENT_ROOT/app-x86-release-unstable.apk
|
||||
cp ./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk $DOCUMENT_ROOT/app-release-unstable.apk
|
||||
git describe --tags > $DOCUMENT_ROOT/version-unstable.txt
|
||||
echo "Deploying unstable artifacts to Cloudflare R2..."
|
||||
upload_apk_latest_and_versioned "./app/build/outputs/apk/unstable/release/app-unstable-x86_64-release.apk" "app-x86_64-release-unstable.apk"
|
||||
upload_apk_latest_and_versioned "./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk" "app-arm64-v8a-release-unstable.apk"
|
||||
upload_apk_latest_and_versioned "./app/build/outputs/apk/unstable/release/app-unstable-armeabi-v7a-release.apk" "app-armeabi-v7a-release-unstable.apk"
|
||||
upload_apk_latest_and_versioned "./app/build/outputs/apk/unstable/release/app-unstable-universal-release.apk" "app-universal-release-unstable.apk"
|
||||
upload_apk_latest_and_versioned "./app/build/outputs/apk/unstable/release/app-unstable-x86-release.apk" "app-x86-release-unstable.apk"
|
||||
upload_apk_latest_and_versioned "./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk" "app-release-unstable.apk"
|
||||
|
||||
aws s3 cp ./app/build/outputs/apk/unstable/release/app-unstable-x86_64-release.apk s3://artifacts-grayjay-app/app-x86_64-release-unstable.apk
|
||||
aws s3 cp ./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk s3://artifacts-grayjay-app/app-arm64-v8a-release-unstable.apk
|
||||
aws s3 cp ./app/build/outputs/apk/unstable/release/app-unstable-armeabi-v7a-release.apk s3://artifacts-grayjay-app/app-armeabi-v7a-release-unstable.apk
|
||||
aws s3 cp ./app/build/outputs/apk/unstable/release/app-unstable-universal-release.apk s3://artifacts-grayjay-app/app-universal-release-unstable.apk
|
||||
aws s3 cp ./app/build/outputs/apk/unstable/release/app-unstable-x86-release.apk s3://artifacts-grayjay-app/app-x86-release-unstable.apk
|
||||
aws s3 cp ./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk s3://artifacts-grayjay-app/app-release-unstable.apk
|
||||
tmp_version="$(mktemp)"
|
||||
printf '%s\n' "$VERSION" > "$tmp_version"
|
||||
r2_cp "$tmp_version" "$VERSION/version-unstable.txt" \
|
||||
"public, max-age=31536000, immutable" \
|
||||
"text/plain; charset=utf-8"
|
||||
r2_cp "$tmp_version" "version-unstable.txt" \
|
||||
"no-store" \
|
||||
"text/plain; charset=utf-8"
|
||||
rm -f "$tmp_version"
|
||||
|
||||
git describe --tags > ./version-unstable.txt
|
||||
aws s3 cp ./version-unstable.txt s3://artifacts-grayjay-app/version-unstable.txt
|
||||
|
||||
# Notify Cloudflare to wipe the CDN cache
|
||||
echo "Purging Cloudflare cache for zone $CLOUDFLARE_ZONE_ID..."
|
||||
curl -X POST "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache" \
|
||||
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{"files":["https://releases.grayjay.app/app-x86_64-release-unstable.apk", "https://releases.grayjay.app/app-arm64-v8a-release-unstable.apk", "https://releases.grayjay.app/app-armeabi-v7a-release-unstable.apk", "https://releases.grayjay.app/app-universal-release-unstable.apk", "https://releases.grayjay.app/app-x86-release-unstable.apk", "https://releases.grayjay.app/app-release-unstable.apk", "https://releases.grayjay.app/version-unstable.txt"]}'
|
||||
|
||||
sleep 30
|
||||
|
||||
# Take site back online
|
||||
echo "Bringing site back online..."
|
||||
rm $DOCUMENT_ROOT/maintenance.file
|
||||
echo "Done."
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import random
|
||||
import time
|
||||
import httplib2
|
||||
import socket
|
||||
|
||||
from google_auth_httplib2 import AuthorizedHttp
|
||||
from google.oauth2 import service_account
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.http import MediaFileUpload
|
||||
from googleapiclient.errors import HttpError
|
||||
from googleapiclient.http import build_http
|
||||
|
||||
SCOPE = "https://www.googleapis.com/auth/androidpublisher"
|
||||
socket.setdefaulttimeout(30 * 60)
|
||||
|
||||
def die(msg: str, code: int = 1):
|
||||
print(msg, file=sys.stderr)
|
||||
raise SystemExit(code)
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--sa", required=True, help="Service account JSON file path")
|
||||
ap.add_argument("--package", required=True, help="ApplicationId / package name")
|
||||
ap.add_argument("--aab", required=True, help="Path to .aab file")
|
||||
ap.add_argument("--track", default="internal", help="internal|alpha|beta|production")
|
||||
ap.add_argument("--status", default="completed", help="draft|inProgress|halted|completed")
|
||||
ap.add_argument("--name", default=None, help="Release name (defaults to CI_COMMIT_TAG)")
|
||||
ap.add_argument("--rollout", type=float, default=None, help="For staged rollout: 0 < rollout < 1")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.isfile(args.sa):
|
||||
die(f"Missing service account JSON: {args.sa}")
|
||||
if not os.path.isfile(args.aab):
|
||||
die(f"Missing AAB: {args.aab}")
|
||||
|
||||
release_name = args.name or os.environ.get("CI_COMMIT_TAG")
|
||||
if not release_name:
|
||||
die("Missing release name: pass --name or set CI_COMMIT_TAG")
|
||||
|
||||
staged = args.status in ("inProgress", "halted")
|
||||
if staged:
|
||||
if args.rollout is None:
|
||||
die("--rollout is required when --status is inProgress or halted")
|
||||
if not (0.0 < args.rollout < 1.0):
|
||||
die("--rollout must satisfy 0 < rollout < 1")
|
||||
else:
|
||||
args.rollout = None
|
||||
|
||||
print(f"Loading service account")
|
||||
|
||||
creds = service_account.Credentials.from_service_account_file(
|
||||
args.sa, scopes=[SCOPE]
|
||||
)
|
||||
|
||||
print(f"Loaded service account")
|
||||
|
||||
|
||||
print(f"Building service")
|
||||
http = build_http()
|
||||
authed_http = AuthorizedHttp(creds, http=http)
|
||||
service = build("androidpublisher", "v3", http=authed_http, cache_discovery=False)
|
||||
print(f"Built service")
|
||||
|
||||
try:
|
||||
print(f"Creating edit")
|
||||
|
||||
edit = service.edits().insert(body={}, packageName=args.package).execute()
|
||||
edit_id = edit["id"]
|
||||
|
||||
UPLOAD_CHUNK_SIZE = 10 * 1024 * 1024
|
||||
MAX_RETRIES = 8
|
||||
|
||||
print(f"Media upload started")
|
||||
|
||||
media = MediaFileUpload(
|
||||
args.aab,
|
||||
mimetype="application/octet-stream",
|
||||
resumable=True,
|
||||
chunksize=UPLOAD_CHUNK_SIZE,
|
||||
)
|
||||
|
||||
request = service.edits().bundles().upload(
|
||||
packageName=args.package,
|
||||
editId=edit_id,
|
||||
media_body=media,
|
||||
)
|
||||
|
||||
response = None
|
||||
last_pct = -1
|
||||
attempt = 0
|
||||
|
||||
while response is None:
|
||||
try:
|
||||
status, response = request.next_chunk(num_retries=3)
|
||||
attempt = 0 # reset after any successful chunk
|
||||
|
||||
if status:
|
||||
pct = int(status.progress() * 100)
|
||||
if pct != last_pct:
|
||||
last_pct = pct
|
||||
print(f"Upload progress: {pct}%", flush=True)
|
||||
|
||||
except HttpError as e:
|
||||
# Retry transient server-side errors with exponential backoff
|
||||
code = getattr(getattr(e, "resp", None), "status", None)
|
||||
if code in (500, 502, 503, 504) and attempt < MAX_RETRIES:
|
||||
sleep_s = min(60, (2 ** attempt)) + random.random()
|
||||
print(f"Transient HTTP {code}; retrying in {sleep_s:.1f}s...", flush=True)
|
||||
time.sleep(sleep_s)
|
||||
attempt += 1
|
||||
continue
|
||||
raise
|
||||
|
||||
print("Media upload finished")
|
||||
bundle = response
|
||||
version_code = bundle["versionCode"]
|
||||
|
||||
release = {
|
||||
"name": release_name,
|
||||
"status": args.status,
|
||||
"versionCodes": [str(version_code)],
|
||||
}
|
||||
if args.rollout is not None:
|
||||
release["userFraction"] = args.rollout
|
||||
|
||||
track_body = {"releases": [release]}
|
||||
|
||||
print(f"Updating track")
|
||||
|
||||
service.edits().tracks().update(
|
||||
packageName=args.package,
|
||||
editId=edit_id,
|
||||
track=args.track,
|
||||
body=track_body,
|
||||
).execute()
|
||||
|
||||
print(f"Updated track")
|
||||
print(f"Committing")
|
||||
|
||||
service.edits().commit(packageName=args.package, editId=edit_id).execute()
|
||||
print(f"Committed")
|
||||
|
||||
print(f"OK: package={args.package} track={args.track} status={args.status} versionCode={version_code} name={release_name}")
|
||||
except HttpError as e:
|
||||
content = e.content.decode("utf-8", errors="replace") if getattr(e, "content", None) else str(e)
|
||||
die(f"Google API error (HTTP {e.resp.status if e.resp else '??'}):\n{content}")
|
||||
except Exception as e:
|
||||
die(f"Unexpected error: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import hashlib
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
APK_URL = "https://releases.grayjay.app/app-universal-release.apk"
|
||||
|
||||
FDROID_REPO_SSH = "git@gitlab.futo.org:fdroid/repo-v2.git"
|
||||
FDROID_INDEX_PATH = "apps/Grayjay/index.yml"
|
||||
UNIVERSAL_APK_GLOB = "app/build/outputs/apk/stable/release/*universal*.apk"
|
||||
|
||||
GIT_USER_NAME = "koen"
|
||||
GIT_USER_EMAIL = "koen@futo.org"
|
||||
|
||||
class Fatal(Exception):
|
||||
pass
|
||||
|
||||
def run(cmd: list[str], *, cwd: Optional[str] = None) -> str:
|
||||
p = subprocess.run(cmd, cwd=cwd, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
if p.returncode != 0:
|
||||
raise Fatal(f"Command failed ({p.returncode}): {' '.join(cmd)}\n{p.stdout}")
|
||||
return p.stdout.strip()
|
||||
|
||||
def sha256_of_file(path: str) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
def pick_universal_apk() -> str:
|
||||
matches = sorted(glob.glob(UNIVERSAL_APK_GLOB))
|
||||
if not matches:
|
||||
raise Fatal(f"No universal APK found via glob: {UNIVERSAL_APK_GLOB}")
|
||||
|
||||
for m in matches:
|
||||
base = os.path.basename(m)
|
||||
if "app-stable-universal" in base:
|
||||
return m
|
||||
|
||||
return matches[-1]
|
||||
|
||||
def get_release_date_today() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).date().isoformat()
|
||||
|
||||
def get_version_code_from_tag() -> int:
|
||||
tag = os.environ.get("CI_COMMIT_TAG", "").strip()
|
||||
if not tag:
|
||||
tag = run(["git", "describe", "--tags"]).strip()
|
||||
|
||||
m = re.search(r"(\d+)", tag)
|
||||
if not m:
|
||||
raise Fatal(f"Could not parse an integer versionCode from tag '{tag}'")
|
||||
|
||||
return int(m.group(1))
|
||||
|
||||
def update_index_yml(path: str, sha256sum: str, date_str: str, version_code: int) -> None:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
url_line_idx = None
|
||||
url_line_indent = ""
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("- url:") and APK_URL in stripped:
|
||||
url_line_idx = i
|
||||
url_line_indent = line[: len(line) - len(stripped)]
|
||||
break
|
||||
if url_line_idx is None:
|
||||
raise Fatal(f"Did not find an apk entry with url {APK_URL} in {path}")
|
||||
|
||||
def is_url_line_same_level(s: str) -> bool:
|
||||
st = s.lstrip()
|
||||
indent = s[: len(s) - len(st)]
|
||||
return st.startswith("- url:") and indent == url_line_indent
|
||||
|
||||
end = len(lines)
|
||||
for j in range(url_line_idx + 1, len(lines)):
|
||||
if is_url_line_same_level(lines[j]):
|
||||
end = j
|
||||
break
|
||||
|
||||
child_indent = url_line_indent + " "
|
||||
found_sha = found_date = found_vc = False
|
||||
|
||||
for j in range(url_line_idx + 1, end):
|
||||
st = lines[j].lstrip()
|
||||
indent = lines[j][: len(lines[j]) - len(st)]
|
||||
if st.startswith("sha256sum:"):
|
||||
lines[j] = f"{indent}sha256sum: {sha256sum}\n"
|
||||
found_sha = True
|
||||
elif st.startswith("date:"):
|
||||
lines[j] = f"{indent}date: {date_str}\n"
|
||||
found_date = True
|
||||
elif st.startswith("version-code:"):
|
||||
lines[j] = f"{indent}version-code: {version_code}\n"
|
||||
found_vc = True
|
||||
|
||||
insert_pos = url_line_idx + 1
|
||||
to_insert: list[str] = []
|
||||
if not found_sha:
|
||||
to_insert.append(f"{child_indent}sha256sum: {sha256sum}\n")
|
||||
if not found_date:
|
||||
to_insert.append(f"{child_indent}date: {date_str}\n")
|
||||
if not found_vc:
|
||||
to_insert.append(f"{child_indent}version-code: {version_code}\n")
|
||||
if to_insert:
|
||||
lines[insert_pos:insert_pos] = to_insert
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
version_code = get_version_code_from_tag()
|
||||
date_str = get_release_date_today()
|
||||
|
||||
apk_path = pick_universal_apk()
|
||||
print(f"Computing sha256 for {apk_path} ...")
|
||||
sha = sha256_of_file(apk_path)
|
||||
print(f"sha256: {sha}")
|
||||
print(f"date: {date_str}")
|
||||
print(f"version-code: {version_code}")
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="fdroid-repo-")
|
||||
try:
|
||||
print(f"Cloning {FDROID_REPO_SSH} ...")
|
||||
run(["git", "clone", "--depth", "1", FDROID_REPO_SSH, tmpdir])
|
||||
|
||||
run(["git", "config", "user.name", GIT_USER_NAME], cwd=tmpdir)
|
||||
run(["git", "config", "user.email", GIT_USER_EMAIL], cwd=tmpdir)
|
||||
|
||||
index_path = os.path.join(tmpdir, FDROID_INDEX_PATH)
|
||||
if not os.path.exists(index_path):
|
||||
raise Fatal(f"Missing {FDROID_INDEX_PATH} in repo-v2")
|
||||
|
||||
update_index_yml(index_path, sha, date_str, version_code)
|
||||
|
||||
run(["git", "add", FDROID_INDEX_PATH], cwd=tmpdir)
|
||||
|
||||
diff_rc = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=tmpdir).returncode
|
||||
if diff_rc == 0:
|
||||
print("No changes to commit.")
|
||||
return 0
|
||||
|
||||
msg = f"Grayjay: update sha/date/version-code to {version_code} ({date_str})"
|
||||
run(["git", "commit", "-m", msg], cwd=tmpdir)
|
||||
run(["git", "push"], cwd=tmpdir)
|
||||
|
||||
print("Pushed update to fdroid/repo-v2.")
|
||||
return 0
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Fatal as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VENV_DIR="${VENV_DIR:-.venv-playstore}"
|
||||
|
||||
if [[ ! -d "$VENV_DIR" ]]; then
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
python -m pip install --upgrade google-api-python-client google-auth google-auth-httplib2
|
||||
Reference in New Issue
Block a user