Compare commits

..

59 Commits

Author SHA1 Message Date
Kelvin 17b9853bb6 Better subscription behavior reporting 2023-11-14 00:27:13 +01:00
Kelvin 8bfb8abd20 Fix notifications 2023-11-13 23:35:25 +01:00
Kelvin 9ee3f1f26e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-13 22:10:43 +01:00
Kelvin 5dcff29d8d Language on all activities, Fix plugin initial state, Allow rotation prevention bypass, Settings warning support 2023-11-13 22:10:31 +01:00
Koen 6cfbd0c8bf Added + Tax indicator 2023-11-13 15:58:01 +01:00
Koen 01d96cce16 Made export request folder to export to. 2023-11-13 15:49:55 +01:00
Koen 58c376f011 Fixed Rumble subscription import. 2023-11-13 12:06:46 +01:00
Kelvin 439d339330 Fix channel membership reset 2023-11-11 16:47:14 +01:00
Kelvin 44eacc2a47 Stable refs 2023-11-10 20:38:46 +01:00
Kelvin 8135d61398 Fix language issue 2023-11-10 20:34:43 +01:00
Kelvin 66208f8265 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-10 19:49:20 +01:00
Kelvin f52251e23a Hide creators, Fix hide video, 2023-11-10 19:49:09 +01:00
Koen dbea93efe5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-10 12:10:30 +01:00
Koen 3bf0740bd1 Fixed control cast on non-fullscreen. 2023-11-10 12:10:12 +01:00
Kelvin fa7f1b11f3 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-09 19:49:51 +01:00
Kelvin ff914bbdf4 Temporary subscription workarounds 2023-11-09 19:49:45 +01:00
Koen b822078d4b Fixed support tab falsely showing when a creator does not have a polycentric profile. 2023-11-09 19:12:54 +01:00
Kelvin 290d2ceb50 Polycentrif ref 2023-11-09 16:57:32 +01:00
Kelvin 8ec9025990 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-09 16:55:32 +01:00
Kelvin c4cf856dcd Stable submods 2023-11-09 16:55:26 +01:00
Koen 38bb4e25d3 Merge branch 'encryption-changes' into 'master'
Encryption changes, load more on import subscriptions.

See merge request videostreaming/grayjay!3
2023-11-09 15:04:55 +00:00
Koen 0de996d91c Encryption changes, load more on import subscriptions. 2023-11-09 15:04:54 +00:00
Kelvin 1f38c9b27d Logs 2023-11-09 15:46:22 +01:00
Kelvin 234f31b02d Logs and submods 2023-11-09 15:42:33 +01:00
Koen 00e40e8cd6 Merge branch 'encryption-provider-split' into 'master'
Encryption provider split.

See merge request videostreaming/grayjay!2
2023-11-08 15:06:29 +00:00
Koen 0bc6a43dc1 Encryption provider split. 2023-11-08 15:06:29 +00:00
Kelvin e7e0157fbc Stable refs 2023-11-08 16:06:07 +01:00
Kelvin 4cae1a41a5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-08 16:03:01 +01:00
Kelvin 4fa61e7f52 Additional plugin settings capabilities 2023-11-08 16:02:52 +01:00
Koen f02ac796f5 Updated YT stable. 2023-11-08 13:23:49 +01:00
Kelvin 22146a6bdc Playlists ui tweaks 2023-11-08 12:15:53 +01:00
Kelvin 5285eae01d Channel membership support and live chat donation support fix 2023-11-07 20:34:23 +01:00
Koen c47ca369e4 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-07 17:16:19 +01:00
Koen f0b1f62bb1 Casting button no longer visible when disabled (may require restart). 2023-11-07 17:16:06 +01:00
Kelvin f7aa6d006e Add header to login activity with current url 2023-11-07 16:46:55 +01:00
Kelvin 6b67cd549f Fix chapters not getting cleared 2023-11-07 16:24:22 +01:00
Kelvin fc6bf85822 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-07 16:04:26 +01:00
Kelvin fbd9345cf8 Fix fallback to cache results 2023-11-07 16:04:19 +01:00
Koen 63137b4c4d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-07 16:03:05 +01:00
Koen e28dc7a3a6 Crash fix for bottom menu bar for specific screen dimensions. 2023-11-07 16:02:55 +01:00
Kelvin 6e14acc685 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-07 15:43:21 +01:00
Kelvin ba64153f1d Language setting, Preview setting, Background audio switch setting, No error on comments failed 2023-11-07 15:43:13 +01:00
Koen 72c04e7556 Added casting button in full screen. 2023-11-07 15:08:33 +01:00
Koen 54f37ee5b2 Fixed crash. 2023-11-07 15:01:22 +01:00
Koen 4fbb325313 Added fix for video player not filling height properly for audio only in a playlist. 2023-11-07 14:20:57 +01:00
Koen e1d3b95f73 Fixed crash on Android 9 when playing a video. 2023-11-07 13:35:13 +01:00
Koen 8f7b4b8257 Merge branch 'monetization' into 'master'
Monetization

