Compare commits

...

50 Commits

Author SHA1 Message Date
Kelvin c2dce52a5b Fix Twitch live streams on channel, hasMore can now be nullable defaulting to false 2023-10-20 17:49:26 +02:00
Kelvin a2c63c59c5 Hide buy on playstore, margins on captcha button 2023-10-20 17:38:33 +02:00
Kelvin 7e54a2ce3d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-10-20 17:20:43 +02:00
Kelvin 5b7fb2c818 Consent reject now works, app now intercepts redirects 2023-10-20 17:20:36 +02:00
Koen da0ac281e2 Added button to open FAQ from settings. 2023-10-20 14:43:01 +02:00
Koen 576b37f64c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-10-20 14:34:53 +02:00
Koen 26c2db5023 Handle pager getComments returning null silently. 2023-10-20 14:34:41 +02:00
Kelvin f344dbf35c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-10-20 14:34:01 +02:00
Kelvin a04acbd4a5 Home all error fix, multi plugin cookie support, completion url semi-wildcard support, delete captcha button, critical exception support, dev portal can now request captchas. WIP Consent fix 2023-10-20 14:33:37 +02:00
Koen bd48aba8d3 Added text for FeedView which allows users to be informed what to do when sub feed is empty. 2023-10-20 14:14:02 +02:00
Koen 12b73bb248 Maximum import 75 subscriptions at once. 2023-10-20 13:17:25 +02:00
Koen c3ff897ef4 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-10-20 12:21:33 +02:00
Koen 242728fbe7 Fix deferred null. 2023-10-20 12:20:56 +02:00
Kelvin 14df7c8d43 Missing negative sub hide, youtube null exception catch, autobackup password field type fix 2023-10-20 00:27:25 +02:00
Kelvin 229377bd6e Subscriptions ratelimit and warnings, Nebula login requirement, Subscription fetch setting, -1 sub hide 2023-10-19 22:47:42 +02:00
Kelvin d4317ff06f Merge 2023-10-19 20:08:18 +02:00
Kelvin c70dbb56c8 Wip ratelimiting subs 2023-10-19 20:05:22 +02:00
Koen f9b772b729 Handle captcha exception on PlatformClientPool 2023-10-19 19:25:08 +02:00
Koen bbcc424393 Added missing throwIfCaptcha. 2023-10-19 19:09:33 +02:00
Koen f433cb1280 Fade mostly disliked comments. 2023-10-19 18:55:59 +02:00
Koen 9cf81ad20a Fixed build error. 2023-10-19 16:00:36 +02:00
Kelvin f65e293e45 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-10-19 15:28:18 +02:00
Kelvin 9a08762e9e Fix nested video serialization, log on login exceptions js 2023-10-19 15:28:14 +02:00
Koen 66dbd20a90 Comment truncation 2023-10-19 14:52:11 +02:00
Koen 8254bcc647 Comment truncation 2023-10-19 14:51:12 +02:00
Koen 51d0f18168 Fixed back button on add source and fixed QR code scanning. 2023-10-19 11:04:45 +02:00
Koen 5dcb535c0f Added Polycentric comment character limit of 5000. 2023-10-19 10:16:15 +02:00
Kelvin b7cbeb3837 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-10-18 20:11:50 +02:00
Kelvin 2067561c09 Fix dedup in subscriptions feed, Download change directory no longer crashes, Allow uppercase letters in email for payment, Fix developer mode not enabling 2023-10-18 20:11:20 +02:00
Koen 1ac70dba3f Update .gitlab-ci.yml 2023-10-17 21:45:28 +00:00
Kelvin f4370c1bfd Revert playlist ignoring missing source exception 2023-10-17 23:07:20 +02:00
Kelvin 73321ee362 Allow import/restore playlist with missing sources 2023-10-17 21:23:02 +02:00
Kelvin 182c88fc9e Prevent subsequent subscription requests if captcha, Prevent retry dialog in some captcha situations, prevent dup captchas 2023-10-17 20:47:23 +02:00
Koen 9d39d74be5 Fixed wrong variable name 2023-10-17 17:43:59 +02:00
Koen d8d8d6f666 Updated submodule 2023-10-17 17:09:53 +02:00
Kelvin df0504cead Captcha plugin system 2023-10-17 15:25:46 +02:00
Koen 851b547d64 Captcha support. 2023-10-17 13:17:54 +02:00
Koen f49ecf1159 Properly hide refresh layout loader. 2023-10-17 09:41:35 +02:00
Kelvin 081ae1dd88 Move unhandled exception announcement check to correct method 2023-10-16 22:05:47 +02:00
Kelvin 374d9950be Plugin disable only after no ongoing v8 calls to reduce crashes, errors of placeholder loaders now visible, cancel retry on home now removes loader 2023-10-16 22:04:19 +02:00
Kelvin 9ffdf39f13 Permanently stop playlist video download on cancel, Use detailed video download overlay in overviews 2023-10-13 19:09:07 +02:00
Kelvin 8bb1ff87c0 Fix issues with attempting to download sources that are not supported (including mixed playlists) 2023-10-13 18:00:01 +02:00
Kelvin 67e29999ef Add missing use 2023-10-12 19:21:14 +02:00
Kelvin f3f13a71dc New auto-backup storage using the Storage Access Framework, minor dialog tweaks, minor settings ui tweaks 2023-10-12 19:18:56 +02:00
Kelvin 5155423a1e Improve auth doc 2023-10-11 23:48:03 +02:00
Kelvin a7d558e48d Additional docs 2023-10-11 23:30:29 +02:00
Kelvin 7afd75c712 Fix missing next override for headphone controls 2023-10-11 22:50:36 +02:00
Kelvin 10a661ad4c Minor UI tweak, allow for settings reload, async settings load (with loader) 2023-10-11 22:15:52 +02:00
Kelvin 201fe6f0df Minor fix/comment 2023-10-11 18:01:49 +02:00
Kelvin f76a5b5f01 Cleaning up some logs, reducing retry intervals, planned live stream auto refresh, tweak some buffer timings, fixing some scopes 2023-10-11 17:58:04 +02:00
132 changed files with 2280 additions and 508 deletions
+3 -2
View File
@@ -4,6 +4,7 @@ variables:
stages: stages:
- buildAndDeployApkUnstable - buildAndDeployApkUnstable
- buildAndDeployApkStable - buildAndDeployApkStable
- buildAndDeployPlaystore
buildAndDeployApkUnstable: buildAndDeployApkUnstable:
stage: buildAndDeployApkUnstable stage: buildAndDeployApkUnstable
@@ -25,8 +26,8 @@ buildAndDeployApkStable:
- branches - branches
when: manual when: manual
buildAndDeployApkStable: buildAndDeployPlaystore:
stage: buildAndDeployApkStable stage: buildAndDeployPlaystore
script: script:
- sh deploy-playstore.sh - sh deploy-playstore.sh
only: only:
+1 -1
View File
@@ -95,7 +95,7 @@ android {
} }
defaultConfig { defaultConfig {
minSdk 29 minSdk 28
targetSdk 33 targetSdk 33
versionCode gitVersionCode versionCode gitVersionCode
versionName gitVersionName versionName gitVersionName
+4
View File
@@ -127,6 +127,10 @@
android:name=".activities.ExceptionActivity" android:name=".activities.ExceptionActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.CaptchaActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity <activity
android:name=".activities.LoginActivity" android:name=".activities.LoginActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
@@ -217,6 +217,9 @@ function pluginUpdateTestPlugin(config) {
} }
function pluginLoginTestPlugin() { function pluginLoginTestPlugin() {
return syncGET("/plugin/loginTestPlugin", {}); return syncGET("/plugin/loginTestPlugin", {});
}//captchaLoginTestPlugin
function pluginCaptchaTestPlugin(url, html) {
return syncPOST("/plugin/captchaTestPlugin?url=" + url, {}, html);
} }
function pluginLogoutTestPlugin() { function pluginLogoutTestPlugin() {
return syncGET("/plugin/logoutTestPlugin", {}); return syncGET("/plugin/logoutTestPlugin", {});
+9
View File
@@ -681,6 +681,9 @@
}); });
}, 1000); }, 1000);
}, },
captchaTestPlugin() {
captchaLoginTestPlugin();
},
logoutTestPlugin() { logoutTestPlugin() {
pluginLogoutTestPlugin(); pluginLogoutTestPlugin();
}, },
@@ -838,6 +841,12 @@
this.Testing.lastResultError = ""; this.Testing.lastResultError = "";
} }
catch(ex) { catch(ex) {
if(ex.plugin_type == "CaptchaRequiredException") {
let shouldCaptcha = confirm("Do you want to request captcha?");
if(shouldCaptcha) {
pluginCaptchaTestPlugin(ex.url, ex.body);
}
}
console.error("Failed to run test for " + req.title, ex); console.error("Failed to run test for " + req.title, ex);
this.Testing.lastResult = "" this.Testing.lastResult = ""
if(ex.message) if(ex.message)
+13
View File
@@ -64,6 +64,19 @@ class ScriptException extends Error {
} }
} }
} }
class CaptchaRequiredException extends Error {
constructor(url, body) {
super(JSON.stringify({ 'plugin_type': 'CaptchaRequiredException', url, body }));
this.plugin_type = "CaptchaRequiredException";
this.url = url;
this.body = body;
}
}
class CriticalException extends ScriptException {
constructor(msg) {
super("CriticalException", msg);
}
}
class UnavailableException extends ScriptException { class UnavailableException extends ScriptException {
constructor(msg) { constructor(msg) {
super("UnavailableException", msg); super("UnavailableException", msg);
@@ -0,0 +1,15 @@
package com.futo.platformplayer
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.helpers.VideoHelper
fun IPlatformVideoDetails.isDownloadable(): Boolean = VideoHelper.isDownloadable(this);
fun IVideoSource.isDownloadable(): Boolean = VideoHelper.isDownloadable(this);
fun IAudioSource.isDownloadable(): Boolean = VideoHelper.isDownloadable(this);
fun IVideoSourceDescriptor.hasAnySource(): Boolean = this.videoSources.any() || (this is VideoUnMuxedSourceDescriptor && this.audioSources.any());
@@ -4,7 +4,9 @@ import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.webkit.CookieManager import android.webkit.CookieManager
import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.* import com.futo.platformplayer.activities.*
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
@@ -43,7 +45,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField( @FormField(
"Manage Polycentric identity", FieldForm.BUTTON, "Manage Polycentric identity", FieldForm.BUTTON,
"Manage your Polycentric identity", -2 "Manage your Polycentric identity", -3
) )
fun managePolycentricIdentity() { fun managePolycentricIdentity() {
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
@@ -55,6 +57,19 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
@FormField(
"Open FAQ", FieldForm.BUTTON,
"Get answers to common questions", -2
)
fun openFAQ() {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://grayjay.app/faq.html"))
SettingsActivity.getActivity()?.startActivity(browserIntent);
} catch (e: Throwable) {
//Ignored
}
}
@FormField( @FormField(
"Submit feedback", FieldForm.BUTTON, "Submit feedback", FieldForm.BUTTON,
"Give feedback on the application", -1 "Give feedback on the application", -1
@@ -63,7 +78,8 @@ class Settings : FragmentedStorageFileJson() {
try { try {
val i = Intent(Intent.ACTION_VIEW); val i = Intent(Intent.ACTION_VIEW);
val subject = "Feedback Grayjay"; val subject = "Feedback Grayjay";
val body = "Hey,\n\nI have some feedback on the Grayjay app.\nVersion information (version_name = ${BuildConfig.VERSION_NAME}, version_code = ${BuildConfig.VERSION_CODE}, flavor = ${BuildConfig.FLAVOR}, build_type = ${BuildConfig.BUILD_TYPE}})\n\n"; val body = "Hey,\n\nI have some feedback on the Grayjay app.\nVersion information (version_name = ${BuildConfig.VERSION_NAME}, version_code = ${BuildConfig.VERSION_CODE}, flavor = ${BuildConfig.FLAVOR}, build_type = ${BuildConfig.BUILD_TYPE}})\n" +
"Device information (brand= ${Build.BRAND}, manufacturer = ${Build.MANUFACTURER}, device = ${Build.DEVICE}, version-sdk = ${Build.VERSION.SDK_INT}, version-os = ${Build.VERSION.BASE_OS})\n\n";
val data = Uri.parse("mailto:grayjay@futo.org?subject=" + Uri.encode(subject) + "&body=" + Uri.encode(body)); val data = Uri.parse("mailto:grayjay@futo.org?subject=" + Uri.encode(subject) + "&body=" + Uri.encode(body));
i.data = data; i.data = data;
@@ -140,7 +156,11 @@ class Settings : FragmentedStorageFileJson() {
return FeedStyle.THUMBNAIL; return FeedStyle.THUMBNAIL;
} }
@FormField("Background Update", FieldForm.DROPDOWN, "Experimental background update for subscriptions cache (requires restart)", 6) @FormField("Fetch on app boot", FieldForm.TOGGLE, "Shortly after opening the app, start fetching subscriptions.", 6)
@Serializable(with = FlexibleBooleanSerializer::class)
var fetchOnAppBoot: Boolean = true;
@FormField("Background Update", FieldForm.DROPDOWN, "Experimental background update for subscriptions cache (requires restart)", 7)
@DropdownFieldOptionsId(R.array.background_interval) @DropdownFieldOptionsId(R.array.background_interval)
var subscriptionsBackgroundUpdateInterval: Int = 0; var subscriptionsBackgroundUpdateInterval: Int = 0;
@@ -156,7 +176,7 @@ class Settings : FragmentedStorageFileJson() {
}; };
@FormField("Subscription Concurrency", FieldForm.DROPDOWN, "Specify how many threads are used to fetch channels (requires restart)", 7) @FormField("Subscription Concurrency", FieldForm.DROPDOWN, "Specify how many threads are used to fetch channels (requires restart)", 8)
@DropdownFieldOptionsId(R.array.thread_count) @DropdownFieldOptionsId(R.array.thread_count)
var subscriptionConcurrency: Int = 3; var subscriptionConcurrency: Int = 3;
@@ -213,7 +233,7 @@ class Settings : FragmentedStorageFileJson() {
fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate()); fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate());
@FormField("Auto-Rotate Dead Zone", FieldForm.DROPDOWN, "Auto-rotate deadzone in degrees", 5) @FormField("Auto-Rotate Dead Zone", FieldForm.DROPDOWN, "This prevents the device from rotating within the given amount of degrees.", 5)
@DropdownFieldOptionsId(R.array.auto_rotate_dead_zone) @DropdownFieldOptionsId(R.array.auto_rotate_dead_zone)
var autoRotateDeadZone: Int = 0; var autoRotateDeadZone: Int = 0;
@@ -333,7 +353,7 @@ class Settings : FragmentedStorageFileJson() {
"Submit logs to help us narrow down issues", 1 "Submit logs to help us narrow down issues", 1
) )
fun submitLogs() { fun submitLogs() {
StateApp.instance.scopeGetter().launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
if (!Logger.submitLogs()) { if (!Logger.submitLogs()) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -406,6 +426,33 @@ class Settings : FragmentedStorageFileJson() {
} }
@FormField("External Storage", FieldForm.GROUP, "", 12)
var storage = Storage();
@Serializable
class Storage {
var storage_general: String? = null;
var storage_download: String? = null;
fun getStorageGeneralUri(): Uri? = storage_general?.let { Uri.parse(it) };
fun getStorageDownloadUri(): Uri? = storage_download?.let { Uri.parse(it) };
fun isStorageMainValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageGeneralUri());
fun isStorageDownloadValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageDownloadUri());
@FormField("Change external General directory", FieldForm.BUTTON, "Change the external directory for general files, used for persistent files like auto-backup", 3)
fun changeStorageGeneral() {
SettingsActivity.getActivity()?.let {
StateApp.instance.changeExternalGeneralDirectory(it);
}
}
@FormField("Change external Downloads directory", FieldForm.BUTTON, "Change the external storage for download files, used for exported download files", 4)
fun changeStorageDownload() {
SettingsActivity.getActivity()?.let {
StateApp.instance.changeExternalDownloadDirectory(it);
}
}
}
@FormField("Auto Update", "group", "Configure the auto updater", 12) @FormField("Auto Update", "group", "Configure the auto updater", 12)
var autoUpdate = AutoUpdate(); var autoUpdate = AutoUpdate();
@Serializable @Serializable
@@ -462,7 +509,7 @@ class Settings : FragmentedStorageFileJson() {
fun viewChangelog() { fun viewChangelog() {
UIDialogs.toast("Retrieving changelog"); UIDialogs.toast("Retrieving changelog");
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
StateApp.instance.scopeGetter().launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
val version = StateUpdate.instance.downloadVersionCode(ManagedHttpClient()) ?: return@launch; val version = StateUpdate.instance.downloadVersionCode(ManagedHttpClient()) ?: return@launch;
Logger.i(TAG, "Version retrieved $version"); Logger.i(TAG, "Version retrieved $version");
@@ -511,7 +558,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField("Set Automatic Backup", FieldForm.BUTTON, "Configure daily backup in case of catastrophic failure. (Written to the external Grayjay directory)", 1) @FormField("Set Automatic Backup", FieldForm.BUTTON, "Configure daily backup in case of catastrophic failure. (Written to the external Grayjay directory)", 1)
fun configureAutomaticBackup() { fun configureAutomaticBackup() {
UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!); UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!, autoBackupPassword != null) {
SettingsActivity.getActivity()?.reloadSettings();
};
} }
@FormField("Restore Automatic Backup", FieldForm.BUTTON, "Restore a previous automatic backup", 2) @FormField("Restore Automatic Backup", FieldForm.BUTTON, "Restore a previous automatic backup", 2)
fun restoreAutomaticBackup() { fun restoreAutomaticBackup() {
@@ -542,6 +591,7 @@ class Settings : FragmentedStorageFileJson() {
StatePayment.instance.clearLicenses(); StatePayment.instance.clearLicenses();
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, "Licenses cleared, might require app restart"); UIDialogs.toast(it, "Licenses cleared, might require app restart");
it.reloadSettings();
} }
} }
} }
@@ -15,7 +15,9 @@ import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.dialogs.* import com.futo.platformplayer.dialogs.*
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -90,11 +92,25 @@ class UIDialogs {
} }
fun showAutomaticBackupDialog(context: Context) { fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
val dialog = AutomaticBackupDialog(context); val dialogAction: ()->Unit = {
registerDialogOpened(dialog); val dialog = AutomaticBackupDialog(context);
dialog.setOnDismissListener { registerDialogClosed(dialog) }; registerDialogOpened(dialog);
dialog.show(); dialog.setOnDismissListener { registerDialogClosed(dialog); onClosed?.invoke() };
dialog.show();
};
if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck)
UIDialogs.showDialog(context, R.drawable.ic_move_up, "An old backup is available", "Would you like to restore this backup?", null, 0,
UIDialogs.Action("Cancel", {}), //To nothing
UIDialogs.Action("Override", {
dialogAction();
}, UIDialogs.ActionStyle.DANGEROUS),
UIDialogs.Action("Restore", {
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
}, UIDialogs.ActionStyle.PRIMARY));
else {
dialogAction();
}
} }
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) { fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
val dialog = AutomaticRestoreDialog(context, scope); val dialog = AutomaticRestoreDialog(context, scope);
@@ -134,10 +150,10 @@ class UIDialogs {
val buttonView = TextView(context); val buttonView = TextView(context);
val dp10 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, resources.displayMetrics).toInt(); val dp10 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, resources.displayMetrics).toInt();
val dp28 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 28f, resources.displayMetrics).toInt(); val dp28 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 28f, resources.displayMetrics).toInt();
val dp14 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14.0f, resources.displayMetrics); val dp14 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14.0f, resources.displayMetrics).toInt();
buttonView.layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { buttonView.layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
if(actions.size > 1) if(actions.size > 1)
this.marginEnd = dp28; this.marginEnd = if(actions.size > 2) dp14 else dp28;
}; };
buttonView.setTextColor(Color.WHITE); buttonView.setTextColor(Color.WHITE);
buttonView.textSize = 14f; buttonView.textSize = 14f;
@@ -151,8 +167,9 @@ class UIDialogs {
ActionStyle.DANGEROUS_TEXT -> buttonView.setTextColor(ContextCompat.getColor(context, R.color.pastel_red)) ActionStyle.DANGEROUS_TEXT -> buttonView.setTextColor(ContextCompat.getColor(context, R.color.pastel_red))
else -> buttonView.setTextColor(ContextCompat.getColor(context, R.color.colorPrimary)) else -> buttonView.setTextColor(ContextCompat.getColor(context, R.color.colorPrimary))
} }
val paddingSpecialButtons = if(actions.size > 2) dp14 else dp28;
if(act.style != ActionStyle.NONE && act.style != ActionStyle.DANGEROUS_TEXT) if(act.style != ActionStyle.NONE && act.style != ActionStyle.DANGEROUS_TEXT)
buttonView.setPadding(dp28, dp10, dp28, dp10); buttonView.setPadding(paddingSpecialButtons, dp10, paddingSpecialButtons, dp10);
else else
buttonView.setPadding(dp10, dp10, dp10, dp10); buttonView.setPadding(dp10, dp10, dp10, dp10);
@@ -4,7 +4,6 @@ import android.content.ContentResolver
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
@@ -18,6 +17,7 @@ import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.* import com.futo.platformplayer.states.*
import com.futo.platformplayer.views.Loader
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
@@ -29,7 +29,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.lang.IllegalStateException
class UISlideOverlays { class UISlideOverlays {
companion object { companion object {
@@ -45,7 +45,7 @@ class UISlideOverlays {
menu.show(); menu.show();
} }
fun showDownloadVideoOverlay(contentResolver: ContentResolver, video: IPlatformVideoDetails, container: ViewGroup): SlideUpMenuOverlay? { fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? {
val items = arrayListOf<View>(); val items = arrayListOf<View>();
var menu: SlideUpMenuOverlay? = null; var menu: SlideUpMenuOverlay? = null;
@@ -68,6 +68,12 @@ class UISlideOverlays {
return null; return null;
} }
if(!VideoHelper.isDownloadable(video)) {
Logger.i(TAG, "Attempted to open downloads without valid sources for [${video.name}]: ${video.url}");
UIDialogs.toast( "No downloadable sources (yet)");
return null;
}
items.add(SlideUpMenuGroup(container.context, "Video", videoSources, items.add(SlideUpMenuGroup(container.context, "Video", videoSources,
listOf(listOf(SlideUpMenuItem(container.context, R.drawable.ic_movie, "None", "Audio Only", "none", { listOf(listOf(SlideUpMenuItem(container.context, R.drawable.ic_movie, "None", "Audio Only", "none", {
selectedVideo = null; selectedVideo = null;
@@ -76,7 +82,7 @@ class UISlideOverlays {
menu?.setOk("Download"); menu?.setOk("Download");
}, false)) + }, false)) +
videoSources videoSources
.filter { it is IVideoUrlSource } .filter { it.isDownloadable() }
.map { .map {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, { SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
selectedVideo = it as IVideoUrlSource; selectedVideo = it as IVideoUrlSource;
@@ -88,14 +94,14 @@ class UISlideOverlays {
)); ));
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0) if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0)
selectedVideo = VideoHelper.selectBestVideoSource(videoSources.filter { it is IVideoUrlSource }.asIterable(), selectedVideo = VideoHelper.selectBestVideoSource(videoSources.filter { it.isDownloadable() }.asIterable(),
Settings.instance.downloads.getDefaultVideoQualityPixels(), Settings.instance.downloads.getDefaultVideoQualityPixels(),
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) as IVideoUrlSource; FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) as IVideoUrlSource;
audioSources?.let { audioSources -> audioSources?.let { audioSources ->
items.add(SlideUpMenuGroup(container.context, "Audio", audioSources, audioSources items.add(SlideUpMenuGroup(container.context, "Audio", audioSources, audioSources
.filter { it is IAudioUrlSource } .filter { VideoHelper.isDownloadable(it) }
.map { .map {
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, { SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
selectedAudio = it as IAudioUrlSource; selectedAudio = it as IAudioUrlSource;
@@ -111,24 +117,27 @@ class UISlideOverlays {
menu?.selectOption(asources, preferredAudioSource); menu?.selectOption(asources, preferredAudioSource);
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it is IAudioUrlSource }.asIterable(), selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it.isDownloadable() }.asIterable(),
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
Settings.instance.playback.getPrimaryLanguage(container.context), Settings.instance.playback.getPrimaryLanguage(container.context),
if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?; if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?;
} }
items.add(SlideUpMenuGroup(container.context, "Subtitles", subtitleSources, subtitleSources //ContentResolver is required for subtitles..
.map { if(contentResolver != null) {
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, { items.add(SlideUpMenuGroup(container.context, "Subtitles", subtitleSources, subtitleSources
if (selectedSubtitle == it) { .map {
selectedSubtitle = null; SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
menu?.selectOption(subtitleSources, null); if (selectedSubtitle == it) {
} else { selectedSubtitle = null;
selectedSubtitle = it; menu?.selectOption(subtitleSources, null);
menu?.selectOption(subtitleSources, it); } else {
} selectedSubtitle = it;
}, false); menu?.selectOption(subtitleSources, it);
})); }
}, false);
}));
}
menu = SlideUpMenuOverlay(container.context, container, "Download Video", null, true, items); menu = SlideUpMenuOverlay(container.context, container, "Download Video", null, true, items);
@@ -153,29 +162,12 @@ class UISlideOverlays {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
val subtitleUri = subtitleToDownload.getSubtitlesURI(); val subtitleUri = subtitleToDownload.getSubtitlesURI();
if (subtitleUri != null) { //TODO: Remove uri dependency, should be able to work with raw aswell?
var subtitles: String? = null; if (subtitleUri != null && contentResolver != null) {
if ("file" == subtitleUri.scheme) { val subtitlesRaw = StateDownloads.instance.downloadSubtitles(subtitleToDownload, contentResolver);
val inputStream = contentResolver.openInputStream(subtitleUri);
inputStream?.use { stream ->
val reader = stream.bufferedReader();
subtitles = reader.use { it.readText() };
}
} else if ("http" == subtitleUri.scheme || "https" == subtitleUri.scheme) {
val client = ManagedHttpClient();
val subtitleResponse = client.get(subtitleUri.toString());
if (!subtitleResponse.isOk) {
throw Exception("Cannot fetch subtitles from source '${subtitleUri}': ${subtitleResponse.code}");
}
subtitles = subtitleResponse.body?.toString()
?: throw Exception("Subtitles are invalid '${subtitleUri}': ${subtitleResponse.code}");
} else {
throw Exception("Unsuported scheme");
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
StateDownloads.instance.download(video, selectedVideo, selectedAudio, if (subtitles != null) SubtitleRawSource(subtitleToDownload.name, subtitleToDownload.format, subtitles!!) else null); StateDownloads.instance.download(video, selectedVideo, selectedAudio, subtitlesRaw);
} }
} else { } else {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -191,10 +183,41 @@ class UISlideOverlays {
}; };
return menu.apply { show() }; return menu.apply { show() };
} }
fun showDownloadVideoOverlay(video: IPlatformVideo, container: ViewGroup) { fun showDownloadVideoOverlay(video: IPlatformVideo, container: ViewGroup, useDetails: Boolean = false) {
showUnknownVideoDownload("Video", container) { px, bitrate -> val handleUnknownDownload: ()->Unit = {
StateDownloads.instance.download(video, px, bitrate) showUnknownVideoDownload("Video", container) { px, bitrate ->
StateDownloads.instance.download(video, px, bitrate)
};
}; };
if(!useDetails)
handleUnknownDownload();
else {
val scope = StateApp.instance.scopeOrNull;
if(scope != null) {
val loader = showLoaderOverlay("Fetching video details", container);
scope.launch(Dispatchers.IO) {
try {
val videoDetails = StatePlatform.instance.getContentDetails(video.url, false).await();
if(videoDetails !is IPlatformVideoDetails)
throw IllegalStateException("Not a video details");
withContext(Dispatchers.Main) {
if(showDownloadVideoOverlay(videoDetails, container, StateApp.instance.contextOrNull?.contentResolver) == null)
loader.hide(true);
}
}
catch(ex: Throwable) {
withContext(Dispatchers.Main) {
UIDialogs.toast("Failed to fetch details for download");
handleUnknownDownload();
loader.hide(true);
}
}
}
}
else handleUnknownDownload();
}
} }
fun showDownloadPlaylistOverlay(playlist: Playlist, container: ViewGroup) { fun showDownloadPlaylistOverlay(playlist: Playlist, container: ViewGroup) {
showUnknownVideoDownload("Video", container) { px, bitrate -> showUnknownVideoDownload("Video", container) { px, bitrate ->
@@ -269,6 +292,18 @@ class UISlideOverlays {
menu.show(); menu.show();
} }
fun showLoaderOverlay(text: String, container: ViewGroup): SlideUpMenuOverlay {
val dp70 = 70.dp(container.context.resources);
val dp15 = 15.dp(container.context.resources);
val overlay = SlideUpMenuOverlay(container.context, container, text, null, true, listOf(
Loader(container.context, true, dp70).apply {
this.setPadding(0, dp15, 0, dp15);
}
), true);
overlay.show();
return overlay;
}
fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, onVideoHidden: (()->Unit)? = null): SlideUpMenuOverlay { fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, onVideoHidden: (()->Unit)? = null): SlideUpMenuOverlay {
val items = arrayListOf<View>(); val items = arrayListOf<View>();
val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist(); val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist();
@@ -291,7 +326,7 @@ class UISlideOverlays {
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, "Hide", "Hide from Home", "hide", SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, "Hide", "Hide from Home", "hide",
{ StateMeta.instance.addHiddenVideo(video.url); onVideoHidden?.invoke() }), { StateMeta.instance.addHiddenVideo(video.url); onVideoHidden?.invoke() }),
SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download", SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download",
{ showDownloadVideoOverlay(video, container); }, false) { showDownloadVideoOverlay(video, container, true); }, false)
)) ))
items.add( items.add(
SlideUpMenuGroup(container.context, "Add To", "addto", SlideUpMenuGroup(container.context, "Add To", "addto",
@@ -344,7 +379,7 @@ class UISlideOverlays {
SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, StatePlayer.TYPE_WATCHLATER, "${watchLater.size} videos", "watch later", SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, StatePlayer.TYPE_WATCHLATER, "${watchLater.size} videos", "watch later",
{ StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }), { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download", SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download",
{ showDownloadVideoOverlay(video, container); }, false)) { showDownloadVideoOverlay(video, container, true); }, false))
); );
val playlistItems = arrayListOf<SlideUpMenuItem>(); val playlistItems = arrayListOf<SlideUpMenuItem>();
@@ -6,6 +6,7 @@ import android.content.Intent
import android.content.res.Resources import android.content.res.Resources
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.icu.util.Output
import android.os.Build import android.os.Build
import android.os.Looper import android.os.Looper
import android.os.OperationCanceledException import android.os.OperationCanceledException
@@ -15,6 +16,7 @@ import android.view.WindowInsetsController
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.documentfile.provider.DocumentFile
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformMultiClientPool import com.futo.platformplayer.api.media.PlatformMultiClientPool
@@ -56,7 +58,7 @@ fun findNonRuntimeException(ex: Throwable?): Throwable? {
fun warnIfMainThread(context: String) { fun warnIfMainThread(context: String) {
if(BuildConfig.DEBUG && Looper.myLooper() == Looper.getMainLooper()) if(BuildConfig.DEBUG && Looper.myLooper() == Looper.getMainLooper())
Logger.w(V8Plugin.TAG, "JAVASCRIPT ON MAIN THREAD\nAt: ${context}\n" + Thread.currentThread().stackTrace); Logger.w(V8Plugin.TAG, "JAVASCRIPT ON MAIN THREAD\nAt: ${context}\n" + Thread.currentThread().stackTrace.joinToString { it.toString() });
} }
fun ensureNotMainThread() { fun ensureNotMainThread() {
@@ -75,6 +77,16 @@ fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPool
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec); fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
fun DocumentFile.getInputStream(context: Context) = context.contentResolver.openInputStream(this.uri);
fun DocumentFile.getOutputStream(context: Context) = context.contentResolver.openOutputStream(this.uri);
fun DocumentFile.copyTo(context: Context, file: DocumentFile) = this.getInputStream(context).use { input ->
file.getOutputStream(context)?.use { output -> input?.copyTo(output) }
};
fun DocumentFile.readBytes(context: Context) = this.getInputStream(context).use { input -> input?.readBytes() };
fun DocumentFile.writeBytes(context: Context, byteArray: ByteArray) = context.contentResolver.openOutputStream(this.uri)?.use {
it.write(byteArray);
it.flush();
};
fun loadBitmap(url: String): Bitmap { fun loadBitmap(url: String): Bitmap {
try { try {
@@ -75,10 +75,10 @@ class AddSourceActivity : AppCompatActivity() {
_buttonInstall = findViewById(R.id.button_install); _buttonInstall = findViewById(R.id.button_install);
_buttonBack.setOnClickListener { _buttonBack.setOnClickListener {
onBackPressed(); finish();
}; };
_buttonCancel.setOnClickListener { _buttonCancel.setOnClickListener {
onBackPressed(); finish();
} }
_buttonInstall.setOnClickListener { _buttonInstall.setOnClickListener {
_config?.let { _config?.let {
@@ -1,7 +1,10 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.widget.* import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
@@ -14,6 +17,31 @@ class AddSourceOptionsActivity : AppCompatActivity() {
lateinit var _buttonQR: BigButton; lateinit var _buttonQR: BigButton;
lateinit var _buttonURL: BigButton; lateinit var _buttonURL: BigButton;
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let {
val content = it.contents
if (content == null) {
UIDialogs.toast(this, "Failed to scan QR code")
return@let
}
val url = if (content.startsWith("https://")) {
content
} else if (content.startsWith("grayjay://plugin/")) {
content.substring("grayjay://plugin/".length)
} else {
UIDialogs.toast(this, "Not a plugin URL")
return@let;
}
val intent = Intent(this, AddSourceActivity::class.java).apply {
data = Uri.parse(url);
};
startActivity(intent);
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_add_source_options); setContentView(R.layout.activity_add_source_options);
@@ -37,8 +65,9 @@ class AddSourceOptionsActivity : AppCompatActivity() {
integrator.setBeepEnabled(false) integrator.setBeepEnabled(false)
integrator.setBarcodeImageEnabled(true) integrator.setBarcodeImageEnabled(true)
integrator.setCaptureActivity(QRCaptureActivity::class.java); integrator.setCaptureActivity(QRCaptureActivity::class.java);
integrator.initiateScan() _qrCodeResultLauncher.launch(integrator.createScanIntent())
} }
_buttonURL.onClick.subscribe { _buttonURL.onClick.subscribe {
UIDialogs.toast(this, "Not implemented yet.."); UIDialogs.toast(this, "Not implemented yet..");
} }
@@ -0,0 +1,120 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.webkit.CookieManager
import android.webkit.WebView
import android.widget.Button
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginCaptchaConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.CaptchaWebViewClient
import com.futo.platformplayer.others.LoginWebViewClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.lang.Exception
import java.util.UUID
class CaptchaActivity : AppCompatActivity() {
private lateinit var _webView: WebView;
private lateinit var _buttonClose: Button;
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_captcha);
setNavigationBarColorAndIcons();
_buttonClose = findViewById(R.id.button_close);
_buttonClose.setOnClickListener { finish(); };
_webView = findViewById(R.id.web_view);
_webView.settings.javaScriptEnabled = true;
CookieManager.getInstance().setAcceptCookie(true);
val config = if(intent.hasExtra("plugin"))
Json.decodeFromString<SourcePluginConfig>(intent.getStringExtra("plugin")!!);
else null;
val captchaConfig = if(config != null)
config.captcha ?: throw IllegalStateException("Plugin has no captcha support");
else if(intent.hasExtra("captcha"))
Json.decodeFromString<SourcePluginCaptchaConfig>(intent.getStringExtra("captcha")!!);
else throw IllegalStateException("No valid configuration?");
//TODO: Backwards compat removal?
val extraUrl = if (intent.hasExtra("url"))
intent.getStringExtra("url");
else null;
val extraBody = if (intent.hasExtra("body"))
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";
_webView.settings.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = true;
val webViewClient = if(config != null) CaptchaWebViewClient(config) else CaptchaWebViewClient(captchaConfig);
webViewClient.onCaptchaFinished.subscribe { captcha ->
_callback?.let {
_callback = null;
it.invoke(captcha);
}
finish();
};
_webView.settings.domStorageEnabled = true;
_webView.webViewClient = webViewClient;
if(captchaConfig.captchaUrl != null)
_webView.loadUrl(captchaConfig.captchaUrl);
else if(extraUrl != null && extraBody != null)
_webView.loadDataWithBaseURL(extraUrl, extraBody, "text/html", "utf-8", null);
else if(extraUrl != null)
_webView.loadUrl(extraUrl);
else throw IllegalStateException("No valid captcha info provided");
}
override fun finish() {
lifecycleScope.launch(Dispatchers.Main) {
_webView.loadUrl("about:blank");
}
_callback?.let {
_callback = null;
it.invoke(null);
}
super.finish();
}
companion object {
private val TAG = "CaptchaActivity";
private var _callback: ((SourceCaptchaData?) -> Unit)? = null;
private fun getCaptchaIntent(context: Context, config: SourcePluginConfig, url: String? = null, body: String? = null): Intent {
val intent = Intent(context, CaptchaActivity::class.java);
if(url != null)
intent.putExtra("url", url);
if(body != null)
intent.putExtra("body", body);
intent.putExtra("plugin", Json.encodeToString(config));
return intent;
}
fun showCaptcha(context: Context, config: SourcePluginConfig, url: String? = null, body: String? = null, callback: ((SourceCaptchaData?) -> Unit)? = null) {
_callback = callback;
context.startActivity(getCaptchaIntent(context, config, url, body));
}
}
}
@@ -1,6 +1,7 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
@@ -40,7 +41,8 @@ class ExceptionActivity : AppCompatActivity() {
val context = intent.getStringExtra(EXTRA_CONTEXT) ?: "Unknown Context"; val context = intent.getStringExtra(EXTRA_CONTEXT) ?: "Unknown Context";
val stack = intent.getStringExtra(EXTRA_STACK) ?: "Something went wrong... missing stack trace?"; val stack = intent.getStringExtra(EXTRA_STACK) ?: "Something went wrong... missing stack trace?";
val exceptionString = "Version information (version_name = ${BuildConfig.VERSION_NAME}, version_code = ${BuildConfig.VERSION_CODE}, flavor = ${BuildConfig.FLAVOR}, build_type = ${BuildConfig.BUILD_TYPE})\n\n" + val exceptionString = "Version information (version_name = ${BuildConfig.VERSION_NAME}, version_code = ${BuildConfig.VERSION_CODE}, flavor = ${BuildConfig.FLAVOR}, build_type = ${BuildConfig.BUILD_TYPE})\n" +
"Device information (brand= ${Build.BRAND}, manufacturer = ${Build.MANUFACTURER}, device = ${Build.DEVICE}, version-sdk = ${Build.VERSION.SDK_INT}, version-os = ${Build.VERSION.BASE_OS})\n\n" +
Logging.buildLogString(LogLevel.ERROR, TAG, "Uncaught exception (\"$context\"): $stack"); Logging.buildLogString(LogLevel.ERROR, TAG, "Uncaught exception (\"$context\"): $stack");
try { try {
val file = File(filesDir, "log.txt"); val file = File(filesDir, "log.txt");
@@ -3,7 +3,9 @@ package com.futo.platformplayer.activities
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.webkit.ConsoleMessage
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebView import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -68,9 +70,15 @@ class LoginActivity : AppCompatActivity() {
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {}); view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
} }
} }
//TODO: Required for some...TBD what to do with it. Clear on finish?
_webView.settings.domStorageEnabled = true; _webView.settings.domStorageEnabled = true;
/*
_webView.webChromeClient = object: WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
Logger.w(TAG, "Login Console: " + consoleMessage?.message());
return super.onConsoleMessage(consoleMessage);
}
}*/
_webView.webViewClient = webViewClient; _webView.webViewClient = webViewClient;
_webView.loadUrl(authConfig.loginUrl); _webView.loadUrl(authConfig.loginUrl);
} }
@@ -392,7 +392,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
override fun onResume() { override fun onResume() {
super.onResume(); super.onResume();
Logger.i(TAG, "onResume") Logger.v(TAG, "onResume")
val curOrientation = _orientationManager.orientation; val curOrientation = _orientationManager.orientation;
@@ -408,13 +408,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
val videoToOpen = StateSaved.instance.videoToOpen; val videoToOpen = StateSaved.instance.videoToOpen;
if (_wasStopped) { if (_wasStopped) {
Logger.i(TAG, "_wasStopped is true");
Logger.i(TAG, "set _wasStopped = false");
_wasStopped = false; _wasStopped = false;
Logger.i(TAG, "onResume videoToOpen=$videoToOpen");
if (videoToOpen != null && _fragVideoDetail.state == VideoDetailFragment.State.CLOSED) { if (videoToOpen != null && _fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
Logger.i(TAG, "onResume videoToOpen=$videoToOpen");
if (StatePlatform.instance.hasEnabledVideoClient(videoToOpen.url)) { if (StatePlatform.instance.hasEnabledVideoClient(videoToOpen.url)) {
navigate(_fragVideoDetail, UrlVideoWithTime(videoToOpen.url, videoToOpen.timeSeconds, false)); navigate(_fragVideoDetail, UrlVideoWithTime(videoToOpen.url, videoToOpen.timeSeconds, false));
_fragVideoDetail.maximizeVideoDetail(true); _fragVideoDetail.maximizeVideoDetail(true);
@@ -427,13 +424,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
override fun onPause() { override fun onPause() {
super.onPause(); super.onPause();
Logger.i(TAG, "onPause") Logger.v(TAG, "onPause")
_isVisible = false; _isVisible = false;
} }
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
Logger.i(TAG, "_wasStopped = true"); Logger.v(TAG, "_wasStopped = true");
_wasStopped = true; _wasStopped = true;
} }
@@ -610,6 +607,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return; return;
}; };
}; };
val name = when(type) { val name = when(type) {
"Playlist" -> recon.split("\n").filter { !it.startsWith(ManagedStore.RECONSTRUCTION_HEADER_OPERATOR) }.firstOrNull() ?: type; "Playlist" -> recon.split("\n").filter { !it.startsWith(ManagedStore.RECONSTRUCTION_HEADER_OPERATOR) }.firstOrNull() ?: type;
else -> type else -> type
@@ -722,22 +720,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
_fragVideoDetail.onOrientationChanged(OrientationManager.Orientation.PORTRAIT); _fragVideoDetail.onOrientationChanged(OrientationManager.Orientation.PORTRAIT);
} }
Logger.i(TAG, "onRestart5");
} }
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig); super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig);
val isStop: Boolean = lifecycle.currentState == Lifecycle.State.CREATED; val isStop: Boolean = lifecycle.currentState == Lifecycle.State.CREATED;
Logger.i(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop") Logger.v(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop")
_fragVideoDetail?.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig); _fragVideoDetail?.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig);
Logger.i(TAG, "onPictureInPictureModeChanged Ready"); Logger.v(TAG, "onPictureInPictureModeChanged Ready");
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy(); super.onDestroy();
Logger.i(TAG, "onDestroy") Logger.v(TAG, "onDestroy")
_orientationManager.disable(); _orientationManager.disable();
@@ -899,7 +895,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//TODO: Only calls last handler due to missing request codes on ActivityResultLaunchers.
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>(); private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
private var requestCode: Int? = -1; private var requestCode: Int? = -1;
private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult( private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
@@ -5,6 +5,7 @@ import android.os.Bundle
import android.widget.EditText import android.widget.EditText
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
@@ -14,6 +15,7 @@ import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.* import com.futo.polycentric.core.*
import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentIntegrator
import com.journeyapps.barcodescanner.CaptureActivity
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -27,6 +29,16 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
private lateinit var _buttonImportProfile: LinearLayout; private lateinit var _buttonImportProfile: LinearLayout;
private lateinit var _editProfile: EditText; private lateinit var _editProfile: EditText;
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let {
if (it.contents != null) {
val scannedUrl = it.contents
import(scannedUrl)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_import_profile); setContentView(R.layout.activity_polycentric_import_profile);
@@ -45,10 +57,15 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
}; };
_buttonScanProfile.setOnClickListener { _buttonScanProfile.setOnClickListener {
val integrator = IntentIntegrator(this); val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE); integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt("Scan a QR code"); integrator.setPrompt("Scan a QR code")
integrator.initiateScan(); integrator.setOrientationLocked(true);
integrator.setCameraId(0)
integrator.setBeepEnabled(false)
integrator.setBarcodeImageEnabled(true)
integrator.setCaptureActivity(QRCaptureActivity::class.java);
_qrCodeResultLauncher.launch(integrator.createScanIntent())
}; };
_buttonImportProfile.setOnClickListener { _buttonImportProfile.setOnClickListener {
@@ -66,18 +83,6 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
} }
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data)
if (result != null) {
if (result.contents != null) {
val scannedUrl = result.contents;
import(scannedUrl);
}
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
private fun import(url: String) { private fun import(url: String) {
if (!url.startsWith("polycentric://")) { if (!url.startsWith("polycentric://")) {
UIDialogs.toast(this, "Not a valid URL"); UIDialogs.toast(this, "Not a valid URL");
@@ -126,4 +131,8 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
companion object { companion object {
private const val TAG = "PolycentricImportProfileActivity"; private const val TAG = "PolycentricImportProfileActivity";
} }
class QRCaptureActivity: CaptureActivity() {
}
} }
@@ -10,7 +10,10 @@ import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.views.Loader
import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.ReadOnlyTextField import com.futo.platformplayer.views.fields.ReadOnlyTextField
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
@@ -18,6 +21,7 @@ import com.google.android.material.button.MaterialButton
class SettingsActivity : AppCompatActivity(), IWithResultLauncher { class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
private lateinit var _form: FieldForm; private lateinit var _form: FieldForm;
private lateinit var _buttonBack: ImageButton; private lateinit var _buttonBack: ImageButton;
private lateinit var _loader: Loader;
private lateinit var _devSets: LinearLayout; private lateinit var _devSets: LinearLayout;
private lateinit var _buttonDev: MaterialButton; private lateinit var _buttonDev: MaterialButton;
@@ -33,9 +37,10 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
_buttonBack = findViewById(R.id.button_back); _buttonBack = findViewById(R.id.button_back);
_buttonDev = findViewById(R.id.button_dev); _buttonDev = findViewById(R.id.button_dev);
_devSets = findViewById(R.id.dev_settings); _devSets = findViewById(R.id.dev_settings);
_loader = findViewById(R.id.loader);
_form.fromObject(Settings.instance);
_form.onChanged.subscribe { field, value -> _form.onChanged.subscribe { field, value ->
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
_form.setObjectValues(); _form.setObjectValues();
Settings.instance.save(); Settings.instance.save();
}; };
@@ -47,18 +52,28 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
startActivity(Intent(this, DeveloperActivity::class.java)); startActivity(Intent(this, DeveloperActivity::class.java));
} }
var devCounter = 0;
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
devCounter++;
if(devCounter > 5) {
devCounter = 0;
SettingsDev.instance.developerMode = true;
SettingsDev.instance.save();
updateDevMode();
UIDialogs.toast(this, "You are now in developer mode");
}
};
_lastActivity = this; _lastActivity = this;
reloadSettings();
}
fun reloadSettings() {
_loader.start();
_form.fromObject(lifecycleScope, Settings.instance) {
_loader.stop();
var devCounter = 0;
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
devCounter++;
if(devCounter > 5) {
devCounter = 0;
SettingsDev.instance.developerMode = true;
SettingsDev.instance.save();
updateDevMode();
UIDialogs.toast(this, "You are now in developer mode");
}
};
};
} }
override fun onResume() { override fun onResume() {
@@ -6,6 +6,7 @@ import com.futo.platformplayer.logging.Logger
import okhttp3.Call import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
@@ -28,7 +29,11 @@ open class ManagedHttpClient {
constructor(builder: OkHttpClient.Builder = OkHttpClient.Builder()) { constructor(builder: OkHttpClient.Builder = OkHttpClient.Builder()) {
_builderTemplate = builder; _builderTemplate = builder;
client = builder.build(); client = builder.addNetworkInterceptor { chain ->
val request = beforeRequest(chain.request());
val response = afterRequest(chain.proceed(request));
return@addNetworkInterceptor response;
}.build();
} }
open fun clone(): ManagedHttpClient { open fun clone(): ManagedHttpClient {
@@ -116,7 +121,7 @@ open class ManagedHttpClient {
fun execute(request : Request) : Response { fun execute(request : Request) : Response {
ensureNotMainThread(); ensureNotMainThread();
beforeRequest(request); //beforeRequest(request);
Logger.v(TAG, "HTTP Request [${request.method}] ${request.url} - [${if(request.body != null) request.body.size else 0}]"); Logger.v(TAG, "HTTP Request [${request.method}] ${request.url} - [${if(request.body != null) request.body.size else 0}]");
@@ -156,23 +161,16 @@ open class ManagedHttpClient {
if(true) if(true)
Logger.v(TAG, "HTTP Response [${request.method}] ${request.url} - [${time}ms]"); Logger.v(TAG, "HTTP Response [${request.method}] ${request.url} - [${time}ms]");
afterRequest(request, resp); //afterRequest(request, resp);
return resp; return resp;
} }
//Set Listeners //Set Listeners
fun setOnBeforeRequest(listener : (Request)->Unit) { open fun beforeRequest(request: okhttp3.Request): okhttp3.Request {
this.onBeforeRequest = listener; return request;
} }
fun setOnAfterRequest(listener : (Request, Response)->Unit) { open fun afterRequest(resp: okhttp3.Response): okhttp3.Response {
this.onAfterRequest = listener; return resp;
}
open fun beforeRequest(request: Request) {
onBeforeRequest?.invoke(request);
}
open fun afterRequest(request: Request, resp: Response) {
onAfterRequest?.invoke(request, resp);
} }
@@ -63,7 +63,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
} }
}.start(); }.start();
Logger.i(TAG, "Started ${port}. \n" + getAddresses().map { it.hostAddress }.joinToString("\n")); Logger.i(TAG, "Started HTTP Server ${port}. \n" + getAddresses().map { it.hostAddress }.joinToString("\n"));
} }
@Synchronized @Synchronized
fun stop() { fun stop() {
@@ -94,7 +94,10 @@ class LiveChatManager {
if(_pager is JSLiveEventPager) if(_pager is JSLiveEventPager)
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong(); nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]"); if(newEvents.size > 0)
Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]");
else
Logger.v(TAG, "No new Live Events");
_scope.launch(Dispatchers.Main) { _scope.launch(Dispatchers.Main) {
try { try {
@@ -6,17 +6,20 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
class PlatformClientPool { class PlatformClientPool {
private val _parent: JSClient; private val _parent: JSClient;
private val _pool: HashMap<JSClient, Int> = hashMapOf(); private val _pool: HashMap<JSClient, Int> = hashMapOf();
private var _poolCounter = 0; private var _poolCounter = 0;
private val _poolName: String?;
var isDead: Boolean = false var isDead: Boolean = false
private set; private set;
val onDead = Event2<JSClient, PlatformClientPool>(); val onDead = Event2<JSClient, PlatformClientPool>();
constructor(parentClient: IPlatformClient) { constructor(parentClient: IPlatformClient, name: String? = null) {
_poolName = name;
if(parentClient !is JSClient) if(parentClient !is JSClient)
throw IllegalArgumentException("Pooling only supported for JSClients right now"); throw IllegalArgumentException("Pooling only supported for JSClients right now");
Logger.i(TAG, "Pool for ${parentClient.name} was started"); Logger.i(TAG, "Pool for ${parentClient.name} was started");
@@ -47,8 +50,13 @@ class PlatformClientPool {
_poolCounter++; _poolCounter++;
reserved = _pool.keys.find { !it.isBusy }; reserved = _pool.keys.find { !it.isBusy };
if(reserved == null && _pool.size < capacity) { if(reserved == null && _pool.size < capacity) {
Logger.i(TAG, "Started additional [${_parent.name}] client in pool (${_pool.size + 1}/${capacity})"); Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})");
reserved = _parent.getCopy(); reserved = _parent.getCopy();
reserved?.onCaptchaException?.subscribe { client, ex ->
StateApp.instance.handleCaptchaException(client, ex);
};
reserved?.initialize(); reserved?.initialize();
_pool[reserved!!] = _poolCounter; _pool[reserved!!] = _poolCounter;
} }
@@ -1,12 +1,14 @@
package com.futo.platformplayer.api.media package com.futo.platformplayer.api.media
class PlatformMultiClientPool { class PlatformMultiClientPool {
private val _name: String;
private val _maxCap: Int; private val _maxCap: Int;
private val _clientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf(); private val _clientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf();
private var _isFake = false; private var _isFake = false;
constructor(maxCap: Int = -1) { constructor(name: String, maxCap: Int = -1) {
_name = name;
_maxCap = if(maxCap > 0) _maxCap = if(maxCap > 0)
maxCap maxCap
else 99; else 99;
@@ -17,7 +19,7 @@ class PlatformMultiClientPool {
return parentClient; return parentClient;
val pool = synchronized(_clientPools) { val pool = synchronized(_clientPools) {
if(!_clientPools.containsKey(parentClient)) if(!_clientPools.containsKey(parentClient))
_clientPools[parentClient] = PlatformClientPool(parentClient).apply { _clientPools[parentClient] = PlatformClientPool(parentClient, _name).apply {
this.onDead.subscribe { client, pool -> this.onDead.subscribe { client, pool ->
synchronized(_clientPools) { synchronized(_clientPools) {
if(_clientPools[parentClient] == pool) if(_clientPools[parentClient] == pool)
@@ -27,6 +27,7 @@ class ResultCapabilities(
const val TYPE_VIDEOS = "VIDEOS"; const val TYPE_VIDEOS = "VIDEOS";
const val TYPE_STREAMS = "STREAMS"; const val TYPE_STREAMS = "STREAMS";
const val TYPE_LIVE = "LIVE"; const val TYPE_LIVE = "LIVE";
const val TYPE_POSTS = "POSTS";
const val TYPE_MIXED = "MIXED"; const val TYPE_MIXED = "MIXED";
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL"; const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
@@ -39,4 +39,8 @@ class PolycentricPlatformComment : IPlatformComment {
fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment { fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment {
return PolycentricPlatformComment(contextUrl, author, message, rating, date, reference, replyCount); return PolycentricPlatformComment(contextUrl, author, message, rating, date, reference, replyCount);
} }
companion object {
val MAX_COMMENT_SIZE = 2000
}
} }
@@ -4,7 +4,7 @@ import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import java.time.OffsetDateTime import java.time.OffsetDateTime
class PlatformContentPlaceholder(pluginId: String): IPlatformContent { class PlatformContentPlaceholder(pluginId: String, exception: Throwable? = null): IPlatformContent {
override val contentType: ContentType = ContentType.PLACEHOLDER; override val contentType: ContentType = ContentType.PLACEHOLDER;
override val id: PlatformID = PlatformID("", null, pluginId); override val id: PlatformID = PlatformID("", null, pluginId);
override val name: String = ""; override val name: String = "";
@@ -12,4 +12,5 @@ class PlatformContentPlaceholder(pluginId: String): IPlatformContent {
override val shareUrl: String = ""; override val shareUrl: String = "";
override val datetime: OffsetDateTime? = null; override val datetime: OffsetDateTime? = null;
override val author: PlatformAuthorLink = PlatformAuthorLink(PlatformID("", pluginId), "", "", null, null); override val author: PlatformAuthorLink = PlatformAuthorLink(PlatformID("", pluginId), "", "", null, null);
val error: Throwable? = exception
} }
@@ -30,7 +30,7 @@ open class SerializedPlatformNestedContent(
override val contentProvider: String?, override val contentProvider: String?,
override val contentThumbnails: Thumbnails override val contentThumbnails: Thumbnails
) : IPlatformNestedContent, SerializedPlatformContent { ) : IPlatformNestedContent, SerializedPlatformContent {
final override val contentType: ContentType get() = ContentType.MEDIA; final override val contentType: ContentType get() = ContentType.NESTED_VIDEO;
override val contentPlugin: String? = StatePlatform.instance.getContentClientOrNull(contentUrl)?.id; override val contentPlugin: String? = StatePlatform.instance.getContentClientOrNull(contentUrl)?.id;
override val contentSupported: Boolean get() = contentPlugin != null; override val contentSupported: Boolean get() = contentPlugin != null;
@@ -7,6 +7,7 @@ import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.states.StateApp
import java.util.* import java.util.*
class DevJSClient : JSClient { class DevJSClient : JSClient {
@@ -15,29 +16,44 @@ class DevJSClient : JSClient {
private val _devScript: String; private val _devScript: String;
private var _auth: SourceAuth? = null; private var _auth: SourceAuth? = null;
private var _captcha: SourceCaptchaData? = null;
val devID: String; val devID: String;
constructor(context: Context, config: SourcePluginConfig, script: String, auth: SourceAuth? = null, devID: String? = null): super(context, SourcePluginDescriptor(config, auth?.toEncrypted(), listOf("DEV")), null, script) { constructor(context: Context, config: SourcePluginConfig, script: String, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, devID: String? = null): super(context, SourcePluginDescriptor(config, auth?.toEncrypted(), captcha?.toEncrypted(), listOf("DEV")), null, script) {
_devScript = script; _devScript = script;
_auth = auth; _auth = auth;
_captcha = captcha;
this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5); this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5);
onCaptchaException.subscribe { client, captcha ->
StateApp.instance.handleCaptchaException(client, captcha);
}
} }
constructor(context: Context, descriptor: SourcePluginDescriptor, script: String, auth: SourceAuth? = null, savedState: String? = null, devID: String? = null): super(context, descriptor, savedState, script) { //TODO: Misisng auth/captcha pass on purpose?
constructor(context: Context, descriptor: SourcePluginDescriptor, script: String, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, savedState: String? = null, devID: String? = null): super(context, descriptor, savedState, script) {
_devScript = script; _devScript = script;
_auth = auth; _auth = auth;
_captcha = captcha;
this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5); this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5);
onCaptchaException.subscribe { client, captcha ->
StateApp.instance.handleCaptchaException(client, captcha);
}
} }
fun setCaptcha(captcha: SourceCaptchaData? = null) {
_captcha = captcha;
}
fun setAuth(auth: SourceAuth? = null) { fun setAuth(auth: SourceAuth? = null) {
_auth = auth; _auth = auth;
} }
fun recreate(context: Context): DevJSClient { fun recreate(context: Context): DevJSClient {
return DevJSClient(context, config, _devScript, _auth, devID); return DevJSClient(context, config, _devScript, _auth, _captcha, devID);
} }
override fun getCopy(): JSClient { override fun getCopy(): JSClient {
return DevJSClient(_context, descriptor, _script, _auth, saveState(), devID); return DevJSClient(_context, descriptor, _script, _auth, _captcha, saveState(), devID);
} }
override fun initialize() { override fun initialize() {
@@ -4,6 +4,7 @@ import android.content.Context
import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.primitive.V8ValueBoolean import com.caoccao.javet.values.primitive.V8ValueBoolean
import com.caoccao.javet.values.primitive.V8ValueInteger import com.caoccao.javet.values.primitive.V8ValueInteger
import com.caoccao.javet.values.primitive.V8ValueNull
import com.caoccao.javet.values.primitive.V8ValueString import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.V8ValueArray import com.caoccao.javet.values.reference.V8ValueArray
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
@@ -23,9 +24,14 @@ import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.platforms.js.internal.* import com.futo.platformplayer.api.media.platforms.js.internal.*
import com.futo.platformplayer.api.media.platforms.js.models.* import com.futo.platformplayer.api.media.platforms.js.models.*
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.engine.exceptions.PluginEngineException
import com.futo.platformplayer.engine.exceptions.PluginEngineStoppedException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptValidationException import com.futo.platformplayer.engine.exceptions.ScriptValidationException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -59,6 +65,7 @@ open class JSClient : IPlatformClient {
private var _enabled: Boolean = false; private var _enabled: Boolean = false;
private val _auth: SourceAuth?; private val _auth: SourceAuth?;
private val _captcha: SourceCaptchaData?;
private val _injectedSaveState: String?; private val _injectedSaveState: String?;
@@ -85,6 +92,7 @@ open class JSClient : IPlatformClient {
val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true
val onDisabled = Event1<JSClient>(); val onDisabled = Event1<JSClient>();
val onCaptchaException = Event2<JSClient, ScriptCaptchaRequiredException>();
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String? = null) { constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String? = null) {
this._context = context; this._context = context;
@@ -93,10 +101,11 @@ open class JSClient : IPlatformClient {
this.descriptor = descriptor; this.descriptor = descriptor;
_injectedSaveState = saveState; _injectedSaveState = saveState;
_auth = descriptor.getAuth(); _auth = descriptor.getAuth();
_captcha = descriptor.getCaptchaData();
flags = descriptor.flags.toTypedArray(); flags = descriptor.flags.toTypedArray();
_client = JSHttpClient(this); _client = JSHttpClient(this, null, _captcha);
_clientAuth = JSHttpClient(this, _auth); _clientAuth = JSHttpClient(this, _auth, _captcha);
_plugin = V8Plugin(context, descriptor.config, null, _client, _clientAuth); _plugin = V8Plugin(context, descriptor.config, null, _client, _clientAuth);
_plugin.withDependency(context, "scripts/polyfil.js"); _plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js"); _plugin.withDependency(context, "scripts/source.js");
@@ -108,6 +117,11 @@ open class JSClient : IPlatformClient {
} }
else else
throw IllegalStateException("Script for plugin [${descriptor.config.name}] was not available"); throw IllegalStateException("Script for plugin [${descriptor.config.name}] was not available");
_plugin.onScriptException.subscribe {
if(it is ScriptCaptchaRequiredException)
onCaptchaException.emit(this, it);
};
} }
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String) { constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String) {
this._context = context; this._context = context;
@@ -116,15 +130,21 @@ open class JSClient : IPlatformClient {
this.descriptor = descriptor; this.descriptor = descriptor;
_injectedSaveState = saveState; _injectedSaveState = saveState;
_auth = descriptor.getAuth(); _auth = descriptor.getAuth();
_captcha = descriptor.getCaptchaData();
flags = descriptor.flags.toTypedArray(); flags = descriptor.flags.toTypedArray();
_client = JSHttpClient(this); _client = JSHttpClient(this, null, _captcha);
_clientAuth = JSHttpClient(this, _auth); _clientAuth = JSHttpClient(this, _auth, _captcha);
_plugin = V8Plugin(context, descriptor.config, script, _client, _clientAuth); _plugin = V8Plugin(context, descriptor.config, script, _client, _clientAuth);
_plugin.withDependency(context, "scripts/polyfil.js"); _plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js"); _plugin.withDependency(context, "scripts/source.js");
_plugin.withScript(script); _plugin.withScript(script);
_script = script; _script = script;
_plugin.onScriptException.subscribe {
if(it is ScriptCaptchaRequiredException)
onCaptchaException.emit(this, it);
};
} }
open fun getCopy(): JSClient { open fun getCopy(): JSClient {
@@ -413,8 +433,11 @@ open class JSClient : IPlatformClient {
@JSDocsParameter("url", "A content url (this platform)") @JSDocsParameter("url", "A content url (this platform)")
override fun getComments(url: String): IPager<IPlatformComment> = isBusyWith { override fun getComments(url: String): IPager<IPlatformComment> = isBusyWith {
ensureEnabled(); ensureEnabled();
return@isBusyWith JSCommentPager(config, plugin, val pager = plugin.executeTyped<V8Value>("source.getComments(${Json.encodeToString(url)})");
plugin.executeTyped("source.getComments(${Json.encodeToString(url)})")); if (pager !is V8ValueObject) { //TODO: Maybe solve this better
return@isBusyWith EmptyPager<IPlatformComment>();
}
return@isBusyWith JSCommentPager(config, plugin, pager);
} }
@JSDocs(17, "source.getSubComments(comment)", "Gets replies for a given comment") @JSDocs(17, "source.getSubComments(comment)", "Gets replies for a given comment")
@JSDocsParameter("comment", "Comment object that was returned by getComments") @JSDocsParameter("comment", "Comment object that was returned by getComments")
@@ -561,11 +584,13 @@ open class JSClient : IPlatformClient {
} }
private fun announcePluginUnhandledException(method: String, ex: Throwable) { private fun announcePluginUnhandledException(method: String, ex: Throwable) {
if(ex is PluginEngineException)
return;
try { try {
StateAnnouncement.instance.registerAnnouncement("PluginUnhandled_${config.id}_${method}", StateAnnouncement.instance.registerAnnouncement("PluginUnhandled_${config.id}_${method}",
"Plugin ${config.name} encountered an error in [${method}]", "Plugin ${config.name} encountered an error in [${method}]",
"${ex.message}\nPlease contact the plugin developer", "${ex.message}\nPlease contact the plugin developer",
AnnouncementType.RECURRING, AnnouncementType.SESSION_RECURRING,
OffsetDateTime.now()); OffsetDateTime.now());
} }
catch(_: Throwable) {} catch(_: Throwable) {}
@@ -0,0 +1,49 @@
package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.encryption.EncryptionProvider
import com.futo.platformplayer.logging.Logger
import kotlinx.serialization.Serializable
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()) {
override fun toString(): String {
return "(headers: '$headers', cookieString: '$cookieMap')";
}
fun toEncrypted(): String{
return EncryptionProvider.instance.encrypt(serialize());
}
private fun serialize(): String {
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers));
}
companion object {
val TAG = "SourceAuth";
fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
if(encrypted == null)
return null;
val decrypted = EncryptionProvider.instance.decrypt(encrypted);
try {
return deserialize(decrypted);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to deserialize authentication", ex);
return null;
}
}
fun deserialize(str: String): SourceCaptchaData {
val data = Json.decodeFromString<SerializedCaptchaData>(str);
return SourceCaptchaData(data.cookieMap, data.headers);
}
}
@Serializable
data class SerializedCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>?,
val headers: Map<String, Map<String, String>> = mapOf())
}
@@ -0,0 +1,12 @@
package com.futo.platformplayer.api.media.platforms.js
import kotlinx.serialization.Serializable
@Serializable
class SourcePluginCaptchaConfig(
val captchaUrl: String? = null,
val completionUrl: String? = null,
val cookiesToFind: List<String>? = null,
val userAgent: String? = null,
val cookiesExclOthers: Boolean = true
)
@@ -35,11 +35,13 @@ class SourcePluginConfig(
val settings: List<Setting> = listOf(), val settings: List<Setting> = listOf(),
var captcha: SourcePluginCaptchaConfig? = null,
val authentication: SourcePluginAuthConfig? = null, val authentication: SourcePluginAuthConfig? = null,
var sourceUrl: String? = null, var sourceUrl: String? = null,
val constants: HashMap<String, String> = hashMapOf(), val constants: HashMap<String, String> = hashMapOf(),
//TODO: These should be vals...but prob for serialization reasons cannot be changed. //TODO: These should be vals...but prob for serialization reasons cannot be changed.
var subscriptionRateLimit: Int? = null,
var enableInSearch: Boolean = true, var enableInSearch: Boolean = true,
var enableInHome: Boolean = true, var enableInHome: Boolean = true,
var supportedClaimTypes: List<Int> = listOf() var supportedClaimTypes: List<Int> = listOf()
@@ -13,22 +13,28 @@ class SourcePluginDescriptor {
var appSettings: AppPluginSettings = AppPluginSettings(); var appSettings: AppPluginSettings = AppPluginSettings();
var authEncrypted: String? var authEncrypted: String? = null
private set;
var captchaEncrypted: String? = null
private set; private set;
val flags: List<String>; val flags: List<String>;
@kotlinx.serialization.Transient @kotlinx.serialization.Transient
val onAuthChanged = Event0(); val onAuthChanged = Event0();
@kotlinx.serialization.Transient
val onCaptchaChanged = Event0();
constructor(config :SourcePluginConfig, authEncrypted: String? = null) { constructor(config :SourcePluginConfig, authEncrypted: String? = null, captchaEncrypted: String? = null) {
this.config = config; this.config = config;
this.authEncrypted = authEncrypted; this.authEncrypted = authEncrypted;
this.captchaEncrypted = captchaEncrypted;
this.flags = listOf(); this.flags = listOf();
} }
constructor(config :SourcePluginConfig, authEncrypted: String? = null, flags: List<String>) { constructor(config :SourcePluginConfig, authEncrypted: String? = null, captchaEncrypted: String? = null, flags: List<String>) {
this.config = config; this.config = config;
this.authEncrypted = authEncrypted; this.authEncrypted = authEncrypted;
this.captchaEncrypted = captchaEncrypted;
this.flags = flags; this.flags = flags;
} }
@@ -41,6 +47,13 @@ class SourcePluginDescriptor {
return map; return map;
} }
fun updateCaptcha(captcha: SourceCaptchaData?) {
captchaEncrypted = captcha?.toEncrypted();
onCaptchaChanged.emit();
}
fun getCaptchaData(): SourceCaptchaData? {
return SourceCaptchaData.fromEncrypted(captchaEncrypted);
}
fun updateAuth(str: SourceAuth?) { fun updateAuth(str: SourceAuth?) {
authEncrypted = str?.toEncrypted(); authEncrypted = str?.toEncrypted();
@@ -5,90 +5,108 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourceAuth import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
import com.futo.platformplayer.matchesDomain import com.futo.platformplayer.matchesDomain
class JSHttpClient : ManagedHttpClient { class JSHttpClient : ManagedHttpClient {
private val _jsClient: JSClient?; private val _jsClient: JSClient?;
private val _auth: SourceAuth?; private val _auth: SourceAuth?;
private val _captcha: SourceCaptchaData?;
var doUpdateCookies: Boolean = true; var doUpdateCookies: Boolean = true;
var doApplyCookies: Boolean = true; var doApplyCookies: Boolean = true;
var doAllowNewCookies: Boolean = true; var doAllowNewCookies: Boolean = true;
val isLoggedIn: Boolean get() = _auth != null; val isLoggedIn: Boolean get() = _auth != null;
private var _currentCookieMap: HashMap<String, HashMap<String, String>>?; private var _currentCookieMap: HashMap<String, HashMap<String, String>>;
constructor(jsClient: JSClient?, auth: SourceAuth? = null) : super() { constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null) : super() {
_jsClient = jsClient; _jsClient = jsClient;
_auth = auth; _auth = auth;
_captcha = captcha;
_currentCookieMap = hashMapOf();
if(!auth?.cookieMap.isNullOrEmpty()) { if(!auth?.cookieMap.isNullOrEmpty()) {
_currentCookieMap = hashMapOf();
for(domainCookies in auth!!.cookieMap!!) for(domainCookies in auth!!.cookieMap!!)
_currentCookieMap!!.put(domainCookies.key, HashMap(domainCookies.value)); _currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value));
} }
else _currentCookieMap = null; if(!captcha?.cookieMap.isNullOrEmpty()) {
for(domainCookies in captcha!!.cookieMap!!) {
if(_currentCookieMap.containsKey(domainCookies.key))
_currentCookieMap[domainCookies.key]?.putAll(domainCookies.value);
else
_currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value));
}
}
} }
override fun clone(): ManagedHttpClient { override fun clone(): ManagedHttpClient {
val newClient = JSHttpClient(_jsClient, _auth); val newClient = JSHttpClient(_jsClient, _auth);
newClient._currentCookieMap = if(_currentCookieMap != null) newClient._currentCookieMap = if(_currentCookieMap != null)
HashMap(_currentCookieMap!!.toList().associate { Pair(it.first, HashMap(it.second)) }) HashMap(_currentCookieMap.toList().associate { Pair(it.first, HashMap(it.second)) })
else else
null; hashMapOf();
return newClient; return newClient;
} }
override fun beforeRequest(request: Request) { override fun beforeRequest(request: okhttp3.Request): okhttp3.Request {
val domain = request.url.host.lowercase();
val auth = _auth; val auth = _auth;
if (auth != null) {
val domain = Uri.parse(request.url).host!!.lowercase();
val newBuilder = if(auth != null || doApplyCookies)
request.newBuilder();
else
null;
if (auth != null) {
//TODO: Possibly add doApplyHeaders //TODO: Possibly add doApplyHeaders
for (header in auth.headers.filter { domain.matchesDomain(it.key) }.flatMap { it.value.entries }) for (header in auth.headers.filter { domain.matchesDomain(it.key) }.flatMap { it.value.entries })
request.headers[header.key] = header.value; newBuilder?.header(header.key, header.value);
}
if(doApplyCookies) { if(doApplyCookies) {
if (!_currentCookieMap.isNullOrEmpty()) { if (!_currentCookieMap.isNullOrEmpty()) {
val cookiesToApply = hashMapOf<String, String>(); val cookiesToApply = hashMapOf<String, String>();
synchronized(_currentCookieMap!!) { synchronized(_currentCookieMap!!) {
for(cookie in _currentCookieMap!! for(cookie in _currentCookieMap!!
.filter { domain.matchesDomain(it.key) } .filter { domain.matchesDomain(it.key) }
.flatMap { it.value.toList() }) .flatMap { it.value.toList() })
cookiesToApply[cookie.first] = cookie.second; cookiesToApply[cookie.first] = cookie.second;
}; };
if(cookiesToApply.size > 0) { if(cookiesToApply.size > 0) {
val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; "); val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; ");
request.headers["Cookie"] = cookieString;
} val existingCookies = request.headers["Cookie"];
//printTestCode(request.url, request.body, auth.headers, cookieString, request.headers.filter { !auth.headers.containsKey(it.key) }); if(!existingCookies.isNullOrEmpty())
newBuilder?.header("Cookie", existingCookies.trim(';') + "; " + cookieString);
else
newBuilder?.header("Cookie", cookieString);
} }
//printTestCode(request.url, request.body, auth.headers, cookieString, request.headers.filter { !auth.headers.containsKey(it.key) });
} }
} }
_jsClient?.validateUrlOrThrow(request.url); _jsClient?.validateUrlOrThrow(request.url.toString());
super.beforeRequest(request) return newBuilder?.let { it.build() } ?: request;
} }
override fun afterRequest(request: Request, resp: Response) { override fun afterRequest(resp: okhttp3.Response): okhttp3.Response {
super.afterRequest(request, resp)
if(doUpdateCookies) { if(doUpdateCookies) {
val domain = Uri.parse(request.url).host!!.lowercase(); val domain = resp.request.url.host.lowercase();
val domainParts = domain!!.split("."); val domainParts = domain.split(".");
val defaultCookieDomain = val defaultCookieDomain =
"." + domainParts.drop(domainParts.size - 2).joinToString("."); "." + domainParts.drop(domainParts.size - 2).joinToString(".");
for (header in resp.headers) { for (header in resp.headers) {
if (_currentCookieMap != null && header.key.lowercase() == "set-cookie") { if ((_auth != null || _currentCookieMap.isNotEmpty()) && header.first.lowercase() == "set-cookie") {
val newCookies = cookieStringToMap(header.value); //val newCookies = cookieStringToMap(header.second.split("; "));
for (cookie in newCookies) { val cookie = cookieStringToPair(header.second);
val endIndex = cookie.value.indexOf(";"); //for (cookie in newCookies) {
var cookieValue = cookie.value; var cookieValue = cookie.second;
var domainToUse = domain; var domainToUse = domain;
if (endIndex > 0) { if (!cookie.first.isNullOrEmpty() && !cookie.second.isNullOrEmpty()) {
val cookieParts = cookie.value.split(";"); val cookieParts = cookie.second.split(";");
if (cookieParts.size == 0) if (cookieParts.size == 0)
continue; continue;
cookieValue = cookieParts[0].trim(); cookieValue = cookieParts[0].trim();
@@ -114,24 +132,29 @@ class JSHttpClient : ManagedHttpClient {
_currentCookieMap!!.put(domainToUse, newMap) _currentCookieMap!!.put(domainToUse, newMap)
newMap; newMap;
} }
if(cookieMap.containsKey(cookie.key) || doAllowNewCookies) if(cookieMap.containsKey(cookie.first) || doAllowNewCookies)
cookieMap.put(cookie.key, cookieValue); cookieMap.put(cookie.first, cookieValue);
} //}
} }
} }
} }
return resp;
} }
private fun cookieStringToMap(parts: List<String>): Map<String, String> { private fun cookieStringToMap(parts: List<String>): Map<String, String> {
val map = hashMapOf<String, String>(); val map = hashMapOf<String, String>();
for(cookie in parts) { for(cookie in parts) {
val cookieKey = cookie.substring(0, cookie.indexOf("=")); val pair = cookieStringToPair(cookie)
val cookieVal = cookie.substring(cookie.indexOf("=") + 1); map.put(pair.first, pair.second);
map.put(cookieKey.trim(), cookieVal.trim());
} }
return map; return map;
} }
private fun cookieStringToPair(cookie: String): Pair<String, String> {
val cookieKey = cookie.substring(0, cookie.indexOf("="));
val cookieVal = cookie.substring(cookie.indexOf("=") + 1);
return Pair(cookieKey.trim(), cookieVal.trim());
}
//Prints out code for test reproduction.. //Prints out code for test reproduction..
fun printTestCode(url: String, body: ByteArray?, headers: Map<String, String>, cookieString: String, allHeaders: Map<String, String>? = null) { fun printTestCode(url: String, body: ByteArray?, headers: Map<String, String>, cookieString: String, allHeaders: Map<String, String>? = null) {
@@ -155,4 +178,5 @@ class JSHttpClient : ManagedHttpClient {
Logger.i("Testing", code); Logger.i("Testing", code);
} }
} }
@@ -42,7 +42,6 @@ open class JSContent : IPlatformContent, IPluginSourced {
id = PlatformID.fromV8(_pluginConfig, _content.getOrThrow(config, "id", contextName)); id = PlatformID.fromV8(_pluginConfig, _content.getOrThrow(config, "id", contextName));
name = HtmlCompat.fromHtml(_content.getOrThrow<String>(config, "name", contextName).decodeUnicode(), HtmlCompat.FROM_HTML_MODE_LEGACY).toString(); name = HtmlCompat.fromHtml(_content.getOrThrow<String>(config, "name", contextName).decodeUnicode(), HtmlCompat.FROM_HTML_MODE_LEGACY).toString();
Logger.i("JSContent", "name=$name");
author = PlatformAuthorLink.fromV8(_pluginConfig, _content.getOrThrow(config, "author", contextName)); author = PlatformAuthorLink.fromV8(_pluginConfig, _content.getOrThrow(config, "author", contextName));
val datetimeInt = _content.getOrThrow<Int>(config, "datetime", contextName).toLong(); val datetimeInt = _content.getOrThrow<Int>(config, "datetime", contextName).toLong();
@@ -7,6 +7,7 @@ import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.warnIfMainThread import com.futo.platformplayer.warnIfMainThread
@@ -27,7 +28,7 @@ abstract class JSPager<T> : IPager<T> {
this.pager = pager; this.pager = pager;
this.config = config; this.config = config;
_hasMorePages = pager.getOrThrow(config, "hasMore", "Pager"); _hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
getResults(); getResults();
} }
@@ -45,7 +46,7 @@ abstract class JSPager<T> : IPager<T> {
pager = plugin.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") { pager = plugin.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") {
pager.invoke("nextPage", arrayOf<Any>()); pager.invoke("nextPage", arrayOf<Any>());
}; };
_hasMorePages = pager.getOrThrow(config, "hasMore", "Pager"); _hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
_resultChanged = true; _resultChanged = true;
/* /*
try { try {
@@ -1,6 +1,7 @@
package com.futo.platformplayer.api.media.platforms.js.models package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.primitive.V8ValueNull
import com.caoccao.javet.values.reference.V8ValueArray import com.caoccao.javet.values.reference.V8ValueArray
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
@@ -99,8 +100,11 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
return getCommentsJS(client); return getCommentsJS(client);
} }
private fun getCommentsJS(client: JSClient): JSCommentPager { private fun getCommentsJS(client: JSClient): IPager<IPlatformComment>? {
val commentPager = _content.invoke<V8ValueObject>("getComments", arrayOf<Any>()); val commentPager = _content.invoke<V8Value>("getComments", arrayOf<Any>());
if (commentPager !is V8ValueObject) //TODO: Maybe handle this better?
return null;
return JSCommentPager(_pluginConfig, client.getUnderlyingPlugin(), commentPager); return JSCommentPager(_pluginConfig, client.getUnderlyingPlugin(), commentPager);
} }
} }
@@ -9,7 +9,7 @@ import com.futo.platformplayer.getOrNull
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.orNull import com.futo.platformplayer.orNull
class JSHLSManifestAudioSource : IAudioUrlSource, IHLSManifestAudioSource, JSSource { class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
override val container : String get() = "application/vnd.apple.mpegurl"; override val container : String get() = "application/vnd.apple.mpegurl";
override val codec: String = "HLS"; override val codec: String = "HLS";
override val name : String; override val name : String;
@@ -31,9 +31,6 @@ class JSHLSManifestAudioSource : IAudioUrlSource, IHLSManifestAudioSource, JSSou
priority = obj.getOrNull(config, "priority", contextName) ?: false; priority = obj.getOrNull(config, "priority", contextName) ?: false;
} }
override fun getAudioUrl(): String {
return url;
}
companion object { companion object {
fun fromV8HLSNullable(config: IV8PluginConfig, obj: V8Value?) : JSHLSManifestAudioSource? = obj.orNull { fromV8HLS(config, it as V8ValueObject) }; fun fromV8HLSNullable(config: IV8PluginConfig, obj: V8Value?) : JSHLSManifestAudioSource? = obj.orNull { fromV8HLS(config, it as V8ValueObject) };
@@ -7,7 +7,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrNull
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
class JSHLSManifestSource : IVideoUrlSource, IHLSManifestSource, JSSource { class JSHLSManifestSource : IHLSManifestSource, JSSource {
override val width : Int = 0; override val width : Int = 0;
override val height : Int = 0; override val height : Int = 0;
override val container : String get() = "application/vnd.apple.mpegurl"; override val container : String get() = "application/vnd.apple.mpegurl";
@@ -28,8 +28,4 @@ class JSHLSManifestSource : IVideoUrlSource, IHLSManifestSource, JSSource {
priority = obj.getOrNull(config, "priority", contextName) ?: false; priority = obj.getOrNull(config, "priority", contextName) ?: false;
} }
override fun getVideoUrl(): String {
return url;
}
} }
@@ -25,7 +25,8 @@ class DedupContentPager : IPager<IPlatformContent>, IAsyncPager<IPlatformContent
_currentResults = dedupResults(_basePager.getResults()); _currentResults = dedupResults(_basePager.getResults());
} }
override fun hasMorePages(): Boolean = _basePager.hasMorePages(); override fun hasMorePages(): Boolean =
_basePager.hasMorePages();
override fun nextPage() { override fun nextPage() {
_basePager.nextPage() _basePager.nextPage()
_currentResults = dedupResults(_basePager.getResults()); _currentResults = dedupResults(_basePager.getResults());
@@ -7,7 +7,7 @@ import java.util.stream.IntStream
* A PlatformContent MultiPager that orders the results of a page based on the datetime of a content item * A PlatformContent MultiPager that orders the results of a page based on the datetime of a content item
*/ */
class MultiChronoContentPager : MultiPager<IPlatformContent> { class MultiChronoContentPager : MultiPager<IPlatformContent> {
constructor(pagers : Array<IPager<IPlatformContent>>, allowFailure: Boolean = false) : super(pagers.map { it }.toList(), allowFailure) {} constructor(pagers : Array<IPager<IPlatformContent>>, allowFailure: Boolean = false, pageSize: Int = 9) : super(pagers.map { it }.toList(), allowFailure, pageSize) {}
@Synchronized @Synchronized
override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int { override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int {
@@ -16,7 +16,7 @@ abstract class MultiPager<T> : IPager<T> {
protected val _subSinglePagers : MutableList<SingleItemPager<T>>; protected val _subSinglePagers : MutableList<SingleItemPager<T>>;
protected val _failedPagers: ArrayList<IPager<T>> = arrayListOf(); protected val _failedPagers: ArrayList<IPager<T>> = arrayListOf();
private val _pageSize : Int = 9; private var _pageSize : Int = 9;
private var _didInitialize = false; private var _didInitialize = false;
@@ -27,7 +27,8 @@ abstract class MultiPager<T> : IPager<T> {
val totalPagers: Int get() = _pagers.size; val totalPagers: Int get() = _pagers.size;
constructor(pagers : List<IPager<T>>, allowFailure: Boolean = false) { constructor(pagers : List<IPager<T>>, allowFailure: Boolean = false, pageSize: Int = 9) {
this._pageSize = pageSize;
this.allowFailure = allowFailure; this.allowFailure = allowFailure;
_pagers = pagers.toMutableList(); _pagers = pagers.toMutableList();
_subSinglePagers = _pagers.map { SingleItemPager(it) }.toMutableList(); _subSinglePagers = _pagers.map { SingleItemPager(it) }.toMutableList();
@@ -137,11 +137,11 @@ abstract class MultiParallelPager<T> : IPager<T>, IAsyncPager<T> {
} }
} }
} }
Logger.i(TAG, "Pager prepare in ${timeForPage}ms"); Logger.v(TAG, "Pager prepare in ${timeForPage}ms");
val timeAwait = measureTimeMillis { val timeAwait = measureTimeMillis {
_currentResults = results.map { it.await() }.mapNotNull { it }; _currentResults = results.map { it.await() }.mapNotNull { it };
}; };
Logger.i(TAG, "Pager load in ${timeAwait}ms"); Logger.v(TAG, "Pager load in ${timeAwait}ms");
_currentResultExceptions = exceptions; _currentResultExceptions = exceptions;
return _currentResults; return _currentResults;
@@ -1,5 +1,6 @@
package com.futo.platformplayer.api.media.structures package com.futo.platformplayer.api.media.structures
import com.futo.platformplayer.api.media.models.contents.PlatformContentPlaceholder
import com.futo.platformplayer.api.media.structures.ReusablePager.Companion.asReusable import com.futo.platformplayer.api.media.structures.ReusablePager.Companion.asReusable
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -37,8 +38,12 @@ abstract class MultiRefreshPager<T>: IRefreshPager<T>, IPager<T> {
synchronized(_pending) { synchronized(_pending) {
_pending.remove(pendingPager); _pending.remove(pendingPager);
} }
if(error != null) if(error != null) {
onPagerError.emit(error); onPagerError.emit(error);
val replacing = _placeHolderPagersPaired[pendingPager];
if(replacing != null)
updatePager(null, replacing, error);
}
else else
updatePager(pendingPager.getCompleted()); updatePager(pendingPager.getCompleted());
} }
@@ -60,9 +65,25 @@ abstract class MultiRefreshPager<T>: IRefreshPager<T>, IPager<T> {
override fun nextPage() = synchronized(_pagersReusable){ _currentPager.nextPage() }; override fun nextPage() = synchronized(_pagersReusable){ _currentPager.nextPage() };
override fun getResults(): List<T> = synchronized(_pagersReusable){ _currentPager.getResults() }; override fun getResults(): List<T> = synchronized(_pagersReusable){ _currentPager.getResults() };
private fun updatePager(pagerToAdd: IPager<T>?) { private fun updatePager(pagerToAdd: IPager<T>?, toReplacePager: IPager<T>? = null, error: Throwable? = null) {
if(pagerToAdd == null) if(pagerToAdd == null) {
if(toReplacePager != null && toReplacePager is PlaceholderPager && error != null) {
val pluginId = toReplacePager.placeholderFactory.invoke().id?.pluginId ?: "";
_pagersReusable.add((PlaceholderPager(5) {
return@PlaceholderPager PlatformContentPlaceholder(pluginId, error)
} as IPager<T>).asReusable());
_currentPager = recreatePager(getCurrentSubPagers());
if(_currentPager is MultiParallelPager<*>)
runBlocking { (_currentPager as MultiParallelPager).initialize(); };
else if(_currentPager is MultiPager<*>)
(_currentPager as MultiPager).initialize()
onPagerChanged.emit(_currentPager);
}
return; return;
}
synchronized(_pagersReusable) { synchronized(_pagersReusable) {
Logger.i("RefreshMultiDistributionContentPager", "Received new pager for RefreshPager") Logger.i("RefreshMultiDistributionContentPager", "Received new pager for RefreshPager")
_pagersReusable.add(pagerToAdd.asReusable()); _pagersReusable.add(pagerToAdd.asReusable());
@@ -6,11 +6,11 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent
* A placeholder pager simply generates PlatformContent by some creator function. * A placeholder pager simply generates PlatformContent by some creator function.
*/ */
class PlaceholderPager : IPager<IPlatformContent> { class PlaceholderPager : IPager<IPlatformContent> {
private val _creator: ()->IPlatformContent; val placeholderFactory: ()->IPlatformContent;
private val _pageSize: Int; private val _pageSize: Int;
constructor(pageSize: Int, placeholderCreator: ()->IPlatformContent) { constructor(pageSize: Int, placeholderCreator: ()->IPlatformContent) {
_creator = placeholderCreator; placeholderFactory = placeholderCreator;
_pageSize = pageSize; _pageSize = pageSize;
} }
@@ -18,7 +18,7 @@ class PlaceholderPager : IPager<IPlatformContent> {
override fun getResults(): List<IPlatformContent> { override fun getResults(): List<IPlatformContent> {
val pages = ArrayList<IPlatformContent>(); val pages = ArrayList<IPlatformContent>();
for(item in 1.._pageSize) for(item in 1.._pageSize)
pages.add(_creator()); pages.add(placeholderFactory());
return pages; return pages;
} }
override fun hasMorePages(): Boolean = true; override fun hasMorePages(): Boolean = true;
@@ -111,7 +111,7 @@ class ChannelContentCache {
init { init {
val results = pager.getResults(); val results = pager.getResults();
Logger.i(TAG, "Caching ${results.size} subscription initial results"); Logger.i(TAG, "Caching ${results.size} subscription initial results [${pager.hashCode()}]");
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
val newCacheItems = instance.cacheVideos(results); val newCacheItems = instance.cacheVideos(results);
@@ -64,7 +64,7 @@ class StateCasting {
} }
override fun serviceResolved(event: ServiceEvent) { override fun serviceResolved(event: ServiceEvent) {
Logger.i(TAG, "ChromeCast service resolved: " + event.info); Logger.v(TAG, "ChromeCast service resolved: " + event.info);
addOrUpdateDevice(event); addOrUpdateDevice(event);
} }
@@ -8,6 +8,10 @@ abstract class EventBase<Handler, ConditionalHandler>: IEvent {
protected val _conditionalListeners = mutableListOf<TaggedHandler<ConditionalHandler>>(); protected val _conditionalListeners = mutableListOf<TaggedHandler<ConditionalHandler>>();
protected val _listeners = mutableListOf<TaggedHandler<Handler>>(); protected val _listeners = mutableListOf<TaggedHandler<Handler>>();
fun hasListeners(): Boolean =
synchronized(_listeners){_listeners.isNotEmpty()} ||
synchronized(_conditionalListeners){_conditionalListeners.isNotEmpty()};
fun subscribeConditional(listener: ConditionalHandler) { fun subscribeConditional(listener: ConditionalHandler) {
synchronized(_conditionalListeners) { synchronized(_conditionalListeners) {
_conditionalListeners.add(TaggedHandler(listener)); _conditionalListeners.add(TaggedHandler(listener));
@@ -65,10 +69,7 @@ abstract class EventBase<Handler, ConditionalHandler>: IEvent {
class Event0() : EventBase<(()->Unit), (()->Boolean)>() { class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
fun emit() : Boolean { fun emit() : Boolean {
var handled: Boolean; var handled = false;
synchronized(_listeners) {
handled = _listeners.isNotEmpty();
}
synchronized(_conditionalListeners) { synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners) for (conditional in _conditionalListeners)
@@ -76,6 +77,7 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
} }
synchronized(_listeners) { synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners) for (handler in _listeners)
handler.handler.invoke(); handler.handler.invoke();
} }
@@ -85,17 +87,14 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
} }
class Event1<T1>() : EventBase<((T1)->Unit), ((T1)->Boolean)>() { class Event1<T1>() : EventBase<((T1)->Unit), ((T1)->Boolean)>() {
fun emit(value : T1): Boolean { fun emit(value : T1): Boolean {
var handled: Boolean; var handled = false;
synchronized(_listeners) {
handled = _listeners.isNotEmpty();
}
synchronized(_conditionalListeners) { synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners) for (conditional in _conditionalListeners)
handled = handled || conditional.handler.invoke(value); handled = handled || conditional.handler.invoke(value);
} }
synchronized(_listeners) { synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners) for (handler in _listeners)
handler.handler.invoke(value); handler.handler.invoke(value);
} }
@@ -105,10 +104,7 @@ class Event1<T1>() : EventBase<((T1)->Unit), ((T1)->Boolean)>() {
} }
class Event2<T1, T2>() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() { class Event2<T1, T2>() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() {
fun emit(value1 : T1, value2 : T2): Boolean { fun emit(value1 : T1, value2 : T2): Boolean {
var handled: Boolean; var handled = false;
synchronized(_listeners) {
handled = _listeners.isNotEmpty();
}
synchronized(_conditionalListeners) { synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners) for (conditional in _conditionalListeners)
@@ -116,6 +112,7 @@ class Event2<T1, T2>() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() {
} }
synchronized(_listeners) { synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners) for (handler in _listeners)
handler.handler.invoke(value1, value2); handler.handler.invoke(value1, value2);
} }
@@ -126,10 +123,7 @@ class Event2<T1, T2>() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() {
class Event3<T1, T2, T3>() : EventBase<((T1, T2, T3)->Unit), ((T1, T2, T3)->Boolean)>() { class Event3<T1, T2, T3>() : EventBase<((T1, T2, T3)->Unit), ((T1, T2, T3)->Boolean)>() {
fun emit(value1 : T1, value2 : T2, value3 : T3): Boolean { fun emit(value1 : T1, value2 : T2, value3 : T3): Boolean {
var handled: Boolean; var handled = false;
synchronized(_listeners) {
handled = _listeners.isNotEmpty();
}
synchronized(_conditionalListeners) { synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners) for (conditional in _conditionalListeners)
@@ -137,6 +131,7 @@ class Event3<T1, T2, T3>() : EventBase<((T1, T2, T3)->Unit), ((T1, T2, T3)->Bool
} }
synchronized(_listeners) { synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners) for (handler in _listeners)
handler.handler.invoke(value1, value2, value3); handler.handler.invoke(value1, value2, value3);
} }
@@ -57,6 +57,7 @@ class TaskHandler<TParameter, TResult> {
fun run(parameter: TParameter) { fun run(parameter: TParameter) {
val id = ++_idGenerator; val id = ++_idGenerator;
var handled = false;
_scope().launch(_dispatcher) { _scope().launch(_dispatcher) {
if (id != _idGenerator) if (id != _idGenerator)
return@launch; return@launch;
@@ -67,35 +68,53 @@ class TaskHandler<TParameter, TResult> {
return@launch; return@launch;
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (id != _idGenerator) if (id != _idGenerator) {
handled = true;
return@withContext; return@withContext;
}
try { try {
onSuccess.emit(result); onSuccess.emit(result);
handled = true;
} }
catch (e: Throwable) { catch (e: Throwable) {
Logger.w(TAG, "Handled exception in TaskHandler onSuccess.", e); Logger.w(TAG, "Handled exception in TaskHandler onSuccess.", e);
onError.emit(e, parameter); onError.emit(e, parameter);
handled = true;
} }
} }
} }
catch (e: Throwable) { catch (e: Throwable) {
Log.i("TaskHandler", "TaskHandler.run in exception: " + e.message); Log.i("TaskHandler", "TaskHandler.run in exception: " + e.message);
if (id != _idGenerator) if (id != _idGenerator) {
handled = true;
return@launch; return@launch;
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
handled = true;
if (id != _idGenerator) if (id != _idGenerator)
return@withContext; return@withContext;
if (!onError.emit(e, parameter)) { if (!onError.emit(e, parameter)) {
Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e); Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e);
} else { } else {
Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); //Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); (Prevents duplicate logs)
} }
} }
} }
} }/*.invokeOnCompletion { //Commented for now, because it doesn't fix the bug it was intended to fix, but might want it later anyway
if(!handled) {
if(it is CancellationException) {
Logger.w(TAG, "Detected unhandled TaskHandler due to cancellation, forwarding cancellation");
onError.emit(it, parameter);
}
else {
//TODO: Forward exception?
Logger.w(TAG, "Detected unhandled TaskHandler due to [${it}]", it);
}
}
}*/
} }
@Synchronized @Synchronized
@@ -1,6 +1,7 @@
package com.futo.platformplayer.developer package com.futo.platformplayer.developer
import android.content.Context import android.content.Context
import com.futo.platformplayer.activities.CaptchaActivity
import com.futo.platformplayer.activities.LoginActivity import com.futo.platformplayer.activities.LoginActivity
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.http.server.HttpContext import com.futo.platformplayer.api.http.server.HttpContext
@@ -201,6 +202,28 @@ class DeveloperEndpoints(private val context: Context) {
context.respondCode(500, (ex::class.simpleName + ":" + ex.message) ?: "", "text/plain") context.respondCode(500, (ex::class.simpleName + ":" + ex.message) ?: "", "text/plain")
} }
} }
@HttpPOST("/plugin/captchaTestPlugin")
fun pluginCaptchaTestPlugin(context: HttpContext) {
val config = _testPlugin?.config as SourcePluginConfig;
val url = context.query.get("url")
val html = context.readContentString();
try {
val captchaConfig = config.captcha;
if(captchaConfig == null) {
context.respondCode(403, "This plugin doesn't support captcha");
return;
}
CaptchaActivity.showCaptcha(StateApp.instance.context, config, url, html) {
_testPluginVariables.clear();
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, it), JSHttpClient(null, null, it));
};
context.respondCode(200, "Captcha started");
}
catch(ex: Throwable) {
context.respondCode(500, (ex::class.simpleName + ":" + ex.message) ?: "", "text/plain")
}
}
@HttpGET("/plugin/loginTestPlugin") @HttpGET("/plugin/loginTestPlugin")
fun pluginLoginTestPlugin(context: HttpContext) { fun pluginLoginTestPlugin(context: HttpContext) {
val config = _testPlugin?.config as SourcePluginConfig; val config = _testPlugin?.config as SourcePluginConfig;
@@ -416,7 +439,7 @@ class DeveloperEndpoints(private val context: Context) {
val resp = _client.get(body.url!!, body.headers); val resp = _client.get(body.url!!, body.headers);
context.respondCode(200, context.respondCode(200,
Json.encodeToString(PackageHttp.BridgeHttpResponse(resp.code, resp.body?.string())), Json.encodeToString(PackageHttp.BridgeHttpResponse(resp.url, resp.code, resp.body?.string())),
context.query.getOrDefault("CT", "text/plain")); context.query.getOrDefault("CT", "text/plain"));
} }
catch(ex: Exception) { catch(ex: Exception) {
@@ -11,6 +11,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup import com.futo.platformplayer.states.StateBackup
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
@@ -58,13 +59,13 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
} }
clearFocus(); clearFocus();
dismiss(); dismiss();
Logger.i(TAG, "Set AutoBackupPassword"); Logger.i(TAG, "Set AutoBackupPassword");
Settings.instance.backup.autoBackupPassword = _editPassword.text.toString(); Settings.instance.backup.autoBackupPassword = _editPassword.text.toString();
Settings.instance.backup.didAskAutoBackup = true; Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save(); Settings.instance.save();
UIDialogs.toast(context, "AutoBackup enabled"); UIDialogs.toast(context, "AutoBackup enabled");
try { try {
StateBackup.startAutomaticBackup(true); StateBackup.startAutomaticBackup(true);
} }
@@ -2,12 +2,16 @@ package com.futo.platformplayer.dialogs
import android.app.AlertDialog import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.WindowManager import android.view.WindowManager
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.* import android.widget.*
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink 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.IPlatformComment
@@ -32,6 +36,8 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
private lateinit var _buttonCancel: MaterialButton; private lateinit var _buttonCancel: MaterialButton;
private lateinit var _editComment: EditText; private lateinit var _editComment: EditText;
private lateinit var _inputMethodManager: InputMethodManager; private lateinit var _inputMethodManager: InputMethodManager;
private lateinit var _textCharacterCount: TextView;
private lateinit var _textCharacterCountMax: TextView;
val onCommentAdded = Event1<IPlatformComment>(); val onCommentAdded = Event1<IPlatformComment>();
@@ -42,6 +48,26 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
_buttonCancel = findViewById(R.id.button_cancel); _buttonCancel = findViewById(R.id.button_cancel);
_buttonCreate = findViewById(R.id.button_create); _buttonCreate = findViewById(R.id.button_create);
_editComment = findViewById(R.id.edit_comment); _editComment = findViewById(R.id.edit_comment);
_textCharacterCount = findViewById(R.id.character_count);
_textCharacterCountMax = findViewById(R.id.character_count_max);
_editComment.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) = Unit
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
_textCharacterCount.text = count.toString();
if (count > PolycentricPlatformComment.MAX_COMMENT_SIZE) {
_textCharacterCount.setTextColor(Color.RED);
_textCharacterCountMax.setTextColor(Color.RED);
_buttonCreate.alpha = 0.4f;
} else {
_textCharacterCount.setTextColor(Color.WHITE);
_textCharacterCountMax.setTextColor(Color.WHITE);
_buttonCreate.alpha = 1.0f;
}
}
});
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager; _inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
@@ -53,11 +79,16 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
_buttonCreate.setOnClickListener { _buttonCreate.setOnClickListener {
clearFocus(); clearFocus();
if (_editComment.text.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) {
UIDialogs.toast(context, "Comment should be less than 5000 characters");
return@setOnClickListener;
}
val comment = _editComment.text.toString(); val comment = _editComment.text.toString();
val processHandle = StatePolycentric.instance.processHandle!! val processHandle = StatePolycentric.instance.processHandle!!
val eventPointer = processHandle.post(comment, null, ref) val eventPointer = processHandle.post(comment, null, ref)
StateApp.instance.scopeGetter().launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
processHandle.fullyBackfillServers() processHandle.fullyBackfillServers()
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -1,8 +1,19 @@
package com.futo.platformplayer.downloads package com.futo.platformplayer.downloads
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
data class PlaylistDownloadDescriptor( data class PlaylistDownloadDescriptor(
val id: String, val id: String,
val targetPxCount: Long?, val targetPxCount: Long?,
val targetBitrate: Long? val targetBitrate: Long?
); ) {
var preventDownload: MutableList<String> = arrayListOf();
fun getPreventDownloadList(): List<String> = synchronized(preventDownload){ preventDownload };
fun shouldDownload(video: IPlatformVideo): Boolean {
synchronized(preventDownload) {
return !preventDownload.contains(video.url);
}
}
}
@@ -13,12 +13,16 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.exceptions.DownloadException
import com.futo.platformplayer.hasAnySource
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.isDownloadable
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSpeed import com.futo.platformplayer.toHumanBytesSpeed
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@@ -27,7 +31,6 @@ import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.concurrent.CancellationException
import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask import java.util.concurrent.ForkJoinTask
import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.ThreadLocalRandom
@@ -147,27 +150,37 @@ class VideoDownload {
if(original !is IPlatformVideoDetails) if(original !is IPlatformVideoDetails)
throw IllegalStateException("Original content is not media?"); throw IllegalStateException("Original content is not media?");
if(original.video.hasAnySource() && !original.isDownloadable()) {
Logger.i(TAG, "Attempted to download unsupported video [${original.name}]:${original.url}");
throw DownloadException("Unsupported video for downloading", false);
}
videoDetails = SerializedPlatformVideoDetails.fromVideo(original, if (subtitleSource != null) listOf(subtitleSource!!) else listOf()); videoDetails = SerializedPlatformVideoDetails.fromVideo(original, if (subtitleSource != null) listOf(subtitleSource!!) else listOf());
if(videoSource == null && targetPixelCount != null) { if(videoSource == null && targetPixelCount != null) {
val vsource = VideoHelper.selectBestVideoSource(videoDetails!!.video, targetPixelCount!!.toInt(), arrayOf()) val vsource = VideoHelper.selectBestVideoSource(videoDetails!!.video, targetPixelCount!!.toInt(), arrayOf())
?: throw IllegalStateException("Could not find a valid video source for video"); // ?: throw IllegalStateException("Could not find a valid video source for video");
if(vsource is IVideoUrlSource) if(vsource != null) {
videoSource = VideoUrlSource.fromUrlSource(vsource); if (vsource is IVideoUrlSource)
else videoSource = VideoUrlSource.fromUrlSource(vsource);
throw IllegalStateException("Download video source is not a url source"); else
throw DownloadException("Video source is not supported for downloading (yet)", false);
}
} }
if(audioSource == null && targetBitrate != null) { if(audioSource == null && targetBitrate != null) {
val asource = VideoHelper.selectBestAudioSource(videoDetails!!.video, arrayOf(), null, targetPixelCount) val asource = VideoHelper.selectBestAudioSource(videoDetails!!.video, arrayOf(), null, targetPixelCount)
?: if(videoSource != null ) null ?: if(videoSource != null ) null
else throw IllegalStateException("Could not find a valid audio source for video"); else throw DownloadException("Could not find a valid video or audio source for download")
if(asource == null) if(asource == null)
audioSource = null; audioSource = null;
else if(asource is IAudioUrlSource) else if(asource is IAudioUrlSource)
audioSource = AudioUrlSource.fromUrlSource(asource); audioSource = AudioUrlSource.fromUrlSource(asource);
else else
throw IllegalStateException("Download audio source is not a url source"); throw DownloadException("Audio source is not supported for downloading (yet)", false);
} }
if(videoSource == null && audioSource == null)
throw DownloadException("No valid sources found for video/audio");
} }
} }
suspend fun download(client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope { suspend fun download(client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope {
@@ -358,7 +371,7 @@ class VideoDownload {
} }
if (isCancelled) if (isCancelled)
throw IllegalStateException("Cancelled"); throw CancellationException("Cancelled");
} while (read > 0); } while (read > 0);
lastSpeed = 0; lastSpeed = 0;
@@ -410,7 +423,7 @@ class VideoDownload {
} }
if(isCancelled) if(isCancelled)
throw IllegalStateException("Cancelled"); throw CancellationException("Cancelled", null);
} }
onProgress(sourceLength, totalRead, 0); onProgress(sourceLength, totalRead, 0);
} }
@@ -1,7 +1,6 @@
package com.futo.platformplayer.engine package com.futo.platformplayer.engine
import android.content.Context import android.content.Context
import android.os.Looper
import com.caoccao.javet.exceptions.JavetCompilationException import com.caoccao.javet.exceptions.JavetCompilationException
import com.caoccao.javet.exceptions.JavetExecutionException import com.caoccao.javet.exceptions.JavetExecutionException
import com.caoccao.javet.interop.V8Host import com.caoccao.javet.interop.V8Host
@@ -18,9 +17,7 @@ import com.futo.platformplayer.engine.exceptions.*
import com.futo.platformplayer.engine.internal.V8Converter import com.futo.platformplayer.engine.internal.V8Converter
import com.futo.platformplayer.engine.packages.* import com.futo.platformplayer.engine.packages.*
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateAssets import com.futo.platformplayer.states.StateAssets
import kotlinx.coroutines.*
class V8Plugin { class V8Plugin {
val config: IV8PluginConfig; val config: IV8PluginConfig;
@@ -31,14 +28,31 @@ class V8Plugin {
val httpClient: ManagedHttpClient get() = _client; val httpClient: ManagedHttpClient get() = _client;
val httpClientAuth: ManagedHttpClient get() = _clientAuth; val httpClientAuth: ManagedHttpClient get() = _clientAuth;
private val _runtimeLock = Object();
var _runtime : V8Runtime? = null; var _runtime : V8Runtime? = null;
private val _deps : LinkedHashMap<String, String> = LinkedHashMap(); private val _deps : LinkedHashMap<String, String> = LinkedHashMap();
private val _depsPackages : MutableList<V8Package> = mutableListOf(); private val _depsPackages : MutableList<V8Package> = mutableListOf();
private var _script : String? = null; private var _script : String? = null;
var isStopped = true;
val onStopped = Event1<V8Plugin>(); val onStopped = Event1<V8Plugin>();
//TODO: Implement a more universal isBusy system for plugins + JSClient + pooling? TBD if propagation would be beneficial
private val _busyCounterLock = Object();
private var _busyCounter = 0;
val isBusy get() = synchronized(_busyCounterLock) { _busyCounter > 0 };
/**
* Called before a busy counter is about to be removed.
* Is primarily used to prevent additional calls to dead runtimes.
*
* Parameter is the busy count after this execution
*/
val afterBusy = Event1<Int>();
val onScriptException = Event1<ScriptException>();
constructor(context: Context, config: IV8PluginConfig, script: String? = null, client: ManagedHttpClient = ManagedHttpClient(), clientAuth: ManagedHttpClient = ManagedHttpClient()) { constructor(context: Context, config: IV8PluginConfig, script: String? = null, client: ManagedHttpClient = ManagedHttpClient(), clientAuth: ManagedHttpClient = ManagedHttpClient()) {
this._client = client; this._client = client;
this._clientAuth = clientAuth; this._clientAuth = clientAuth;
@@ -81,7 +95,7 @@ class V8Plugin {
fun start() { fun start() {
val script = _script ?: throw IllegalStateException("Attempted to start V8 without script"); val script = _script ?: throw IllegalStateException("Attempted to start V8 without script");
synchronized(this) { synchronized(_runtimeLock) {
if (_runtime != null) if (_runtime != null)
return; return;
@@ -121,19 +135,25 @@ class V8Plugin {
catchScriptErrors("Plugin[${config.name}]") { catchScriptErrors("Plugin[${config.name}]") {
it.getExecutor(script).executeVoid() it.getExecutor(script).executeVoid()
}; };
isStopped = false;
} }
} }
} }
fun stop(){ fun stop(){
Logger.i(TAG, "Stopping plugin [${config.name}]"); Logger.i(TAG, "Stopping plugin [${config.name}]");
synchronized(this) { isStopped = true;
_runtime?.let { whenNotBusy {
_runtime = null; synchronized(_runtimeLock) {
if(!it.isClosed && !it.isDead) isStopped = true;
it.close(); _runtime?.let {
}; _runtime = null;
if(!it.isClosed && !it.isDead)
it.close();
Logger.i(TAG, "Stopped plugin [${config.name}]");
};
}
onStopped.emit(this);
} }
onStopped.emit(this);
} }
fun execute(js: String) : V8Value { fun execute(js: String) : V8Value {
@@ -141,14 +161,53 @@ class V8Plugin {
} }
fun <T : V8Value> executeTyped(js: String) : T { fun <T : V8Value> executeTyped(js: String) : T {
warnIfMainThread("V8Plugin.executeTyped"); warnIfMainThread("V8Plugin.executeTyped");
if(isStopped)
throw PluginEngineStoppedException(config, "Instance is stopped", js);
synchronized(_busyCounterLock) {
_busyCounter++;
}
val runtime = _runtime ?: throw IllegalStateException("JSPlugin not started yet"); val runtime = _runtime ?: throw IllegalStateException("JSPlugin not started yet");
return catchScriptErrors("Plugin[${config.name}]", js) { runtime.getExecutor(js).execute() }; try {
return catchScriptErrors("Plugin[${config.name}]", js) {
runtime.getExecutor(js).execute()
};
}
finally {
synchronized(_busyCounterLock) {
//Free busy *after* afterBusy calls are done to prevent calls on dead runtimes
try {
afterBusy.emit(_busyCounter - 1);
}
catch(ex: Throwable) {
Logger.e(TAG, "Unhandled V8Plugin.afterBusy", ex);
}
_busyCounter--;
}
}
} }
fun executeBoolean(js: String) : Boolean? = catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueBoolean>(js).value }; fun executeBoolean(js: String) : Boolean? = catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueBoolean>(js).value };
fun executeString(js: String) : String? = catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueString>(js).value }; fun executeString(js: String) : String? = catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueString>(js).value };
fun executeInteger(js: String) : Int? = catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueInteger>(js).value }; fun executeInteger(js: String) : Int? = catchScriptErrors("Plugin[${config.name}]") { executeTyped<V8ValueInteger>(js).value };
fun whenNotBusy(handler: (V8Plugin)->Unit) {
synchronized(_busyCounterLock) {
if(_busyCounter == 0)
handler(this);
else {
val tag = Object();
afterBusy.subscribe(tag) {
if(it == 0) {
Logger.w(TAG, "V8Plugin afterBusy handled");
afterBusy.remove(tag);
handler(this);
}
}
}
}
}
private fun getPackage(context: Context, packageName: String): V8Package { private fun getPackage(context: Context, packageName: String): V8Package {
//TODO: Auto get all package types? //TODO: Auto get all package types?
return when(packageName) { return when(packageName) {
@@ -160,7 +219,13 @@ class V8Plugin {
} }
fun <T : Any> catchScriptErrors(context: String, code: String? = null, handle: ()->T): T { fun <T : Any> catchScriptErrors(context: String, code: String? = null, handle: ()->T): T {
return catchScriptErrors(this.config, context, code, handle); try {
return catchScriptErrors(this.config, context, code, handle);
}
catch(ex: ScriptException) {
onScriptException.emit(ex);
throw ex;
}
} }
companion object { companion object {
@@ -185,7 +250,7 @@ class V8Plugin {
if(result is V8ValueObject) { if(result is V8ValueObject) {
val type = result.getString("plugin_type"); val type = result.getString("plugin_type");
if(type != null && type.endsWith("Exception")) if(type != null && type.endsWith("Exception"))
Companion.throwExceptionFromV8( throwExceptionFromV8(
config, config,
result.getOrThrow(config, "plugin_type", "V8Plugin"), result.getOrThrow(config, "plugin_type", "V8Plugin"),
result.getOrThrow(config, "message", "V8Plugin"), result.getOrThrow(config, "message", "V8Plugin"),
@@ -202,19 +267,28 @@ class V8Plugin {
throw ScriptCompilationException(config, "Compilation: ${scriptEx.message}\n(${scriptEx.scriptingError.lineNumber})[${scriptEx.scriptingError.startColumn}-${scriptEx.scriptingError.endColumn}]: ${scriptEx.scriptingError.sourceLine}", null, codeStripped); throw ScriptCompilationException(config, "Compilation: ${scriptEx.message}\n(${scriptEx.scriptingError.lineNumber})[${scriptEx.scriptingError.startColumn}-${scriptEx.scriptingError.endColumn}]: ${scriptEx.scriptingError.sourceLine}", null, codeStripped);
} }
catch(executeEx: JavetExecutionException) { catch(executeEx: JavetExecutionException) {
val exMessage = extractJSExceptionMessage(executeEx); if(executeEx.scriptingError?.context?.containsKey("plugin_type") == true) {
val pluginType = executeEx.scriptingError.context["plugin_type"].toString();
if(executeEx.scriptingError?.context?.containsKey("plugin_type") == true) //Captcha
if (pluginType == "CaptchaRequiredException") {
throw ScriptCaptchaRequiredException(config,
executeEx.scriptingError.context["url"]?.toString(),
executeEx.scriptingError.context["body"]?.toString(),
executeEx, executeEx.scriptingError?.stack, codeStripped);
}
//Others
throwExceptionFromV8( throwExceptionFromV8(
config, config,
executeEx.scriptingError.context["plugin_type"].toString(), pluginType,
(exMessage ?: ""), (extractJSExceptionMessage(executeEx) ?: ""),
executeEx, executeEx,
executeEx.scriptingError?.stack, executeEx.scriptingError?.stack,
codeStripped codeStripped
); );
}
throw ScriptExecutionException(config, "${exMessage}", null, executeEx.scriptingError?.stack, codeStripped); throw ScriptExecutionException(config, extractJSExceptionMessage(executeEx) ?: "", null, executeEx.scriptingError?.stack, codeStripped);
} }
catch(ex: Exception) { catch(ex: Exception) {
throw ex; throw ex;
@@ -224,6 +298,7 @@ class V8Plugin {
private fun throwExceptionFromV8(config: IV8PluginConfig, pluginType: String, msg: String, innerEx: Exception? = null, stack: String? = null, code: String? = null) { private fun throwExceptionFromV8(config: IV8PluginConfig, pluginType: String, msg: String, innerEx: Exception? = null, stack: String? = null, code: String? = null) {
when(pluginType) { when(pluginType) {
"ScriptException" -> throw ScriptException(config, msg, innerEx, stack, code); "ScriptException" -> throw ScriptException(config, msg, innerEx, stack, code);
"CriticalException" -> throw ScriptCriticalException(config, msg, innerEx, stack, code);
"AgeException" -> throw ScriptAgeException(config, msg, innerEx, stack, code); "AgeException" -> throw ScriptAgeException(config, msg, innerEx, stack, code);
"UnavailableException" -> throw ScriptUnavailableException(config, msg, innerEx, stack, code); "UnavailableException" -> throw ScriptUnavailableException(config, msg, innerEx, stack, code);
"ScriptExecutionException" -> throw ScriptExecutionException(config, msg, innerEx, stack, code); "ScriptExecutionException" -> throw ScriptExecutionException(config, msg, innerEx, stack, code);
@@ -0,0 +1,11 @@
package com.futo.platformplayer.engine.exceptions
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.getOrThrow
import java.lang.Exception
open class PluginEngineException(config: IV8PluginConfig, error: String, code: String? = null) : PluginException(config, error, null, code) {
}
@@ -0,0 +1,11 @@
package com.futo.platformplayer.engine.exceptions
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.getOrThrow
import java.lang.Exception
class PluginEngineStoppedException(config: IV8PluginConfig, error: String, code: String? = null) : PluginEngineException(config, error, code) {
}
@@ -0,0 +1,18 @@
package com.futo.platformplayer.engine.exceptions
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
class ScriptCaptchaRequiredException(config: IV8PluginConfig, val url: String?, val body: String?, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, "Captcha required", ex, stack, code) {
companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException {
val contextName = "ScriptCaptchaRequiredException";
return ScriptCaptchaRequiredException(config,
obj.getOrDefault<String>(config, "url", contextName, null),
obj.getOrDefault<String>(config, "body", contextName, null));
}
}
}
@@ -0,0 +1,17 @@
package com.futo.platformplayer.engine.exceptions
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.getOrThrow
open class ScriptCriticalException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, error, ex, stack, code) {
companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException {
return ScriptCriticalException(config, obj.getOrThrow(config, "message", "ScriptCriticalException"));
}
}
}
@@ -108,11 +108,12 @@ class PackageHttp: V8Package {
} }
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
class BridgeHttpResponse(val code: Int, val body: String?, val headers: Map<String, List<String>>? = null) : IV8Convertable { class BridgeHttpResponse(val url: String, val code: Int, val body: String?, val headers: Map<String, List<String>>? = null) : IV8Convertable {
val isOk = code >= 200 && code < 300; val isOk = code >= 200 && code < 300;
override fun toV8(runtime: V8Runtime): V8Value? { override fun toV8(runtime: V8Runtime): V8Value? {
val obj = runtime.createV8ValueObject(); val obj = runtime.createV8ValueObject();
obj.set("url", url);
obj.set("code", code); obj.set("code", code);
obj.set("body", body); obj.set("body", body);
obj.set("headers", headers); obj.set("headers", headers);
@@ -227,7 +228,7 @@ class PackageHttp: V8Package {
val resp = client.requestMethod(method, url, headers); val resp = client.requestMethod(method, url, headers);
val responseBody = resp.body?.string(); val responseBody = resp.body?.string();
logResponse(method, url, resp.code, resp.headers, responseBody); logResponse(method, url, resp.code, resp.headers, responseBody);
return@catchHttp BridgeHttpResponse(resp.code, responseBody, sanitizeResponseHeaders(resp.headers)); return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers));
} }
}; };
} }
@@ -241,7 +242,7 @@ class PackageHttp: V8Package {
val resp = client.requestMethod(method, url, body, headers); val resp = client.requestMethod(method, url, body, headers);
val responseBody = resp.body?.string(); val responseBody = resp.body?.string();
logResponse(method, url, resp.code, resp.headers, responseBody); logResponse(method, url, resp.code, resp.headers, responseBody);
return@catchHttp BridgeHttpResponse(resp.code, responseBody, sanitizeResponseHeaders(resp.headers)); return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers));
} }
}; };
} }
@@ -256,7 +257,7 @@ class PackageHttp: V8Package {
val resp = client.get(url, headers); val resp = client.get(url, headers);
val responseBody = resp.body?.string(); val responseBody = resp.body?.string();
logResponse("GET", url, resp.code, resp.headers, responseBody); logResponse("GET", url, resp.code, resp.headers, responseBody);
return@catchHttp BridgeHttpResponse(resp.code, responseBody, sanitizeResponseHeaders(resp.headers)); return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers));
} }
}; };
} }
@@ -270,7 +271,7 @@ class PackageHttp: V8Package {
val resp = client.post(url, body, headers); val resp = client.post(url, body, headers);
val responseBody = resp.body?.string(); val responseBody = resp.body?.string();
logResponse("POST", url, resp.code, resp.headers, responseBody); logResponse("POST", url, resp.code, resp.headers, responseBody);
return@catchHttp BridgeHttpResponse(resp.code, responseBody, sanitizeResponseHeaders(resp.headers)); return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers));
} }
}; };
} }
@@ -367,7 +368,7 @@ class PackageHttp: V8Package {
} }
//Forward timeouts //Forward timeouts
catch(ex: SocketTimeoutException) { catch(ex: SocketTimeoutException) {
return BridgeHttpResponse(408, null); return BridgeHttpResponse("", 408, null);
} }
} }
} }
@@ -461,7 +462,7 @@ class PackageHttp: V8Package {
} }
//Forward timeouts //Forward timeouts
catch(ex: SocketTimeoutException) { catch(ex: SocketTimeoutException) {
return BridgeHttpResponse(408, null); return BridgeHttpResponse("", 408, null);
} }
} }
@@ -0,0 +1,12 @@
package com.futo.platformplayer.exceptions
class DownloadException : Throwable {
val isRetryable: Boolean;
constructor(innerException: Throwable, retryable: Boolean = true): super(innerException) {
isRetryable = retryable;
}
constructor(msg: String, retryable: Boolean = true): super(msg) {
isRetryable = retryable;
}
}
@@ -0,0 +1,9 @@
package com.futo.platformplayer.exceptions
class RateLimitException : Throwable {
val pluginIds: List<String>;
constructor(pluginIds: List<String>): super() {
this.pluginIds = pluginIds ?: listOf();
}
}
@@ -77,7 +77,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment {
}; };
_textName?.text = channel.name; _textName?.text = channel.name;
val metadata = "${channel.subscribers.toHumanNumber()} subscribers"; val metadata = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} subscribers" else "";
_textMetadata?.text = metadata; _textMetadata?.text = metadata;
_lastChannel = channel; _lastChannel = channel;
setLinks(channel.links, channel.name); setLinks(channel.links, channel.name);
@@ -29,6 +29,7 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.main.FeedView import com.futo.platformplayer.fragment.mainactivity.main.FeedView
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
@@ -76,7 +77,9 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
}).success { }).success {
setLoading(false); setLoading(false);
setPager(it); setPager(it);
}.exception<Throwable> { }
.exception<ScriptCaptchaRequiredException> { }
.exception<Throwable> {
Logger.w(TAG, "Failed to load initial videos.", it); Logger.w(TAG, "Failed to load initial videos.", it);
UIDialogs.showGeneralRetryErrorDialog(requireContext(), it.message ?: "", it, { loadNextPage() }); UIDialogs.showGeneralRetryErrorDialog(requireContext(), it.message ?: "", it, { loadNextPage() });
}; };
@@ -14,6 +14,7 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -52,7 +53,8 @@ class ChannelListFragment : Fragment, IChannelTabFragment {
_authorLinks.add(PlatformAuthorLink(it.id, it.name, it.url, it.thumbnail)); _authorLinks.add(PlatformAuthorLink(it.id, it.name, it.url, it.thumbnail));
adapter.notifyItemInserted(adapter.childToParentPosition(_authorLinks.size - 1)); adapter.notifyItemInserted(adapter.childToParentPosition(_authorLinks.size - 1));
loadNext(); loadNext();
}.exceptionWithParameter<Throwable> { ex, para -> }.exception<ScriptCaptchaRequiredException> { }
.exceptionWithParameter<Throwable> { ex, para ->
Logger.w(ChannelFragment.TAG, "Failed to load results.", ex); Logger.w(ChannelFragment.TAG, "Failed to load results.", ex);
UIDialogs.toast(requireContext(), "Failed to fetch\n${para}", false) UIDialogs.toast(requireContext(), "Failed to fetch\n${para}", false)
loadNext(); loadNext();
@@ -10,6 +10,7 @@ import android.widget.TextView
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.futopay.PaymentConfigurations import com.futo.futopay.PaymentConfigurations
import com.futo.futopay.PaymentManager import com.futo.futopay.PaymentManager
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -68,9 +69,12 @@ class BuyFragment : MainFragment() {
} }
} }
_buttonBuy.setOnClickListener { if(!BuildConfig.IS_PLAYSTORE_BUILD)
buy(); _buttonBuy.setOnClickListener {
} buy();
}
else
_buttonBuy.visibility = View.GONE;
_buttonPaid.setOnClickListener { _buttonPaid.setOnClickListener {
paid(); paid();
} }
@@ -361,7 +361,7 @@ class ChannelFragment : MainFragment() {
_buttonSubscribe.setSubscribeChannel(channel); _buttonSubscribe.setSubscribeChannel(channel);
_textChannel.text = channel.name; _textChannel.text = channel.name;
_textChannelSub.text = "${channel.subscribers.toHumanNumber()} subscribers"; _textChannelSub.text = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} subscribers" else "";
_creatorThumbnail.setThumbnail(channel.thumbnail, true); _creatorThumbnail.setThumbnail(channel.thumbnail, true);
Glide.with(_imageBanner) Glide.with(_imageBanner)
@@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -86,7 +87,7 @@ class ContentSearchResultsFragment : MainFragment() {
StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds) StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
} }
}) })
.success { loadedResult(it); } .success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
.exception<Throwable> { .exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load results.", it); Logger.w(ChannelFragment.TAG, "Failed to load results.", it);
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() }); UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
@@ -13,6 +13,7 @@ import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
@@ -56,6 +57,7 @@ class CreatorSearchResultsFragment : MainFragment() {
constructor(fragment: CreatorSearchResultsFragment, inflater: LayoutInflater): super(fragment, inflater) { constructor(fragment: CreatorSearchResultsFragment, inflater: LayoutInflater): super(fragment, inflater) {
_taskSearch = TaskHandler<String, IPager<PlatformAuthorLink>>({fragment.lifecycleScope}, { query -> StatePlatform.instance.searchChannels(query) }) _taskSearch = TaskHandler<String, IPager<PlatformAuthorLink>>({fragment.lifecycleScope}, { query -> StatePlatform.instance.searchChannels(query) })
.success { loadedResult(it); } .success { loadedResult(it); }
.exception<ScriptCaptchaRequiredException> { }
.exception<Throwable> { .exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load results.", it); Logger.w(ChannelFragment.TAG, "Failed to load results.", it);
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() }); UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
@@ -39,6 +39,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private val _spinnerSortBy: Spinner; private val _spinnerSortBy: Spinner;
private val _containerSortBy: LinearLayout; private val _containerSortBy: LinearLayout;
private val _tagsView: TagsView; private val _tagsView: TagsView;
private val _textCentered: TextView;
protected val _toolbarContentView: LinearLayout; protected val _toolbarContentView: LinearLayout;
@@ -68,6 +69,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
this.fragment = fragment; this.fragment = fragment;
inflater.inflate(R.layout.fragment_feed, this); inflater.inflate(R.layout.fragment_feed, this);
_textCentered = findViewById(R.id.text_centered);
_progress_bar = findViewById(R.id.progress_bar); _progress_bar = findViewById(R.id.progress_bar);
_progress_bar.inactiveColor = Color.TRANSPARENT; _progress_bar.inactiveColor = Color.TRANSPARENT;
@@ -169,6 +171,10 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_recyclerResults.addOnScrollListener(_scrollListener); _recyclerResults.addOnScrollListener(_scrollListener);
} }
protected fun setTextCentered(text: String?) {
_textCentered.text = text;
}
fun onResume() { fun onResume() {
//Reload the pager if the plugin was killed //Reload the pager if the plugin was killed
val pager = recyclerData.pager; val pager = recyclerData.pager;
@@ -8,21 +8,27 @@ import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.activities.CaptchaActivity
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.CaptchaWebViewClient
import com.futo.platformplayer.states.AnnouncementType import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.announcements.AnnouncementView import com.futo.platformplayer.views.announcements.AnnouncementView
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
@@ -92,6 +98,7 @@ class HomeFragment : MainFragment() {
StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope) StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope)
}) })
.success { loadedResult(it); } .success { loadedResult(it); }
.exception<ScriptCaptchaRequiredException> { }
.exception<ScriptExecutionException> { .exception<ScriptExecutionException> {
Logger.w(ChannelFragment.TAG, "Plugin failure.", it); Logger.w(ChannelFragment.TAG, "Plugin failure.", it);
UIDialogs.showDialog(context, R.drawable.ic_error_pred, "Failed to get Home\nPlugin [${it.config.name}]", it.message, null, 0, UIDialogs.showDialog(context, R.drawable.ic_error_pred, "Failed to get Home\nPlugin [${it.config.name}]", it.message, null, 0,
@@ -100,17 +107,20 @@ class HomeFragment : MainFragment() {
); );
} }
.exception<ScriptImplementationException> { .exception<ScriptImplementationException> {
Logger.w(ChannelFragment.TAG, "Plugin failure.", it); Logger.w(TAG, "Plugin failure.", it);
UIDialogs.showDialog(context, R.drawable.ic_error_pred, "Failed to get Home\nPlugin [${it.config.name}]", it.message, null, 0, UIDialogs.showDialog(context, R.drawable.ic_error_pred, "Failed to get Home\nPlugin [${it.config.name}]", it.message, null, 0,
UIDialogs.Action("Ignore", {}), UIDialogs.Action("Ignore", {}),
UIDialogs.Action("Sources", { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY) UIDialogs.Action("Sources", { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY)
); );
} }
.exception<Throwable> { .exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load channel.", it); Logger.w(TAG, "Failed to load channel.", it);
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to get Home", it, { UIDialogs.showGeneralRetryErrorDialog(context, "Failed to get Home", it, {
loadResults() loadResults()
}); }) {
finishRefreshLayoutLoader();
setLoading(false);
};
}; };
} }
@@ -131,6 +141,8 @@ class HomeFragment : MainFragment() {
} else { } else {
setLoading(false); setLoading(false);
} }
finishRefreshLayoutLoader();
} }
override fun reload() { override fun reload() {
@@ -69,6 +69,8 @@ class ImportSubscriptionsFragment : MainFragment() {
private var _currentLoadIndex = 0; private var _currentLoadIndex = 0;
private var _taskLoadChannel: TaskHandler<String, IPlatformChannel>; private var _taskLoadChannel: TaskHandler<String, IPlatformChannel>;
private var _counter: Int = 0;
private var _limitToastShown = false;
constructor(fragment: ImportSubscriptionsFragment, inflater: LayoutInflater) : super(inflater.context) { constructor(fragment: ImportSubscriptionsFragment, inflater: LayoutInflater) : super(inflater.context) {
_fragment = fragment; _fragment = fragment;
@@ -104,6 +106,7 @@ class ImportSubscriptionsFragment : MainFragment() {
setLoading(false); setLoading(false);
_taskLoadChannel = TaskHandler<String, IPlatformChannel>({_fragment.lifecycleScope}, { link -> _taskLoadChannel = TaskHandler<String, IPlatformChannel>({_fragment.lifecycleScope}, { link ->
_counter++;
val channel: IPlatformChannel = StatePlatform.instance.getChannelLive(link, false); val channel: IPlatformChannel = StatePlatform.instance.getChannelLive(link, false);
return@TaskHandler channel; return@TaskHandler channel;
}).success { }).success {
@@ -124,6 +127,8 @@ class ImportSubscriptionsFragment : MainFragment() {
} }
fun onShown(parameter: Any ?, isBack: Boolean) { fun onShown(parameter: Any ?, isBack: Boolean) {
_counter = 0;
_limitToastShown = false;
updateSelected(); updateSelected();
val itemsRemoved = _items.size; val itemsRemoved = _items.size;
@@ -157,6 +162,15 @@ class ImportSubscriptionsFragment : MainFragment() {
private fun load() { private fun load() {
setLoading(true); setLoading(true);
if (_counter >= MAXIMUM_BATCH_SIZE) {
if (!_limitToastShown) {
_limitToastShown = true;
UIDialogs.toast(context, "Stopped after $MAXIMUM_BATCH_SIZE to avoid rate limit, re-enter to import rest");
}
setLoading(false);
return;
}
_taskLoadChannel.run(_links[_currentLoadIndex]); _taskLoadChannel.run(_links[_currentLoadIndex]);
} }
@@ -196,6 +210,7 @@ class ImportSubscriptionsFragment : MainFragment() {
companion object { companion object {
val TAG = "ImportSubscriptionsFragment"; val TAG = "ImportSubscriptionsFragment";
private const val MAXIMUM_BATCH_SIZE = 75;
fun newInstance() = ImportSubscriptionsFragment().apply {} fun newInstance() = ImportSubscriptionsFragment().apply {}
} }
} }
@@ -346,24 +346,24 @@ class PostDetailFragment : MainFragment {
_rating.visibility = VISIBLE; _rating.visibility = VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked); _rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { processHandle, newHasLiked, newHasDisliked -> _rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (newHasLiked) { if (args.hasLiked) {
processHandle.opinion(ref, Opinion.like); args.processHandle.opinion(ref, Opinion.like);
} else if (newHasDisliked) { } else if (args.hasDisliked) {
processHandle.opinion(ref, Opinion.dislike); args.processHandle.opinion(ref, Opinion.dislike);
} else { } else {
processHandle.opinion(ref, Opinion.neutral); args.processHandle.opinion(ref, Opinion.neutral);
} }
StateApp.instance.scopeGetter().launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
processHandle.fullyBackfillServers(); args.processHandle.fullyBackfillServers();
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e) Logger.e(TAG, "Failed to backfill servers", e)
} }
} }
StatePolycentric.instance.updateLikeMap(ref, newHasLiked, newHasDisliked) StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked)
}; };
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -601,7 +601,7 @@ class PostDetailFragment : MainFragment {
val subscribers = value?.author?.subscribers; val subscribers = value?.author?.subscribers;
if(subscribers != null && subscribers > 0) { if(subscribers != null && subscribers > 0) {
_channelMeta.visibility = View.VISIBLE; _channelMeta.visibility = View.VISIBLE;
_channelMeta.text = value.author.subscribers!!.toHumanNumber() + " subscribers"; _channelMeta.text = if((value.author?.subscribers ?: 0) > 0) value.author.subscribers!!.toHumanNumber() + " subscribers" else "";
} else { } else {
_channelMeta.visibility = View.GONE; _channelMeta.visibility = View.GONE;
_channelMeta.text = ""; _channelMeta.text = "";
@@ -258,11 +258,25 @@ class SourceDetailFragment : MainFragment() {
} }
} }
val clientIfExists = StatePlugins.instance.getPlugin(config.id);
groups.add( groups.add(
BigButtonGroup(c, "Management", BigButtonGroup(c, "Management",
BigButton(c, "Uninstall", "Removes the plugin from the app", R.drawable.ic_block) { BigButton(c, "Uninstall", "Removes the plugin from the app", R.drawable.ic_block) {
uninstallSource(); uninstallSource();
}.withBackground(R.drawable.background_big_button_red) }.withBackground(R.drawable.background_big_button_red).apply {
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
};
},
if(clientIfExists?.captchaEncrypted != null)
BigButton(c, "Delete Captcha", "Deletes stored captcha answer for this plugin", R.drawable.ic_block) {
clientIfExists?.updateCaptcha(null);
}.apply {
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
};
}.withBackground(R.drawable.background_big_button_red)
else null
) )
) )
@@ -12,13 +12,17 @@ import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.cache.ChannelContentCache import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.exceptions.RateLimitException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson import com.futo.platformplayer.stores.FragmentedStorageFileJson
@@ -28,8 +32,10 @@ import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.subscriptions.SubscriptionBar import com.futo.platformplayer.views.subscriptions.SubscriptionBar
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -129,6 +135,10 @@ class SubscriptionsFeedFragment : MainFragment() {
}; };
} }
} }
if (!StateSubscriptions.instance.isGlobalUpdating) {
finishRefreshLayoutLoader();
}
} }
override fun cleanup() { override fun cleanup() {
@@ -155,8 +165,17 @@ class SubscriptionsFeedFragment : MainFragment() {
private val _filterLock = Object(); private val _filterLock = Object();
private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter"); private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter");
private var _bypassRateLimit = false;
private val _lastExceptions: List<Throwable>? = null; private val _lastExceptions: List<Throwable>? = null;
private val _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({StateApp.instance.scope}, { withRefresh -> private val _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({StateApp.instance.scope}, { withRefresh ->
if(!_bypassRateLimit) {
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.config.subscriptionRateLimit}" }.joinToString("\n");
val rateLimitPlugins = subRequestCounts.filter { clientCount -> clientCount.key.config.subscriptionRateLimit?.let { rateLimit -> clientCount.value > rateLimit } == true }
Logger.w(TAG, "Refreshing subscriptions with requests:\n" + reqCountStr);
if(rateLimitPlugins.any())
throw RateLimitException(rateLimitPlugins.map { it.key.id });
}
val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh); val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh);
val currentExs = StateSubscriptions.instance.globalSubscriptionExceptions; val currentExs = StateSubscriptions.instance.globalSubscriptionExceptions;
@@ -166,9 +185,37 @@ class SubscriptionsFeedFragment : MainFragment() {
return@TaskHandler resp; return@TaskHandler resp;
}) })
.success { loadedResult(it); } .success { loadedResult(it); }
.exception<RateLimitException> {
fragment.lifecycleScope.launch(Dispatchers.IO) {
val subs = StateSubscriptions.instance.getSubscriptions();
val subsByLimited = it.pluginIds.map{ StatePlatform.instance.getClientOrNull(it) }
.filterIsInstance<JSClient>()
.associateWith { client -> subs.filter { it.getClient() == client } }
.map { Pair(it.key, it.value) }
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_security_pred,
"Rate Limit Warning", "This is a temporary measure to prevent people from hitting rate limit until we have better support for lots of subscriptions." +
"\n\nYou have too many subscriptions for the following plugins:\n",
subsByLimited.map { "${it.first.config.name}: ${it.second.size} Subscriptions" } .joinToString("\n"), 0, UIDialogs.Action("Refresh Anyway", {
_bypassRateLimit = true;
loadResults();
}, UIDialogs.ActionStyle.DANGEROUS_TEXT),
UIDialogs.Action("OK", {
finishRefreshLayoutLoader();
setLoading(false);
}, UIDialogs.ActionStyle.PRIMARY));
}
}
}
.exception<Throwable> { .exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load channel.", it); Logger.w(ChannelFragment.TAG, "Failed to load channel.", it);
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() }); if(it !is CancellationException)
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
else {
finishRefreshLayoutLoader();
setLoading(false);
}
}; };
private fun initializeToolbarContent() { private fun initializeToolbarContent() {
@@ -234,8 +281,12 @@ class SubscriptionsFeedFragment : MainFragment() {
Logger.i(TAG, "Subscriptions load"); Logger.i(TAG, "Subscriptions load");
if(recyclerData.results.size == 0) { if(recyclerData.results.size == 0) {
val cachePager = ChannelContentCache.instance.getSubscriptionCachePager(); val cachePager = ChannelContentCache.instance.getSubscriptionCachePager();
Logger.i(TAG, "Subscription show cache (${cachePager.getResults().size})"); val results = cachePager.getResults();
Logger.i(TAG, "Subscription show cache (${results.size})");
setTextCentered(if (results.isEmpty()) "No results found\nSwipe down to refresh" else null);
setPager(cachePager); setPager(cachePager);
} else {
setTextCentered(null);
} }
_taskGetPager.run(withRefetch); _taskGetPager.run(withRefetch);
} }
@@ -248,10 +299,15 @@ class SubscriptionsFeedFragment : MainFragment() {
finishRefreshLayoutLoader(); finishRefreshLayoutLoader();
setLoading(false); setLoading(false);
setPager(pager); setPager(pager);
setTextCentered(if (pager.getResults().isEmpty()) "No results found\nSwipe down to refresh" else null);
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to finish loading", e) Logger.e(TAG, "Failed to finish loading", e)
} }
} }/*.invokeOnCompletion { //Commented for now, because it doesn't fix the bug it was intended to fix, but might want it later anyway
if(it is CancellationException) {
setLoading(false);
}
}*/
} }
private fun handleExceptions(exs: List<Throwable>) { private fun handleExceptions(exs: List<Throwable>) {
@@ -306,12 +306,12 @@ class VideoDetailFragment : MainFragment {
override fun onResume() { override fun onResume() {
super.onResume(); super.onResume();
Logger.i(TAG, "onResume"); Logger.v(TAG, "onResume");
_isActive = true; _isActive = true;
_leavingPiP = false; _leavingPiP = false;
_viewDetail?.let { _viewDetail?.let {
Logger.i(TAG, "onResume preventPictureInPicture=false"); Logger.v(TAG, "onResume preventPictureInPicture=false");
it.preventPictureInPicture = false; it.preventPictureInPicture = false;
if (state != State.CLOSED) { if (state != State.CLOSED) {
@@ -326,7 +326,7 @@ class VideoDetailFragment : MainFragment {
} }
override fun onPause() { override fun onPause() {
super.onPause(); super.onPause();
Logger.i(TAG, "onPause"); Logger.v(TAG, "onPause");
_isActive = false; _isActive = false;
if(!isInPictureInPicture && state != State.CLOSED) if(!isInPictureInPicture && state != State.CLOSED)
@@ -334,7 +334,7 @@ class VideoDetailFragment : MainFragment {
} }
override fun onStop() { override fun onStop() {
Logger.i(TAG, "onStop"); Logger.v(TAG, "onStop");
stopIfRequired(); stopIfRequired();
super.onStop(); super.onStop();
@@ -352,7 +352,7 @@ class VideoDetailFragment : MainFragment {
shouldStop = false; shouldStop = false;
} }
Logger.i(TAG, "shouldStop: $shouldStop"); Logger.v(TAG, "shouldStop: $shouldStop");
if(shouldStop) { if(shouldStop) {
_viewDetail?.let { _viewDetail?.let {
val v = it.video ?: return@let; val v = it.video ?: return@let;
@@ -361,13 +361,13 @@ class VideoDetailFragment : MainFragment {
_viewDetail?.onStop(); _viewDetail?.onStop();
StateCasting.instance.onStop(); StateCasting.instance.onStop();
Logger.i(TAG, "called onStop() shouldStop: $shouldStop"); Logger.v(TAG, "called onStop() shouldStop: $shouldStop");
} }
} }
override fun onDestroyMainView() { override fun onDestroyMainView() {
super.onDestroyMainView(); super.onDestroyMainView();
Logger.i(TAG, "onDestroyMainView"); Logger.v(TAG, "onDestroyMainView");
_viewDetail?.let { _viewDetail?.let {
_viewDetail = null; _viewDetail = null;
it.onDestroy(); it.onDestroy();
@@ -100,6 +100,7 @@ import kotlinx.coroutines.*
import userpackage.Protocol import userpackage.Protocol
import java.time.OffsetDateTime import java.time.OffsetDateTime
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
import kotlin.math.abs
import kotlin.math.roundToLong import kotlin.math.roundToLong
import kotlin.streams.toList import kotlin.streams.toList
@@ -232,9 +233,19 @@ class VideoDetailView : ConstraintLayout {
private val DP_5 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics); private val DP_5 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
private val DP_2 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics); private val DP_2 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics);
private var _retryJob: Job? = null; private var _retryJob: Job? = null;
private var _retryCount = 0; private var _retryCount = 0;
private val _retryIntervals: Array<Long> = arrayOf(1, 2, 4, 8, 16, 32); //TODO: Determine better behavior, waiting 60 seconds for an error that is guaranteed to happen is a bit much. (Needed? If so, maybe need special UI for retrying)
private val _retryIntervals: Array<Long> = arrayOf(1, 1);//2, 4, 8, 16, 32);
private var _liveTryJob: Job? = null;
private val _liveStreamCheckInterval = listOf(
Pair(-10 * 60, 5 * 60), //around 10 minutes, try every 5 minute
Pair(-5 * 60, 30), //around 5 minutes, try every 30 seconds
Pair(0, 10) //around live, try every 10 seconds
);
constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) { constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.fragview_video_detail, this); inflate(context, R.layout.fragview_video_detail, this);
@@ -491,7 +502,7 @@ class VideoDetailView : ConstraintLayout {
MediaControlReceiver.onLowerVolumeReceived.subscribe(this) { handleLowerVolume() }; MediaControlReceiver.onLowerVolumeReceived.subscribe(this) { handleLowerVolume() };
MediaControlReceiver.onPlayReceived.subscribe(this) { handlePlay() }; MediaControlReceiver.onPlayReceived.subscribe(this) { handlePlay() };
MediaControlReceiver.onPauseReceived.subscribe(this) { handlePause() }; MediaControlReceiver.onPauseReceived.subscribe(this) { handlePause() };
MediaControlReceiver.onNextReceived.subscribe(this) { nextVideo() }; MediaControlReceiver.onNextReceived.subscribe(this) { nextVideo(true) };
MediaControlReceiver.onPreviousReceived.subscribe(this) { prevVideo() }; MediaControlReceiver.onPreviousReceived.subscribe(this) { prevVideo() };
MediaControlReceiver.onCloseReceived.subscribe(this) { MediaControlReceiver.onCloseReceived.subscribe(this) {
Logger.i(TAG, "MediaControlReceiver.onCloseReceived") Logger.i(TAG, "MediaControlReceiver.onCloseReceived")
@@ -597,7 +608,7 @@ class VideoDetailView : ConstraintLayout {
}, },
RoundButton(context, R.drawable.ic_download, "Download", TAG_DOWNLOAD) { RoundButton(context, R.drawable.ic_download, "Download", TAG_DOWNLOAD) {
video?.let { video?.let {
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(context.contentResolver, it, _overlayContainer); _slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
}; };
}, },
RoundButton(context, R.drawable.ic_share, "Share", TAG_SHARE) { RoundButton(context, R.drawable.ic_share, "Share", TAG_SHARE) {
@@ -660,7 +671,7 @@ class VideoDetailView : ConstraintLayout {
//Lifecycle //Lifecycle
fun onResume() { fun onResume() {
Logger.i(TAG, "onResume"); Logger.v(TAG, "onResume");
_onPauseCalled = false; _onPauseCalled = false;
Logger.i(TAG, "_video: ${video?.name ?: "no video"}"); Logger.i(TAG, "_video: ${video?.name ?: "no video"}");
@@ -694,7 +705,7 @@ class VideoDetailView : ConstraintLayout {
_player.updateRotateLock(); _player.updateRotateLock();
} }
fun onPause() { fun onPause() {
Logger.i(TAG, "onPause"); Logger.v(TAG, "onPause");
_onPauseCalled = true; _onPauseCalled = true;
_taskLoadVideo.cancel(); _taskLoadVideo.cancel();
@@ -722,6 +733,8 @@ class VideoDetailView : ConstraintLayout {
_overlay_quality_selector?.hide(); _overlay_quality_selector?.hide();
_retryJob?.cancel(); _retryJob?.cancel();
_retryJob = null; _retryJob = null;
_liveTryJob?.cancel();
_liveTryJob = null;
_taskLoadVideo.cancel(); _taskLoadVideo.cancel();
handleStop(); handleStop();
_didStop = true; _didStop = true;
@@ -808,6 +821,8 @@ class VideoDetailView : ConstraintLayout {
_retryJob?.cancel(); _retryJob?.cancel();
_retryJob = null; _retryJob = null;
_liveTryJob?.cancel();
_liveTryJob = null;
_retryCount = 0; _retryCount = 0;
fetchVideo(); fetchVideo();
@@ -858,7 +873,7 @@ class VideoDetailView : ConstraintLayout {
_channelName.text = video.author.name; _channelName.text = video.author.name;
_playWhenReady = true; _playWhenReady = true;
if(video.author.subscribers != null) { if(video.author.subscribers != null) {
_channelMeta.text = video.author.subscribers!!.toHumanNumber() + " subscribers"; _channelMeta.text = if((video.author.subscribers ?: 0) > 0) video.author.subscribers!!.toHumanNumber() + " subscribers" else "";
(_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_5 * -1).toInt(), 0, 0); (_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_5 * -1).toInt(), 0, 0);
} else { } else {
_channelMeta.text = ""; _channelMeta.text = "";
@@ -897,6 +912,8 @@ class VideoDetailView : ConstraintLayout {
_retryJob?.cancel(); _retryJob?.cancel();
_retryJob = null; _retryJob = null;
_liveTryJob?.cancel();
_liveTryJob = null;
_retryCount = 0; _retryCount = 0;
fetchVideo(); fetchVideo();
} }
@@ -965,7 +982,7 @@ class VideoDetailView : ConstraintLayout {
_title.text = video.name; _title.text = video.name;
_channelName.text = video.author.name; _channelName.text = video.author.name;
if(video.author.subscribers != null) { if(video.author.subscribers != null) {
_channelMeta.text = video.author.subscribers!!.toHumanNumber() + " subscribers"; _channelMeta.text = if((video.author.subscribers ?: 0) > 0) video.author.subscribers!!.toHumanNumber() + " subscribers" else "";
(_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_5 * -1).toInt(), 0, 0); (_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_5 * -1).toInt(), 0, 0);
} else { } else {
_channelMeta.text = ""; _channelMeta.text = "";
@@ -1025,24 +1042,24 @@ class VideoDetailView : ConstraintLayout {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_rating.visibility = View.VISIBLE; _rating.visibility = View.VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked); _rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { processHandle, newHasLiked, newHasDisliked -> _rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (newHasLiked) { if (args.hasLiked) {
processHandle.opinion(ref, Opinion.like); args.processHandle.opinion(ref, Opinion.like);
} else if (newHasDisliked) { } else if (args.hasDisliked) {
processHandle.opinion(ref, Opinion.dislike); args.processHandle.opinion(ref, Opinion.dislike);
} else { } else {
processHandle.opinion(ref, Opinion.neutral); args.processHandle.opinion(ref, Opinion.neutral);
} }
StateApp.instance.scopeGetter().launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
processHandle.fullyBackfillServers(); args.processHandle.fullyBackfillServers();
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e) Logger.e(TAG, "Failed to backfill servers", e)
} }
} }
StatePolycentric.instance.updateLikeMap(ref, newHasLiked, newHasDisliked) StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked)
}; };
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -1130,6 +1147,8 @@ class VideoDetailView : ConstraintLayout {
if(video.isLive && video.live != null) { if(video.isLive && video.live != null) {
loadLiveChat(video); loadLiveChat(video);
} }
if(video.isLive && video.live == null && !video.video.videoSources.any())
startLiveTry(video);
updateMoreButtons(); updateMoreButtons();
} }
@@ -1259,7 +1278,7 @@ class VideoDetailView : ConstraintLayout {
//If LiveStream, set to end //If LiveStream, set to end
if(videoSource is IDashManifestSource || videoSource is IHLSManifestSource) { if(videoSource is IDashManifestSource || videoSource is IHLSManifestSource) {
if (video?.isLive == true) { if (video?.isLive == true) {
_player.seekToEnd(5000); _player.seekToEnd(6000);
} }
val videoTracks = _player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO } val videoTracks = _player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO }
@@ -1344,9 +1363,11 @@ class VideoDetailView : ConstraintLayout {
} }
} }
fun nextVideo(): Boolean { fun nextVideo(forceLoop: Boolean = false): Boolean {
Logger.i(TAG, "nextVideo") Logger.i(TAG, "nextVideo")
val next = StatePlayer.instance.nextQueueItem(_player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9); var next = StatePlayer.instance.nextQueueItem(_player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9);
if(next == null && forceLoop)
next = StatePlayer.instance.restartQueue();
if(next != null) { if(next != null) {
setVideoOverview(next); setVideoOverview(next);
return true; return true;
@@ -1589,10 +1610,10 @@ class VideoDetailView : ConstraintLayout {
_lastSubtitleSource = toSet; _lastSubtitleSource = toSet;
} }
private fun handleUnavailableVideo() { private fun handleUnavailableVideo(msg: String? = null) {
if (!nextVideo()) { if (!nextVideo()) {
if(video?.datetime == null || video?.datetime!! < OffsetDateTime.now().minusHours(1)) if(video?.datetime == null || video?.datetime!! < OffsetDateTime.now().minusHours(1))
UIDialogs.showDialog(context, R.drawable.ic_lock, "Unavailable video", "This video is unavailable.", null, 0, UIDialogs.showDialog(context, R.drawable.ic_lock, "Unavailable video", msg ?: "This video is unavailable.", null, 0,
UIDialogs.Action("Back", { UIDialogs.Action("Back", {
this@VideoDetailView.onClose.emit(); this@VideoDetailView.onClose.emit();
}, UIDialogs.ActionStyle.PRIMARY)); }, UIDialogs.ActionStyle.PRIMARY));
@@ -1961,7 +1982,7 @@ class VideoDetailView : ConstraintLayout {
} }
fun setProgressBarOverlayed(isOverlayed: Boolean?) { fun setProgressBarOverlayed(isOverlayed: Boolean?) {
Logger.i(TAG, "setProgressBarOverlayed(isOverlayed: ${isOverlayed ?: "null"})"); Logger.v(TAG, "setProgressBarOverlayed(isOverlayed: ${isOverlayed ?: "null"})");
isOverlayed?.let{ _cast.setProgressBarOverlayed(it) }; isOverlayed?.let{ _cast.setProgressBarOverlayed(it) };
if(isOverlayed == null) { if(isOverlayed == null) {
@@ -2071,7 +2092,7 @@ class VideoDetailView : ConstraintLayout {
} }
.exception<ScriptUnavailableException> { .exception<ScriptUnavailableException> {
Logger.w(TAG, "exception<ScriptUnavailableException>", it); Logger.w(TAG, "exception<ScriptUnavailableException>", it);
handleUnavailableVideo(); handleUnavailableVideo(it.message);
} }
.exception<ScriptException> { .exception<ScriptException> {
Logger.w(TAG, "exception<ScriptException>", it) Logger.w(TAG, "exception<ScriptException>", it)
@@ -2080,6 +2101,8 @@ class VideoDetailView : ConstraintLayout {
_retryCount = 0; _retryCount = 0;
_retryJob?.cancel(); _retryJob?.cancel();
_retryJob = null; _retryJob = null;
_liveTryJob?.cancel();
_liveTryJob = null;
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load video (ScriptException)", it, ::fetchVideo); UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load video (ScriptException)", it, ::fetchVideo);
} }
} }
@@ -2090,6 +2113,8 @@ class VideoDetailView : ConstraintLayout {
_retryCount = 0; _retryCount = 0;
_retryJob?.cancel(); _retryJob?.cancel();
_retryJob = null; _retryJob = null;
_liveTryJob?.cancel();
_liveTryJob = null;
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load video", it, ::fetchVideo); UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load video", it, ::fetchVideo);
} }
} else TaskHandler(IPlatformVideoDetails::class.java, {fragment.lifecycleScope}); } else TaskHandler(IPlatformVideoDetails::class.java, {fragment.lifecycleScope});
@@ -2107,14 +2132,16 @@ class VideoDetailView : ConstraintLayout {
Log.i(TAG, "handleErrorOrCall _retryCount=$_retryCount, starting retry job"); Log.i(TAG, "handleErrorOrCall _retryCount=$_retryCount, starting retry job");
_retryJob?.cancel(); _retryJob?.cancel();
_retryJob = StateApp.instance.scopeGetter().launch(Dispatchers.Main) { _retryJob = StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
try { try {
delay(_retryIntervals[_retryCount++] * 1000); delay(_retryIntervals[_retryCount++] * 1000);
fetchVideo(); fetchVideo();
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to fetch video.", e) Logger.e(TAG, "Failed to retry fetch video.", e)
} }
} }
_liveTryJob?.cancel();
_liveTryJob = null;
} else if (isConnected && nextVideo()) { } else if (isConnected && nextVideo()) {
Log.i(TAG, "handleErrorOrCall retries failed, is connected, skipped to next video"); Log.i(TAG, "handleErrorOrCall retries failed, is connected, skipped to next video");
} else { } else {
@@ -2123,6 +2150,45 @@ class VideoDetailView : ConstraintLayout {
} }
} }
private fun startLiveTry(liveTryVideo: IPlatformVideoDetails) {
val datetime = liveTryVideo.datetime ?: return;
val diffSeconds = datetime.getNowDiffSeconds();
val toWait = _liveStreamCheckInterval.toList().sortedBy { abs(diffSeconds - it.first) }.firstOrNull()?.second?.toLong() ?: return;
fragment.lifecycleScope.launch(Dispatchers.Main){
UIDialogs.toast(context, "Not yet available, retrying in ${toWait}s");
}
_liveTryJob?.cancel();
_liveTryJob = fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
delay(toWait * 1000);
val videoDetail = StatePlatform.instance.getContentDetails(liveTryVideo.url, true).await();
if(videoDetail !is IPlatformVideoDetails)
throw IllegalStateException("Expected media content, found ${video?.contentType}");
if(videoDetail.datetime != null && videoDetail.live == null && !videoDetail.video.videoSources.any()) {
if(videoDetail.datetime!! > OffsetDateTime.now())
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Planned in ${videoDetail.datetime?.toHumanNowDiffString(true)}");
}
startLiveTry(liveTryVideo);
}
else
withContext(Dispatchers.Main) {
setVideoDetails(videoDetail);
_liveTryJob = null;
}
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to live try fetch video.", ex);
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to retry for live stream");
}
}
}
}
fun applyFragment(frag: VideoDetailFragment) { fun applyFragment(frag: VideoDetailFragment) {
fragment = frag; fragment = frag;
fragment.onMinimize.subscribe { fragment.onMinimize.subscribe {
@@ -4,7 +4,10 @@ import android.net.Uri
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -17,6 +20,12 @@ import com.google.android.exoplayer2.upstream.ResolvingDataSource
class VideoHelper { class VideoHelper {
companion object { companion object {
fun isDownloadable(detail: IPlatformVideoDetails) =
(detail.video.videoSources.any { isDownloadable(it) }) ||
(if (detail is VideoUnMuxedSourceDescriptor) detail.audioSources.any { isDownloadable(it) } else false);
fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource;
fun isDownloadable(source: IAudioSource) = source is IAudioUrlSource;
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers); fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? { fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {
val targetVideo = if(desiredPixelCount > 0) val targetVideo = if(desiredPixelCount > 0)
@@ -61,9 +61,21 @@ public class PolycentricModelLoader implements ModelLoader<String, ByteBuffer> {
_deferred.invokeOnCompletion(throwable -> { _deferred.invokeOnCompletion(throwable -> {
if (throwable != null) { if (throwable != null) {
callback.onLoadFailed(new Exception(throwable)); callback.onLoadFailed(new Exception(throwable));
return Unit.INSTANCE;
}
Deferred<ByteBuffer> deferred = _deferred;
if (deferred == null) {
callback.onLoadFailed(new Exception("Deferred is null"));
return Unit.INSTANCE;
}
ByteBuffer completed = deferred.getCompleted();
if (completed != null) {
callback.onDataReady(completed);
} else {
callback.onLoadFailed(new Exception("Completed is null"));
} }
final ByteBuffer completed = _deferred.getCompleted();
callback.onDataReady(completed);
return Unit.INSTANCE; return Unit.INSTANCE;
}); });
} }
@@ -3,24 +3,48 @@ package com.futo.platformplayer.models
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.channels.SerializedChannel import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.getNowDiffDays
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.states.StatePlatform
import java.time.OffsetDateTime import java.time.OffsetDateTime
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
class Subscription { class Subscription {
var channel: SerializedChannel; var channel: SerializedChannel;
//Last found content
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastVideo : OffsetDateTime = OffsetDateTime.MAX; var lastVideo : OffsetDateTime = OffsetDateTime.MAX;
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastLiveStream : OffsetDateTime = OffsetDateTime.MAX; var lastLiveStream : OffsetDateTime = OffsetDateTime.MAX;
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastPost : OffsetDateTime = OffsetDateTime.MAX;
//Last update time
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastVideoUpdate : OffsetDateTime = OffsetDateTime.MIN;
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastStreamUpdate : OffsetDateTime = OffsetDateTime.MIN;
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastLiveStreamUpdate : OffsetDateTime = OffsetDateTime.MIN;
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastPostUpdate : OffsetDateTime = OffsetDateTime.MIN;
//Last video interval
var uploadInterval : Int = 0; var uploadInterval : Int = 0;
var uploadPostInterval : Int = 0;
constructor(channel : SerializedChannel) { constructor(channel : SerializedChannel) {
this.channel = channel; this.channel = channel;
} }
fun shouldFetchStreams() = lastLiveStream.getNowDiffDays() < 7;
fun shouldFetchLiveStreams() = lastLiveStream.getNowDiffDays() < 14;
fun shouldFetchPosts() = lastPost.getNowDiffDays() < 2;
fun getClient() = StatePlatform.instance.getChannelClientOrNull(channel.url);
fun updateChannel(channel: IPlatformChannel) { fun updateChannel(channel: IPlatformChannel) {
this.channel = SerializedChannel.fromChannel(channel); this.channel = SerializedChannel.fromChannel(channel);
} }
@@ -9,7 +9,6 @@ data class Telemetry(
val buildType: String, val buildType: String,
val debug: Boolean, val debug: Boolean,
val isUnstableBuild: Boolean, val isUnstableBuild: Boolean,
val time: Long,
val brand: String, val brand: String,
val manufacturer: String, val manufacturer: String,
val model: String val model: String
@@ -0,0 +1,76 @@
package com.futo.platformplayer.others
import android.webkit.*
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginCaptchaConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.logging.Logger
import kotlinx.serialization.encodeToString
class CaptchaWebViewClient : WebViewClient {
val onCaptchaFinished = Event1<SourceCaptchaData?>();
val onPageLoaded = Event2<WebView?, String?>()
private val _pluginConfig: SourcePluginConfig?;
private val _captchaConfig: SourcePluginCaptchaConfig;
private var _didNotify = false;
private val _extractor: WebViewRequirementExtractor;
constructor(config: SourcePluginConfig) : super() {
_pluginConfig = config;
_captchaConfig = config.captcha!!;
_extractor = WebViewRequirementExtractor(
config.allowUrls,
null,
null,
config.captcha!!.cookiesToFind,
config.captcha!!.completionUrl,
config.captcha!!.cookiesExclOthers
);
Logger.i(TAG, "Captcha [${config.name}]" +
"\nRequired Cookies: ${Serializer.json.encodeToString(config.captcha!!.cookiesToFind)}",);
}
constructor(captcha: SourcePluginCaptchaConfig) : super() {
_pluginConfig = null;
_captchaConfig = captcha;
_extractor = WebViewRequirementExtractor(
null,
null,
null,
captcha.cookiesToFind,
captcha.completionUrl,
captcha.cookiesExclOthers
);
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url);
Logger.i(TAG, "onPageFinished url = ${url}")
onPageLoaded.emit(view, url);
}
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
if(request == null)
return super.shouldInterceptRequest(view, request as WebResourceRequest?);
val extracted = _extractor.handleRequest(view, request);
if(extracted != null && !_didNotify) {
_didNotify = true;
onCaptchaFinished.emit(SourceCaptchaData(
extracted.cookies,
extracted.headers
));
}
return super.shouldInterceptRequest(view, request);
}
companion object {
private val TAG = "CaptchaWebViewClient";
}
}
@@ -2,6 +2,7 @@ package com.futo.platformplayer.others
import android.net.Uri import android.net.Uri
import android.webkit.* import android.webkit.*
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
@@ -12,6 +13,7 @@ import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.matchesDomain import com.futo.platformplayer.matchesDomain
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class LoginWebViewClient : WebViewClient { class LoginWebViewClient : WebViewClient {
private val LOG_VERBOSE = false; private val LOG_VERBOSE = false;
@@ -42,10 +44,13 @@ class LoginWebViewClient : WebViewClient {
private var urlFound = false; private var urlFound = false;
override fun onPageFinished(view: WebView?, url: String?) { override fun onPageFinished(view: WebView?, url: String?) {
if(BuildConfig.DEBUG)
Logger.i(TAG, "Login Url Page: " + url);
super.onPageFinished(view, url); super.onPageFinished(view, url);
onPageLoaded.emit(view, url); onPageLoaded.emit(view, url);
} }
//TODO: Use new WebViewRequirementExtractor when time to test extensively
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? { override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
if(request == null) if(request == null)
return super.shouldInterceptRequest(view, request as WebResourceRequest?); return super.shouldInterceptRequest(view, request as WebResourceRequest?);
@@ -54,11 +59,29 @@ class LoginWebViewClient : WebViewClient {
return null; return null;
} }
var completionUrlExcludeQuery = false
var completionUrlToCheck = if(urlFound) null else _authConfig.completionUrl;
if(completionUrlToCheck != null) {
if(completionUrlToCheck.endsWith("?*")) {
completionUrlToCheck = completionUrlToCheck.substring(0, completionUrlToCheck.length - 2);
completionUrlExcludeQuery = true;
}
}
val domain = request.url.host; val domain = request.url.host;
val domainLower = request.url.host?.lowercase(); val domainLower = request.url.host?.lowercase();
val urlString = request.url.toString();
if(_authConfig.completionUrl == null) if(_authConfig.completionUrl == null)
urlFound = true; urlFound = true;
else urlFound = urlFound || request.url == Uri.parse(_authConfig.completionUrl); else urlFound = urlFound || (
if(completionUrlExcludeQuery)
(if(urlString.contains("?"))
urlString.substring(0, urlString.indexOf("?")) == completionUrlToCheck
else urlString == completionUrlToCheck)
else
request.url == Uri.parse(_authConfig.completionUrl)
);
//HEADERS //HEADERS
if(domainLower != null) { if(domainLower != null) {
@@ -0,0 +1,125 @@
package com.futo.platformplayer.others
import android.net.Uri
import android.webkit.CookieManager
import android.webkit.WebResourceRequest
import android.webkit.WebView
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.matchesDomain
class WebViewRequirementExtractor {
private val allowedUrls: List<String>;
private val headersToFind: List<String>?;
private val domainHeadersToFind: Map<String, List<String>>?;
private val cookiesToFind: List<String>?;
private val completionUrl: String?;
private val exclOtherCookies: Boolean;
private val headersFoundMap: HashMap<String, HashMap<String, String>> = hashMapOf();
private val cookiesFoundMap = hashMapOf<String, HashMap<String, String>>();
private var urlFound = false;
constructor(allowedUrls: List<String>?, headers: List<String>?, domainHeaders: Map<String, List<String>>?, cookies: List<String>?, url: String?, exclOtherCookies: Boolean = false) {
this.allowedUrls = allowedUrls ?: listOf("everywhere");
this.exclOtherCookies = exclOtherCookies;
headersToFind = headers;
domainHeadersToFind = domainHeaders;
cookiesToFind = cookies;
completionUrl = url;
}
fun handleRequest(view: WebView?, request: WebResourceRequest, logVerbose: Boolean = false): ExtractedData? {
val domain = request.url.host;
val domainLower = request.url.host?.lowercase();
if(completionUrl == null)
urlFound = true;
else urlFound = urlFound || request.url == Uri.parse(completionUrl);
//HEADERS
if(domainLower != null) {
val headersToFind = ((headersToFind?.map { Pair(it.lowercase(), domainLower) } ?: listOf()) +
(domainHeadersToFind?.filter { domainLower.matchesDomain(it.key.lowercase())}
?.flatMap { it.value.map { header -> Pair(header.lowercase(), it.key.lowercase()) } } ?: listOf()));
val foundHeaders = request.requestHeaders.filter { requestHeader -> headersToFind.any { it.first.equals(requestHeader.key, true)} &&
(!requestHeader.key.equals("Authorization", ignoreCase = true) || requestHeader.value != "undefined") } //TODO: More universal fix (optional regex?)
for(header in foundHeaders) {
for(headerDomain in headersToFind.filter { it.first.equals(header.key, true) }) {
if (!headersFoundMap.containsKey(headerDomain.second))
headersFoundMap[headerDomain.second] = hashMapOf();
headersFoundMap[headerDomain.second]!![header.key.lowercase()] = header.value;
}
}
}
//COOKIES
//TODO: This is not an ideal solution, we want to intercept the response, but interception need to be rewritten to support that. Correct implementation commented underneath
//TODO: For now we assume cookies are legit for all subdomains of a top-level domain, this is the most common scenario anyway
val cookieString = CookieManager.getInstance().getCookie(request.url.toString());
if(cookieString != null) {
val domainParts = domain!!.split(".");
val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString(".");
if(allowedUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) })
cookiesToFind?.let { cookiesToFind ->
val cookies = cookieString.split(";");
for(cookieStr in cookies) {
val cookieSplitIndex = cookieStr.indexOf("=");
if(cookieSplitIndex <= 0) continue;
val cookieKey = cookieStr.substring(0, cookieSplitIndex).trim();
val cookieVal = cookieStr.substring(cookieSplitIndex + 1).trim();
if (exclOtherCookies && !cookiesToFind.contains(cookieKey))
continue;
if (cookiesFoundMap.containsKey(cookieDomain))
cookiesFoundMap[cookieDomain]!![cookieKey] = cookieVal;
else
cookiesFoundMap[cookieDomain] = hashMapOf(Pair(cookieKey, cookieVal));
}
};
}
val headersFound = headersToFind?.map { it.lowercase() }?.all { reqHeader -> headersFoundMap.any { it.value.containsKey(reqHeader) } } ?: true
val domainHeadersFound = domainHeadersToFind?.all {
if(it.value.isEmpty())
return@all true;
if(!headersFoundMap.containsKey(it.key.lowercase()))
return@all false;
val foundDomainHeaders = headersFoundMap[it.key.lowercase()] ?: mapOf();
return@all it.value.all { reqHeader -> foundDomainHeaders.containsKey(reqHeader.lowercase()) };
} ?: true;
val cookiesFound = cookiesToFind?.all { toFind -> cookiesFoundMap.any { it.value.containsKey(toFind) } } ?: true;
if(logVerbose) {
val builder = StringBuilder();
builder.appendLine("Request (method: ${request.method}, host: ${request.url.host}, url: ${request.url}, path: ${request.url.path}):");
for (pair in request.requestHeaders) {
builder.appendLine(" ${pair.key}: ${pair.value}");
}
builder.appendLine(" Cookies: ${cookiesFoundMap.values.sumOf { it.values.size }}");
Logger.i(TAG, builder.toString());
Logger.i(TAG, "Result (urlFound: $urlFound, headersFound: $headersFound, cookiesFound: $cookiesFound)");
}
if (urlFound && headersFound && domainHeadersFound && cookiesFound)
return ExtractedData(cookiesFoundMap, headersFoundMap);
return null;
}
data class ExtractedData(
val cookies: HashMap<String, HashMap<String, String>>,
val headers: HashMap<String, HashMap<String, String>>
);
companion object {
val TAG = "WebViewRequirementExtractor";
}
}
@@ -20,10 +20,10 @@ class PlatformContentSerializer() : JsonContentPolymorphicSerializer<SerializedP
if(obj?.jsonPrimitive?.isString ?: true) if(obj?.jsonPrimitive?.isString ?: true)
return when(obj?.jsonPrimitive?.contentOrNull) { return when(obj?.jsonPrimitive?.contentOrNull) {
"MEDIA" -> SerializedPlatformVideo.serializer(); "MEDIA" -> SerializedPlatformVideo.serializer();
"NESTED" -> SerializedPlatformNestedContent.serializer(); "NESTED_VIDEO" -> SerializedPlatformNestedContent.serializer();
"ARTICLE" -> throw NotImplementedError("Articles not yet implemented"); "ARTICLE" -> throw NotImplementedError("Articles not yet implemented");
"POST" -> throw NotImplementedError("Post not yet implemented"); "POST" -> throw NotImplementedError("Post not yet implemented");
else -> throw NotImplementedError("Unknown Content Type Value: ${obj?.jsonPrimitive?.int}") else -> throw NotImplementedError("Unknown Content Type Value: ${obj?.jsonPrimitive?.contentOrNull}")
}; };
else else
return when(obj?.jsonPrimitive?.int) { return when(obj?.jsonPrimitive?.int) {
@@ -12,12 +12,14 @@ import com.futo.platformplayer.*
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.downloads.VideoDownload import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.exceptions.DownloadException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.Announcement import com.futo.platformplayer.states.Announcement
import com.futo.platformplayer.states.AnnouncementType import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
@@ -134,15 +136,21 @@ class DownloadService : Service() {
Logger.w(TAG, "Video had no video or videodetail, removing download"); Logger.w(TAG, "Video had no video or videodetail, removing download");
StateDownloads.instance.removeDownload(currentVideo); StateDownloads.instance.removeDownload(currentVideo);
} }
else if(ex is DownloadException && !ex.isRetryable) {
Logger.w(TAG, "Video had exception that should not be retried");
StateDownloads.instance.removeDownload(currentVideo);
StateDownloads.instance.preventPlaylistDownload(currentVideo);
}
else else
Logger.e(TAG, "Failed download [${currentVideo.name}]: ${ex.message}", ex); Logger.e(TAG, "Failed download [${currentVideo.name}]: ${ex.message}", ex);
currentVideo.error = ex.message; currentVideo.error = ex.message;
currentVideo.changeState(VideoDownload.State.ERROR); currentVideo.changeState(VideoDownload.State.ERROR);
ignore.add(currentVideo); ignore.add(currentVideo);
StateAnnouncement.instance.registerAnnouncement(currentVideo?.id?.value?:"" + currentVideo?.id?.pluginId?:"" + "_FailDownload", if(ex !is CancellationException)
"Download failed", StateAnnouncement.instance.registerAnnouncement(currentVideo?.id?.value?:"" + currentVideo?.id?.pluginId?:"" + "_FailDownload",
"Download for [${currentVideo.name}] failed.\nDownloads are automatically retried.\nReason: ${ex.message}", AnnouncementType.SESSION, null, "download"); "Download failed",
"Download for [${currentVideo.name}] failed.\nDownloads are automatically retried.\nReason: ${ex.message}", AnnouncementType.SESSION, null, "download");
//Give it a sec //Give it a sec
Thread.sleep(500); Thread.sleep(500);
@@ -50,7 +50,7 @@ class MediaPlaybackService : Service() {
private var _focusRequest: AudioFocusRequest? = null; private var _focusRequest: AudioFocusRequest? = null;
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Logger.i(TAG, "onStartCommand"); Logger.v(TAG, "onStartCommand");
if(!FragmentedStorage.isInitialized) { if(!FragmentedStorage.isInitialized) {
@@ -91,43 +91,49 @@ class MediaPlaybackService : Service() {
_mediaSession?.setCallback(object: MediaSessionCompat.Callback() { _mediaSession?.setCallback(object: MediaSessionCompat.Callback() {
override fun onSeekTo(pos: Long) { override fun onSeekTo(pos: Long) {
super.onSeekTo(pos) super.onSeekTo(pos)
Log.i(TAG, "Media session callback onSeekTo(pos = $pos)"); Logger.i(TAG, "Media session callback onSeekTo(pos = $pos)");
MediaControlReceiver.onSeekToReceived.emit(pos); MediaControlReceiver.onSeekToReceived.emit(pos);
} }
override fun onPlay() { override fun onPlay() {
super.onPlay(); super.onPlay();
Log.i(TAG, "Media session callback onPlay()"); Logger.i(TAG, "Media session callback onPlay()");
MediaControlReceiver.onPlayReceived.emit(); MediaControlReceiver.onPlayReceived.emit();
} }
override fun onPause() { override fun onPause() {
super.onPause(); super.onPause();
Log.i(TAG, "Media session callback onPause()"); Logger.i(TAG, "Media session callback onPause()");
MediaControlReceiver.onPauseReceived.emit(); MediaControlReceiver.onPauseReceived.emit();
} }
override fun onStop() { override fun onStop() {
super.onStop(); super.onStop();
Log.i(TAG, "Media session callback onStop()"); Logger.i(TAG, "Media session callback onStop()");
MediaControlReceiver.onCloseReceived.emit(); MediaControlReceiver.onCloseReceived.emit();
} }
override fun onSkipToPrevious() { override fun onSkipToPrevious() {
super.onSkipToPrevious(); super.onSkipToPrevious();
Log.i(TAG, "Media session callback onSkipToPrevious()"); Logger.i(TAG, "Media session callback onSkipToPrevious()");
MediaControlReceiver.onPreviousReceived.emit(); MediaControlReceiver.onPreviousReceived.emit();
} }
override fun onSkipToNext() {
super.onSkipToNext()
Logger.i(TAG, "Media session callback onSkipToNext()");
MediaControlReceiver.onNextReceived.emit();
}
}); });
} }
override fun onCreate() { override fun onCreate() {
Logger.i(TAG, "onCreate called"); Logger.v(TAG, "onCreate");
super.onCreate() super.onCreate()
} }
override fun onDestroy() { override fun onDestroy() {
Logger.i(TAG, "onDestroy called"); Logger.v(TAG, "onDestroy");
_instance = null; _instance = null;
MediaControlReceiver.onCloseReceived.emit(); MediaControlReceiver.onCloseReceived.emit();
super.onDestroy(); super.onDestroy();
@@ -138,7 +144,7 @@ class MediaPlaybackService : Service() {
} }
fun closeMediaSession() { fun closeMediaSession() {
Logger.i(TAG, "closeMediaSession called"); Logger.v(TAG, "closeMediaSession");
stopForeground(true); stopForeground(true);
val focusRequest = _focusRequest; val focusRequest = _focusRequest;
@@ -159,7 +165,7 @@ class MediaPlaybackService : Service() {
} }
fun updateMediaSession(videoUpdated: IPlatformVideo?) { fun updateMediaSession(videoUpdated: IPlatformVideo?) {
Logger.i(TAG, "updateMediaSession called"); Logger.v(TAG, "updateMediaSession");
var isUpdating = false; var isUpdating = false;
val video: IPlatformVideo; val video: IPlatformVideo;
if(videoUpdated == null) { if(videoUpdated == null) {
@@ -270,7 +276,7 @@ class MediaPlaybackService : Service() {
val notif = builder.build(); val notif = builder.build();
notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR; notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR;
Logger.i(TAG, "Updating notification bitmap=${if (bitmap != null) "not null" else "null"} channelId=${channel.id} icon=${icon} video=${video?.name ?: ""} playWhenReady=${playWhenReady} session.sessionToken=${session.sessionToken}"); Logger.i(TAG, "Updating notification bitmap=${if (bitmap != null) "yes" else "no."} channelId=${channel.id} icon=${icon} video=${video?.name ?: ""} playWhenReady=${playWhenReady} session.sessionToken=${session.sessionToken}");
startForeground(MEDIA_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK); startForeground(MEDIA_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
@@ -285,6 +291,7 @@ class MediaPlaybackService : Service() {
PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_PLAY_PAUSE PlaybackStateCompat.ACTION_PLAY_PAUSE
) )
.setState(state, pos, 1f, SystemClock.elapsedRealtime()) .setState(state, pos, 1f, SystemClock.elapsedRealtime())
@@ -25,11 +25,19 @@ import androidx.lifecycle.lifecycleScope
import androidx.work.* import androidx.work.*
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.activities.CaptchaActivity
import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.background.BackgroundWorker import com.futo.platformplayer.background.BackgroundWorker
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
import com.futo.platformplayer.logging.AndroidLogConsumer import com.futo.platformplayer.logging.AndroidLogConsumer
import com.futo.platformplayer.logging.FileLogConsumer import com.futo.platformplayer.logging.FileLogConsumer
import com.futo.platformplayer.logging.LogLevel import com.futo.platformplayer.logging.LogLevel
@@ -38,7 +46,10 @@ import com.futo.platformplayer.receivers.AudioNoisyReceiver
import com.futo.platformplayer.services.DownloadService import com.futo.platformplayer.services.DownloadService
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
import com.stripe.android.core.utils.encodeToJson
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File import java.io.File
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.* import java.util.*
@@ -52,10 +63,9 @@ import java.util.concurrent.TimeUnit
class StateApp { class StateApp {
val isMainActive: Boolean get() = contextOrNull != null && contextOrNull is MainActivity; //if context is MainActivity, it means its active val isMainActive: Boolean get() = contextOrNull != null && contextOrNull is MainActivity; //if context is MainActivity, it means its active
/*
private val externalRootDirectory = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "Grayjay"); private val externalRootDirectory = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "Grayjay");
fun getExternalRootDirectory(): File? { fun getExternalRootDirectory(): File? {
if(!externalRootDirectory.exists()) { if(!externalRootDirectory.exists()) {
val result = externalRootDirectory.mkdirs(); val result = externalRootDirectory.mkdirs();
@@ -65,6 +75,61 @@ class StateApp {
} }
else else
return externalRootDirectory; return externalRootDirectory;
}*/
fun getExternalGeneralDirectory(context: Context): DocumentFile? {
val generalUri = Settings.instance.storage.getStorageGeneralUri();
if(isValidStorageUri(context, generalUri))
return DocumentFile.fromTreeUri(context, generalUri!!);
return null;
}
fun changeExternalGeneralDirectory(context: IWithResultLauncher, onChanged: ((DocumentFile?)->Unit)? = null) {
if(context is Context)
requestDirectoryAccess(context, "General Files", "This directory is used to save auto-backups and other persistent files.", null) {
if(it != null)
context.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION));
if(it != null && isValidStorageUri(context, it)) {
Logger.i(TAG, "Changed external general directory: ${it}");
Settings.instance.storage.storage_general = it.toString();
Settings.instance.save();
onChanged?.invoke(getExternalGeneralDirectory(context));
}
else
scopeOrNull?.launch(Dispatchers.Main) {
UIDialogs.toast("Failed to gain access to\n [${it?.lastPathSegment}]");
};
};
}
fun getExternalDownloadDirectory(context: Context): DocumentFile? {
val downloadUri = Settings.instance.storage.storage_download?.let { Uri.parse(it) };
if(isValidStorageUri(context, downloadUri))
return DocumentFile.fromTreeUri(context, downloadUri!!);
return null;
}
fun changeExternalDownloadDirectory(context: IWithResultLauncher, onChanged: ((DocumentFile?)->Unit)? = null) {
scopeOrNull?.launch(Dispatchers.Main) {
UIDialogs.toast("External download directory not yet used by export (WIP)");
};
if(context is Context)
requestDirectoryAccess(context, "Download Exports", "This directory is used to export downloads to for external usage.", null) {
if(it != null)
context.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION));
if(it != null && isValidStorageUri(context, it)) {
Logger.i(TAG, "Changed external download directory: ${it}");
Settings.instance.storage.storage_general = it.toString();
Settings.instance.save();
onChanged?.invoke(getExternalDownloadDirectory(context));
}
};
}
fun isValidStorageUri(context: Context, uri: Uri?): Boolean {
if(uri == null)
return false;
return context.contentResolver.persistedUriPermissions.any { it.uri == uri && it.isReadPermission && it.isWritePermission };
} }
//Scope //Scope
@@ -171,20 +236,20 @@ class StateApp {
return state; return state;
} }
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, path: Uri?, handle: (Uri?)->Unit) fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit)
{ {
if(activity is Context) if(activity is Context)
{ {
UIDialogs.showDialog(activity, R.drawable.ic_security, "Missing Access", "Please grant access to ${name}", null, 0, 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("Cancel", {}),
UIDialogs.Action("Ok", { UIDialogs.Action("Ok", {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
if(path != null) if(path != null)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path); intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
.and(Intent.FLAG_GRANT_READ_URI_PERMISSION) .or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.and(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) .or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
.and(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION); .or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
activity.launchForResult(intent, 99) { activity.launchForResult(intent, 99) {
if(it.resultCode == Activity.RESULT_OK) { if(it.resultCode == Activity.RESULT_OK) {
@@ -299,7 +364,7 @@ class StateApp {
} }
Logger.onLogSubmitted.subscribe { Logger.onLogSubmitted.subscribe {
scopeGetter().launch(Dispatchers.Main) { scopeOrNull?.launch(Dispatchers.Main) {
try { try {
if (it != null) { if (it != null) {
UIDialogs.toast("Uploaded " + (it ?: "null"), true); UIDialogs.toast("Uploaded " + (it ?: "null"), true);
@@ -369,24 +434,50 @@ class StateApp {
StatePlaylists.instance.toMigrateCheck() StatePlaylists.instance.toMigrateCheck()
).flatten(), 0); ).flatten(), 0);
scope.launch { if(Settings.instance.subscriptions.fetchOnAppBoot) {
delay(5000); scope.launch(Dispatchers.IO) {
StateSubscriptions.instance.updateSubscriptionFeed(scope, false); val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.config.subscriptionRateLimit}" }.joinToString("\n");
val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.config.subscriptionRateLimit?.let { rateLimit -> clientCount.value > rateLimit } == true };
if (isRateLimitReached) {
Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}");
delay(5000);
StateSubscriptions.instance.updateSubscriptionFeed(scope, false);
}
else
Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}");
}
} }
val interval = Settings.instance.subscriptions.getSubscriptionsBackgroundIntervalMinutes(); val interval = Settings.instance.subscriptions.getSubscriptionsBackgroundIntervalMinutes();
scheduleBackgroundWork(context, interval != 0, interval); scheduleBackgroundWork(context, interval != 0, interval);
if(!Settings.instance.backup.didAskAutoBackup && !Settings.instance.backup.shouldAutomaticBackup()) { 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", { 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", {
UIDialogs.showAutomaticBackupDialog(context); if(context is IWithResultLauncher && !Settings.instance.storage.isStorageMainValid(context)) {
StateAnnouncement.instance.deleteAnnouncement("backup"); UIDialogs.toast("Missing general directory");
changeExternalGeneralDirectory(context) {
UIDialogs.showAutomaticBackupDialog(context);
StateAnnouncement.instance.deleteAnnouncement("backup");
};
}
else {
UIDialogs.showAutomaticBackupDialog(context);
StateAnnouncement.instance.deleteAnnouncement("backup");
}
}, "No Backup", { }, "No Backup", {
Settings.instance.backup.didAskAutoBackup = true; Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save(); Settings.instance.save();
}); });
} }
else if(Settings.instance.backup.didAskAutoBackup && Settings.instance.backup.shouldAutomaticBackup() && !Settings.instance.storage.isStorageMainValid(context)) {
if(context is IWithResultLauncher) {
Logger.i(TAG, "Backup set without general directory, please select general external directory");
changeExternalGeneralDirectory(context) {
Logger.i(TAG, "Directory set, Auto-backup should resume to this location");
};
}
}
instance.scopeOrNull?.launch(Dispatchers.IO) { instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
@@ -510,7 +601,6 @@ class StateApp {
if (_lastNetworkState != NetworkState.DISCONNECTED) { if (_lastNetworkState != NetworkState.DISCONNECTED) {
scopeOrNull?.launch(Dispatchers.Main) { scopeOrNull?.launch(Dispatchers.Main) {
try { try {
Logger.i(TAG, "onConnectionAvailable emitted");
onConnectionAvailable.emit(); onConnectionAvailable.emit();
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to emit onConnectionAvailable", e) Logger.e(TAG, "Failed to emit onConnectionAvailable", e)
@@ -572,6 +662,40 @@ class StateApp {
} }
} }
private var hasCaptchaDialog = false;
fun handleCaptchaException(client: JSClient, exception: ScriptCaptchaRequiredException) {
Logger.w(HomeFragment.TAG, "[${client.name}] Plugin captcha required.", exception);
scopeOrNull?.launch(Dispatchers.Main) {
if(hasCaptchaDialog)
return@launch;
hasCaptchaDialog = true;
UIDialogs.showConfirmationDialog(context, "Captcha required\nPlugin [${client.config.name}]", {
CaptchaActivity.showCaptcha(context, client.config, exception.url, exception.body) {
hasCaptchaDialog = false;
if(client is DevJSClient) {
client.setCaptcha(it);
client.recreate(context);
}
else {
StatePlugins.instance.setPluginCaptcha(client.config.id, it);
scopeOrNull?.launch(Dispatchers.IO) {
try {
StatePlatform.instance.reloadClient(context, client.config.id);
} catch (e: Throwable) {
Logger.e(SourceDetailFragment.TAG, "Failed to reload client.", e)
return@launch;
}
}
}
}
}, {
hasCaptchaDialog = false;
})
}
}
companion object { companion object {
private val TAG = "StateApp"; private val TAG = "StateApp";
@SuppressLint("StaticFieldLeak") //This is only alive while MainActivity is alive @SuppressLint("StaticFieldLeak") //This is only alive while MainActivity is alive
@@ -17,11 +17,17 @@ import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.copyTo
import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.encryption.EncryptionProvider import com.futo.platformplayer.encryption.EncryptionProvider
import com.futo.platformplayer.getInputStream
import com.futo.platformplayer.getNowDiffHours import com.futo.platformplayer.getNowDiffHours
import com.futo.platformplayer.getOutputStream
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.readBytes
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.writeBytes
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -47,20 +53,22 @@ class StateBackup {
private val _autoBackupLock = Object(); private val _autoBackupLock = Object();
private fun getAutomaticBackupDocumentFiles(context: Context, root: Uri, create: Boolean = false): Pair<DocumentFile?, DocumentFile?> { private fun getAutomaticBackupDocumentFiles(context: Context, create: Boolean = false): Pair<DocumentFile?, DocumentFile?> {
val dir = DocumentFile.fromTreeUri(context, root); if(!Settings.instance.storage.isStorageMainValid(context))
if(dir == null) return Pair(null, null);
throw IllegalStateException("Can't access external document files"); val uri = Settings.instance.storage.getStorageGeneralUri() ?: return Pair(null, null);
val dir = DocumentFile.fromTreeUri(context, uri) ?: return Pair(null, null);
val mainBackupFile = dir.findFile("GrayjayBackup.ezip") ?: if(create) dir.createFile("grayjay/ezip", "GrayjayBackup.ezip") else null; val mainBackupFile = dir.findFile("GrayjayBackup.ezip") ?: if(create) dir.createFile("grayjay/ezip", "GrayjayBackup.ezip") else null;
val secondaryBackupFile = dir.findFile("GrayjayBackup.ezip.old") ?: if(create) dir.createFile("grayjay/ezip", "GrayjayBackup.ezip.old") else null; val secondaryBackupFile = dir.findFile("GrayjayBackup.ezip.old") ?: if(create) dir.createFile("grayjay/ezip", "GrayjayBackup.ezip.old") else null;
return Pair(mainBackupFile, secondaryBackupFile); return Pair(mainBackupFile, secondaryBackupFile);
} }
/*
private fun getAutomaticBackupFiles(): Pair<File, File> { private fun getAutomaticBackupFiles(): Pair<File, File> {
val dir = StateApp.instance.getExternalRootDirectory(); val dir = StateApp.instance.getExternalRootDirectory();
if(dir == null) if(dir == null)
throw IllegalStateException("Can't access external files"); throw IllegalStateException("Can't access external files");
return Pair(File(dir, "GrayjayBackup.ezip"), File(dir, "GrayjayBackup.ezip.old")) return Pair(File(dir, "GrayjayBackup.ezip"), File(dir, "GrayjayBackup.ezip.old"))
} }*/
fun getAllMigrationStores(): List<ManagedStore<*>> = listOf( fun getAllMigrationStores(): List<ManagedStore<*>> = listOf(
@@ -77,10 +85,11 @@ class StateBackup {
return password.padStart(32, '9'); return password.padStart(32, '9');
} }
fun hasAutomaticBackup(): Boolean { fun hasAutomaticBackup(): Boolean {
if(StateApp.instance.getExternalRootDirectory() == null) val context = StateApp.instance.contextOrNull ?: return false;
if(!Settings.instance.storage.isStorageMainValid(context))
return false; return false;
val files = getAutomaticBackupFiles(); val files = getAutomaticBackupDocumentFiles(context,);
return files.first.exists() || files.second.exists(); return files.first?.exists() ?: false || files.second?.exists() ?: false;
} }
fun startAutomaticBackup(force: Boolean = false) { fun startAutomaticBackup(force: Boolean = false) {
val lastBackupHoursAgo = Settings.instance.backup.lastAutoBackupTime.getNowDiffHours(); val lastBackupHoursAgo = Settings.instance.backup.lastAutoBackupTime.getNowDiffHours();
@@ -93,20 +102,27 @@ class StateBackup {
try { try {
Logger.i(TAG, "Starting AutoBackup (Last ${lastBackupHoursAgo} ago)"); Logger.i(TAG, "Starting AutoBackup (Last ${lastBackupHoursAgo} ago)");
synchronized(_autoBackupLock) { synchronized(_autoBackupLock) {
val context = StateApp.instance.contextOrNull ?: return@synchronized;
val data = export(); val data = export();
val zip = data.asZip(); val zip = data.asZip();
val encryptedZip = EncryptionProvider.instance.encrypt(zip, getAutomaticBackupPassword()); val encryptedZip = EncryptionProvider.instance.encrypt(zip, getAutomaticBackupPassword());
val backupFiles = getAutomaticBackupFiles(); if(!Settings.instance.storage.isStorageMainValid(context)) {
val exportFile = backupFiles.first; StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
if (exportFile.exists()) UIDialogs.toast("Missing permissions for auto-backup, please set the external general directory in settings");
exportFile.copyTo(backupFiles.second, true); }
}
else {
val backupFiles = getAutomaticBackupDocumentFiles(context, true);
val exportFile = backupFiles.first;
if (exportFile?.exists() == true && backupFiles.second != null)
exportFile!!.copyTo(context, backupFiles.second!!);
exportFile!!.writeBytes(context, encryptedZip);
exportFile.writeBytes(encryptedZip); Settings.instance.backup.lastAutoBackupTime = OffsetDateTime.now(); //OffsetDateTime.now();
Settings.instance.save();
Settings.instance.backup.lastAutoBackupTime = OffsetDateTime.now(); //OffsetDateTime.now(); }
Settings.instance.save();
} }
Logger.i(TAG, "Finished AutoBackup"); Logger.i(TAG, "Finished AutoBackup");
} }
@@ -119,28 +135,22 @@ class StateBackup {
} }
} }
//TODO: This contains a temporary workaround to make it semi-compatible with > Android 11. By mixing "File" and "DocumentFile" usage. //TODO: This goes has recently changed to use DocumentFiles and DocumentTree, and might need additional checks/edgecases covered.
//TODO: For now this is used to at least recover and gain temporary access to docs after losing access (due to permission lost after reinstall) fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false) {
//TODO: Should be replaced with a more re-usable system that leverages OPEN_DOCUMENT_TREE once, and somehow persist this content after uninstall
//TODO: DocumentFiles are not compatible with normal files and require its own system.
//TODO: Investigate persistence of DOCUMENT_TREE files after uninstall...
fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false, withStream: InputStream? = null) {
if(ifExists && !hasAutomaticBackup()) { if(ifExists && !hasAutomaticBackup()) {
Logger.i(TAG, "No AutoBackup exists, not restoring"); Logger.i(TAG, "No AutoBackup exists, not restoring");
return; return;
} }
//TODO: Sadly on reinstalls of app this fails on file permissions.
Logger.i(TAG, "Starting AutoBackup restore"); Logger.i(TAG, "Starting AutoBackup restore");
synchronized(_autoBackupLock) { synchronized(_autoBackupLock) {
val backupFiles = getAutomaticBackupFiles(); val backupFiles = getAutomaticBackupDocumentFiles(StateApp.instance.context, false);
try { try {
if (!backupFiles.first.exists() && withStream == null) if (backupFiles.first?.exists() != true)
throw IllegalStateException("Backup file does not exist"); throw IllegalStateException("Backup file does not exist");
val backupBytesEncrypted = if(withStream != null) withStream.readBytes() else backupFiles.first.readBytes(); val backupBytesEncrypted = backupFiles.first!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.first?.uri}]");
val backupBytes = EncryptionProvider.instance.decrypt(backupBytesEncrypted, getAutomaticBackupPassword(password)); val backupBytes = EncryptionProvider.instance.decrypt(backupBytesEncrypted, getAutomaticBackupPassword(password));
importZipBytes(context, scope, backupBytes); importZipBytes(context, scope, backupBytes);
Logger.i(TAG, "Finished AutoBackup restore"); Logger.i(TAG, "Finished AutoBackup restore");
@@ -154,21 +164,21 @@ class StateBackup {
else null; else null;
if(activity != null) { if(activity != null) {
if(activity is IWithResultLauncher) if(activity is IWithResultLauncher)
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", backupFiles.first.parent?.toUri()) { StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", "Allows restoring of a backup", backupFiles.first?.parentFile?.uri) {
if(it != null) { if(it != null) {
val customFiles = StateBackup.getAutomaticBackupDocumentFiles(activity, it); val customFiles = StateBackup.getAutomaticBackupDocumentFiles(activity);
if(customFiles.first != null && customFiles.first!!.isFile && customFiles.first!!.exists() && customFiles.first!!.canRead()) if(customFiles.first != null && customFiles.first!!.isFile && customFiles.first!!.exists() && customFiles.first!!.canRead())
restoreAutomaticBackup(context, scope, password, ifExists, activity.contentResolver.openInputStream(customFiles.first!!.uri)); restoreAutomaticBackup(context, scope, password, ifExists);
} }
}; };
} }
} }
catch (ex: Throwable) { catch (ex: Throwable) {
Logger.e(TAG, "Failed main AutoBackup restore", ex) Logger.e(TAG, "Failed main AutoBackup restore", ex)
if (!backupFiles.second.exists()) if (backupFiles.second?.exists() != true)
throw ex; throw ex;
val backupBytesEncrypted = backupFiles.second.readBytes(); val backupBytesEncrypted = backupFiles.second!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.second?.uri}]");
val backupBytes = EncryptionProvider.instance.decrypt(backupBytesEncrypted, getAutomaticBackupPassword(password)); val backupBytes = EncryptionProvider.instance.decrypt(backupBytesEncrypted, getAutomaticBackupPassword(password));
importZipBytes(context, scope, backupBytes); importZipBytes(context, scope, backupBytes);
Logger.i(TAG, "Finished AutoBackup restore"); Logger.i(TAG, "Finished AutoBackup restore");
@@ -1,12 +1,16 @@
package com.futo.platformplayer.states package com.futo.platformplayer.states
import android.content.ContentResolver
import android.net.Uri
import android.os.StatFs import android.os.StatFs
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.AlreadyQueuedException import com.futo.platformplayer.api.media.exceptions.AlreadyQueuedException
import com.futo.platformplayer.api.media.models.streams.sources.* import com.futo.platformplayer.api.media.models.streams.sources.*
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
@@ -108,6 +112,11 @@ class StateDownloads {
fun getPlaylistDownload(playlistId: String): PlaylistDownloadDescriptor? { fun getPlaylistDownload(playlistId: String): PlaylistDownloadDescriptor? {
return _downloadPlaylists.findItem { it.id == playlistId }; return _downloadPlaylists.findItem { it.id == playlistId };
} }
fun savePlaylistDownload(playlistDownload: PlaylistDownloadDescriptor) {
synchronized(playlistDownload.preventDownload) {
_downloadPlaylists.save(playlistDownload);
}
}
fun deleteCachedPlaylist(id: String) { fun deleteCachedPlaylist(id: String) {
val pdl = getPlaylistDownload(id); val pdl = getPlaylistDownload(id);
if(pdl != null) if(pdl != null)
@@ -142,6 +151,19 @@ class StateDownloads {
_downloading.delete(download); _downloading.delete(download);
onDownloadsChanged.emit(); onDownloadsChanged.emit();
} }
fun preventPlaylistDownload(download: VideoDownload) {
if(download.video != null && download.groupID != null && download.groupType == VideoDownload.GROUP_PLAYLIST) {
getPlaylistDownload(download.groupID!!)?.let {
synchronized(it.preventDownload) {
if(download.video?.url != null && !it.preventDownload.contains(download.video!!.url)) {
it.preventDownload.add(download.video!!.url);
savePlaylistDownload(it);
Logger.w(TAG, "Preventing further download attempts in playlist [${it.id}] for [${download.name}]:${download.video?.url}");
}
}
}
}
}
fun checkForDownloadsTodos() { fun checkForDownloadsTodos() {
val hasPlaylistChanged = checkForOutdatedPlaylists(); val hasPlaylistChanged = checkForOutdatedPlaylists();
@@ -157,12 +179,15 @@ class StateDownloads {
val playlistsDownloaded = getCachedPlaylists(); val playlistsDownloaded = getCachedPlaylists();
for(playlist in playlistsDownloaded) { for(playlist in playlistsDownloaded) {
val playlistDownload = getPlaylistDownload(playlist.playlist.id) ?: continue; val playlistDownload = getPlaylistDownload(playlist.playlist.id) ?: continue;
val toIgnore = playlistDownload.getPreventDownloadList();
if(playlist.playlist.videos.any{ getCachedVideo(it.id) == null }) { val missingVideoCount = playlist.playlist.videos.count { !toIgnore.contains(it.url) && getCachedVideo(it.id) == null };
Logger.i(TAG, "Found new videos on playlist [${playlist.playlist.name}]"); if(missingVideoCount > 0) {
Logger.i(TAG, "Found new videos (${missingVideoCount}) on playlist [${playlist.playlist.name}] to download");
continueDownload(playlistDownload, playlist.playlist); continueDownload(playlistDownload, playlist.playlist);
hasChanged = true; hasChanged = true;
} }
else
Logger.v(TAG, "Offline playlist [${playlist.playlist.name}] is up to date");
} }
return hasChanged; return hasChanged;
} }
@@ -171,6 +196,11 @@ class StateDownloads {
var hasNew = false; var hasNew = false;
for(item in playlist.videos) { for(item in playlist.videos) {
val existing = getCachedVideo(item.id); val existing = getCachedVideo(item.id);
if(!playlistDownload.shouldDownload(item)) {
Logger.i(TAG, "Not downloading for playlist [${playlistDownload.id}] Video [${item.name}]:${item.url}")
continue;
}
if(existing == null) { if(existing == null) {
val ongoingDownload = getDownloading().find { it.id.value == item.id.value && it.id.value != null }; val ongoingDownload = getDownloading().find { it.id.value == item.id.value && it.id.value != null };
if(ongoingDownload != null) { if(ongoingDownload != null) {
@@ -291,6 +321,32 @@ class StateDownloads {
} }
} }
suspend fun downloadSubtitles(subtitle: ISubtitleSource, contentResolver: ContentResolver): SubtitleRawSource? {
val subtitleUri = subtitle.getSubtitlesURI();
if(subtitleUri == null)
return null;
var subtitles: String? = null;
if ("file" == subtitleUri.scheme) {
val inputStream = contentResolver.openInputStream(subtitleUri);
inputStream?.use { stream ->
val reader = stream.bufferedReader();
subtitles = reader.use { it.readText() };
}
} else if ("http" == subtitleUri.scheme || "https" == subtitleUri.scheme) {
val client = ManagedHttpClient();
val subtitleResponse = client.get(subtitleUri.toString());
if (!subtitleResponse.isOk) {
throw Exception("Cannot fetch subtitles from source '${subtitleUri}': ${subtitleResponse.code}");
}
subtitles = subtitleResponse.body?.toString()
?: throw Exception("Subtitles are invalid '${subtitleUri}': ${subtitleResponse.code}");
} else {
throw NotImplementedError("Unsuported scheme");
}
return if (subtitles != null) SubtitleRawSource(subtitle.name, subtitle.format, subtitles!!) else null;
}
fun cleanupDownloads(): Pair<Int, Long> { fun cleanupDownloads(): Pair<Int, Long> {
val expected = getDownloadedVideos(); val expected = getDownloadedVideos();
val validFiles = HashSet(expected.flatMap { it.videoSource.map { it.filePath } + it.audioSource.map { it.filePath } }); val validFiles = HashSet(expected.flatMap { it.videoSource.map { it.filePath } + it.audioSource.map { it.filePath } });
@@ -70,11 +70,11 @@ class StatePlatform {
//Pools always follow the behavior of the base client. So if user disables a plugin, it kills all pooled clients. //Pools always follow the behavior of the base client. So if user disables a plugin, it kills all pooled clients.
//Each pooled client adds additional memory usage. //Each pooled client adds additional memory usage.
//WARNING: Be careful with pooling some calls, as they might use the plugin subsequently afterwards. For example pagers might block plugins in future calls. //WARNING: Be careful with pooling some calls, as they might use the plugin subsequently afterwards. For example pagers might block plugins in future calls.
private val _mainClientPool = PlatformMultiClientPool(2); //Used for all main user events, generally user critical private val _mainClientPool = PlatformMultiClientPool("Main", 2); //Used for all main user events, generally user critical
private val _pagerClientPool = PlatformMultiClientPool(2); //Used primarily for calls that result in front-end pagers, preventing them from blocking other calls. private val _pagerClientPool = PlatformMultiClientPool("Pagers", 2); //Used primarily for calls that result in front-end pagers, preventing them from blocking other calls.
private val _channelClientPool = PlatformMultiClientPool(15); //Used primarily for subscription/background channel fetches private val _channelClientPool = PlatformMultiClientPool("Channels", 15); //Used primarily for subscription/background channel fetches
private val _trackerClientPool = PlatformMultiClientPool(1); //Used exclusively for playback trackers private val _trackerClientPool = PlatformMultiClientPool("Trackers", 1); //Used exclusively for playback trackers
private val _liveEventClientPool = PlatformMultiClientPool(1); //Used exclusively for live events private val _liveEventClientPool = PlatformMultiClientPool("LiveEvents", 1); //Used exclusively for live events
private val _primaryClientPersistent = FragmentedStorage.get<StringStorage>("primaryClient"); private val _primaryClientPersistent = FragmentedStorage.get<StringStorage>("primaryClient");
@@ -172,7 +172,11 @@ class StatePlatform {
_icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?: _icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?:
ImageVariable(plugin.config.absoluteIconUrl, null); ImageVariable(plugin.config.absoluteIconUrl, null);
_availableClients.add(JSClient(context, plugin)); val client = JSClient(context, plugin);
client.onCaptchaException.subscribe { client, ex ->
StateApp.instance.handleCaptchaException(client, ex);
}
_availableClients.add(client);
} }
if(_availableClients.distinctBy { it.id }.count() < _availableClients.size) if(_availableClients.distinctBy { it.id }.count() < _availableClients.size)
@@ -287,6 +291,9 @@ class StatePlatform {
StatePlugins.instance.getPlugin(id) StatePlugins.instance.getPlugin(id)
?: throw IllegalStateException("Client existed, but plugin config didn't") ?: throw IllegalStateException("Client existed, but plugin config didn't")
); );
newClient.onCaptchaException.subscribe { client, ex ->
StateApp.instance.handleCaptchaException(client, ex);
}
synchronized(_clientsLock) { synchronized(_clientsLock) {
if (_enabledClients.contains(client)) { if (_enabledClients.contains(client)) {
@@ -399,13 +406,15 @@ class StatePlatform {
return@async searchResult; return@async searchResult;
} catch(ex: Throwable) { } catch(ex: Throwable) {
Logger.e(TAG, "getHomeRefresh", ex); Logger.e(TAG, "getHomeRefresh", ex);
return@async null; throw ex;
//return@async null;
} }
}); });
}.toList(); }.toList();
val finishedPager = deferred.map { it.second }.awaitFirstNotNullDeferred() ?: return EmptyPager(); val finishedPager = deferred.map { it.second }.awaitFirstNotNullDeferred() ?: return EmptyPager();
val toAwait = deferred.filter { it.second != finishedPager.first }; val toAwait = deferred.filter { it.second != finishedPager.first };
return RefreshDistributionContentPager( return RefreshDistributionContentPager(
listOf(finishedPager.second), listOf(finishedPager.second),
toAwait.map { it.second }, toAwait.map { it.second },
@@ -616,9 +625,13 @@ class StatePlatform {
} }
fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { it.isChannelUrl(url) }; fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { it.isChannelUrl(url) };
fun getChannelClient(url : String) : IPlatformClient = getChannelClientOrNull(url) fun getChannelClient(url : String, exclude: List<String>? = null) : IPlatformClient = getChannelClientOrNull(url, exclude)
?: throw NoPlatformClientException("No client enabled that supports this channel url (${url})"); ?: throw NoPlatformClientException("No client enabled that supports this channel url (${url})");
fun getChannelClientOrNull(url : String) : IPlatformClient? = getEnabledClients().find { it.isChannelUrl(url) }; fun getChannelClientOrNull(url : String, exclude: List<String>? = null) : IPlatformClient? =
if(exclude == null)
getEnabledClients().find { it.isChannelUrl(url) }
else
getEnabledClients().find { !exclude.contains(it.id) && it.isChannelUrl(url) };
fun getChannel(url: String, updateSubscriptions: Boolean = true): Deferred<IPlatformChannel> { fun getChannel(url: String, updateSubscriptions: Boolean = true): Deferred<IPlatformChannel> {
Logger.i(TAG, "Platform - getChannel"); Logger.i(TAG, "Platform - getChannel");
@@ -629,9 +642,9 @@ class StatePlatform {
return _scope.async { getChannelLive(url, updateSubscriptions) }; return _scope.async { getChannelLive(url, updateSubscriptions) };
} }
fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0): IPager<IPlatformContent> { fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List<String>? = null): IPager<IPlatformContent> {
Logger.i(TAG, "Platform - getChannelVideos"); Logger.i(TAG, "Platform - getChannelVideos");
val baseClient = getChannelClient(channelUrl); val baseClient = getChannelClient(channelUrl, ignorePlugins);
val clientCapabilities = baseClient.getChannelCapabilities(); val clientCapabilities = baseClient.getChannelCapabilities();
val client = if(usePooledClients > 1) val client = if(usePooledClients > 1)
@@ -651,19 +664,24 @@ class StatePlatform {
toQuery.add(ResultCapabilities.TYPE_STREAMS); toQuery.add(ResultCapabilities.TYPE_STREAMS);
if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE)) if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE))
toQuery.add(ResultCapabilities.TYPE_LIVE); toQuery.add(ResultCapabilities.TYPE_LIVE);
if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS))
toQuery.add(ResultCapabilities.TYPE_POSTS);
if(isSubscriptionOptimized) { if(isSubscriptionOptimized) {
val sub = StateSubscriptions.instance.getSubscription(channelUrl); val sub = StateSubscriptions.instance.getSubscription(channelUrl);
if(sub != null) { if(sub != null) {
val daysSinceLiveStream = sub.lastLiveStream.getNowDiffDays() if(!sub.shouldFetchStreams()) {
if(daysSinceLiveStream > 7) { Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 7 days, skipping live streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
Logger.i(TAG, "Subscription [${channelUrl}] Last livestream > 7 days, skipping live streams [${daysSinceLiveStream} days ago]");
toQuery.remove(ResultCapabilities.TYPE_LIVE); toQuery.remove(ResultCapabilities.TYPE_LIVE);
} }
if(daysSinceLiveStream > 14) { if(!sub.shouldFetchLiveStreams()) {
Logger.i(TAG, "Subscription [${channelUrl}] Last livestream > 15 days, skipping streams [${daysSinceLiveStream} days ago]"); Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
toQuery.remove(ResultCapabilities.TYPE_STREAMS); toQuery.remove(ResultCapabilities.TYPE_STREAMS);
} }
if(!sub.shouldFetchPosts()) {
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 5 days, skipping posts [${sub.lastPost.getNowDiffDays()} days ago]");
toQuery.remove(ResultCapabilities.TYPE_POSTS);
}
} }
} }
@@ -25,7 +25,7 @@ class StatePlayer {
private val MIN_BUFFER_DURATION = 10000; private val MIN_BUFFER_DURATION = 10000;
private val MAX_BUFFER_DURATION = 60000; private val MAX_BUFFER_DURATION = 60000;
private val MIN_PLAYBACK_START_BUFFER = 500; private val MIN_PLAYBACK_START_BUFFER = 500;
private val MIN_PLAYBACK_RESUME_BUFFER = 1000; private val MIN_PLAYBACK_RESUME_BUFFER = 2500;
private val BUFFER_SIZE = 1024 * 64; private val BUFFER_SIZE = 1024 * 64;
var isOpen : Boolean = false var isOpen : Boolean = false
@@ -5,6 +5,7 @@ import android.net.Uri
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@@ -265,6 +266,12 @@ class StatePlaylists {
builder.messages.add("${name}:[${it}] is no longer available"); builder.messages.add("${name}:[${it}] is no longer available");
return@map null; return@map null;
} }
catch(ex: NoPlatformClientException) {
throw ReconstructionException(name, "No source enabled for [${it}]", ex);
//TODO: Propagate this to dialog, and then back, allowing users to enable plugins...
//builder.messages.add("No source enabled for [${it}]");
//return@map null;
}
catch(ex: Throwable) { catch(ex: Throwable) {
throw ReconstructionException(name, "${name}:[${it}] ${ex.message}", ex); throw ReconstructionException(name, "${name}:[${it}] ${ex.message}", ex);
} }
@@ -6,6 +6,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourceAuth import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -27,12 +28,20 @@ class StatePlugins {
private val TAG = "StatePlugins"; private val TAG = "StatePlugins";
private val FORCE_REINSTALL_EMBEDDED = false; private val FORCE_REINSTALL_EMBEDDED = false;
private var _isFirstEmbedUpdate = true;
private val _pluginScripts = FragmentedStorage.getDirectory<PluginScriptsDirectory>(); private val _pluginScripts = FragmentedStorage.getDirectory<PluginScriptsDirectory>();
private var _plugins = FragmentedStorage.storeJson<SourcePluginDescriptor>("plugins") private var _plugins = FragmentedStorage.storeJson<SourcePluginDescriptor>("plugins")
.load(); .load();
private val iconsDir = FragmentedStorage.getDirectory<PluginIconStorage>(); private val iconsDir = FragmentedStorage.getDirectory<PluginIconStorage>();
private val _syncObject = Object()
private var _embeddedSources: Map<String, String>? = null
private var _embeddedSourcesDefault: List<String>? = null
private var _sourcesUnderConstruction: Map<String, ImageVariable>? = null
fun getPluginIconOrNull(id: String): ImageVariable? { fun getPluginIconOrNull(id: String): ImageVariable? {
if(iconsDir.hasIcon(id)) if(iconsDir.hasIcon(id))
return iconsDir.getIconBinary(id); return iconsDir.getIconBinary(id);
@@ -53,18 +62,6 @@ class StatePlugins {
} }
} }
@Serializable
private data class PluginConfig(
val SOURCES_EMBEDDED: Map<String, String>,
val SOURCES_EMBEDDED_DEFAULT: List<String>,
val SOURCES_UNDER_CONSTRUCTION: Map<String, String>
)
private val _syncObject = Object()
private var _embeddedSources: Map<String, String>? = null
private var _embeddedSourcesDefault: List<String>? = null
private var _sourcesUnderConstruction: Map<String, ImageVariable>? = null
private fun ensureSourcesConfigLoaded(context: Context) { private fun ensureSourcesConfigLoaded(context: Context) {
if (_embeddedSources != null && _embeddedSourcesDefault != null && _sourcesUnderConstruction != null) { if (_embeddedSources != null && _embeddedSourcesDefault != null && _sourcesUnderConstruction != null) {
return return
@@ -122,8 +119,11 @@ class StatePlugins {
Logger.i(TAG, "Found outdated embedded plugin [${existing.config.id}] ${existing.config.name}, deleting and reinstalling"); Logger.i(TAG, "Found outdated embedded plugin [${existing.config.id}] ${existing.config.name}, deleting and reinstalling");
deletePlugin(embedded.key); deletePlugin(embedded.key);
} }
else if(existing != null && _isFirstEmbedUpdate)
Logger.i(TAG, "Embedded plugin [${existing.config.id}] ${existing.config.name}, up to date (${existing.config.version} >= ${embeddedConfig?.version})");
} }
} }
_isFirstEmbedUpdate = false;
} }
fun installMissingEmbeddedPlugins(context: Context) { fun installMissingEmbeddedPlugins(context: Context) {
val plugins = getPlugins(); val plugins = getPlugins();
@@ -373,7 +373,7 @@ class StatePlugins {
if(icon != null) if(icon != null)
iconsDir.saveIconBinary(config.id, icon); iconsDir.saveIconBinary(config.id, icon);
_plugins.save(SourcePluginDescriptor(config, null, flags)); _plugins.save(SourcePluginDescriptor(config, null, null, flags));
return null; return null;
} }
catch(ex: Throwable) { catch(ex: Throwable) {
@@ -408,6 +408,18 @@ class StatePlugins {
} }
} }
fun setPluginCaptcha(id: String, captcha: SourceCaptchaData?) {
if(id == StateDeveloper.DEV_ID) {
StatePlatform.instance.getDevClient()?.let {
it.setCaptcha(captcha);
};
return;
}
val descriptor = getPlugin(id) ?: throw IllegalArgumentException("Plugin [${id}] does not exist");
descriptor.updateCaptcha(captcha);
_plugins.save(descriptor);
}
fun setPluginAuth(id: String, auth: SourceAuth?) { fun setPluginAuth(id: String, auth: SourceAuth?) {
if(id == StateDeveloper.DEV_ID) { if(id == StateDeveloper.DEV_ID) {
StatePlatform.instance.getDevClient()?.let { StatePlatform.instance.getDevClient()?.let {
@@ -422,6 +434,13 @@ class StatePlugins {
} }
@Serializable
private data class PluginConfig(
val SOURCES_EMBEDDED: Map<String, String>,
val SOURCES_EMBEDDED_DEFAULT: List<String>,
val SOURCES_UNDER_CONSTRUCTION: Map<String, String>
)
companion object { companion object {
private var _instance : StatePlugins? = null; private var _instance : StatePlugins? = null;
val instance : StatePlugins val instance : StatePlugins
@@ -126,7 +126,7 @@ class StatePolycentric {
} }
} }
fun getChannelContent(profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager<IPlatformContent> { fun getChannelContent(profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1, ignorePlugins: List<String>? = null): IPager<IPlatformContent> {
//TODO: Currently abusing subscription concurrency for parallelism //TODO: Currently abusing subscription concurrency for parallelism
val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency; val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency;
val pagers = profile.ownedClaims.groupBy { it.claim.claimType }.mapNotNull { val pagers = profile.ownedClaims.groupBy { it.claim.claimType }.mapNotNull {
@@ -138,7 +138,7 @@ class StatePolycentric {
return@mapNotNull null; return@mapNotNull null;
} }
return@mapNotNull StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency); return@mapNotNull StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency, ignorePlugins);
}.toTypedArray(); }.toTypedArray();
val pager = MultiChronoContentPager(pagers); val pager = MultiChronoContentPager(pagers);
@@ -302,7 +302,7 @@ class StatePolycentric {
thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(PolycentricCache.SERVER)) }, thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(PolycentricCache.SERVER)) },
subscribers = null subscribers = null
), ),
msg = post.content, msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content,
rating = RatingLikeDislikes(likes, dislikes), rating = RatingLikeDislikes(likes, dislikes),
date = if (unixMilliseconds != null) Instant.ofEpochMilli(unixMilliseconds).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN, date = if (unixMilliseconds != null) Instant.ofEpochMilli(unixMilliseconds).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN,
replyCount = replies.toInt(), replyCount = replies.toInt(),
@@ -2,9 +2,12 @@ package com.futo.platformplayer.states
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.channels.SerializedChannel import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.* import com.futo.platformplayer.api.media.structures.*
import com.futo.platformplayer.api.media.structures.ReusablePager.Companion.asReusable import com.futo.platformplayer.api.media.structures.ReusablePager.Companion.asReusable
import com.futo.platformplayer.cache.ChannelContentCache import com.futo.platformplayer.cache.ChannelContentCache
@@ -12,9 +15,12 @@ import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.findNonRuntimeException import com.futo.platformplayer.findNonRuntimeException
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.getNowDiffDays
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricCache
@@ -67,7 +73,7 @@ class StateSubscriptions {
return Pair(_lastGlobalSubscriptionProgress, _lastGlobalSubscriptionTotal); return Pair(_lastGlobalSubscriptionProgress, _lastGlobalSubscriptionTotal);
} }
fun updateSubscriptionFeed(scope: CoroutineScope, onlyIfNull: Boolean = false, onProgress: ((Int, Int)->Unit)? = null) { fun updateSubscriptionFeed(scope: CoroutineScope, onlyIfNull: Boolean = false, onProgress: ((Int, Int)->Unit)? = null) {
Logger.i(TAG, "updateSubscriptionFeed"); Logger.v(TAG, "updateSubscriptionFeed");
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
synchronized(_globalSubscriptionsLock) { synchronized(_globalSubscriptionsLock) {
if (isGlobalUpdating || (onlyIfNull && _globalSubscriptionFeed != null)) { if (isGlobalUpdating || (onlyIfNull && _globalSubscriptionFeed != null)) {
@@ -216,13 +222,38 @@ class StateSubscriptions {
} }
} }
fun getSubscriptionsFeed(allowFailure: Boolean = false): MultiChronoContentPager { fun getSubscriptionRequestCount(): Map<JSClient, Int> {
val subs = getSubscriptions();
val pluginReqCounts = mutableMapOf<JSClient, Int>();
for(sub in subs) {
val client = StatePlatform.instance.getChannelClientOrNull(sub.channel.url);
if(client !is JSClient)
continue;
val channelCaps = client.getChannelCapabilities();
if(!pluginReqCounts.containsKey(client))
pluginReqCounts[client] = 1;
else
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
if(channelCaps.hasType(ResultCapabilities.TYPE_STREAMS) && sub.shouldFetchStreams())
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
if(channelCaps.hasType(ResultCapabilities.TYPE_LIVE) && sub.shouldFetchLiveStreams())
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
if(channelCaps.hasType(ResultCapabilities.TYPE_POSTS) && sub.shouldFetchPosts())
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
}
return pluginReqCounts;
}
fun getSubscriptionsFeed(allowFailure: Boolean = false): IPager<IPlatformContent> {
val result = getSubscriptionsFeedWithExceptions(allowFailure, true); val result = getSubscriptionsFeedWithExceptions(allowFailure, true);
if(result.second.any()) if(result.second.any())
throw result.second.first(); throw result.second.first();
return result.first; return result.first;
} }
fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope? = null, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null): Pair<MultiChronoContentPager, List<Throwable>> { fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope? = null, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null): Pair<IPager<IPlatformContent>, List<Throwable>> {
val subsPager: Array<IPager<IPlatformContent>>; val subsPager: Array<IPager<IPlatformContent>>;
val exs: ArrayList<Throwable> = arrayListOf(); val exs: ArrayList<Throwable> = arrayListOf();
@@ -230,8 +261,11 @@ class StateSubscriptions {
var finished = 0; var finished = 0;
val exceptionMap: HashMap<Subscription, Throwable> = hashMapOf(); val exceptionMap: HashMap<Subscription, Throwable> = hashMapOf();
val concurrency = Settings.instance.subscriptions.getSubscriptionsConcurrency(); val concurrency = Settings.instance.subscriptions.getSubscriptionsConcurrency();
val failedPlugins = arrayListOf<String>();
for (sub in getSubscriptions().filter { StatePlatform.instance.hasEnabledChannelClient(it.channel.url) }) { for (sub in getSubscriptions().filter { StatePlatform.instance.hasEnabledChannelClient(it.channel.url) }) {
tasks.add(_subscriptionsPool.submit<Pair<Subscription, IPager<IPlatformContent>?>> { tasks.add(_subscriptionsPool.submit<Pair<Subscription, IPager<IPlatformContent>?>> {
val toIgnore = synchronized(failedPlugins){ failedPlugins.toList() };
var polycentricProfile : PolycentricCache.CachedPolycentricProfile? = null; var polycentricProfile : PolycentricCache.CachedPolycentricProfile? = null;
val getProfileTime = measureTimeMillis { val getProfileTime = measureTimeMillis {
try { try {
@@ -258,9 +292,9 @@ class StateSubscriptions {
val time = measureTimeMillis { val time = measureTimeMillis {
val profile = polycentricProfile?.profile val profile = polycentricProfile?.profile
pager = if (profile != null) pager = if (profile != null)
StatePolycentric.instance.getChannelContent(profile, true, concurrency) StatePolycentric.instance.getChannelContent(profile, true, concurrency, toIgnore)
else else
StatePlatform.instance.getChannelContent(sub.channel.url, true, concurrency); StatePlatform.instance.getChannelContent(sub.channel.url, true, concurrency, toIgnore);
if (cacheScope != null) if (cacheScope != null)
pager = ChannelContentCache.cachePagerResults(cacheScope, pager) { pager = ChannelContentCache.cachePagerResults(cacheScope, pager) {
@@ -276,12 +310,31 @@ class StateSubscriptions {
); );
} }
catch(ex: Throwable) { catch(ex: Throwable) {
Logger.e(TAG, "Subscription [${sub.channel.name}] failed", ex);
finished++; finished++;
onProgress?.invoke(finished, tasks.size); onProgress?.invoke(finished, tasks.size);
val channelEx = ChannelException(sub.channel, ex); val channelEx = ChannelException(sub.channel, ex);
synchronized(exceptionMap) { synchronized(exceptionMap) {
exceptionMap.put(sub, channelEx); exceptionMap.put(sub, channelEx);
} }
if(ex is ScriptCaptchaRequiredException) {
synchronized(failedPlugins) {
//Fail all subscription calls to plugin if it has a captcha issue
if(ex.config is SourcePluginConfig && !failedPlugins.contains(ex.config.id)) {
Logger.w(TAG, "Subscriptionsgnoring plugin [${ex.config.name}] due to Captcha");
failedPlugins.add(ex.config.id);
}
}
}
else if(ex is ScriptCriticalException) {
synchronized(failedPlugins) {
//Fail all subscription calls to plugin if it has a critical issue
if(ex.config is SourcePluginConfig && !failedPlugins.contains(ex.config.id)) {
Logger.w(TAG, "Subscriptions ignoring plugin [${ex.config.name}] due to critical exception:\n" + ex.message);
failedPlugins.add(ex.config.id);
}
}
}
if(!withCacheFallback) if(!withCacheFallback)
throw channelEx; throw channelEx;
else { else {
@@ -327,9 +380,10 @@ class StateSubscriptions {
throw exs.first(); throw exs.first();
Logger.i(TAG, "Subscription pager with ${subsPager.size} channels"); Logger.i(TAG, "Subscription pager with ${subsPager.size} channels");
val pager = MultiChronoContentPager(subsPager, allowFailure); val pager = MultiChronoContentPager(subsPager, allowFailure, 15);
pager.initialize(); pager.initialize();
return Pair(pager, exs); //return Pair(pager, exs);
return Pair(DedupContentPager(pager), exs);
} }
//New Migration //New Migration
@@ -37,14 +37,13 @@ class StateTelemetry {
BuildConfig.BUILD_TYPE, BuildConfig.BUILD_TYPE,
BuildConfig.DEBUG, BuildConfig.DEBUG,
BuildConfig.IS_UNSTABLE_BUILD, BuildConfig.IS_UNSTABLE_BUILD,
Instant.now().epochSecond,
Build.BRAND, Build.BRAND,
Build.MANUFACTURER, Build.MANUFACTURER,
Build.MODEL Build.MODEL
); );
val headers = hashMapOf( val headers = hashMapOf(
"Content-Type" to "text/plain" "Content-Type" to "application/json"
); );
val json = Json.encodeToString(telemetry); val json = Json.encodeToString(telemetry);
@@ -118,6 +118,7 @@ class ManagedStore<T>{
val builder = ReconstructStore.Builder(); val builder = ReconstructStore.Builder();
for (recon in items) { for (recon in items) {
onProgress?.invoke(0, total);
//Retry once //Retry once
for (i in 0 .. 1) { for (i in 0 .. 1) {
try { try {
@@ -5,8 +5,10 @@ import android.graphics.drawable.Animatable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.core.view.updateLayoutParams
import com.futo.platformplayer.R import com.futo.platformplayer.R
class Loader : LinearLayout { class Loader : LinearLayout {
@@ -15,7 +17,7 @@ class Loader : LinearLayout {
private val _animatable: Animatable; private val _animatable: Animatable;
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.view_loader, this, true); inflate(context, R.layout.view_loader, this);
_imageLoader = findViewById(R.id.image_loader); _imageLoader = findViewById(R.id.image_loader);
_animatable = _imageLoader.drawable as Animatable; _animatable = _imageLoader.drawable as Animatable;
@@ -29,6 +31,18 @@ class Loader : LinearLayout {
visibility = View.GONE; visibility = View.GONE;
} }
constructor(context: Context, automatic: Boolean, height: Int = -1) : super(context) {
inflate(context, R.layout.view_loader, this);
_imageLoader = findViewById(R.id.image_loader);
_animatable = _imageLoader.drawable as Animatable;
_automatic = automatic;
if(height > 0) {
layoutParams = ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, height);
}
visibility = View.GONE;
}
override fun onAttachedToWindow() { override fun onAttachedToWindow() {
super.onAttachedToWindow() super.onAttachedToWindow()

Some files were not shown because too many files have changed in this diff Show More