Compare commits

..

16 Commits

Author SHA1 Message Date
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
42 changed files with 362 additions and 101 deletions
+3 -2
View File
@@ -4,6 +4,7 @@ variables:
stages: stages:
- buildAndDeployApkUnstable - buildAndDeployApkUnstable
- buildAndDeployApkStable - buildAndDeployApkStable
- buildAndDeployPlaystore
buildAndDeployApkUnstable: buildAndDeployApkUnstable:
stage: buildAndDeployApkUnstable stage: buildAndDeployApkUnstable
@@ -25,8 +26,8 @@ buildAndDeployApkStable:
- branches - branches
when: manual when: manual
buildAndDeployApkStable: buildAndDeployPlaystore:
stage: buildAndDeployApkStable stage: buildAndDeployPlaystore
script: script:
- sh deploy-playstore.sh - sh deploy-playstore.sh
only: only:
+1 -1
View File
@@ -95,7 +95,7 @@ android {
} }
defaultConfig { defaultConfig {
minSdk 29 minSdk 28
targetSdk 33 targetSdk 33
versionCode gitVersionCode versionCode gitVersionCode
versionName gitVersionName versionName gitVersionName
@@ -4,6 +4,7 @@ import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.webkit.CookieManager import android.webkit.CookieManager
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.* import com.futo.platformplayer.activities.*
@@ -63,7 +64,8 @@ class Settings : FragmentedStorageFileJson() {
try { try {
val i = Intent(Intent.ACTION_VIEW); val i = Intent(Intent.ACTION_VIEW);
val subject = "Feedback Grayjay"; val subject = "Feedback Grayjay";
val body = "Hey,\n\nI have some feedback on the Grayjay app.\nVersion information (version_name = ${BuildConfig.VERSION_NAME}, version_code = ${BuildConfig.VERSION_CODE}, flavor = ${BuildConfig.FLAVOR}, build_type = ${BuildConfig.BUILD_TYPE}})\n\n"; val body = "Hey,\n\nI have some feedback on the Grayjay app.\nVersion information (version_name = ${BuildConfig.VERSION_NAME}, version_code = ${BuildConfig.VERSION_CODE}, flavor = ${BuildConfig.FLAVOR}, build_type = ${BuildConfig.BUILD_TYPE}})\n" +
"Device information (brand= ${Build.BRAND}, manufacturer = ${Build.MANUFACTURER}, device = ${Build.DEVICE}, version-sdk = ${Build.VERSION.SDK_INT}, version-os = ${Build.VERSION.BASE_OS})\n\n";
val data = Uri.parse("mailto:grayjay@futo.org?subject=" + Uri.encode(subject) + "&body=" + Uri.encode(body)); val data = Uri.parse("mailto:grayjay@futo.org?subject=" + Uri.encode(subject) + "&body=" + Uri.encode(body));
i.data = data; i.data = data;
@@ -140,7 +142,11 @@ class Settings : FragmentedStorageFileJson() {
return FeedStyle.THUMBNAIL; return FeedStyle.THUMBNAIL;
} }
@FormField("Background Update", FieldForm.DROPDOWN, "Experimental background update for subscriptions cache (requires restart)", 6) @FormField("Fetch on app boot", FieldForm.TOGGLE, "Shortly after opening the app, start fetching subscriptions.", 6)
@Serializable(with = FlexibleBooleanSerializer::class)
var fetchOnAppBoot: Boolean = true;
@FormField("Background Update", FieldForm.DROPDOWN, "Experimental background update for subscriptions cache (requires restart)", 7)
@DropdownFieldOptionsId(R.array.background_interval) @DropdownFieldOptionsId(R.array.background_interval)
var subscriptionsBackgroundUpdateInterval: Int = 0; var subscriptionsBackgroundUpdateInterval: Int = 0;
@@ -156,7 +162,7 @@ class Settings : FragmentedStorageFileJson() {
}; };
@FormField("Subscription Concurrency", FieldForm.DROPDOWN, "Specify how many threads are used to fetch channels (requires restart)", 7) @FormField("Subscription Concurrency", FieldForm.DROPDOWN, "Specify how many threads are used to fetch channels (requires restart)", 8)
@DropdownFieldOptionsId(R.array.thread_count) @DropdownFieldOptionsId(R.array.thread_count)
var subscriptionConcurrency: Int = 3; var subscriptionConcurrency: Int = 3;
@@ -213,7 +219,7 @@ class Settings : FragmentedStorageFileJson() {
fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate()); fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate());
@FormField("Auto-Rotate Dead Zone", FieldForm.DROPDOWN, "Auto-rotate deadzone in degrees", 5) @FormField("Auto-Rotate Dead Zone", FieldForm.DROPDOWN, "This prevents the device from rotating within the given amount of degrees.", 5)
@DropdownFieldOptionsId(R.array.auto_rotate_dead_zone) @DropdownFieldOptionsId(R.array.auto_rotate_dead_zone)
var autoRotateDeadZone: Int = 0; var autoRotateDeadZone: Int = 0;
@@ -75,10 +75,10 @@ class AddSourceActivity : AppCompatActivity() {
_buttonInstall = findViewById(R.id.button_install); _buttonInstall = findViewById(R.id.button_install);
_buttonBack.setOnClickListener { _buttonBack.setOnClickListener {
onBackPressed(); finish();
}; };
_buttonCancel.setOnClickListener { _buttonCancel.setOnClickListener {
onBackPressed(); finish();
} }
_buttonInstall.setOnClickListener { _buttonInstall.setOnClickListener {
_config?.let { _config?.let {
@@ -1,7 +1,10 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.widget.* import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
@@ -14,6 +17,31 @@ class AddSourceOptionsActivity : AppCompatActivity() {
lateinit var _buttonQR: BigButton; lateinit var _buttonQR: BigButton;
lateinit var _buttonURL: BigButton; lateinit var _buttonURL: BigButton;
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let {
val content = it.contents
if (content == null) {
UIDialogs.toast(this, "Failed to scan QR code")
return@let
}
val url = if (content.startsWith("https://")) {
content
} else if (content.startsWith("grayjay://plugin/")) {
content.substring("grayjay://plugin/".length)
} else {
UIDialogs.toast(this, "Not a plugin URL")
return@let;
}
val intent = Intent(this, AddSourceActivity::class.java).apply {
data = Uri.parse(url);
};
startActivity(intent);
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_add_source_options); setContentView(R.layout.activity_add_source_options);
@@ -37,8 +65,9 @@ class AddSourceOptionsActivity : AppCompatActivity() {
integrator.setBeepEnabled(false) integrator.setBeepEnabled(false)
integrator.setBarcodeImageEnabled(true) integrator.setBarcodeImageEnabled(true)
integrator.setCaptureActivity(QRCaptureActivity::class.java); integrator.setCaptureActivity(QRCaptureActivity::class.java);
integrator.initiateScan() _qrCodeResultLauncher.launch(integrator.createScanIntent())
} }
_buttonURL.onClick.subscribe { _buttonURL.onClick.subscribe {
UIDialogs.toast(this, "Not implemented yet.."); UIDialogs.toast(this, "Not implemented yet..");
} }
@@ -1,6 +1,7 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
@@ -40,7 +41,8 @@ class ExceptionActivity : AppCompatActivity() {
val context = intent.getStringExtra(EXTRA_CONTEXT) ?: "Unknown Context"; val context = intent.getStringExtra(EXTRA_CONTEXT) ?: "Unknown Context";
val stack = intent.getStringExtra(EXTRA_STACK) ?: "Something went wrong... missing stack trace?"; val stack = intent.getStringExtra(EXTRA_STACK) ?: "Something went wrong... missing stack trace?";
val exceptionString = "Version information (version_name = ${BuildConfig.VERSION_NAME}, version_code = ${BuildConfig.VERSION_CODE}, flavor = ${BuildConfig.FLAVOR}, build_type = ${BuildConfig.BUILD_TYPE})\n\n" + val exceptionString = "Version information (version_name = ${BuildConfig.VERSION_NAME}, version_code = ${BuildConfig.VERSION_CODE}, flavor = ${BuildConfig.FLAVOR}, build_type = ${BuildConfig.BUILD_TYPE})\n" +
"Device information (brand= ${Build.BRAND}, manufacturer = ${Build.MANUFACTURER}, device = ${Build.DEVICE}, version-sdk = ${Build.VERSION.SDK_INT}, version-os = ${Build.VERSION.BASE_OS})\n\n" +
Logging.buildLogString(LogLevel.ERROR, TAG, "Uncaught exception (\"$context\"): $stack"); Logging.buildLogString(LogLevel.ERROR, TAG, "Uncaught exception (\"$context\"): $stack");
try { try {
val file = File(filesDir, "log.txt"); val file = File(filesDir, "log.txt");
@@ -3,7 +3,9 @@ package com.futo.platformplayer.activities
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.webkit.ConsoleMessage
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebView import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -68,9 +70,15 @@ class LoginActivity : AppCompatActivity() {
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {}); view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
} }
} }
//TODO: Required for some...TBD what to do with it. Clear on finish?
_webView.settings.domStorageEnabled = true; _webView.settings.domStorageEnabled = true;
/*
_webView.webChromeClient = object: WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
Logger.w(TAG, "Login Console: " + consoleMessage?.message());
return super.onConsoleMessage(consoleMessage);
}
}*/
_webView.webViewClient = webViewClient; _webView.webViewClient = webViewClient;
_webView.loadUrl(authConfig.loginUrl); _webView.loadUrl(authConfig.loginUrl);
} }
@@ -5,6 +5,7 @@ import android.os.Bundle
import android.widget.EditText import android.widget.EditText
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
@@ -14,6 +15,7 @@ import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.* import com.futo.polycentric.core.*
import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentIntegrator
import com.journeyapps.barcodescanner.CaptureActivity
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -27,6 +29,16 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
private lateinit var _buttonImportProfile: LinearLayout; private lateinit var _buttonImportProfile: LinearLayout;
private lateinit var _editProfile: EditText; private lateinit var _editProfile: EditText;
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let {
if (it.contents != null) {
val scannedUrl = it.contents
import(scannedUrl)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_import_profile); setContentView(R.layout.activity_polycentric_import_profile);
@@ -45,10 +57,15 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
}; };
_buttonScanProfile.setOnClickListener { _buttonScanProfile.setOnClickListener {
val integrator = IntentIntegrator(this); val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE); integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt("Scan a QR code"); integrator.setPrompt("Scan a QR code")
integrator.initiateScan(); integrator.setOrientationLocked(true);
integrator.setCameraId(0)
integrator.setBeepEnabled(false)
integrator.setBarcodeImageEnabled(true)
integrator.setCaptureActivity(QRCaptureActivity::class.java);
_qrCodeResultLauncher.launch(integrator.createScanIntent())
}; };
_buttonImportProfile.setOnClickListener { _buttonImportProfile.setOnClickListener {
@@ -66,18 +83,6 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
} }
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data)
if (result != null) {
if (result.contents != null) {
val scannedUrl = result.contents;
import(scannedUrl);
}
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
private fun import(url: String) { private fun import(url: String) {
if (!url.startsWith("polycentric://")) { if (!url.startsWith("polycentric://")) {
UIDialogs.toast(this, "Not a valid URL"); UIDialogs.toast(this, "Not a valid URL");
@@ -126,4 +131,8 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
companion object { companion object {
private const val TAG = "PolycentricImportProfileActivity"; private const val TAG = "PolycentricImportProfileActivity";
} }
class QRCaptureActivity: CaptureActivity() {
}
} }
@@ -52,17 +52,6 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
startActivity(Intent(this, DeveloperActivity::class.java)); startActivity(Intent(this, DeveloperActivity::class.java));
} }
var devCounter = 0;
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
devCounter++;
if(devCounter > 5) {
devCounter = 0;
SettingsDev.instance.developerMode = true;
SettingsDev.instance.save();
updateDevMode();
UIDialogs.toast(this, "You are now in developer mode");
}
};
_lastActivity = this; _lastActivity = this;
reloadSettings(); reloadSettings();
@@ -72,6 +61,18 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
_loader.start(); _loader.start();
_form.fromObject(lifecycleScope, Settings.instance) { _form.fromObject(lifecycleScope, Settings.instance) {
_loader.stop(); _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.api.media.platforms.js.JSClient
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
class PlatformClientPool { class PlatformClientPool {
private val _parent: JSClient; private val _parent: JSClient;
@@ -51,6 +52,11 @@ class PlatformClientPool {
if(reserved == null && _pool.size < capacity) { if(reserved == null && _pool.size < capacity) {
Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})"); Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})");
reserved = _parent.getCopy(); reserved = _parent.getCopy();
reserved?.onCaptchaException?.subscribe { client, ex ->
StateApp.instance.handleCaptchaException(client, ex);
};
reserved?.initialize(); reserved?.initialize();
_pool[reserved!!] = _poolCounter; _pool[reserved!!] = _poolCounter;
} }
@@ -27,6 +27,7 @@ class ResultCapabilities(
const val TYPE_VIDEOS = "VIDEOS"; const val TYPE_VIDEOS = "VIDEOS";
const val TYPE_STREAMS = "STREAMS"; const val TYPE_STREAMS = "STREAMS";
const val TYPE_LIVE = "LIVE"; const val TYPE_LIVE = "LIVE";
const val TYPE_POSTS = "POSTS";
const val TYPE_MIXED = "MIXED"; const val TYPE_MIXED = "MIXED";
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL"; const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
@@ -39,4 +39,8 @@ class PolycentricPlatformComment : IPlatformComment {
fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment { fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment {
return PolycentricPlatformComment(contextUrl, author, message, rating, date, reference, replyCount); return PolycentricPlatformComment(contextUrl, author, message, rating, date, reference, replyCount);
} }
companion object {
val MAX_COMMENT_SIZE = 2000
}
} }
@@ -30,7 +30,7 @@ open class SerializedPlatformNestedContent(
override val contentProvider: String?, override val contentProvider: String?,
override val contentThumbnails: Thumbnails override val contentThumbnails: Thumbnails
) : IPlatformNestedContent, SerializedPlatformContent { ) : IPlatformNestedContent, SerializedPlatformContent {
final override val contentType: ContentType get() = ContentType.MEDIA; final override val contentType: ContentType get() = ContentType.NESTED_VIDEO;
override val contentPlugin: String? = StatePlatform.instance.getContentClientOrNull(contentUrl)?.id; override val contentPlugin: String? = StatePlatform.instance.getContentClientOrNull(contentUrl)?.id;
override val contentSupported: Boolean get() = contentPlugin != null; override val contentSupported: Boolean get() = contentPlugin != null;
@@ -41,6 +41,7 @@ class SourcePluginConfig(
val constants: HashMap<String, String> = hashMapOf(), val constants: HashMap<String, String> = hashMapOf(),
//TODO: These should be vals...but prob for serialization reasons cannot be changed. //TODO: These should be vals...but prob for serialization reasons cannot be changed.
var subscriptionRateLimit: Int? = null,
var enableInSearch: Boolean = true, var enableInSearch: Boolean = true,
var enableInHome: Boolean = true, var enableInHome: Boolean = true,
var supportedClaimTypes: List<Int> = listOf() var supportedClaimTypes: List<Int> = listOf()
@@ -25,7 +25,8 @@ class DedupContentPager : IPager<IPlatformContent>, IAsyncPager<IPlatformContent
_currentResults = dedupResults(_basePager.getResults()); _currentResults = dedupResults(_basePager.getResults());
} }
override fun hasMorePages(): Boolean = _basePager.hasMorePages(); override fun hasMorePages(): Boolean =
_basePager.hasMorePages();
override fun nextPage() { override fun nextPage() {
_basePager.nextPage() _basePager.nextPage()
_currentResults = dedupResults(_basePager.getResults()); _currentResults = dedupResults(_basePager.getResults());
@@ -7,7 +7,7 @@ import java.util.stream.IntStream
* A PlatformContent MultiPager that orders the results of a page based on the datetime of a content item * A PlatformContent MultiPager that orders the results of a page based on the datetime of a content item
*/ */
class MultiChronoContentPager : MultiPager<IPlatformContent> { class MultiChronoContentPager : MultiPager<IPlatformContent> {
constructor(pagers : Array<IPager<IPlatformContent>>, allowFailure: Boolean = false) : super(pagers.map { it }.toList(), allowFailure) {} constructor(pagers : Array<IPager<IPlatformContent>>, allowFailure: Boolean = false, pageSize: Int = 9) : super(pagers.map { it }.toList(), allowFailure, pageSize) {}
@Synchronized @Synchronized
override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int { override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int {
@@ -16,7 +16,7 @@ abstract class MultiPager<T> : IPager<T> {
protected val _subSinglePagers : MutableList<SingleItemPager<T>>; protected val _subSinglePagers : MutableList<SingleItemPager<T>>;
protected val _failedPagers: ArrayList<IPager<T>> = arrayListOf(); protected val _failedPagers: ArrayList<IPager<T>> = arrayListOf();
private val _pageSize : Int = 9; private var _pageSize : Int = 9;
private var _didInitialize = false; private var _didInitialize = false;
@@ -27,7 +27,8 @@ abstract class MultiPager<T> : IPager<T> {
val totalPagers: Int get() = _pagers.size; val totalPagers: Int get() = _pagers.size;
constructor(pagers : List<IPager<T>>, allowFailure: Boolean = false) { constructor(pagers : List<IPager<T>>, allowFailure: Boolean = false, pageSize: Int = 9) {
this._pageSize = pageSize;
this.allowFailure = allowFailure; this.allowFailure = allowFailure;
_pagers = pagers.toMutableList(); _pagers = pagers.toMutableList();
_subSinglePagers = _pagers.map { SingleItemPager(it) }.toMutableList(); _subSinglePagers = _pagers.map { SingleItemPager(it) }.toMutableList();
@@ -2,12 +2,16 @@ package com.futo.platformplayer.dialogs
import android.app.AlertDialog import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.WindowManager import android.view.WindowManager
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.* import android.widget.*
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.comments.IPlatformComment
@@ -32,6 +36,8 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
private lateinit var _buttonCancel: MaterialButton; private lateinit var _buttonCancel: MaterialButton;
private lateinit var _editComment: EditText; private lateinit var _editComment: EditText;
private lateinit var _inputMethodManager: InputMethodManager; private lateinit var _inputMethodManager: InputMethodManager;
private lateinit var _textCharacterCount: TextView;
private lateinit var _textCharacterCountMax: TextView;
val onCommentAdded = Event1<IPlatformComment>(); val onCommentAdded = Event1<IPlatformComment>();
@@ -42,6 +48,26 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
_buttonCancel = findViewById(R.id.button_cancel); _buttonCancel = findViewById(R.id.button_cancel);
_buttonCreate = findViewById(R.id.button_create); _buttonCreate = findViewById(R.id.button_create);
_editComment = findViewById(R.id.edit_comment); _editComment = findViewById(R.id.edit_comment);
_textCharacterCount = findViewById(R.id.character_count);
_textCharacterCountMax = findViewById(R.id.character_count_max);
_editComment.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) = Unit
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
_textCharacterCount.text = count.toString();
if (count > PolycentricPlatformComment.MAX_COMMENT_SIZE) {
_textCharacterCount.setTextColor(Color.RED);
_textCharacterCountMax.setTextColor(Color.RED);
_buttonCreate.alpha = 0.4f;
} else {
_textCharacterCount.setTextColor(Color.WHITE);
_textCharacterCountMax.setTextColor(Color.WHITE);
_buttonCreate.alpha = 1.0f;
}
}
});
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager; _inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
@@ -53,6 +79,11 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
_buttonCreate.setOnClickListener { _buttonCreate.setOnClickListener {
clearFocus(); clearFocus();
if (_editComment.text.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) {
UIDialogs.toast(context, "Comment should be less than 5000 characters");
return@setOnClickListener;
}
val comment = _editComment.text.toString(); val comment = _editComment.text.toString();
val processHandle = StatePolycentric.instance.processHandle!! val processHandle = StatePolycentric.instance.processHandle!!
val eventPointer = processHandle.post(comment, null, ref) val eventPointer = processHandle.post(comment, null, ref)
@@ -0,0 +1,9 @@
package com.futo.platformplayer.exceptions
class RateLimitException : Throwable {
val pluginIds: List<String>;
constructor(pluginIds: List<String>): super() {
this.pluginIds = pluginIds ?: listOf();
}
}
@@ -77,7 +77,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment {
}; };
_textName?.text = channel.name; _textName?.text = channel.name;
val metadata = "${channel.subscribers.toHumanNumber()} subscribers"; val metadata = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} subscribers" else "";
_textMetadata?.text = metadata; _textMetadata?.text = metadata;
_lastChannel = channel; _lastChannel = channel;
setLinks(channel.links, channel.name); setLinks(channel.links, channel.name);
@@ -361,7 +361,7 @@ class ChannelFragment : MainFragment() {
_buttonSubscribe.setSubscribeChannel(channel); _buttonSubscribe.setSubscribeChannel(channel);
_textChannel.text = channel.name; _textChannel.text = channel.name;
_textChannelSub.text = "${channel.subscribers.toHumanNumber()} subscribers"; _textChannelSub.text = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} subscribers" else "";
_creatorThumbnail.setThumbnail(channel.thumbnail, true); _creatorThumbnail.setThumbnail(channel.thumbnail, true);
Glide.with(_imageBanner) Glide.with(_imageBanner)
@@ -346,24 +346,24 @@ class PostDetailFragment : MainFragment {
_rating.visibility = VISIBLE; _rating.visibility = VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked); _rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { processHandle, newHasLiked, newHasDisliked -> _rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (newHasLiked) { if (args.hasLiked) {
processHandle.opinion(ref, Opinion.like); args.processHandle.opinion(ref, Opinion.like);
} else if (newHasDisliked) { } else if (args.hasDisliked) {
processHandle.opinion(ref, Opinion.dislike); args.processHandle.opinion(ref, Opinion.dislike);
} else { } else {
processHandle.opinion(ref, Opinion.neutral); args.processHandle.opinion(ref, Opinion.neutral);
} }
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
processHandle.fullyBackfillServers(); args.processHandle.fullyBackfillServers();
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e) Logger.e(TAG, "Failed to backfill servers", e)
} }
} }
StatePolycentric.instance.updateLikeMap(ref, newHasLiked, newHasDisliked) StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked)
}; };
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -12,13 +12,16 @@ import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.cache.ChannelContentCache import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.exceptions.RateLimitException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson import com.futo.platformplayer.stores.FragmentedStorageFileJson
@@ -31,6 +34,7 @@ import com.futo.platformplayer.views.subscriptions.SubscriptionBar
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -160,8 +164,17 @@ class SubscriptionsFeedFragment : MainFragment() {
private val _filterLock = Object(); private val _filterLock = Object();
private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter"); private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter");
private var _bypassRateLimit = false;
private val _lastExceptions: List<Throwable>? = null; private val _lastExceptions: List<Throwable>? = null;
private val _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({StateApp.instance.scope}, { withRefresh -> private val _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({StateApp.instance.scope}, { withRefresh ->
if(!_bypassRateLimit) {
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.config.subscriptionRateLimit}" }.joinToString("\n");
val rateLimitPlugins = subRequestCounts.filter { clientCount -> clientCount.key.config.subscriptionRateLimit?.let { rateLimit -> clientCount.value > rateLimit } == true }
Logger.w(TAG, "Refreshing subscriptions with requests:\n" + reqCountStr);
if(rateLimitPlugins.any())
throw RateLimitException(rateLimitPlugins.map { it.key.id });
}
val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh); val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh);
val currentExs = StateSubscriptions.instance.globalSubscriptionExceptions; val currentExs = StateSubscriptions.instance.globalSubscriptionExceptions;
@@ -171,6 +184,29 @@ class SubscriptionsFeedFragment : MainFragment() {
return@TaskHandler resp; return@TaskHandler resp;
}) })
.success { loadedResult(it); } .success { loadedResult(it); }
.exception<RateLimitException> {
fragment.lifecycleScope.launch(Dispatchers.IO) {
val subs = StateSubscriptions.instance.getSubscriptions();
val subsByLimited = it.pluginIds.map{ StatePlatform.instance.getClientOrNull(it) }
.filterIsInstance<JSClient>()
.associateWith { client -> subs.filter { it.getClient() == client } }
.map { Pair(it.key, it.value) }
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_security_pred,
"Rate Limit Warning", "This is a temporary measure to prevent people from hitting rate limit until we have better support for lots of subscriptions." +
"\n\nYou have too many subscriptions for the following plugins:\n",
subsByLimited.map { "${it.first.config.name}: ${it.second.size} Subscriptions" } .joinToString("\n"), 0, UIDialogs.Action("Refresh Anyway", {
_bypassRateLimit = true;
loadResults();
}, UIDialogs.ActionStyle.DANGEROUS_TEXT),
UIDialogs.Action("OK", {
finishRefreshLayoutLoader();
setLoading(false);
}, UIDialogs.ActionStyle.PRIMARY));
}
}
}
.exception<Throwable> { .exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load channel.", it); Logger.w(ChannelFragment.TAG, "Failed to load channel.", it);
if(it !is CancellationException) if(it !is CancellationException)
@@ -1042,24 +1042,24 @@ class VideoDetailView : ConstraintLayout {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_rating.visibility = View.VISIBLE; _rating.visibility = View.VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked); _rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { processHandle, newHasLiked, newHasDisliked -> _rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (newHasLiked) { if (args.hasLiked) {
processHandle.opinion(ref, Opinion.like); args.processHandle.opinion(ref, Opinion.like);
} else if (newHasDisliked) { } else if (args.hasDisliked) {
processHandle.opinion(ref, Opinion.dislike); args.processHandle.opinion(ref, Opinion.dislike);
} else { } else {
processHandle.opinion(ref, Opinion.neutral); args.processHandle.opinion(ref, Opinion.neutral);
} }
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
processHandle.fullyBackfillServers(); args.processHandle.fullyBackfillServers();
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e) Logger.e(TAG, "Failed to backfill servers", e)
} }
} }
StatePolycentric.instance.updateLikeMap(ref, newHasLiked, newHasDisliked) StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked)
}; };
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -1610,10 +1610,10 @@ class VideoDetailView : ConstraintLayout {
_lastSubtitleSource = toSet; _lastSubtitleSource = toSet;
} }
private fun handleUnavailableVideo() { private fun handleUnavailableVideo(msg: String? = null) {
if (!nextVideo()) { if (!nextVideo()) {
if(video?.datetime == null || video?.datetime!! < OffsetDateTime.now().minusHours(1)) if(video?.datetime == null || video?.datetime!! < OffsetDateTime.now().minusHours(1))
UIDialogs.showDialog(context, R.drawable.ic_lock, "Unavailable video", "This video is unavailable.", null, 0, UIDialogs.showDialog(context, R.drawable.ic_lock, "Unavailable video", msg ?: "This video is unavailable.", null, 0,
UIDialogs.Action("Back", { UIDialogs.Action("Back", {
this@VideoDetailView.onClose.emit(); this@VideoDetailView.onClose.emit();
}, UIDialogs.ActionStyle.PRIMARY)); }, UIDialogs.ActionStyle.PRIMARY));
@@ -2092,7 +2092,7 @@ class VideoDetailView : ConstraintLayout {
} }
.exception<ScriptUnavailableException> { .exception<ScriptUnavailableException> {
Logger.w(TAG, "exception<ScriptUnavailableException>", it); Logger.w(TAG, "exception<ScriptUnavailableException>", it);
handleUnavailableVideo(); handleUnavailableVideo(it.message);
} }
.exception<ScriptException> { .exception<ScriptException> {
Logger.w(TAG, "exception<ScriptException>", it) Logger.w(TAG, "exception<ScriptException>", it)
@@ -3,24 +3,48 @@ package com.futo.platformplayer.models
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.channels.SerializedChannel import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.getNowDiffDays
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.states.StatePlatform
import java.time.OffsetDateTime import java.time.OffsetDateTime
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
class Subscription { class Subscription {
var channel: SerializedChannel; var channel: SerializedChannel;
//Last found content
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastVideo : OffsetDateTime = OffsetDateTime.MAX; var lastVideo : OffsetDateTime = OffsetDateTime.MAX;
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastLiveStream : OffsetDateTime = OffsetDateTime.MAX; var lastLiveStream : OffsetDateTime = OffsetDateTime.MAX;
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastPost : OffsetDateTime = OffsetDateTime.MAX;
//Last update time
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastVideoUpdate : OffsetDateTime = OffsetDateTime.MIN;
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastStreamUpdate : OffsetDateTime = OffsetDateTime.MIN;
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastLiveStreamUpdate : OffsetDateTime = OffsetDateTime.MIN;
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
var lastPostUpdate : OffsetDateTime = OffsetDateTime.MIN;
//Last video interval
var uploadInterval : Int = 0; var uploadInterval : Int = 0;
var uploadPostInterval : Int = 0;
constructor(channel : SerializedChannel) { constructor(channel : SerializedChannel) {
this.channel = channel; this.channel = channel;
} }
fun shouldFetchStreams() = lastLiveStream.getNowDiffDays() < 7;
fun shouldFetchLiveStreams() = lastLiveStream.getNowDiffDays() < 14;
fun shouldFetchPosts() = lastPost.getNowDiffDays() < 2;
fun getClient() = StatePlatform.instance.getChannelClientOrNull(channel.url);
fun updateChannel(channel: IPlatformChannel) { fun updateChannel(channel: IPlatformChannel) {
this.channel = SerializedChannel.fromChannel(channel); this.channel = SerializedChannel.fromChannel(channel);
} }
@@ -9,7 +9,6 @@ data class Telemetry(
val buildType: String, val buildType: String,
val debug: Boolean, val debug: Boolean,
val isUnstableBuild: Boolean, val isUnstableBuild: Boolean,
val time: Long,
val brand: String, val brand: String,
val manufacturer: String, val manufacturer: String,
val model: String val model: String
@@ -12,6 +12,7 @@ import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.matchesDomain import com.futo.platformplayer.matchesDomain
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class LoginWebViewClient : WebViewClient { class LoginWebViewClient : WebViewClient {
private val LOG_VERBOSE = false; private val LOG_VERBOSE = false;
@@ -20,10 +20,10 @@ class PlatformContentSerializer() : JsonContentPolymorphicSerializer<SerializedP
if(obj?.jsonPrimitive?.isString ?: true) if(obj?.jsonPrimitive?.isString ?: true)
return when(obj?.jsonPrimitive?.contentOrNull) { return when(obj?.jsonPrimitive?.contentOrNull) {
"MEDIA" -> SerializedPlatformVideo.serializer(); "MEDIA" -> SerializedPlatformVideo.serializer();
"NESTED" -> SerializedPlatformNestedContent.serializer(); "NESTED_VIDEO" -> SerializedPlatformNestedContent.serializer();
"ARTICLE" -> throw NotImplementedError("Articles not yet implemented"); "ARTICLE" -> throw NotImplementedError("Articles not yet implemented");
"POST" -> throw NotImplementedError("Post not yet implemented"); "POST" -> throw NotImplementedError("Post not yet implemented");
else -> throw NotImplementedError("Unknown Content Type Value: ${obj?.jsonPrimitive?.int}") else -> throw NotImplementedError("Unknown Content Type Value: ${obj?.jsonPrimitive?.contentOrNull}")
}; };
else else
return when(obj?.jsonPrimitive?.int) { return when(obj?.jsonPrimitive?.int) {
@@ -28,6 +28,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.activities.CaptchaActivity import com.futo.platformplayer.activities.CaptchaActivity
import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.background.BackgroundWorker import com.futo.platformplayer.background.BackgroundWorker
@@ -44,7 +45,10 @@ import com.futo.platformplayer.receivers.AudioNoisyReceiver
import com.futo.platformplayer.services.DownloadService import com.futo.platformplayer.services.DownloadService
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
import com.stripe.android.core.utils.encodeToJson
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File import java.io.File
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.* import java.util.*
@@ -91,7 +95,7 @@ class StateApp {
onChanged?.invoke(getExternalGeneralDirectory(context)); onChanged?.invoke(getExternalGeneralDirectory(context));
} }
else else
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { scopeOrNull?.launch(Dispatchers.Main) {
UIDialogs.toast("Failed to gain access to\n [${it?.lastPathSegment}]"); UIDialogs.toast("Failed to gain access to\n [${it?.lastPathSegment}]");
}; };
}; };
@@ -103,10 +107,14 @@ class StateApp {
return null; return null;
} }
fun changeExternalDownloadDirectory(context: IWithResultLauncher, onChanged: ((DocumentFile?)->Unit)? = 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) if(context is Context)
requestDirectoryAccess(context, "Download Exports", "This directory is used to export downloads to for external usage.", null) { requestDirectoryAccess(context, "Download Exports", "This directory is used to export downloads to for external usage.", null) {
if(it != 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)) { if(it != null && isValidStorageUri(context, it)) {
Logger.i(TAG, "Changed external download directory: ${it}"); Logger.i(TAG, "Changed external download directory: ${it}");
Settings.instance.storage.storage_general = it.toString(); Settings.instance.storage.storage_general = it.toString();
@@ -425,9 +433,19 @@ class StateApp {
StatePlaylists.instance.toMigrateCheck() StatePlaylists.instance.toMigrateCheck()
).flatten(), 0); ).flatten(), 0);
scope.launch { if(Settings.instance.subscriptions.fetchOnAppBoot) {
delay(5000); scope.launch(Dispatchers.IO) {
StateSubscriptions.instance.updateSubscriptionFeed(scope, false); val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.config.subscriptionRateLimit}" }.joinToString("\n");
val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.config.subscriptionRateLimit?.let { rateLimit -> clientCount.value > rateLimit } == true };
if (isRateLimitReached) {
Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}");
delay(5000);
StateSubscriptions.instance.updateSubscriptionFeed(scope, false);
}
else
Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}");
}
} }
val interval = Settings.instance.subscriptions.getSubscriptionsBackgroundIntervalMinutes(); val interval = Settings.instance.subscriptions.getSubscriptionsBackgroundIntervalMinutes();
@@ -664,19 +664,24 @@ class StatePlatform {
toQuery.add(ResultCapabilities.TYPE_STREAMS); toQuery.add(ResultCapabilities.TYPE_STREAMS);
if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE)) if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE))
toQuery.add(ResultCapabilities.TYPE_LIVE); toQuery.add(ResultCapabilities.TYPE_LIVE);
if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS))
toQuery.add(ResultCapabilities.TYPE_POSTS);
if(isSubscriptionOptimized) { if(isSubscriptionOptimized) {
val sub = StateSubscriptions.instance.getSubscription(channelUrl); val sub = StateSubscriptions.instance.getSubscription(channelUrl);
if(sub != null) { if(sub != null) {
val daysSinceLiveStream = sub.lastLiveStream.getNowDiffDays() if(!sub.shouldFetchStreams()) {
if(daysSinceLiveStream > 7) { Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 7 days, skipping live streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 7 days, skipping live streams [${daysSinceLiveStream} days ago]");
toQuery.remove(ResultCapabilities.TYPE_LIVE); toQuery.remove(ResultCapabilities.TYPE_LIVE);
} }
if(daysSinceLiveStream > 14) { if(!sub.shouldFetchLiveStreams()) {
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${daysSinceLiveStream} days ago]"); Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
toQuery.remove(ResultCapabilities.TYPE_STREAMS); toQuery.remove(ResultCapabilities.TYPE_STREAMS);
} }
if(!sub.shouldFetchPosts()) {
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 5 days, skipping posts [${sub.lastPost.getNowDiffDays()} days ago]");
toQuery.remove(ResultCapabilities.TYPE_POSTS);
}
} }
} }
@@ -302,7 +302,7 @@ class StatePolycentric {
thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(PolycentricCache.SERVER)) }, thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(PolycentricCache.SERVER)) },
subscribers = null subscribers = null
), ),
msg = post.content, msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content,
rating = RatingLikeDislikes(likes, dislikes), rating = RatingLikeDislikes(likes, dislikes),
date = if (unixMilliseconds != null) Instant.ofEpochMilli(unixMilliseconds).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN, date = if (unixMilliseconds != null) Instant.ofEpochMilli(unixMilliseconds).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN,
replyCount = replies.toInt(), replyCount = replies.toInt(),
@@ -2,6 +2,7 @@ package com.futo.platformplayer.states
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.channels.SerializedChannel import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
@@ -18,6 +19,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.findNonRuntimeException import com.futo.platformplayer.findNonRuntimeException
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.getNowDiffDays
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricCache
@@ -219,13 +221,38 @@ class StateSubscriptions {
} }
} }
fun getSubscriptionsFeed(allowFailure: Boolean = false): MultiChronoContentPager { fun getSubscriptionRequestCount(): Map<JSClient, Int> {
val subs = getSubscriptions();
val pluginReqCounts = mutableMapOf<JSClient, Int>();
for(sub in subs) {
val client = StatePlatform.instance.getChannelClientOrNull(sub.channel.url);
if(client !is JSClient)
continue;
val channelCaps = client.getChannelCapabilities();
if(!pluginReqCounts.containsKey(client))
pluginReqCounts[client] = 1;
else
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
if(channelCaps.hasType(ResultCapabilities.TYPE_STREAMS) && sub.shouldFetchStreams())
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
if(channelCaps.hasType(ResultCapabilities.TYPE_LIVE) && sub.shouldFetchLiveStreams())
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
if(channelCaps.hasType(ResultCapabilities.TYPE_POSTS) && sub.shouldFetchPosts())
pluginReqCounts[client] = pluginReqCounts[client]!! + 1;
}
return pluginReqCounts;
}
fun getSubscriptionsFeed(allowFailure: Boolean = false): IPager<IPlatformContent> {
val result = getSubscriptionsFeedWithExceptions(allowFailure, true); val result = getSubscriptionsFeedWithExceptions(allowFailure, true);
if(result.second.any()) if(result.second.any())
throw result.second.first(); throw result.second.first();
return result.first; return result.first;
} }
fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope? = null, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null): Pair<MultiChronoContentPager, List<Throwable>> { fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope? = null, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null): Pair<IPager<IPlatformContent>, List<Throwable>> {
val subsPager: Array<IPager<IPlatformContent>>; val subsPager: Array<IPager<IPlatformContent>>;
val exs: ArrayList<Throwable> = arrayListOf(); val exs: ArrayList<Throwable> = arrayListOf();
@@ -343,9 +370,10 @@ class StateSubscriptions {
throw exs.first(); throw exs.first();
Logger.i(TAG, "Subscription pager with ${subsPager.size} channels"); Logger.i(TAG, "Subscription pager with ${subsPager.size} channels");
val pager = MultiChronoContentPager(subsPager, allowFailure); val pager = MultiChronoContentPager(subsPager, allowFailure, 15);
pager.initialize(); pager.initialize();
return Pair(pager, exs); //return Pair(pager, exs);
return Pair(DedupContentPager(pager), exs);
} }
//New Migration //New Migration
@@ -37,14 +37,13 @@ class StateTelemetry {
BuildConfig.BUILD_TYPE, BuildConfig.BUILD_TYPE,
BuildConfig.DEBUG, BuildConfig.DEBUG,
BuildConfig.IS_UNSTABLE_BUILD, BuildConfig.IS_UNSTABLE_BUILD,
Instant.now().epochSecond,
Build.BRAND, Build.BRAND,
Build.MANUFACTURER, Build.MANUFACTURER,
Build.MODEL Build.MODEL
); );
val headers = hashMapOf( val headers = hashMapOf(
"Content-Type" to "text/plain" "Content-Type" to "application/json"
); );
val json = Json.encodeToString(telemetry); val json = Json.encodeToString(telemetry);
@@ -6,6 +6,7 @@ import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.comments.IPlatformComment
@@ -35,12 +36,14 @@ class CommentViewHolder : ViewHolder {
private val _buttonReplies: PillButton; private val _buttonReplies: PillButton;
private val _layoutRating: LinearLayout; private val _layoutRating: LinearLayout;
private val _pillRatingLikesDislikes: PillRatingLikesDislikes; private val _pillRatingLikesDislikes: PillRatingLikesDislikes;
private val _layoutComment: ConstraintLayout;
var onClick = Event1<IPlatformComment>(); var onClick = Event1<IPlatformComment>();
var comment: IPlatformComment? = null var comment: IPlatformComment? = null
private set; private set;
constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_comment, viewGroup, false)) { 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); _creatorThumbnail = itemView.findViewById(R.id.image_thumbnail);
_textAuthor = itemView.findViewById(R.id.text_author); _textAuthor = itemView.findViewById(R.id.text_author);
_textMetadata = itemView.findViewById(R.id.text_metadata); _textMetadata = itemView.findViewById(R.id.text_metadata);
@@ -53,29 +56,31 @@ class CommentViewHolder : ViewHolder {
_layoutRating = itemView.findViewById(R.id.layout_rating); _layoutRating = itemView.findViewById(R.id.layout_rating);
_pillRatingLikesDislikes = itemView.findViewById(R.id.rating); _pillRatingLikesDislikes = itemView.findViewById(R.id.rating);
_pillRatingLikesDislikes.onLikeDislikeUpdated.subscribe { processHandle, hasLiked, hasDisliked -> _pillRatingLikesDislikes.onLikeDislikeUpdated.subscribe { args ->
val c = comment val c = comment
if (c !is PolycentricPlatformComment) { if (c !is PolycentricPlatformComment) {
throw Exception("Not implemented for non polycentric comments") throw Exception("Not implemented for non polycentric comments")
} }
if (hasLiked) { if (args.hasLiked) {
processHandle.opinion(c.reference, Opinion.like); args.processHandle.opinion(c.reference, Opinion.like);
} else if (hasDisliked) { } else if (args.hasDisliked) {
processHandle.opinion(c.reference, Opinion.dislike); args.processHandle.opinion(c.reference, Opinion.dislike);
} else { } else {
processHandle.opinion(c.reference, Opinion.neutral); args.processHandle.opinion(c.reference, Opinion.neutral);
} }
_layoutComment.alpha = if (args.dislikes > 2 && args.dislikes / (args.likes + args.dislikes) >= 0.7) 0.5f else 1.0f;
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
processHandle.fullyBackfillServers(); args.processHandle.fullyBackfillServers();
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers.", e) Logger.e(TAG, "Failed to backfill servers.", e)
} }
} }
StatePolycentric.instance.updateLikeMap(c.reference, hasLiked, hasDisliked) StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked)
}; };
_buttonReplies.onClick.subscribe { _buttonReplies.onClick.subscribe {
@@ -98,6 +103,13 @@ class CommentViewHolder : ViewHolder {
_textMetadata.visibility = View.GONE; _textMetadata.visibility = View.GONE;
} }
val rating = comment.rating;
if (rating is RatingLikeDislikes) {
_layoutComment.alpha = if (rating.dislikes > 0 && rating.dislikes / (rating.likes + rating.dislikes) >= 0.7) 0.5f else 1.0f;
} else {
_layoutComment.alpha = 1.0f;
}
_textBody.text = comment.message.fixHtmlLinks(); _textBody.text = comment.message.fixHtmlLinks();
if (readonly) { if (readonly) {
@@ -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.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.models.ratings.RatingLikes 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.constructs.Event3
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.toHumanNumber
import com.futo.polycentric.core.ProcessHandle 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 { class PillRatingLikesDislikes : LinearLayout {
private val _textLikes: TextView; private val _textLikes: TextView;
private val _textDislikes: TextView; private val _textDislikes: TextView;
@@ -29,7 +38,7 @@ class PillRatingLikesDislikes : LinearLayout {
private var _dislikes = 0L; private var _dislikes = 0L;
private var _hasDisliked = false; private var _hasDisliked = false;
val onLikeDislikeUpdated = Event3<ProcessHandle, Boolean, Boolean>(); val onLikeDislikeUpdated = Event1<OnLikeDislikeUpdatedArgs>();
constructor(context : Context, attrs : AttributeSet?) : super(context, attrs) { constructor(context : Context, attrs : AttributeSet?) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.rating_likesdislikes, this, true); LayoutInflater.from(context).inflate(R.layout.rating_likesdislikes, this, true);
@@ -76,7 +85,7 @@ class PillRatingLikesDislikes : LinearLayout {
_textLikes.text = _likes.toHumanNumber(); _textLikes.text = _likes.toHumanNumber();
updateColors(); updateColors();
onLikeDislikeUpdated.emit(processHandle, _hasLiked, _hasDisliked); onLikeDislikeUpdated.emit(OnLikeDislikeUpdatedArgs(processHandle, _likes, _hasLiked, _dislikes, _hasDisliked));
} }
fun dislike(processHandle: ProcessHandle) { fun dislike(processHandle: ProcessHandle) {
@@ -96,7 +105,7 @@ class PillRatingLikesDislikes : LinearLayout {
_textDislikes.text = _dislikes.toHumanNumber(); _textDislikes.text = _dislikes.toHumanNumber();
updateColors(); updateColors();
onLikeDislikeUpdated.emit(processHandle, _hasLiked, _hasDisliked); onLikeDislikeUpdated.emit(OnLikeDislikeUpdatedArgs(processHandle, _likes, _hasLiked, _dislikes, _hasDisliked));
} }
private fun updateColors() { private fun updateColors() {
@@ -35,6 +35,25 @@
android:gravity="center" android:gravity="center"
android:layout_marginTop="12dp"> 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 <Space
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
+1
View File
@@ -62,6 +62,7 @@
android:isScrollContainer="false" android:isScrollContainer="false"
android:textColor="#CCCCCC" android:textColor="#CCCCCC"
android:textSize="13sp" android:textSize="13sp"
android:maxLines="100"
app:layout_constraintTop_toBottomOf="@id/text_metadata" app:layout_constraintTop_toBottomOf="@id/text_metadata"
app:layout_constraintLeft_toRightOf="@id/image_thumbnail" app:layout_constraintLeft_toRightOf="@id/image_thumbnail"
app:layout_constraintRight_toRightOf="parent" app:layout_constraintRight_toRightOf="parent"