See merge request videostreaming/grayjay!1
2023-11-07 12:10:40 +00:00
Koen 9d906025ea Monetization 2023-11-07 12:10:40 +00:00
Kelvin d7f4dd65e8 Stable refs 2023-11-06 14:58:11 +01:00
Kelvin 599b119e62 Remove plugin interaction on main thread for channels 2023-11-06 14:53:24 +01:00
Kelvin 41176464db Fix missing swipe to refresh on tab switch 2023-11-06 14:43:24 +01:00
Kelvin dd0ad19fb9 NewLine subs import, fix no-recent video subscriptions 2023-11-06 14:25:09 +01:00
Kelvin 430625d2fb Fix icon colors 2023-11-06 13:37:18 +01:00
Kelvin 796cd1a776 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-06 13:20:42 +01:00
Kelvin baa26af0c0 Only show sub toasts when on subs page, WIP import ui 2023-11-06 13:20:33 +01:00
Koen ea0c27936e Fixed videos not automatically going to next video in playlist when casting. 2023-11-05 15:13:57 +01:00
Kelvin 4aade35d19 Grayjay schema channel support 2023-11-04 18:42:04 +01:00
Kelvin 251a5701af Custom grayjay open video url handling 2023-11-04 18:31:01 +01:00
Kelvin 2da3116111 Fix initial selection of subscription settings 2023-11-03 20:07:08 +01:00
155 changed files with 2759 additions and 541 deletions
@@ -1,13 +1,14 @@
package com.futo.platformplayer
import com.futo.platformplayer.encryption.EncryptionProvider
import com.futo.platformplayer.encryption.GEncryptionProviderV0
import com.futo.platformplayer.encryption.GEncryptionProviderV1
import junit.framework.TestCase.assertEquals
import org.junit.Test
class EncryptionProviderTests {
class GEncryptionProviderTests {
@Test
fun testEncryptDecrypt() {
val encryptionProvider = EncryptionProvider.instance
fun testEncryptDecryptV1() {
val encryptionProvider = GEncryptionProviderV1.instance
val plaintext = "This is a test string."
// Encrypt the plaintext
@@ -22,8 +23,8 @@ class EncryptionProviderTests {
@Test
fun testEncryptDecryptBytes() {
val encryptionProvider = EncryptionProvider.instance
fun testEncryptDecryptBytesV1() {
val encryptionProvider = GEncryptionProviderV1.instance
val bytes = "This is a test string.".toByteArray();
// Encrypt the plaintext
@@ -36,21 +37,36 @@ class EncryptionProviderTests {
assertArrayEquals(bytes, decrypted);
}
@Test
fun testEncryptDecryptBytesPassword() {
val encryptionProvider = EncryptionProvider.instance
val bytes = "This is a test string.".toByteArray();
val password = "1234".padStart(32, '9');
fun testEncryptDecryptV0() {
val encryptionProvider = GEncryptionProviderV0.instance
val plaintext = "This is a test string."
// Encrypt the plaintext
val ciphertext = encryptionProvider.encrypt(bytes, password)
val ciphertext = encryptionProvider.encrypt(plaintext)
// Decrypt the ciphertext
val decrypted = encryptionProvider.decrypt(ciphertext, password)
val decrypted = encryptionProvider.decrypt(ciphertext)
// The decrypted string should be equal to the original plaintext
assertEquals(plaintext, decrypted)
}
@Test
fun testEncryptDecryptBytesV0() {
val encryptionProvider = GEncryptionProviderV0.instance
val bytes = "This is a test string.".toByteArray();
// Encrypt the plaintext
val ciphertext = encryptionProvider.encrypt(bytes)
// Decrypt the ciphertext
val decrypted = encryptionProvider.decrypt(ciphertext)
// The decrypted string should be equal to the original plaintext
assertArrayEquals(bytes, decrypted);
}
private fun assertArrayEquals(a: ByteArray, b: ByteArray) {
@@ -0,0 +1,45 @@
package com.futo.platformplayer
import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV0
import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV1
import junit.framework.TestCase.assertEquals
import org.junit.Test
class GPasswordEncryptionProviderTests {
@Test
fun testEncryptDecryptBytesPasswordV1() {
val encryptionProvider = GPasswordEncryptionProviderV1();
val bytes = "This is a test string.".toByteArray();
// Encrypt the plaintext
val ciphertext = encryptionProvider.encrypt(bytes, "1234")
// Decrypt the ciphertext
val decrypted = encryptionProvider.decrypt(ciphertext, "1234")
// The decrypted string should be equal to the original plaintext
assertArrayEquals(bytes, decrypted);
}
@Test
fun testEncryptDecryptBytesPasswordV0() {
val encryptionProvider = GPasswordEncryptionProviderV0("1234".padStart(32, '9'));
val bytes = "This is a test string.".toByteArray();
// Encrypt the plaintext
val ciphertext = encryptionProvider.encrypt(bytes)
// Decrypt the ciphertext
val decrypted = encryptionProvider.decrypt(ciphertext)
// The decrypted string should be equal to the original plaintext
assertArrayEquals(bytes, decrypted);
}
private fun assertArrayEquals(a: ByteArray, b: ByteArray) {
assertEquals(a.size, b.size);
for(i in 0 until a.size) {
assertEquals(a[i], b[i]);
}
}
}
+20
View File
@@ -92,6 +92,26 @@
<data android:host="*" />
<data android:scheme="file" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:autoVerify="true">
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<data android:host="*" />
<data android:scheme="content" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:autoVerify="true">
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<data android:host="*" />
<data android:scheme="file" />
<data android:mimeType="application/zip" />
</intent-filter>
<intent-filter android:autoVerify="true">
+5 -2
View File
@@ -540,6 +540,8 @@
<script>
IS_TESTING = true;
let lastScriptTag = null;
let shouldDevLog = true;
let shouldLoginCheck = true;
new Vue({
el: '#app',
data: {
@@ -603,7 +605,7 @@
};
setInterval(()=>{
try{
if(!this.Plugin.currentPlugin)
if(!this.Plugin.currentPlugin || !shouldDevLog)
return;
getDevLogs(this.Integration.lastLogIndex, (newLogs)=> {
@@ -638,7 +640,8 @@
}, 1000);
setInterval(()=>{
try{
this.isTestLoggedIn();
if(shouldLoginCheck)
this.isTestLoggedIn();
}catch(ex){}
}, 2500);
},
+17 -2
View File
@@ -10,7 +10,8 @@ let Type = {
Videos: "VIDEOS",
Streams: "STREAMS",
Mixed: "MIXED",
Live: "LIVE"
Live: "LIVE",
Subscriptions: "SUBSCRIPTIONS"
},
Order: {
Chronological: "CHRONOLOGICAL"
@@ -159,13 +160,27 @@ class FilterCapability {
class PlatformAuthorLink {
constructor(id, name, url, thumbnail, subscribers) {
constructor(id, name, url, thumbnail, subscribers, membershipUrl) {
this.id = id ?? PlatformID(); //PlatformID
this.name = name ?? ""; //string
this.url = url ?? ""; //string
this.thumbnail = thumbnail; //string
if(subscribers)
this.subscribers = subscribers;
if(membershipUrl)
this.membershipUrl = membershipUrl ?? null; //string (for backcompat)
}
}
class PlatformAuthorMembershipLink {
constructor(id, name, url, thumbnail, subscribers, membershipUrl) {
this.id = id ?? PlatformID(); //PlatformID
this.name = name ?? ""; //string
this.url = url ?? ""; //string
this.thumbnail = thumbnail; //string
if(subscribers)
this.subscribers = subscribers;
if(membershipUrl)
this.membershipUrl = membershipUrl ?? null; //string
}
}
class PlatformContent {
@@ -0,0 +1,20 @@
package com.futo.platformplayer
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
class HorizontalSpaceItemDecoration(private val startSpace: Int, private val betweenSpace: Int, private val endSpace: Int) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
outRect.left = betweenSpace
val position = parent.getChildAdapterPosition(view)
if (position == 0) {
outRect.left = startSpace
}
else if (position == state.itemCount - 1) {
outRect.right = endSpace
}
}
}
@@ -4,12 +4,11 @@ import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.webkit.CookieManager
import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.*
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.logging.Logger
@@ -23,6 +22,7 @@ import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
import com.futo.platformplayer.views.fields.FormField
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormFieldButton
import com.futo.platformplayer.views.fields.FormFieldWarning
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -30,6 +30,7 @@ import kotlinx.serialization.*
import kotlinx.serialization.json.*
import java.io.File
import java.time.OffsetDateTime
import java.util.Locale
@Serializable
data class MenuBottomBarSetting(val id: Int, var enabled: Boolean);
@@ -44,10 +45,7 @@ class Settings : FragmentedStorageFileJson() {
@Transient
val onTabsChanged = Event0();
@FormField(
R.string.manage_polycentric_identity, FieldForm.BUTTON,
R.string.manage_your_polycentric_identity, -4
)
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -5)
@FormFieldButton(R.drawable.ic_person)
fun managePolycentricIdentity() {
SettingsActivity.getActivity()?.let {
@@ -59,10 +57,7 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField(
R.string.show_faq, FieldForm.BUTTON,
R.string.get_answers_to_common_questions, -3
)
@FormField(R.string.show_faq, FieldForm.BUTTON, R.string.get_answers_to_common_questions, -4)
@FormFieldButton(R.drawable.ic_quiz)
fun openFAQ() {
try {
@@ -72,10 +67,7 @@ class Settings : FragmentedStorageFileJson() {
//Ignored
}
}
@FormField(
R.string.show_issues, FieldForm.BUTTON,
R.string.a_list_of_user_reported_and_self_reported_issues, -2
)
@FormField(R.string.show_issues, FieldForm.BUTTON, R.string.a_list_of_user_reported_and_self_reported_issues, -3)
@FormFieldButton(R.drawable.ic_data_alert)
fun openIssues() {
try {
@@ -107,10 +99,7 @@ class Settings : FragmentedStorageFileJson() {
}
}*/
@FormField(
R.string.manage_tabs, FieldForm.BUTTON,
R.string.change_tabs_visible_on_the_home_screen, -1
)
@FormField(R.string.manage_tabs, FieldForm.BUTTON, R.string.change_tabs_visible_on_the_home_screen, -2)
@FormFieldButton(R.drawable.ic_tabs)
fun manageTabs() {
try {
@@ -122,11 +111,39 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField(R.string.language, "group", -1, 0)
var language = LanguageSettings();
@Serializable
class LanguageSettings {
@FormField(R.string.app_language, FieldForm.DROPDOWN, R.string.may_require_restart, 5, "app_language")
@DropdownFieldOptionsId(R.array.app_languages)
var appLanguage: Int = 0;
fun getAppLanguageLocaleString(): String? {
return when(appLanguage) {
0 -> null
1 -> "en";
2 -> "de";
3 -> "es";
4 -> "pt";
5 -> "fr"
6 -> "ja";
7 -> "ko";
8 -> "zh";
9 -> "ru";
10 -> "ar";
else -> null
}
}
}
@FormField(R.string.home, "group", R.string.configure_how_your_home_tab_works_and_feels, 1)
var home = HomeSettings();
@Serializable
class HomeSettings {
@FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 5)
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 5)
@DropdownFieldOptionsId(R.array.feed_style)
var homeFeedStyle: Int = 1;
@@ -136,21 +153,39 @@ class Settings : FragmentedStorageFileJson() {
else
return FeedStyle.THUMBNAIL;
}
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
var previewFeedItems: Boolean = true;
@FormField(R.string.clear_hidden, FieldForm.BUTTON, R.string.clear_hidden_description, 7)
@FormFieldButton(R.drawable.ic_visibility_off)
fun clearHidden() {
StateMeta.instance.removeAllHiddenCreators();
StateMeta.instance.removeAllHiddenVideos();
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, "Creators and videos should show up again");
}
}
}
@FormField(R.string.search, "group", -1, 2)
var search = SearchSettings();
@Serializable
class SearchSettings {
@FormField(R.string.search_history, FieldForm.TOGGLE, -1, 4)
@FormField(R.string.search_history, FieldForm.TOGGLE, R.string.may_require_restart, 3)
@Serializable(with = FlexibleBooleanSerializer::class)
var searchHistory: Boolean = true;
@FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 5)
@FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 4)
@DropdownFieldOptionsId(R.array.feed_style)
var searchFeedStyle: Int = 1;
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
var previewFeedItems: Boolean = true;
fun getSearchFeedStyle(): FeedStyle {
if(searchFeedStyle == 0)
@@ -164,7 +199,7 @@ class Settings : FragmentedStorageFileJson() {
var subscriptions = SubscriptionsSettings();
@Serializable
class SubscriptionsSettings {
@FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 5)
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 4)
@DropdownFieldOptionsId(R.array.feed_style)
var subscriptionsFeedStyle: Int = 1;
@@ -175,10 +210,16 @@ class Settings : FragmentedStorageFileJson() {
return FeedStyle.THUMBNAIL;
}
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
var previewFeedItems: Boolean = true;
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 6)
@Serializable(with = FlexibleBooleanSerializer::class)
var fetchOnAppBoot: Boolean = true;
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 6)
var fetchOnTabOpen: Boolean = true;
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 7)
@DropdownFieldOptionsId(R.array.background_interval)
var subscriptionsBackgroundUpdateInterval: Int = 0;
@@ -208,6 +249,17 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 10)
var allowPlaytimeTracking: Boolean = true;
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 11)
var alwaysReloadFromCache: Boolean = false;
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 12)
fun clearChannelCache() {
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
ChannelContentCache.instance.clear();
UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing");
}
}
@FormField(R.string.player, "group", R.string.change_behavior_of_the_player, 4)
@@ -215,10 +267,10 @@ class Settings : FragmentedStorageFileJson() {
@Serializable
class PlaybackSettings {
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.languages)
@DropdownFieldOptionsId(R.array.audio_languages)
var primaryLanguage: Int = 0;
fun getPrimaryLanguage(context: Context) = context.resources.getStringArray(R.array.languages)[primaryLanguage];
fun getPrimaryLanguage(context: Context) = context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
@FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 1)
@DropdownFieldOptionsId(R.array.playback_speeds)
@@ -277,10 +329,6 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.resume_after_preview)
var resumeAfterPreview: Int = 1;
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 8)
var useLiveChatWindow: Boolean = true;
fun shouldResumePreview(previewedPosition: Long): Boolean{
if(resumeAfterPreview == 2)
return true;
@@ -288,6 +336,14 @@ class Settings : FragmentedStorageFileJson() {
return true;
return false;
}
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 8)
var useLiveChatWindow: Boolean = true;
@FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 8)
var backgroundSwitchToAudio: Boolean = true;
}
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 5)
@@ -373,10 +429,7 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.log_levels)
var logLevel: Int = 0;
@FormField(
R.string.submit_logs, FieldForm.BUTTON,
R.string.submit_logs_to_help_us_narrow_down_issues, 1
)
@FormField(R.string.submit_logs, FieldForm.BUTTON, R.string.submit_logs_to_help_us_narrow_down_issues, 1)
fun submitLogs() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
@@ -398,10 +451,7 @@ class Settings : FragmentedStorageFileJson() {
var announcementSettings = AnnouncementSettings();
@Serializable
class AnnouncementSettings {
@FormField(
R.string.reset_announcements, FieldForm.BUTTON,
R.string.reset_hidden_announcements, 1
)
@FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1)
fun resetAnnouncements() {
StateAnnouncement.instance.resetAnnouncements();
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
@@ -417,18 +467,12 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
var clearCookiesOnLogout: Boolean = true;
@FormField(
R.string.clear_cookies, FieldForm.BUTTON,
R.string.clears_in_app_browser_cookies, 1
)
@FormField(R.string.clear_cookies, FieldForm.BUTTON, R.string.clears_in_app_browser_cookies, 1)
fun clearCookies() {
val cookieManager: CookieManager = CookieManager.getInstance();
cookieManager.removeAllCookies(null);
}
@FormField(
R.string.reinstall_embedded_plugins, FieldForm.BUTTON,
R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1
)
@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
fun reinstallEmbedded() {
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
try {
@@ -475,6 +519,13 @@ class Settings : FragmentedStorageFileJson() {
StateApp.instance.changeExternalDownloadDirectory(it);
}
}
@FormField(R.string.clear_external_downloads_directory, FieldForm.BUTTON, R.string.clear_the_external_storage_for_download_files, 5)
fun clearStorageDownload() {
Settings.instance.storage.storage_download = null;
Settings.instance.save();
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Cleared download storage directory") };
}
}
@@ -507,10 +558,7 @@ class Settings : FragmentedStorageFileJson() {
return check == 0 && !BuildConfig.IS_PLAYSTORE_BUILD;
}
@FormField(
R.string.manual_check, FieldForm.BUTTON,
R.string.manually_check_for_updates, 3
)
@FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3)
fun manualCheck() {
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
SettingsActivity.getActivity()?.let {
@@ -527,10 +575,7 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField(
R.string.view_changelog, FieldForm.BUTTON,
R.string.review_the_current_and_past_changelogs, 4
)
@FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4)
fun viewChangelog() {
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it.getString(R.string.retrieving_changelog));
@@ -550,10 +595,7 @@ class Settings : FragmentedStorageFileJson() {
};
}
@FormField(
R.string.remove_cached_version, FieldForm.BUTTON,
R.string.remove_the_last_downloaded_version, 5
)
@FormField(R.string.remove_cached_version, FieldForm.BUTTON, R.string.remove_the_last_downloaded_version, 5)
fun removeCachedVersion() {
StateApp.withContext {
val outputDirectory = File(it.filesDir, "autoupdate");
@@ -603,6 +645,23 @@ class Settings : FragmentedStorageFileJson() {
fun export() {
StateBackup.startExternalBackup();
}
/*
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, 4)
fun import() {
val act = SettingsActivity.getActivity() ?: return;
StateApp.instance.requestFileReadAccess(act, null) {
if(it != null && it.exists()) {
val name = it.name;
val contents = it.readBytes(act);
if(contents != null) {
if(name != null && name.endsWith(".zip", true))
StateBackup.importZipBytes(act, act.lifecycleScope, contents);
}
}
}
}*/
}
@FormField(R.string.payment, FieldForm.GROUP, -1, 14)
@@ -622,7 +681,16 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField(R.string.info, FieldForm.GROUP, -1, 15)
@FormField(R.string.other, FieldForm.GROUP, -1, 15)
var other = Other();
@Serializable
class Other {
@FormField(R.string.bypass_rotation_prevention, FieldForm.TOGGLE, R.string.bypass_rotation_prevention_description, 1)
@FormFieldWarning(R.string.bypass_rotation_prevention_warning)
var bypassRotationPrevention: Boolean = false;
}
@FormField(R.string.info, FieldForm.GROUP, -1, 16)
var info = Info();
@Serializable
class Info {
@@ -82,6 +82,8 @@ class UISlideOverlays {
menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items);
if(subscription.doNotifications)
menu.selectOption(null, "notifications", true, true);
if(subscription.doFetchLive)
menu.selectOption(null, "fetchLive", true, true);
if(subscription.doFetchStreams)
@@ -387,8 +389,13 @@ class UISlideOverlays {
val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
(listOf(
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download),
{ showDownloadVideoOverlay(video, container, true); }, false))
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download), {
showDownloadVideoOverlay(video, container, true);
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, container.context.getString(R.string.hide_creator_from_home), "", "hide_creator", {
StateMeta.instance.addHiddenCreator(video.author.url);
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
}))
+ actions)
));
items.add(
@@ -164,9 +164,7 @@ fun Int.sp(resources: Resources): Int {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, this.toFloat(), resources.displayMetrics).toInt()
}
fun File.share(context: Context) {
val uri = FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), this);
fun DocumentFile.share(context: Context) {
val shareIntent = Intent();
shareIntent.action = Intent.ACTION_SEND;
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Animatable
import android.os.Bundle
@@ -45,6 +46,10 @@ class AddSourceActivity : AppCompatActivity() {
private var _config: SourcePluginConfig? = null;
private var _script: String? = null;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
@@ -7,6 +8,7 @@ import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.*
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.buttons.BigButton
import com.google.zxing.integration.android.IntentIntegrator
import com.journeyapps.barcodescanner.CaptureActivity
@@ -43,6 +45,10 @@ class AddSourceOptionsActivity : AppCompatActivity() {
}
}
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_add_source_options);
@@ -18,6 +18,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.CaptchaWebViewClient
import com.futo.platformplayer.others.LoginWebViewClient
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
@@ -31,6 +32,10 @@ class CaptchaActivity : AppCompatActivity() {
private lateinit var _webView: WebView;
private lateinit var _buttonClose: Button;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_captcha);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
@@ -11,6 +12,7 @@ import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.logging.LogLevel
import com.futo.platformplayer.logging.Logging
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -27,6 +29,10 @@ class ExceptionActivity : AppCompatActivity() {
private var _file: File? = null;
private var _submitted = false;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_exception);
@@ -7,6 +7,8 @@ import android.webkit.ConsoleMessage
import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.widget.ImageButton
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
@@ -15,6 +17,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.LoginWebViewClient
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
@@ -23,13 +26,25 @@ import kotlinx.serialization.json.Json
class LoginActivity : AppCompatActivity() {
private lateinit var _webView: WebView;
private lateinit var _textUrl: TextView;
private lateinit var _buttonClose: ImageButton;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_login);
setNavigationBarColorAndIcons();
_textUrl = findViewById(R.id.text_url);
_buttonClose = findViewById(R.id.button_close);
_buttonClose.setOnClickListener {
finish();
}
_webView = findViewById(R.id.web_view);
_webView.settings.javaScriptEnabled = true;
CookieManager.getInstance().setAcceptCookie(true);
@@ -60,6 +75,8 @@ class LoginActivity : AppCompatActivity() {
};
var isFirstLoad = true;
webViewClient.onPageLoaded.subscribe { view, url ->
_textUrl.setText(url ?: "");
if(!isFirstLoad)
return@subscribe;
isFirstLoad = false;
@@ -7,6 +7,7 @@ import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.preference.PreferenceManager
import android.util.TypedValue
import android.view.View
import android.widget.FrameLayout
@@ -154,6 +155,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
override fun attachBaseContext(newBase: Context?) {
Logger.i(TAG, "MainActivity.attachBaseContext")
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
StateApp.instance.setGlobalContext(this, lifecycleScope);
StateApp.instance.mainAppStarting(this);
@@ -321,6 +327,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
fragCurrent.onOrientationChanged(it);
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED)
_fragVideoDetail.onOrientationChanged(it);
else if(Settings.instance.other.bypassRotationPrevention)
{
requestedOrientation = when(orientation) {
OrientationManager.Orientation.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
OrientationManager.Orientation.LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
OrientationManager.Orientation.REVERSED_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
OrientationManager.Orientation.REVERSED_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
}
}
};
_orientationManager.enable();
@@ -497,6 +512,14 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
};
startActivity(intent);
}
else if(targetData.startsWith("grayjay://video/")) {
val videoUrl = targetData.substring("grayjay://video/".length);
navigate(_fragVideoDetail, videoUrl);
}
else if(targetData.startsWith("grayjay://channel/")) {
val channelUrl = targetData.substring("grayjay://channel/".length);
navigate(_fragMainChannel, channelUrl);
}
}
"content" -> {
if(!handleContent(targetData, intent.type)) {
@@ -583,6 +606,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateBackup.importZipBytes(this, lifecycleScope, data);
return true;
}
else if(file.lowercase().endsWith(".txt") || mime == "text/plain") {
return handleUnknownText(String(data));
}
return false;
}
fun handleFile(file: String): Boolean {
@@ -600,6 +626,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateBackup.importZipBytes(this, lifecycleScope, readSharedFile(file));
return true;
}
else if(file.lowercase().endsWith(".txt")) {
return handleUnknownText(String(readSharedFile(file)));
}
return false;
}
fun handleReconstruction(recon: String) {
@@ -625,6 +654,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
fun handleUnknownText(text: String): Boolean {
try {
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) {
Logger.e(TAG, ex.message, ex);
UIDialogs.showGeneralErrorDialog(this, getString(R.string.failed_to_parse_text_file), ex);
}
return false;
}
fun handleUnknownJson(name: String?, json: String): Boolean {
val context = this;
@@ -745,6 +788,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateSaved.instance.setVideoToOpenBlocking(null);
}
inline fun <reified T> isFragmentActive(): Boolean {
return fragCurrent is T;
}
/**
* Navigate takes a MainFragment, and makes them the current main visible view
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.os.Bundle
import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity
@@ -10,6 +11,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.AnyAdapterView
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.adapters.ItemMoveCallback
@@ -23,6 +25,10 @@ class ManageTabsActivity : AppCompatActivity() {
private lateinit var _recyclerTabs: RecyclerView;
private lateinit var _touchHelper: ItemTouchHelper;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_manage_tabs);
@@ -16,6 +16,7 @@ import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.R
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.polycentric.core.*
@@ -33,6 +34,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
private lateinit var _exportBundle: String;
private lateinit var _textQR: TextView;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_backup);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.EditText
@@ -11,6 +12,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.Store
@@ -28,6 +30,10 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
private var _creating = false;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_create_profile);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
@@ -15,6 +16,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.dp
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.polycentric.core.Store
@@ -27,6 +29,10 @@ class PolycentricHomeActivity : AppCompatActivity() {
private lateinit var _buttonImportProfile: BigButton;
private lateinit var _layoutButtons: LinearLayout;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_home);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.EditText
@@ -12,6 +13,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.*
import com.google.zxing.integration.android.IntentIntegrator
@@ -39,6 +41,10 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
}
}
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_import_profile);
@@ -2,6 +2,7 @@ package com.futo.platformplayer.activities
import android.app.Activity
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
@@ -29,6 +30,7 @@ import com.futo.polycentric.core.Store
import com.futo.polycentric.core.Synchronization
import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.toURLInfoDataLink
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.github.dhaval2404.imagepicker.ImagePicker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -47,6 +49,10 @@ class PolycentricProfileActivity : AppCompatActivity() {
private lateinit var _imagePolycentric: ImageView;
private var _avatarUri: Uri? = null;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_profile);
@@ -222,7 +228,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
val avatar = systemState.avatar.selectBestImage(dp_80 * dp_80);
Glide.with(_imagePolycentric)
.load(avatar?.toURLInfoDataLink(processHandle.system.toProto(), processHandle.processSecret.process.toProto(), systemState.servers.toList()))
.load(avatar?.toURLInfoSystemLinkUrl(processHandle.system.toProto(), avatar.process, systemState.servers.toList()))
.placeholder(R.drawable.placeholder_profile)
.crossfade()
.into(_imagePolycentric)
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
@@ -7,12 +8,17 @@ import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.R
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.buttons.BigButton
class PolycentricWhyActivity : AppCompatActivity() {
private lateinit var _buttonVideo: BigButton;
private lateinit var _buttonTechnical: BigButton;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_why);
@@ -1,6 +1,7 @@
package com.futo.platformplayer.activities
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
@@ -13,6 +14,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.Loader
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.ReadOnlyTextField
@@ -28,6 +30,10 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
private var _isFinished = false;
override fun attachBaseContext(newBase: Context?) {
Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext")
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_settings);
@@ -43,6 +49,11 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
_form.setObjectValues();
Settings.instance.save();
if(field.descriptor?.id == "app_language") {
Logger.i("SettingsActivity", "App language change detected, propogating to shared preferences");
StateApp.instance.setLocaleSetting(this, Settings.instance.language.getAppLanguageLocaleString());
}
};
_buttonBack.setOnClickListener {
finish();
@@ -190,7 +190,9 @@ class HttpContext : AutoCloseable {
do {
read = readContentBytes(buffer, buffer.size);
writer.write(buffer, 0, read);
} while(read > 0);
} while(read > 0);// && _stream.ready());
//if(!_stream.ready())
// _totalRead = contentLength;
return writer.toString();
}
inline fun <reified T> readContentJson() : T {
@@ -200,7 +200,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
handler(req);
requestsTotal++;
if(req.keepAlive) {
if(req.keepAlive){// && requestReader.ready()) {
keepAlive = true;
if(req.keepAliveMax > 0)
requestsMax = req.keepAliveMax;
@@ -10,7 +10,7 @@ import com.futo.platformplayer.getOrThrow
* A link to a channel, often with its own name and thumbnail
*/
@kotlinx.serialization.Serializable
class PlatformAuthorLink {
open class PlatformAuthorLink {
val id: PlatformID;
val name: String;
val url: String;
@@ -28,6 +28,9 @@ class PlatformAuthorLink {
companion object {
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorLink {
if(value.has("membershipUrl"))
return PlatformAuthorMembershipLink.fromV8(config, value);
val context = "AuthorLink"
return PlatformAuthorLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)),
value.getOrThrow(config ,"name", context),
@@ -0,0 +1,33 @@
package com.futo.platformplayer.api.media.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
/**
* A link to a channel, often with its own name and thumbnail
*/
@kotlinx.serialization.Serializable
class PlatformAuthorMembershipLink: PlatformAuthorLink {
val membershipUrl: String?;
constructor(id: PlatformID, name: String, url: String, thumbnail: String? = null, subscribers: Long? = null, membershipUrl: String? = null): super(id, name, url, thumbnail, subscribers)
{
this.membershipUrl = membershipUrl;
}
companion object {
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorMembershipLink {
val context = "AuthorMembershipLink"
return PlatformAuthorMembershipLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)),
value.getOrThrow(config ,"name", context),
value.getOrThrow(config, "url", context),
value.getOrDefault<String>(config, "thumbnail", context, null),
if(value.has("subscribers")) value.getOrThrow(config,"subscribers", context) else null,
if(value.has("membershipUrl")) value.getOrThrow(config, "membershipUrl", context) else null
);
}
}
}
@@ -29,6 +29,7 @@ class ResultCapabilities(
const val TYPE_LIVE = "LIVE";
const val TYPE_POSTS = "POSTS";
const val TYPE_MIXED = "MIXED";
const val TYPE_SUBSCRIPTIONS = "SUBSCRIPTIONS";
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
@@ -20,6 +20,10 @@ class Thumbnails {
fun getLQThumbnail() : String? {
return sources.firstOrNull()?.url;
}
fun getMinimumThumbnail(quality: Int): String? {
return sources.firstOrNull { it.quality >= quality }?.url ?: getHQThumbnail();
}
fun hasMultiple() = sources.size > 1;
@@ -1,20 +1,17 @@
package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.encryption.EncryptionProvider
import com.futo.platformplayer.logging.Logger
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
override fun toString(): String {
return "(headers: '$headers', cookieString: '$cookieMap')";
}
fun toEncrypted(): String{
return EncryptionProvider.instance.encrypt(serialize());
return SourceEncrypted.fromDecrypted { serialize() }.toJson();
}
private fun serialize(): String {
@@ -25,20 +22,10 @@ data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? =
val TAG = "SourceAuth";
fun fromEncrypted(encrypted: String?): SourceAuth? {
if(encrypted == null)
return null;
val decrypted = EncryptionProvider.instance.decrypt(encrypted);
try {
return deserialize(decrypted);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to deserialize authentication", ex);
return null;
}
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
}
fun deserialize(str: String): SourceAuth {
private fun deserialize(str: String): SourceAuth {
val data = Json.decodeFromString<SerializedAuth>(str);
return SourceAuth(data.cookieMap, data.headers);
}
@@ -1,7 +1,5 @@
package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.encryption.EncryptionProvider
import com.futo.platformplayer.logging.Logger
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@@ -13,7 +11,7 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
}
fun toEncrypted(): String{
return EncryptionProvider.instance.encrypt(serialize());
return SourceEncrypted.fromDecrypted { serialize() }.toJson();
}
private fun serialize(): String {
@@ -21,20 +19,10 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
}
companion object {
val TAG = "SourceAuth";
val TAG = "SourceCaptchaData";
fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
if(encrypted == null)
return null;
val decrypted = EncryptionProvider.instance.decrypt(encrypted);
try {
return deserialize(decrypted);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to deserialize authentication", ex);
return null;
}
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
}
fun deserialize(str: String): SourceCaptchaData {
@@ -0,0 +1,59 @@
package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.encryption.GEncryptionProvider
import com.futo.platformplayer.encryption.GEncryptionProviderV0
import com.futo.platformplayer.logging.Logger
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.lang.Exception
@Serializable
data class SourceEncrypted(
val encrypted: String,
val version: Int = GEncryptionProvider.version
) {
fun toJson(): String {
return Json.encodeToString(this);
}
companion object {
fun fromDecrypted(serializer: () -> String): SourceEncrypted {
return SourceEncrypted(GEncryptionProvider.instance.encrypt(serializer()));
}
fun <T> decryptEncrypted(encrypted: String?, deserializer: (decrypted: String) -> T): T? {
if(encrypted == null)
return null;
try {
val encryptedSourceAuth = Json.decodeFromString<SourceEncrypted>(encrypted)
if (encryptedSourceAuth.version != GEncryptionProvider.version) {
throw Exception("Invalid encryption version.");
}
val decrypted = GEncryptionProvider.instance.decrypt(encryptedSourceAuth.encrypted);
try {
return deserializer(decrypted);
} catch(ex: Throwable) {
Logger.e(SourceAuth.TAG, "Failed to deserialize SourceEncrypted<T>", ex);
return null;
}
} catch (e: Throwable) {
//Try to fall back to old mechanism, remove this eventually
if (!encrypted.contains("version")) {
val decrypted = GEncryptionProviderV0.instance.decrypt(encrypted);
try {
return deserializer(decrypted);
} catch (ex: Throwable) {
Logger.e(SourceAuth.TAG, "Failed to deserialize SourceEncrypted<T>", ex);
return null;
}
} else {
return null;
}
}
}
}
}
@@ -144,7 +144,10 @@ class SourcePluginConfig(
val description: String,
val type: String,
val default: String? = null,
val variable: String? = null
val variable: String? = null,
val dependency: String? = null,
val warningDialog: String? = null,
val options: List<String>? = null
) {
@kotlinx.serialization.Transient
val variableOrName: String get() = variable ?: name;
@@ -19,6 +19,7 @@ import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription
@@ -51,6 +51,14 @@ class ChannelContentCache {
Logger.i(TAG, "ChannelContentCache time: ${initializeTime}ms channels: ${allFiles.size}, videos: ${totalItems}, trimmed: ${trimmed}, total: ${totalItems - trimmed}");
}
fun clear() {
synchronized(_channelContents) {
for(channel in _channelContents)
for(content in channel.value.getItems())
uncacheContent(content);
}
}
fun getChannelCachePager(channelUrl: String): PlatformContentPager {
val validID = channelUrl.toSafeFileName();
@@ -83,7 +91,7 @@ class ChannelContentCache {
val items = validStores.flatMap { it.getItems() }
.sortedByDescending { it.datetime };
return DedupContentPager(PlatformContentPager(items, Math.min(150, items.size)), StatePlatform.instance.getEnabledClients().map { it.id });
return DedupContentPager(PlatformContentPager(items, Math.min(30, items.size)), StatePlatform.instance.getEnabledClients().map { it.id });
}
fun uncacheContent(content: SerializedPlatformContent) {
@@ -287,7 +287,6 @@ class DeveloperEndpoints(private val context: Context) {
@HttpPOST("/plugin/remoteCall")
fun pluginRemoteCall(context: HttpContext) {
try {
val parameters = context.readContentString();
val objId = context.query.get("id")
val method = context.query.get("method")
@@ -299,12 +298,15 @@ class DeveloperEndpoints(private val context: Context) {
context.respondCode(400, "Missing method");
return;
}
if(method != "isLoggedIn")
Logger.i(TAG, "Remote Call [${objId}].${method}(...)");
val parameters = context.readContentString(); //TODO: Temporary
val remoteObj = getRemoteObject(objId);
val paras = JsonParser.parseString(parameters);
if(!paras.isJsonArray)
throw IllegalArgumentException("Expected json array as body");
if(method != "isLoggedIn")
Logger.i(TAG, "Remote Call [${objId}].${method}(...)");
val callResult = remoteObj.call(method, paras as JsonArray);
val json = wrapRemoteResult(callResult, false);
context.respondCode(200, json, "application/json");
@@ -1,13 +1,18 @@
package com.futo.platformplayer.downloads
import android.content.Context
import android.net.Uri
import android.os.Environment
import androidx.documentfile.provider.DocumentFile
import com.arthenica.ffmpegkit.*
import com.futo.platformplayer.api.media.models.streams.sources.*
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toHumanBitrate
import kotlinx.coroutines.*
import java.io.*
import java.util.UUID
import java.util.concurrent.CancellationException
import java.util.concurrent.Executors
import kotlin.coroutines.resumeWithException
@@ -43,7 +48,7 @@ class VideoExport {
this.subtitleSource = subtitleSource;
}
suspend fun export(onProgress: ((Double) -> Unit)? = null): File = coroutineScope {
suspend fun export(context: Context, onProgress: ((Double) -> Unit)? = null): DocumentFile = coroutineScope {
if(isCancelled) throw CancellationException("Export got cancelled");
val v = videoSource;
@@ -55,34 +60,47 @@ class VideoExport {
if (a != null) sourceCount++;
if (s != null) sourceCount++;
var outputFile: File? = null;
val moviesRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES);
val musicRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC);
val moviesGrayjay = File(moviesRoot, "Grayjay");
val musicGrayjay = File(musicRoot, "Grayjay");
if(!moviesGrayjay.exists())
moviesGrayjay.mkdirs();
if(!musicGrayjay.exists())
musicGrayjay.mkdirs();
val outputFile: DocumentFile?;
val downloadRoot = StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set");
if (sourceCount > 1) {
val outputFileName = toSafeFileName(videoLocal.name) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container);
val f = File(moviesGrayjay, outputFileName);
val f = downloadRoot.createFile("video/mp4", outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Combining video and audio through FFMPEG.");
combine(a?.filePath, v?.filePath, s?.filePath, f.absolutePath, videoLocal.duration.toDouble()) { progress -> onProgress?.invoke(progress) };
val tempFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4");
try {
combine(a?.filePath, v?.filePath, s?.filePath, tempFile.absolutePath, videoLocal.duration.toDouble()) { progress -> onProgress?.invoke(progress) };
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
copy(tempFile.absolutePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
} finally {
tempFile.delete();
}
outputFile = f;
} else if (v != null) {
val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.videoContainerToExtension(v.container);
val f = File(moviesGrayjay, outputFileName);
val f = downloadRoot.createFile(v.container, outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying video.");
copy(v.filePath, f.absolutePath) { progress -> onProgress?.invoke(progress) };
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
copy(v.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
outputFile = f;
} else if (a != null) {
val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.audioContainerToExtension(a.container);
val f = File(musicGrayjay, outputFileName);
val f = downloadRoot.createFile(a.container, outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying audio.");
copy(a.filePath, f.absolutePath) { progress -> onProgress?.invoke(progress) };
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
copy(a.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
outputFile = f;
} else {
throw Exception("Cannot export when no audio or video source is set.");
@@ -179,10 +197,9 @@ class VideoExport {
}
}
private suspend fun copy(fromPath: String, toPath: String, bufferSize: Int = 8192, onProgress: ((Double) -> Unit)? = null) {
private suspend fun copy(fromPath: String, outputStream: OutputStream, bufferSize: Int = 8192, onProgress: ((Double) -> Unit)? = null) {
withContext(Dispatchers.IO) {
var inputStream: FileInputStream? = null
var outputStream: FileOutputStream? = null
try {
val srcFile = File(fromPath)
@@ -190,17 +207,7 @@ class VideoExport {
throw IOException("Source file not found.")
}
val dstFile = File(toPath)
val parentDir = dstFile.parentFile ?: throw IOException("Non existent parent dir.")
if (!parentDir.exists()) {
if (!parentDir.mkdirs()) {
throw IOException("Failed to create destination directory.")
}
}
inputStream = FileInputStream(srcFile)
outputStream = FileOutputStream(dstFile)
val buffer = ByteArray(bufferSize)
val totalBytes = srcFile.length()
@@ -221,7 +228,6 @@ class VideoExport {
throw IOException("Error occurred while copying file: ${e.message}", e)
} finally {
inputStream?.close()
outputStream?.close()
}
}
}
@@ -0,0 +1,8 @@
package com.futo.platformplayer.encryption
class GEncryptionProvider {
companion object {
val instance: GEncryptionProviderV1 = GEncryptionProviderV1.instance;
val version = 1;
}
}
@@ -8,9 +8,8 @@ import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
class EncryptionProvider {
class GEncryptionProviderV0 {
private val _keyStore: KeyStore;
private val secretKey: Key? get() = _keyStore.getKey(KEY_ALIAS, null);
@@ -25,45 +24,43 @@ class EncryptionProvider {
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(false)
.build());
keyGenerator.generateKey();
}
}
fun encrypt(decrypted: String, password: String? = null): String {
val encodedBytes = encrypt(decrypted.toByteArray(), password);
fun encrypt(decrypted: String): String {
val encodedBytes = encrypt(decrypted.toByteArray());
val encrypted = Base64.encodeToString(encodedBytes, Base64.DEFAULT);
return encrypted;
}
fun encrypt(decrypted: ByteArray, password: String? = null): ByteArray {
fun encrypt(decrypted: ByteArray): ByteArray {
val c: Cipher = Cipher.getInstance(AES_MODE);
val keyToUse = if(password == null) secretKey else SecretKeySpec(password.toByteArray(), "AES");
c.init(Cipher.ENCRYPT_MODE, keyToUse, GCMParameterSpec(128, FIXED_IV));
c.init(Cipher.ENCRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
val encodedBytes: ByteArray = c.doFinal(decrypted);
return encodedBytes;
}
fun decrypt(encrypted: String, password: String? = null): String {
fun decrypt(encrypted: String): String {
val c = Cipher.getInstance(AES_MODE);
val keyToUse = if(password == null) secretKey else SecretKeySpec(password.toByteArray(), "AES");
c.init(Cipher.DECRYPT_MODE, keyToUse, GCMParameterSpec(128, FIXED_IV));
c.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
val decrypted = String(c.doFinal(Base64.decode(encrypted, Base64.DEFAULT)));
return decrypted;
}
fun decrypt(encrypted: ByteArray, password: String? = null): ByteArray {
fun decrypt(encrypted: ByteArray): ByteArray {
val c = Cipher.getInstance(AES_MODE);
val keyToUse = if(password == null) secretKey else SecretKeySpec(password.toByteArray(), "AES");
c.init(Cipher.DECRYPT_MODE, keyToUse, GCMParameterSpec(128, FIXED_IV));
c.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
return c.doFinal(encrypted);
}
companion object {
val instance: EncryptionProvider = EncryptionProvider();
val instance: GEncryptionProviderV0 = GEncryptionProviderV0();
private val FIXED_IV = byteArrayOf(12, 43, 127, 2, 99, 22, 6, 78, 24, 53, 8, 101);
private const val AndroidKeyStore = "AndroidKeyStore";
private const val KEY_ALIAS = "FUTOMedia_Key";
private const val AES_MODE = "AES/GCM/NoPadding";
private val TAG = "EncryptionProvider";
private const val TAG_LENGTH = 128
private val TAG = "GEncryptionProviderV0";
}
}
@@ -0,0 +1,76 @@
package com.futo.platformplayer.encryption
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.Key
import java.security.KeyStore
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.spec.GCMParameterSpec
class GEncryptionProviderV1 {
private val _keyStore: KeyStore;
private val secretKey: Key? get() = _keyStore.getKey(KEY_ALIAS, null);
constructor() {
_keyStore = KeyStore.getInstance(AndroidKeyStore);
_keyStore.load(null);
if (!_keyStore.containsAlias(KEY_ALIAS)) {
val keyGenerator: KeyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, AndroidKeyStore)
keyGenerator.init(KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(false)
.build());
keyGenerator.generateKey();
}
}
fun encrypt(decrypted: String): String {
val encrypted = encrypt(decrypted.toByteArray());
val encoded = Base64.encodeToString(encrypted, Base64.DEFAULT);
return encoded;
}
fun encrypt(decrypted: ByteArray): ByteArray {
val ivBytes = generateIv()
val c: Cipher = Cipher.getInstance(AES_MODE);
c.init(Cipher.ENCRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, ivBytes));
val encodedBytes: ByteArray = c.doFinal(decrypted);
return ivBytes + encodedBytes;
}
fun decrypt(data: String): String {
val bytes = Base64.decode(data, Base64.DEFAULT)
return String(decrypt(bytes));
}
fun decrypt(bytes: ByteArray): ByteArray {
val encrypted = bytes.sliceArray(IntRange(IV_SIZE, bytes.size - 1))
val ivBytes = bytes.sliceArray(IntRange(0, IV_SIZE - 1))
val c = Cipher.getInstance(AES_MODE);
c.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, ivBytes));
return c.doFinal(encrypted);
}
private fun generateIv(): ByteArray {
val r = SecureRandom()
val ivBytes = ByteArray(IV_SIZE)
r.nextBytes(ivBytes)
return ivBytes
}
companion object {
val instance: GEncryptionProviderV1 = GEncryptionProviderV1();
private const val AndroidKeyStore = "AndroidKeyStore";
private const val KEY_ALIAS = "FUTOMedia_Key";
private const val AES_MODE = "AES/GCM/NoPadding";
private const val IV_SIZE = 12;
private const val TAG_LENGTH = 128
private val TAG = "GEncryptionProviderV1";
}
}
@@ -0,0 +1,8 @@
package com.futo.platformplayer.encryption
class GPasswordEncryptionProvider {
companion object {
val version = 1;
val instance = GPasswordEncryptionProviderV1.instance;
}
}
@@ -0,0 +1,45 @@
package com.futo.platformplayer.encryption
import android.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
class GPasswordEncryptionProviderV0 {
private val _key: SecretKeySpec;
constructor(password: String) {
_key = SecretKeySpec(password.toByteArray(), "AES");
}
fun encrypt(decrypted: String): String {
val encodedBytes = encrypt(decrypted.toByteArray());
val encrypted = Base64.encodeToString(encodedBytes, Base64.DEFAULT);
return encrypted;
}
fun encrypt(decrypted: ByteArray): ByteArray {
val c: Cipher = Cipher.getInstance(AES_MODE);
c.init(Cipher.ENCRYPT_MODE, _key, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
val encodedBytes: ByteArray = c.doFinal(decrypted);
return encodedBytes;
}
fun decrypt(encrypted: String): String {
val c = Cipher.getInstance(AES_MODE);
c.init(Cipher.DECRYPT_MODE, _key, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
val decrypted = String(c.doFinal(Base64.decode(encrypted, Base64.DEFAULT)));
return decrypted;
}
fun decrypt(encrypted: ByteArray): ByteArray {
val c = Cipher.getInstance(AES_MODE);
c.init(Cipher.DECRYPT_MODE, _key, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
return c.doFinal(encrypted);
}
companion object {
private val FIXED_IV = byteArrayOf(12, 43, 127, 2, 99, 22, 6, 78, 24, 53, 8, 101);
private const val TAG_LENGTH = 128
private const val AES_MODE = "AES/GCM/NoPadding";
private val TAG = "GPasswordEncryptionProviderV0";
}
}
@@ -0,0 +1,75 @@
package com.futo.platformplayer.encryption
import android.util.Base64
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
class GPasswordEncryptionProviderV1 {
fun encrypt(decrypted: String, password: String): String {
val encrypted = encrypt(decrypted.toByteArray(), password);
val encoded = Base64.encodeToString(encrypted, Base64.DEFAULT);
return encoded;
}
fun encrypt(decrypted: ByteArray, password: String): ByteArray {
val saltBytes = generateSalt()
val ivBytes = generateIv()
val c: Cipher = Cipher.getInstance(AES_MODE);
val key = deriveKeyFromPassword(password, saltBytes)
c.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(TAG_LENGTH, ivBytes));
val encodedBytes: ByteArray = c.doFinal(decrypted);
return saltBytes + ivBytes + encodedBytes;
}
fun decrypt(data: String, password: String): String {
val bytes = Base64.decode(data, Base64.DEFAULT)
return String(decrypt(bytes, password));
}
fun decrypt(bytes: ByteArray, password: String): ByteArray {
val encrypted = bytes.sliceArray(IntRange(SALT_SIZE + IV_SIZE, bytes.size - 1))
val ivBytes = bytes.sliceArray(IntRange(SALT_SIZE, SALT_SIZE + IV_SIZE - 1))
val saltBytes = bytes.sliceArray(IntRange(0, SALT_SIZE - 1))
val key = deriveKeyFromPassword(password, saltBytes)
val c = Cipher.getInstance(AES_MODE);
c.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_LENGTH, ivBytes));
return c.doFinal(encrypted);
}
private fun deriveKeyFromPassword(password: String, salt: ByteArray): SecretKeySpec {
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
val spec = PBEKeySpec(password.toCharArray(), salt, ITERATION_COUNT, KEY_LENGTH)
val tmp = factory.generateSecret(spec)
return SecretKeySpec(tmp.encoded, "AES")
}
private fun generateSalt(): ByteArray {
val random = SecureRandom()
val salt = ByteArray(SALT_SIZE)
random.nextBytes(salt)
return salt
}
private fun generateIv(): ByteArray {
val r = SecureRandom()
val ivBytes = ByteArray(IV_SIZE)
r.nextBytes(ivBytes)
return ivBytes
}
companion object {
val instance = GPasswordEncryptionProviderV1();
private const val AES_MODE = "AES/GCM/NoPadding";
private const val IV_SIZE = 12
private const val SALT_SIZE = 16
private const val ITERATION_COUNT = 2 * 65536
private const val KEY_LENGTH = 256
private const val TAG_LENGTH = 128
private val TAG = "GPasswordEncryptionProviderV1";
}
}
@@ -1,21 +1,20 @@
package com.futo.platformplayer.fragment.channel.tab
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.views.SupportView
class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
private var _buttonStore: BigButton? = null;
private var _supportView: SupportView? = null
private var _textMonetization: TextView? = null
private var _lastChannel: IPlatformChannel? = null;
private var _lastPolycentricProfile: PolycentricProfile? = null;
@@ -24,51 +23,39 @@ class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_channel_monetization, container, false);
_buttonStore = view.findViewById(R.id.button_store);
_buttonStore?.onClick?.subscribe {
_lastPolycentricProfile?.systemState?.store?.let {
try {
val uri = Uri.parse(it);
val intent = Intent(Intent.ACTION_VIEW)
intent.data = uri
startActivity(intent)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to open URI: '${it}'.", e);
}
}
};
_supportView = view.findViewById(R.id.support);
_textMonetization = view.findViewById(R.id.text_monetization);
_lastChannel?.also {
setChannel(it);
};
_lastPolycentricProfile?.also {
setPolycentricProfile(it, animate = false);
}
_supportView?.visibility = View.GONE;
_textMonetization?.visibility = View.GONE;
setPolycentricProfile(_lastPolycentricProfile, animate = false);
return view;
}
override fun onDestroyView() {
super.onDestroyView();
_buttonStore = null;
_supportView = null;
_textMonetization = null;
}
override fun setChannel(channel: IPlatformChannel) {
_lastChannel = channel;
_buttonStore?.visibility = View.GONE;
}
fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) {
_lastPolycentricProfile = polycentricProfile;
if (polycentricProfile == null) {
return;
}
if (polycentricProfile.systemState.store.isNotEmpty()) {
_buttonStore?.visibility = View.VISIBLE;
_lastPolycentricProfile = polycentricProfile
if (polycentricProfile != null) {
_supportView?.setPolycentricProfile(polycentricProfile, animate)
_supportView?.visibility = View.VISIBLE
_textMonetization?.visibility = View.GONE
} else {
_supportView?.setPolycentricProfile(null, animate)
_supportView?.visibility = View.GONE
_textMonetization?.visibility = View.VISIBLE
}
}
@@ -225,7 +225,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
if (faqIndex != -1) {
val button = buttons[faqIndex]
buttons.removeAt(faqIndex)
buttons.add(1, button)
buttons.add(if (buttons.size == 1) 1 else 0, button)
}
for (data in buttons) {
@@ -252,8 +252,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
val defs = currentButtonDefinitions?.toMutableList() ?: return
val metrics = StateApp.instance.displayMetrics ?: resources.displayMetrics;
_buttonsVisible = floor(metrics.widthPixels.toDouble() / 65.dp(resources).toDouble()).roundToInt();
if (_buttonsVisible - 2 >= defs.size) {
updateBottomMenuButtons(defs.slice(IntRange(0, defs.size - 1)).toMutableList(), false);
if (_buttonsVisible - 1 >= defs.size) {
updateBottomMenuButtons(defs.toMutableList(), false);
} else {
updateBottomMenuButtons(defs.slice(IntRange(0, _buttonsVisible - 2)).toMutableList(), true);
updateMoreButtons(defs.slice(IntRange(_buttonsVisible - 1, defs.size - 1)).toMutableList());
@@ -91,7 +91,7 @@ class BuyFragment : MainFragment() {
val price = prices[currency.id]!!;
val priceDecimal = (price.toDouble() / 100);
withContext(Dispatchers.Main) {
_buttonBuyText.text = currency.symbol + String.format("%.2f", priceDecimal);
_buttonBuyText.text = currency.symbol + String.format("%.2f", priceDecimal) + context.getString(R.string.plus_tax);
}
}
}
@@ -386,14 +386,18 @@ class ChannelFragment : MainFragment() {
});
});
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url);
if (plugin != null && plugin.capabilities.hasSearchChannelContents) {
buttons.add(Pair(R.drawable.ic_search) {
_fragment.navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO, channel.url));
});
}
_fragment.lifecycleScope.launch(Dispatchers.IO) {
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url);
withContext(Dispatchers.Main) {
if (plugin != null && plugin.capabilities.hasSearchChannelContents) {
buttons.add(Pair(R.drawable.ic_search) {
_fragment.navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO, channel.url));
});
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons);
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons);
}
}
}
_buttonSubscribe.setSubscribeChannel(channel);
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
@@ -63,7 +63,7 @@ class ContentSearchResultsFragment : MainFragment() {
}
fun setPreviewsEnabled(previewsEnabled: Boolean) {
_view?.setPreviewsEnabled(previewsEnabled);
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.search.previewFeedItems);
}
@SuppressLint("ViewConstructor")
@@ -93,6 +93,8 @@ class ContentSearchResultsFragment : MainFragment() {
Logger.w(TAG, "Failed to load results.", it);
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
}
setPreviewsEnabled(Settings.instance.search.previewFeedItems);
}
override fun cleanup() {
@@ -78,7 +78,7 @@ class HomeFragment : MainFragment() {
}
fun setPreviewsEnabled(previewsEnabled: Boolean) {
_view?.setPreviewsEnabled(previewsEnabled);
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.home.previewFeedItems);
}
@SuppressLint("ViewConstructor")
@@ -122,6 +122,8 @@ class HomeFragment : MainFragment() {
setLoading(false);
};
};
setPreviewsEnabled(Settings.instance.home.previewFeedItems);
}
fun onShown() {
@@ -150,7 +152,7 @@ class HomeFragment : MainFragment() {
}
override fun filterResults(contents: List<IPlatformContent>): List<IPlatformContent> {
return contents.filter { it !is IPlatformVideo || !StateMeta.instance.isVideoHidden(it.url) };
return contents.filter { !StateMeta.instance.isVideoHidden(it.url) && !StateMeta.instance.isCreatorHidden(it.author.url) };
}
private fun loadResults() {
@@ -63,6 +63,7 @@ class ImportSubscriptionsFragment : MainFragment() {
private var _textSelectDeselectAll: TextView;
private var _textNothingToImport: TextView;
private var _textCounter: TextView;
private var _textLoadMore: TextView;
private var _adapterView: AnyAdapterView<SelectableIPlatformChannel, ImportSubscriptionViewHolder>;
private var _links: List<String> = listOf();
private val _items: ArrayList<SelectableIPlatformChannel> = arrayListOf();
@@ -79,6 +80,7 @@ class ImportSubscriptionsFragment : MainFragment() {
_textNothingToImport = findViewById(R.id.nothing_to_import);
_textSelectDeselectAll = findViewById(R.id.text_select_deselect_all);
_textCounter = findViewById(R.id.text_select_counter);
_textLoadMore = findViewById(R.id.text_load_more);
_spinner = findViewById(R.id.channel_loader);
_adapterView = findViewById<RecyclerView>(R.id.recycler_import).asAny( _items) {
@@ -120,6 +122,19 @@ class ImportSubscriptionsFragment : MainFragment() {
//UIDialogs.showDataRetryDialog(layoutInflater, { load(); });
loadNext();
};
_textLoadMore.setOnClickListener {
if (!_limitToastShown) {
return@setOnClickListener;
}
_textLoadMore.visibility = View.GONE;
_limitToastShown = false;
_counter = 0;
load();
};
_textLoadMore.visibility = View.GONE;
}
fun cleanup() {
@@ -165,7 +180,8 @@ class ImportSubscriptionsFragment : MainFragment() {
if (_counter >= MAXIMUM_BATCH_SIZE) {
if (!_limitToastShown) {
_limitToastShown = true;
UIDialogs.toast(context, "Stopped after {requestCount} to avoid rate limit, re-enter to import rest".replace("{requestCount}", MAXIMUM_BATCH_SIZE.toString()));
_textLoadMore.visibility = View.VISIBLE;
UIDialogs.toast(context, context.getString(R.string.stopped_after_requestcount_to_avoid_rate_limit_click_load_more_to_load_more).replace("{requestCount}", MAXIMUM_BATCH_SIZE.toString()));
}
setLoading(false);
@@ -210,7 +226,7 @@ class ImportSubscriptionsFragment : MainFragment() {
companion object {
val TAG = "ImportSubscriptionsFragment";
private const val MAXIMUM_BATCH_SIZE = 90;
private const val MAXIMUM_BATCH_SIZE = 100;
fun newInstance() = ImportSubscriptionsFragment().apply {}
}
}
@@ -81,7 +81,7 @@ class SubscriptionsFeedFragment : MainFragment() {
}
fun setPreviewsEnabled(previewsEnabled: Boolean) {
_view?.setPreviewsEnabled(previewsEnabled);
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.subscriptions.previewFeedItems);
}
@SuppressLint("ViewConstructor")
@@ -108,6 +108,8 @@ class SubscriptionsFeedFragment : MainFragment() {
};
initializeToolbarContent();
setPreviewsEnabled(Settings.instance.subscriptions.previewFeedItems);
}
fun onShown() {
@@ -119,7 +121,7 @@ class SubscriptionsFeedFragment : MainFragment() {
recyclerData.lastLoad.getNowDiffSeconds() > 60 ) {
recyclerData.lastLoad = OffsetDateTime.now();
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5 && Settings.instance.subscriptions.fetchOnTabOpen)
loadResults(false);
else if(recyclerData.results.size == 0)
loadCache();
@@ -191,7 +193,15 @@ class SubscriptionsFeedFragment : MainFragment() {
return@TaskHandler resp;
})
.success { loadedResult(it); }
.success {
if(!Settings.instance.subscriptions.alwaysReloadFromCache)
loadedResult(it);
else {
finishRefreshLayoutLoader();
setLoading(false);
loadCache();
}
} //TODO: Remove
.exception<RateLimitException> {
fragment.lifecycleScope.launch(Dispatchers.IO) {
val subs = StateSubscriptions.instance.getSubscriptions();
@@ -254,7 +264,10 @@ class SubscriptionsFeedFragment : MainFragment() {
else null;
_filterSettings.save();
};
loadResults(false)
if(Settings.instance.subscriptions.fetchOnTabOpen) //TODO: Do this different, temporary workaround
loadResults(false);
else
loadCache();
}
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
@@ -282,6 +295,7 @@ class SubscriptionsFeedFragment : MainFragment() {
loadResults(true);
}
private fun loadCache() {
Logger.i(TAG, "Subscriptions load cache");
val cachePager = ChannelContentCache.instance.getSubscriptionCachePager();
@@ -301,6 +315,10 @@ class SubscriptionsFeedFragment : MainFragment() {
_taskGetPager.run(withRefetch);
}
override fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>) {
super.onRestoreCachedData(cachedData);
setTextCentered(if (cachedData.results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
}
private fun loadedResult(pager: IPager<IPlatformContent>) {
Logger.i(TAG, "Subscriptions new pager loaded (${pager.getResults().size})");
@@ -105,6 +105,8 @@ class VideoDetailFragment : MainFragment {
return;
}
if(Settings.instance.other.bypassRotationPrevention && orientation == OrientationManager.Orientation.PORTRAIT)
changeOrientation(OrientationManager.Orientation.PORTRAIT);
if(lastOrientation == newOrientation)
return;
@@ -36,6 +36,8 @@ import com.futo.platformplayer.api.media.LiveChatManager
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
import com.futo.platformplayer.api.media.models.chapters.ChapterType
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
@@ -49,6 +51,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.casting.CastConnectionState
@@ -70,6 +73,7 @@ import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.states.*
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.views.MonetizationView
import com.futo.platformplayer.views.behavior.TouchInterceptFrameLayout
import com.futo.platformplayer.views.casting.CastView
import com.futo.platformplayer.views.comments.AddCommentView
@@ -79,6 +83,7 @@ import com.futo.platformplayer.views.overlays.DescriptionOverlay
import com.futo.platformplayer.views.overlays.LiveChatOverlay
import com.futo.platformplayer.views.overlays.QueueEditorOverlay
import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.platformplayer.views.overlays.SupportOverlay
import com.futo.platformplayer.views.overlays.slideup.*
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
import com.futo.platformplayer.views.pills.RoundButton
@@ -191,6 +196,7 @@ class VideoDetailView : ConstraintLayout {
private val _container_content_replies: RepliesOverlay;
private val _container_content_description: DescriptionOverlay;
private val _container_content_liveChat: LiveChatOverlay;
private val _container_content_support: SupportOverlay;
private var _container_content_current: View;
@@ -200,9 +206,7 @@ class VideoDetailView : ConstraintLayout {
private val _imageDislikeIcon: ImageView;
private val _imageLikeIcon: ImageView;
private val _buttonSupport: LinearLayout;
private val _buttonStore: LinearLayout;
private val _layoutMonetization: LinearLayout;
private val _monetization: MonetizationView;
private val _buttonMore: RoundButton;
@@ -292,6 +296,7 @@ class VideoDetailView : ConstraintLayout {
_container_content_replies = findViewById(R.id.videodetail_container_replies);
_container_content_description = findViewById(R.id.videodetail_container_description);
_container_content_liveChat = findViewById(R.id.videodetail_container_livechat);
_container_content_support = findViewById(R.id.videodetail_container_support)
_textComments = findViewById(R.id.text_comments);
_addCommentView = findViewById(R.id.add_comment_view);
@@ -310,11 +315,7 @@ class VideoDetailView : ConstraintLayout {
_imageLikeIcon = findViewById(R.id.image_like_icon);
_imageDislikeIcon = findViewById(R.id.image_dislike_icon);
_buttonSupport = findViewById(R.id.button_support);
_buttonStore = findViewById(R.id.button_store);
_layoutMonetization = findViewById(R.id.layout_monetization);
_layoutMonetization.visibility = View.GONE;
_monetization = findViewById(R.id.monetization);
_player.attachPlayer();
@@ -327,16 +328,12 @@ class VideoDetailView : ConstraintLayout {
fragment.navigate<VideoDetailFragment>(it.targetUrl);
};
_buttonSupport.setOnClickListener {
val author = video?.author ?: _searchVideo?.author;
author?.let { fragment.navigate<ChannelFragment>(it).selectTab(2); };
fragment.lifecycleScope.launch {
delay(100);
fragment.minimizeVideoDetail();
};
_monetization.onSupportTap.subscribe {
_container_content_support.setPolycentricProfile(_polycentricProfile?.profile, false);
switchContentView(_container_content_support);
};
_buttonStore.setOnClickListener {
_monetization.onStoreTap.subscribe {
_polycentricProfile?.profile?.systemState?.store?.let {
try {
val uri = Uri.parse(it);
@@ -349,6 +346,13 @@ class VideoDetailView : ConstraintLayout {
}
};
_player.attachPlayer();
_container_content_liveChat.onRaidNow.subscribe {
StatePlayer.instance.clearQueue();
fragment.navigate<VideoDetailFragment>(it.targetUrl);
};
StateApp.instance.preventPictureInPicture.subscribe(this) {
Logger.i(TAG, "StateApp.instance.preventPictureInPicture.subscribe preventPictureInPicture = true");
preventPictureInPicture = true;
@@ -494,8 +498,14 @@ class VideoDetailView : ConstraintLayout {
updatePillButtonVisibilities();
StateCasting.instance.onActiveDevicePlayChanged.subscribe(this) {
if (StateCasting.instance.activeDevice != null) {
val activeDevice = StateCasting.instance.activeDevice;
if (activeDevice != null) {
handlePlayChanged(it);
val v = video;
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
nextVideo();
}
}
};
@@ -539,6 +549,7 @@ class VideoDetailView : ConstraintLayout {
_container_content_liveChat.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_queue.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_replies.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_support.onClose.subscribe { switchContentView(_container_content_main); };
_description_viewMore.setOnClickListener {
switchContentView(_container_content_description);
@@ -806,7 +817,7 @@ class VideoDetailView : ConstraintLayout {
when (Settings.instance.playback.backgroundPlay) {
0 -> handlePause();
1 -> {
if(!(video?.isLive ?: false))
if(!(video?.isLive ?: false) && Settings.instance.playback.backgroundSwitchToAudio)
_player.switchToAudioMode();
StatePlayer.instance.startOrUpdateMediaSession(context, video);
}
@@ -841,6 +852,7 @@ class VideoDetailView : ConstraintLayout {
_container_content_replies.cleanup();
_container_content_queue.cleanup();
_container_content_description.cleanup();
_container_content_support.cleanup();
StateCasting.instance.onActiveDevicePlayChanged.remove(this);
StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
@@ -1042,6 +1054,7 @@ class VideoDetailView : ConstraintLayout {
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to get chapters", ex);
_player.setChapters(null);
/*withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to get chapters\n" + ex.message);
@@ -1088,6 +1101,14 @@ class VideoDetailView : ConstraintLayout {
(_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_2).toInt(), 0, 0);
}
video.author.let {
if(it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty())
_monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl);
else
_monetization.setPlatformMembership(null, null);
}
_minimize_title.text = video.name;
_minimize_meta.text = video.author.name;
@@ -1803,6 +1824,7 @@ class VideoDetailView : ConstraintLayout {
_isCasting = isCasting;
if(isCasting) {
setFullscreen(false);
_player.stop();
_player.hideControls(false);
_cast.visibility = View.VISIBLE;
@@ -2090,12 +2112,7 @@ class VideoDetailView : ConstraintLayout {
_creatorThumbnail.setHarborAvailable(profile != null, animate);
}
if (profile != null) {
_channelName.text = cachedPolycentricProfile.profile.systemState.username;
_layoutMonetization.visibility = View.VISIBLE;
} else {
_layoutMonetization.visibility = View.GONE;
}
_monetization.setPolycentricProfile(cachedPolycentricProfile, animate);
}
fun setProgressBarOverlayed(isOverlayed: Boolean?) {
@@ -1,5 +1,7 @@
package com.futo.platformplayer.images;
import android.util.Log;
import androidx.annotation.NonNull;
import com.bumptech.glide.Priority;
@@ -125,6 +125,14 @@ class Subscription {
lastVideo = OffsetDateTime.MIN;
lastVideoUpdate = OffsetDateTime.now();
}
ResultCapabilities.TYPE_SUBSCRIPTIONS -> {
uploadInterval = interval;
if(mostRecent != null)
lastVideo = mostRecent;
else if(lastVideo.year > 3000)
lastVideo = OffsetDateTime.MIN;
lastVideoUpdate = OffsetDateTime.now();
}
ResultCapabilities.TYPE_STREAMS -> {
uploadStreamInterval = interval;
if(mostRecent != null)
@@ -39,7 +39,12 @@ class PolycentricCache {
ContentType.USERNAME.value,
ContentType.DESCRIPTION.value,
ContentType.STORE.value,
ContentType.SERVER.value
ContentType.SERVER.value,
ContentType.STORE_DATA.value,
ContentType.PROMOTION_BANNER.value,
ContentType.PROMOTION.value,
ContentType.MEMBERSHIP_URLS.value,
ContentType.DONATION_DESTINATIONS.value
)
).eventsList.map { e -> SignedEvent.fromProto(e) };
@@ -107,7 +107,7 @@ class ExportingService : Service() {
{
try{
notifyExport(currentExport);
doExport(currentExport);
doExport(applicationContext, currentExport);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed export [${currentExport.videoLocal.name}]: ${ex.message}", ex);
@@ -125,13 +125,13 @@ class ExportingService : Service() {
stopService(this);
}
private suspend fun doExport(export: VideoExport) {
private suspend fun doExport(context: Context, export: VideoExport) {
Logger.i(TAG, "Exporting [${export.videoLocal.name}] started");
export.changeState(VideoExport.State.EXPORTING);
var lastNotifyTime: Long = 0L;
val file = export.export { progress ->
val file = export.export(context) { progress ->
export.progress = progress;
val currentTime = System.currentTimeMillis();
@@ -146,7 +146,7 @@ class ExportingService : Service() {
notifyExport(export);
withContext(Dispatchers.Main) {
StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "File exported", "Exported [${file.path}]", AnnouncementType.SESSION, time = null, category = "download", actionButton = "Open") {
StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "File exported", "Exported [${file.uri}]", AnnouncementType.SESSION, time = null, category = "download", actionButton = "Open") {
file.share(this@ExportingService);
};
}
@@ -10,6 +10,7 @@ import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.AudioManager.OnAudioFocusChangeListener
import android.media.MediaMetadata
import android.os.Build
import android.os.IBinder
import android.os.SystemClock
import android.support.v4.media.MediaMetadataCompat
@@ -278,7 +279,13 @@ class MediaPlaybackService : Service() {
Logger.i(TAG, "Updating notification bitmap=${if (bitmap != null) "yes" else "no."} channelId=${channel.id} icon=${icon} video=${video?.name ?: ""} playWhenReady=${playWhenReady} session.sessionToken=${session.sessionToken}");
startForeground(MEDIA_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// For API 29 and above
startForeground(MEDIA_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
} else {
// For API 28 and below
startForeground(MEDIA_NOTIF_ID, notif);
}
_notif_last_bitmap = bitmap;
}
@@ -111,17 +111,13 @@ class StateApp {
return null;
}
fun changeExternalDownloadDirectory(context: IWithResultLauncher, onChanged: ((DocumentFile?)->Unit)? = null) {
scopeOrNull?.launch(Dispatchers.Main) {
UIDialogs.toast("External download directory not yet used by export (WIP)");
};
if(context is Context)
requestDirectoryAccess(context, "Download Exports", "This directory is used to export downloads to for external usage.", null) {
if(it != null)
context.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION));
if(it != null && isValidStorageUri(context, it)) {
Logger.i(TAG, "Changed external download directory: ${it}");
Settings.instance.storage.storage_general = it.toString();
Settings.instance.storage.storage_download = it.toString();
Settings.instance.save();
onChanged?.invoke(getExternalDownloadDirectory(context));
@@ -239,6 +235,25 @@ class StateApp {
return state;
}
fun requestFileReadAccess(activity: IWithResultLauncher, path: Uri?, handle: (DocumentFile?)->Unit) {
if(activity is Context) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT);
if(path != null)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION);
activity.launchForResult(intent, 98) {
if(it.resultCode == Activity.RESULT_OK) {
val uri = it.data?.data;
if(uri != null)
handle(DocumentFile.fromSingleUri(activity, uri));
}
else
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
};
}
}
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit)
{
if(activity is Context)
@@ -335,6 +350,7 @@ class StateApp {
}
fun mainAppStarting(context: Context) {
Logger.i(TAG, "MainApp Starting");
initializeFiles(true);
val logFile = File(context.filesDir, "log.txt");
@@ -353,14 +369,18 @@ class StateApp {
Logger.setLogConsumers(listOf(AndroidLogConsumer()));
}
StatePayment.instance.initialize();
Logger.i(TAG, "MainApp Starting: Initializing [Polycentric]");
StatePolycentric.instance.load(context);
Logger.i(TAG, "MainApp Starting: Initializing [Saved]");
StateSaved.instance.load();
Logger.i(TAG, "MainApp Starting: Initializing [Connectivity]");
displayMetrics = context.resources.displayMetrics;
ensureConnectivityManager(context);
Logger.i(TAG, "MainApp Starting: Initializing [Telemetry]");
if (!BuildConfig.DEBUG) {
StateTelemetry.instance.initialize();
StateTelemetry.instance.upload();
@@ -381,11 +401,12 @@ class StateApp {
}
}
fun mainAppStarted(context: Context) {
Logger.i(TAG, "App started");
Logger.i(TAG, "MainApp Started");
//Start loading cache
instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "MainApp Started: Initializing [ChannelContentCache]");
val time = measureTimeMillis {
ChannelContentCache.instance;
}
@@ -400,10 +421,12 @@ class StateApp {
if(SettingsDev.instance.developerMode && SettingsDev.instance.devServerSettings.devServerOnBoot)
StateDeveloper.instance.runServer();
Logger.i(TAG, "MainApp Started: Check [Migration (Subscriptions)]");
if(StateSubscriptions.instance.shouldMigrate())
StateSubscriptions.instance.tryMigrateIfNecessary();
if(Settings.instance.downloads.shouldDownload()) {
Logger.i(TAG, "MainApp Started: Check [Downloads]");
StateDownloads.instance.checkForOutdatedPlaylists();
StateDownloads.instance.getDownloadPlaylists();
@@ -411,8 +434,10 @@ class StateApp {
DownloadService.getOrCreateService(context);
}
Logger.i(TAG, "MainApp Started: Check [Exports]");
StateDownloads.instance.checkForExportTodos();
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]");
val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled();
val shouldDownload = Settings.instance.autoUpdate.shouldDownload();
val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1;
@@ -436,6 +461,7 @@ class StateApp {
}
}
Logger.i(TAG, "MainApp Started: Initialize [Noisy]");
_receiverBecomingNoisy?.let {
_receiverBecomingNoisy = null;
context.unregisterReceiver(it);
@@ -444,6 +470,7 @@ class StateApp {
context.registerReceiver(_receiverBecomingNoisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
//Migration
Logger.i(TAG, "MainApp Started: Check [Migrations]");
migrateStores(context, listOf(
StateSubscriptions.instance.toMigrateCheck(),
StatePlaylists.instance.toMigrateCheck()
@@ -451,6 +478,7 @@ class StateApp {
if(Settings.instance.subscriptions.fetchOnAppBoot) {
scope.launch(Dispatchers.IO) {
Logger.i(TAG, "MainApp Started: Fetch [Subscriptions]");
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true };
@@ -465,9 +493,11 @@ class StateApp {
}
}
Logger.i(TAG, "MainApp Started: Initialize [BackgroundWork]");
val interval = Settings.instance.subscriptions.getSubscriptionsBackgroundIntervalMinutes();
scheduleBackgroundWork(context, interval != 0, interval);
Logger.i(TAG, "MainApp Started: Initialize [AutoBackup]");
if(!Settings.instance.backup.didAskAutoBackup && !Settings.instance.backup.shouldAutomaticBackup()) {
StateAnnouncement.instance.registerAnnouncement("backup", "Set Automatic Backup", "Configure daily backups of your data to restore in case of catastrophic failure.", AnnouncementType.SESSION, null, null, "Configure", {
if(context is IWithResultLauncher && !Settings.instance.storage.isStorageMainValid(context)) {
@@ -495,6 +525,7 @@ class StateApp {
}
}
Logger.i(TAG, "MainApp Started: Initialize [Announcements]");
instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
StateAnnouncement.instance.loadAnnouncements();
@@ -513,7 +544,7 @@ class StateApp {
}
StateAnnouncement.instance.registerDidYouKnow();
Logger.i(TAG, "MainApp Started: Finished");
}
fun mainAppStartedWithExternalFiles(context: Context) {
if(!Settings.instance.didFirstStart) {
@@ -712,6 +743,34 @@ class StateApp {
}
}
fun getLocaleContext(baseContext: Context?): Context? {
val locale = getLocaleSetting(baseContext);
try {
if (baseContext != null && locale != null) {
val config = baseContext.resources.configuration;
config.setLocale(locale);
return baseContext.createConfigurationContext(config);
}
return baseContext;
}
catch (ex: Throwable) {
Logger.e(TAG, "Failed to load locale", ex);
return baseContext;
}
}
fun getLocaleSetting(context: Context?): Locale? {
return context?.getSharedPreferences("language", Context.MODE_PRIVATE)
?.getString("language", null)
?.let { Locale(it) };
}
fun setLocaleSetting(context: Context?, locale: String?) {
context?.getSharedPreferences("language", Context.MODE_PRIVATE)
?.edit()
?.putString("language", locale)
?.apply();
}
companion object {
private val TAG = "StateApp";
@SuppressLint("StaticFieldLeak") //This is only alive while MainActivity is alive
@@ -1,28 +1,19 @@
package com.futo.platformplayer.states
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract.EXTRA_INITIAL_URI
import androidx.activity.ComponentActivity
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.copyTo
import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.encryption.EncryptionProvider
import com.futo.platformplayer.getInputStream
import com.futo.platformplayer.encryption.GPasswordEncryptionProvider
import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV0
import com.futo.platformplayer.getNowDiffHours
import com.futo.platformplayer.getOutputStream
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.readBytes
import com.futo.platformplayer.stores.FragmentedStorage
@@ -38,9 +29,8 @@ import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.io.InputStream
import java.lang.Exception
import java.time.OffsetDateTime
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
@@ -82,7 +72,7 @@ class StateBackup {
val pbytes = password.toByteArray();
if(pbytes.size < 4 || pbytes.size > 32)
throw IllegalStateException("Automatic backup passwords should atleast be 4 character and smaller than 32");
return password.padStart(32, '9');
return password;
}
fun hasAutomaticBackup(): Boolean {
val context = StateApp.instance.contextOrNull ?: return false;
@@ -106,8 +96,8 @@ class StateBackup {
val data = export();
val zip = data.asZip();
val encryptedZip = EncryptionProvider.instance.encrypt(zip, getAutomaticBackupPassword());
//Prepend some magic bytes to identify everything version 1 and up
val encryptedZip = byteArrayOf(0x11, 0x22, 0x33, 0x44, GPasswordEncryptionProvider.version.toByte()) + GPasswordEncryptionProvider.instance.encrypt(zip, getAutomaticBackupPassword());
if(!Settings.instance.storage.isStorageMainValid(context)) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
UIDialogs.toast("Missing permissions for auto-backup, please set the external general directory in settings");
@@ -151,8 +141,7 @@ class StateBackup {
throw IllegalStateException("Backup file does not exist");
val backupBytesEncrypted = backupFiles.first!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.first?.uri}]");
val backupBytes = EncryptionProvider.instance.decrypt(backupBytesEncrypted, getAutomaticBackupPassword(password));
importZipBytes(context, scope, backupBytes);
importEncryptedZipBytes(context, scope, backupBytesEncrypted, password);
Logger.i(TAG, "Finished AutoBackup restore");
}
catch (exSec: FileNotFoundException) {
@@ -179,13 +168,30 @@ class StateBackup {
throw ex;
val backupBytesEncrypted = backupFiles.second!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.second?.uri}]");
val backupBytes = EncryptionProvider.instance.decrypt(backupBytesEncrypted, getAutomaticBackupPassword(password));
importZipBytes(context, scope, backupBytes);
importEncryptedZipBytes(context, scope, backupBytesEncrypted, password);
Logger.i(TAG, "Finished AutoBackup restore");
}
}
}
private fun importEncryptedZipBytes(context: Context, scope: CoroutineScope, backupBytesEncrypted: ByteArray, password: String) {
val backupBytes: ByteArray;
//Check magic bytes indicating version 1 and up
if (backupBytesEncrypted[0] == 0x11.toByte() && backupBytesEncrypted[1] == 0x22.toByte() && backupBytesEncrypted[2] == 0x33.toByte() && backupBytesEncrypted[3] == 0x44.toByte()) {
val version = backupBytesEncrypted[4].toInt();
if (version != GPasswordEncryptionProvider.version) {
throw Exception("Invalid encryption version");
}
backupBytes = GPasswordEncryptionProvider.instance.decrypt(backupBytesEncrypted.sliceArray(IntRange(5, backupBytesEncrypted.size - 1)), getAutomaticBackupPassword(password))
} else {
//Else its a version 0
backupBytes = GPasswordEncryptionProviderV0(getAutomaticBackupPassword(password).padStart(32, '9')).decrypt(backupBytesEncrypted);
}
importZipBytes(context, scope, backupBytes);
}
fun startExternalBackup() {
val data = export();
val now = OffsetDateTime.now();
@@ -400,10 +400,7 @@ class StateDownloads {
_exporting.save(videoExport);
if(notify) {
if(videoSource == null)
UIDialogs.toast("Exporting [${shortName}]\nIn your music directory under Grayjay");
else
UIDialogs.toast("Exporting [${shortName}]\nIn your movies directory under Grayjay");
UIDialogs.toast("Exporting [${shortName}]");
StateApp.withContext { ExportingService.getOrCreateService(it) };
onExportsChanged.emit();
}
@@ -5,15 +5,39 @@ import com.futo.platformplayer.stores.StringHashSetStorage
class StateMeta {
val hiddenVideos = FragmentedStorage.get<StringHashSetStorage>("hiddenVideos");
val hiddenCreators = FragmentedStorage.get<StringHashSetStorage>("hiddenCreators");
fun isVideoHidden(videoUrl: String) : Boolean {
return hiddenVideos.contains(videoUrl);
}
fun addHiddenVideo(videoUrl: String) {
hiddenVideos.addDistinct(videoUrl);
hiddenVideos.save();
}
fun removeHiddenVideo(videoUrl: String) {
hiddenVideos.remove(videoUrl);
hiddenVideos.save();
}
fun removeAllHiddenVideos() {
hiddenVideos.removeAll();
hiddenVideos.save();
}
fun isCreatorHidden(creatorUrl: String): Boolean {
return hiddenCreators.contains(creatorUrl);
}
fun addHiddenCreator(creatorUrl: String) {
hiddenCreators.addDistinct(creatorUrl);
hiddenCreators.save();
}
fun removeHiddenCreator(creatorUrl: String) {
hiddenCreators.remove(creatorUrl);
hiddenCreators.save();
}
fun removeAllHiddenCreators() {
hiddenCreators.removeAll();
hiddenCreators.save();
}
companion object {
@@ -246,13 +246,12 @@ class StateSubscriptions {
fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null): Pair<IPager<IPlatformContent>, List<Throwable>> {
val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool);
if(onNewCacheHit != null)
algo.onNewCacheHit.subscribe(onNewCacheHit)
algo.onProgress.subscribe { progress, total ->
onProgress?.invoke(progress, total);
}
algo.onNewCacheHit.subscribe { sub, content ->
}
val usePolycentric = true;
val subUrls = getSubscriptions().parallelStream().map {
@@ -35,6 +35,11 @@ class StringHashSetStorage : FragmentedStorageFileJson() {
values.remove(obj);
}
}
fun removeAll() {
synchronized(values) {
values.clear();
}
}
fun set(vararg objs: String) {
synchronized(values) {
values.clear();
@@ -35,6 +35,8 @@ class SmartSubscriptionAlgorithm(
if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
return@flatMap listOf(SubscriptionTask(client, sub, it.key, ResultCapabilities.TYPE_MIXED));
else if(capabilities.hasType(ResultCapabilities.TYPE_SUBSCRIPTIONS))
return@flatMap listOf(SubscriptionTask(client, sub, it.key, ResultCapabilities.TYPE_SUBSCRIPTIONS))
else {
val types = listOf(
if(sub.shouldFetchVideos()) ResultCapabilities.TYPE_VIDEOS else null,
@@ -42,9 +44,13 @@ class SmartSubscriptionAlgorithm(
if(sub.shouldFetchPosts()) ResultCapabilities.TYPE_POSTS else null,
if(sub.shouldFetchLiveStreams()) ResultCapabilities.TYPE_LIVE else null
).filterNotNull().filter { capabilities.hasType(it) };
return@flatMap types.map {
SubscriptionTask(client, sub, url, it);
};
if(!types.isEmpty())
return@flatMap types.map {
SubscriptionTask(client, sub, url, it);
};
else
listOf(SubscriptionTask(client, sub, url, ResultCapabilities.TYPE_VIDEOS, true))
}
};
};
@@ -63,12 +69,17 @@ class SmartSubscriptionAlgorithm(
if(limit == null || limit <= 0)
finalTasks.addAll(clientTasks.second);
else {
val fetchTasks = clientTasks.second.take(limit);
val cacheTasks = clientTasks.second.drop(limit);
for(cacheTask in cacheTasks)
cacheTask.fromCache = true;
val fetchTasks = mutableListOf<SubscriptionTask>();
val cacheTasks = mutableListOf<SubscriptionTask>();
for(task in clientTasks.second) {
if(!task.fromCache && fetchTasks.size < limit)
fetchTasks.add(task);
else {
task.fromCache = true;
cacheTasks.add(task);
}
}
Logger.i(TAG, "Subscription Client Budget [${clientTasks.first.name}]: ${fetchTasks.size}/${limit}")
finalTasks.addAll(fetchTasks + cacheTasks);
@@ -2,6 +2,7 @@ package com.futo.platformplayer.subscription
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient
@@ -19,6 +20,7 @@ import com.futo.platformplayer.findNonRuntimeException
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions
import kotlinx.coroutines.CoroutineScope
@@ -55,7 +57,8 @@ abstract class SubscriptionsTaskFetchAlgorithm(
for(clientTasks in tasksGrouped) {
val clientTaskCount = clientTasks.value.filter { !it.fromCache }.size;
val clientCacheCount = clientTasks.value.size - clientTaskCount;
if(clientCacheCount > 0) {
val limit = clientTasks.key.getSubscriptionRateLimit();
if(clientCacheCount > 0 && clientTaskCount > 0 && limit != null && clientTaskCount >= limit && StateApp.instance.contextOrNull?.let { it is MainActivity && it.isFragmentActive<SubscriptionsFeedFragment>() } == true) {
UIDialogs.toast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels (rqs). (${clientCacheCount} cached)");
}
}
@@ -77,7 +80,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
if(result != null) {
if(result.pager != null)
taskResults.add(result);
else if(result.exception != null) {
if(result.exception != null) {
val ex = result.exception;
if(ex != null) {
val nonRuntimeEx = findNonRuntimeException(ex);
@@ -196,6 +199,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
Logger.i(StateSubscriptions.TAG, "Channel ${task.sub.channel.name} failed, substituting with cache");
pager = ChannelContentCache.instance.getChannelCachePager(task.sub.channel.url);
taskEx = ex;
return@submit SubscriptionTaskResult(task, pager, taskEx);
}
}
return@submit SubscriptionTaskResult(task, null, taskEx);
@@ -0,0 +1,164 @@
package com.futo.platformplayer.views
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.HorizontalSpaceItemDecoration
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.adapters.viewholders.StoreItemViewHolder
import com.futo.platformplayer.views.platform.PlatformIndicator
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
@Serializable
data class StoreItem(
val url: String,
val name: String,
val image: String
);
class MonetizationView : LinearLayout {
private val _buttonSupport: LinearLayout;
private val _buttonStore: LinearLayout;
private val _buttonMembership: LinearLayout;
private val _membershipPlatform: PlatformIndicator;
private var _membershipUrl: String? = null;
private val _textMerchandise: TextView;
private val _recyclerMerchandise: RecyclerView;
private val _loaderMerchandise: Loader;
private val _layoutMerchandise: FrameLayout;
private var _merchandiseAdapterView: AnyAdapterView<StoreItem, StoreItemViewHolder>? = null;
private val _root: LinearLayout;
private val _taskLoadMerchandise = TaskHandler<String, List<StoreItem>>(StateApp.instance.scopeGetter, { url ->
val client = ManagedHttpClient();
val result = client.get("https://storecache.grayjay.app/StoreData?url=$url")
if (!result.isOk) {
throw Exception("Failed to retrieve store data.");
}
return@TaskHandler result.body?.let { Json.decodeFromString<List<StoreItem>>(it.string()); } ?: listOf();
})
.success { setMerchandise(it) }
.exception<Throwable> {
Logger.w(TAG, "Failed to load merchandise profile.", it);
};
val onSupportTap = Event0();
val onStoreTap = Event0();
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_monetization, this);
_buttonSupport = findViewById(R.id.button_support);
_buttonStore = findViewById(R.id.button_store);
_buttonMembership = findViewById(R.id.button_membership);
_membershipPlatform = findViewById(R.id.membership_platform);
_buttonMembership.setOnClickListener {
_membershipUrl?.let {
val uri = Uri.parse(it);
val intent = Intent(Intent.ACTION_VIEW);
intent.data = uri;
context.startActivity(intent);
}
}
_textMerchandise = findViewById(R.id.text_merchandise);
_recyclerMerchandise = findViewById(R.id.recycler_merchandise);
_loaderMerchandise = findViewById(R.id.loader_merchandise);
_layoutMerchandise = findViewById(R.id.layout_merchandise);
_root = findViewById(R.id.root);
_recyclerMerchandise.addItemDecoration(HorizontalSpaceItemDecoration(30, 16, 30))
_merchandiseAdapterView = _recyclerMerchandise.asAny(orientation = RecyclerView.HORIZONTAL);
_buttonSupport.setOnClickListener { onSupportTap.emit(); }
_buttonStore.setOnClickListener { onStoreTap.emit(); }
_buttonMembership.visibility = View.GONE;
setMerchandise(null);
}
fun setPlatformMembership(pluginId: String?, url: String? = null) {
if(pluginId.isNullOrEmpty() || url.isNullOrEmpty()) {
_buttonMembership.visibility = GONE;
_membershipUrl = null;
}
else {
_membershipUrl = url;
_membershipPlatform.setPlatformFromClientID(pluginId);
_buttonMembership.visibility = VISIBLE;
}
}
private fun setMerchandise(items: List<StoreItem>?) {
_loaderMerchandise.stop();
if (items == null) {
_textMerchandise.visibility = View.GONE;
_recyclerMerchandise.visibility = View.GONE;
_layoutMerchandise.visibility = View.GONE;
} else {
_textMerchandise.visibility = View.VISIBLE;
_recyclerMerchandise.visibility = View.VISIBLE;
_layoutMerchandise.visibility = View.VISIBLE;
_merchandiseAdapterView?.adapter?.setData(items.shuffled());
}
}
fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
val profile = cachedPolycentricProfile?.profile;
if (profile != null) {
if (profile.systemState.store.isNotEmpty()) {
_buttonStore.visibility = View.VISIBLE;
} else {
_buttonStore.visibility = View.GONE;
}
_root.visibility = View.VISIBLE;
} else {
_root.visibility = View.GONE;
}
setMerchandise(null);
val storeData = profile?.systemState?.storeData;
if (storeData != null) {
try {
val storeItems = Json.decodeFromString<List<StoreItem>>(storeData);
setMerchandise(storeItems);
} catch (_: Throwable) {
try {
val uri = Uri.parse(storeData);
if (uri.isAbsolute) {
_taskLoadMerchandise.run(storeData);
_loaderMerchandise.start();
} else {
Logger.i(TAG, "Merchandise not loaded, not URL nor JSON")
}
} catch (_: Throwable) {
}
}
}
}
companion object {
const val TAG = "MonetizationView";
}
}
@@ -0,0 +1,243 @@
package com.futo.platformplayer.views
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.AttributeSet
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.R
import com.futo.platformplayer.dp
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.ShapeAppearanceModel
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import userpackage.Protocol.ImageManifest
class SupportView : LinearLayout {
private val _layoutStore: LinearLayout
private val _buttonPromotion: BigButton
private val _layoutMemberships: LinearLayout
private val _layoutMembershipEntries: LinearLayout
private val _layoutPromotions: LinearLayout
private val _layoutPromotionEntries: LinearLayout
private val _layoutDonation: LinearLayout
private val _layoutDonationEntries: LinearLayout
private val _buttonStore: BigButton
private val _imagePromotion: ShapeableImageView
private var _textNoSupportOptionsSet: TextView
private var _polycentricProfile: PolycentricProfile? = null
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_support, this);
_layoutStore = findViewById(R.id.layout_store)
_buttonStore = findViewById(R.id.button_store)
_layoutMemberships = findViewById(R.id.layout_memberships)
_layoutMembershipEntries = findViewById(R.id.layout_membership_entries)
_layoutPromotions = findViewById(R.id.layout_promotions)
_layoutPromotionEntries = findViewById(R.id.layout_promotion_entries)
_layoutDonation = findViewById(R.id.layout_donation)
_layoutDonationEntries = findViewById(R.id.layout_donation_entries)
_buttonPromotion = findViewById(R.id.button_promotion)
_imagePromotion = findViewById(R.id.image_promotion)
_textNoSupportOptionsSet = findViewById(R.id.text_no_support_options_set)
_buttonPromotion.onClick.subscribe { openPromotion() }
_imagePromotion.setOnClickListener { openPromotion() }
_buttonStore.onClick.subscribe {
val storeUrl = _polycentricProfile?.systemState?.store ?: return@subscribe
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(storeUrl))
context.startActivity(browserIntent)
}
}
private fun openPromotion() {
val promotionUrl = _polycentricProfile?.systemState?.promotion ?: return
val uri = Uri.parse(promotionUrl)
if (!uri.isAbsolute && (uri.scheme == "https" || uri.scheme == "http")) {
return
}
val browserIntent = Intent(Intent.ACTION_VIEW, uri)
context.startActivity(browserIntent)
}
private fun setMemberships(urls: List<String>) {
_layoutMembershipEntries.removeAllViews()
for (url in urls) {
val button = createMembershipButton(url)
_layoutMembershipEntries.addView(button)
}
_layoutMemberships.visibility = if (urls.isEmpty()) View.GONE else View.VISIBLE
}
private fun createMembershipButton(url: String): BigButton {
val uri = Uri.parse(url)
val name: String
val iconDrawableId: Int
if (uri.host?.contains("patreon.com") == true) {
name = "Patreon"
iconDrawableId = R.drawable.patreon
} else {
name = uri.host.toString()
iconDrawableId = R.drawable.ic_web_white
}
return BigButton(context, name, "Become a member on $name", iconDrawableId) {
val intent = Intent(Intent.ACTION_VIEW);
intent.data = uri;
context.startActivity(intent);
}.apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
};
}
private fun setDonations(destinations: List<String>) {
_layoutDonationEntries.removeAllViews()
for (destination in destinations) {
val button = createDonationButton(destination)
_layoutDonationEntries.addView(button)
}
_layoutDonation.visibility = if (destinations.isEmpty()) View.GONE else View.VISIBLE
}
private enum class CryptoType {
BITCOIN, ETHEREUM, LITECOIN, RIPPLE, UNKNOWN
}
private fun getCryptoType(address: String): CryptoType {
val btcRegex = Regex("^(1|3)[1-9A-HJ-NP-Za-km-z]{25,34}$|^(bc1)[0-9a-zA-HJ-NP-Z]{39,59}$")
val ethRegex = Regex("^(0x)[0-9a-fA-F]{40}$")
val ltcRegex = Regex("^(L|M)[1-9A-HJ-NP-Za-km-z]{26,33}$|^(ltc1)[0-9a-zA-HJ-NP-Z]{39,59}$")
val xrpRegex = Regex("^r[1-9A-HJ-NP-Za-km-z]{24,34}$")
return when {
ltcRegex.matches(address) -> CryptoType.LITECOIN
btcRegex.matches(address) -> CryptoType.BITCOIN
ethRegex.matches(address) -> CryptoType.ETHEREUM
xrpRegex.matches(address) -> CryptoType.RIPPLE
else -> CryptoType.UNKNOWN
}
}
private fun createDonationButton(destination: String): BigButton {
val uri = Uri.parse(destination)
var action: (() -> Unit)? = null
val (name, iconDrawableId, cryptoType) = if (uri.scheme == "http" || uri.scheme == "https") {
val hostName = uri.host ?: ""
action = {
val intent = Intent(Intent.ACTION_VIEW);
intent.data = uri;
context.startActivity(intent);
}
if (hostName.contains("paypal.com")) {
Triple("Paypal", R.drawable.paypal, null) // Replace with your actual PayPal drawable resource
} else {
Triple(hostName, R.drawable.ic_web_white, null) // Replace with your generic web drawable resource
}
} else {
action = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Donation Address", destination)
clipboard.setPrimaryClip(clip)
Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
}
when (getCryptoType(destination)) {
CryptoType.BITCOIN -> Triple("Bitcoin", R.drawable.bitcoin, CryptoType.BITCOIN)
CryptoType.ETHEREUM -> Triple("Ethereum", R.drawable.ethereum, CryptoType.ETHEREUM)
CryptoType.LITECOIN -> Triple("Litecoin", R.drawable.litecoin, CryptoType.LITECOIN)
CryptoType.RIPPLE -> Triple("Ripple", R.drawable.ripple, CryptoType.RIPPLE)
CryptoType.UNKNOWN -> Triple("Unknown", R.drawable.ic_paid, CryptoType.UNKNOWN)
}
}
return BigButton(context, name, destination.takeIf { cryptoType != null } ?: "Donate on $name", iconDrawableId, action).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
};
}
private fun setPromotions(url: String?, imageUrl: String?) {
Logger.i(TAG, "setPromotions($url, $imageUrl)")
if (url != null) {
_layoutPromotions.visibility = View.VISIBLE
if (imageUrl != null) {
_buttonPromotion.visibility = View.GONE
_imagePromotion.visibility = View.VISIBLE
Glide.with(_imagePromotion)
.load(imageUrl)
.crossfade()
.into(_imagePromotion)
} else {
_buttonPromotion.setSecondaryText(url)
_buttonPromotion.visibility = View.VISIBLE
_imagePromotion.visibility = View.GONE
}
} else {
_layoutPromotions.visibility = View.GONE
}
}
fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) {
if (_polycentricProfile == profile) {
return
}
if (profile != null) {
setDonations(profile.systemState.donationDestinations);
setMemberships(profile.systemState.membershipUrls);
val imageManifest = profile.systemState.promotionBanner?.imageManifestsList?.firstOrNull()
if (imageManifest != null) {
val imageUrl = imageManifest.toURLInfoSystemLinkUrl(profile.system.toProto(), imageManifest.process, profile.systemState.servers.toList());
setPromotions(profile.systemState.promotion, imageUrl);
} else {
setPromotions(null, null);
}
if (profile.systemState.store.isNotEmpty()) {
_layoutStore.visibility = View.VISIBLE
} else {
_layoutStore.visibility = View.GONE
}
_textNoSupportOptionsSet.visibility = View.GONE
} else {
setDonations(listOf());
setMemberships(listOf());
setPromotions(null, null);
_layoutStore.visibility = View.GONE
_textNoSupportOptionsSet.visibility = View.VISIBLE
}
_polycentricProfile = profile
}
companion object {
const val TAG = "SupportView";
}
}
@@ -43,7 +43,7 @@ class PlaylistsViewHolder : ViewHolder {
fun bind(p: Playlist) {
if (p.videos.isNotEmpty()) {
Glide.with(_imageThumbnail)
.load(p.videos[0].thumbnails.getLQThumbnail())
.load(p.videos[0].thumbnails.getMinimumThumbnail(380))
.placeholder(R.drawable.placeholder_video_thumbnail)
.crossfade()
.into(_imageThumbnail);
@@ -11,6 +11,8 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.constructs.Event1
@@ -76,7 +78,7 @@ class VideoListEditorViewHolder : ViewHolder {
fun bind(v: IPlatformVideo, canEdit: Boolean) {
Glide.with(_imageThumbnail)
.load(v.thumbnails.getLQThumbnail())
.load(v.thumbnails.getHQThumbnail())
.placeholder(R.drawable.placeholder_video_thumbnail)
.crossfade()
.into(_imageThumbnail);
@@ -0,0 +1,55 @@
package com.futo.platformplayer.views.adapters.viewholders
import android.content.Intent
import android.net.Uri
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.views.StoreItem
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.google.android.material.imageview.ShapeableImageView
class StoreItemViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<StoreItem>(
LayoutInflater.from(_viewGroup.context).inflate(R.layout.view_store_item, _viewGroup, false)) {
private val _image: ShapeableImageView;
private val _name: TextView;
private var _storeItem: StoreItem? = null;
init {
_image = _view.findViewById(R.id.image_item);
_name = _view.findViewById(R.id.text_item);
_view.findViewById<LinearLayout>(R.id.root).setOnClickListener {
val s = _storeItem ?: return@setOnClickListener;
try {
val uri = Uri.parse(s.url);
val intent = Intent(Intent.ACTION_VIEW);
intent.data = uri;
_view.context.startActivity(intent);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to open URI: '${it}'.", e);
}
}
}
override fun bind(storeItem: StoreItem) {
Glide.with(_image)
.load(storeItem.image)
.crossfade()
.into(_image);
_name.text = storeItem.name;
_storeItem = storeItem;
}
companion object {
private const val TAG = "StoreItemViewHolder";
}
}
@@ -1,5 +1,6 @@
package com.futo.platformplayer.views.adapters.viewholders
import android.app.Activity
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageButton
@@ -7,9 +8,11 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.*
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.images.GlideHelper.Companion.loadThumbnails
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.adapters.AnyAdapter
@@ -47,7 +50,18 @@ class VideoDownloadViewHolder(_viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<
}
_videoExport.setOnClickListener {
val v = _video ?: return@setOnClickListener;
StateDownloads.instance.export(v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull());
if (StateApp.instance.getExternalDownloadDirectory(_view.context) == null) {
StateApp.instance.changeExternalDownloadDirectory(_view.context as MainActivity) {
if (it == null) {
UIDialogs.toast(_view.context, "Download directory must be set to export.");
return@changeExternalDownloadDirectory;
}
StateDownloads.instance.export(v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull());
};
} else {
StateDownloads.instance.export(v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull());
}
}
}
@@ -69,6 +69,10 @@ open class BigButton : LinearLayout {
_textSecondary.text = attrTextSecondary;
}
fun setSecondaryText(text: String?) {
_textSecondary.text = text
}
fun withPrimaryText(text: String): BigButton {
_textPrimary.text = text;
return this;
@@ -8,6 +8,7 @@ import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.dp
import com.futo.platformplayer.views.buttons.BigButton
import java.lang.reflect.Field
@@ -25,6 +26,8 @@ class ButtonField : BigButton, IField {
override var reference: Any? = null;
override val value: Any? = null;
override val obj : Any? get() {
if(this._obj == null)
throw java.lang.IllegalStateException("Can only be called if fromField is used");
@@ -37,7 +40,7 @@ class ButtonField : BigButton, IField {
//private val _title : TextView;
//private val _subtitle : TextView;
override val onChanged = Event2<IField, Any>();
override val onChanged = Event3<IField, Any, Any>();
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){
//inflate(context, R.layout.field_button, this);
@@ -59,6 +62,8 @@ class ButtonField : BigButton, IField {
}
}
override fun setValue(value: Any) {}
fun fromMethod(obj : Any, method: Method) : ButtonField {
this._method = method;
this._obj = obj;
@@ -6,6 +6,8 @@ import android.view.View
import android.widget.*
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.logging.Logger
import java.lang.reflect.Field
class DropdownField : TableRow, IField {
@@ -35,7 +37,9 @@ class DropdownField : TableRow, IField {
override var reference: Any? = null;
override val onChanged = Event2<IField, Any>();
override val onChanged = Event3<IField, Any, Any>();
override val value: Any? get() = _selected;
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs){
inflate(context, R.layout.field_dropdown, this);
@@ -50,13 +54,21 @@ class DropdownField : TableRow, IField {
_isInitFire = false;
return;
}
Logger.i("DropdownField", "Changed: ${_selected} -> ${pos}");
val old = _selected;
_selected = pos;
onChanged.emit(this@DropdownField, pos);
onChanged.emit(this@DropdownField, pos, old);
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
};
}
override fun setValue(value: Any) {
if(value is Int) {
_spinner.setSelection(value);
}
}
fun asBoolean(name: String, description: String?, obj: Boolean) : DropdownField {
_options = resources.getStringArray(R.array.enabled_disabled_array);
_spinner.adapter = ArrayAdapter<String>(context, R.layout.spinner_item_simple, _options).also {
@@ -77,6 +89,23 @@ class DropdownField : TableRow, IField {
return this;
}
fun withValue(title: String, description: String?, options: List<String>, value: Int): DropdownField {
_title.text = title;
_description.visibility = if(description.isNullOrEmpty()) View.GONE else View.VISIBLE;
_description.text = description ?: "";
_options = options.toTypedArray();
_spinner.adapter = ArrayAdapter<String>(context, R.layout.spinner_item_simple, _options).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
};
_selected = value;
_spinner.isSelected = false;
_spinner.setSelection(_selected, true);
return this;
}
override fun fromField(obj: Any, field: Field, formField: FormField?) : DropdownField {
this._field = field;
this._obj = obj;
@@ -1,6 +1,7 @@
package com.futo.platformplayer.views.fields
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.Event3
import java.lang.reflect.Field
@@ -8,16 +9,35 @@ import java.lang.reflect.Field
@Retention(AnnotationRetention.RUNTIME)
annotation class FormField(val title: Int, val type: String, val subtitle: Int = -1, val order: Int = 0, val id: String = "")
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class FormFieldWarning(val messageRes: Int)
interface IField {
var descriptor: FormField?;
val obj : Any?;
val field : Field?;
val onChanged : Event2<IField, Any>;
val value: Any?;
val onChanged : Event3<IField, Any, Any>;
var reference: Any?;
fun fromField(obj : Any, field : Field, formField: FormField? = null) : IField;
fun setField();
fun setValue(value: Any);
companion object {
fun isValueTrue(value: Any?): Boolean {
if(value == null)
return false;
return when(value) {
is Int -> value > 0;
is Boolean -> value;
is String -> value.toIntOrNull()?.let { it > 0 } ?: false || value.lowercase() == "true";
else -> false
};
}
}
}
@@ -5,6 +5,7 @@ import android.util.AttributeSet
import android.view.View
import android.widget.LinearLayout
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.logging.Logger
@@ -16,6 +17,7 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.lang.reflect.Field
import java.lang.reflect.Method
import kotlin.reflect.KProperty
import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.hasAnnotation
@@ -49,7 +51,7 @@ class FieldForm : LinearLayout {
throw java.lang.IllegalStateException("Only views can be IFields");
_root.addView(field as View);
field.onChanged.subscribe { a1, a2 ->
field.onChanged.subscribe { a1, a2, oldValue ->
onChanged.emit(a1, a2);
};
}
@@ -67,7 +69,7 @@ class FieldForm : LinearLayout {
throw java.lang.IllegalStateException("Only views can be IFields");
_root.addView(field as View);
field.onChanged.subscribe { a1, a2 ->
field.onChanged.subscribe { a1, a2, oldValue ->
onChanged.emit(a1, a2);
};
}
@@ -82,25 +84,54 @@ class FieldForm : LinearLayout {
if(groupTitle == null) {
for(field in newFields) {
if(field !is View)
if(field.second !is View)
throw java.lang.IllegalStateException("Only views can be IFields");
field.onChanged.subscribe { field, value ->
onChanged.emit(field, value);
}
finalizePluginSettingField(field.first, field.second, newFields);
_root.addView(field as View);
}
_fields = newFields;
_fields = newFields.map { it.second };
} else {
for(field in newFields) {
field.onChanged.subscribe { field, value ->
onChanged.emit(field, value);
}
finalizePluginSettingField(field.first, field.second, newFields);
}
val group = GroupField(context, groupTitle, groupDescription)
.withFields(newFields);
.withFields(newFields.map { it.second });
_root.addView(group as View);
}
}
private fun finalizePluginSettingField(setting: SourcePluginConfig.Setting, field: IField, others: List<Pair<SourcePluginConfig.Setting, IField>>) {
field.onChanged.subscribe { field, value, oldValue ->
onChanged.emit(field, value);
setting.warningDialog?.let {
if(it.isNotBlank() && IField.isValueTrue(value))
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, setting.warningDialog, null, null, 0,
UIDialogs.Action("Cancel", {
field.setValue(oldValue);
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Ok", {
}, UIDialogs.ActionStyle.PRIMARY));
}
}
if(setting.dependency != null) {
val dependentField = others.firstOrNull { it.first.variableOrName == setting.dependency };
if(dependentField == null || dependentField.second !is View)
(field as View).visibility = View.GONE;
else {
val dependencyReady = IField.isValueTrue(dependentField.second.value);
if(!dependencyReady)
(field as View).visibility = View.GONE;
dependentField.second.onChanged.subscribe { dependentField, value, oldValue ->
val isValid = IField.isValueTrue(value);
if(isValid)
(field as View).visibility = View.VISIBLE;
else
(field as View).visibility = View.GONE;
}
}
}
}
fun setObjectValues(){
val fields = _fields;
@@ -133,26 +164,42 @@ class FieldForm : LinearLayout {
private val _json = Json {};
fun getFieldsFromPluginSettings(context: Context, settings: List<SourcePluginConfig.Setting>, values: HashMap<String, String?>): List<IField> {
val fields = mutableListOf<IField>()
fun getFieldsFromPluginSettings(context: Context, settings: List<SourcePluginConfig.Setting>, values: HashMap<String, String?>): List<Pair<SourcePluginConfig.Setting, IField>> {
val fields = mutableListOf<Pair<SourcePluginConfig.Setting, IField>>()
for(setting in settings) {
val value = if(values.containsKey(setting.variableOrName)) values[setting.variableOrName] else setting.default;
val field = when(setting.type.lowercase()) {
"header" -> {
val groupField = GroupField(context, setting.name, setting.description);
groupField;
}
"boolean" -> {
val value = if(values.containsKey(setting.variableOrName)) values[setting.variableOrName] else setting.default;
val field = ToggleField(context).withValue(setting.name,
setting.description,
value == "true" || value == "1" || value == "True");
field.onChanged.subscribe { field, value ->
field.onChanged.subscribe { field, value, oldValue ->
values[setting.variableOrName] = _json.encodeToString (value == 1 || value == true);
}
field;
}
"dropdown" -> {
if(setting.options != null && !setting.options.isEmpty()) {
var selected = value?.toIntOrNull()?.coerceAtLeast(0) ?: 0;
val field = DropdownField(context).withValue(setting.name, setting.description, setting.options, selected);
field.onChanged.subscribe { field, value, oldValue ->
values[setting.variableOrName] = value.toString();
}
field;
}
else null;
}
else -> null;
}
if(field != null)
fields.add(field);
fields.add(Pair(setting, field));
}
return fields;
}
@@ -162,21 +209,42 @@ class FieldForm : LinearLayout {
.asSequence()
.asStream()
.filter { it.hasAnnotation<FormField>() && it.javaField != null }
.map { Pair<Field, FormField>(it.javaField!!, it.findAnnotation()!!) }
.map { Pair<KProperty<*>, FormField>(it, it.findAnnotation()!!) }
.toList()
//TODO: Rewrite fields to properties so no map is required
val propertyMap = mutableMapOf<Field, KProperty<*>>();
val fields = mutableListOf<IField>();
for(prop in objFields) {
prop.first.isAccessible = true;
prop.first.javaField!!.isAccessible = true;
val field = when(prop.second.type) {
GROUP -> GroupField(context).fromField(obj, prop.first, prop.second);
DROPDOWN -> DropdownField(context).fromField(obj, prop.first, prop.second);
TOGGLE -> ToggleField(context).fromField(obj, prop.first, prop.second);
READONLYTEXT -> ReadOnlyTextField(context).fromField(obj, prop.first, prop.second);
GROUP -> GroupField(context).fromField(obj, prop.first.javaField!!, prop.second);
DROPDOWN -> DropdownField(context).fromField(obj, prop.first.javaField!!, prop.second);
TOGGLE -> ToggleField(context).fromField(obj, prop.first.javaField!!, prop.second);
READONLYTEXT -> ReadOnlyTextField(context).fromField(obj, prop.first.javaField!!, prop.second);
else -> throw java.lang.IllegalStateException("Unknown field type ${prop.second.type} for ${prop.second.title}")
}
fields.add(field as IField);
propertyMap.put(prop.first.javaField!!, prop.first);
}
for(field in fields) {
if(field.field != null) {
val warning = propertyMap[field.field]?.findAnnotation<FormFieldWarning>();
if(warning != null) {
field.onChanged.subscribe { field, value, oldValue ->
if(IField.isValueTrue(value))
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, context.getString(warning.messageRes), null, null, 0,
UIDialogs.Action("Cancel", {
field.setValue(oldValue);
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Ok", {
}, UIDialogs.ActionStyle.PRIMARY));
}
}
}
}
val objProps = obj::class.declaredMemberProperties
@@ -7,7 +7,9 @@ import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.Event3
import java.lang.reflect.Field
import kotlin.reflect.KProperty
class GroupField : LinearLayout, IField {
override var descriptor : FormField? = null;
@@ -27,7 +29,7 @@ class GroupField : LinearLayout, IField {
return _field;
};
override val onChanged = Event2<IField, Any>();
override val onChanged = Event3<IField, Any, Any>();
private val _title : TextView;
private val _subtitle : TextView;
@@ -35,6 +37,8 @@ class GroupField : LinearLayout, IField {
override var reference: Any? = null;
override val value: Any? = null;
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.field_group, this);
_title = findViewById(R.id.field_group_title);
@@ -138,4 +142,6 @@ class GroupField : LinearLayout, IField {
field.setField();
}
}
override fun setValue(value: Any) {}
}
@@ -5,6 +5,7 @@ import android.util.AttributeSet
import android.widget.*
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.Event3
import java.lang.reflect.Field
import java.lang.reflect.Method
@@ -27,15 +28,20 @@ class ReadOnlyTextField : TableRow, IField {
private val _title : TextView;
private val _value : TextView;
override val onChanged = Event2<IField, Any>();
override val onChanged = Event3<IField, Any, Any>();
override var reference: Any? = null;
override val value: Any? = null;
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){
inflate(context, R.layout.field_readonly_text, this);
_title = findViewById(R.id.field_title);
_value = findViewById(R.id.field_value);
}
override fun setValue(value: Any) {}
override fun fromField(obj : Any, field : Field, formField: FormField?) : ReadOnlyTextField {
this._field = field;
this._obj = obj;
@@ -6,6 +6,8 @@ import android.view.View
import android.widget.*
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.views.others.Toggle
import java.lang.reflect.Field
@@ -28,10 +30,13 @@ class ToggleField : TableRow, IField {
private val _title : TextView;
private val _description : TextView;
private val _toggle : Toggle;
private var _lastValue: Boolean = false;
override var reference: Any? = null;
override val onChanged = Event2<IField, Any>();
override val onChanged = Event3<IField, Any, Any>();
override val value: Any get() = _lastValue;
constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){
inflate(context, R.layout.field_toggle, this);
@@ -40,10 +45,18 @@ class ToggleField : TableRow, IField {
_description = findViewById(R.id.field_description);
_toggle.onValueChanged.subscribe {
onChanged.emit(this, it);
val lastVal = _lastValue;
Logger.i("ToggleField", "Changed: ${lastVal} -> ${it}");
_lastValue = it;
onChanged.emit(this, it, lastVal);
};
}
override fun setValue(value: Any) {
if(value is Boolean)
_toggle.setValue(value, true, true);
}
fun withValue(title: String, description: String?, value: Boolean): ToggleField {
_title.text = title;
@@ -54,6 +67,7 @@ class ToggleField : TableRow, IField {
_description.visibility = View.GONE;
_toggle.setValue(value, true);
_lastValue = value;
return this;
}
@@ -78,14 +92,16 @@ class ToggleField : TableRow, IField {
}
val value = field.get(obj);
if(value is Boolean)
_toggle.setValue(value, true);
val toggleValue = if(value is Boolean)
value;
else if(value is Number)
_toggle.setValue((value as Number).toInt() > 0, true);
(value as Number).toInt() > 0;
else if(value == null)
_toggle.setValue(false, true);
false;
else
_toggle.setValue(false, true);
false;
_toggle.setValue(toggleValue, true);
_lastValue = toggleValue;
return this;
}
@@ -29,7 +29,7 @@ class Toggle : AppCompatImageView {
scaleType = ScaleType.FIT_CENTER;
}
fun setValue(v: Boolean, animated: Boolean = true) {
fun setValue(v: Boolean, animated: Boolean = true, withEvent: Boolean = false) {
if (value == v) {
return;
}
@@ -44,5 +44,8 @@ class Toggle : AppCompatImageView {
} else {
setImageResource(if (v) R.drawable.toggle_enabled else R.drawable.toggle_disabled);
}
if(withEvent)
onValueChanged.emit(value);
}
}
@@ -0,0 +1,33 @@
package com.futo.platformplayer.views.overlays
import android.content.Context
import android.util.AttributeSet
import android.widget.LinearLayout
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.views.SupportView
class SupportOverlay : LinearLayout {
val onClose = Event0();
private val _topbar: OverlayTopbar;
private val _support: SupportView;
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.overlay_support, this)
_topbar = findViewById(R.id.topbar);
_support = findViewById(R.id.support);
_topbar.onClose.subscribe(this, onClose::emit);
}
fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) {
_support.setPolycentricProfile(profile, animate)
}
fun cleanup() {
_topbar.onClose.remove(this);
}
}
@@ -34,7 +34,8 @@ class CommentsList : ConstraintLayout {
}
.exception<Throwable> {
Logger.e(TAG, "Failed to load comments.", it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_comments) + (it.message ?: ""), it, ::fetchComments);
UIDialogs.toast(context, context.getString(R.string.failed_to_load_comments) + "\n" + (it.message ?: ""));
//UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_comments) + (it.message ?: ""), it, ::fetchComments);
setLoading(false);
} else TaskHandler(IPlatformVideoDetails::class.java, StateApp.instance.scopeGetter);
@@ -71,6 +71,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
private val _control_videosettings_fullscreen: ImageButton;
private val _control_minimize_fullscreen: ImageButton;
private val _control_rotate_lock_fullscreen: ImageButton;
private val _control_cast_fullscreen: ImageButton;
private val _control_play_fullscreen: ImageButton;
private val _time_bar_fullscreen: TimeBar;
private val _overlay_brightness: FrameLayout;
@@ -127,10 +128,15 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_control_minimize_fullscreen = _videoControls_fullscreen.findViewById(R.id.exo_minimize);
_control_videosettings_fullscreen = _videoControls_fullscreen.findViewById(R.id.exo_settings);
_control_rotate_lock_fullscreen = _videoControls_fullscreen.findViewById(R.id.exo_rotate_lock);
_control_cast_fullscreen = _videoControls_fullscreen.findViewById(R.id.exo_cast);
_control_play_fullscreen = videoControls.findViewById(com.google.android.exoplayer2.ui.R.id.exo_play);
_control_chapter_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_chapter_current);
_time_bar_fullscreen = _videoControls_fullscreen.findViewById(com.google.android.exoplayer2.ui.R.id.exo_progress);
val castVisibility = if (Settings.instance.casting.enabled) View.VISIBLE else View.GONE
_control_cast.visibility = castVisibility
_control_cast_fullscreen.visibility = castVisibility
_overlay_brightness = findViewById(R.id.overlay_brightness);
_title_fullscreen = _videoControls_fullscreen.findViewById(R.id.exo_title);
@@ -229,6 +235,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
StatePlayer.instance.rotationLock = !StatePlayer.instance.rotationLock;
updateRotateLock();
};
_control_cast_fullscreen.setOnClickListener {
UIDialogs.showCastingDialog(context);
};
var lastPos = 0L;
videoControls.setProgressUpdateListener { position, bufferedPosition ->
@@ -270,7 +279,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
if (drawable != null) {
_videoView.defaultArtwork = drawable;
_videoView.useArtwork = true;
fitHeight();
fitOrFill(isFullScreen);
} else {
_videoView.defaultArtwork = null;
_videoView.useArtwork = false;
@@ -311,7 +320,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
gestureControl.hideControls();
//videoControlsBar.visibility = View.GONE;
_videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT;
fillHeight();
_videoControls_fullscreen.show();
videoControls.hide();
}
@@ -323,16 +332,25 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
gestureControl.hideControls();
//videoControlsBar.visibility = View.VISIBLE;
_videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM;
fitHeight();
videoControls.show();
_videoControls_fullscreen.hide();
}
fitOrFill(fullScreen);
gestureControl.setFullscreen(fullScreen);
onToggleFullScreen.emit(fullScreen);
isFullScreen = fullScreen;
}
private fun fitOrFill(fullScreen: Boolean) {
if (fullScreen) {
fillHeight();
} else {
fitHeight();
}
}
fun lockControlsAlpha(locked : Boolean) {
if(locked && _isControlsLocked != locked) {
_isControlsLocked = locked;
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#000000" />
<corners android:radius="4dp" />
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#232323" />
<corners android:radius="5dp" />
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#3A1448" />
<corners android:radius="14dp" />
<corners android:radius="5dp" />
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#144826" />
<corners android:radius="14dp" />
<corners android:radius="5dp" />
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="273.6dp"
android:height="360dp"
android:viewportWidth="273.6"
android:viewportHeight="360">
<path
android:pathData="M217.02,167.04c18.63,-9.48 30.29,-26.18 27.57,-54.01c-3.67,-38.02 -36.53,-50.77 -78.01,-54.4l-0.01,-52.74h-32.14l-0.01,51.35c-8.46,0 -17.08,0.17 -25.66,0.34L108.76,5.9l-32.11,-0l-0.01,52.73c-6.96,0.14 -13.79,0.28 -20.47,0.28v-0.16l-44.33,-0.02l0.01,34.28c0,0 23.73,-0.45 23.34,-0.01c13.01,0.01 17.26,7.56 18.48,14.08l0.01,60.08v84.4c-0.57,4.09 -2.98,10.63 -12.08,10.64c0.41,0.36 -23.38,-0 -23.38,-0l-6.38,38.33h41.82c7.79,0.01 15.45,0.13 22.96,0.19l0.03,53.34l32.1,0.01l-0.01,-52.78c8.83,0.18 17.36,0.26 25.68,0.25l-0.01,52.53h32.14l0.02,-53.25c54.02,-3.1 91.84,-16.7 96.54,-67.39C266.92,192.61 247.69,174.4 217.02,167.04zM109.54,95.32c18.13,0 75.13,-5.77 75.14,32.06c-0.01,36.27 -57,32.03 -75.14,32.03V95.32zM109.52,262.45l0.01,-70.67c21.78,-0.01 90.08,-6.26 90.09,35.32C199.64,266.97 131.31,262.43 109.52,262.45z"
android:fillColor="#ffffff"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<size android:height="0dp"
android:width="4dp"/>
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<size android:height="0dp"
android:width="8dp"/>
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<size android:height="20dp"
android:width="0dp"/>
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<size android:height="8dp"
android:width="0dp"/>
</shape>
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="800dp"
android:height="800dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M15.927,23.959l-9.823,-5.797 9.817,13.839 9.828,-13.839 -9.828,5.797zM16.073,0l-9.819,16.297 9.819,5.807 9.823,-5.801z"
android:fillColor="#ffffff"/>
</vector>
+1 -2
View File
@@ -2,8 +2,7 @@
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,760Q546,760 593,713Q640,666 640,600L640,440Q640,374 593,327Q546,280 480,280Q414,280 367,327Q320,374 320,440L320,600Q320,666 367,713Q414,760 480,760ZM400,640L560,640L560,560L400,560L400,640ZM400,480L560,480L560,400L400,400L400,480ZM480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520ZM480,840Q415,840 359.5,808Q304,776 272,720L160,720L160,640L244,640Q241,620 240.5,600Q240,580 240,560L160,560L160,480L240,480Q240,460 240.5,440Q241,420 244,400L160,400L160,320L272,320Q286,297 303.5,277Q321,257 344,242L280,176L336,120L422,206Q450,197 479,197Q508,197 536,206L624,120L680,176L614,242Q637,257 655.5,276.5Q674,296 688,320L800,320L800,400L716,400Q719,420 719.5,440Q720,460 720,480L800,480L800,560L720,560Q720,580 719.5,600Q719,620 716,640L800,640L800,720L688,720Q656,776 600.5,808Q545,840 480,840Z"/>
+1 -2
View File
@@ -2,8 +2,7 @@
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M120,800L120,720L600,720L600,800L120,800ZM640,520Q557,520 498.5,461.5Q440,403 440,320Q440,237 498.5,178.5Q557,120 640,120Q723,120 781.5,178.5Q840,237 840,320Q840,403 781.5,461.5Q723,520 640,520ZM120,480L120,400L372,400Q379,422 388,442Q397,462 410,480L120,480ZM120,640L120,560L496,560Q519,574 545,583.5Q571,593 600,597L600,640L120,640ZM620,360L660,360L660,200L620,200L620,360ZM640,440Q648,440 654,434Q660,428 660,420Q660,412 654,406Q648,400 640,400Q632,400 626,406Q620,412 620,420Q620,428 626,434Q632,440 640,440Z"/>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M420,600L540,600L517,471Q537,461 548.5,442Q560,423 560,400Q560,367 536.5,343.5Q513,320 480,320Q447,320 423.5,343.5Q400,367 400,400Q400,423 411.5,442Q423,461 443,471L420,600ZM480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,596 709.5,720.5Q619,845 480,880ZM480,796Q584,763 652,664Q720,565 720,444L720,255L480,165L240,255L240,444Q240,565 308,664Q376,763 480,796ZM480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M120,720L120,640L600,640L600,720L120,720ZM120,520L120,440L840,440L840,520L120,520ZM120,320L120,240L840,240L840,320L120,320Z"/>
</vector>
+1 -2
View File
@@ -2,8 +2,7 @@
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M380,620L660,440L380,260L380,620ZM320,840L320,760L160,760Q127,760 103.5,736.5Q80,713 80,680L80,200Q80,167 103.5,143.5Q127,120 160,120L800,120Q833,120 856.5,143.5Q880,167 880,200L880,680Q880,713 856.5,736.5Q833,760 800,760L640,760L640,840L320,840ZM160,680L800,680Q800,680 800,680Q800,680 800,680L800,200Q800,200 800,200Q800,200 800,200L160,200Q160,200 160,200Q160,200 160,200L160,680Q160,680 160,680Q160,680 160,680ZM160,680Q160,680 160,680Q160,680 160,680L160,200Q160,200 160,200Q160,200 160,200L160,200Q160,200 160,200Q160,200 160,200L160,680Q160,680 160,680Q160,680 160,680L160,680Z"/>
@@ -2,8 +2,7 @@
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M160,760L160,680L240,680L240,400Q240,317 290,252.5Q340,188 420,168L420,140Q420,115 437.5,97.5Q455,80 480,80Q505,80 522.5,97.5Q540,115 540,140L540,168Q620,188 670,252.5Q720,317 720,400L720,680L800,680L800,760L160,760ZM480,460L480,460L480,460L480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460ZM480,880Q447,880 423.5,856.5Q400,833 400,800L560,800Q560,833 536.5,856.5Q513,880 480,880ZM320,680L640,680L640,400Q640,334 593,287Q546,240 480,240Q414,240 367,287Q320,334 320,400L320,680Z"/>

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