Compare commits

...

53 Commits

Author SHA1 Message Date
Kelvin f486513105 Casting HLS fixed 2023-10-24 23:10:15 +02:00
Kelvin f338adf033 Fix polycentric profile content ordering and deduplication 2023-10-24 22:16:10 +02:00
Kelvin 74be667114 Retain login and captcha on embedded update, Play entire feed option 2023-10-24 14:47:34 +02:00
Kelvin b5a1fc92dc Add misisng synchronization, unsub all dev action, Dedup capital insensitive and more scaling max video date difference 2023-10-23 22:38:13 +02:00
Kelvin 9cec1a8c49 Stable ref updates 2023-10-23 21:03:23 +02:00
Kelvin d4afba929b Fix captcha, FAQ, issues page, icons on settings buttons 2023-10-23 20:36:26 +02:00
Koen 70939cbac6 Fixed log submission and added telemetry OS version. 2023-10-23 16:31:50 +02:00
Koen a3aa61df6d Fixed Odysee get channel contents. 2023-10-23 15:24:55 +02:00
Koen e13ab5cb40 Deduplicated map. 2023-10-23 15:23:46 +02:00
Koen d059947925 Odysee now works with more different types of channel URLs. 2023-10-23 14:24:28 +02:00
Koen d6c4b730de Fixes to Polycentric data display. 2023-10-23 14:21:10 +02:00
Koen 8241863170 Fixed comment alpha. 2023-10-21 16:06:05 +02:00
Koen 31a758e4f3 Updated stable plugins. 2023-10-20 19:57:46 +02:00
Kelvin ca971a0e77 Fix playlist edit name 2023-10-20 19:13:44 +02:00
Kelvin a45a0f9a8a Fix soundcloud missing whitelist domain 2023-10-20 18:40:13 +02:00
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
115 changed files with 1885 additions and 431 deletions
+3 -2
View File
@@ -4,6 +4,7 @@ variables:
stages:
- buildAndDeployApkUnstable
- buildAndDeployApkStable
- buildAndDeployPlaystore
buildAndDeployApkUnstable:
stage: buildAndDeployApkUnstable
@@ -25,8 +26,8 @@ buildAndDeployApkStable:
- branches
when: manual
buildAndDeployApkStable:
stage: buildAndDeployApkStable
buildAndDeployPlaystore:
stage: buildAndDeployPlaystore
script:
- sh deploy-playstore.sh
only:
+1 -1
View File
@@ -95,7 +95,7 @@ android {
}
defaultConfig {
minSdk 29
minSdk 28
targetSdk 33
versionCode gitVersionCode
versionName gitVersionName
+4
View File
@@ -127,6 +127,10 @@
android:name=".activities.ExceptionActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.CaptchaActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.LoginActivity"
android:screenOrientation="portrait"
@@ -217,6 +217,9 @@ function pluginUpdateTestPlugin(config) {
}
function pluginLoginTestPlugin() {
return syncGET("/plugin/loginTestPlugin", {});
}//captchaLoginTestPlugin
function pluginCaptchaTestPlugin(url, html) {
return syncPOST("/plugin/captchaTestPlugin?url=" + url, {}, html);
}
function pluginLogoutTestPlugin() {
return syncGET("/plugin/logoutTestPlugin", {});
+9
View File
@@ -681,6 +681,9 @@
});
}, 1000);
},
captchaTestPlugin() {
captchaLoginTestPlugin();
},
logoutTestPlugin() {
pluginLogoutTestPlugin();
},
@@ -838,6 +841,12 @@
this.Testing.lastResultError = "";
}
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);
this.Testing.lastResult = ""
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 {
constructor(msg) {
super("UnavailableException", msg);
@@ -35,4 +35,8 @@ fun Protocol.ImageBundle?.selectHighestResolutionImage(): Protocol.ImageManifest
fun Protocol.Claim.resolveChannelUrl(): String? {
return StatePlatform.instance.resolveChannelUrlByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
}
fun Protocol.Claim.resolveChannelUrls(): List<String> {
return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
}
@@ -4,7 +4,9 @@ import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.webkit.CookieManager
import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.*
import com.futo.platformplayer.api.http.ManagedHttpClient
@@ -20,6 +22,7 @@ import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
import com.futo.platformplayer.views.fields.FormField
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormFieldButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -43,8 +46,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(
"Manage Polycentric identity", FieldForm.BUTTON,
"Manage your Polycentric identity", -2
"Manage your Polycentric identity", -4
)
@FormFieldButton(R.drawable.ic_person)
fun managePolycentricIdentity() {
SettingsActivity.getActivity()?.let {
if (StatePolycentric.instance.processHandle != null) {
@@ -55,15 +59,44 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField(
"Show FAQ", FieldForm.BUTTON,
"Get answers to common questions", -3
)
@FormFieldButton(R.drawable.ic_quiz)
fun openFAQ() {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(Settings.URL_FAQ))
SettingsActivity.getActivity()?.startActivity(browserIntent);
} catch (e: Throwable) {
//Ignored
}
}
@FormField(
"Show Issues", FieldForm.BUTTON,
"A list of user-reported and self-reported issues", -2
)
@FormFieldButton(R.drawable.ic_data_alert)
fun openIssues() {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/futo-org/grayjay-android/issues"))
SettingsActivity.getActivity()?.startActivity(browserIntent);
} catch (e: Throwable) {
//Ignored
}
}
@FormField(
"Submit feedback", FieldForm.BUTTON,
"Give feedback on the application", -1
)
@FormFieldButton(R.drawable.ic_bug)
fun submitFeedback() {
try {
val i = Intent(Intent.ACTION_VIEW);
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));
i.data = data;
@@ -77,6 +110,7 @@ class Settings : FragmentedStorageFileJson() {
"Manage Tabs", FieldForm.BUTTON,
"Change tabs visible on the home screen", -1
)
@FormFieldButton(R.drawable.ic_tabs)
fun manageTabs() {
try {
SettingsActivity.getActivity()?.let {
@@ -140,7 +174,11 @@ class Settings : FragmentedStorageFileJson() {
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)
var subscriptionsBackgroundUpdateInterval: Int = 0;
@@ -156,7 +194,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)
var subscriptionConcurrency: Int = 3;
@@ -213,7 +251,7 @@ class Settings : FragmentedStorageFileJson() {
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)
var autoRotateDeadZone: Int = 0;
@@ -595,6 +633,7 @@ class Settings : FragmentedStorageFileJson() {
companion object {
private const val TAG = "Settings";
const val URL_FAQ = "https://grayjay.app/faq.html";
private var _isFirst = true;
@@ -17,6 +17,7 @@ import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson
import com.futo.platformplayer.views.fields.FieldForm
@@ -272,6 +273,15 @@ class SettingsDev : FragmentedStorageFileJson() {
@FormField("Other", FieldForm.GROUP, "Others...", 5)
val otherTests: OtherTests = OtherTests();
class OtherTests {
@FormField("Unsubscribe all", FieldForm.BUTTON, "Removes all subscriptions", -1)
fun unsubscribeAll() {
val toUnsub = StateSubscriptions.instance.getSubscriptions();
UIDialogs.toast("Started unsubbing.. (${toUnsub.size})")
toUnsub.forEach {
StateSubscriptions.instance.removeSubscription(it.channel.url);
};
UIDialogs.toast("Finished unsubbing.. (${toUnsub.size})")
}
@FormField("Clear Downloads", FieldForm.BUTTON, "Deletes all ongoing downloads", 1)
fun clearDownloads() {
StateDownloads.instance.getDownloading().forEach {
@@ -304,7 +304,7 @@ class UISlideOverlays {
return overlay;
}
fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, onVideoHidden: (()->Unit)? = null): SlideUpMenuOverlay {
fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, vararg actions: SlideUpMenuItem): SlideUpMenuOverlay {
val items = arrayListOf<View>();
val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist();
@@ -323,11 +323,11 @@ class UISlideOverlays {
val queue = StatePlayer.instance.getQueue();
val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, "Actions", "actions",
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, "Hide", "Hide from Home", "hide",
{ StateMeta.instance.addHiddenVideo(video.url); onVideoHidden?.invoke() }),
SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download",
{ showDownloadVideoOverlay(video, container, true); }, false)
))
(listOf(
SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download",
{ showDownloadVideoOverlay(video, container, true); }, false))
+ actions)
));
items.add(
SlideUpMenuGroup(container.context, "Add To", "addto",
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, "Add to Queue", "${queue.size} videos", "queue",
@@ -75,10 +75,10 @@ class AddSourceActivity : AppCompatActivity() {
_buttonInstall = findViewById(R.id.button_install);
_buttonBack.setOnClickListener {
onBackPressed();
finish();
};
_buttonCancel.setOnClickListener {
onBackPressed();
finish();
}
_buttonInstall.setOnClickListener {
_config?.let {
@@ -1,7 +1,10 @@
package com.futo.platformplayer.activities
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.*
import com.futo.platformplayer.views.buttons.BigButton
@@ -14,6 +17,31 @@ class AddSourceOptionsActivity : AppCompatActivity() {
lateinit var _buttonQR: 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?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_add_source_options);
@@ -37,8 +65,9 @@ class AddSourceOptionsActivity : AppCompatActivity() {
integrator.setBeepEnabled(false)
integrator.setBarcodeImageEnabled(true)
integrator.setCaptureActivity(QRCaptureActivity::class.java);
integrator.initiateScan()
_qrCodeResultLauncher.launch(integrator.createScanIntent())
}
_buttonURL.onClick.subscribe {
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
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.widget.LinearLayout
import android.widget.TextView
@@ -40,7 +41,8 @@ class ExceptionActivity : AppCompatActivity() {
val context = intent.getStringExtra(EXTRA_CONTEXT) ?: "Unknown Context";
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");
try {
val file = File(filesDir, "log.txt");
@@ -3,7 +3,9 @@ package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.webkit.ConsoleMessage
import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
@@ -68,9 +70,15 @@ class LoginActivity : AppCompatActivity() {
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.webChromeClient = object: WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
Logger.w(TAG, "Login Console: " + consoleMessage?.message());
return super.onConsoleMessage(consoleMessage);
}
}*/
_webView.webViewClient = webViewClient;
_webView.loadUrl(authConfig.loginUrl);
}
@@ -607,6 +607,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return;
};
};
val name = when(type) {
"Playlist" -> recon.split("\n").filter { !it.startsWith(ManagedStore.RECONSTRUCTION_HEADER_OPERATOR) }.firstOrNull() ?: type;
else -> type
@@ -894,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 requestCode: Int? = -1;
private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
@@ -5,6 +5,7 @@ import android.os.Bundle
import android.widget.EditText
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
@@ -14,6 +15,7 @@ import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.*
import com.google.zxing.integration.android.IntentIntegrator
import com.journeyapps.barcodescanner.CaptureActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@@ -27,6 +29,16 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
private lateinit var _buttonImportProfile: LinearLayout;
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?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_import_profile);
@@ -45,10 +57,15 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
};
_buttonScanProfile.setOnClickListener {
val integrator = IntentIntegrator(this);
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE);
integrator.setPrompt("Scan a QR code");
integrator.initiateScan();
val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt("Scan a QR code")
integrator.setOrientationLocked(true);
integrator.setCameraId(0)
integrator.setBeepEnabled(false)
integrator.setBarcodeImageEnabled(true)
integrator.setCaptureActivity(QRCaptureActivity::class.java);
_qrCodeResultLauncher.launch(integrator.createScanIntent())
};
_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) {
if (!url.startsWith("polycentric://")) {
UIDialogs.toast(this, "Not a valid URL");
@@ -126,4 +131,8 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
companion object {
private const val TAG = "PolycentricImportProfileActivity";
}
class QRCaptureActivity: CaptureActivity() {
}
}
@@ -52,17 +52,6 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
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;
reloadSettings();
@@ -72,6 +61,18 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
_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");
}
};
};
}
@@ -6,6 +6,7 @@ import com.futo.platformplayer.logging.Logger
import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
@@ -28,7 +29,11 @@ open class ManagedHttpClient {
constructor(builder: OkHttpClient.Builder = OkHttpClient.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 {
@@ -116,7 +121,7 @@ open class ManagedHttpClient {
fun execute(request : Request) : Response {
ensureNotMainThread();
beforeRequest(request);
//beforeRequest(request);
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)
Logger.v(TAG, "HTTP Response [${request.method}] ${request.url} - [${time}ms]");
afterRequest(request, resp);
//afterRequest(request, resp);
return resp;
}
//Set Listeners
fun setOnBeforeRequest(listener : (Request)->Unit) {
this.onBeforeRequest = listener;
open fun beforeRequest(request: okhttp3.Request): okhttp3.Request {
return request;
}
fun setOnAfterRequest(listener : (Request, Response)->Unit) {
this.onAfterRequest = listener;
}
open fun beforeRequest(request: Request) {
onBeforeRequest?.invoke(request);
}
open fun afterRequest(request: Request, resp: Response) {
onAfterRequest?.invoke(request, resp);
open fun afterRequest(resp: okhttp3.Response): okhttp3.Response {
return resp;
}
@@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
class PlatformClientPool {
private val _parent: JSClient;
@@ -51,6 +52,11 @@ class PlatformClientPool {
if(reserved == null && _pool.size < capacity) {
Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})");
reserved = _parent.getCopy();
reserved?.onCaptchaException?.subscribe { client, ex ->
StateApp.instance.handleCaptchaException(client, ex);
};
reserved?.initialize();
_pool[reserved!!] = _poolCounter;
}
@@ -27,6 +27,7 @@ class ResultCapabilities(
const val TYPE_VIDEOS = "VIDEOS";
const val TYPE_STREAMS = "STREAMS";
const val TYPE_LIVE = "LIVE";
const val TYPE_POSTS = "POSTS";
const val TYPE_MIXED = "MIXED";
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
@@ -39,4 +39,8 @@ class PolycentricPlatformComment : IPlatformComment {
fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment {
return PolycentricPlatformComment(contextUrl, author, message, rating, date, reference, replyCount);
}
companion object {
val MAX_COMMENT_SIZE = 2000
}
}
@@ -30,7 +30,7 @@ open class SerializedPlatformNestedContent(
override val contentProvider: String?,
override val contentThumbnails: Thumbnails
) : 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 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.IPlatformContentDetails
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.states.StateApp
import java.util.*
class DevJSClient : JSClient {
@@ -15,29 +16,44 @@ class DevJSClient : JSClient {
private val _devScript: String;
private var _auth: SourceAuth? = null;
private var _captcha: SourceCaptchaData? = null;
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;
_auth = auth;
_captcha = captcha;
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;
_auth = auth;
_captcha = captcha;
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) {
_auth = auth;
}
fun recreate(context: Context): DevJSClient {
return DevJSClient(context, config, _devScript, _auth, devID);
return DevJSClient(context, config, _devScript, _auth, _captcha, devID);
}
override fun getCopy(): JSClient {
return DevJSClient(_context, descriptor, _script, _auth, saveState(), devID);
return DevJSClient(_context, descriptor, _script, _auth, _captcha, saveState(), devID);
}
override fun initialize() {
@@ -4,6 +4,7 @@ import android.content.Context
import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.primitive.V8ValueBoolean
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.reference.V8ValueArray
import com.caoccao.javet.values.reference.V8ValueObject
@@ -23,11 +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.platforms.js.internal.*
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.constructs.Event1
import com.futo.platformplayer.constructs.Event2
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.ScriptValidationException
import com.futo.platformplayer.logging.Logger
@@ -61,6 +65,7 @@ open class JSClient : IPlatformClient {
private var _enabled: Boolean = false;
private val _auth: SourceAuth?;
private val _captcha: SourceCaptchaData?;
private val _injectedSaveState: String?;
@@ -87,6 +92,7 @@ open class JSClient : IPlatformClient {
val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true
val onDisabled = Event1<JSClient>();
val onCaptchaException = Event2<JSClient, ScriptCaptchaRequiredException>();
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String? = null) {
this._context = context;
@@ -95,10 +101,11 @@ open class JSClient : IPlatformClient {
this.descriptor = descriptor;
_injectedSaveState = saveState;
_auth = descriptor.getAuth();
_captcha = descriptor.getCaptchaData();
flags = descriptor.flags.toTypedArray();
_client = JSHttpClient(this);
_clientAuth = JSHttpClient(this, _auth);
_client = JSHttpClient(this, null, _captcha);
_clientAuth = JSHttpClient(this, _auth, _captcha);
_plugin = V8Plugin(context, descriptor.config, null, _client, _clientAuth);
_plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js");
@@ -110,6 +117,11 @@ open class JSClient : IPlatformClient {
}
else
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) {
this._context = context;
@@ -118,15 +130,21 @@ open class JSClient : IPlatformClient {
this.descriptor = descriptor;
_injectedSaveState = saveState;
_auth = descriptor.getAuth();
_captcha = descriptor.getCaptchaData();
flags = descriptor.flags.toTypedArray();
_client = JSHttpClient(this);
_clientAuth = JSHttpClient(this, _auth);
_client = JSHttpClient(this, null, _captcha);
_clientAuth = JSHttpClient(this, _auth, _captcha);
_plugin = V8Plugin(context, descriptor.config, script, _client, _clientAuth);
_plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js");
_plugin.withScript(script);
_script = script;
_plugin.onScriptException.subscribe {
if(it is ScriptCaptchaRequiredException)
onCaptchaException.emit(this, it);
};
}
open fun getCopy(): JSClient {
@@ -415,8 +433,11 @@ open class JSClient : IPlatformClient {
@JSDocsParameter("url", "A content url (this platform)")
override fun getComments(url: String): IPager<IPlatformComment> = isBusyWith {
ensureEnabled();
return@isBusyWith JSCommentPager(config, plugin,
plugin.executeTyped("source.getComments(${Json.encodeToString(url)})"));
val pager = plugin.executeTyped<V8Value>("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")
@JSDocsParameter("comment", "Comment object that was returned by getComments")
@@ -547,6 +568,23 @@ open class JSClient : IPlatformClient {
};
}
fun resolveChannelUrlsByClaimTemplates(claimType: Int, values: Map<Int, String>): List<String> {
val urls = arrayListOf<String>();
channelClaimTemplates?.let {
if(it.containsKey(claimType)) {
val templates = it[claimType];
if(templates != null)
for(value in values.keys.sortedBy { it }) {
if(templates.containsKey(value)) {
urls.add(templates[value]!!.replace("{{CLAIMVALUE}}", values[value]!!));
}
}
}
};
return urls;
}
private fun <T> isBusyWith(handle: ()->T): T {
try {
@@ -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(),
var captcha: SourcePluginCaptchaConfig? = null,
val authentication: SourcePluginAuthConfig? = null,
var sourceUrl: String? = null,
val constants: HashMap<String, String> = hashMapOf(),
//TODO: These should be vals...but prob for serialization reasons cannot be changed.
var subscriptionRateLimit: Int? = null,
var enableInSearch: Boolean = true,
var enableInHome: Boolean = true,
var supportedClaimTypes: List<Int> = listOf()
@@ -13,22 +13,28 @@ class SourcePluginDescriptor {
var appSettings: AppPluginSettings = AppPluginSettings();
var authEncrypted: String?
var authEncrypted: String? = null
private set;
var captchaEncrypted: String? = null
private set;
val flags: List<String>;
@kotlinx.serialization.Transient
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.authEncrypted = authEncrypted;
this.captchaEncrypted = captchaEncrypted;
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.authEncrypted = authEncrypted;
this.captchaEncrypted = captchaEncrypted;
this.flags = flags;
}
@@ -41,6 +47,13 @@ class SourcePluginDescriptor {
return map;
}
fun updateCaptcha(captcha: SourceCaptchaData?) {
captchaEncrypted = captcha?.toEncrypted();
onCaptchaChanged.emit();
}
fun getCaptchaData(): SourceCaptchaData? {
return SourceCaptchaData.fromEncrypted(captchaEncrypted);
}
fun updateAuth(str: SourceAuth?) {
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.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
import com.futo.platformplayer.matchesDomain
class JSHttpClient : ManagedHttpClient {
private val _jsClient: JSClient?;
private val _auth: SourceAuth?;
private val _captcha: SourceCaptchaData?;
var doUpdateCookies: Boolean = true;
var doApplyCookies: Boolean = true;
var doAllowNewCookies: Boolean = true;
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;
_auth = auth;
_captcha = captcha;
_currentCookieMap = hashMapOf();
if(!auth?.cookieMap.isNullOrEmpty()) {
_currentCookieMap = hashMapOf();
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 {
val newClient = JSHttpClient(_jsClient, _auth);
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
null;
hashMapOf();
return newClient;
}
override fun beforeRequest(request: Request) {
override fun beforeRequest(request: okhttp3.Request): okhttp3.Request {
val domain = request.url.host.lowercase();
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
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 (!_currentCookieMap.isNullOrEmpty()) {
val cookiesToApply = hashMapOf<String, String>();
synchronized(_currentCookieMap!!) {
for(cookie in _currentCookieMap!!
.filter { domain.matchesDomain(it.key) }
.flatMap { it.value.toList() })
cookiesToApply[cookie.first] = cookie.second;
};
if(doApplyCookies) {
if (!_currentCookieMap.isNullOrEmpty()) {
val cookiesToApply = hashMapOf<String, String>();
synchronized(_currentCookieMap!!) {
for(cookie in _currentCookieMap!!
.filter { domain.matchesDomain(it.key) }
.flatMap { it.value.toList() })
cookiesToApply[cookie.first] = cookie.second;
};
if(cookiesToApply.size > 0) {
val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; ");
request.headers["Cookie"] = cookieString;
}
//printTestCode(request.url, request.body, auth.headers, cookieString, request.headers.filter { !auth.headers.containsKey(it.key) });
if(cookiesToApply.size > 0) {
val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; ");
val existingCookies = request.headers["Cookie"];
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);
super.beforeRequest(request)
_jsClient?.validateUrlOrThrow(request.url.toString());
return newBuilder?.let { it.build() } ?: request;
}
override fun afterRequest(request: Request, resp: Response) {
super.afterRequest(request, resp)
override fun afterRequest(resp: okhttp3.Response): okhttp3.Response {
if(doUpdateCookies) {
val domain = Uri.parse(request.url).host!!.lowercase();
val domainParts = domain!!.split(".");
val domain = resp.request.url.host.lowercase();
val domainParts = domain.split(".");
val defaultCookieDomain =
"." + domainParts.drop(domainParts.size - 2).joinToString(".");
for (header in resp.headers) {
if (_currentCookieMap != null && header.key.lowercase() == "set-cookie") {
val newCookies = cookieStringToMap(header.value);
for (cookie in newCookies) {
val endIndex = cookie.value.indexOf(";");
var cookieValue = cookie.value;
if ((_auth != null || _currentCookieMap.isNotEmpty()) && header.first.lowercase() == "set-cookie") {
//val newCookies = cookieStringToMap(header.second.split("; "));
val cookie = cookieStringToPair(header.second);
//for (cookie in newCookies) {
var cookieValue = cookie.second;
var domainToUse = domain;
if (endIndex > 0) {
val cookieParts = cookie.value.split(";");
if (!cookie.first.isNullOrEmpty() && !cookie.second.isNullOrEmpty()) {
val cookieParts = cookie.second.split(";");
if (cookieParts.size == 0)
continue;
cookieValue = cookieParts[0].trim();
@@ -114,24 +132,29 @@ class JSHttpClient : ManagedHttpClient {
_currentCookieMap!!.put(domainToUse, newMap)
newMap;
}
if(cookieMap.containsKey(cookie.key) || doAllowNewCookies)
cookieMap.put(cookie.key, cookieValue);
}
if(cookieMap.containsKey(cookie.first) || doAllowNewCookies)
cookieMap.put(cookie.first, cookieValue);
//}
}
}
}
return resp;
}
private fun cookieStringToMap(parts: List<String>): Map<String, String> {
val map = hashMapOf<String, String>();
for(cookie in parts) {
val cookieKey = cookie.substring(0, cookie.indexOf("="));
val cookieVal = cookie.substring(cookie.indexOf("=") + 1);
map.put(cookieKey.trim(), cookieVal.trim());
val pair = cookieStringToPair(cookie)
map.put(pair.first, pair.second);
}
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..
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);
}
}
@@ -7,6 +7,7 @@ import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.warnIfMainThread
@@ -27,7 +28,7 @@ abstract class JSPager<T> : IPager<T> {
this.pager = pager;
this.config = config;
_hasMorePages = pager.getOrThrow(config, "hasMore", "Pager");
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
getResults();
}
@@ -45,7 +46,7 @@ abstract class JSPager<T> : IPager<T> {
pager = plugin.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") {
pager.invoke("nextPage", arrayOf<Any>());
};
_hasMorePages = pager.getOrThrow(config, "hasMore", "Pager");
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
_resultChanged = true;
/*
try {
@@ -1,6 +1,7 @@
package com.futo.platformplayer.api.media.platforms.js.models
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.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient
@@ -99,8 +100,11 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
return getCommentsJS(client);
}
private fun getCommentsJS(client: JSClient): JSCommentPager {
val commentPager = _content.invoke<V8ValueObject>("getComments", arrayOf<Any>());
private fun getCommentsJS(client: JSClient): IPager<IPlatformComment>? {
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);
}
}
@@ -25,7 +25,8 @@ class DedupContentPager : IPager<IPlatformContent>, IAsyncPager<IPlatformContent
_currentResults = dedupResults(_basePager.getResults());
}
override fun hasMorePages(): Boolean = _basePager.hasMorePages();
override fun hasMorePages(): Boolean =
_basePager.hasMorePages();
override fun nextPage() {
_basePager.nextPage()
_currentResults = dedupResults(_basePager.getResults());
@@ -74,7 +75,12 @@ class DedupContentPager : IPager<IPlatformContent>, IAsyncPager<IPlatformContent
return toReturn;
}
private fun isSameItem(item: IPlatformContent, item2: IPlatformContent): Boolean {
return item.name == item2.name && (item.datetime == null || item2.datetime == null || abs(item.datetime!!.getDiffDays(item2.datetime!!)) < 2);
//return item == item2;
val daysAgo = Math.abs(item.datetime?.getNowDiffDays() ?: return false);
val maxDelta = Math.max(2, (daysAgo / 1.5).toInt()); //TODO: Better scaling delta
val isSame = item.name.equals(item2.name, true) && (item.datetime == null || item2.datetime == null || abs(item.datetime!!.getDiffDays(item2.datetime!!)) < maxDelta);
return isSame;
}
private fun calculateHash(item: IPlatformContent): Int {
return combineHashCodes(listOf(item.name.hashCode(), item.datetime?.hashCode()));
@@ -7,7 +7,8 @@ import java.util.stream.IntStream
* A PlatformContent MultiPager that orders the results of a page based on the datetime of a content item
*/
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) {}
constructor(pagers : List<IPager<IPlatformContent>>, allowFailure: Boolean = false, pageSize: Int = 9) : super(pagers, allowFailure, pageSize) {}
@Synchronized
override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int {
@@ -0,0 +1,33 @@
package com.futo.platformplayer.api.media.structures
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import kotlinx.coroutines.runBlocking
import java.util.stream.IntStream
/**
* A Content AsyncMultiPager that returns results based on a specified distribution
* Unlike its non-async counterpart, this one uses parallel nextPage requests
*/
class MultiChronoContentParallelPager : MultiParallelPager<IPlatformContent> {
constructor(pagers: List<IPager<IPlatformContent>>) : super(pagers)
@Synchronized
override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int {
if(options.size == 0)
return -1;
var bestIndex = 0;
val allResults = runBlocking { options.map { Pair(it, it.item?.await()) } };
for(i in IntStream.range(1, options.size)) {
val best = allResults[bestIndex].second;
val cur = allResults[i].second ?: continue;
if(best?.datetime == null || (cur.datetime != null && cur.datetime!! > best.datetime!!))
bestIndex = i;
}
return bestIndex;
}
}
@@ -16,7 +16,7 @@ abstract class MultiPager<T> : IPager<T> {
protected val _subSinglePagers : MutableList<SingleItemPager<T>>;
protected val _failedPagers: ArrayList<IPager<T>> = arrayListOf();
private val _pageSize : Int = 9;
private var _pageSize : Int = 9;
private var _didInitialize = false;
@@ -27,7 +27,8 @@ abstract class MultiPager<T> : IPager<T> {
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;
_pagers = pagers.toMutableList();
_subSinglePagers = _pagers.map { SingleItemPager(it) }.toMutableList();
@@ -66,17 +66,25 @@ abstract class MultiRefreshPager<T>: IRefreshPager<T>, IPager<T> {
override fun getResults(): List<T> = synchronized(_pagersReusable){ _currentPager.getResults() };
private fun updatePager(pagerToAdd: IPager<T>?, toReplacePager: IPager<T>? = null, error: Throwable? = null) {
if(pagerToAdd == null) {
if(toReplacePager != null && toReplacePager is PlaceholderPager && error != null) {
val pluginId = toReplacePager.placeholderFactory.invoke().id?.pluginId ?: "";
_currentPager = PlaceholderPager(5) {
return@PlaceholderPager PlatformContentPlaceholder(pluginId, error)
} as IPager<T>;
onPagerChanged.emit(_currentPager);
}
return;
}
synchronized(_pagersReusable) {
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;
}
Logger.i("RefreshMultiDistributionContentPager", "Received new pager for RefreshPager")
_pagersReusable.add(pagerToAdd.asReusable());
@@ -0,0 +1,19 @@
package com.futo.platformplayer.api.media.structures
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import kotlinx.coroutines.Deferred
/**
* A RefreshMultiPager that simply returns all respective pagers in equal distribution, optionally inserting PlaceholderPager results as provided for their respective promised pagers
* (Eg. Pager A is completed, Pager [B,C,D] are promised/deferred. placeholderPagers [1,2,3] will map B=>1, C=>2, D=>3 until promised pagers are completed)
* Uses wrapped MultiDistributionContentAsyncPager for inidivual pagers.
*/
class RefreshChronoContentPager(pagers: List<IPager<IPlatformContent>>, pendingPagers: List<Deferred<IPager<IPlatformContent>?>>, placeholderPagers: List<IPager<IPlatformContent>>? = null)
: MultiRefreshPager<IPlatformContent>(pagers, pendingPagers, placeholderPagers) {
override fun recreatePager(pagers: List<IPager<IPlatformContent>>): IPager<IPlatformContent> {
return MultiChronoContentPager(pagers);
//return MultiChronoContentParallelPager(pagers);
//return MultiDistributionContentPager(pagers.associateWith { 1f });
}
}
@@ -43,6 +43,7 @@ class SingleAsyncItemPager<T> {
if (_currentResultPos >= _requestedPageItems.size) {
val startPos = fillDeferredUntil(_currentResultPos);
if(!_pager.hasMorePages()) {
Logger.i("SingleAsyncItemPager", "end of async page reached");
completeRemainder { it?.complete(null) };
}
if(_isRequesting)
@@ -12,6 +12,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.builders.DashBuilder
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.states.StateApp
@@ -352,16 +353,25 @@ class StateCasting {
}
}
} else {
if (videoSource is IVideoUrlSource) {
if (videoSource is IVideoUrlSource)
ad.loadVideo("BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble());
} else if (audioSource is IAudioUrlSource) {
else if(videoSource is IHLSManifestSource)
ad.loadVideo("BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble());
else if (audioSource is IAudioUrlSource)
ad.loadVideo("BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
} else if (videoSource is LocalVideoSource) {
else if(audioSource is IHLSManifestAudioSource)
ad.loadVideo("BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble());
else if (videoSource is LocalVideoSource)
castLocalVideo(video, videoSource, resumePosition);
} else if (audioSource is LocalAudioSource) {
else if (audioSource is LocalAudioSource)
castLocalAudio(video, audioSource, resumePosition);
} else {
throw Exception("Unhandled source type videoSource=$videoSource audioSource=$audioSource subtitleSource=$subtitleSource");
else {
var str = listOf(
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null,
if(subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null
).filterNotNull().joinToString(", ");
throw UnsupportedCastException(str);
}
}
@@ -1,6 +1,7 @@
package com.futo.platformplayer.developer
import android.content.Context
import com.futo.platformplayer.activities.CaptchaActivity
import com.futo.platformplayer.activities.LoginActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
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")
}
}
@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")
fun pluginLoginTestPlugin(context: HttpContext) {
val config = _testPlugin?.config as SourcePluginConfig;
@@ -416,7 +439,7 @@ class DeveloperEndpoints(private val context: Context) {
val resp = _client.get(body.url!!, body.headers);
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"));
}
catch(ex: Exception) {
@@ -2,12 +2,16 @@ package com.futo.platformplayer.dialogs
import android.app.AlertDialog
import android.content.Context
import android.graphics.Color
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.*
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
@@ -32,6 +36,8 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
private lateinit var _buttonCancel: MaterialButton;
private lateinit var _editComment: EditText;
private lateinit var _inputMethodManager: InputMethodManager;
private lateinit var _textCharacterCount: TextView;
private lateinit var _textCharacterCountMax: TextView;
val onCommentAdded = Event1<IPlatformComment>();
@@ -42,6 +48,26 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
_buttonCancel = findViewById(R.id.button_cancel);
_buttonCreate = findViewById(R.id.button_create);
_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;
@@ -53,6 +79,11 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
_buttonCreate.setOnClickListener {
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 processHandle = StatePolycentric.instance.processHandle!!
val eventPointer = processHandle.post(comment, null, ref)
@@ -51,6 +51,8 @@ class V8Plugin {
*/
val afterBusy = Event1<Int>();
val onScriptException = Event1<ScriptException>();
constructor(context: Context, config: IV8PluginConfig, script: String? = null, client: ManagedHttpClient = ManagedHttpClient(), clientAuth: ManagedHttpClient = ManagedHttpClient()) {
this._client = client;
this._clientAuth = clientAuth;
@@ -217,7 +219,13 @@ class V8Plugin {
}
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 {
@@ -242,7 +250,7 @@ class V8Plugin {
if(result is V8ValueObject) {
val type = result.getString("plugin_type");
if(type != null && type.endsWith("Exception"))
Companion.throwExceptionFromV8(
throwExceptionFromV8(
config,
result.getOrThrow(config, "plugin_type", "V8Plugin"),
result.getOrThrow(config, "message", "V8Plugin"),
@@ -259,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);
}
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(
config,
executeEx.scriptingError.context["plugin_type"].toString(),
(exMessage ?: ""),
pluginType,
(extractJSExceptionMessage(executeEx) ?: ""),
executeEx,
executeEx.scriptingError?.stack,
codeStripped
);
throw ScriptExecutionException(config, "${exMessage}", null, executeEx.scriptingError?.stack, codeStripped);
}
throw ScriptExecutionException(config, extractJSExceptionMessage(executeEx) ?: "", null, executeEx.scriptingError?.stack, codeStripped);
}
catch(ex: Exception) {
throw ex;
@@ -281,6 +298,7 @@ class V8Plugin {
private fun throwExceptionFromV8(config: IV8PluginConfig, pluginType: String, msg: String, innerEx: Exception? = null, stack: String? = null, code: String? = null) {
when(pluginType) {
"ScriptException" -> throw ScriptException(config, msg, innerEx, stack, code);
"CriticalException" -> throw ScriptCriticalException(config, msg, innerEx, stack, code);
"AgeException" -> throw ScriptAgeException(config, msg, innerEx, stack, code);
"UnavailableException" -> throw ScriptUnavailableException(config, msg, innerEx, stack, code);
"ScriptExecutionException" -> throw ScriptExecutionException(config, msg, innerEx, stack, 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
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;
override fun toV8(runtime: V8Runtime): V8Value? {
val obj = runtime.createV8ValueObject();
obj.set("url", url);
obj.set("code", code);
obj.set("body", body);
obj.set("headers", headers);
@@ -227,7 +228,7 @@ class PackageHttp: V8Package {
val resp = client.requestMethod(method, url, headers);
val responseBody = resp.body?.string();
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 responseBody = resp.body?.string();
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 responseBody = resp.body?.string();
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 responseBody = resp.body?.string();
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
catch(ex: SocketTimeoutException) {
return BridgeHttpResponse(408, null);
return BridgeHttpResponse("", 408, null);
}
}
}
@@ -461,7 +462,7 @@ class PackageHttp: V8Package {
}
//Forward timeouts
catch(ex: SocketTimeoutException) {
return BridgeHttpResponse(408, null);
return BridgeHttpResponse("", 408, null);
}
}
@@ -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();
}
}
@@ -0,0 +1,6 @@
package com.futo.platformplayer.exceptions
import java.lang.Exception
class UnsupportedCastException(msg: String) : Exception(msg) {
}
@@ -77,7 +77,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment {
};
_textName?.text = channel.name;
val metadata = "${channel.subscribers.toHumanNumber()} subscribers";
val metadata = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} subscribers" else "";
_textMetadata?.text = metadata;
_lastChannel = channel;
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.TaskHandler
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.PolycentricProfile
import com.futo.platformplayer.states.StatePolycentric
@@ -76,7 +77,9 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
}).success {
setLoading(false);
setPager(it);
}.exception<Throwable> {
}
.exception<ScriptCaptchaRequiredException> { }
.exception<Throwable> {
Logger.w(TAG, "Failed to load initial videos.", it);
UIDialogs.showGeneralRetryErrorDialog(requireContext(), it.message ?: "", it, { loadNextPage() });
};
@@ -245,7 +248,6 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
if(_pager is IReplacerPager<*>)
(_pager as IReplacerPager<*>).onReplaced.remove(this);
if(pager is IReplacerPager<*>) {
pager.onReplaced.subscribe(this) { oldItem, newItem ->
if(_pager != pager)
@@ -254,11 +256,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
if(_pager !is IPager<IPlatformContent>)
return@subscribe;
val toReplaceIndex = _results.indexOfFirst { it == newItem };
if(toReplaceIndex >= 0) {
_results[toReplaceIndex] = newItem as IPlatformContent;
_adapterResults?.let {
it.notifyItemChanged(it.childToParentPosition(toReplaceIndex));
lifecycleScope.launch(Dispatchers.Main) {
val toReplaceIndex = _results.indexOfFirst { it == oldItem };
if (toReplaceIndex >= 0) {
_results[toReplaceIndex] = newItem as IPlatformContent;
_adapterResults?.let {
it.notifyItemChanged(it.childToParentPosition(toReplaceIndex));
}
}
}
}
@@ -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.constructs.Event1
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.PolycentricProfile
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));
adapter.notifyItemInserted(adapter.childToParentPosition(_authorLinks.size - 1));
loadNext();
}.exceptionWithParameter<Throwable> { ex, para ->
}.exception<ScriptCaptchaRequiredException> { }
.exceptionWithParameter<Throwable> { ex, para ->
Logger.w(ChannelFragment.TAG, "Failed to load results.", ex);
UIDialogs.toast(requireContext(), "Failed to fetch\n${para}", false)
loadNext();
@@ -220,6 +220,13 @@ class MenuBottomBarFragment : MainActivityFragment() {
buttons.removeAt(buyIndex)
buttons.add(0, button)
}
//Force faq to be second
val faqIndex = buttons.indexOfFirst { b -> b.id == 97 };
if (faqIndex != -1) {
val button = buttons[faqIndex]
buttons.removeAt(faqIndex)
buttons.add(1, button)
}
for (data in buttons) {
val button = MenuButton(context, data, _fragment, true);
@@ -289,6 +296,9 @@ class MenuBottomBarFragment : MainActivityFragment() {
if (!StatePayment.instance.hasPaid) {
newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>() }))
}
newCurrentButtonDefinitions.add(ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz, R.string.faq, canToggle = false, { false }, {
it.navigate<BrowserFragment>(Settings.URL_FAQ);
}))
//Add conditional buttons here, when you add a conditional button, be sure to add the register and unregister events for when the button needs to be updated
@@ -10,6 +10,7 @@ import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import com.futo.futopay.PaymentConfigurations
import com.futo.futopay.PaymentManager
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
@@ -68,9 +69,12 @@ class BuyFragment : MainFragment() {
}
}
_buttonBuy.setOnClickListener {
buy();
}
if(!BuildConfig.IS_PLAYSTORE_BUILD)
_buttonBuy.setOnClickListener {
buy();
}
else
_buttonBuy.visibility = View.GONE;
_buttonPaid.setOnClickListener {
paid();
}
@@ -246,28 +246,45 @@ class ChannelFragment : MainFragment() {
if (parameter is String) {
_buttonSubscribe.setSubscribeChannel(parameter);
_textChannel.text = "";
_textChannelSub.text = "";
setPolycentricProfileOr(parameter) {
_textChannel.text = "";
_textChannelSub.text = "";
_creatorThumbnail.setThumbnail(null, true);
Glide.with(_imageBanner)
.clear(_imageBanner);
};
_url = parameter;
loadChannel();
} else if (parameter is SerializedChannel) {
showChannel(parameter);
_url = parameter.url;
_creatorThumbnail.setThumbnail(parameter.url, false);
loadChannel();
} else if (parameter is IPlatformChannel)
showChannel(parameter);
else if (parameter is PlatformAuthorLink) {
_textChannel.text = parameter.name;
_textChannelSub.text = "";
_creatorThumbnail.setThumbnail(parameter.url, false);
setPolycentricProfileOr(parameter.url) {
_textChannel.text = parameter.name;
_textChannelSub.text = "";
_creatorThumbnail.setThumbnail(parameter.thumbnail, true);
Glide.with(_imageBanner)
.clear(_imageBanner);
_taskLoadPolycentricProfile.run(parameter.id);
};
_url = parameter.url;
loadChannel();
} else if (parameter is Subscription) {
_textChannel.text = parameter.channel.name;
_textChannelSub.text = "";
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, false);
setPolycentricProfileOr(parameter.channel.url) {
_textChannel.text = parameter.channel.name;
_textChannelSub.text = "";
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true);
Glide.with(_imageBanner)
.clear(_imageBanner);
_taskLoadPolycentricProfile.run(parameter.channel.id);
};
_url = parameter.channel.url;
loadChannel();
@@ -360,14 +377,7 @@ class ChannelFragment : MainFragment() {
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons);
_buttonSubscribe.setSubscribeChannel(channel);
_textChannel.text = channel.name;
_textChannelSub.text = "${channel.subscribers.toHumanNumber()} subscribers";
_creatorThumbnail.setThumbnail(channel.thumbnail, true);
Glide.with(_imageBanner)
.load(channel.banner)
.crossfade()
.into(_imageBanner)
_textChannelSub.text = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} subscribers" else "";
//TODO: Find a better way to access the adapter fragments..
@@ -381,51 +391,68 @@ class ChannelFragment : MainFragment() {
this.channel = channel;
val cachedProfile = PolycentricCache.instance.getCachedProfile(channel.url);
setPolycentricProfileOr(channel.url) {
_textChannel.text = channel.name;
_creatorThumbnail.setThumbnail(channel.thumbnail, true);
Glide.with(_imageBanner)
.load(channel.banner)
.crossfade()
.into(_imageBanner);
_taskLoadPolycentricProfile.run(channel.id);
};
}
private fun setPolycentricProfileOr(url: String, or: () -> Unit) {
val cachedProfile = channel?.let { PolycentricCache.instance.getCachedProfile(it.url) };
if (cachedProfile != null) {
setPolycentricProfile(cachedProfile, animate = false);
} else {
setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(channel.id);
or();
}
}
private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
Log.i(TAG, "setPolycentricProfile(cachedPolycentricProfile = $cachedPolycentricProfile, animate = $animate)")
val polycentricProfile = cachedPolycentricProfile?.profile;
if (polycentricProfile != null) {
_fragment.topBar?.onShown(polycentricProfile);
val dp_35 = 35.dp(resources)
val profile = cachedPolycentricProfile?.profile;
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_35 * dp_35)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (polycentricProfile.systemState.username.isNotBlank())
_textChannel.text = polycentricProfile.systemState.username;
if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
}
val dp_35 = 35.dp(resources)
val avatar = polycentricProfile.systemState.avatar?.selectBestImage(dp_35 * dp_35)
?.let { it.toURLInfoSystemLinkUrl(polycentricProfile.system.toProto(), it.process, polycentricProfile.systemState.servers.toList()) };
val banner = profile?.systemState?.banner?.selectHighestResolutionImage()
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, true);
} else {
_creatorThumbnail.setHarborAvailable(true, true);
}
if (banner != null) {
Glide.with(_imageBanner)
.load(banner)
.crossfade()
.into(_imageBanner);
} else {
Glide.with(_imageBanner)
.load(channel?.banner)
.crossfade()
.into(_imageBanner);
}
val banner = polycentricProfile.systemState.banner?.selectHighestResolutionImage()
?.let { it.toURLInfoSystemLinkUrl(polycentricProfile.system.toProto(), it.process, polycentricProfile.systemState.servers.toList()) };
if (banner != null) {
Glide.with(_imageBanner)
.load(banner)
.crossfade()
.into(_imageBanner);
}
if (profile != null) {
_fragment.topBar?.onShown(profile);
_textChannel.text = profile.systemState.username;
}
(_viewPager.adapter as ChannelViewPagerAdapter?)?.let {
it.getFragment<ChannelAboutFragment>().setPolycentricProfile(polycentricProfile, animate);
it.getFragment<ChannelMonetizationFragment>().setPolycentricProfile(polycentricProfile, animate);
it.getFragment<ChannelListFragment>().setPolycentricProfile(polycentricProfile, animate);
it.getFragment<ChannelContentsFragment>().setPolycentricProfile(polycentricProfile, animate);
it.getFragment<ChannelAboutFragment>().setPolycentricProfile(profile, animate);
it.getFragment<ChannelMonetizationFragment>().setPolycentricProfile(profile, animate);
it.getFragment<ChannelListFragment>().setPolycentricProfile(profile, animate);
it.getFragment<ChannelContentsFragment>().setPolycentricProfile(profile, animate);
//TODO: Call on other tabs as needed
}
}
@@ -15,7 +15,9 @@ import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.structures.*
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.PreviewContentListAdapter
@@ -24,6 +26,7 @@ import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.adapters.PreviewNestedVideoViewHolder
import com.futo.platformplayer.views.adapters.PreviewVideoViewHolder
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import kotlin.math.floor
abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent, IPlatformContent, IPager<IPlatformContent>, ContentPreviewViewHolder> where TFragment : MainFragment {
@@ -69,15 +72,24 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
//TODO: Reconstruct search video from detail if search is null
_overlayContainer.let {
if(content is IPlatformVideo)
UISlideOverlays.showVideoOptionsOverlay(content, it) {
if (fragment is HomeFragment) {
val removeIndex = recyclerData.results.indexOf(content);
if (removeIndex >= 0) {
recyclerData.results.removeAt(removeIndex);
recyclerData.adapter.notifyItemRemoved(recyclerData.adapter.childToParentPosition(removeIndex));
UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(context, R.drawable.ic_visibility_off, "Hide", "Hide from Home", "hide",
{ StateMeta.instance.addHiddenVideo(content.url);
if (fragment is HomeFragment) {
val removeIndex = recyclerData.results.indexOf(content);
if (removeIndex >= 0) {
recyclerData.results.removeAt(removeIndex);
recyclerData.adapter.notifyItemRemoved(recyclerData.adapter.childToParentPosition(removeIndex));
}
}
}
};
}),
SlideUpMenuItem(context, R.drawable.ic_playlist, "Play Feed as Queue", "Play entire feed", "playFeed",
{
val newQueue = listOf(content) + recyclerData.results
.filterIsInstance<IPlatformVideo>()
.filter { it != content };
StatePlayer.instance.setQueue(newQueue, StatePlayer.TYPE_QUEUE, "Feed Queue", true, false);
})
);
}
};
adapter.onAddToQueueClicked.subscribe(this) {
@@ -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.structures.IPager
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.views.FeedStyle
import kotlinx.coroutines.Dispatchers
@@ -86,7 +87,7 @@ class ContentSearchResultsFragment : MainFragment() {
StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
}
})
.success { loadedResult(it); }
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
.exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load results.", it);
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
@@ -100,14 +101,12 @@ class ContentSearchResultsFragment : MainFragment() {
fun onShown(parameter: Any?, isBack: Boolean) {
if(parameter is SuggestionsFragmentData) {
if(!isBack) {
setQuery(parameter.query, false);
setChannelUrl(parameter.channelUrl, false);
setQuery(parameter.query, false);
setChannelUrl(parameter.channelUrl, false);
fragment.topBar?.apply {
if (this is SearchTopBarFragment) {
this.setText(parameter.query);
}
fragment.topBar?.apply {
if (this is SearchTopBarFragment) {
this.setText(parameter.query);
}
}
}
@@ -13,6 +13,7 @@ import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.structures.IPager
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.views.FeedStyle
@@ -56,6 +57,7 @@ class CreatorSearchResultsFragment : MainFragment() {
constructor(fragment: CreatorSearchResultsFragment, inflater: LayoutInflater): super(fragment, inflater) {
_taskSearch = TaskHandler<String, IPager<PlatformAuthorLink>>({fragment.lifecycleScope}, { query -> StatePlatform.instance.searchChannels(query) })
.success { loadedResult(it); }
.exception<ScriptCaptchaRequiredException> { }
.exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load results.", it);
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
@@ -69,16 +71,14 @@ class CreatorSearchResultsFragment : MainFragment() {
fun onShown(parameter: Any?, isBack: Boolean) {
if(parameter is String) {
if(!isBack) {
setQuery(parameter);
setQuery(parameter);
fragment.topBar?.apply {
if (this is SearchTopBarFragment) {
setText(parameter);
onSearch.subscribe(this) {
setQuery(it);
};
}
fragment.topBar?.apply {
if (this is SearchTopBarFragment) {
setText(parameter);
onSearch.subscribe(this) {
setQuery(it);
};
}
}
}
@@ -39,6 +39,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private val _spinnerSortBy: Spinner;
private val _containerSortBy: LinearLayout;
private val _tagsView: TagsView;
private val _textCentered: TextView;
protected val _toolbarContentView: LinearLayout;
@@ -68,6 +69,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
this.fragment = fragment;
inflater.inflate(R.layout.fragment_feed, this);
_textCentered = findViewById(R.id.text_centered);
_progress_bar = findViewById(R.id.progress_bar);
_progress_bar.inactiveColor = Color.TRANSPARENT;
@@ -169,6 +171,10 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_recyclerResults.addOnScrollListener(_scrollListener);
}
protected fun setTextCentered(text: String?) {
_textCentered.text = text;
}
fun onResume() {
//Reload the pager if the plugin was killed
val pager = recyclerData.pager;
@@ -8,21 +8,27 @@ import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.futo.platformplayer.*
import com.futo.platformplayer.activities.CaptchaActivity
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
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.internal.JSHttpClient
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
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.ScriptImplementationException
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.CaptchaWebViewClient
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateMeta
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.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
@@ -92,6 +98,7 @@ class HomeFragment : MainFragment() {
StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope)
})
.success { loadedResult(it); }
.exception<ScriptCaptchaRequiredException> { }
.exception<ScriptExecutionException> {
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,
@@ -100,14 +107,14 @@ class HomeFragment : MainFragment() {
);
}
.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.Action("Ignore", {}),
UIDialogs.Action("Sources", { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY)
);
}
.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, {
loadResults()
}) {
@@ -134,6 +141,8 @@ class HomeFragment : MainFragment() {
} else {
setLoading(false);
}
finishRefreshLayoutLoader();
}
override fun reload() {
@@ -69,6 +69,8 @@ class ImportSubscriptionsFragment : MainFragment() {
private var _currentLoadIndex = 0;
private var _taskLoadChannel: TaskHandler<String, IPlatformChannel>;
private var _counter: Int = 0;
private var _limitToastShown = false;
constructor(fragment: ImportSubscriptionsFragment, inflater: LayoutInflater) : super(inflater.context) {
_fragment = fragment;
@@ -104,6 +106,7 @@ class ImportSubscriptionsFragment : MainFragment() {
setLoading(false);
_taskLoadChannel = TaskHandler<String, IPlatformChannel>({_fragment.lifecycleScope}, { link ->
_counter++;
val channel: IPlatformChannel = StatePlatform.instance.getChannelLive(link, false);
return@TaskHandler channel;
}).success {
@@ -124,6 +127,8 @@ class ImportSubscriptionsFragment : MainFragment() {
}
fun onShown(parameter: Any ?, isBack: Boolean) {
_counter = 0;
_limitToastShown = false;
updateSelected();
val itemsRemoved = _items.size;
@@ -157,6 +162,15 @@ class ImportSubscriptionsFragment : MainFragment() {
private fun load() {
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]);
}
@@ -196,6 +210,7 @@ class ImportSubscriptionsFragment : MainFragment() {
companion object {
val TAG = "ImportSubscriptionsFragment";
private const val MAXIMUM_BATCH_SIZE = 75;
fun newInstance() = ImportSubscriptionsFragment().apply {}
}
}
@@ -73,16 +73,14 @@ class PlaylistSearchResultsFragment : MainFragment() {
fun onShown(parameter: Any?, isBack: Boolean) {
if(parameter is String) {
if(!isBack) {
setQuery(parameter);
setQuery(parameter);
fragment.topBar?.apply {
if (this is SearchTopBarFragment) {
setText(parameter);
onSearch.subscribe(this) {
setQuery(it);
};
}
fragment.topBar?.apply {
if (this is SearchTopBarFragment) {
setText(parameter);
onSearch.subscribe(this) {
setQuery(it);
};
}
}
}
@@ -346,24 +346,24 @@ class PostDetailFragment : MainFragment {
_rating.visibility = VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { processHandle, newHasLiked, newHasDisliked ->
if (newHasLiked) {
processHandle.opinion(ref, Opinion.like);
} else if (newHasDisliked) {
processHandle.opinion(ref, Opinion.dislike);
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) {
args.processHandle.opinion(ref, Opinion.dislike);
} else {
processHandle.opinion(ref, Opinion.neutral);
args.processHandle.opinion(ref, Opinion.neutral);
}
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
processHandle.fullyBackfillServers();
args.processHandle.fullyBackfillServers();
} catch (e: Throwable) {
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) {
@@ -601,7 +601,7 @@ class PostDetailFragment : MainFragment {
val subscribers = value?.author?.subscribers;
if(subscribers != null && subscribers > 0) {
_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 {
_channelMeta.visibility = View.GONE;
_channelMeta.text = "";
@@ -258,11 +258,25 @@ class SourceDetailFragment : MainFragment() {
}
}
val clientIfExists = StatePlugins.instance.getPlugin(config.id);
groups.add(
BigButtonGroup(c, "Management",
BigButton(c, "Uninstall", "Removes the plugin from the app", R.drawable.ic_block) {
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.IPlatformContent
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.cache.ChannelContentCache
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.exceptions.RateLimitException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson
@@ -31,6 +35,7 @@ import com.futo.platformplayer.views.subscriptions.SubscriptionBar
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@@ -130,6 +135,10 @@ class SubscriptionsFeedFragment : MainFragment() {
};
}
}
if (!StateSubscriptions.instance.isGlobalUpdating) {
finishRefreshLayoutLoader();
}
}
override fun cleanup() {
@@ -156,8 +165,17 @@ class SubscriptionsFeedFragment : MainFragment() {
private val _filterLock = Object();
private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter");
private var _bypassRateLimit = false;
private val _lastExceptions: List<Throwable>? = null;
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 currentExs = StateSubscriptions.instance.globalSubscriptionExceptions;
@@ -167,6 +185,29 @@ class SubscriptionsFeedFragment : MainFragment() {
return@TaskHandler resp;
})
.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> {
Logger.w(ChannelFragment.TAG, "Failed to load channel.", it);
if(it !is CancellationException)
@@ -240,8 +281,12 @@ class SubscriptionsFeedFragment : MainFragment() {
Logger.i(TAG, "Subscriptions load");
if(recyclerData.results.size == 0) {
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);
} else {
setTextCentered(null);
}
_taskGetPager.run(withRefetch);
}
@@ -254,6 +299,7 @@ class SubscriptionsFeedFragment : MainFragment() {
finishRefreshLayoutLoader();
setLoading(false);
setPager(pager);
setTextCentered(if (pager.getResults().isEmpty()) "No results found\nSwipe down to refresh" else null);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to finish loading", e)
}
@@ -62,6 +62,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptAgeException
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
@@ -870,10 +871,9 @@ class VideoDetailView : ConstraintLayout {
_commentsList.clear();
_platform.setPlatformFromClientID(video.id.pluginId);
_subTitle.text = subTitleSegments.joinToString("");
_channelName.text = video.author.name;
_playWhenReady = true;
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);
} else {
_channelMeta.text = "";
@@ -897,6 +897,7 @@ class VideoDetailView : ConstraintLayout {
} else {
setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(video.author.id);
_channelName.text = video.author.name;
}
_player.clear();
@@ -982,7 +983,7 @@ class VideoDetailView : ConstraintLayout {
_title.text = video.name;
_channelName.text = video.author.name;
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);
} else {
_channelMeta.text = "";
@@ -1042,24 +1043,24 @@ class VideoDetailView : ConstraintLayout {
withContext(Dispatchers.Main) {
_rating.visibility = View.VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { processHandle, newHasLiked, newHasDisliked ->
if (newHasLiked) {
processHandle.opinion(ref, Opinion.like);
} else if (newHasDisliked) {
processHandle.opinion(ref, Opinion.dislike);
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) {
args.processHandle.opinion(ref, Opinion.dislike);
} else {
processHandle.opinion(ref, Opinion.neutral);
args.processHandle.opinion(ref, Opinion.neutral);
}
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
processHandle.fullyBackfillServers();
args.processHandle.fullyBackfillServers();
} catch (e: Throwable) {
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) {
@@ -1254,6 +1255,10 @@ class VideoDetailView : ConstraintLayout {
_lastVideoSource = videoSource;
_lastAudioSource = audioSource;
}
catch(ex: UnsupportedCastException) {
Logger.e(TAG, "Failed to load cast media", ex);
UIDialogs.showGeneralErrorDialog(context, "Unsupported Cast format", ex);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to load media", ex);
UIDialogs.showGeneralErrorDialog(context, "Failed to load media", ex);
@@ -1610,10 +1615,10 @@ class VideoDetailView : ConstraintLayout {
_lastSubtitleSource = toSet;
}
private fun handleUnavailableVideo() {
private fun handleUnavailableVideo(msg: String? = null) {
if (!nextVideo()) {
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", {
this@VideoDetailView.onClose.emit();
}, UIDialogs.ActionStyle.PRIMARY));
@@ -1971,14 +1976,24 @@ class VideoDetailView : ConstraintLayout {
private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
_polycentricProfile = cachedPolycentricProfile;
if (cachedPolycentricProfile?.profile == null) {
_layoutMonetization.visibility = View.GONE;
_creatorThumbnail.setHarborAvailable(false, animate);
return;
val dp_35 = 35.dp(context.resources)
val profile = cachedPolycentricProfile?.profile;
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_35 * dp_35)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(video?.author?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
}
_layoutMonetization.visibility = View.VISIBLE;
_creatorThumbnail.setHarborAvailable(true, animate);
if (profile != null) {
_channelName.text = cachedPolycentricProfile.profile.systemState.username;
_layoutMonetization.visibility = View.VISIBLE;
} else {
_layoutMonetization.visibility = View.GONE;
}
}
fun setProgressBarOverlayed(isOverlayed: Boolean?) {
@@ -2092,7 +2107,7 @@ class VideoDetailView : ConstraintLayout {
}
.exception<ScriptUnavailableException> {
Logger.w(TAG, "exception<ScriptUnavailableException>", it);
handleUnavailableVideo();
handleUnavailableVideo(it.message);
}
.exception<ScriptException> {
Logger.w(TAG, "exception<ScriptException>", it)
@@ -57,10 +57,8 @@ abstract class VideoListEditorView : LinearLayout {
buttonPlayAll.setOnClickListener { onPlayAllClick(); };
buttonShuffle.setOnClickListener { onShuffleClick(); };
if (canEdit())
_buttonEdit.setOnClickListener { onEditClick(); };
else
_buttonEdit.visibility = View.GONE;
_buttonEdit.setOnClickListener { onEditClick(); };
setButtonDownloadVisible(canEdit());
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved);
@@ -61,9 +61,21 @@ public class PolycentricModelLoader implements ModelLoader<String, ByteBuffer> {
_deferred.invokeOnCompletion(throwable -> {
if (throwable != null) {
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;
});
}
@@ -64,12 +64,11 @@ class Logging {
val client = OkHttpClient()
val response: Response = client.newCall(request).execute()
if (response.isSuccessful) {
val body = response.body?.string();
return if (body != null) Json.decodeFromString<String>(body) else null;
return if (response.isSuccessful) {
response.body?.string();
} else {
Logger.e("Failed to submit log.") { "Failed to submit logs (${response.code}): ${response.body?.string()}" };
return null;
null;
}
}
}
@@ -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.SerializedChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.getNowDiffDays
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.states.StatePlatform
import java.time.OffsetDateTime
@kotlinx.serialization.Serializable
class Subscription {
var channel: SerializedChannel;
//Last found content
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastVideo : OffsetDateTime = OffsetDateTime.MAX;
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
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 uploadPostInterval : Int = 0;
constructor(channel : SerializedChannel) {
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) {
this.channel = SerializedChannel.fromChannel(channel);
}
@@ -9,8 +9,8 @@ data class Telemetry(
val buildType: String,
val debug: Boolean,
val isUnstableBuild: Boolean,
val time: Long,
val brand: String,
val manufacturer: String,
val model: String
val model: String,
val sdkVersion: Int
) { }
@@ -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.webkit.*
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.Serializer
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.matchesDomain
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class LoginWebViewClient : WebViewClient {
private val LOG_VERBOSE = false;
@@ -42,10 +44,13 @@ class LoginWebViewClient : WebViewClient {
private var urlFound = false;
override fun onPageFinished(view: WebView?, url: String?) {
if(BuildConfig.DEBUG)
Logger.i(TAG, "Login Url Page: " + url);
super.onPageFinished(view, url);
onPageLoaded.emit(view, url);
}
//TODO: Use new WebViewRequirementExtractor when time to test extensively
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
if(request == null)
return super.shouldInterceptRequest(view, request as WebResourceRequest?);
@@ -54,11 +59,29 @@ class LoginWebViewClient : WebViewClient {
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 domainLower = request.url.host?.lowercase();
val urlString = request.url.toString();
if(_authConfig.completionUrl == null)
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
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";
}
}
@@ -8,6 +8,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.resolveChannelUrls
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.stores.CachedPolycentricProfileStorage
import com.futo.platformplayer.stores.FragmentedStorage
@@ -37,7 +38,8 @@ class PolycentricCache {
ContentType.AVATAR.value,
ContentType.USERNAME.value,
ContentType.DESCRIPTION.value,
ContentType.STORE.value
ContentType.STORE.value,
ContentType.SERVER.value
)
).eventsList.map { e -> SignedEvent.fromProto(e) };
@@ -88,8 +90,9 @@ class PolycentricCache {
if (result.profile != null) {
for (claim in result.profile.ownedClaims) {
val url = claim.claim.resolveChannelUrl() ?: continue;
_profileUrlCache.map[url] = result;
val urls = claim.claim.resolveChannelUrls();
for (url in urls)
_profileUrlCache.map[url] = result;
}
}
@@ -20,10 +20,10 @@ class PlatformContentSerializer() : JsonContentPolymorphicSerializer<SerializedP
if(obj?.jsonPrimitive?.isString ?: true)
return when(obj?.jsonPrimitive?.contentOrNull) {
"MEDIA" -> SerializedPlatformVideo.serializer();
"NESTED" -> SerializedPlatformNestedContent.serializer();
"NESTED_VIDEO" -> SerializedPlatformNestedContent.serializer();
"ARTICLE" -> throw NotImplementedError("Articles 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
return when(obj?.jsonPrimitive?.int) {
@@ -25,11 +25,19 @@ import androidx.lifecycle.lifecycleScope
import androidx.work.*
import com.futo.platformplayer.*
import com.futo.platformplayer.R
import com.futo.platformplayer.activities.CaptchaActivity
import com.futo.platformplayer.activities.IWithResultLauncher
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.casting.StateCasting
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.FileLogConsumer
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.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.stripe.android.core.utils.encodeToJson
import kotlinx.coroutines.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.time.OffsetDateTime
import java.util.*
@@ -85,7 +96,7 @@ class StateApp {
onChanged?.invoke(getExternalGeneralDirectory(context));
}
else
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
scopeOrNull?.launch(Dispatchers.Main) {
UIDialogs.toast("Failed to gain access to\n [${it?.lastPathSegment}]");
};
};
@@ -97,10 +108,14 @@ class StateApp {
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_PERSISTABLE_URI_PERMISSION.or(Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)));
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();
@@ -419,9 +434,19 @@ class StateApp {
StatePlaylists.instance.toMigrateCheck()
).flatten(), 0);
scope.launch {
delay(5000);
StateSubscriptions.instance.updateSubscriptionFeed(scope, false);
if(Settings.instance.subscriptions.fetchOnAppBoot) {
scope.launch(Dispatchers.IO) {
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();
@@ -637,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 {
private val TAG = "StateApp";
@SuppressLint("StaticFieldLeak") //This is only alive while MainActivity is alive
@@ -172,7 +172,11 @@ class StatePlatform {
_icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?:
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)
@@ -287,6 +291,9 @@ class StatePlatform {
StatePlugins.instance.getPlugin(id)
?: throw IllegalStateException("Client existed, but plugin config didn't")
);
newClient.onCaptchaException.subscribe { client, ex ->
StateApp.instance.handleCaptchaException(client, ex);
}
synchronized(_clientsLock) {
if (_enabledClients.contains(client)) {
@@ -618,9 +625,13 @@ class StatePlatform {
}
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})");
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> {
Logger.i(TAG, "Platform - getChannel");
@@ -631,9 +642,9 @@ class StatePlatform {
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");
val baseClient = getChannelClient(channelUrl);
val baseClient = getChannelClient(channelUrl, ignorePlugins);
val clientCapabilities = baseClient.getChannelCapabilities();
val client = if(usePooledClients > 1)
@@ -653,19 +664,24 @@ class StatePlatform {
toQuery.add(ResultCapabilities.TYPE_STREAMS);
if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE))
toQuery.add(ResultCapabilities.TYPE_LIVE);
if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS))
toQuery.add(ResultCapabilities.TYPE_POSTS);
if(isSubscriptionOptimized) {
val sub = StateSubscriptions.instance.getSubscription(channelUrl);
if(sub != null) {
val daysSinceLiveStream = sub.lastLiveStream.getNowDiffDays()
if(daysSinceLiveStream > 7) {
Logger.i(TAG, "Subscription [${channelUrl}] Last livestream > 7 days, skipping live streams [${daysSinceLiveStream} days ago]");
if(!sub.shouldFetchStreams()) {
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 7 days, skipping live streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
toQuery.remove(ResultCapabilities.TYPE_LIVE);
}
if(daysSinceLiveStream > 14) {
Logger.i(TAG, "Subscription [${channelUrl}] Last livestream > 15 days, skipping streams [${daysSinceLiveStream} days ago]");
if(!sub.shouldFetchLiveStreams()) {
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
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);
}
}
}
@@ -768,6 +784,15 @@ class StatePlatform {
return null;
}
fun resolveChannelUrlsByClaimTemplates(claimType: Int, claimValues: Map<Int, String>): List<String> {
val urls = arrayListOf<String>();
for(client in getClientsByClaimType(claimType).filter { it is JSClient }) {
val res = (client as JSClient).resolveChannelUrlsByClaimTemplates(claimType, claimValues);
urls.addAll(res);
}
return urls;
}
fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { it.isPlaylistUrl(url) }
fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { it.isPlaylistUrl(url) }
?: throw NoPlatformClientException("No client enabled that supports this playlist url (${url})");
@@ -5,6 +5,7 @@ import android.net.Uri
import androidx.core.content.FileProvider
import com.futo.platformplayer.R
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.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@@ -265,6 +266,12 @@ class StatePlaylists {
builder.messages.add("${name}:[${it}] is no longer available");
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) {
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.media.platforms.js.JSClient
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.SourcePluginDescriptor
import com.futo.platformplayer.logging.Logger
@@ -115,8 +116,9 @@ class StatePlugins {
else if(embeddedConfig != null) {
val existing = getPlugin(embedded.key);
if(existing != null && existing.config.version < embeddedConfig.version ) {
Logger.i(TAG, "Found outdated embedded plugin [${existing.config.id}] ${existing.config.name}, deleting and reinstalling");
deletePlugin(embedded.key);
Logger.i(TAG, "Outdated Embedded plugin [${existing.config.id}] ${existing.config.name} (${existing.config.version} < ${embeddedConfig?.version}), reinstalling");
//deletePlugin(embedded.key);
installEmbeddedPlugin(context, embedded.value)
}
else if(existing != null && _isFirstEmbedUpdate)
Logger.i(TAG, "Embedded plugin [${existing.config.id}] ${existing.config.name}, up to date (${existing.config.version} >= ${embeddedConfig?.version})");
@@ -359,6 +361,8 @@ class StatePlugins {
}
val existing = getPlugin(config.id)
val existingAuth = existing?.getAuth();
val existingCaptcha = existing?.getCaptchaData();
if (existing != null) {
if(!reinstall)
throw IllegalStateException("Plugin with id ${config.id} already exists");
@@ -372,7 +376,7 @@ class StatePlugins {
if(icon != null)
iconsDir.saveIconBinary(config.id, icon);
_plugins.save(SourcePluginDescriptor(config, null, flags));
_plugins.save(SourcePluginDescriptor(config, existingAuth?.toEncrypted(), existingCaptcha?.toEncrypted(), flags));
return null;
}
catch(ex: Throwable) {
@@ -407,6 +411,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?) {
if(id == StateDeveloper.DEV_ID) {
StatePlatform.instance.getDevClient()?.let {
@@ -19,6 +19,7 @@ import com.futo.platformplayer.api.media.structures.IAsyncPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
import com.futo.platformplayer.api.media.structures.PlaceholderPager
import com.futo.platformplayer.api.media.structures.RefreshChronoContentPager
import com.futo.platformplayer.api.media.structures.RefreshDedupContentPager
import com.futo.platformplayer.api.media.structures.RefreshDistributionContentPager
import com.futo.platformplayer.awaitFirstDeferred
@@ -126,19 +127,16 @@ 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
val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency;
val pagers = profile.ownedClaims.groupBy { it.claim.claimType }.mapNotNull {
//TODO: Deduplicate once multiple urls in single claim is supported
return@mapNotNull it.value.firstOrNull();
}.mapNotNull {
val url = it.claim.resolveChannelUrl() ?: return@mapNotNull null;
val url = it.value.firstOrNull()?.claim?.resolveChannelUrl() ?: return@mapNotNull null;
if (!StatePlatform.instance.hasEnabledChannelClient(url)) {
return@mapNotNull null;
}
return@mapNotNull StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency);
return@mapNotNull StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency, ignorePlugins);
}.toTypedArray();
val pager = MultiChronoContentPager(pagers);
@@ -151,10 +149,7 @@ class StatePolycentric {
val concurrency = if (channelConcurrency == -1) Settings.instance.subscriptions.getSubscriptionsConcurrency() else channelConcurrency;
val deferred = profile.ownedClaims.groupBy { it.claim.claimType }
.mapNotNull {
//TODO: Deduplicate once multiple urls in single claim is supported
return@mapNotNull it.value.firstOrNull();
}.mapNotNull {
val url = it.claim.resolveChannelUrl() ?: return@mapNotNull null;
val url = it.value.firstOrNull()?.claim?.resolveChannelUrl() ?: return@mapNotNull null;
val client = StatePlatform.instance.getChannelClientOrNull(url) ?: return@mapNotNull null;
return@mapNotNull Pair(client, scope.async(Dispatchers.IO) {
@@ -173,12 +168,21 @@ class StatePolycentric {
}) ?: return null;
val toAwait = deferred.filter { it.second != finishedPager.first };
//TODO: Get a Parallel pager to work here.
val innerPager = MultiChronoContentPager(listOf(finishedPager.second!!) + toAwait.mapNotNull { runBlocking { it.second.await(); } });
innerPager.initialize();
//return RefreshChronoContentPager(listOf(finishedPager.second!!), toAwait.map { it.second }, listOf());
//return RefreshDedupContentPager(RefreshChronoContentPager(listOf(finishedPager.second!!), toAwait.map { it.second }, listOf()), StatePlatform.instance.getEnabledClients().map { it.id });
return DedupContentPager(innerPager, StatePlatform.instance.getEnabledClients().map { it.id });
/* //Gives out-of-order results
return RefreshDedupContentPager(RefreshDistributionContentPager(
listOf(finishedPager.second!!),
toAwait.map { it.second },
toAwait.map { PlaceholderPager(5) { PlatformContentPlaceholder(it.first.id) } }),
StatePlatform.instance.getEnabledClients().map { it.id }
);
);*/
}
suspend fun getChannelContent(profile: PolycentricProfile): IPager<IPlatformContent> {
return withContext(Dispatchers.IO) {
@@ -302,7 +306,7 @@ class StatePolycentric {
thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(PolycentricCache.SERVER)) },
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),
date = if (unixMilliseconds != null) Instant.ofEpochMilli(unixMilliseconds).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN,
replyCount = replies.toInt(),
@@ -2,9 +2,12 @@ package com.futo.platformplayer.states
import com.futo.platformplayer.Settings
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.SerializedChannel
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.ReusablePager.Companion.asReusable
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.Event2
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.findNonRuntimeException
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.getNowDiffDays
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache
@@ -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);
if(result.second.any())
throw result.second.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 exs: ArrayList<Throwable> = arrayListOf();
@@ -230,8 +261,11 @@ class StateSubscriptions {
var finished = 0;
val exceptionMap: HashMap<Subscription, Throwable> = hashMapOf();
val concurrency = Settings.instance.subscriptions.getSubscriptionsConcurrency();
val failedPlugins = arrayListOf<String>();
for (sub in getSubscriptions().filter { StatePlatform.instance.hasEnabledChannelClient(it.channel.url) }) {
tasks.add(_subscriptionsPool.submit<Pair<Subscription, IPager<IPlatformContent>?>> {
val toIgnore = synchronized(failedPlugins){ failedPlugins.toList() };
var polycentricProfile : PolycentricCache.CachedPolycentricProfile? = null;
val getProfileTime = measureTimeMillis {
try {
@@ -258,9 +292,9 @@ class StateSubscriptions {
val time = measureTimeMillis {
val profile = polycentricProfile?.profile
pager = if (profile != null)
StatePolycentric.instance.getChannelContent(profile, true, concurrency)
StatePolycentric.instance.getChannelContent(profile, true, concurrency, toIgnore)
else
StatePlatform.instance.getChannelContent(sub.channel.url, true, concurrency);
StatePlatform.instance.getChannelContent(sub.channel.url, true, concurrency, toIgnore);
if (cacheScope != null)
pager = ChannelContentCache.cachePagerResults(cacheScope, pager) {
@@ -276,12 +310,31 @@ class StateSubscriptions {
);
}
catch(ex: Throwable) {
Logger.e(TAG, "Subscription [${sub.channel.name}] failed", ex);
finished++;
onProgress?.invoke(finished, tasks.size);
val channelEx = ChannelException(sub.channel, ex);
synchronized(exceptionMap) {
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)
throw channelEx;
else {
@@ -327,9 +380,10 @@ class StateSubscriptions {
throw exs.first();
Logger.i(TAG, "Subscription pager with ${subsPager.size} channels");
val pager = MultiChronoContentPager(subsPager, allowFailure);
val pager = MultiChronoContentPager(subsPager, allowFailure, 15);
pager.initialize();
return Pair(pager, exs);
//return Pair(pager, exs);
return Pair(DedupContentPager(pager), exs);
}
//New Migration
@@ -37,14 +37,14 @@ class StateTelemetry {
BuildConfig.BUILD_TYPE,
BuildConfig.DEBUG,
BuildConfig.IS_UNSTABLE_BUILD,
Instant.now().epochSecond,
Build.BRAND,
Build.MANUFACTURER,
Build.MODEL
Build.MODEL,
Build.VERSION.SDK_INT
);
val headers = hashMapOf(
"Content-Type" to "text/plain"
"Content-Type" to "application/json"
);
val json = Json.encodeToString(telemetry);
@@ -118,6 +118,7 @@ class ManagedStore<T>{
val builder = ReconstructStore.Builder();
for (recon in items) {
onProgress?.invoke(0, total);
//Retry once
for (i in 0 .. 1) {
try {
@@ -6,6 +6,7 @@ import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
@@ -35,12 +36,14 @@ class CommentViewHolder : ViewHolder {
private val _buttonReplies: PillButton;
private val _layoutRating: LinearLayout;
private val _pillRatingLikesDislikes: PillRatingLikesDislikes;
private val _layoutComment: ConstraintLayout;
var onClick = Event1<IPlatformComment>();
var comment: IPlatformComment? = null
private set;
constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_comment, viewGroup, false)) {
_layoutComment = itemView.findViewById(R.id.layout_comment);
_creatorThumbnail = itemView.findViewById(R.id.image_thumbnail);
_textAuthor = itemView.findViewById(R.id.text_author);
_textMetadata = itemView.findViewById(R.id.text_metadata);
@@ -53,29 +56,31 @@ class CommentViewHolder : ViewHolder {
_layoutRating = itemView.findViewById(R.id.layout_rating);
_pillRatingLikesDislikes = itemView.findViewById(R.id.rating);
_pillRatingLikesDislikes.onLikeDislikeUpdated.subscribe { processHandle, hasLiked, hasDisliked ->
_pillRatingLikesDislikes.onLikeDislikeUpdated.subscribe { args ->
val c = comment
if (c !is PolycentricPlatformComment) {
throw Exception("Not implemented for non polycentric comments")
}
if (hasLiked) {
processHandle.opinion(c.reference, Opinion.like);
} else if (hasDisliked) {
processHandle.opinion(c.reference, Opinion.dislike);
if (args.hasLiked) {
args.processHandle.opinion(c.reference, Opinion.like);
} else if (args.hasDisliked) {
args.processHandle.opinion(c.reference, Opinion.dislike);
} else {
processHandle.opinion(c.reference, Opinion.neutral);
args.processHandle.opinion(c.reference, Opinion.neutral);
}
_layoutComment.alpha = if (args.dislikes > 2 && args.dislikes.toFloat() / (args.likes + args.dislikes).toFloat() >= 0.7f) 0.5f else 1.0f;
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
processHandle.fullyBackfillServers();
args.processHandle.fullyBackfillServers();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers.", e)
}
}
StatePolycentric.instance.updateLikeMap(c.reference, hasLiked, hasDisliked)
StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked)
};
_buttonReplies.onClick.subscribe {
@@ -88,6 +93,7 @@ class CommentViewHolder : ViewHolder {
fun bind(comment: IPlatformComment, readonly: Boolean) {
_creatorThumbnail.setThumbnail(comment.author.thumbnail, false);
_creatorThumbnail.setHarborAvailable(comment is PolycentricPlatformComment,false);
_textAuthor.text = comment.author.name;
val date = comment.date;
@@ -98,6 +104,13 @@ class CommentViewHolder : ViewHolder {
_textMetadata.visibility = View.GONE;
}
val rating = comment.rating;
if (rating is RatingLikeDislikes) {
_layoutComment.alpha = if (rating.dislikes > 2 && rating.dislikes.toFloat() / (rating.likes + rating.dislikes).toFloat() >= 0.7f) 0.5f else 1.0f;
} else {
_layoutComment.alpha = 1.0f;
}
_textBody.text = comment.message.fixHtmlLinks();
if (readonly) {
@@ -32,6 +32,7 @@ import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.video.FutoThumbnailPlayer
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
open class PreviewVideoView : LinearLayout {
@@ -58,11 +59,12 @@ open class PreviewVideoView : LinearLayout {
protected val _exoPlayer: PlayerManager?;
private val _taskLoadValidClaims = TaskHandler<PlatformID, PolycentricCache.CachedOwnedClaims>(StateApp.instance.scopeGetter,
{ PolycentricCache.instance.getValidClaimsAsync(it).await() })
.success { it -> updateClaimsLayout(it, animate = true) }
private val _taskLoadProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>(
StateApp.instance.scopeGetter,
{ PolycentricCache.instance.getProfileAsync(it) })
.success { it -> onProfileLoaded(it, true) }
.exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it);
Logger.w(TAG, "Failed to load profile.", it);
};
val onVideoClicked = Event2<IPlatformVideo, Long>();
@@ -145,15 +147,7 @@ open class PreviewVideoView : LinearLayout {
open fun bind(content: IPlatformContent) {
_taskLoadValidClaims.cancel();
val cachedClaims = PolycentricCache.instance.getCachedValidClaims(content.author.id);
if (cachedClaims != null) {
updateClaimsLayout(cachedClaims, animate = false);
} else {
updateClaimsLayout(null, animate = false);
_taskLoadValidClaims.run(content.author.id);
}
_taskLoadProfile.cancel();
isClickable = true;
@@ -161,16 +155,25 @@ open class PreviewVideoView : LinearLayout {
stopPreview();
if(_imageChannel != null)
Glide.with(_imageChannel)
.load(content.author.thumbnail)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(_imageChannel);
val cachedProfile = PolycentricCache.instance.getCachedProfile(content.author.url, true);
if (cachedProfile != null) {
onProfileLoaded(cachedProfile, false);
} else {
_imageNeopassChannel?.visibility = View.GONE;
_creatorThumbnail?.setThumbnail(content.author.thumbnail, false);
_imageChannel?.let {
Glide.with(_imageChannel)
.load(content.author.thumbnail)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(_imageChannel);
}
_taskLoadProfile.run(content.author.id);
_textChannelName.text = content.author.name
}
_imageChannel?.clipToOutline = true;
_textVideoName.text = content.name;
_textChannelName.text = content.author.name
_layoutDownloaded.visibility = if (StateDownloads.instance.isDownloaded(content.id)) VISIBLE else GONE;
_platformIndicator.setPlatformFromClientID(content.id.pluginId);
@@ -296,22 +299,50 @@ open class PreviewVideoView : LinearLayout {
_playerVideoThumbnail?.setMuteChangedListener(callback);
}
private fun updateClaimsLayout(claims: PolycentricCache.CachedOwnedClaims?, animate: Boolean) {
private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
_neopassAnimator?.cancel();
_neopassAnimator = null;
val harborAvailable = claims != null && !claims.ownedClaims.isNullOrEmpty();
if (harborAvailable) {
_imageNeopassChannel?.visibility = View.VISIBLE
if (animate) {
_neopassAnimator = ObjectAnimator.ofFloat(_imageNeopassChannel, "alpha", 0.0f, 1.0f).setDuration(500)
_neopassAnimator?.start()
val profile = cachedPolycentricProfile?.profile;
if (_creatorThumbnail != null) {
val dp_32 = 32.dp(context.resources);
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_32 * dp_32)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(content?.author?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
}
} else if (_imageChannel != null) {
val dp_28 = 28.dp(context.resources);
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_28 * dp_28)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (avatar != null) {
_imageChannel.let {
Glide.with(_imageChannel)
.load(avatar)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(_imageChannel);
}
_imageNeopassChannel?.visibility = View.VISIBLE
if (animate) {
_neopassAnimator = ObjectAnimator.ofFloat(_imageNeopassChannel, "alpha", 0.0f, 1.0f).setDuration(500)
_neopassAnimator?.start()
} else {
_imageNeopassChannel?.alpha = 1.0f;
}
} else {
_imageNeopassChannel?.visibility = View.GONE
}
} else {
_imageNeopassChannel?.visibility = View.GONE
}
_creatorThumbnail?.setHarborAvailable(harborAvailable, animate)
if (profile != null) {
_textChannelName.text = profile.systemState.username
}
}
companion object {
@@ -72,22 +72,27 @@ class SubscriptionViewHolder : ViewHolder {
} else {
_creatorThumbnail.setThumbnail(sub.channel.thumbnail, false);
_taskLoadProfile.run(sub.channel.id);
_textName.text = sub.channel.name;
}
_textName.text = sub.channel.name;
_platformIndicator.setPlatformFromClientID(sub.channel.id.pluginId);
}
private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
val dp_46 = 46.dp(itemView.context.resources);
val avatar = cachedPolycentricProfile?.profile?.systemState?.avatar?.selectBestImage(dp_46 * dp_46)
?.let { it.toURLInfoSystemLinkUrl(cachedPolycentricProfile.profile.system.toProto(), it.process, cachedPolycentricProfile.profile.systemState.servers.toList()) };
val profile = cachedPolycentricProfile?.profile;
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_46 * dp_46)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(this.subscription?.channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(cachedPolycentricProfile?.profile != null, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
}
if (profile != null) {
_textName.text = profile.systemState.username;
}
}
@@ -6,13 +6,23 @@ import android.view.ViewGroup
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.SubscriptionViewHolder
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Boolean) : AnyAdapter.AnyViewHolder<PlatformAuthorLink>(
LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_creator, _viewGroup, false)) {
@@ -25,7 +35,15 @@ class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Bo
private var _authorLink: PlatformAuthorLink? = null;
val onClick = Event1<PlatformAuthorLink>();
private val _taskLoadProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>(
StateApp.instance.scopeGetter,
{ PolycentricCache.instance.getProfileAsync(it) })
.success { it -> onProfileLoaded(it, true) }
.exception<Throwable> {
Logger.w(TAG, "Failed to load profile.", it);
};
init {
_textName = _view.findViewById(R.id.text_channel_name);
_creatorThumbnail = _view.findViewById(R.id.creator_thumbnail);
@@ -45,12 +63,21 @@ class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Bo
}
override fun bind(authorLink: PlatformAuthorLink) {
_textName.text = authorLink.name;
_creatorThumbnail.setThumbnail(authorLink.thumbnail, false);
_taskLoadProfile.cancel();
val cachedProfile = PolycentricCache.instance.getCachedProfile(authorLink.url, true);
if (cachedProfile != null) {
onProfileLoaded(cachedProfile, false);
} else {
_creatorThumbnail.setThumbnail(authorLink.thumbnail, false);
_taskLoadProfile.run(authorLink.id);
_textName.text = authorLink.name;
}
if(authorLink.subscribers == null || (authorLink.subscribers ?: 0) <= 0L)
_textMetadata.visibility = View.GONE;
else {
_textMetadata.text = authorLink.subscribers!!.toHumanNumber() + " subscribers";
_textMetadata.text = if((authorLink.subscribers ?: 0) > 0) authorLink.subscribers!!.toHumanNumber() + " subscribers" else "";
_textMetadata.visibility = View.VISIBLE;
}
_buttonSubscribe.setSubscribeChannel(authorLink.url);
@@ -58,6 +85,25 @@ class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Bo
_authorLink = authorLink;
}
private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
val dp_61 = 61.dp(itemView.context.resources);
val profile = cachedPolycentricProfile?.profile;
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_61 * dp_61)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(_authorLink?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
}
if (profile != null) {
_textName.text = profile.systemState.username;
}
}
companion object {
private const val TAG = "CreatorViewHolder";
}
@@ -57,22 +57,27 @@ class SubscriptionBarViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.
} else {
_creatorThumbnail.setThumbnail(subscription.channel.thumbnail, false);
_taskLoadProfile.run(subscription.channel.id);
_name.text = subscription.channel.name;
}
_name.text = subscription.channel.name;
_subscription = subscription;
}
private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
val dp_55 = 55.dp(itemView.context.resources)
val avatar = cachedPolycentricProfile?.profile?.systemState?.avatar?.selectBestImage(dp_55 * dp_55)
?.let { it.toURLInfoSystemLinkUrl(cachedPolycentricProfile.profile.system.toProto(), it.process, cachedPolycentricProfile.profile.systemState.servers.toList()) };
val profile = cachedPolycentricProfile?.profile;
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_55 * dp_55)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(_channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(cachedPolycentricProfile?.profile != null, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
}
if (profile != null) {
_name.text = profile.systemState.username;
}
}
@@ -13,7 +13,7 @@ import com.futo.platformplayer.constructs.Event0
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.shape.ShapeAppearanceModel
class BigButton : LinearLayout {
open class BigButton : LinearLayout {
private val _root: LinearLayout;
private val _icon: ShapeableImageView;
private val _textPrimary: TextView;
@@ -78,6 +78,10 @@ class BigButton : LinearLayout {
_textSecondary.text = text;
return this;
}
fun withSecondaryTextMaxLines(lines: Int): BigButton {
_textSecondary.maxLines = lines;
return this;
}
fun withIcon(resourceId: Int, rounded: Boolean = false): BigButton {
if (resourceId != -1) {
@@ -14,13 +14,13 @@ class BigButtonGroup : LinearLayout {
_header = findViewById(R.id.header_title);
_buttons = findViewById(R.id.buttons);
}
constructor(context: Context, header: String, vararg buttons: BigButton) : super(context) {
constructor(context: Context, header: String, vararg buttons: BigButton?) : super(context) {
inflate(context, R.layout.big_button_group, this);
_header = findViewById(R.id.header_title);
_buttons = findViewById(R.id.buttons);
_header.text = header;
for(button in buttons)
for(button in buttons.filterNotNull())
_buttons.addView(button);
}
@@ -4,13 +4,21 @@ import android.content.Context
import android.util.AttributeSet
import android.widget.*
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.dp
import com.futo.platformplayer.views.buttons.BigButton
import java.lang.reflect.Field
import java.lang.reflect.Method
class ButtonField : LinearLayout, IField {
@Target(AnnotationTarget.FIELD, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class FormFieldButton(val drawable: Int = 0)
class ButtonField : BigButton, IField {
override var descriptor: FormField? = null;
private var _obj : Any? = null;
private var _method : Method? = null;
@@ -26,17 +34,22 @@ class ButtonField : LinearLayout, IField {
return null;
};
private val _title : TextView;
private val _subtitle : TextView;
//private val _title : TextView;
//private val _subtitle : TextView;
override val onChanged = Event2<IField, Any>();
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){
inflate(context, R.layout.field_button, this);
_title = findViewById(R.id.field_title);
_subtitle = findViewById(R.id.field_subtitle);
//inflate(context, R.layout.field_button, this);
//_title = findViewById(R.id.field_title);
//_subtitle = findViewById(R.id.field_subtitle);
setOnClickListener {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
val dp5 = 5.dp(context.resources);
setMargins(0, dp5, 0, dp5)
};
super.onClick.subscribe {
if(_method?.parameterCount == 1)
_method?.invoke(_obj, context);
else if(_method?.parameterCount == 2)
@@ -51,13 +64,17 @@ class ButtonField : LinearLayout, IField {
this._obj = obj;
val attrField = method.getAnnotation(FormField::class.java);
val attrButtonField = method.getAnnotation(FormFieldButton::class.java);
if(attrField != null) {
_title.text = attrField.title;
_subtitle.text = attrField.subtitle;
super.withPrimaryText(attrField.title)
.withSecondaryText(attrField.subtitle)
.withSecondaryTextMaxLines(2);
descriptor = attrField;
}
else
_title.text = method.name;
super.withPrimaryText(method.name);
if(attrButtonField != null)
super.withIcon(attrButtonField.drawable, false);
return this;
}
@@ -12,11 +12,20 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNumber
import com.futo.polycentric.core.ProcessHandle
data class OnLikeDislikeUpdatedArgs(
val processHandle: ProcessHandle,
val likes: Long,
val hasLiked: Boolean,
val dislikes: Long,
val hasDisliked: Boolean,
);
class PillRatingLikesDislikes : LinearLayout {
private val _textLikes: TextView;
private val _textDislikes: TextView;
@@ -29,7 +38,7 @@ class PillRatingLikesDislikes : LinearLayout {
private var _dislikes = 0L;
private var _hasDisliked = false;
val onLikeDislikeUpdated = Event3<ProcessHandle, Boolean, Boolean>();
val onLikeDislikeUpdated = Event1<OnLikeDislikeUpdatedArgs>();
constructor(context : Context, attrs : AttributeSet?) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.rating_likesdislikes, this, true);
@@ -76,7 +85,7 @@ class PillRatingLikesDislikes : LinearLayout {
_textLikes.text = _likes.toHumanNumber();
updateColors();
onLikeDislikeUpdated.emit(processHandle, _hasLiked, _hasDisliked);
onLikeDislikeUpdated.emit(OnLikeDislikeUpdatedArgs(processHandle, _likes, _hasLiked, _dislikes, _hasDisliked));
}
fun dislike(processHandle: ProcessHandle) {
@@ -96,7 +105,7 @@ class PillRatingLikesDislikes : LinearLayout {
_textDislikes.text = _dislikes.toHumanNumber();
updateColors();
onLikeDislikeUpdated.emit(processHandle, _hasLiked, _hasDisliked);
onLikeDislikeUpdated.emit(OnLikeDislikeUpdatedArgs(processHandle, _likes, _hasLiked, _dislikes, _hasDisliked));
}
private fun updateColors() {
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,760Q546,760 593,713Q640,666 640,600L640,440Q640,374 593,327Q546,280 480,280Q414,280 367,327Q320,374 320,440L320,600Q320,666 367,713Q414,760 480,760ZM400,640L560,640L560,560L400,560L400,640ZM400,480L560,480L560,400L400,400L400,480ZM480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520ZM480,840Q415,840 359.5,808Q304,776 272,720L160,720L160,640L244,640Q241,620 240.5,600Q240,580 240,560L160,560L160,480L240,480Q240,460 240.5,440Q241,420 244,400L160,400L160,320L272,320Q286,297 303.5,277Q321,257 344,242L280,176L336,120L422,206Q450,197 479,197Q508,197 536,206L624,120L680,176L614,242Q637,257 655.5,276.5Q674,296 688,320L800,320L800,400L716,400Q719,420 719.5,440Q720,460 720,480L800,480L800,560L720,560Q720,580 719.5,600Q719,620 716,640L800,640L800,720L688,720Q656,776 600.5,808Q545,840 480,840Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M120,800L120,720L600,720L600,800L120,800ZM640,520Q557,520 498.5,461.5Q440,403 440,320Q440,237 498.5,178.5Q557,120 640,120Q723,120 781.5,178.5Q840,237 840,320Q840,403 781.5,461.5Q723,520 640,520ZM120,480L120,400L372,400Q379,422 388,442Q397,462 410,480L120,480ZM120,640L120,560L496,560Q519,574 545,583.5Q571,593 600,597L600,640L120,640ZM620,360L660,360L660,200L620,200L620,360ZM640,440Q648,440 654,434Q660,428 660,420Q660,412 654,406Q648,400 640,400Q632,400 626,406Q620,412 620,420Q620,428 626,434Q632,440 640,440Z"/>
</vector>
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M560,600Q577,600 589.5,587.5Q602,575 602,558Q602,541 589.5,528.5Q577,516 560,516Q543,516 530.5,528.5Q518,541 518,558Q518,575 530.5,587.5Q543,600 560,600ZM530,472L590,472Q590,443 596,429.5Q602,416 624,394Q654,364 664,345.5Q674,327 674,302Q674,257 642.5,228.5Q611,200 560,200Q519,200 488.5,223Q458,246 446,284L500,306Q509,281 524.5,268.5Q540,256 560,256Q584,256 599,269.5Q614,283 614,306Q614,320 606,332.5Q598,345 578,364Q545,393 537.5,409.5Q530,426 530,472ZM320,720Q287,720 263.5,696.5Q240,673 240,640L240,160Q240,127 263.5,103.5Q287,80 320,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L320,720ZM320,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640ZM160,880Q127,880 103.5,856.5Q80,833 80,800L80,240L160,240L160,800Q160,800 160,800Q160,800 160,800L720,800L720,880L160,880ZM320,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640L320,640Q320,640 320,640Q320,640 320,640L320,160Q320,160 320,160Q320,160 320,160Z"/>
</vector>
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M160,720L800,720Q800,720 800,720Q800,720 800,720L800,400L520,400L520,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720L160,720Z"/>
</vector>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/black">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:layout_gravity="center_vertical"
android:text="Please enter the captcha and close when finished" />
<Button
android:id="@+id/button_close"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginEnd="6dp"
android:text="CLOSE" />
</LinearLayout>
<WebView
android:id="@+id/web_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
@@ -69,6 +69,7 @@
android:layout_marginRight="30dp"
android:layout_height="wrap_content"
android:singleLine="true"
android:inputType="textPassword"
android:hint="Backup Password" />
<LinearLayout
@@ -51,6 +51,7 @@
android:layout_marginTop="10dp"
android:layout_marginRight="30dp"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:singleLine="true"
android:hint="Backup Password" />
@@ -35,6 +35,25 @@
android:gravity="center"
android:layout_marginTop="12dp">
<TextView
android:id="@+id/character_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
android:layout_marginStart="24dp"/>
<TextView
android:id="@+id/character_count_max"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="/2000"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular" />
<Space
android:layout_width="0dp"
android:layout_height="wrap_content"

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