diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index 5e73638c..519c9c19 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -6,6 +6,7 @@ import android.content.Context import android.content.Intent import android.graphics.Color import android.net.Uri +import android.text.Layout import android.text.method.ScrollingMovementMethod import android.util.TypedValue import android.view.Gravity @@ -198,7 +199,6 @@ class UIDialogs { dialog.show(); } - fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action) { val builder = AlertDialog.Builder(context); val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null); @@ -214,18 +214,20 @@ class UIDialogs { this.text = text; }; view.findViewById(R.id.dialog_text_details).apply { - if(textDetails == null) + if (textDetails == null) this.visibility = View.GONE; - else + else { this.text = textDetails; + this.textAlignment = View.TEXT_ALIGNMENT_VIEW_START + } }; view.findViewById(R.id.dialog_text_code).apply { - if(code == null) - this.visibility = View.GONE; + if (code == null) this.visibility = View.GONE; else { this.text = code; this.movementMethod = ScrollingMovementMethod.getInstance(); this.visibility = View.VISIBLE; + this.textAlignment = View.TEXT_ALIGNMENT_VIEW_START } }; view.findViewById(R.id.dialog_buttons).apply { diff --git a/app/src/main/java/com/futo/platformplayer/Utility.kt b/app/src/main/java/com/futo/platformplayer/Utility.kt index a3b7464a..64efb992 100644 --- a/app/src/main/java/com/futo/platformplayer/Utility.kt +++ b/app/src/main/java/com/futo/platformplayer/Utility.kt @@ -32,6 +32,7 @@ import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.nio.ByteBuffer +import java.nio.ByteOrder import java.util.* import java.util.concurrent.ThreadLocalRandom @@ -271,4 +272,10 @@ fun findNewIndex(originalArr: List, newArr: List, item: T): Int{ return originalArr.size; else return newIndex; -} \ No newline at end of file +} + +fun ByteBuffer.toUtf8String(): String { + val remainingBytes = ByteArray(remaining()) + get(remainingBytes) + return String(remainingBytes, Charsets.UTF_8) +} diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 3f6c38bd..76b06525 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -7,7 +7,6 @@ import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.pm.PackageManager import android.content.res.Configuration import android.net.Uri -import android.net.wifi.WifiManager import android.os.Bundle import android.os.StrictMode import android.os.StrictMode.VmPolicy @@ -33,6 +32,7 @@ import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment @@ -81,6 +81,7 @@ import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.stores.SubscriptionStorage import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.views.ToastView @@ -88,11 +89,14 @@ import com.futo.polycentric.core.ApiMethods import com.google.gson.JsonParser import com.google.zxing.integration.android.IntentIntegrator import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.File import java.io.PrintWriter @@ -102,7 +106,6 @@ import java.util.LinkedList import java.util.Queue import java.util.concurrent.ConcurrentLinkedQueue - class MainActivity : AppCompatActivity, IWithResultLauncher { //TODO: Move to dimensions @@ -110,7 +113,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { private val HEIGHT_VIDEO_MINIMIZED_DP = 60f; //Containers - lateinit var rootView : MotionLayout; + lateinit var rootView: MotionLayout; private lateinit var _overlayContainer: FrameLayout; private lateinit var _toastView: ToastView; @@ -167,11 +170,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { lateinit var _fragVideoDetail: VideoDetailFragment; //State - private val _queue : Queue> = LinkedList(); - lateinit var fragCurrent : MainFragment private set; + private val _queue: Queue> = LinkedList(); + lateinit var fragCurrent: MainFragment private set; private var _parameterCurrent: Any? = null; - var fragBeforeOverlay : MainFragment? = null; private set; + var fragBeforeOverlay: MainFragment? = null; private set; val onNavigated = Event1(); @@ -217,15 +220,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { Logger.e("Application", "Uncaught", excp); //Resolve invocation chains - while(excp is InvocationTargetException || excp is java.lang.RuntimeException) { + while (excp is InvocationTargetException || excp is java.lang.RuntimeException) { val before = excp; - if(excp is InvocationTargetException) + if (excp is InvocationTargetException) excp = excp.targetException ?: excp.cause ?: excp; - else if(excp is java.lang.RuntimeException) + else if (excp is java.lang.RuntimeException) excp = excp.cause ?: excp; - if(excp == before) + if (excp == before) break; } writer.write((excp.message ?: "Empty error") + "\n\n"); @@ -256,7 +259,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { setContentView(R.layout.activity_main); setNavigationBarColorAndIcons(); if (Settings.instance.playback.allowVideoToGoUnderCutout) - window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + window.attributes.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES runBlocking { StatePlatform.instance.updateAvailableClients(this@MainActivity); @@ -330,10 +334,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { updateSegmentPaddings(); }; _fragVideoDetail.onTransitioning.subscribe { - if(it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED) - _fragContainerOverlay.elevation = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics); + if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED) + _fragContainerOverlay.elevation = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics); else - _fragContainerOverlay.elevation = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics); + _fragContainerOverlay.elevation = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics); } _fragVideoDetail.onCloseEvent.subscribe { @@ -350,40 +356,39 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _buttonIncognito.alpha = 0f; StateApp.instance.privateModeChanged.subscribe { //Messing with visibility causes some issues with layout ordering? - if(it) { + if (it) { _buttonIncognito.elevation = 99f; _buttonIncognito.alpha = 1f; - } - else { + } else { _buttonIncognito.elevation = -99f; _buttonIncognito.alpha = 0f; } } _buttonIncognito.setOnClickListener { - if(!StateApp.instance.privateMode) + if (!StateApp.instance.privateMode) return@setOnClickListener; - UIDialogs.showDialog(this, R.drawable.ic_disabled_visible_purple, "Disable Privacy Mode", + UIDialogs.showDialog( + this, R.drawable.ic_disabled_visible_purple, "Disable Privacy Mode", "Do you want to disable privacy mode? New videos will be tracked again.", null, 0, UIDialogs.Action("Cancel", { StateApp.instance.setPrivacyMode(true); }, UIDialogs.ActionStyle.NONE), UIDialogs.Action("Disable", { StateApp.instance.setPrivacyMode(false); - }, UIDialogs.ActionStyle.DANGEROUS)); + }, UIDialogs.ActionStyle.DANGEROUS) + ); }; _fragVideoDetail.onFullscreenChanged.subscribe { Logger.i(TAG, "onFullscreenChanged ${it}"); - if(it) { + if (it) { _buttonIncognito.elevation = -99f; _buttonIncognito.alpha = 0f; - } - else { - if(StateApp.instance.privateMode) { + } else { + if (StateApp.instance.privateMode) { _buttonIncognito.elevation = 99f; _buttonIncognito.alpha = 1f; - } - else { + } else { _buttonIncognito.elevation = -99f; _buttonIncognito.alpha = 0f; } @@ -396,7 +401,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { return@subscribe; } - if(_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) { + if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) { if (fragCurrent !is VideoDetailFragment) { val toPlay = StatePlayer.instance.getCurrentQueueItem(); navigate(_fragVideoDetail, toPlay); @@ -444,11 +449,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragSubGroupList.topBar = _fragTopBarAdd; _fragBrowser.topBar = _fragTopBarNavigation; - + fragCurrent = _fragMainHome; val defaultTab = Settings.instance.tabs.mapNotNull { - val buttonDefinition = MenuBottomBarFragment.buttonDefinitions.firstOrNull { bd -> it.id == bd.id }; + val buttonDefinition = + MenuBottomBarFragment.buttonDefinitions.firstOrNull { bd -> it.id == bd.id }; if (buttonDefinition == null) { return@mapNotNull null; } else { @@ -507,7 +513,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { //startActivity(Intent(this, TestActivity::class.java)); - val sharedPreferences = getSharedPreferences("GrayjayFirstBoot", Context.MODE_PRIVATE) + val sharedPreferences = + getSharedPreferences("GrayjayFirstBoot", Context.MODE_PRIVATE) val isFirstBoot = sharedPreferences.getBoolean("IsFirstBoot", true) if (isFirstBoot) { UIDialogs.showConfirmationDialog(this, getString(R.string.do_you_want_to_see_the_tutorials_you_can_find_them_at_any_time_through_the_more_button), { @@ -516,6 +523,64 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply() } + + val submissionStatus = FragmentedStorage.get("subscriptionSubmissionStatus") + + val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount() + + val subscriptionsThreshold = 20 + + if ( + submissionStatus.value == "" + && StateApp.instance.getCurrentNetworkState() != StateApp.NetworkState.DISCONNECTED + && numSubscriptions >= subscriptionsThreshold + ) { + + UIDialogs.showDialog( + this, + R.drawable.ic_internet, + getString(R.string.contribute_personal_subscriptions_list), + getString(R.string.contribute_personal_subscriptions_list_description), + null, + 0, + UIDialogs.Action("Cancel", { + submissionStatus.setAndSave("dismissed") + }, UIDialogs.ActionStyle.NONE), + UIDialogs.Action("Upload", { + submissionStatus.setAndSave("submitted") + + GlobalScope.launch(Dispatchers.IO) { + @Serializable + data class CreatorInfo(val pluginId: String, val url: String) + + val subscriptions = + StateSubscriptions.instance.getSubscriptions().map { original -> + CreatorInfo( + pluginId = original.channel.id.pluginId ?: "", + url = original.channel.url + ) + } + + val json = Json.encodeToString(subscriptions) + + val url = "https://data.grayjay.app/donate-subscription-list" + val client = ManagedHttpClient(); + val headers = hashMapOf( + "Content-Type" to "application/json" + ) + try { + val response = client.post(url, json, headers) + // if it failed retry one time + if (!response.isOk) { + client.post(url, json, headers) + } + } catch (e: Exception) { + Logger.i(TAG, "Failed to submit subscription list.", e) + } + } + }, UIDialogs.ActionStyle.PRIMARY) + ) + } } /* @@ -580,39 +645,45 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { } private fun handleIntent(intent: Intent?) { - if(intent == null) + if (intent == null) return; Logger.i(TAG, "handleIntent started by " + intent.action); var targetData: String? = null; - when(intent.action) { + when (intent.action) { Intent.ACTION_SEND -> { - targetData = intent.getStringExtra(Intent.EXTRA_STREAM) ?: intent.getStringExtra(Intent.EXTRA_TEXT); + targetData = intent.getStringExtra(Intent.EXTRA_STREAM) + ?: intent.getStringExtra(Intent.EXTRA_TEXT); Logger.i(TAG, "Share Received: " + targetData); } + Intent.ACTION_VIEW -> { targetData = intent.dataString - if(!targetData.isNullOrEmpty()) { + if (!targetData.isNullOrEmpty()) { Logger.i(TAG, "View Received: " + targetData); } } + "VIDEO" -> { val url = intent.getStringExtra("VIDEO"); navigate(_fragVideoDetail, url); } + "IMPORT_OPTIONS" -> { UIDialogs.showImportOptionsDialog(this); } + "ACTION" -> { val action = intent.getStringExtra("ACTION"); StateDeveloper.instance.testState = "TestPlayback"; StateDeveloper.instance.testPlayback(); } + "TAB" -> { - when(intent.getStringExtra("TAB")){ + when (intent.getStringExtra("TAB")) { "Sources" -> { runBlocking { StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed.. @@ -623,7 +694,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { navigate(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf( Pair("grayjay") { req -> StateApp.instance.contextOrNull?.let { - if(it is MainActivity) { + if (it is MainActivity) { runBlocking { it.handleUrlAll(req.url.toString()); } @@ -642,8 +713,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { handleUrlAll(targetData) } } - } - catch(ex: Throwable) { + } catch (ex: Throwable) { UIDialogs.showGeneralErrorDialog(this, getString(R.string.failed_to_handle_file), ex); } } @@ -652,35 +722,31 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { val uri = Uri.parse(url) when (uri.scheme) { "grayjay" -> { - if(url.startsWith("grayjay://license/")) { - if(StatePayment.instance.setPaymentLicenseUrl(url)) - { + if (url.startsWith("grayjay://license/")) { + if (StatePayment.instance.setPaymentLicenseUrl(url)) { UIDialogs.showDialogOk(this, R.drawable.ic_check, getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required)); - if(fragCurrent is BuyFragment) + if (fragCurrent is BuyFragment) closeSegment(fragCurrent); - } - else + } else UIDialogs.toast(getString(R.string.invalid_license_format)); - } - else if(url.startsWith("grayjay://plugin/")) { + } else if (url.startsWith("grayjay://plugin/")) { val intent = Intent(this, AddSourceActivity::class.java).apply { data = Uri.parse(url.substring("grayjay://plugin/".length)); }; startActivity(intent); - } - else if(url.startsWith("grayjay://video/")) { + } else if (url.startsWith("grayjay://video/")) { val videoUrl = url.substring("grayjay://video/".length); navigate(_fragVideoDetail, videoUrl); - } - else if(url.startsWith("grayjay://channel/")) { + } else if (url.startsWith("grayjay://channel/")) { val channelUrl = url.substring("grayjay://channel/".length); navigate(_fragMainChannel, channelUrl); } } + "content" -> { - if(!handleContent(url, intent.type)) { + if (!handleContent(url, intent.type)) { UIDialogs.showSingleButtonDialog( this, R.drawable.ic_play, @@ -689,8 +755,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { { }); } } + "file" -> { - if(!handleFile(url)) { + if (!handleFile(url)) { UIDialogs.showSingleButtonDialog( this, R.drawable.ic_play, @@ -699,8 +766,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { { }); } } + "polycentric" -> { - if(!handlePolycentric(url)) { + if (!handlePolycentric(url)) { UIDialogs.showSingleButtonDialog( this, R.drawable.ic_play, @@ -709,8 +777,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { { }); } } + "fcast" -> { - if(!handleFCast(url)) { + if (!handleFCast(url)) { UIDialogs.showSingleButtonDialog( this, R.drawable.ic_cast, @@ -719,6 +788,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { { }); } } + else -> { if (!handleUrl(url)) { UIDialogs.showSingleButtonDialog( @@ -740,7 +810,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { if (StatePlatform.instance.hasEnabledVideoClient(url)) { Logger.i(TAG, "handleUrl(url=$url) found video client"); lifecycleScope.launch(Dispatchers.Main) { - if(position > 0) + if (position > 0) navigate(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true)); else navigate(_fragVideoDetail, url); @@ -768,24 +838,25 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { return@withContext false; } } + fun handleContent(file: String, mime: String? = null): Boolean { Logger.i(TAG, "handleContent(url=$file)"); val data = readSharedContent(file); - if(file.lowercase().endsWith(".json") || mime == "application/json") { + if (file.lowercase().endsWith(".json") || mime == "application/json") { var recon = String(data); - if(!recon.trim().startsWith("[")) + if (!recon.trim().startsWith("[")) return handleUnknownJson(recon); var reconLines = Json.decodeFromString>(recon); - val cacheStr = reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length); + val cacheStr = + reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length); reconLines = reconLines.filter { !it.startsWith("__CACHE:") }; //TODO: constant prefix var cache: ImportCache? = null; try { - if(cacheStr != null) + if (cacheStr != null) cache = Json.decodeFromString(cacheStr); - } - catch(ex: Throwable) { + } catch (ex: Throwable) { Logger.e(TAG, "Failed to deserialize cache"); } @@ -794,32 +865,31 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}"); handleReconstruction(recon, cache); return true; - } - else if(file.lowercase().endsWith(".zip") || mime == "application/zip") { + } else if (file.lowercase().endsWith(".zip") || mime == "application/zip") { StateBackup.importZipBytes(this, lifecycleScope, data); return true; - } - else if(file.lowercase().endsWith(".txt") || mime == "text/plain") { + } else if (file.lowercase().endsWith(".txt") || mime == "text/plain") { return handleUnknownText(String(data)); } return false; } + fun handleFile(file: String): Boolean { Logger.i(TAG, "handleFile(url=$file)"); - if(file.lowercase().endsWith(".json")) { + if (file.lowercase().endsWith(".json")) { var recon = String(readSharedFile(file)); - if(!recon.startsWith("[")) + if (!recon.startsWith("[")) return handleUnknownJson(recon); var reconLines = Json.decodeFromString>(recon); - val cacheStr = reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length); + val cacheStr = + reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length); reconLines = reconLines.filter { !it.startsWith("__CACHE:") }; //TODO: constant prefix var cache: ImportCache? = null; try { - if(cacheStr != null) + if (cacheStr != null) cache = Json.decodeFromString(cacheStr); - } - catch(ex: Throwable) { + } catch (ex: Throwable) { Logger.e(TAG, "Failed to deserialize cache"); } recon = reconLines.joinToString("\n"); @@ -827,19 +897,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}"); handleReconstruction(recon, cache); return true; - } - else if(file.lowercase().endsWith(".zip")) { + } else if (file.lowercase().endsWith(".zip")) { StateBackup.importZipBytes(this, lifecycleScope, readSharedFile(file)); return true; - } - else if(file.lowercase().endsWith(".txt")) { + } else if (file.lowercase().endsWith(".txt")) { return handleUnknownText(String(readSharedFile(file))); } return false; } + fun handleReconstruction(recon: String, cache: ImportCache? = null) { val type = ManagedStore.getReconstructionIdentifier(recon); - val store: ManagedStore<*> = when(type) { + val store: ManagedStore<*> = when (type) { "Playlist" -> StatePlaylists.instance.playlistStore else -> { UIDialogs.toast(getString(R.string.unknown_reconstruction_type) + " ${type}", false); @@ -847,13 +916,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { }; }; - val name = when(type) { - "Playlist" -> recon.split("\n").filter { !it.startsWith(ManagedStore.RECONSTRUCTION_HEADER_OPERATOR) }.firstOrNull() ?: type; + val name = when (type) { + "Playlist" -> recon.split("\n") + .filter { !it.startsWith(ManagedStore.RECONSTRUCTION_HEADER_OPERATOR) } + .firstOrNull() ?: type; else -> type } - if(!type.isNullOrEmpty()) { + if (!type.isNullOrEmpty()) { UIDialogs.showImportDialog(this, store, name, listOf(recon), cache) { } @@ -862,18 +933,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { fun handleUnknownText(text: String): Boolean { try { - if(text.startsWith("@/Subscription") || text.startsWith("Subscriptions")) { + if (text.startsWith("@/Subscription") || text.startsWith("Subscriptions")) { val lines = text.split("\n").map { it.trim() }.drop(1).filter { it.isNotEmpty() }; navigate(_fragImportSubscriptions, lines); return true; } - } - catch(ex: Throwable) { + } catch (ex: Throwable) { Logger.e(TAG, ex.message, ex); UIDialogs.showGeneralErrorDialog(this, getString(R.string.failed_to_parse_text_file), ex); } return false; } + fun handleUnknownJson(json: String): Boolean { val context = this; @@ -885,8 +956,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { return false;//throw IllegalArgumentException("Invalid NewPipe json structure found"); StateBackup.importNewPipeSubs(this, newPipeSubsParsed); - } - catch(ex: Exception) { + } catch (ex: Exception) { Logger.e(TAG, ex.message, ex); UIDialogs.showGeneralErrorDialog(context, getString(R.string.failed_to_parse_newpipe_subscriptions), ex); } @@ -932,7 +1002,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { private fun readSharedFile(filePath: String): ByteArray { val dataFile = File(filePath); - if(!dataFile.exists()) + if (!dataFile.exists()) throw IllegalArgumentException("Opened file does not exist or not permitted"); val data = dataFile.readBytes(); return data; @@ -941,13 +1011,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { override fun onBackPressed() { Logger.i(TAG, "onBackPressed") - if(_fragBotBarMenu.onBackPressed()) + if (_fragBotBarMenu.onBackPressed()) return; - if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed()) + if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed()) return; - if(!fragCurrent.onBackPressed()) + if (!fragCurrent.onBackPressed()) closeSegment(); } @@ -955,7 +1025,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { super.onUserLeaveHint(); Logger.i(TAG, "onUserLeaveHint") - if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED || _fragVideoDetail.state == VideoDetailFragment.State.MINIMIZED) + if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED || _fragVideoDetail.state == VideoDetailFragment.State.MINIMIZED) _fragVideoDetail.onUserLeaveHint(); } @@ -991,12 +1061,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { fun navigate(segment: MainFragment, parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) { Logger.i(TAG, "Navigate to $segment (parameter=$parameter, withHistory=$withHistory, isBack=$isBack)") - if(segment != fragCurrent) { - - if(segment is VideoDetailFragment) { - if(_fragContainerVideoDetail.visibility != View.VISIBLE) + if (segment != fragCurrent) { + + if (segment is VideoDetailFragment) { + if (_fragContainerVideoDetail.visibility != View.VISIBLE) _fragContainerVideoDetail.visibility = View.VISIBLE; - when(segment.state) { + when (segment.state) { VideoDetailFragment.State.MINIMIZED -> segment.maximizeVideoDetail() VideoDetailFragment.State.CLOSED -> segment.maximizeVideoDetail() else -> {} @@ -1004,11 +1074,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { segment.onShown(parameter, isBack); return; } - - + fragCurrent.onHide(); - if(segment.isMainView) { + if (segment.isMainView) { var transaction = supportFragmentManager.beginTransaction(); if (segment.topBar != null) { if (segment.topBar != fragCurrent.topBar) { @@ -1017,8 +1086,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { .replace(R.id.fragment_top_bar, segment.topBar as Fragment); fragCurrent.topBar?.onHide(); } - } - else if(fragCurrent.topBar != null) + } else if (fragCurrent.topBar != null) transaction.hide(fragCurrent.topBar as Fragment); transaction = transaction.replace(R.id.fragment_main, segment); @@ -1026,25 +1094,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { if (segment.hasBottomBar) { if (!fragCurrent.hasBottomBar) transaction = transaction.show(_fragBotBarMenu); - } - else { - if(fragCurrent.hasBottomBar) + } else { + if (fragCurrent.hasBottomBar) transaction = transaction.hide(_fragBotBarMenu); } transaction.commitNow(); } else { - if(!segment.hasBottomBar) { + if (!segment.hasBottomBar) { supportFragmentManager.beginTransaction() .hide(_fragBotBarMenu) .commitNow(); } } - if(fragCurrent.isHistory && withHistory && _queue.lastOrNull() != fragCurrent) + if (fragCurrent.isHistory && withHistory && _queue.lastOrNull() != fragCurrent) _queue.add(Pair(fragCurrent, _parameterCurrent)); - if(segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory) + if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory) fragBeforeOverlay = fragCurrent; @@ -1062,12 +1129,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { * If called with a non-null fragment, it will only close if the current fragment is the provided one */ fun closeSegment(fragment: MainFragment? = null) { - if(fragment is VideoDetailFragment) { + if (fragment is VideoDetailFragment) { fragment.onHide(); return; } - if((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) { + if ((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) { navigate(fragBeforeOverlay!!, null, false, true); } else { val last = _queue.lastOrNull(); @@ -1089,8 +1156,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { /** * Provides the fragment instance for the provided fragment class */ - inline fun getFragment() : T { - return when(T::class) { + inline fun getFragment(): T { + return when (T::class) { HomeFragment::class -> _fragMainHome as T; TutorialFragment::class -> _fragMainTutorial as T; ContentSearchResultsFragment::class -> _fragMainVideoSearchResults as T; @@ -1127,15 +1194,21 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { private fun updateSegmentPaddings() { var paddingBottom = 0f; - if(fragCurrent.hasBottomBar) + if (fragCurrent.hasBottomBar) paddingBottom += HEIGHT_MENU_DP; - _fragContainerOverlay.setPadding(0,0,0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingBottom - HEIGHT_MENU_DP, resources.displayMetrics).toInt()); + _fragContainerOverlay.setPadding( + 0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingBottom - HEIGHT_MENU_DP, resources.displayMetrics) + .toInt() + ); - if(_fragVideoDetail.state == VideoDetailFragment.State.MINIMIZED) + if (_fragVideoDetail.state == VideoDetailFragment.State.MINIMIZED) paddingBottom += HEIGHT_VIDEO_MINIMIZED_DP; - _fragContainerMain.setPadding(0,0,0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingBottom, resources.displayMetrics).toInt()); + _fragContainerMain.setPadding( + 0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingBottom, resources.displayMetrics) + .toInt() + ); } @@ -1151,14 +1224,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { ContextCompat.checkSelfPermission(this, notifPermission) == PackageManager.PERMISSION_GRANTED -> { } + ActivityCompat.shouldShowRequestPermissionRationale(this, notifPermission) -> { - UIDialogs.showDialog(this, R.drawable.ic_notifications, "Notifications Required", + UIDialogs.showDialog( + this, R.drawable.ic_notifications, "Notifications Required", reason, null, 0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Enable", { requestPermissionLauncher.launch(notifPermission); - }, UIDialogs.ActionStyle.PRIMARY)); + }, UIDialogs.ActionStyle.PRIMARY) + ); } + else -> { requestPermissionLauncher.launch(notifPermission); } @@ -1170,15 +1247,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { fun showAppToast(toast: ToastView.Toast) { synchronized(_toastQueue) { _toastQueue.add(toast); - if(_toastJob?.isActive != true) + if (_toastJob?.isActive != true) _toastJob = lifecycleScope.launch(Dispatchers.Default) { launchAppToastJob(); }; } } + private suspend fun launchAppToastJob() { Logger.i(TAG, "Starting appToast loop"); - while(!_toastQueue.isEmpty()) { + while (!_toastQueue.isEmpty()) { val toast = _toastQueue.poll() ?: continue; Logger.i(TAG, "Showing next toast (${toast.msg})"); @@ -1191,7 +1269,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _toastView.setToastAnimated(toast); } } - if(toast.long) + if (toast.long) delay(5000); else delay(3000); @@ -1205,18 +1283,19 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { //TODO: Only calls last handler due to missing request codes on ActivityResultLaunchers. - private var resultLauncherMap = mutableMapOfUnit>(); + private var resultLauncherMap = mutableMapOf Unit>(); private var requestCode: Int? = -1; private val resultLauncher: ActivityResultLauncher = registerForActivityResult( - ActivityResultContracts.StartActivityForResult()) { - result: ActivityResult -> + ActivityResultContracts.StartActivityForResult() + ) { result: ActivityResult -> val handler = synchronized(resultLauncherMap) { resultLauncherMap.remove(requestCode); } - if(handler != null) + if (handler != null) handler(result); }; - override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit) { + + override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult) -> Unit) { synchronized(resultLauncherMap) { resultLauncherMap[code] = handler; } @@ -1227,21 +1306,23 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { companion object { private val TAG = "MainActivity" - fun getTabIntent(context: Context, tab: String) : Intent { + fun getTabIntent(context: Context, tab: String): Intent { val sourcesIntent = Intent(context, MainActivity::class.java); sourcesIntent.action = "TAB"; sourcesIntent.putExtra("TAB", tab); sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); return sourcesIntent; } - fun getVideoIntent(context: Context, videoUrl: String) : Intent { + + fun getVideoIntent(context: Context, videoUrl: String): Intent { val sourcesIntent = Intent(context, MainActivity::class.java); sourcesIntent.action = "VIDEO"; sourcesIntent.putExtra("VIDEO", videoUrl); sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); return sourcesIntent; } - fun getActionIntent(context: Context, action: String) : Intent { + + fun getActionIntent(context: Context, action: String): Intent { val sourcesIntent = Intent(context, MainActivity::class.java); sourcesIntent.action = "ACTION"; sourcesIntent.putExtra("ACTION", action); diff --git a/app/src/main/java/com/futo/platformplayer/builders/DashBuilder.kt b/app/src/main/java/com/futo/platformplayer/builders/DashBuilder.kt index 98593848..629f4da5 100644 --- a/app/src/main/java/com/futo/platformplayer/builders/DashBuilder.kt +++ b/app/src/main/java/com/futo/platformplayer/builders/DashBuilder.kt @@ -88,7 +88,8 @@ class DashBuilder : XMLBuilder { fun withRepresentationOnDemand(id: String, subtitleSource: ISubtitleSource, subtitleUrl: String) { withRepresentation(id, mapOf( Pair("mimeType", subtitleSource.format ?: "text/vtt"), - Pair("startWithSAP", "1"), + Pair("default", "true"), + Pair("lang", "en"), Pair("bandwidth", "1000") )) { it.withBaseURL(subtitleUrl) @@ -151,7 +152,7 @@ class DashBuilder : XMLBuilder { ) ) { //TODO: Verify if & really should be replaced like this? - it.withRepresentationOnDemand("1", subtitleSource, subtitleUrl.replace("&", "&")) + it.withRepresentationOnDemand("caption_en", subtitleSource, subtitleUrl.replace("&", "&")) } } //Video @@ -164,7 +165,7 @@ class DashBuilder : XMLBuilder { Pair("subsegmentStartsWithSAP", "1") ) ) { - it.withRepresentationOnDemand("1", vidSource, vidUrl.replace("&", "&")); + it.withRepresentationOnDemand("2", vidSource, vidUrl.replace("&", "&")); } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 5551722d..3f3cb293 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -1352,7 +1352,7 @@ class VideoDetailView : ConstraintLayout { } catch(ex: Throwable) { Logger.e(TAG, "Playback tracker failed", ex); - if(me.video?.isLive == true) withContext(Dispatchers.Main) { + if(me.video?.isLive == true || ex.message?.contains("Unable to resolve host") == true) withContext(Dispatchers.Main) { UIDialogs.toast(context, context.getString(R.string.failed_to_get_playback_tracker)); }; else withContext(Dispatchers.Main) { @@ -2823,13 +2823,15 @@ class VideoDetailView : ConstraintLayout { .exception { Logger.w(ChannelFragment.TAG, "Failed to load video.", it); - handleErrorOrCall { - _retryCount = 0; - _retryJob?.cancel(); - _retryJob = null; - _liveTryJob?.cancel(); - _liveTryJob = null; - UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, ::fetchVideo, null, fragment); + if(!(it.message?.contains("Unable to resolve host") ?: false && nextVideo())){ + handleErrorOrCall { + _retryCount = 0; + _retryJob?.cancel(); + _retryJob = null; + _liveTryJob?.cancel(); + _liveTryJob = null; + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, ::fetchVideo, null, fragment); + } } } else TaskHandler(IPlatformVideoDetails::class.java, {fragment.lifecycleScope}); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt index 088655f9..44d8a9ad 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt @@ -26,6 +26,7 @@ import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.SearchHistoryStorage class SearchTopBarFragment : TopFragment() { + @Suppress("PrivatePropertyName") private val TAG = "SearchTopBarFragment" private var _editSearch: EditText? = null; @@ -191,29 +192,32 @@ class SearchTopBarFragment : TopFragment() { } private fun onDone() { - val editSearch = _editSearch; + val editSearch = _editSearch if (editSearch != null) { - val text = editSearch.text.toString(); - if (text.length < 3) { - UIDialogs.toast(getString(R.string.please_use_at_least_3_characters)); - return; + val text = editSearch.text.toString() + if (text.isEmpty()) { + UIDialogs.toast(getString(R.string.please_use_at_least_1_character)) + return } - editSearch.clearFocus(); - _inputMethodManager?.hideSoftInputFromWindow(editSearch.windowToken, 0); + editSearch.clearFocus() + _inputMethodManager?.hideSoftInputFromWindow(editSearch.windowToken, 0) if (Settings.instance.search.searchHistory) { - val storage = FragmentedStorage.get(); - storage.add(text); + val storage = FragmentedStorage.get() + storage.add(text) } if (_searchType == SearchType.CREATOR) { - onSearch.emit(text); + onSearch.emit(text) } else { - onSearch.emit(text); + onSearch.emit(text) } } else { - Logger.w(TAG, "Unexpected condition happened where done is edit search is null but done is triggered."); + Logger.w( + TAG, + "Unexpected condition happened where done is edit search is null but done is triggered." + ) } } diff --git a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt index 734248b2..9d1a3faa 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -182,13 +182,14 @@ class HLS { private fun parseAttributes(content: String): Map { val attributes = mutableMapOf() - val attributePairs = content.substringAfter(":").splitToSequence(',') + val maybeAttributePairs = content.substringAfter(":").splitToSequence(',') var currentPair = StringBuilder() - for (pair in attributePairs) { + for (pair in maybeAttributePairs) { currentPair.append(pair) if (currentPair.count { it == '\"' } % 2 == 0) { // Check if the number of quotes is even - val (key, value) = currentPair.toString().split('=') + val key = currentPair.toString().substringBefore("=") + val value = currentPair.toString().substringAfter("=") attributes[key.trim()] = value.trim().removeSurrounding("\"") currentPair = StringBuilder() // Reset for the next attribute } else { diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt index 7fdbe432..dfddd51f 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -537,7 +537,7 @@ class StatePlatform { else getSortedEnabledClient().filter { if (it is JSClient) it.enableInSearch else true }; clients.parallelStream().forEach { - val searchCapabilities = it.getSearchCapabilities(); + val searchCapabilities = it.getSearchChannelContentsCapabilities(); val mappedFilters = filters.map { pair -> Pair(pair.key, pair.value.map { v -> searchCapabilities.filters.first { g -> g.idOrName == pair.key }.filters.first { f -> f.idOrName == v }.value }) }.toMap(); if (it.isChannelUrl(channelUrl)) { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 4de1b41c..89c3b6c5 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -284,12 +284,16 @@ class StateSync { return@SyncSocketSession } - Logger.i(TAG, "Handshake complete with ${s.remotePublicKey}") + Logger.i(TAG, "Handshake complete with (LocalPublicKey = ${s.localPublicKey}, RemotePublicKey = ${s.remotePublicKey})") synchronized(_sessions) { session = _sessions[s.remotePublicKey] if (session == null) { - session = SyncSession(remotePublicKey, onAuthorized = { + session = SyncSession(remotePublicKey, onAuthorized = { it, isNewlyAuthorized, isNewSession -> + if (!isNewSession) { + return@SyncSession + } + Logger.i(TAG, "${s.remotePublicKey} authorized") synchronized(_lastAddressStorage) { _lastAddressStorage.setAndSave(remotePublicKey, s.remoteAddress) @@ -358,6 +362,16 @@ class StateSync { } }) } + } else { + val publicKey = session!!.remotePublicKey + session!!.unauthorize(s) + session!!.close() + + synchronized(_sessions) { + _sessions.remove(publicKey) + } + + Logger.i(TAG, "Connection unauthorized for ${remotePublicKey} because not authorized and not on pairing activity to ask") } } else { //Responder does not need to check because already approved diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt index 02d17a37..f4ad14eb 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt @@ -23,6 +23,7 @@ import com.futo.platformplayer.sync.models.SyncPlaylistsPackage import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage import com.futo.platformplayer.sync.models.SyncWatchLaterPackage +import com.futo.platformplayer.toUtf8String import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString @@ -32,6 +33,7 @@ import java.nio.ByteBuffer import java.time.Instant import java.time.OffsetDateTime import java.time.ZoneOffset +import java.util.UUID interface IAuthorizable { val isAuthorized: Boolean @@ -41,13 +43,16 @@ class SyncSession : IAuthorizable { private val _socketSessions: MutableList = mutableListOf() private var _authorized: Boolean = false private var _remoteAuthorized: Boolean = false - private val _onAuthorized: (session: SyncSession) -> Unit + private val _onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit private val _onUnauthorized: (session: SyncSession) -> Unit private val _onClose: (session: SyncSession) -> Unit private val _onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit val remotePublicKey: String override val isAuthorized get() = _authorized && _remoteAuthorized private var _wasAuthorized = false + private val _id = UUID.randomUUID() + private var _remoteId: UUID? = null + private var _lastAuthorizedRemoteId: UUID? = null var connected: Boolean = false private set(v) { @@ -57,7 +62,7 @@ class SyncSession : IAuthorizable { } } - constructor(remotePublicKey: String, onAuthorized: (session: SyncSession) -> Unit, onUnauthorized: (session: SyncSession) -> Unit, onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit, onClose: (session: SyncSession) -> Unit) { + constructor(remotePublicKey: String, onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit, onUnauthorized: (session: SyncSession) -> Unit, onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit, onClose: (session: SyncSession) -> Unit) { this.remotePublicKey = remotePublicKey _onAuthorized = onAuthorized _onUnauthorized = onUnauthorized @@ -79,7 +84,8 @@ class SyncSession : IAuthorizable { } fun authorize(socketSession: SyncSocketSession) { - socketSession.send(Opcode.NOTIFY_AUTHORIZED.value) + Logger.i(TAG, "Sent AUTHORIZED with session id $_id") + socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(_id.toString().toByteArray())) _authorized = true checkAuthorized() } @@ -97,9 +103,13 @@ class SyncSession : IAuthorizable { } private fun checkAuthorized() { - if (!_wasAuthorized && isAuthorized) { + if (isAuthorized) { + val isNewlyAuthorized = !_wasAuthorized; + val isNewSession = _lastAuthorizedRemoteId != _remoteId; + Logger.i(TAG, "onAuthorized (isNewlyAuthorized = $isNewlyAuthorized, isNewSession = $isNewSession)"); + _onAuthorized.invoke(this, !_wasAuthorized, _lastAuthorizedRemoteId != _remoteId) _wasAuthorized = true - _onAuthorized.invoke(this) + _lastAuthorizedRemoteId = _remoteId } } @@ -128,12 +138,19 @@ class SyncSession : IAuthorizable { when (opcode) { Opcode.NOTIFY_AUTHORIZED.value -> { + val str = data.toUtf8String() + _remoteId = if (data.remaining() >= 0) UUID.fromString(str) else UUID.fromString("00000000-0000-0000-0000-000000000000") _remoteAuthorized = true + Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId") checkAuthorized() + return } Opcode.NOTIFY_UNAUTHORIZED.value -> { + _remoteId = null + _lastAuthorizedRemoteId = null _remoteAuthorized = false _onUnauthorized(this) + return } //TODO: Handle any kind of packet (that is not necessarily authorized) } diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index 8b5f305a..585f9562 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -10,6 +10,7 @@ import com.futo.platformplayer.noise.protocol.HandshakeState import com.futo.platformplayer.states.StateSync import java.nio.ByteBuffer import java.nio.ByteOrder +import java.util.UUID class SyncSocketSession { enum class Opcode(val value: UByte) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a916a2ba..ad3a4c49 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -726,7 +726,7 @@ Not yet available, retrying in {time}s Failed to retry for live stream This app is in development. Please submit bug reports and understand that many features are incomplete. - Please use at least 3 characters + Please use at least 1 character Are you sure you want to delete this video? Tap to open Update available! @@ -810,6 +810,8 @@ Scroll to top Disable Battery Optimization Click to go to battery optimization settings. Disabling battery optimization will prevent the OS from killing media sessions. + Contribute Personal Subscriptions List + \nWould you liked to contribute your current creator subscriptions list to FUTO?\n\nThe data will be handled according to the Grayjay privacy policy. That is the list will be anonymized and stored without any reference to whomever the list of creators belonged to.\n\nThe intention is for Grayjay and FUTO to use these data to build a cross platform creator recommendation system to make it easier to find new creators you might like from within Grayjay. Cast button Incognito button Creator thumbnail