Compare commits

...

74 Commits

Author SHA1 Message Date
Koen J c695379885 Fix for casting downloaded videos (UMP, requires redownloading). 2026-04-29 16:53:24 +02:00
Koen J 73466892f7 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2026-04-29 13:13:37 +02:00
Koen J bb8a9d4dd7 Fixed notification system issues and plugin auto update. 2026-04-29 13:07:14 +02:00
Koen J 43ed2b16ab Hide browser interop warning. 2026-04-29 11:23:19 +02:00
Koen J 64938dba6c Changed default setting on clear cookies on login. 2026-04-27 12:14:52 +02:00
Koen 8b7d51cd70 Merge branch 'possiblebusyfix' into 'master'
Possible busy fix

See merge request videostreaming/grayjay!173
2026-04-27 10:14:01 +00:00
Koen ace7ca1551 Possible busy fix 2026-04-27 10:14:01 +00:00
Koen J 22b5adc4b8 Fix for #2614 2026-04-22 15:09:51 +02:00
Koen J 0f7fb9059b Made sub exchange default and fixed #2930. Also fixed unrelated export issues. 2026-04-22 15:02:22 +02:00
Koen J 05afa12274 Fix for #2068 2026-04-22 12:47:15 +02:00
Koen J b4a280cee8 Fixed up translation 2026-04-22 11:17:57 +02:00
koen-futo ac5d7eab2a Merge pull request #2135 from ddrews-de/patch-1
Update strings.xml for German Language
2026-04-22 11:14:07 +02:00
koen-futo b624d45ab6 Merge pull request #3132 from jraleman/master
feat: add support to sort downloads by type
2026-04-22 11:12:39 +02:00
koen-futo 5340088ada Merge pull request #3133 from GorlovDanila/patch-1
fixed bug: Playlist title blocks text/element below it
2026-04-22 11:09:53 +02:00
Koen J fcab0f5ee5 Potential fix for #3247 2026-04-22 10:23:40 +02:00
Koen J 80c9b27d48 Build fix 2026-04-17 15:33:15 +02:00
Koen J f54216d52f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2026-04-16 16:42:36 +02:00
Koen J fea69d265a Article open bug. 2026-04-16 16:42:13 +02:00
Koen 030086e769 Merge branch 'marcus/cast-button-sync' into 'master'
casting: set cast button to inactive color when device is disconnected

See merge request videostreaming/grayjay!172
2026-04-16 14:40:04 +00:00
Koen 81516c31fb Merge branch 'marcus/casting-device-remove-comment' into 'master'
casting: remove old commented out code

See merge request videostreaming/grayjay!170
2026-04-16 14:39:55 +00:00
Marcus Hanestad 3d13a21700 casting: set cast button to inactive color when device is disconnected 2026-04-16 10:12:16 +02:00
Koen J c14d2580ee Update dependencies. 2026-04-01 13:40:35 +02:00
Marcus Hanestad 795259564d casting: remove old commented out code 2026-03-27 14:46:40 +01:00
Kelvin 81d0b08306 Merge branch 'tlg/opinion-dupe-bug' into 'master'
Opinion Dupe Bug

See merge request videostreaming/grayjay!169
2026-03-26 21:42:44 +00:00
Tim Giroux 9a97a901fb dupe opinion bug 2026-03-26 13:10:29 -05:00
Koen J d9b23eff62 Build fix. 2026-03-24 13:02:06 +01:00
Koen 8591deaf86 Merge branch 'plugin-init-failures' into 'master'
Handle plugin init failures per source

See merge request videostreaming/grayjay!168
2026-03-20 11:53:19 +00:00
Stefan 22c5581d00 Handle plugin init failures per source 2026-03-20 11:53:19 +00:00
Koen 6e815dc868 Merge branch 'new-plugins' into 'master'
Add Radio Browser, Red Bull TV and FOSDEM plugins

See merge request videostreaming/grayjay!167
2026-03-20 11:52:53 +00:00
Stefan 1ac409561c Add Radio Browser, Red Bull TV and FOSDEM plugins 2026-03-20 11:52:53 +00:00
Koen J 897ba8a560 Added error handling to onConsoleMessage. 2026-03-20 11:35:16 +01:00
Koen J 8982ea2289 Fix for Furilabs phone (thanks @Aleks K) 2026-03-16 15:52:31 +01:00
Koen f693f1e6b3 Edit deploy-stable.sh 2026-03-12 18:09:21 +00:00
Koen e118bc09b9 Edit deploy-unstable.sh 2026-03-12 18:07:57 +00:00
Kelvin 5ba77b60c8 Merge branch 'marcus/fcast-fix' into 'master'
fix fcast sender sdk

See merge request videostreaming/grayjay!166
2026-03-12 17:42:35 +00:00
Kelvin K 19b63ba372 Remove transient from plugin settings, submods 2026-03-12 12:34:04 -05:00
Marcus Hanestad 5fc39d3bb3 fix fcast sender sdk 2026-03-11 12:51:17 -05:00
Koen 1d046538f8 Merge branch 'marcus/fcast-sdk-0.4.1' into 'master'
upgrade fcast sdk to 0.4.1

See merge request videostreaming/grayjay!165
2026-03-10 13:30:43 +00:00
Marcus Hanestad 9f10b86861 upgrade fcast sdk to 0.4.1 2026-03-10 08:28:52 -05:00
Koen J d1336c711a Changed over deploy location to Cloudflare R2. 2026-03-03 10:37:25 +01:00
jraleman 837ee76bdc fix: sorting by download type 2026-02-28 21:46:12 -05:00
Koen 2a2ed08a3c Merge branch 'captcha-improvements' into 'master'
feat: propagate WebView user agent to plugins via bridge.captchaUserAgent and bridge.authUserAgent

See merge request videostreaming/grayjay!164
2026-02-27 06:59:36 +00:00
Stefan 8a0e49232e feat: propagate WebView user agent to plugins via bridge.captchaUserAgent and bridge.authUserAgent 2026-02-27 06:59:35 +00:00
Danila Gorlov 624ef3c6e9 fixed bug: Playlist title blocks text/element below it 2026-02-26 17:59:31 +03:00
jraleman 3d5b9a94fb feat: add support to sort downloads by type 2026-02-24 23:35:02 -05:00
Koen J a8decdb0d9 Updated FDroid pipeline. 2026-02-19 14:15:05 +01:00
Koen J 2609929780 FDroid automation in pipeline. 2026-02-19 14:06:53 +01:00
Koen J 2bcfbf89d3 Added proper automation for playstore builds. 2026-02-19 11:13:27 +01:00
Koen J fa1954ceef Fixes. 2026-02-16 11:35:29 +01:00
Koen J 13aa49726a Improved WaitTillLoaded. 2026-02-15 14:46:03 +01:00
Koen J 20bab7d056 Updated submodules. 2026-02-15 11:34:25 +01:00
Koen J cbf7ca0181 Fixes to make it less detectable. 2026-02-15 11:26:10 +01:00
Koen J b7477080d2 Add scripts on load. 2026-02-13 14:05:37 +01:00
Koen J ac5bc27581 Package browser wip 2026-02-13 13:40:00 +01:00
Koen J 748551af2a Added support for injecting scripts on bootup. 2026-02-13 12:20:30 +01:00
Koen J 9ce41bc8d0 Fixed issue where media session was not properly restarted after reopening the app after closing pip. 2026-02-11 11:10:57 +01:00
Koen J 8cf542e201 Improved auto backup flow. 2026-02-10 15:15:32 +01:00
Koen J 88950843b3 Fixed artwork not updating when in audio only. 2026-02-10 15:08:43 +01:00
Koen J 4a08058322 Run import on IO. 2026-02-10 14:48:03 +01:00
Koen J 7b76ba1539 Fixed resume after non manual pause. 2026-02-10 13:24:21 +01:00
Koen J 6492278e7d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2026-02-10 12:16:04 +01:00
Koen J 9de9440160 Reworked automatic backups. 2026-02-10 12:15:34 +01:00
Koen 372af6cf47 Merge branch 'jf/futopay-2.0' into 'master'
Jf/futopay 2.0

See merge request videostreaming/grayjay!147
2026-02-09 16:02:55 +00:00
Justin Fowler 29d08c8554 Jf/futopay 2.0 2026-02-09 16:02:55 +00:00
Koen J cfeceabe5b Added italian to audio_languages. 2026-02-09 10:18:27 +01:00
koen-futo a51f609a92 Merge pull request #3028 from goodness-from-me-forks/goodness-from-me-patch-1
Add www.twitch.tv and m.twitch.tv to intent urls
2026-02-09 10:12:17 +01:00
Koen 15a655f196 Edit StateAnnouncement.kt 2026-02-07 09:45:35 +00:00
Kelvin c6525f1caa Clear cookies after login 2026-02-06 17:20:10 +01:00
Kelvin e147fdd77e Empty view for notifs, back on toggle off 2026-02-02 20:12:30 +01:00
Kelvin 6a8ac0bfaa Refs 2026-02-02 18:46:01 +01:00
Kelvin 772bff6bc0 Browser package fixes, advanced settings for plugin support 2026-02-02 18:41:51 +01:00
goodness-from-me 8e4ad54de1 Apply the same changes to unstable 2026-01-22 16:31:10 +00:00
goodness-from-me 6139696714 Add www.twitch.tv and m.twitch.tv to intent urls
Streams tend to post www.twitch.tv link in Telegram channels when stream is live. Also m.twitch.tv is a valid link too.
2026-01-22 16:29:42 +00:00
Daniel Drews 76a42f5f6f Update strings.xml for German Language
some small translation correction, some extended translations, where sentences had just one word as translation and add missing translations from the english strings.xml
2025-04-13 10:41:29 +02:00
107 changed files with 3247 additions and 1377 deletions
+39 -19
View File
@@ -1,37 +1,57 @@
variables:
GIT_SUBMODULE_STRATEGY: recursive
stages:
- buildAndDeployApkUnstable
- buildAndDeployApkStable
- buildAndDeployPlaystore
buildAndDeployApkUnstable:
stage: buildAndDeployApkUnstable
stage: build
script:
- sh deploy-unstable.sh
only:
- tags
except:
- ^(dev)
when: manual
needs: []
allow_failure: true
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/apk/unstable/release/*.apk
buildAndDeployApkStable:
stage: buildAndDeployApkStable
stage: build
script:
- sh deploy-stable.sh
only:
- tags
except:
- branches
when: manual
needs: []
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/apk/stable/release/*.apk
buildAndDeployPlaystore:
stage: buildAndDeployPlaystore
stage: deploy
script:
- sh deploy-playstore.sh
- sh build-playstore.sh
- bash tools/venv_playstore.sh
- . .venv-playstore/bin/activate
- python publish_playstore.py --sa /root/grayjay.json --package com.futo.platformplayer.playstore --aab ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab --track production --status completed
only:
- tags
except:
- branches
when: manual
when: on_success
needs:
- buildAndDeployApkStable
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/bundle/playstoreRelease/*.aab
updateFdroidRepo:
stage: deploy
only:
- tags
when: on_success
needs:
- job: buildAndDeployApkStable
artifacts: true
script:
- python3 update_fdroid_index.py
+18
View File
@@ -106,3 +106,21 @@
[submodule "app/src/unstable/assets/sources/mixcloud"]
path = app/src/unstable/assets/sources/mixcloud
url = ../plugins/mixcloud.git
[submodule "app/src/unstable/assets/sources/radiobrowser"]
path = app/src/unstable/assets/sources/radiobrowser
url = ../plugins/radiobrowser.git
[submodule "app/src/stable/assets/sources/radiobrowser"]
path = app/src/stable/assets/sources/radiobrowser
url = ../plugins/radiobrowser.git
[submodule "app/src/stable/assets/sources/redbull-tv"]
path = app/src/stable/assets/sources/redbull-tv
url = ../plugins/redbull-tv.git
[submodule "app/src/unstable/assets/sources/redbull-tv"]
path = app/src/unstable/assets/sources/redbull-tv
url = ../plugins/redbull-tv.git
[submodule "app/src/unstable/assets/sources/fosdem"]
path = app/src/unstable/assets/sources/fosdem
url = ../plugins/fosdem.git
[submodule "app/src/stable/assets/sources/fosdem"]
path = app/src/stable/assets/sources/fosdem
url = ../plugins/fosdem.git
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:50b53a5d297d0c3008b3359a452a5c9e7e9f530533ec197e3dd1e9f08f9e84ad
size 6342128
+1 -6
View File
@@ -206,6 +206,7 @@ dependencies {
implementation 'com.google.zxing:core:3.5.3'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'androidx.webkit:webkit:1.15.0'
//Protobuf
implementation 'com.google.protobuf:protobuf-javalite:4.33.0'
@@ -230,10 +231,4 @@ dependencies {
testImplementation "org.mockito:mockito-core:5.20.0"
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
//Rust casting SDK
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.4.0') {
// Polycentricandroid includes this
exclude group: 'net.java.dev.jna'
}
}
@@ -1,111 +0,0 @@
package com.futo.platformplayer
import android.util.Base64
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.futo.platformplayer.casting.FCastCastingDevice
import com.futo.platformplayer.casting.Opcode
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
import com.futo.platformplayer.casting.models.FCastKeyExchangeMessage
import com.futo.platformplayer.casting.models.FCastPlayMessage
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import java.security.KeyFactory
import java.security.spec.PKCS8EncodedKeySpec
import javax.crypto.spec.SecretKeySpec
@RunWith(AndroidJUnit4::class)
class FCastEncryptionTests {
@Test
fun testDHEncryptionSelf() {
val keyPair1 = FCastCastingDevice.generateKeyPair()
val keyPair2 = FCastCastingDevice.generateKeyPair()
Log.i("testDHEncryptionSelf", "privates (1: ${Base64.encodeToString(keyPair1.private.encoded, Base64.NO_WRAP)}, 2: ${Base64.encodeToString(keyPair2.private.encoded, Base64.NO_WRAP)})")
val keyExchangeMessage1 = FCastCastingDevice.getKeyExchangeMessage(keyPair1)
val keyExchangeMessage2 = FCastCastingDevice.getKeyExchangeMessage(keyPair2)
Log.i("testDHEncryptionSelf", "publics (1: ${keyExchangeMessage1.publicKey}, 2: ${keyExchangeMessage2.publicKey})")
val aesKey1 = FCastCastingDevice.computeSharedSecret(keyPair1.private, keyExchangeMessage2)
val aesKey2 = FCastCastingDevice.computeSharedSecret(keyPair2.private, keyExchangeMessage1)
assertEquals(Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP), Base64.encodeToString(aesKey2.encoded, Base64.NO_WRAP))
Log.i("testDHEncryptionSelf", "aesKey ${Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP)}")
val message = FCastPlayMessage("text/html")
val serializedBody = Json.encodeToString(message)
val encryptedMessage = FCastCastingDevice.encryptMessage(aesKey1, FCastDecryptedMessage(Opcode.Play.value.toLong(), serializedBody))
Log.i("testDHEncryptionSelf", Json.encodeToString(encryptedMessage))
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey1, encryptedMessage)
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
assertEquals(serializedBody, decryptedMessage.message)
}
@Test
fun testAESKeyGeneration() {
val cases = listOf(
listOf(
//Public other
"MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgEnOS0oHteVA+3kND3u4yXe7GGRohy1LkR9Q5tL4c4ylC5n4iSwWSoIhcSIvUMWth6KAhPhu05sMcPY74rFMSS2AGTNCdT/5KilediipuUMdFVvjGqfNMNH1edzW5mquIw3iXKdfQmfY/qxLTI2wccyDj4hHFhLCZL3Y+shsm3KF",
//Private self
"MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAeo/ceIeH8Jt1ZRNKX5aTHkMi23GCV1LtcS2O6Tktn9k8DCv7gIoekysQUhMyWtR+MsZlq2mXjr1JFpAyxl89rqoEPU6QDsGe9q8R4O8eBZ2u+48mkUkGSh7xPGRQUBvmhH2yk4hIEA8aK4BcYi1OTsCZtmk7pQq+uaFkKovD/8M=",
//AES
"7dpl1/6KQTTooOrFf2VlUOSqgrFHi6IYxapX0IxFfwk="
),
listOf(
//Public other
"MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgGvIlCP/S+xpAuNEHSn4cEDOL1esUf+uMuY2Kp5J10a7HGbwzNd+7eYsgEc4+adddgB7hJgTvjsGg7lXUhHQ7WbfbCGgt7dbkx8qkic6Rgq4f5eRYd1Cgidw4MhZt7mEIOKrHweqnV6B9rypbXjbqauc6nGgtwx+Gvl6iLpVATRK",
//Private self
"MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAMXmiIgWyutbaO+f4UiMAb09iVVSCI6Lb6xzNyD2MpUZyk4/JOT04Daj4JeCKFkF1Fq79yKhrnFlXCrF4WFX00xUOXb8BpUUUH35XG5ApvolQQLL6N0om8/MYP4FK/3PUxuZAJz45TUsI/v3u6UqJelVTNL83ltcFbZDIfEVftRA=",
//AES
"a2tUSxnXifKohfNocAQHkAlPffDv6ReihJ7OojBGt0Q="
)
)
for (case in cases) {
val decodedPrivateKey1 = Base64.decode(case[1], Base64.NO_WRAP)
val keyExchangeMessage2 = FCastKeyExchangeMessage(1, case[0])
val keyFactory = KeyFactory.getInstance("DH")
val privateKeySpec = PKCS8EncodedKeySpec(decodedPrivateKey1)
val privateKey = keyFactory.generatePrivate(privateKeySpec)
val aesKey1 = FCastCastingDevice.computeSharedSecret(privateKey, keyExchangeMessage2)
assertEquals(case[2], Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP))
}
}
@Test
fun testDHEncryptionKnown() {
val decodedPrivateKey1 = Base64.decode("MIIDJwIBADCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egSCAQQCggEAECNvEczf0y6IoX/IwhrPeWZ5IxrHcpwjcdVAuyZQLLlOq0iqnYMFcSD8QjMF8NKObfZZCDQUJlzGzRsG0oXsWiWtmoRvUZ9tQK0j28hDylpbyP00Bt9NlMgeHXkAy54P7Z2v/BPCd3o23kzjgXzYaSRuCFY7zQo1g1IQG8mfjYjdE4jjRVdVrlh8FS8x4OLPeglc+cp2/kuyxaVEfXAG84z/M8019mRSfdczi4z1iidPX6HgDEEWsN42Ud60mNKy5jsQpQYkRdOLmxR3+iQEtGFjdzbVhVCUr7S5EORU9B1MOl5gyPJpjfU3baOqrg6WXVyTvMDaA05YEnAHQNOOfA==", Base64.NO_WRAP)
val keyExchangeMessage2 = FCastKeyExchangeMessage(1, "MIIDJTCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egOCAQUAAoIBAGlL9EYsrFz3I83NdlwhM241M+M7PA9P5WXgtdvS+pcalIaqN2IYdfzzCUfye7lchVkT9A2Y9eWQYX0OUhmjf8PPKkRkATLXrqO5HTsxV96aYNxMjz5ipQ6CaErTQaPLr3OPoauIMPVVI9zM+WT0KOGp49YMyx+B5rafT066vOVbF/0z1crq0ZXxyYBUv135rwFkIHxBMj5bhRLXKsZ2G5aLAZg0DsVam104mgN/v75f7Spg/n5hO7qxbNgbvSrvQ7Ag/rMk5T3sk7KoM23Qsjl08IZKs2jjx21MiOtyLqGuCW6GOTNK4yEEDF5gA0K13eXGwL5lPS0ilRw+Lrw7cJU=")
val keyFactory = KeyFactory.getInstance("DH")
val privateKeySpec = PKCS8EncodedKeySpec(decodedPrivateKey1)
val privateKey = keyFactory.generatePrivate(privateKeySpec)
val aesKey1 = FCastCastingDevice.computeSharedSecret(privateKey, keyExchangeMessage2)
assertEquals("vI5LGE625zGEG350ggkyBsIAXm2y4sNohiPcED1oAEE=", Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP))
val message = FCastPlayMessage("text/html")
val serializedBody = Json.encodeToString(message)
val encryptedMessage = FCastCastingDevice.encryptMessage(aesKey1, FCastDecryptedMessage(Opcode.Play.value.toLong(), serializedBody))
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey1, encryptedMessage)
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
assertEquals(serializedBody, decryptedMessage.message)
}
@Test
fun testDecryptMessageKnown() {
val encryptedMessage = Json.decodeFromString<FCastEncryptedMessage>("{\"version\":1,\"iv\":\"C4H70VC5FWrNtkty9/cLIA==\",\"blob\":\"K6/N7JMyi1PFwKhU0mFj7ZJmd/tPp3NCOMldmQUtDaQ7hSmPoIMI5QNMOj+NFEiP4qTgtYp5QmBPoQum6O88pA==\"}")
val aesKey = SecretKeySpec(Base64.decode("+hr9Jg8yre7S9WGUohv2AUSzHNQN514JPh6MoFAcFNU=", Base64.NO_WRAP), "AES")
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey, encryptedMessage)
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
assertEquals("{\"container\":\"text/html\"}", decryptedMessage.message)
}
}
@@ -118,14 +118,13 @@ inline fun <reified T> V8ValueArray.expectV8Variants(config: IV8PluginConfig, co
inline fun V8Plugin.ensureIsBusy() {
this.let {
if (!it.isThreadAlreadyBusy()) {
//throw IllegalStateException("Tried to access V8Plugin without busy");
val stacktrace = Thread.currentThread().stackTrace;
Logger.w("Extensions_V8",
"V8 USE OUTSIDE BUSY: " + stacktrace.drop(3)?.firstOrNull().toString() +
", " + stacktrace.drop(4)?.firstOrNull().toString() +
", " + stacktrace.drop(5)?.firstOrNull()?.toString() +
", " + stacktrace.drop(6)?.firstOrNull()?.toString()
);
val message = "V8 USE OUTSIDE BUSY: " + stacktrace.drop(3)?.firstOrNull().toString() +
", " + stacktrace.drop(4)?.firstOrNull().toString() +
", " + stacktrace.drop(5)?.firstOrNull()?.toString() +
", " + stacktrace.drop(6)?.firstOrNull()?.toString();
Logger.w("Extensions_V8", message);
throw IllegalStateException(message);
}
}
}
@@ -136,8 +135,7 @@ inline fun V8Value.ensureIsBusy() {
}
inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextName: String): T {
if(false)
ensureIsBusy();
ensureIsBusy();
return when(T::class) {
String::class -> this.expectOrThrow<V8ValueString>(config, contextName).value as T;
Int::class -> {
@@ -186,10 +184,14 @@ inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextN
else -> throw NotImplementedError("Type ${T::class.simpleName} not implemented conversion");
}
}
fun V8ArrayToStringList(obj: V8ValueArray): List<String> = obj.keys.map { obj.getString(it) };
fun V8ArrayToStringList(obj: V8ValueArray): List<String> {
obj.ensureIsBusy();
return obj.keys.map { obj.getString(it) };
}
fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap<String, String> {
if(obj == null)
return hashMapOf();
obj.ensureIsBusy();
val map = hashMapOf<String, String>();
for(prop in obj.ownPropertyNames.keys.map { obj.ownPropertyNames.get<V8Value>(it).toString() })
map.put(prop, obj.getString(prop));
@@ -203,21 +205,27 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
plugin.busy {
this.register(object: IV8ValuePromise.IListener {
override fun onFulfilled(p0: V8Value?) {
if(p0 is V8ValueError)
promiseException = ScriptExecutionException(plugin.config, p0.message);
else {
if(p0 is V8ValueObject)
p0.setWeak();
promiseResult = p0 as T;
plugin.busy {
if(p0 is V8ValueError)
promiseException = ScriptExecutionException(plugin.config, p0.message);
else {
if(p0 is V8ValueObject)
p0.setWeak();
promiseResult = p0 as T;
}
}
latch.countDown();
}
override fun onRejected(p0: V8Value?) {
promiseException = p0?.toException(plugin.config);
plugin.busy {
promiseException = p0?.toException(plugin.config);
}
latch.countDown();
}
override fun onCatch(p0: V8Value?) {
promiseException = p0?.toException(plugin.config);
plugin.busy {
promiseException = p0?.toException(plugin.config);
}
latch.countDown();
}
});
@@ -229,20 +237,23 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
}
//Logger.i("V8", "V8ValueBlocking started (Busy) [" + blockCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString());
if(!promise.isPending) {
try {
Logger.i("V8", "V8Promise resolved synchronously");
if(promise.isFulfilled)
promiseResult = promise.getResult<T>();
else
promiseException = promise.getResult<V8Value>().toException(plugin.config);
val isPending = plugin.busy {
promise.isPending
};
if(!isPending) {
plugin.busy {
try {
Logger.i("V8", "V8Promise resolved synchronously");
if(promise.isFulfilled)
promiseResult = promise.getResult<T>();
else
promiseException = promise.getResult<V8Value>().toException(plugin.config);
}
catch(ex: Throwable) {
promiseException = ex;
}
}
catch(ex: Throwable) {
promiseException = ex;
}
}
else {
} else {
plugin.unbusy {
latch.await();
}
@@ -266,15 +277,19 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
plugin.busy {
this.register(object: IV8ValuePromise.IListener {
override fun onFulfilled(p0: V8Value?) {
plugin.resolvePromise(promise);
underlyingDef.complete(p0 as T);
plugin.busy {
plugin.resolvePromise(promise);
underlyingDef.complete(p0 as T);
}
}
override fun onRejected(p0: V8Value?) {
try {
plugin.resolvePromise(promise);
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented..");
Logger.i("V8", "Promise rejected, setting exception");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
plugin.busy {
plugin.resolvePromise(promise);
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented..");
Logger.i("V8", "Promise rejected, setting exception");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
}
}
catch(ex: Throwable) {
Logger.e("V8", "Rejection handling failed?" , ex);
@@ -282,9 +297,11 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
}
override fun onCatch(p0: V8Value?) {
try {
plugin.resolvePromise(promise);
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented..");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
plugin.busy {
plugin.resolvePromise(promise);
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented..");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
}
}
catch(ex: Throwable) {
Logger.e("V8", "Catching handling failed?" , ex);
@@ -300,6 +317,7 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
}
fun V8Value.toException(config: IV8PluginConfig): Throwable {
ensureIsBusy();
val p0 = this;
if(p0 is V8ValueObject) {
return V8Plugin.getExceptionFromPlugin(config, p0, null, null, null, "P:");
@@ -349,6 +367,7 @@ class V8Deferred<T>(val deferred: Deferred<T>, val estDuration: Int = -1): Defer
fun <T: V8Value> V8ValueObject.invokeV8(method: String, vararg obj: Any?): T {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) {
return result.toV8ValueBlocking(this.getSourcePlugin()!!);
@@ -356,6 +375,7 @@ fun <T: V8Value> V8ValueObject.invokeV8(method: String, vararg obj: Any?): T {
return result as T;
}
fun <T: V8Value> V8ValueObject.invokeV8Async(method: String, vararg obj: Any?): V8Deferred<T> {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) {
return result.toV8ValueAsync(this.getSourcePlugin()!!);
@@ -363,6 +383,7 @@ fun <T: V8Value> V8ValueObject.invokeV8Async(method: String, vararg obj: Any?):
return V8Deferred(CompletableDeferred(result as T));
}
fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) {
return result.toV8ValueBlocking(this.getSourcePlugin()!!);
@@ -370,6 +391,7 @@ fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value {
return result;
}
fun V8ValueObject.invokeV8VoidAsync(method: String, vararg obj: Any?): V8Deferred<V8Value> {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) {
val result = result.toV8ValueAsync<V8Value>(this.getSourcePlugin()!!);
@@ -399,4 +421,4 @@ fun <T> IPager<T>.toList(): List<T> {
}
return list.toList();
}
}
@@ -6,6 +6,7 @@ import android.content.Intent
import android.net.Uri
import android.webkit.CookieManager
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.ManageTabsActivity
import com.futo.platformplayer.activities.PolycentricHomeActivity
@@ -312,7 +313,7 @@ class Settings : FragmentedStorageFileJson() {
var showSubscriptionGroups: Boolean = true;
@FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6)
var useSubscriptionExchange: Boolean = false;
var useSubscriptionExchange: Boolean = true;
@AdvancedField
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
@@ -400,10 +401,11 @@ class Settings : FragmentedStorageFileJson() {
8 -> "id";
9 -> "hi";
10 -> "ar";
11 -> "tu";
11 -> "tr";
12 -> "ru";
13 -> "pt";
14 -> "zh";
15 -> "it";
else -> null
}
}
@@ -787,7 +789,6 @@ class Settings : FragmentedStorageFileJson() {
}
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
@Transient
var plugins = Plugins();
@Serializable
class Plugins {
@@ -796,6 +797,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
var checkDisabledPluginsForUpdates: Boolean = false;
@AdvancedField
@FormField(R.string.clear_cookies_after_login, FieldForm.TOGGLE, R.string.clear_cookies_after_login_desc, 0)
var clearCookiesAfterLogin: Boolean = false;
@AdvancedField
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
var clearCookiesOnLogout: Boolean = true;
@@ -805,6 +809,12 @@ class Settings : FragmentedStorageFileJson() {
val cookieManager: CookieManager = CookieManager.getInstance();
cookieManager.removeAllCookies(null);
}
fun shouldClearWebviewCookies(): Boolean {
return clearCookiesAfterLogin;
}
/*@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) {
@@ -952,18 +962,31 @@ class Settings : FragmentedStorageFileJson() {
class Backup {
@Serializable(with = OffsetDateTimeSerializer::class)
var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN;
var didAskAutoBackup: Boolean = true;
var didAskAutoBackup: Boolean = false;
var autoBackupEnabled: Boolean = false
var autoBackupPassword: String? = null;
fun shouldAutomaticBackup() = autoBackupPassword != null;
fun shouldAutomaticBackup() = autoBackupEnabled
@FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0)
val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day";
@FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
fun configureAutomaticBackup() {
UIDialogs.showAutomaticBackupDialog(StateApp.instance.activity!!, autoBackupPassword != null) {
SettingsFragment.currentView?.reloadSettings();
};
StateApp.instance.activity?.let { activity ->
if(!Settings.instance.storage.isStorageMainValid(activity)) {
UIDialogs.toast("Missing general directory")
StateApp.instance.changeExternalGeneralDirectory(activity) {
UIDialogs.showAutomaticBackupDialog(activity) {
SettingsFragment.currentView?.reloadSettings()
}
};
}
else {
UIDialogs.showAutomaticBackupDialog(activity) {
SettingsFragment.currentView?.reloadSettings()
}
}
}
}
@FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
fun restoreAutomaticBackup() {
@@ -1048,11 +1071,6 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
var polycentricLocalCache: Boolean = true;
var showPrivacyModeDialog: Boolean = true;
fun shouldClearWebviewCookies(): Boolean {
return true;
}
}
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
@@ -19,6 +19,7 @@ import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -165,27 +166,42 @@ class UIDialogs {
dialog.show()
}
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
val dialogAction: ()->Unit = {
val dialog = AutomaticBackupDialog(context);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog); onClosed?.invoke() };
dialog.show();
};
if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck)
UIDialogs.showDialog(context, R.drawable.ic_move_up, context.getString(R.string.an_old_backup_is_available), context.getString(R.string.would_you_like_to_restore_this_backup), null, 0,
UIDialogs.Action(context.getString(R.string.cancel), {}), //To nothing
UIDialogs.Action(context.getString(R.string.override), {
dialogAction();
fun showAutomaticBackupDialog(context: Context, onClosed: (() -> Unit)? = null) {
val dialogAction: () -> Unit = {
val dialog = AutomaticBackupDialog(context)
registerDialogOpened(dialog)
dialog.setOnDismissListener {
registerDialogClosed(dialog)
onClosed?.invoke()
}
dialog.show()
}
if (!Settings.instance.backup.autoBackupEnabled && StateBackup.hasAutomaticBackup()) {
UIDialogs.showDialog(
context,
R.drawable.ic_move_up,
context.getString(R.string.an_old_backup_is_available),
context.getString(R.string.would_you_like_to_restore_this_backup),
null,
0,
UIDialogs.Action(context.getString(R.string.cancel), {}),
UIDialogs.Action(context.getString(R.string.continue_anyway), {
dialogAction()
}, UIDialogs.ActionStyle.DANGEROUS),
UIDialogs.Action(context.getString(R.string.restore), {
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
val scope = (context as? androidx.lifecycle.LifecycleOwner)?.lifecycleScope
?: StateApp.instance.scopeOrNull
?: StateApp.instance.scope
UIDialogs.showAutomaticRestoreDialog(context, scope)
}, UIDialogs.ActionStyle.PRIMARY)
);
else {
dialogAction();
)
} else {
dialogAction()
}
}
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
val dialog = AutomaticRestoreDialog(context, scope);
registerDialogOpened(dialog);
@@ -68,11 +68,15 @@ class CaptchaActivity : AppCompatActivity() {
intent.getStringExtra("body");
else null;
_webView.settings.userAgentString = captchaConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
if (captchaConfig.userAgent != null)
_webView.settings.userAgentString = captchaConfig.userAgent;
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
val capturedUserAgent = _webView.settings.userAgentString;
_webView.settings.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = true;
val webViewClient = if(config != null) CaptchaWebViewClient(config) else CaptchaWebViewClient(captchaConfig);
val webViewClient = if(config != null) CaptchaWebViewClient(config, capturedUserAgent) else CaptchaWebViewClient(captchaConfig, capturedUserAgent);
webViewClient.onCaptchaFinished.subscribe { captcha ->
_callback?.let {
_callback = null;
@@ -61,11 +61,15 @@ class LoginActivity : AppCompatActivity() {
else throw IllegalStateException("No valid configuration?");
//TODO: Backwards compat removal?
_webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
if (authConfig.userAgent != null)
_webView.settings.userAgentString = authConfig.userAgent;
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
val capturedUserAgent = _webView.settings.userAgentString;
_webView.settings.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = true;
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig);
val webViewClient = if(config != null) LoginWebViewClient(config, capturedUserAgent) else LoginWebViewClient(authConfig, capturedUserAgent);
webViewClient.onLogin.subscribe { auth ->
_callback?.let {
@@ -1543,4 +1543,4 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return sourcesIntent;
}
}
}
}
@@ -55,6 +55,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptValidationException
import com.futo.platformplayer.engine.packages.PackageBridge
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.states.AnnouncementType
@@ -156,6 +157,7 @@ open class JSClient : IPlatformClient {
_httpClient = JSHttpClient(this, null, _captcha, config);
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
_plugin.bridge.descriptor = descriptor;
_plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js");
@@ -189,6 +191,7 @@ open class JSClient : IPlatformClient {
_httpClient = JSHttpClient(this, null, _captcha, config);
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
_plugin.bridge.descriptor = descriptor;
_plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js");
_plugin.withScript(script);
@@ -485,13 +488,14 @@ open class JSClient : IPlatformClient {
if (_peekChannelTypes != null) {
return _peekChannelTypes!!;
}
val arr: V8ValueArray = plugin.executeTyped("source.getPeekChannelTypes()");
_peekChannelTypes = arr.keys.mapNotNull {
val str = arr.get<V8ValueString>(it);
return@mapNotNull str.value;
};
return _peekChannelTypes ?: listOf();
return busy {
val arr: V8ValueArray = plugin.executeTyped("source.getPeekChannelTypes()");
_peekChannelTypes = arr.keys.mapNotNull {
val str = arr.get<V8ValueString>(it);
return@mapNotNull str.value;
};
return@busy _peekChannelTypes ?: listOf();
}
}
catch(ex: Throwable) {
announcePluginUnhandledException("getPeekChannelTypes", ex);
@@ -520,10 +524,12 @@ open class JSClient : IPlatformClient {
if(!capabilities.hasGetChannelUrlByClaim)
throw IllegalStateException("This plugin does not support channel url by claim");
val value = plugin.executeTyped<V8Value>("source.getChannelUrlByClaim(${claimType}, ${Json.encodeToString(claimValues)})");
if(value !is V8ValueString)
return null;
return value.value;
return busy {
val value = plugin.executeTyped<V8Value>("source.getChannelUrlByClaim(${claimType}, ${Json.encodeToString(claimValues)})");
if(value !is V8ValueString)
return@busy null;
return@busy value.value;
}
}
@JSOptional
@JSDocs(12, "source.getChannelTemplateByClaimMap()", "Get a map for every supported claimtype mapping field to urls")
@@ -533,28 +539,30 @@ open class JSClient : IPlatformClient {
if(!capabilities.hasGetChannelTemplateByClaimMap)
throw IllegalStateException("This plugin does not support channel template by claim map");
val value = plugin.executeTyped<V8Value>("source.getChannelTemplateByClaimMap()");
if(value !is V8ValueObject)
return mapOf();
return busy {
val value = plugin.executeTyped<V8Value>("source.getChannelTemplateByClaimMap()");
if(value !is V8ValueObject)
return@busy mapOf();
val claimTypes = mutableMapOf<Int, Map<Int, String>>();
val claimTypes = mutableMapOf<Int, Map<Int, String>>();
val keys = value.ownPropertyNames;
for(key in keys.toArray()) {
if(key is V8ValueInteger) {
val map = value.get<V8ValueObject>(key);
val mapKeys = map.ownPropertyNames;
val keys = value.ownPropertyNames;
for(key in keys.toArray()) {
if(key is V8ValueInteger) {
val map = value.get<V8ValueObject>(key);
val mapKeys = map.ownPropertyNames;
claimTypes[key.value] = mapKeys.toArray().filter {
it is V8ValueInteger
}.associate {
val mapKey = (it as V8ValueInteger).value;
return@associate Pair(mapKey, map.getString(mapKey));
};
claimTypes[key.value] = mapKeys.toArray().filter {
it is V8ValueInteger
}.associate {
val mapKey = (it as V8ValueInteger).value;
return@associate Pair(mapKey, map.getString(mapKey));
};
}
}
channelClaimTemplates = claimTypes.toMap();
return@busy claimTypes;
}
channelClaimTemplates = claimTypes.toMap();
return claimTypes;
}
@@ -695,27 +703,33 @@ open class JSClient : IPlatformClient {
@JSDocs(22, "source.getUserPlaylists()", "Gets the playlist of the current user")
override fun getUserPlaylists(): Array<String> {
ensureEnabled();
return plugin.executeTyped<V8ValueArray>("source.getUserPlaylists()")
.toArray()
.map { (it as V8ValueString).value }
.toTypedArray();
return busy {
return@busy plugin.executeTyped<V8ValueArray>("source.getUserPlaylists()")
.toArray()
.map { (it as V8ValueString).value }
.toTypedArray();
}
}
@JSOptional
@JSDocs(23, "source.getUserSubscriptions()", "Gets the subscriptions of the current user")
override fun getUserSubscriptions(): Array<String> {
ensureEnabled();
return plugin.executeTyped<V8ValueArray>("source.getUserSubscriptions()")
.toArray()
.map { (it as V8ValueString).value }
.toTypedArray();
return busy {
return@busy plugin.executeTyped<V8ValueArray>("source.getUserSubscriptions()")
.toArray()
.map { (it as V8ValueString).value }
.toTypedArray();
}
}
@JSOptional
@JSDocs(23, "source.getUserHistory()", "Gets the history of the current user")
override fun getUserHistory(): IPager<IPlatformContent> {
ensureEnabled();
return JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
return isBusyWith("getUserHistory") {
return@isBusyWith JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
}
}
fun validate() {
@@ -891,4 +905,4 @@ open class JSClient : IPlatformClient {
return name;
}
}
}
}
@@ -5,9 +5,9 @@ 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()) {
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf(), val userAgent: String? = null) {
override fun toString(): String {
return "(headers: '$headers', cookieString: '$cookieMap')";
return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
}
fun toEncrypted(): String{
@@ -15,23 +15,25 @@ data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? =
}
private fun serialize(): String {
return Json.encodeToString(SerializedAuth(cookieMap, headers));
return Json.encodeToString(SerializedAuth(cookieMap, headers, userAgent));
}
companion object {
val TAG = "SourceAuth";
private val _json = Json { ignoreUnknownKeys = true };
fun fromEncrypted(encrypted: String?): SourceAuth? {
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
}
private fun deserialize(str: String): SourceAuth {
val data = Json.decodeFromString<SerializedAuth>(str);
return SourceAuth(data.cookieMap, data.headers);
val data = _json.decodeFromString<SerializedAuth>(str);
return SourceAuth(data.cookieMap, data.headers, data.userAgent);
}
}
@Serializable
data class SerializedAuth(val cookieMap: HashMap<String, HashMap<String, String>>?,
val headers: Map<String, Map<String, String>> = mapOf())
val headers: Map<String, Map<String, String>> = mapOf(),
val userAgent: String? = null)
}
@@ -5,9 +5,9 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf(), val userAgent: String? = null) {
override fun toString(): String {
return "(headers: '$headers', cookieString: '$cookieMap')";
return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
}
fun toEncrypted(): String{
@@ -15,23 +15,25 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
}
private fun serialize(): String {
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers));
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers, userAgent));
}
companion object {
val TAG = "SourceCaptchaData";
private val _json = Json { ignoreUnknownKeys = true };
fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
}
fun deserialize(str: String): SourceCaptchaData {
val data = Json.decodeFromString<SerializedCaptchaData>(str);
return SourceCaptchaData(data.cookieMap, data.headers);
val data = _json.decodeFromString<SerializedCaptchaData>(str);
return SourceCaptchaData(data.cookieMap, data.headers, data.userAgent);
}
}
@Serializable
data class SerializedCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>?,
val headers: Map<String, Map<String, String>> = mapOf())
val headers: Map<String, Map<String, String>> = mapOf(),
val userAgent: String? = null)
}
@@ -170,12 +170,12 @@ class SourcePluginConfig(
"Unrestricted Http Header access",
"Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests."
))
if(packagesOptional.contains("Browser") || packages.contains("Browser")) {
/*if(packagesOptional.contains("Browser") || packages.contains("Browser")) {
list.add(Pair(
"Browser Interop",
"This plugin requires webbrowser interop. May access urls outside of the restricted urls. This will only work for official plugins and during development builds."
))
}
}*/
return list;
}
@@ -235,7 +235,8 @@ class SourcePluginConfig(
val variable: String? = null,
val dependency: String? = null,
val warningDialog: String? = null,
val options: List<String>? = null
val options: List<String>? = null,
val isAdvanced: Boolean? = null
) {
val variableOrName: String get() = variable ?: name;
}
@@ -21,6 +21,7 @@ import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.states.StateDeveloper
open class JSArticle(
@@ -34,7 +35,7 @@ open class JSArticle(
obj.getOrDefault<String>(config, "summary", "PlatformArticle", "") ?: ""
override val thumbnails: Thumbnails? =
if (obj.has("thumbnails"))
if (obj.getSourcePlugin()?.busy { obj.has("thumbnails") } ?: obj.has("thumbnails"))
Thumbnails.fromV8(
config,
obj.getOrThrow<V8ValueObject>(config, "thumbnails", "PlatformArticle")
@@ -31,18 +31,20 @@ open class JSArticleDetails(
final override val contentType: ContentType = ContentType.ARTICLE
private val _hasGetComments: Boolean = _content.has("getComments")
private val _hasGetContentRecommendations: Boolean = _content.has("getContentRecommendations")
private val _hasGetComments: Boolean = client.busy { _content.has("getComments") }
private val _hasGetContentRecommendations: Boolean = client.busy { _content.has("getContentRecommendations") }
override val rating: IRating =
override val rating: IRating = client.busy {
obj.getOrDefault<V8ValueObject>(client.config, "rating", "PlatformArticle", null)
?.let { IRating.fromV8(client.config, it, "PlatformArticle") }
?: RatingLikes(0)
}
override val summary: String =
override val summary: String = client.busy {
_content.getOrThrow(client.config, "summary", "PlatformArticle")
}
override val thumbnails: Thumbnails? =
override val thumbnails: Thumbnails? = client.busy {
if (_content.has("thumbnails"))
Thumbnails.fromV8(
client.config,
@@ -50,14 +52,19 @@ open class JSArticleDetails(
)
else
null
}
override val segments: List<IJSArticleSegment> =
override val segments: List<IJSArticleSegment> = client.busy {
obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", "PlatformArticle")
?.mapNotNull { fromV8Segment(client, it) }
?: emptyList()
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
if(!_hasGetComments || _content.isClosed)
val canGetComments = this.client.busy {
_hasGetComments && !_content.isClosed
}
if(!canGetComments)
return null;
if(client is DevJSClient)
@@ -73,7 +80,10 @@ open class JSArticleDetails(
override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
if(!_hasGetContentRecommendations || _content.isClosed)
val canGetContentRecommendations = this.client.busy {
_hasGetContentRecommendations && !_content.isClosed
}
if(!canGetContentRecommendations)
return null;
if(client is DevJSClient)
@@ -87,25 +97,31 @@ open class JSArticleDetails(
}
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return JSContentPager(_pluginConfig, client, contentPager);
return client.busy {
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return@busy JSContentPager(_pluginConfig, client, contentPager);
}
}
private fun getCommentsJS(client: JSClient): JSCommentPager {
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return JSCommentPager(_pluginConfig, client, commentPager);
return client.busy {
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return@busy JSCommentPager(_pluginConfig, client, commentPager);
}
}
companion object {
fun fromV8Segment(client: JSClient, obj: V8ValueObject): IJSArticleSegment? {
if(!obj.has("type"))
throw IllegalArgumentException("Object missing type field");
return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
SegmentType.TEXT -> JSTextSegment(client, obj);
SegmentType.IMAGES -> JSImagesSegment(client, obj);
SegmentType.HEADER -> JSHeaderSegment(client, obj);
SegmentType.NESTED -> JSNestedSegment(client, obj);
else -> null;
return client.busy {
if(!obj.has("type"))
throw IllegalArgumentException("Object missing type field");
return@busy when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
SegmentType.TEXT -> JSTextSegment(client, obj);
SegmentType.IMAGES -> JSImagesSegment(client, obj);
SegmentType.HEADER -> JSHeaderSegment(client, obj);
SegmentType.NESTED -> JSNestedSegment(client, obj);
else -> null;
}
}
}
}
@@ -176,4 +192,4 @@ class JSNestedSegment: IJSArticleSegment {
val nestedObj = obj.getOrThrow<V8ValueObject>(client.config, "nested", contextName, false);
nested = IJSContent.fromV8(client, nestedObj);
}
}
}
@@ -46,23 +46,45 @@ class JSComment : IPlatformComment {
_comment = obj;
_plugin = plugin;
val contextName = "Comment";
contextUrl = _comment!!.getOrThrow(config, "contextUrl", contextName);
author = PlatformAuthorLink.fromV8(_config!!, _comment!!.getOrThrow(config, "author", contextName));
message = _comment!!.getOrThrow(config, "message", contextName);
rating = IRating.fromV8(config, _comment!!.getOrThrow(config, "rating", contextName));
date = _comment!!.getOrThrowNullable<Int>(config, "date", contextName)?.let { OffsetDateTime.of(LocalDateTime.ofEpochSecond(it.toLong(), 0, ZoneOffset.UTC), ZoneOffset.UTC) }
replyCount = _comment!!.getOrThrowNullable(config, "replyCount", contextName);
context = _comment!!.getOrDefault(config, "context", contextName, hashMapOf()) ?: hashMapOf();
_hasGetReplies = _comment!!.has("getReplies");
var parsedContextUrl: String? = null;
var parsedAuthor: PlatformAuthorLink? = null;
var parsedMessage: String? = null;
var parsedRating: IRating? = null;
var parsedDate: OffsetDateTime? = null;
var parsedReplyCount: Int? = null;
var parsedContext: Map<String, String>? = null;
var parsedHasGetReplies = false;
plugin.busy {
val contextName = "Comment";
parsedContextUrl = _comment!!.getOrThrow(config, "contextUrl", contextName);
parsedAuthor = PlatformAuthorLink.fromV8(_config!!, _comment!!.getOrThrow(config, "author", contextName));
parsedMessage = _comment!!.getOrThrow(config, "message", contextName);
parsedRating = IRating.fromV8(config, _comment!!.getOrThrow(config, "rating", contextName));
parsedDate = _comment!!.getOrThrowNullable<Int>(config, "date", contextName)?.let { OffsetDateTime.of(LocalDateTime.ofEpochSecond(it.toLong(), 0, ZoneOffset.UTC), ZoneOffset.UTC) };
parsedReplyCount = _comment!!.getOrThrowNullable(config, "replyCount", contextName);
parsedContext = _comment!!.getOrDefault(config, "context", contextName, hashMapOf()) ?: hashMapOf();
parsedHasGetReplies = _comment!!.has("getReplies");
}
contextUrl = parsedContextUrl ?: "";
author = parsedAuthor ?: PlatformAuthorLink.UNKNOWN;
message = parsedMessage ?: "";
rating = parsedRating ?: throw IllegalStateException("Missing comment rating");
date = parsedDate;
replyCount = parsedReplyCount;
context = parsedContext ?: hashMapOf();
_hasGetReplies = parsedHasGetReplies;
}
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment>? {
if(!_hasGetReplies)
return null;
val obj = _comment!!.invokeV8<V8ValueObject>("getReplies", arrayOf<Any>());
val plugin = if(client is JSClient) client else throw NotImplementedError("Only implemented for JSClient");
return JSCommentPager(_config!!, plugin, obj);
return plugin.busy {
val obj = _comment!!.invokeV8<V8ValueObject>("getReplies", arrayOf<Any>());
return@busy JSCommentPager(_config!!, plugin, obj);
}
}
}
}
@@ -11,6 +11,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.decodeUnicode
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.logging.Logger
import java.time.LocalDateTime
import java.time.OffsetDateTime
@@ -23,7 +24,8 @@ open class JSContent(
override val contentType: ContentType = ContentType.UNKNOWN
protected val _hasGetDetails: Boolean = _content.has("getDetails")
protected val _hasGetDetails: Boolean =
_content.getSourcePlugin()?.busy { _content.has("getDetails") } ?: false
override val id: PlatformID =
PlatformID.fromV8(_pluginConfig, _content.getOrThrow(_pluginConfig, "id", CTX))
@@ -41,7 +41,9 @@ abstract class JSPager<T> : IPager<T> {
}
override fun hasMorePages(): Boolean {
return _hasMorePages && !pager.isClosed;
return plugin.getUnderlyingPlugin().busy {
_hasMorePages && !pager.isClosed;
}
}
override fun nextPage() {
@@ -91,4 +93,4 @@ abstract class JSPager<T> : IPager<T> {
}
abstract fun convertResult(obj: V8ValueObject): T;
}
}
@@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.states.StateDeveloper
@@ -30,52 +31,79 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformPostDetails";
var parsedRating: IRating? = null;
var parsedTextType: TextType? = null;
var parsedContent: String? = null;
var parsedHasGetComments = false;
var parsedHasGetContentRecommendations = false;
rating = obj.getOrDefault<V8ValueObject>(config, "rating", contextName, null)?.let { IRating.fromV8(config, it, contextName) } ?: RatingLikes(0);
textType = TextType.fromInt((obj.getOrDefault<Int>(config, "textType", contextName, null) ?: 0));
content = obj.getOrDefault(config, "content", contextName, "") ?: "";
val parse = {
val contextName = "PlatformPostDetails";
_hasGetComments = _content.has("getComments");
_hasGetContentRecommendations = _content.has("getContentRecommendations");
parsedRating = obj.getOrDefault<V8ValueObject>(config, "rating", contextName, null)?.let { IRating.fromV8(config, it, contextName) } ?: RatingLikes(0);
parsedTextType = TextType.fromInt((obj.getOrDefault<Int>(config, "textType", contextName, null) ?: 0));
parsedContent = obj.getOrDefault(config, "content", contextName, "") ?: "";
parsedHasGetComments = _content.has("getComments");
parsedHasGetContentRecommendations = _content.has("getContentRecommendations");
};
obj.getSourcePlugin()?.busy {
parse();
} ?: parse()
rating = parsedRating ?: RatingLikes(0);
textType = parsedTextType ?: TextType.RAW;
content = parsedContent ?: "";
_hasGetComments = parsedHasGetComments;
_hasGetContentRecommendations = parsedHasGetContentRecommendations;
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
if(!_hasGetComments || _content.isClosed)
val jsClient = client as? JSClient;
if(jsClient == null)
return null;
val canGetComments = jsClient.busy {
_hasGetComments && !_content.isClosed
}
if(!canGetComments)
return null;
if(client is DevJSClient)
return StateDeveloper.instance.handleDevCall(client.devID, "videoDetail.getComments()") {
return@handleDevCall getCommentsJS(client);
}
else if(client is JSClient)
return getCommentsJS(client);
return null;
return getCommentsJS(jsClient);
}
override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
if(!_hasGetContentRecommendations || _content.isClosed)
val jsClient = client as? JSClient;
if(jsClient == null)
return null;
val canGetContentRecommendations = jsClient.busy {
_hasGetContentRecommendations && !_content.isClosed
}
if(!canGetContentRecommendations)
return null;
if(client is DevJSClient)
return StateDeveloper.instance.handleDevCall(client.devID, "postDetail.getContentRecommendations()") {
return@handleDevCall getContentRecommendationsJS(client);
}
else if(client is JSClient)
return getContentRecommendationsJS(client);
return null;
return getContentRecommendationsJS(jsClient);
}
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return JSContentPager(_pluginConfig, client, contentPager);
return client.busy {
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return@busy JSContentPager(_pluginConfig, client, contentPager);
}
}
private fun getCommentsJS(client: JSClient): JSCommentPager {
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return JSCommentPager(_pluginConfig, client, commentPager);
return client.busy {
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return@busy JSCommentPager(_pluginConfig, client, commentPager);
}
}
}
}
@@ -41,20 +41,26 @@ class JSRequestExecutor: AutoCloseable {
this._config = plugin.config;
val config = plugin.config;
urlPrefix = executor.getOrDefault(config, "urlPrefix", "RequestExecutor", null);
var parsedUrlPrefix: String? = null;
var parsedHasCleanup = false;
plugin.busy {
parsedUrlPrefix = executor.getOrDefault(config, "urlPrefix", "RequestExecutor", null);
if(!executor.has("executeRequest"))
throw ScriptImplementationException(config, "RequestExecutor is missing executeRequest", null);
hasCleanup = executor.has("cleanup");
if(!executor.has("executeRequest"))
throw ScriptImplementationException(config, "RequestExecutor is missing executeRequest", null);
parsedHasCleanup = executor.has("cleanup");
}
urlPrefix = parsedUrlPrefix;
hasCleanup = parsedHasCleanup;
}
//TODO: Executor properties?
@Throws(ScriptException::class)
open fun executeRequest(method: String, url: String, body: ByteArray?, headers: Map<String, String>): ByteArray {
if (_executor.isClosed)
throw IllegalStateException("Executor object is closed");
return _plugin.getUnderlyingPlugin().busy {
if (_executor.isClosed)
throw IllegalStateException("Executor object is closed");
val result = if(_plugin is DevJSClient)
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
@@ -108,10 +114,12 @@ class JSRequestExecutor: AutoCloseable {
open fun cleanup() {
synchronized(_cleanLock) {
if (!hasCleanup || _executor.isClosed || _cleaned)
return;
_cleaned = true;
_plugin.busy {
synchronized(_cleanLock) {
if (!hasCleanup || _executor.isClosed || _cleaned)
return@busy;
_cleaned = true;
}
}
Logger.i("JSRequestExecutor", "JSRequestExecutor cleanup requested");
_plugin.busy {
@@ -163,4 +171,4 @@ class ExecutorParameters {
var rangeEnd: Int = -1;
var segment: Int = -1;
}
}
@@ -36,11 +36,11 @@ class JSRequestModifier: IRequestModifier {
}
override fun modifyRequest(url: String, headers: Map<String, String>): IRequest {
if (_modifier.isClosed) {
return Request(url, headers);
}
return _plugin.busy {
if (_modifier.isClosed) {
return@busy Request(url, headers);
}
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") {
_modifier.invokeV8("modifyRequest", url, headers);
} as V8ValueObject;
@@ -53,4 +53,4 @@ class JSRequestModifier: IRequestModifier {
data class Request(override val url: String, override val headers: Map<String, String>) : IRequest;
}
}
@@ -29,12 +29,28 @@ class JSSubtitleSource : ISubtitleSource {
constructor(config: SourcePluginConfig, v8Value: V8ValueObject) {
_obj = v8Value;
val context = "JSSubtitles";
name = v8Value.getOrThrow(config, "name", context, false);
language = v8Value.getOrDefault(config, "language", context, null);
url = v8Value.getOrThrow(config, "url", context, true);
format = v8Value.getOrThrow(config, "format", context, true);
hasFetch = v8Value.has("getSubtitles");
var parsedName: String? = null;
var parsedLanguage: String? = null;
var parsedUrl: String? = null;
var parsedFormat: String? = null;
var parsedHasFetch = false;
val parse = {
val context = "JSSubtitles";
parsedName = v8Value.getOrThrow(config, "name", context, false);
parsedLanguage = v8Value.getOrDefault(config, "language", context, null);
parsedUrl = v8Value.getOrThrow(config, "url", context, true);
parsedFormat = v8Value.getOrThrow(config, "format", context, true);
parsedHasFetch = v8Value.has("getSubtitles");
};
v8Value.getSourcePlugin()?.busy {
parse();
} ?: parse()
name = parsedName ?: "";
language = parsedLanguage;
url = parsedUrl;
format = parsedFormat;
hasFetch = parsedHasFetch;
}
override fun getSubtitles(): String {
@@ -69,4 +85,4 @@ class JSSubtitleSource : ISubtitleSource {
return JSSubtitleSource(config, value);
}
}
}
}
@@ -52,34 +52,63 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
override val subtitles: List<ISubtitleSource>;
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) {
val contextName = "VideoDetails";
_plugin = plugin;
val config = plugin.config;
description = _content.getOrThrow(config, "description", contextName);
video = JSVideoSourceDescriptor.fromV8(plugin, _content.getOrThrow(config, "video", contextName));
dash = JSSource.fromV8DashNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "dash", contextName));
hls = JSSource.fromV8HLSNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "hls", contextName));
live = JSSource.fromV8VideoNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "live", contextName));
rating = IRating.fromV8OrDefault(config, _content.getOrDefault<V8ValueObject>(config, "rating", contextName, null), RatingLikes(0));
var parsedDescription: String? = null;
var parsedVideo: IVideoSourceDescriptor? = null;
var parsedDash: IDashManifestSource? = null;
var parsedHls: IHLSManifestSource? = null;
var parsedLive: IVideoSource? = null;
var parsedRating: IRating? = null;
var parsedSubtitles: List<ISubtitleSource>? = null;
var parsedHasGetComments = false;
var parsedHasGetPlaybackTracker = false;
var parsedHasGetContentRecommendations = false;
var parsedHasGetVODEvents = false;
if(!_content.has("subtitles"))
subtitles = listOf();
else {
val subArrs = _content.getOrThrowNullable<V8ValueArray>(config, "subtitles", contextName);
if(subArrs != null)
subtitles = subArrs.keys.map { JSSubtitleSource.fromV8(config, subArrs.get(it)) };
else
subtitles = listOf();
plugin.busy {
val contextName = "VideoDetails";
val config = plugin.config;
parsedDescription = _content.getOrThrow(config, "description", contextName);
parsedVideo = JSVideoSourceDescriptor.fromV8(plugin, _content.getOrThrow(config, "video", contextName));
parsedDash = JSSource.fromV8DashNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "dash", contextName));
parsedHls = JSSource.fromV8HLSNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "hls", contextName));
parsedLive = JSSource.fromV8VideoNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "live", contextName));
parsedRating = IRating.fromV8OrDefault(config, _content.getOrDefault<V8ValueObject>(config, "rating", contextName, null), RatingLikes(0));
if(!_content.has("subtitles"))
parsedSubtitles = listOf();
else {
val subArrs = _content.getOrThrowNullable<V8ValueArray>(config, "subtitles", contextName);
if(subArrs != null)
parsedSubtitles = subArrs.keys.map { JSSubtitleSource.fromV8(config, subArrs.get(it)) };
else
parsedSubtitles = listOf();
}
parsedHasGetComments = _content.has("getComments");
parsedHasGetPlaybackTracker = _content.has("getPlaybackTracker");
parsedHasGetContentRecommendations = _content.has("getContentRecommendations");
parsedHasGetVODEvents = _content.has("getVODEvents");
}
_hasGetComments = _content.has("getComments");
_hasGetPlaybackTracker = _content.has("getPlaybackTracker");
_hasGetContentRecommendations = _content.has("getContentRecommendations");
_hasGetVODEvents = _content.has("getVODEvents");
description = parsedDescription ?: "";
video = parsedVideo ?: throw IllegalStateException("Missing video source descriptor");
dash = parsedDash;
hls = parsedHls;
live = parsedLive;
rating = parsedRating ?: RatingLikes(0);
subtitles = parsedSubtitles ?: listOf();
_hasGetComments = parsedHasGetComments;
_hasGetPlaybackTracker = parsedHasGetPlaybackTracker;
_hasGetContentRecommendations = parsedHasGetContentRecommendations;
_hasGetVODEvents = parsedHasGetVODEvents;
}
override fun getPlaybackTracker(): IPlaybackTracker? {
if(!_hasGetPlaybackTracker || _content.isClosed)
val canGetPlaybackTracker = _plugin.busy {
_hasGetPlaybackTracker && !_content.isClosed
}
if(!canGetPlaybackTracker)
return null;
if(_pluginConfig.id == StateDeveloper.DEV_ID)
return StateDeveloper.instance.handleDevCall(_pluginConfig.id, "videoDetail.getComments()") {
@@ -102,7 +131,10 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
}
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
if(!_hasGetContentRecommendations || _content.isClosed)
val canGetContentRecommendations = _plugin.busy {
_hasGetContentRecommendations && !_content.isClosed
}
if(!canGetContentRecommendations)
return null;
if(client is DevJSClient)
@@ -122,7 +154,12 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
if(client !is JSClient || !_hasGetComments || _content.isClosed)
if(client !is JSClient)
return null;
val canGetComments = _plugin.busy {
_hasGetComments && !_content.isClosed
}
if(!canGetComments)
return null;
if(client is DevJSClient)
@@ -153,4 +190,4 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
return@busy JSVODEventPager(_plugin.config, _plugin,
_content.invokeV8<V8ValueObject>("getVODEvents", arrayOf<Any>()));
}
}
}
@@ -39,10 +39,10 @@ open class JSAudioUrlSource(
?: "$container $bitrate"
override var priority: Boolean =
if (_obj.has("priority")) _obj.getOrThrow<Boolean>(cfg, "priority", ctx) else false
plugin.busy { if (_obj.has("priority")) _obj.getOrThrow<Boolean>(cfg, "priority", ctx) else false }
override var original: Boolean =
if (_obj.has("original")) _obj.getOrThrow<Boolean>(cfg, "original", ctx) else false
plugin.busy { if (_obj.has("original")) _obj.getOrThrow<Boolean>(cfg, "original", ctx) else false }
override fun getAudioUrl(): String = url
@@ -19,21 +19,23 @@ class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource {
val config = plugin.config
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
hasLicenseRequestExecutor = plugin.busy { obj.has("getLicenseRequestExecutor") }
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return null
return _plugin.busy {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return@busy null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return@busy null
return@busy JSRequestExecutor(_plugin, result)
}
if (result !is V8ValueObject)
return null
return JSRequestExecutor(_plugin, result)
}
override fun toString(): String {
@@ -55,7 +55,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
original = obj.getOrNull(config, "original", contextName) ?: false;
hasGenerate = _obj.has("generate");
hasGenerate = plugin.busy { _obj.has("generate") };
}
private var _pregenerate: V8Deferred<String?>? = null;
@@ -67,7 +67,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
if(!hasGenerate)
return V8Deferred(CompletableDeferred(manifest));
if(_obj.isClosed)
if(_plugin.busy { _obj.isClosed })
throw IllegalStateException("Source object already closed");
val pregenerated = _pregenerate;
@@ -111,7 +111,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
override fun generate(): String? {
if(!hasGenerate)
return manifest;
if(_obj.isClosed)
if(_plugin.busy { _obj.isClosed })
throw IllegalStateException("Source object already closed");
val plugin = _plugin.getUnderlyingPlugin();
@@ -145,4 +145,4 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
}
return result;
}
}
}
@@ -73,12 +73,13 @@ open class JSDashManifestRawSource(
override var manifest: String? =
_obj.getOrDefault<String>(cfg, "manifest", ctx, null)
override val hasGenerate: Boolean = _obj.has("generate")
override val hasGenerate: Boolean = plugin.busy { _obj.has("generate") }
val canMerge: Boolean =
_obj.getOrDefault<Boolean>(cfg, "canMerge", ctx, false) ?: false
override var streamMetaData: StreamMetaData? = null
var audioStreamMetaData: StreamMetaData? = null
private var _pregenerate: V8Deferred<String?>? = null
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
@@ -89,7 +90,7 @@ open class JSDashManifestRawSource(
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
if(!hasGenerate)
return V8Deferred(CompletableDeferred(manifest));
if(_obj.isClosed)
if(_plugin.busy { _obj.isClosed })
throw IllegalStateException("Source object already closed");
val pregenerated = _pregenerate;
if(pregenerated != null) {
@@ -125,6 +126,14 @@ open class JSDashManifestRawSource(
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
}
val audioInitStart = _obj.getOrDefault<Int>(_config, "audioInitStart", "JSDashManifestRawSource", null) ?: 0;
val audioInitEnd = _obj.getOrDefault<Int>(_config, "audioInitEnd", "JSDashManifestRawSource", null) ?: 0;
val audioIndexStart = _obj.getOrDefault<Int>(_config, "audioIndexStart", "JSDashManifestRawSource", null) ?: 0;
val audioIndexEnd = _obj.getOrDefault<Int>(_config, "audioIndexEnd", "JSDashManifestRawSource", null) ?: 0;
if(audioInitEnd > 0 && audioIndexStart > 0 && audioIndexEnd > 0) {
audioStreamMetaData = StreamMetaData(audioInitStart, audioInitEnd, audioIndexStart, audioIndexEnd);
}
return@busy result.convert {
it.value
};
@@ -133,7 +142,7 @@ open class JSDashManifestRawSource(
override open fun generate(): String? {
if(!hasGenerate)
return manifest;
if(_obj.isClosed)
if(_plugin.busy { _obj.isClosed })
throw IllegalStateException("Source object already closed");
var result: String? = null;
@@ -162,6 +171,14 @@ open class JSDashManifestRawSource(
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
}
val audioInitStart = _obj.getOrDefault<Int>(_config, "audioInitStart", "JSDashManifestRawSource", null) ?: 0;
val audioInitEnd = _obj.getOrDefault<Int>(_config, "audioInitEnd", "JSDashManifestRawSource", null) ?: 0;
val audioIndexStart = _obj.getOrDefault<Int>(_config, "audioIndexStart", "JSDashManifestRawSource", null) ?: 0;
val audioIndexEnd = _obj.getOrDefault<Int>(_config, "audioIndexEnd", "JSDashManifestRawSource", null) ?: 0;
if(audioInitEnd > 0 && audioIndexStart > 0 && audioIndexEnd > 0) {
audioStreamMetaData = StreamMetaData(audioInitStart, audioInitEnd, audioIndexStart, audioIndexEnd);
}
}
}
return result;
@@ -241,4 +258,4 @@ class JSDashManifestMergingRawSource(
companion object {
private val adaptationSetRegex = Regex("<AdaptationSet.*?>.*?<\\/AdaptationSet>", RegexOption.DOT_MATCHES_ALL);
}
}
}
@@ -42,27 +42,29 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
priority = obj.getOrNull(config, "priority", contextName) ?: false
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
hasLicenseRequestExecutor = plugin.busy { obj.has("getLicenseRequestExecutor") }
language = _obj.getOrNull(config, "language", contextName);
original = _obj.getOrNull(config, "original", contextName);
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return null
return _plugin.busy {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return@busy null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return@busy null
return@busy JSRequestExecutor(_plugin, result)
}
if (result !is V8ValueObject)
return null
return JSRequestExecutor(_plugin, result)
}
override fun getVideoUrl(): String {
return url
}
}
}
@@ -44,15 +44,26 @@ abstract class JSSource {
this._obj = obj;
this.type = type;
_requestModifier = obj.getOrDefault<V8ValueObject>(_config, "requestModifier", "JSSource.requestModifier", null)?.let {
JSRequest(plugin, it, null, null, true);
}
hasRequestModifier = _requestModifier != null || obj.has("getRequestModifier");
var parsedRequestModifier: JSRequest? = null;
var parsedHasRequestModifier = false;
var parsedRequestExecutor: JSRequest? = null;
var parsedHasRequestExecutor = false;
plugin.busy {
parsedRequestModifier = obj.getOrDefault<V8ValueObject>(_config, "requestModifier", "JSSource.requestModifier", null)?.let {
JSRequest(plugin, it, null, null, true);
};
parsedHasRequestModifier = parsedRequestModifier != null || obj.has("getRequestModifier");
_requestExecutor = obj.getOrDefault<V8ValueObject>(_config, "requestExecutor", "JSSource.requestExecutor", null)?.let {
JSRequest(plugin, it, null, null, true);
parsedRequestExecutor = obj.getOrDefault<V8ValueObject>(_config, "requestExecutor", "JSSource.requestExecutor", null)?.let {
JSRequest(plugin, it, null, null, true);
};
parsedHasRequestExecutor = parsedRequestExecutor != null || obj.has("getRequestExecutor");
}
hasRequestExecutor = _requestExecutor != null || obj.has("getRequestExecutor");
_requestModifier = parsedRequestModifier;
hasRequestModifier = parsedHasRequestModifier;
_requestExecutor = parsedRequestExecutor;
hasRequestExecutor = parsedHasRequestExecutor;
}
fun getRequestModifier(): IRequestModifier? = _plugin.isBusyWith("getRequestModifier") {
@@ -166,4 +177,4 @@ abstract class JSSource {
}
}
}
}
}
@@ -18,25 +18,27 @@ class JSVideoUrlWidevineSource : JSVideoUrlSource, IVideoUrlWidevineSource {
val config = plugin.config
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
hasLicenseRequestExecutor = plugin.busy { obj.has("getLicenseRequestExecutor") }
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return null
return _plugin.busy {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return@busy null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return@busy null
return@busy JSRequestExecutor(_plugin, result)
}
if (result !is V8ValueObject)
return null
return JSRequestExecutor(_plugin, result)
}
override fun toString(): String {
val url = getVideoUrl()
return "(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url, hasLicenseRequestExecutor=$hasLicenseRequestExecutor, licenseUri=$licenseUri)"
}
}
}
@@ -95,79 +95,7 @@ private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
)
}
// abstract class CastingDevice {
class CastingDevice(val device: RsCastingDevice) {
// abstract val isReady: Boolean
// abstract val usedRemoteAddress: InetAddress?
// abstract val localAddress: InetAddress?
// abstract val name: String?
// abstract val onConnectionStateChanged: Event1<CastConnectionState>
// abstract val onPlayChanged: Event1<Boolean>
// abstract val onTimeChanged: Event1<Double>
// abstract val onDurationChanged: Event1<Double>
// abstract val onVolumeChanged: Event1<Double>
// abstract val onSpeedChanged: Event1<Double>
// abstract val onMediaItemEnd: Event0
// abstract var connectionState: CastConnectionState
// abstract val protocolType: CastProtocolType
// abstract var isPlaying: Boolean
// abstract val expectedCurrentTime: Double
// abstract var speed: Double
// abstract var time: Double
// abstract var duration: Double
// abstract var volume: Double
// abstract fun canSetVolume(): Boolean
// abstract fun canSetSpeed(): Boolean
// @Throws
// abstract fun resumePlayback()
// @Throws
// abstract fun pausePlayback()
// @Throws
// abstract fun stopPlayback()
// @Throws
// abstract fun seekTo(timeSeconds: Double)
// @Throws
// abstract fun changeVolume(timeSeconds: Double)
// @Throws
// abstract fun changeSpeed(speed: Double)
// @Throws
// abstract fun connect()
// @Throws
// abstract fun disconnect()
// abstract fun getDeviceInfo(): CastingDeviceInfo
// abstract fun getAddresses(): List<InetAddress>
// @Throws
// abstract fun loadVideo(
// streamType: String,
// contentType: String,
// contentId: String,
// resumePosition: Double,
// duration: Double,
// speed: Double?,
// metadata: Metadata?
// )
// @Throws
// fun loadContent(
// contentType: String,
// content: String,
// resumePosition: Double,
// duration: Double,
// speed: Double?,
// metadata: Metadata?
// )
// fun ensureThreadStarted()
class EventHandler : RsDeviceEventHandler {
var onConnectionStateChanged = Event1<DeviceConnectionState>();
var onPlayChanged = Event1<Boolean>()
@@ -1,9 +1,13 @@
package com.futo.platformplayer.dialogs
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.*
@@ -11,88 +15,88 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.google.android.material.button.MaterialButton
class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
private lateinit var _buttonStart: LinearLayout;
private lateinit var _buttonStop: LinearLayout;
private lateinit var _buttonCancel: ImageButton;
private lateinit var _editPassword: EditText;
private lateinit var _editPassword2: EditText;
private lateinit var _inputMethodManager: InputMethodManager;
private lateinit var _buttonStart: LinearLayout
private lateinit var _buttonStop: LinearLayout
private lateinit var _buttonCancel: ImageButton
private lateinit var _imm: InputMethodManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null));
super.onCreate(savedInstanceState)
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null))
_buttonCancel = findViewById(R.id.button_cancel);
_buttonStop = findViewById(R.id.button_stop);
_buttonStart = findViewById(R.id.button_start);
_editPassword = findViewById(R.id.edit_password);
_editPassword2 = findViewById(R.id.edit_password2);
_buttonCancel = findViewById(R.id.button_cancel)
_buttonStop = findViewById(R.id.button_stop)
_buttonStart = findViewById(R.id.button_start)
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
_imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
_buttonStart.visibility = if (Settings.instance.backup.autoBackupEnabled) View.GONE else View.VISIBLE
_buttonStop.visibility = if (Settings.instance.backup.autoBackupEnabled) View.VISIBLE else View.GONE
_buttonCancel.setOnClickListener {
clearFocus();
dismiss();
};
_buttonStop.setOnClickListener {
clearFocus();
dismiss();
Settings.instance.backup.autoBackupPassword = null;
Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save();
dismiss()
}
UIDialogs.toast(context, "AutoBackup disabled");
_buttonStop.setOnClickListener {
dismiss()
Settings.instance.backup.autoBackupEnabled = false
Settings.instance.backup.autoBackupPassword = null
Settings.instance.backup.didAskAutoBackup = true
Settings.instance.save()
UIDialogs.toast(context, context.getString(R.string.automatic_backup_disabled))
}
_buttonStart.setOnClickListener {
val p1 = _editPassword.text.toString();
val p2 = _editPassword2.text.toString();
if(!(p1?.equals(p2) ?: false)) {
UIDialogs.toast(context, "Password fields do not match, confirm that you typed it correctly.");
return@setOnClickListener;
dismiss()
Logger.i(TAG, "Enable AutoBackup (unencrypted)")
val activity = StateApp.instance.activity as? Activity
if (activity == null) {
UIDialogs.toast(context, "No activity available")
return@setOnClickListener
}
val pbytes = _editPassword.text.toString().toByteArray();
if(pbytes.size < 4 || pbytes.size > 32) {
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and smaller than 32 bytes", false);
return@setOnClickListener;
}
clearFocus();
dismiss();
dismiss()
Logger.i(TAG, "Set AutoBackupPassword");
Settings.instance.backup.autoBackupPassword = _editPassword.text.toString();
Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save();
Logger.i(TAG, "Enable AutoBackup")
Settings.instance.backup.autoBackupPassword = null
Settings.instance.backup.didAskAutoBackup = true
Settings.instance.save()
UIDialogs.toast(context, "AutoBackup enabled");
UIDialogs.toast(context, "AutoBackup enabled")
try {
StateBackup.startAutomaticBackup(true);
StateBackup.startAutomaticBackup(true)
} catch (ex: Throwable) {
Logger.e(TAG, "Forced automatic backup failed", ex)
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message)
}
catch(ex: Throwable) {
Logger.e(TAG, "Forced automatic backup failed", ex);
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message);
Settings.instance.backup.autoBackupEnabled = true
Settings.instance.backup.autoBackupPassword = null
Settings.instance.backup.didAskAutoBackup = true
Settings.instance.save()
StateAnnouncement.instance.deleteAnnouncement("backup")
UIDialogs.toast(context, context.getString(R.string.automatic_backup_enabled))
try {
StateBackup.startAutomaticBackup(true)
} catch (ex: Throwable) {
Logger.e(TAG, "Forced automatic backup failed", ex)
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message)
}
};
}
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
}
private fun clearFocus() {
_editPassword.clearFocus();
currentFocus?.let { _inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) };
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
}
companion object {
private val TAG = "AutomaticBackupDialog";
private const val TAG = "AutomaticBackupDialog"
}
}
@@ -3,87 +3,155 @@ package com.futo.platformplayer.dialogs
import android.app.AlertDialog
import android.content.Context
import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.*
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.*
import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import userpackage.Protocol
import java.time.OffsetDateTime
import kotlinx.coroutines.withContext
class AutomaticRestoreDialog(context: Context, private val scope: CoroutineScope) : AlertDialog(context) {
class AutomaticRestoreDialog(context: Context, val scope: CoroutineScope) : AlertDialog(context) {
private lateinit var _buttonStart: LinearLayout;
private lateinit var _buttonCancel: MaterialButton;
private lateinit var _editPassword: EditText;
private lateinit var _inputMethodManager: InputMethodManager;
private lateinit var _buttonStart: LinearLayout
private lateinit var _buttonCancel: MaterialButton
private lateinit var _textReason: TextView
private lateinit var _editPassword: EditText
private lateinit var _passwordContainer: LinearLayout
private lateinit var _icon: ImageView
private lateinit var _progress: ProgressBar
private lateinit var _textStart: TextView
private lateinit var _imm: InputMethodManager
private var _needsPassword: Boolean = true
private var _detectJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup_restore, null));
super.onCreate(savedInstanceState)
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup_restore, null))
_buttonCancel = findViewById(R.id.button_cancel);
_buttonStart = findViewById(R.id.button_start);
_editPassword = findViewById(R.id.edit_password);
_buttonCancel = findViewById(R.id.button_cancel)
_buttonStart = findViewById(R.id.button_start)
_editPassword = findViewById(R.id.edit_password)
_textReason = findViewById(R.id.text_reason)
_passwordContainer = findViewById(R.id.password_container)
_icon = findViewById(R.id.image_icon)
_progress = findViewById(R.id.progress_restore)
_textStart = findViewById(R.id.text_start)
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
_imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
_needsPassword = true
applyMode(needsPassword = true)
setBusy(true, labelRes = R.string.checking_backup, lockCancel = false)
_buttonCancel.setOnClickListener {
clearFocus();
dismiss();
};
clearFocus()
dismiss()
}
_buttonStart.setOnClickListener { onStartClicked() }
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
}
_buttonStart.setOnClickListener {
val pbytes = _editPassword.text.toString().toByteArray();
if(pbytes.size < 4 || pbytes.size > 32) {
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and less than 32 bytes", false);
return@setOnClickListener;
override fun onStart() {
super.onStart()
_detectJob?.cancel()
_detectJob = scope.launch(Dispatchers.Main) {
val needs = try {
StateBackup.requiresPasswordForAutomaticBackup(context)
} catch (_: Throwable) {
true
}
clearFocus();
if (!isShowing) return@launch
_needsPassword = needs
applyMode(needsPassword = needs)
setBusy(false)
}
}
override fun onStop() {
_detectJob?.cancel()
_detectJob = null
super.onStop()
}
private fun applyMode(needsPassword: Boolean) {
_textStart.setText(R.string.restore)
if (needsPassword) {
_icon.setImageResource(R.drawable.ic_lock)
_passwordContainer.visibility = View.VISIBLE
_editPassword.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
_textReason.setText(R.string.it_appears_an_automatic_backup_exists_on_your_device_if_you_would_like_to_restore_enter_your_backup_password)
} else {
_icon.setImageResource(R.drawable.ic_move_up)
_passwordContainer.visibility = View.GONE
_editPassword.setText("")
_textReason.setText(R.string.automatic_backup_found_no_password)
}
}
private fun onStartClicked() {
val password = _editPassword.text?.toString() ?: ""
if (_needsPassword) {
val pbytes = password.toByteArray()
if (pbytes.size < 4 || pbytes.size > 32) {
_editPassword.error = context.getString(R.string.backup_password_length_error)
_editPassword.requestFocus()
return
}
}
clearFocus()
setBusy(true, labelRes = R.string.restoring, lockCancel = true)
scope.launch(Dispatchers.IO) {
try {
StateBackup.restoreAutomaticBackup(context, scope, _editPassword.text.toString(), true);
dismiss();
StateBackup.restoreAutomaticBackup(context, scope, if (_needsPassword) password else "", true)
withContext(Dispatchers.Main) {
if (isShowing) dismiss()
}
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to restore automatic backup", ex)
withContext(Dispatchers.Main) {
if (!isShowing) return@withContext
setBusy(false)
UIDialogs.showGeneralErrorDialog(context, "Restore failed", ex)
}
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to restore automatic backup", ex);
//UIDialogs.toast(context, "Restore failed due to:\n" + ex.message);
UIDialogs.showGeneralErrorDialog(context, "Restore failed", ex);
}
};
}
}
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
private fun setBusy(busy: Boolean, labelRes: Int = R.string.restore, lockCancel: Boolean = busy) {
_progress.visibility = if (busy) View.VISIBLE else View.GONE
_buttonCancel.isEnabled = !lockCancel
_buttonStart.isEnabled = !busy
_editPassword.isEnabled = !busy && _needsPassword
_buttonStart.alpha = if (busy) 0.6f else 1.0f
_textStart.setText(labelRes)
}
private fun clearFocus() {
_editPassword.clearFocus();
currentFocus?.let { _inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) };
_editPassword.clearFocus()
currentFocus?.let { _imm.hideSoftInputFromWindow(it.windowToken, 0) }
}
companion object {
private val TAG = "AutomaticRestoreDialog";
private const val TAG = "AutomaticRestoreDialog"
}
}
}
@@ -139,13 +139,17 @@ class VideoDownload {
@Contextual
@kotlinx.serialization.Transient
var videoSourceLive: JSSource? = null;
val isLiveVideoSourceValid get() = videoSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
val isLiveVideoSourceValid get() = videoSourceLive?.getUnderlyingPlugin()?.busy {
videoSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
} ?: false;
var requiresLiveAudioSource: Boolean = false;
@Contextual
@kotlinx.serialization.Transient
var audioSourceLive: JSSource? = null;
val isLiveAudioSourceValid get() = audioSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
val isLiveAudioSourceValid get() = audioSourceLive?.getUnderlyingPlugin()?.busy {
audioSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
} ?: false;
var hasVideoRequestExecutor: Boolean = false;
var hasAudioRequestExecutor: Boolean = false;
@@ -1458,6 +1462,9 @@ class VideoDownload {
if(localAudioSource != null && audioSourceToUse != null && audioSourceToUse is IStreamMetaDataSource)
localAudioSource.streamMetaData = (audioSourceToUse as IStreamMetaDataSource).streamMetaData;
if(localAudioSource != null && localAudioSource.streamMetaData == null && videoSourceToUse is JSDashManifestRawSource)
localAudioSource.streamMetaData = (videoSourceToUse as JSDashManifestRawSource).audioStreamMetaData;
if(existing != null) {
existing.videoSerialized = videoDetails!!;
if(localVideoSource != null) {
@@ -1603,4 +1610,4 @@ class VideoDownload {
}
}
}
}
@@ -13,8 +13,10 @@ import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toHumanBitrate
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
@@ -51,8 +53,12 @@ class VideoExport {
val outputFile: DocumentFile?;
val downloadRoot = documentRoot ?: StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set");
val safeBaseName = videoLocal.name.sanitizeFileName(true).ifBlank {
"video_${UUID.randomUUID()}"
}
if (sourceCount > 1) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container);
val outputFileName = "$safeBaseName.mp4"
val f = downloadRoot.createFile("video/mp4", outputFileName)
?: throw Exception("Failed to create file in external directory.");
@@ -60,7 +66,9 @@ class VideoExport {
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 ->
val outputStream = context.contentResolver.openOutputStream(f.uri)
?: throw IOException("Failed to open output stream for ${f.uri}")
outputStream.use { outputStream ->
copy(tempFile.absolutePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
} finally {
@@ -68,25 +76,29 @@ class VideoExport {
}
outputFile = f;
} else if (v != null) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container);
val outputFileName = "$safeBaseName.${VideoDownload.videoContainerToExtension(v.container)}"
val f = downloadRoot.createFile(if (v.container == "application/vnd.apple.mpegurl") "video/mp4" else v.container, outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying video.");
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
val outputStream = context.contentResolver.openOutputStream(f.uri)
?: throw IOException("Failed to open output stream for ${f.uri}")
outputStream.use { outputStream ->
copy(v.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
outputFile = f;
} else if (a != null) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container);
val outputFileName = "$safeBaseName.${VideoDownload.audioContainerToExtension(a.container)}"
val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "video/mp4" else a.container, outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying audio.");
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
val outputStream = context.contentResolver.openOutputStream(f.uri)
?: throw IOException("Failed to open output stream for ${f.uri}")
outputStream.use { outputStream ->
copy(a.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
@@ -98,29 +110,48 @@ class VideoExport {
return@coroutineScope outputFile;
}
private fun ffmpegArg(value: String): String {
return "\"" + value
.replace("\\", "\\\\")
.replace("\"", "\\\"") + "\""
}
private fun resumeSuccessSafely(continuation: CancellableContinuation<Unit>) {
if (!continuation.isActive) return
try {
continuation.resumeWith(Result.success(Unit))
} catch (_: IllegalStateException) {
}
}
private fun resumeFailureSafely(continuation: CancellableContinuation<Unit>, throwable: Throwable) {
if (!continuation.isActive) return
try {
continuation.resumeWithException(throwable)
} catch (_: IllegalStateException) {
}
}
private suspend fun combine(inputPathAudio: String?, inputPathVideo: String?, inputPathSubtitles: String?, outputPath: String, duration: Double, onProgress: ((Double) -> Unit)? = null) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation ->
//ffmpeg -i a.mp4 -i b.m4a -scodec mov_text -i c.vtt -map 0:v -map 1:a -map 2 -c:v copy -c:a copy -c:s mov_text output.mp4
val cmdBuilder = StringBuilder("-y")
var counter = 0
if (inputPathVideo != null) {
cmdBuilder.append(" -i $inputPathVideo")
cmdBuilder.append(" -i ${ffmpegArg(inputPathVideo)}")
}
if (inputPathAudio != null) {
cmdBuilder.append(" -i $inputPathAudio")
cmdBuilder.append(" -i ${ffmpegArg(inputPathAudio)}")
}
if (inputPathSubtitles != null) {
val subtitleExtension = File(inputPathSubtitles).extension
val codec = when (subtitleExtension.lowercase()) {
"srt" -> "mov_text"
"vtt" -> "webvtt"
val subtitleExtension = File(inputPathSubtitles).extension.lowercase()
when (subtitleExtension) {
"srt", "vtt" -> {}
else -> throw Exception("Unsupported subtitle format: $subtitleExtension")
}
cmdBuilder.append(" -scodec $codec -i $inputPathSubtitles")
cmdBuilder.append(" -i ${ffmpegArg(inputPathSubtitles)}")
}
if (inputPathVideo != null) {
@@ -132,6 +163,7 @@ class VideoExport {
if (inputPathSubtitles != null) {
cmdBuilder.append(" -map ${counter++}")
cmdBuilder.append(" -c:s mov_text")
}
if (inputPathVideo != null) {
@@ -140,33 +172,44 @@ class VideoExport {
if (inputPathAudio != null) {
cmdBuilder.append(" -c:a copy")
}
if (inputPathAudio != null) {
cmdBuilder.append(" -c:s mov_text")
}
cmdBuilder.append(" $outputPath")
cmdBuilder.append(" ${ffmpegArg(outputPath)}")
val cmd = cmdBuilder.toString()
Logger.i(TAG, "Used command: $cmd");
val statisticsCallback = StatisticsCallback { statistics ->
val time = statistics.time.toDouble() / 1000.0
val progressPercentage = (time / duration)
onProgress?.invoke(progressPercentage)
val progressPercentage = if (duration > 0.0) {
(time / duration).coerceIn(0.0, 1.0)
} else {
0.0
}
onProgress?.let { callback ->
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
callback(progressPercentage)
}
}
}
val executorService = Executors.newSingleThreadExecutor()
val session = FFmpegKit.executeAsync(cmd,
{ session ->
if (ReturnCode.isSuccess(session.returnCode)) {
continuation.resumeWith(Result.success(Unit))
} else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
"Command cancelled"
try {
if (ReturnCode.isSuccess(session.returnCode)) {
resumeSuccessSafely(continuation)
} else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
"Command cancelled"
} else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
}
resumeFailureSafely(continuation, RuntimeException(errorMessage))
}
continuation.resumeWithException(RuntimeException(errorMessage))
} finally {
executorService.shutdown()
}
},
LogCallback { Logger.v(TAG, it.message) },
@@ -176,6 +219,7 @@ class VideoExport {
continuation.invokeOnCancellation {
session.cancel()
executorService.shutdown()
}
}
}
@@ -196,14 +240,24 @@ class VideoExport {
val totalBytes = srcFile.length()
var bytesCopied: Long = 0
if (totalBytes == 0L) {
onProgress?.let {
withContext(Dispatchers.Main) {
it(1.0)
}
}
return@withContext
}
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
bytesCopied += bytesRead.toLong()
onProgress?.let {
val progress = (bytesCopied / totalBytes.toDouble()).coerceIn(0.0, 1.0)
withContext(Dispatchers.Main) {
it(bytesCopied / totalBytes.toDouble())
it(progress)
}
}
}
@@ -58,6 +58,7 @@ import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.cancel
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
@@ -87,6 +88,7 @@ class V8Plugin {
private val _deps : LinkedHashMap<String, String> = LinkedHashMap();
private val _depsPackages : MutableList<V8Package> = mutableListOf();
private var _script : String? = null;
val bridge: PackageBridge;
var isStopped = true;
val onStopped = Event1<V8Plugin>();
@@ -94,6 +96,9 @@ class V8Plugin {
private val _busyLock = ReentrantLock()
val isBusy get() = _busyLock.isLocked;
@Volatile
private var _busyHolder: Thread? = null;
var allowDevSubmit: Boolean = false
private set(value) {
field = value;
@@ -114,7 +119,8 @@ class V8Plugin {
this._clientAuth = clientAuth;
this.config = config;
this._script = script;
withDependency(PackageBridge(this, config));
bridge = PackageBridge(this, config);
withDependency(bridge);
for(pack in config.packages)
withDependency(getPackage(pack)!!);
@@ -159,51 +165,53 @@ class V8Plugin {
fun start() {
val script = _script ?: throw IllegalStateException("Attempted to start V8 without script");
synchronized(_runtimeLock) {
if (_runtime != null)
return;
runtimeId = runtimeId + 1;
//V8RuntimeOptions.V8_FLAGS.setUseStrict(true);
val host = V8Host.getV8Instance();
val options = host.jsRuntimeType.getRuntimeOptions();
tryBusy(BUSY_STARTUP_MS) {
synchronized(_runtimeLock) {
if (_runtime != null)
return@tryBusy;
runtimeId = runtimeId + 1;
//V8RuntimeOptions.V8_FLAGS.setUseStrict(true);
val host = V8Host.getV8Instance();
val options = host.jsRuntimeType.getRuntimeOptions();
_runtime = host.createV8Runtime(options);
if (!host.isIsolateCreated)
throw IllegalStateException("Isolate not created");
_runtime = host.createV8Runtime(options);
if (!host.isIsolateCreated)
throw IllegalStateException("Isolate not created");
_runtimeMap.put(_runtime!!, this);
_runtimeMap.put(_runtime!!, this);
//Setup bridge
_runtime?.let {
it.converter = V8Converter();
//Setup bridge
_runtime?.let {
it.converter = V8Converter();
for (pack in _depsPackages) {
if (pack.variableName != null)
it.createV8ValueObject().use { v8valueObject ->
it.globalObject.set(pack.variableName, v8valueObject);
v8valueObject.bind(pack);
};
catchScriptErrors("Package Dep[${pack.name}]") {
for (packScript in pack.getScripts())
it.getExecutor(packScript).executeVoid();
for (pack in _depsPackages) {
if (pack.variableName != null)
it.createV8ValueObject().use { v8valueObject ->
it.globalObject.set(pack.variableName, v8valueObject);
v8valueObject.bind(pack);
};
catchScriptErrors("Package Dep[${pack.name}]") {
for (packScript in pack.getScripts())
it.getExecutor(packScript).executeVoid();
}
}
}
//Load deps
for (dep in _deps)
catchScriptErrors("Dep[${dep.key}]") {
it.getExecutor(dep.value).executeVoid()
//Load deps
for (dep in _deps)
catchScriptErrors("Dep[${dep.key}]") {
it.getExecutor(dep.value).executeVoid()
};
if (config.allowEval)
it.allowEval(true);
//Load plugin
catchScriptErrors("Plugin[${config.name}]") {
it.getExecutor(script).executeVoid()
};
if (config.allowEval)
it.allowEval(true);
//Load plugin
catchScriptErrors("Plugin[${config.name}]") {
it.getExecutor(script).executeVoid()
};
isStopped = false;
isStopped = false;
}
}
}
}
@@ -252,27 +260,30 @@ class V8Plugin {
fun isThreadAlreadyBusy(): Boolean {
return _busyLock.isHeldByCurrentThread;
}
fun <T> busy(handle: ()->T): T {
_busyLock.lock();
//Logger.i(TAG, "Busy Enter [" + _busyLock.holdCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString())
fun <T> busy(handle: ()->T): T = busyInternal(BUSY_FATAL_MS, true, "busy(enter)", handle)
fun <T> tryBusy(maxWaitMs: Long, handle: ()->T): T = busyInternal(maxWaitMs, false, "tryBusy(enter)", handle)
private fun <T> busyInternal(maxWaitMs: Long, allowUnwedge: Boolean, context: String, handle: ()->T): T {
acquireBusyOrThrow(context, maxWaitMs, allowUnwedge);
_busyHolder = Thread.currentThread();
try {
return handle();
}
finally {
//Logger.i(TAG, "Busy Leave [" + _busyLock.holdCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString())
_busyLock.unlock();
if (_busyLock.isHeldByCurrentThread) {
if (_busyLock.holdCount == 1)
_busyHolder = null;
_busyLock.unlock();
}
}
/*
_busyLock.withLock {
//Logger.i(TAG, "Entered busy: " + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString());
return handle();
}*/
}
fun <T> unbusy(handle: ()->T): T {
val wasLocked = isThreadAlreadyBusy();
if(!wasLocked)
return handle();
val lockCount = _busyLock.holdCount;
_busyHolder = null;
for(i in 1..lockCount)
_busyLock.unlock();
try {
@@ -281,9 +292,90 @@ class V8Plugin {
}
finally {
Logger.w(TAG, "Relocking V8 thread for [${config.name}] for a blocking resolve of a promise")
var acquired = 0;
try {
for (i in 1..lockCount) {
acquireBusyOrThrow("unbusy(relock)");
acquired++;
}
_busyHolder = Thread.currentThread();
}
catch (timeout: Throwable) {
for (j in 1..acquired)
_busyLock.unlock();
throw timeout;
}
}
}
for(i in 1..lockCount)
_busyLock.lock();
private fun acquireBusyOrThrow(context: String, maxWaitMs: Long = BUSY_FATAL_MS, allowUnwedge: Boolean = true) {
val warnAt = Math.min(BUSY_WARN_MS, maxWaitMs);
if (_busyLock.tryLock(warnAt, TimeUnit.MILLISECONDS))
return;
logBusyContention(context);
val remaining = maxWaitMs - warnAt;
if (remaining > 0 && _busyLock.tryLock(remaining, TimeUnit.MILLISECONDS))
return;
if (!allowUnwedge)
throw IllegalStateException("V8 busy lock for [${config.name}] timed out after ${maxWaitMs}ms in $context (fast-fail)");
unwedgeBusyHolder(context);
if (_busyLock.tryLock(BUSY_RECOVERY_MS, TimeUnit.MILLISECONDS))
return;
throw IllegalStateException("V8 busy lock for [${config.name}] timed out after ${maxWaitMs + BUSY_RECOVERY_MS}ms in $context; holder did not release after recovery");
}
private fun unwedgeBusyHolder(context: String) {
val holder = _busyHolder;
Logger.w(TAG, "V8 busy lock for [${config.name}] still held in $context after ${BUSY_FATAL_MS}ms; attempting to unwedge holder ${holder?.name ?: "unknown"}");
try {
val rt = _runtime;
if (rt != null && !rt.isClosed && !rt.isDead) {
Logger.w(TAG, "Calling terminateExecution() on [${config.name}] runtime");
rt.terminateExecution();
}
}
catch (ex: Throwable) {
Logger.e(TAG, "terminateExecution() failed for [${config.name}]", ex);
}
try {
holder?.interrupt();
}
catch (ex: Throwable) {
Logger.e(TAG, "Interrupting holder thread for [${config.name}] failed", ex);
}
}
private fun logBusyContention(context: String) {
try {
val holder = _busyHolder;
val sb = StringBuilder();
sb.append("V8 BUSY CONTENTION [${config.name}] in $context: queueLength=${_busyLock.queueLength}, holdCount=${_busyLock.holdCount}, waited>${BUSY_WARN_MS}ms\n");
if (holder != null) {
sb.append("Lock holder: ${holder.name} (id=${holder.id}, state=${holder.state})\n");
for (frame in holder.stackTrace.take(40))
sb.append(" at ").append(frame.toString()).append("\n");
} else {
sb.append("Lock holder unknown (likely already released or never set)\n");
}
sb.append("Suspect waiting/blocked threads:\n");
val cur = Thread.currentThread();
for ((thread, stack) in Thread.getAllStackTraces()) {
if (thread == cur || thread == holder) continue;
if (thread.state != Thread.State.WAITING && thread.state != Thread.State.BLOCKED && thread.state != Thread.State.TIMED_WAITING) continue;
if (stack.none {
val cn = it.className;
cn.contains("V8Plugin") || cn.contains("JSClient") || cn.contains("Extensions_V8")
|| cn.contains("Subscription") || cn.contains("PackageHttp") || cn.contains("JSPager")
|| cn.contains("JSContent")
}) continue;
sb.append(" ${thread.name} (state=${thread.state}):\n");
for (frame in stack.take(20))
sb.append(" at ").append(frame.toString()).append("\n");
}
Logger.w(TAG, sb.toString());
}
catch (ex: Throwable) {
Logger.e(TAG, "Failed to log busy contention", ex);
}
}
fun execute(js: String) : V8Value {
@@ -428,6 +520,11 @@ class V8Plugin {
val TAG = "V8Plugin";
private const val BUSY_WARN_MS = 10_000L;
private const val BUSY_FATAL_MS = 60_000L;
private const val BUSY_RECOVERY_MS = 5_000L;
const val BUSY_STARTUP_MS = 5_000L;
fun getPluginFromRuntime(runtime: V8Runtime): V8Plugin? {
return _runtimeMap.getOrDefault(runtime, null);
}
@@ -565,4 +662,4 @@ class V8Plugin {
return StateAssets.readAsset(context, path) ?: throw java.lang.IllegalStateException("script ${path} not found");
}
}
}
}
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.JSClientConstants
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
@@ -36,6 +37,9 @@ class PackageBridge : V8Package {
private val _client: ManagedHttpClient
@Transient
private val _clientAuth: ManagedHttpClient
// Set by JSClient after construction to provide access to auth/captcha data
@Transient
var descriptor: SourcePluginDescriptor? = null
override val name: String get() = "Bridge";
@@ -80,6 +84,17 @@ class PackageBridge : V8Package {
return "android";
}
// User agent captured during captcha/auth WebView flows, matching Desktop's bridge.captchaUserAgent/bridge.authUserAgent.
// Plugins use these to make HTTP requests with the same UA that was used in the WebView.
@V8Property
fun captchaUserAgent(): String? {
return descriptor?.getCaptchaData()?.userAgent
}
@V8Property
fun authUserAgent(): String? {
return descriptor?.getAuth()?.userAgent
}
@V8Property
fun supportedFeatures(): Array<String> {
return arrayOf(
@@ -113,7 +128,9 @@ class PackageBridge : V8Package {
@V8Function
fun dispose(value: V8Value) {
Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
value.close();
_plugin.busy {
value.close();
}
}
var timeoutCounter = 0;
@@ -279,4 +296,4 @@ class PackageBridge : V8Package {
}
}
}
@@ -1,14 +1,22 @@
package com.futo.platformplayer.engine.packages
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.net.Uri
import android.os.Looper
import android.util.Log
import android.webkit.ConsoleMessage
import android.webkit.CookieManager
import android.webkit.JavascriptInterface
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.collection.emptyLongSet
import androidx.webkit.ScriptHandler
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.caoccao.javet.annotations.V8Function
import com.caoccao.javet.utils.JavetResourceUtils
import com.caoccao.javet.values.reference.V8ValueFunction
@@ -24,19 +32,40 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
import java.nio.charset.Charset
import androidx.core.net.toUri
class PackageBrowser: V8Package {
val useAddDocumentStartJavaScript = true
override val name: String get() = "Browser";
override val variableName: String = "browser";
@Volatile private var _loadToken: String? = null
@Volatile private var _expectedMainUrl: String? = null
private val _json = Json { };
@Transient
private val _pageLoadScriptRefs = ConcurrentHashMap<String, ScriptHandler>()
@Transient
private val _pageLoadScriptsFallback = ConcurrentHashMap<String, String>()
@Transient
private var _readySemaphore: Semaphore? = null;
@Transient
private val _callbacks = mutableMapOf<String, (String?)->Unit>();
@Transient
private val _interop = JSInterop(this);
@Transient
private var _browser: WebView? = null;
private val browser: WebView get() {
if(_browser == null)
@@ -44,54 +73,191 @@ class PackageBrowser: V8Package {
return _browser!!;
}
@Volatile
private var _userAgent: String = ""
private val http = OkHttpClient.Builder()
.followRedirects(false)
.followSslRedirects(false)
.build()
constructor(v8Plugin: V8Plugin): super(v8Plugin) {
}
@V8Function
fun initialize() {
if(_browser == null){
StateApp.instance.scope.launch(Dispatchers.Main) {
_browser = WebView(StateApp.instance.contextOrNull ?: return@launch);
_browser?.settings?.javaScriptEnabled = true;
_browser?.settings?.blockNetworkImage = false;
_browser?.settings?.blockNetworkLoads = false;
_browser?.settings?.allowContentAccess = false;
_browser?.settings?.allowFileAccess = false;
//_browser?.settings?.useWideViewPort = true;
//_browser?.settings?.loadWithOverviewMode = true;
_browser?.webViewClient = object : WebViewClient() {
override fun onPageCommitVisible(view: WebView?, url: String?) {
super.onPageCommitVisible(view, url)
_readySemaphore?.release();
_readySemaphore = null;
Logger.i("PackageBrowser", "Browser loaded");
}
if (_browser != null) return
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
return false;
onMainBlocking {
_browser = WebView(StateApp.instance.contextOrNull ?: return@onMainBlocking);
_userAgent = _browser?.settings?.userAgentString.orEmpty()
_browser?.settings?.javaScriptEnabled = true;
_browser?.settings?.blockNetworkImage = false;
_browser?.settings?.blockNetworkLoads = false;
_browser?.settings?.allowContentAccess = false;
_browser?.settings?.allowFileAccess = false;
//_browser?.settings?.useWideViewPort = true;
//_browser?.settings?.loadWithOverviewMode = true;
_browser?.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
if (view == null || request == null) return null
if (useAddDocumentStartJavaScript && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return null
if (!request.isForMainFrame) return null
if (!request.method.equals("GET", ignoreCase = true)) return null
val url = request.url?.toString() ?: return null
Log.i("PackageBrowser", "shouldInterceptRequest: " + url)
val scheme = request.url?.scheme ?: return null
if (scheme != "http" && scheme != "https") return null
val scripts = _pageLoadScriptsFallback.values.toList()
if (scripts.isEmpty()) return null
return try {
val cookie = request.requestHeaders["Cookie"] ?: runCatching { CookieManager.getInstance().getCookie(url) }.getOrNull()
val ua = request.requestHeaders["User-Agent"] ?: _userAgent
val okReq = Request.Builder()
.url(url)
.get()
.header("User-Agent", ua)
.apply { if (!cookie.isNullOrEmpty()) header("Cookie", cookie) }
.build()
http.newCall(okReq).execute().use { resp ->
val code = resp.code
val reason = resp.message.ifBlank { "OK" }
if (code in 300..399) return null
val contentType = resp.header("Content-Type") ?: ""
val isHtml =
contentType.startsWith("text/html", ignoreCase = true) ||
contentType.startsWith("application/xhtml+xml", ignoreCase = true)
if (!isHtml) return null
val bodyBytes = resp.body.bytes()
val charset = charsetFromContentType(contentType) ?: Charsets.UTF_8
val html = bodyBytes.toString(charset)
val cspHeader = resp.header("Content-Security-Policy")
?: resp.header("Content-Security-Policy-Report-Only")
val nonce = extractNonceFromCsp(cspHeader) ?: extractNonceFromHtml(html)
val injected = injectIntoHead(html, scripts.joinToString("\n"), nonce)
val outBytes = injected.toByteArray(charset)
val headers = resp.headers.toMultimap()
.mapValues { it.value.joinToString(",") }
.toMutableMap()
headers.remove("Content-Length")
val cookieMgr = CookieManager.getInstance()
resp.headers.values("Set-Cookie").forEach { sc ->
try { cookieMgr.setCookie(url, sc) } catch (_: Throwable) {}
}
try { cookieMgr.flush() } catch (_: Throwable) {}
WebResourceResponse("text/html", charset.name(), code, reason, headers, ByteArrayInputStream(outBytes))
}
} catch (_: Throwable) {
null
}
}
_browser?.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
if(consoleMessage?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
val msg = "Browser Error:${consoleMessage?.message()} [${consoleMessage?.lineNumber()}]" ?: ""
Logger.e("PackageBrowser", msg);
if(_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
StateDeveloper.instance.logDevException(StateDeveloper.instance.currentDevID ?: "", msg)
override fun onPageCommitVisible(view: WebView?, url: String?) {
super.onPageCommitVisible(view, url)
Logger.i("PackageBrowser", "Browser loaded (commit visible): $url")
releaseReadyIfCurrent(url)
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
Logger.i("PackageBrowser", "Browser loaded (finished): $url")
releaseReadyIfCurrent(url)
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
return false;
}
}
_browser?.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
try {
val raw = consoleMessage?.message().orEmpty()
val normalized = raw.trim().let { s ->
if (s.length >= 2 && s.first() == '"' && s.last() == '"') {
s.substring(1, s.length - 1)
} else s
}
else {
val msg = "Browser Log:" + consoleMessage?.message() ?: "";
Logger.e("PackageBrowser", msg);
if(_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", msg);
if (normalized.startsWith(CONSOLE_BRIDGE_PREFIX)) {
val payload = normalized.substring(CONSOLE_BRIDGE_PREFIX.length)
if (handleConsoleBridgeMessage(payload)) return true
}
if (consoleMessage?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
val emsg =
"Browser Error:${consoleMessage.message()} [${consoleMessage.lineNumber()}]"
Logger.e("PackageBrowser", emsg)
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
StateDeveloper.instance.logDevException(
StateDeveloper.instance.currentDevID ?: "", emsg
)
} else {
val imsg = "Browser Log:${consoleMessage?.message()}"
Logger.i("PackageBrowser", imsg)
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
StateDeveloper.instance.logDevInfo(
StateDeveloper.instance.currentDevID ?: "", imsg
)
}
return super.onConsoleMessage(consoleMessage)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle onConsoleMessage", e)
return super.onConsoleMessage(consoleMessage)
}
}
_browser?.addJavascriptInterface(_interop, "__GJ");
}
return;
}
val bootstrap = """
(() => {
try {
if (window.__GJ) return;
const PREFIX = ${CONSOLE_BRIDGE_PREFIX.quoteForJs()};
const emit = (obj) => {
try {
console.info(PREFIX + JSON.stringify(obj));
} catch (_) {}
};
Object.defineProperty(window, "__GJ", {
value: {
callback: (id, result) => {
try {
const r = (typeof result === "string")
? result
: (() => { try { return JSON.stringify(result); } catch (_) { return String(result); } })();
emit({ t: "cb", id: String(id), result: r });
} catch (_) {}
},
log: (msg) => {
try { emit({ t: "log", msg: String(msg) }); } catch (_) {}
}
},
enumerable: false,
configurable: false,
writable: false
});
} catch (_) {}
})();
""".trimIndent()
addScriptOnLoad(bootstrap)
}
@V8Function
fun deinitialize() {
@@ -134,13 +300,28 @@ class PackageBrowser: V8Package {
@V8Function
fun load(url: String) {
Logger.i("PackageBrowser", "Browser loading url [${url}]");
_readySemaphore = Semaphore(1, 1);
Logger.i("PackageBrowser", "Browser loading url [$url]")
val token = UUID.randomUUID().toString()
_loadToken = token
_expectedMainUrl = url
_readySemaphore = Semaphore(1, acquiredPermits = 1)
StateApp.instance.scope.launch(Dispatchers.Main) {
browser.loadUrl(url);
try { browser.loadUrl(url) }
catch (t: Throwable) { Logger.e("PackageBrowser", "loadUrl failed", t) }
}
}
private fun releaseReadyIfCurrent(url: String?) {
if (url == null) return
val expected = _expectedMainUrl
if (url.trimEnd('/') != expected?.trimEnd('/')) return
_readySemaphore?.release()
_readySemaphore = null
_expectedMainUrl = null
}
@V8Function
fun run(js: String, callbackId: String? = null, callback: V8ValueFunction? = null) {
waitTillLoaded();
@@ -158,12 +339,17 @@ class PackageBrowser: V8Package {
}
StateApp.instance.scope.launch(Dispatchers.Main) {
try {
Logger.i("PackageBrowser", "Browser running JS with callback [${callbackId}]\n${(if(js.length > 200) (js.substring(0, 200) + "...") else js)})");
browser.evaluateJavascript(js, object : ValueCallback<String> {
override fun onReceiveValue(value: String?) {
Logger.i("PackageBrowser", "Browser run finished");
}
})
try {
Logger.i("PackageBrowser", "Browser running JS with callback [${callbackId}]\n${(if(js.length > 200) (js.substring(0, 200) + "...") else js)})");
browser.evaluateJavascript(js, object : ValueCallback<String> {
override fun onReceiveValue(value: String?) {
Logger.i("PackageBrowser", "Browser run finished");
}
})
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Browser running failed: " + ex.message, ex);
}
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Failed to invoke browser", ex);
@@ -182,38 +368,187 @@ class PackageBrowser: V8Package {
Logger.i("PackageBrowser", "Browser run returned: " + (value ?: ""));
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
Logger.i("PackageBrowser", "Invoking V8 with result (${funcClone != null})");
_plugin.busy {
funcClone?.callVoid(null, arrayOf(value));
try {
_plugin.busy {
if (value != null) {
val json = _json.decodeFromString<String>(value);
funcClone?.callVoid(null, arrayOf(json));
} else
funcClone?.callVoid(null, arrayOf((null as String?)));
}
if (!_plugin.isStopped)
JavetResourceUtils.safeClose(funcClone);
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Browser Failed to callback: " + ex.message, ex);
}
if (!_plugin.isStopped)
JavetResourceUtils.safeClose(funcClone);
}
}
})
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Failed to invoke browser", ex);
Logger.e("PackageBrowser", "Browser Failed to invoke browser", ex);
}
}
}
class JSInterop(private val pack: PackageBrowser) {
@V8Function
fun addScriptOnLoad(js: String): String {
require(js.isNotBlank()) { "Script must be non-empty." }
@JavascriptInterface
fun callback(id: String, result: String) {
Logger.i("PackageBrowser", "Browser Callback [${id}]: ${result}");
val callback = synchronized(pack._callbacks) { pack._callbacks.remove(id); };
if(callback != null) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
callback.invoke(result);
}
val id = UUID.randomUUID().toString()
onMainBlocking {
if (useAddDocumentStartJavaScript && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
val ref = WebViewCompat.addDocumentStartJavaScript(browser, js, setOf("*"))
_pageLoadScriptRefs[id] = ref
} else {
_pageLoadScriptsFallback[id] = js
}
}
@JavascriptInterface
fun log(msg: String) {
Logger.i("PackageBrowser", "Log: " + msg);
Logger.i("PackageBrowser", "addScriptOnLoad() registered (id=$id)")
return id
}
@SuppressLint("RequiresFeature")
@V8Function
fun removeScriptOnLoad(identifier: String): Boolean {
if (identifier.isBlank()) return false
val ref = _pageLoadScriptRefs.remove(identifier)
val removedFallback = _pageLoadScriptsFallback.remove(identifier) != null
if (ref != null) {
onMainBlocking {
try { ref.remove() } catch (_: Throwable) {}
}
Logger.i("PackageBrowser", "removeScriptOnLoad() removed (id=$identifier)")
return true
}
if (removedFallback) {
Logger.i("PackageBrowser", "removeScriptOnLoad() removed fallback (id=$identifier)")
return true
}
return false
}
@SuppressLint("RequiresFeature")
@V8Function
fun clearScriptsOnLoad() {
val refs = _pageLoadScriptRefs.values.toList()
_pageLoadScriptRefs.clear()
_pageLoadScriptsFallback.clear()
onMainBlocking {
for (r in refs) {
try { r.remove() } catch (_: Throwable) {}
}
}
Logger.i("PackageBrowser", "clearScriptsOnLoad() cleared")
}
private fun charsetFromContentType(ct: String): Charset? {
val m = Regex("(?i)charset=([\\w\\-]+)").find(ct) ?: return null
val name = m.groupValues.getOrNull(1)?.trim().orEmpty()
return runCatching { Charset.forName(name) }.getOrNull()
}
private fun injectIntoHead(html: String, js: String, nonce: String?): String {
val nonceAttr = nonce?.let { " nonce=\"${escapeHtmlAttr(it)}\"" } ?: ""
val tag = "<script$nonceAttr>\n$js\n</script>\n"
val head = Regex("(?i)<head[^>]*>").find(html)
if (head != null) {
val i = head.range.last + 1
return buildString(html.length + tag.length + 8) {
append(html, 0, i)
append('\n')
append(tag)
append(html, i, html.length)
}
}
return tag + html
}
private fun <T> onMainBlocking(block: () -> T): T {
return if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else runBlocking {
withContext(Dispatchers.Main) { block() }
}
}
private fun extractNonceFromCsp(csp: String?): String? {
if (csp.isNullOrBlank()) return null
val m = Regex("(?i)'nonce-([^'\\s;]+)'").find(csp) ?: return null
return m.groupValues[1]
}
private fun extractNonceFromHtml(html: String): String? {
val m = Regex("(?i)<script[^>]*\\snonce\\s*=\\s*['\"]([^'\"]+)['\"][^>]*>").find(html)
return m?.groupValues?.get(1)
}
private fun escapeHtmlAttr(s: String): String =
s.replace("&", "&amp;").replace("\"", "&quot;")
@Serializable
private data class ConsoleBridgeMsg(
val t: String,
val id: String? = null,
val result: String? = null,
val msg: String? = null
)
private fun handleConsoleBridgeMessage(payload: String): Boolean {
Logger.i("PackageBrowser", "handleConsoleBridgeMessage: " + payload)
val parsed = runCatching { _json.decodeFromString<ConsoleBridgeMsg>(payload) }.getOrNull()
?: return false
when (parsed.t) {
"cb" -> {
val id = parsed.id ?: return true
val res = parsed.result
val cb = synchronized(_callbacks) { _callbacks.remove(id) } ?: return true
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
cb.invoke(res)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to invoke callback asynchronously", e)
}
}
return true
}
"log" -> {
val text = parsed.msg.orEmpty()
Logger.i("PackageBrowser", "Browser Log: $text")
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID) {
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", text)
}
return true
}
else -> return true
}
}
private companion object {
private const val CONSOLE_BRIDGE_PREFIX = "__GJ__:"
private const val TAG = "PackageBrowser"
private fun String.quoteForJs(): String {
val s = this
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
return "\"$s\""
}
}
}
@@ -651,14 +651,17 @@ class PackageHttp: V8Package {
@V8Function
fun connect(socketObj: V8ValueObject) {
val hasOpen = socketObj.has("open");
val hasMessage = socketObj.has("message");
val hasClosing = socketObj.has("closing");
val hasClosed = socketObj.has("closed");
val hasFailure = socketObj.has("failure");
val (hasOpen, hasMessage, hasClosing, hasClosed, hasFailure) = _package._plugin.busy {
val open = socketObj.has("open");
val message = socketObj.has("message");
val closing = socketObj.has("closing");
val closed = socketObj.has("closed");
val failure = socketObj.has("failure");
socketObj.setWeak(); //We have to manage this lifecycle
_listeners = socketObj;
socketObj.setWeak(); //We have to manage this lifecycle
_listeners = socketObj;
Quintuple(open, message, closing, closed, failure);
};
_socket = _packageClient.logExceptions {
val client = _client;
@@ -666,51 +669,50 @@ class PackageHttp: V8Package {
override fun open() {
Logger.i(TAG, "Websocket opened: " + _url);
_isOpen = true;
if(hasOpen && _listeners?.isClosed != true) {
try {
_package._plugin.busy {
try {
_package._plugin.busy {
if(hasOpen && _listeners?.isClosed != true) {
_listeners?.invokeV8Void("open", arrayOf<Any>());
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] open failed: " + ex.message, ex);
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] open failed: " + ex.message, ex);
}
}
override fun message(msg: String) {
if(hasMessage && _listeners?.isClosed != true) {
try {
_package._plugin.busy {
try {
_package._plugin.busy {
if(hasMessage && _listeners?.isClosed != true) {
_listeners?.invokeV8Void("message", msg);
}
}
catch(ex: Throwable) {}
}
catch(ex: Throwable) {}
}
override fun closing(code: Int, reason: String) {
if(hasClosing && _listeners?.isClosed != true)
{
try {
_package._plugin.busy {
try {
_package._plugin.busy {
if(hasClosing && _listeners?.isClosed != true) {
_listeners?.invokeV8Void("closing", code, reason);
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closing failed: " + ex.message, ex);
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closing failed: " + ex.message, ex);
}
}
override fun closed(code: Int, reason: String) {
_isOpen = false;
if(hasClosed && _listeners?.isClosed != true) {
try {
_package._plugin.busy {
try {
_package._plugin.busy {
if(hasClosed && _listeners?.isClosed != true) {
_listeners?.invokeV8Void("closed", code, reason);
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
}
Logger.w(TAG, "PackageHttp Socket removed");
synchronized(_package.aliveSockets) {
@@ -720,15 +722,15 @@ class PackageHttp: V8Package {
override fun failure(exception: Throwable) {
_isOpen = false;
Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception);
if(hasFailure && _listeners?.isClosed != true) {
try {
_package._plugin.busy {
try {
_package._plugin.busy {
if(hasFailure && _listeners?.isClosed != true) {
_listeners?.invokeV8Void("failure", exception.message);
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
}
}
});
@@ -747,10 +749,20 @@ class PackageHttp: V8Package {
@V8Function
fun close(code: Int?, reason: String?) {
_socket?.close(code ?: 1000, reason ?: "");
_listeners?.close()
_package._plugin.busy {
_listeners?.close()
}
}
}
private data class Quintuple<A, B, C, D, E>(
val first: A,
val second: B,
val third: C,
val fourth: D,
val fifth: E
)
data class RequestDescriptor(
val method: String,
val url: String,
@@ -780,4 +792,4 @@ class PackageHttp: V8Package {
private const val TAG = "PackageHttp";
private val WHITELISTED_RESPONSE_HEADERS = listOf("content-type", "date", "content-length", "last-modified", "etag", "cache-control", "content-encoding", "content-disposition", "connection")
}
}
}
@@ -367,7 +367,7 @@ class ArticleDetailFragment : MainFragment {
_rating.visibility = VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
_rating.onLikeDislikeUpdated.subscribe(this@ArticleDetailView) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) {
@@ -1,8 +1,6 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -16,6 +14,8 @@ import com.futo.futopay.formatMoney
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StatePayment
import com.futo.platformplayer.views.overlays.LoaderOverlay
@@ -68,8 +68,11 @@ class BuyFragment : MainFragment() {
_paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, _, exception ->
if(success) {
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0, UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY));
_fragment.close(true);
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0,
UIDialogs.Action("Ok", {
(fragment.activity as? MainActivity)?.navigate<SettingsFragment>(withHistory = false);
}, UIDialogs.ActionStyle.PRIMARY)
);
}
else {
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.payment_failed), exception);
@@ -89,16 +92,19 @@ class BuyFragment : MainFragment() {
fragment.lifecycleScope.launch(Dispatchers.IO) {
//Calling this function will cache first call
try {
val currencies = StatePayment.instance.getAvailableCurrencies("grayjay");
val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay");
val country = StatePayment.instance.getPaymentCountryFromIP(true)?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } };
// TODO: Restore multi-currency support when payment backend supports it
// val currencies = StatePayment.instance.getAvailableCurrencies("grayjay");
// val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay");
// val country = StatePayment.instance.getPaymentCountryFromIP(true)?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
// val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } };
// if(currency != null && prices.containsKey(currency.id)) {
// val price = prices[currency.id]!!;
// _buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax);
// }
if(currency != null && prices.containsKey(currency.id)) {
val price = prices[currency.id]!!;
withContext(Dispatchers.Main) {
_buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax);
}
val priceCents = StatePayment.instance.getPolarProductPrice(PaymentConfigurations.PolarConfig.PRODUCT_SLUG)
withContext(Dispatchers.Main) {
_buttonBuyText.text = formatMoney("US", "usd", priceCents) + context.getString(R.string.plus_tax)
}
}
catch(ex: Throwable) {
@@ -165,4 +171,4 @@ class BuyFragment : MainFragment() {
fun newInstance() = BuyFragment().apply {}
private val TAG = "BuyFragment"
}
}
}
@@ -27,6 +27,7 @@ import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.contents.ContentType
@@ -215,6 +216,10 @@ class ChannelFragment : MainFragment() {
is IPlatformPost -> {
fragment.navigate<PostDetailFragment>(v)
}
is IPlatformArticle -> {
fragment.navigate<ArticleDetailFragment>(v)
}
}
}
adapter.onShortClicked.subscribe { v, _, pagerPair ->
@@ -150,7 +150,7 @@ class DownloadsFragment : MainFragment() {
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
};
val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc", "sizeAsc", "sizeDesc");
val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc", "sizeAsc", "sizeDesc", "typeAudio", "typeVideo");
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
when(pos) {
@@ -162,6 +162,8 @@ class DownloadsFragment : MainFragment() {
5 -> ordering.setAndSave("releasedDesc")
6 -> ordering.setAndSave("sizeAsc")
7 -> ordering.setAndSave("sizeDesc")
8 -> ordering.setAndSave("typeAudio")
9 -> ordering.setAndSave("typeVideo")
else -> ordering.setAndSave("")
}
updateContentFilters()
@@ -261,6 +263,8 @@ class DownloadsFragment : MainFragment() {
"releasedDesc" -> vidsToReturn.sortedByDescending { it.datetime ?: OffsetDateTime.MIN }
"sizeAsc" -> vidsToReturn.sortedBy { it.videoSource.sumOf { it.fileSize } + it.audioSource.sumOf { it.fileSize } }
"sizeDesc" -> vidsToReturn.sortedByDescending { it.videoSource.sumOf { it.fileSize } + it.audioSource.sumOf { it.fileSize } }
"typeAudio" -> vidsToReturn.sortedBy { if (it.videoSource.isEmpty() && it.audioSource.isNotEmpty()) 0 else 1 }
"typeVideo" -> vidsToReturn.sortedBy { if (it.videoSource.isNotEmpty()) 0 else 1 }
else -> vidsToReturn
}
}
@@ -28,6 +28,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
@@ -176,6 +177,10 @@ class LibraryArtistFragment : MainFragment() {
is IPlatformPost -> {
fragment.navigate<PostDetailFragment>(v)
}
is IPlatformArticle -> {
fragment.navigate<ArticleDetailFragment>(v)
}
}
}
adapter.onShortClicked.subscribe { v, _, pagerPair ->
@@ -96,11 +96,15 @@ class LoginFragment : MainFragment() {
else throw IllegalStateException("No valid configuration?");
//TODO: Backwards compat removal?
_webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
if (authConfig.userAgent != null)
_webView.settings.userAgentString = authConfig.userAgent;
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
val capturedUserAgent = _webView.settings.userAgentString;
_webView.settings.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = true;
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig);
val webViewClient = if(config != null) LoginWebViewClient(config, capturedUserAgent) else LoginWebViewClient(authConfig, capturedUserAgent);
webViewClient.onLogin.subscribe { auth ->
_callback?.let {
@@ -370,7 +370,7 @@ class PostDetailFragment : MainFragment {
_rating.visibility = VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
_rating.onLikeDislikeUpdated.subscribe(this@PostDetailView) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) {
@@ -688,6 +688,7 @@ class ShortView : FrameLayout {
dislikeButton.visibility = GONE
loadLikesTask?.cancel()
onLikeDislikeUpdated.remove(this@ShortView)
loadLikesTask =
TaskHandler<IPlatformVideo, Pair<Protocol.Reference, Protocol.QueryReferencesResponse>>(
StateApp.instance.scopeGetter, {
@@ -715,7 +716,7 @@ class ShortView : FrameLayout {
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())
onLikesLoaded.emit(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked)
onLikeDislikeUpdated.subscribe(this) { args ->
onLikeDislikeUpdated.subscribe(this@ShortView) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like)
} else if (args.hasDisliked) {
@@ -309,7 +309,7 @@ class SourceDetailFragment : MainFragment() {
BigButton(c, context.getString(R.string.logout), context.getString(R.string.sign_out_of_the_platform), R.drawable.ic_logout) {
logoutSource();
},
if(!Settings.instance.other.shouldClearWebviewCookies())
if(!Settings.instance.plugins.shouldClearWebviewCookies())
BigButton(c, "Logout without Clear", "Logout but keep the browser cookies.\nThis allows for quick re-logging.", R.drawable.ic_logout) {
logoutSource(false);
}.apply {
@@ -520,7 +520,7 @@ class SourceDetailFragment : MainFragment() {
Logger.e(TAG, "Failed to set plugin authentication (loginSource, loginWarning)", e)
}
finally {
if(Settings.instance.other.shouldClearWebviewCookies()) {
if(Settings.instance.plugins.shouldClearWebviewCookies()) {
try {
val cookieManager: CookieManager =
CookieManager.getInstance();
@@ -459,7 +459,14 @@ class VideoDetailFragment() : MainFragment() {
val params = _viewDetail?.getPictureInPictureParams();
if(params != null) {
Logger.i(TAG, "enterPictureInPictureMode")
activity?.enterPictureInPictureMode(params);
try {
activity?.enterPictureInPictureMode(params);
} catch(e: IllegalStateException) {
if(e.message?.contains("Device doesn't support picture-in-picture") != true) {
throw e
}
}
}
}
@@ -470,8 +477,15 @@ class VideoDetailFragment() : MainFragment() {
fun forcePictureInPicture() {
val params = _viewDetail?.getPictureInPictureParams();
if(params != null)
activity?.enterPictureInPictureMode(params);
if(params != null) {
try {
activity?.enterPictureInPictureMode(params);
} catch(e: IllegalStateException) {
if(e.message?.contains("Device doesn't support picture-in-picture") != true) {
throw e
}
}
}
}
fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, isStop: Boolean, newConfig: Configuration) {
try {
@@ -216,6 +216,7 @@ class VideoDetailView : ConstraintLayout {
private val _playerProgress: PlayerControlView;
private val _timeBar: TimeBar;
private var _upNext: UpNextView;
private var _artworkTarget: CustomTarget<Bitmap>? = null
private val rootView: ConstraintLayout;
@@ -693,7 +694,14 @@ class VideoDetailView : ConstraintLayout {
onShouldEnterPictureInPictureChanged.subscribe {
val params = getPictureInPictureParams()
fragment.activity?.setPictureInPictureParams(params)
try {
fragment.activity?.setPictureInPictureParams(params)
} catch(e: IllegalStateException) {
if(e.message?.contains("Device doesn't support picture-in-picture") != true) {
throw e
}
}
}
if (!isInEditMode) {
@@ -882,6 +890,9 @@ class VideoDetailView : ConstraintLayout {
};
onClose.subscribe {
_artworkTarget?.let { Glide.with(context).clear(it) }
_artworkTarget = null
_player.setArtwork(null)
checkAndRemoveWatchLater();
_lastVideoSource = null;
_lastAudioSource = null;
@@ -894,6 +905,7 @@ class VideoDetailView : ConstraintLayout {
cleanupPlaybackTracker();
Logger.i(TAG, "Keep screen on unset onClose")
fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
clearChapters()
};
StatePlayer.instance.autoplayChanged.subscribe(this) {
@@ -988,6 +1000,12 @@ class VideoDetailView : ConstraintLayout {
_cast.stopAllGestures();
}
private fun clearChapters() {
_chapters = null
_player.setChapters(null)
_cast.setChapters(null)
}
fun showChaptersUI(){
video?.let {
try {
@@ -1195,6 +1213,7 @@ class VideoDetailView : ConstraintLayout {
else if(_didStop) {
_didStop = false;
Logger.i(TAG, "loadCurrentVideo _lastPosition=${lastPositionMilliseconds}");
StatePlayer.instance.startOrUpdateMediaSession(context, video);
loadCurrentVideo(lastPositionMilliseconds);
handlePause();
}
@@ -1264,6 +1283,9 @@ class VideoDetailView : ConstraintLayout {
fun onDestroy() {
Logger.i(TAG, "onDestroy");
_destroyed = true;
_artworkTarget?.let { Glide.with(context).clear(it) }
_artworkTarget = null
_player.setArtwork(null)
_taskLoadVideo.cancel();
_commentsList.cancel();
_player.clear();
@@ -1315,6 +1337,7 @@ class VideoDetailView : ConstraintLayout {
_lastVideoSource = null;
_lastAudioSource = null;
_lastSubtitleSource = null;
clearChapters()
}
fun setVideo(url: String, resumeSeconds: Long = 0, playWhenReady: Boolean = true) {
Logger.i(TAG, "setVideo url=$url resumeSeconds=$resumeSeconds playWhenReady=$playWhenReady")
@@ -1325,6 +1348,7 @@ class VideoDetailView : ConstraintLayout {
_searchVideo = null;
video = null;
cleanupPlaybackTracker();
clearChapters()
_url = url;
_videoResumePositionMilliseconds = resumeSeconds * 1000;
_rating.visibility = View.GONE;
@@ -1535,6 +1559,7 @@ class VideoDetailView : ConstraintLayout {
}
val me = this;
clearChapters()
if (video is JSVideoDetails) {
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
@@ -1731,7 +1756,7 @@ class VideoDetailView : ConstraintLayout {
hasLiked,
hasDisliked
);
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
_rating.onLikeDislikeUpdated.subscribe(this@VideoDetailView) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) {
@@ -2053,19 +2078,31 @@ class VideoDetailView : ConstraintLayout {
_player.switchToVideoMode()
isAudioOnlyUserAction = false;
} else {
val thumbnail = video.thumbnails.getHQThumbnail();
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
.into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
_player.setArtwork(BitmapDrawable(resources, resource));
}
override fun onLoadCleared(placeholder: Drawable?) {
_player.setArtwork(null);
}
});
else
_player.setArtwork(null);
_artworkTarget?.let { Glide.with(context).clear(it) }
_artworkTarget = null
val thumbnail = video.thumbnails.getHQThumbnail()
val showArtwork = _player.isAudioMode || isAudioOnlyUserAction || (videoSource == null)
if (showArtwork && !thumbnail.isNullOrBlank()) {
val target = object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
_player.setArtwork(BitmapDrawable(resources, resource))
}
override fun onLoadCleared(placeholder: Drawable?) {
_player.setArtwork(null)
}
}
_artworkTarget = target
Glide.with(context)
.asBitmap()
.load(thumbnail)
.withMaxSizePx()
.into(target)
} else {
_player.setArtwork(null)
}
}
fragment.lifecycleScope.launch(Dispatchers.Main) {
@@ -90,7 +90,10 @@ class GeneralTopBarFragment : TopFragment() {
updateNotifCount();
_buttonNotifs?.setOnClickListener {
navigate<NotificationOverlayView.Frag>();
if(currentMain is NotificationOverlayView.Frag)
closeSegment();
else
navigate<NotificationOverlayView.Frag>();
}
buttonSearch.setOnClickListener {
@@ -1,18 +1,35 @@
package com.futo.platformplayer.helpers
import java.text.Normalizer
class FileHelper {
companion object {
fun String.sanitizeFileName(allowSpace: Boolean = false): String {
return this.filter {
(it in '0' .. '9') ||
(it in 'a'..'z') ||
(it in 'A'..'Z') ||
(it == '-' || it == '.' || it == '_' || (it == ' ' && allowSpace)) ||
(it in '丁'..'龤') || //Chinese/Kanji
(it in '\u3040'..'\u309f') || //Hiragana
(it in '\u30A0'..'\u30ff') || //Katakana
(it in '\u0600'..'\u06FF') //Arabic
}; //Chinese
val normalized = Normalizer.normalize(this, Normalizer.Form.NFC)
val cleaned = buildString(normalized.length) {
for (ch in normalized) {
when {
ch == '\u0000' -> {}
Character.isISOControl(ch) -> {}
ch == '/' || ch == '\\' || ch == ':' || ch == '*' ||
ch == '?' || ch == '"' || ch == '<' || ch == '>' || ch == '|' -> append('_')
ch == ' ' && !allowSpace -> append('_')
else -> append(ch)
}
}
}
val collapsed = if (allowSpace) {
cleaned.replace(Regex("""\s+"""), " ")
} else {
cleaned.replace(Regex("""\s+"""), "_")
}
return collapsed
.trim()
.trimEnd('.')
}
}
}
@@ -16,13 +16,15 @@ class CaptchaWebViewClient : WebViewClient {
private val _pluginConfig: SourcePluginConfig?;
private val _captchaConfig: SourcePluginCaptchaConfig;
private val _userAgent: String?;
private var _didNotify = false;
private val _extractor: WebViewRequirementExtractor;
constructor(config: SourcePluginConfig) : super() {
constructor(config: SourcePluginConfig, userAgent: String? = null) : super() {
_pluginConfig = config;
_captchaConfig = config.captcha!!;
_userAgent = userAgent;
_extractor = WebViewRequirementExtractor(
config.allowUrls,
null,
@@ -34,9 +36,10 @@ class CaptchaWebViewClient : WebViewClient {
Logger.i(TAG, "Captcha [${config.name}]" +
"\nRequired Cookies: ${Serializer.json.encodeToString(config.captcha!!.cookiesToFind)}",);
}
constructor(captcha: SourcePluginCaptchaConfig) : super() {
constructor(captcha: SourcePluginCaptchaConfig, userAgent: String? = null) : super() {
_pluginConfig = null;
_captchaConfig = captcha;
_userAgent = userAgent;
_extractor = WebViewRequirementExtractor(
null,
null,
@@ -62,7 +65,8 @@ class CaptchaWebViewClient : WebViewClient {
_didNotify = true;
onCaptchaFinished.emit(SourceCaptchaData(
extracted.cookies,
extracted.headers
extracted.headers,
_userAgent
));
}
@@ -24,23 +24,26 @@ class LoginWebViewClient : WebViewClient {
private val _pluginConfig: SourcePluginConfig?;
private val _authConfig: SourcePluginAuthConfig;
private val _userAgent: String?;
private val _client = ManagedHttpClient();
val onLogin = Event1<SourceAuth>();
val onPageLoaded = Event2<WebView?, String?>()
constructor(config: SourcePluginConfig) : super() {
constructor(config: SourcePluginConfig, userAgent: String? = null) : super() {
_pluginConfig = config;
_authConfig = config.authentication!!;
_userAgent = userAgent;
Logger.i(TAG, "Login [${config.name}]" +
"\nRequired Headers: ${config.authentication.headersToFind?.joinToString(", ")}" +
"\nRequired Domain Headers: ${Serializer.json.encodeToString(config.authentication.domainHeadersToFind)}" +
"\nRequired Cookies: ${Serializer.json.encodeToString(config.authentication.cookiesToFind)}",);
}
constructor(auth: SourcePluginAuthConfig) : super() {
constructor(auth: SourcePluginAuthConfig, userAgent: String? = null) : super() {
_pluginConfig = null;
_authConfig = auth;
_userAgent = userAgent;
}
private val headersFoundMap: HashMap<String, HashMap<String, String>> = hashMapOf();
@@ -192,13 +195,14 @@ class LoginWebViewClient : WebViewClient {
if (urlFound && headersFound && domainHeadersFound && cookiesFound) {
onLogin.emit(SourceAuth(
cookieMap = cookiesFoundMap,
headers = headersFoundMap /*.associate { headerToFind ->
headers = headersFoundMap, /*.associate { headerToFind ->
headerToFind to headersFoundMap.firstNotNullOf { requestHeader ->
if (requestHeader.key.equals(headerToFind, ignoreCase = true))
requestHeader.value
else null;
}
} ?: mapOf()*/
userAgent = _userAgent
));
}
@@ -28,6 +28,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import java.net.InetAddress
@@ -41,22 +42,16 @@ class DownloadService : Service() {
private val TAG = "DownloadService";
private val DOWNLOAD_NOTIF_ID = 3;
private val DOWNLOAD_NOTIF_TAG = "download";
private val DOWNLOAD_NOTIF_CHANNEL_ID = "downloadChannel";
private val DOWNLOAD_NOTIF_CHANNEL_NAME = "Downloads";
//Context
private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default);
private val _scope: CoroutineScope = CoroutineScope(Dispatchers.IO);
private var _notificationManager: NotificationManager? = null;
private var _notificationChannel: NotificationChannel? = null;
private var _isForeground = false
private val _client = ManagedHttpClient(OkHttpClient.Builder()
//.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(InetAddress.getByName("192.168.1.175"), 8081)))
.readTimeout(Duration.ofMinutes(0))
.writeTimeout(Duration.ofMinutes(0))
.connectTimeout(Duration.ofSeconds(100))
.callTimeout(Duration.ofMinutes(0)))
private lateinit var _client: ManagedHttpClient
private var _started = false;
@@ -76,11 +71,23 @@ class DownloadService : Service() {
setupNotificationRequirements();
notifyDownload(null);
if (StateDownloads.instance.getDownloading().isEmpty()) {
Logger.i(TAG, "No downloads queued, stopping service")
closeDownloadSession()
return START_NOT_STICKY
}
_callOnStarted?.invoke(this);
_instance = this;
_scope.launch {
try {
_client = ManagedHttpClient(OkHttpClient.Builder()
//.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(InetAddress.getByName("192.168.1.175"), 8081)))
.readTimeout(Duration.ofMinutes(0))
.writeTimeout(Duration.ofMinutes(0))
.connectTimeout(Duration.ofSeconds(100))
.callTimeout(Duration.ofMinutes(0)))
doDownloading();
}
catch(ex: Throwable) {
@@ -167,7 +174,7 @@ class DownloadService : Service() {
ignore.add(currentVideo);
//Give it a sec
Thread.sleep(500);
delay(500);
}
catch(ex: Throwable) {
//if(ex is ScriptReloadRequiredException)
@@ -200,7 +207,7 @@ class DownloadService : Service() {
}
//Give it a sec
Thread.sleep(500);
delay(500);
}
StateDownloads.instance.updateDownloading(currentVideo);
@@ -338,6 +345,8 @@ class DownloadService : Service() {
fun getOrCreateService(context: Context, handle: ((DownloadService)->Unit)? = null) {
if(!FragmentedStorage.isInitialized)
return;
if(StateDownloads.instance.getDownloading().isEmpty())
return
if(_instance == null) {
_callOnStarted = handle;
val intent = Intent(context, DownloadService::class.java);
@@ -456,26 +456,19 @@ class MediaPlaybackService : Service() {
val audioFocusLossDuration = _audioFocusLossTime_ms?.let { System.currentTimeMillis() - it }
_audioFocusLossTime_ms = null
Log.i(TAG, "Audio focus gained (restartPlaybackAfterLoss = ${Settings.instance.playback.restartPlaybackAfterLoss}, _audioFocusLossTime_ms = $_audioFocusLossTime_ms, audioFocusLossDuration = ${audioFocusLossDuration})");
if (Settings.instance.playback.restartPlaybackAfterLoss == 1) {
if (audioFocusLossDuration != null && audioFocusLossDuration < 1000 * 10) {
MediaControlReceiver.onPlayReceived.emit()
}
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 2) {
if (audioFocusLossDuration != null && audioFocusLossDuration < 1000 * 30) {
MediaControlReceiver.onPlayReceived.emit()
}
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 3) {
MediaControlReceiver.onPlayReceived.emit()
if (audioFocusLossDuration == null) return@OnAudioFocusChangeListener
when (Settings.instance.playback.restartPlaybackAfterLoss) {
1 -> if (audioFocusLossDuration < 10_000) MediaControlReceiver.onPlayReceived.emit()
2 -> if (audioFocusLossDuration < 30_000) MediaControlReceiver.onPlayReceived.emit()
3 -> MediaControlReceiver.onPlayReceived.emit()
}
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
_audioFocusLossTime_ms = if (isPlaying) {
System.currentTimeMillis()
} else {
null
}
val wasPlaying = isPlaying
_audioFocusLossTime_ms = if (wasPlaying) System.currentTimeMillis() else null
_hasFocus = false;
_isTransientLoss = true;
@@ -488,11 +481,8 @@ class MediaPlaybackService : Service() {
_isTransientLoss = true;
}
AudioManager.AUDIOFOCUS_LOSS -> {
_audioFocusLossTime_ms = if (isPlaying) {
System.currentTimeMillis()
} else {
null
}
val wasPlaying = isPlaying
_audioFocusLossTime_ms = if (wasPlaying) System.currentTimeMillis() else null
MediaControlReceiver.onPauseReceived.emit();
abandonAudioFocus();
@@ -1,5 +1,6 @@
package com.futo.platformplayer.states
import android.content.Context
import android.view.View
import android.view.WindowManager
import com.futo.platformplayer.R
@@ -122,7 +123,7 @@ class StateAnnouncement {
//Special Announcements
fun registerPluginUpdate(oldConfig: SourcePluginConfig, newConfig: SourcePluginConfig): SessionAnnouncement {
val announcement = SessionAnnouncement(
"update-plugin-" + UUID.randomUUID().toString(),
"update-plugin-" + oldConfig.id + "-v" + newConfig.version,
"${newConfig.name} update v${newConfig.version} available!",
"An update is available to upgrade from ${oldConfig.version} to ${newConfig.version}.",
AnnouncementType.SESSION,
@@ -130,19 +131,62 @@ class StateAnnouncement {
null, null,oldConfig.id,
newConfig?.absoluteIconUrl?.let { ImageVariable.fromUrl(it) }
).withExtraAction("Changelog", StateAnnouncement.ACTION_CHANGELOG, oldConfig.id);
announcement.extraObj = newConfig;
registerAnnouncementSession(announcement);
return announcement;
}
fun registerPluginUpdated(newConfig: SourcePluginConfig) {
registerAnnouncementSession(SessionAnnouncement(
"updated-plugin-" + UUID.randomUUID().toString(),
val announcement = SessionAnnouncement(
"updated-plugin-" + newConfig.id + "-v" + newConfig.version,
"${newConfig.name} updated to v${newConfig.version}!",
"You have succesfully been updater to v${newConfig.version}.",
"You have succesfully been updated to v${newConfig.version}.",
AnnouncementType.SESSION,
null, "updates", null, null,
null, null,null,
newConfig?.absoluteIconUrl?.let { ImageVariable.fromUrl(it) }
).withExtraAction("Changelog", StateAnnouncement.ACTION_CHANGELOG, newConfig.id));
).withExtraAction("Changelog", StateAnnouncement.ACTION_CHANGELOG, newConfig.id);
announcement.extraObj = newConfig;
registerAnnouncementSession(announcement);
}
fun tryAutoUpdatePlugin(oldConfig: SourcePluginConfig, newConfig: SourcePluginConfig) {
val context = StateApp.instance.contextOrNull;
if(context == null) {
registerPluginUpdate(oldConfig, newConfig);
return;
}
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
val client = ManagedHttpClient();
client.setTimeout(10000);
val oldScript = StatePlugins.instance.getScript(oldConfig.id) ?: "";
val newScript = client.get(newConfig.absoluteScriptUrl)?.body?.string();
if(newScript.isNullOrEmpty()) {
Logger.w(TAG, "Auto-update for ${oldConfig.name}: no script returned, falling back to notification");
withContext(Dispatchers.Main) { registerPluginUpdate(oldConfig, newConfig); }
return@launch;
}
if(!oldConfig.isLowRiskUpdate(oldScript, newConfig, newScript)) {
withContext(Dispatchers.Main) { registerPluginUpdate(oldConfig, newConfig); }
return@launch;
}
StatePlugins.instance.installPluginBackground(context, StateApp.instance.scope, newConfig, newScript,
{ _: String, _: Double -> },
{ ex ->
if(ex == null) {
registerPluginUpdated(newConfig);
} else {
Logger.e(TAG, "Auto-update for ${newConfig.name} failed during install", ex);
UIDialogs.appToast("Update for ${newConfig.name} failed\n" + ex.message);
}
});
} catch(ex: Throwable) {
Logger.e(TAG, "Auto-update for ${oldConfig.name} failed", ex);
withContext(Dispatchers.Main) { registerPluginUpdate(oldConfig, newConfig); }
}
}
}
fun registerLoading(title: String, description: String, icon: ImageVariable? = null, customId: String? = null): SessionAnnouncement {
@@ -282,7 +326,7 @@ class StateAnnouncement {
when (actionId) {
ACTION_NEVER -> neverAnnouncement(item.id);
ACTION_SOMETHING -> actionSomething();
ACTION_CHANGELOG -> actionChangelog(actionData);
ACTION_CHANGELOG -> actionChangelog(item, actionData);
ACTION_UPDATE_PLUGIN -> actionUpdatePlugin(item.id, actionData);
}
}
@@ -314,27 +358,35 @@ class StateAnnouncement {
}
private fun actionChangelog(id: String?) {
if(id == null)
private fun actionChangelog(item: Announcement, id: String?) {
val context = StateApp.instance.contextOrNull ?: return;
val cached = (item as? SessionAnnouncement)?.extraObj as? SourcePluginConfig;
if(cached != null) {
showPluginChangelog(context, cached);
return;
}
StateApp.instance.contextOrNull?.let { context ->
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val plugin = StatePlugins.instance.getPlugin(id);
if (plugin == null)
return@launch
val update = StatePlugins.instance.checkForUpdates(plugin.config);
if(update == null)
return@launch;
if(id == null) return;
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
UIDialogs.showChangelogDialog(context, update.version, update.changelog!!.filterKeys { it.toIntOrNull() != null }
.mapKeys { it.key.toInt() }
.mapValues { update.getChangelogString(it.key.toString()) ?: "" });
}
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val plugin = StatePlugins.instance.getPlugin(id) ?: return@launch;
val update = StatePlugins.instance.checkForUpdates(plugin.config) ?: return@launch;
withContext(Dispatchers.Main) {
showPluginChangelog(context, update);
}
}
}
private fun showPluginChangelog(context: Context, config: SourcePluginConfig) {
if(config.changelog?.any() != true) {
UIDialogs.toast(context, "No changelog available");
return;
}
UIDialogs.showChangelogDialog(context, config.version, config.changelog!!.filterKeys { it.toIntOrNull() != null }
.mapKeys { it.key.toInt() }
.mapValues { config.getChangelogString(it.key.toString()) ?: "" });
}
private fun actionUpdatePlugin(notifId: String?, id: String?) {
if(id == null)
return;
@@ -363,7 +415,7 @@ class StateAnnouncement {
if(newScript.isNullOrEmpty())
throw IllegalStateException("No script found");
if(true || plugin.config.isLowRiskUpdate(script, update, newScript)) {
if(plugin.config.isLowRiskUpdate(script, update, newScript)) {
StatePlugins.instance.installPluginBackground(context, StateApp.instance.scope, update, newScript,
{ text: String, progress: Double -> },
{ ex ->
@@ -13,9 +13,12 @@ import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.DocumentsContract
import android.util.DisplayMetrics
import android.util.Log
import android.webkit.CookieManager
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
@@ -31,6 +34,7 @@ import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.background.BackgroundWorker
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0
@@ -100,10 +104,16 @@ class StateApp {
var hasMediaStoreVideoPermission: Boolean = false;
fun getExternalGeneralDirectory(context: Context): DocumentFile? {
val generalUri = Settings.instance.storage.getStorageGeneralUri();
if(isValidStorageUri(context, generalUri))
return DocumentFile.fromTreeUri(context, generalUri!!);
return null;
val generalUri = Settings.instance.storage.getStorageGeneralUri()
val document = getAccessibleTreeDirectory(context, generalUri)
if (document == null && generalUri != null) {
Logger.w(TAG, "Stored general directory is no longer valid, clearing setting [$generalUri]")
Settings.instance.storage.storage_general = null
Settings.instance.save()
}
return document
}
fun changeExternalGeneralDirectory(context: IWithResultLauncher, onChanged: ((DocumentFile?)->Unit)? = null) {
if(context is Context)
@@ -124,10 +134,16 @@ class StateApp {
};
}
fun getExternalDownloadDirectory(context: Context): DocumentFile? {
val downloadUri = Settings.instance.storage.storage_download?.let { Uri.parse(it) };
if(isValidStorageUri(context, downloadUri))
return DocumentFile.fromTreeUri(context, downloadUri!!);
return null;
val downloadUri = Settings.instance.storage.storage_download?.let { Uri.parse(it) }
val document = getAccessibleTreeDirectory(context, downloadUri)
if (document == null && downloadUri != null) {
Logger.w(TAG, "Stored download directory is no longer valid, clearing setting [$downloadUri]")
Settings.instance.storage.storage_download = null
Settings.instance.save()
}
return document
}
fun changeExternalDownloadDirectory(context: IWithResultLauncher, onChanged: ((DocumentFile?)->Unit)? = null) {
if(context is Context)
@@ -143,11 +159,80 @@ class StateApp {
}
};
}
fun isValidStorageUri(context: Context, uri: Uri?): Boolean {
if(uri == null)
return false;
return context.contentResolver.persistedUriPermissions.any { it.uri == uri && it.isReadPermission && it.isWritePermission };
private fun hasPersistedStoragePermission(context: Context, uri: Uri?): Boolean {
if (uri == null)
return false
return context.contentResolver.persistedUriPermissions.any {
it.uri == uri && it.isReadPermission && it.isWritePermission
}
}
private fun getAccessibleTreeDirectory(context: Context, uri: Uri?): DocumentFile? {
if (uri == null)
return null
if (!hasPersistedStoragePermission(context, uri))
return null
return try {
val treeDocumentId = DocumentsContract.getTreeDocumentId(uri)
val treeDocumentUri = DocumentsContract.buildDocumentUriUsingTree(uri, treeDocumentId)
// Force a provider round-trip. If the directory was deleted, storage was removed,
// or the URI is otherwise stale, this usually fails here.
context.contentResolver.query(
treeDocumentUri,
arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_FLAGS
),
null,
null,
null
)?.use { cursor ->
if (!cursor.moveToFirst())
return null
val mimeTypeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE)
if (mimeTypeIndex >= 0) {
val mimeType = cursor.getString(mimeTypeIndex)
if (mimeType != DocumentsContract.Document.MIME_TYPE_DIR)
return null
}
} ?: return null
val document = DocumentFile.fromTreeUri(context, uri) ?: return null
if (!document.exists())
return null
if (!document.isDirectory)
return null
if (!document.canRead())
return null
if (!document.canWrite())
return null
document
}
catch (e: SecurityException) {
Logger.w(TAG, "Storage URI is no longer accessible [$uri]", e)
null
}
catch (e: IllegalArgumentException) {
Logger.w(TAG, "Storage URI is invalid [$uri]", e)
null
}
catch (e: Throwable) {
Logger.w(TAG, "Failed to validate storage URI [$uri]", e)
null
}
}
fun isValidStorageUri(context: Context, uri: Uri?): Boolean {
return getAccessibleTreeDirectory(context, uri) != null
}
//Scope
@@ -307,49 +392,45 @@ class StateApp {
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit) {
return requestDirectoryAccess(activity, name, purpose, path, handle, false);
}
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit, skipDialog: Boolean = false)
{
if(activity is Context)
{
if(skipDialog) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
if(path != null)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?) -> Unit, skipDialog: Boolean = false) {
if (Looper.myLooper() != Looper.getMainLooper()) {
Handler(Looper.getMainLooper()).post {
requestDirectoryAccess(activity, name, purpose, path, handle, skipDialog)
}
return
}
if (activity is Context) {
if (skipDialog) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
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)
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
activity.launchForResult(intent, 99) {
if(it.resultCode == Activity.RESULT_OK) {
handle(it.data?.data);
}
else
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
};
}
else {
if (it.resultCode == Activity.RESULT_OK) handle(it.data?.data)
else UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted")
}
} else {
UIDialogs.showDialog(activity, R.drawable.ic_security, "Directory required for\n${name}", "Please select a directory for ${name}.\n${purpose}".trim(), null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Ok", {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
if(path != null)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
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)
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
activity.launchForResult(intent, 99) {
if(it.resultCode == Activity.RESULT_OK) {
handle(it.data?.data);
}
else
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
};
}, UIDialogs.ActionStyle.PRIMARY));
if (it.resultCode == Activity.RESULT_OK) handle(it.data?.data)
else UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted")
}
}, UIDialogs.ActionStyle.PRIMARY)
)
}
}
}
@@ -448,7 +529,16 @@ class StateApp {
_cacheDirectory?.let { ApiMethods.initCache(it) };
}
if(Settings.instance.plugins.shouldClearWebviewCookies()) {
try {
Logger.i(TAG, "Clearing cookies on startup");
val cookieManager: CookieManager =
CookieManager.getInstance();
cookieManager.removeAllCookies(null);
} catch (ex: Throwable) {
Logger.e(SourceDetailFragment.Companion.TAG, "Failed to clear cookies", ex);
}
}
Logger.i(TAG, "MainApp Starting: Initializing [ModerationsManager]");
ModerationsManager.initialize(context);
@@ -662,9 +752,7 @@ class StateApp {
scheduleBackgroundWork(context, interval != 0, interval);
Logger.i(TAG, "MainApp Started: Initialize [AutoBackup]");
Settings.instance.backup.didAskAutoBackup = true; //Some users have issues with it
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)) {
UIDialogs.toast("Missing general directory");
@@ -681,7 +769,6 @@ class StateApp {
Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save();
});
*/
}
else if(Settings.instance.backup.didAskAutoBackup && Settings.instance.backup.shouldAutomaticBackup() && !Settings.instance.storage.isStorageMainValid(context)) {
if(context is IWithResultLauncher) {
@@ -718,15 +805,24 @@ class StateApp {
if(StateHistory.instance.shouldMigrateLegacyHistory())
StateHistory.instance.migrateLegacyHistory();
StateAnnouncement.instance.deleteAnnouncement("plugin-update")
scopeOrNull?.launch(Dispatchers.IO) {
val updateAvailable = StatePlugins.instance.checkForUpdates()
withContext(Dispatchers.Main) {
if (updateAvailable.isNotEmpty()) {
val toNotify = mutableListOf<Pair<SourcePluginConfig, SourcePluginConfig>>();
for(update in updateAvailable) {
if(!StatePlatform.instance.isClientEnabled(update.first.id))
continue;
val descriptor = StatePlugins.instance.getPlugin(update.first.id);
if(descriptor?.appSettings?.automaticUpdate == true)
StateAnnouncement.instance.tryAutoUpdatePlugin(update.first, update.second);
else
toNotify.add(update);
}
if(toNotify.isNotEmpty()) {
UIDialogs.appToast(
ToastView.Toast(updateAvailable
ToastView.Toast(toNotify
.map { " - " + it.first.name }
.joinToString("\n"),
true,
@@ -734,11 +830,8 @@ class StateApp {
"Plugin updates available"
));
for(update in updateAvailable)
if(StatePlatform.instance.isClientEnabled(update.first.id)) {
//UIDialogs.showPluginUpdateDialog(context, update.first, update.second);
StateAnnouncement.instance.registerPluginUpdate(update.first, update.second);
}
for(update in toNotify)
StateAnnouncement.instance.registerPluginUpdate(update.first, update.second);
}
}
}
@@ -49,6 +49,33 @@ class StateBackup {
private val _autoBackupLock = Object();
private val AUTO_MAGIC = byteArrayOf(
0x11.toByte(), 0x22.toByte(), 0x33.toByte(), 0x44.toByte()
)
private val AUTO_MARKER = byteArrayOf(
'G'.code.toByte(), 'J'.code.toByte()
)
private const val AUTO_FORMAT_VERSION: Byte = 1
private const val FLAG_ENCRYPTED: Byte = 0x01
private fun ByteArray.startsWithZipSignature(): Boolean =
this.size >= 2 && this[0] == 0x50.toByte() && this[1] == 0x4B.toByte()
private fun ByteArray.hasAutoMagic(): Boolean =
this.size >= 4 &&
this[0] == AUTO_MAGIC[0] &&
this[1] == AUTO_MAGIC[1] &&
this[2] == AUTO_MAGIC[2] &&
this[3] == AUTO_MAGIC[3]
private fun ByteArray.hasNewAutoHeader(): Boolean =
this.size >= 8 &&
this.hasAutoMagic() &&
this[4] == AUTO_MARKER[0] &&
this[5] == AUTO_MARKER[1]
private fun getAutomaticBackupDocumentFiles(context: Context, create: Boolean = false): Pair<DocumentFile?, DocumentFile?> {
if(!Settings.instance.storage.isStorageMainValid(context))
return Pair(null, null);
@@ -76,14 +103,13 @@ class StateBackup {
);
}
private fun getAutomaticBackupPassword(customPassword: String? = null): String {
val password = customPassword ?: Settings.instance.backup.autoBackupPassword ?: "";
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;
private fun requireLegacyBackupPassword(password: String): String {
val pbytes = password.toByteArray()
if (pbytes.size < 4 || pbytes.size > 32)
throw IllegalStateException("Password must be at least 4 bytes and smaller than 32 bytes")
return password
}
fun hasAutomaticBackup(): Boolean {
val context = StateApp.instance.contextOrNull ?: return false;
if(!Settings.instance.storage.isStorageMainValid(context))
@@ -106,8 +132,6 @@ class StateBackup {
val data = export();
val zip = data.asZip();
//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");
@@ -118,7 +142,8 @@ class StateBackup {
val exportFile = backupFiles.first;
if (exportFile?.exists() == true && backupFiles.second != null)
exportFile.copyTo(context, backupFiles.second!!);
exportFile!!.writeBytes(context, encryptedZip);
val backupBytes = AUTO_MAGIC + AUTO_MARKER + byteArrayOf(AUTO_FORMAT_VERSION, 0x00.toByte()) + zip
exportFile!!.writeBytes(context, backupBytes)
Settings.instance.backup.lastAutoBackupTime = OffsetDateTime.now(); //OffsetDateTime.now();
Settings.instance.save();
@@ -137,69 +162,105 @@ class StateBackup {
//TODO: This goes has recently changed to use DocumentFiles and DocumentTree, and might need additional checks/edgecases covered.
fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false) {
if(ifExists && !hasAutomaticBackup()) {
Logger.i(TAG, "No AutoBackup exists, not restoring");
return;
if (ifExists && !hasAutomaticBackup()) {
Logger.i(TAG, "No AutoBackup exists, not restoring")
return
}
Logger.i(TAG, "Starting AutoBackup restore");
synchronized(_autoBackupLock) {
Logger.i(TAG, "Starting AutoBackup restore")
var permissionRequest: Pair<IWithResultLauncher, android.net.Uri?>? = null
val backupBytesEncrypted: ByteArray? = synchronized(_autoBackupLock) {
val backupFiles = getAutomaticBackupDocumentFiles(StateApp.instance.context, false)
val backupFiles = getAutomaticBackupDocumentFiles(StateApp.instance.context, false);
fun read(doc: DocumentFile?): ByteArray? = doc?.readBytes(context)
try {
if (backupFiles.first?.exists() != true)
throw IllegalStateException("Backup file does not exist");
throw IllegalStateException("Backup file does not exist")
val backupBytesEncrypted = backupFiles.first!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.first?.uri}]");
importEncryptedZipBytes(context, scope, backupBytesEncrypted, password);
Logger.i(TAG, "Finished AutoBackup restore");
}
catch (exSec: FileNotFoundException) {
Logger.e(TAG, "Failed to access backup file", exSec);
val activity = if(StateApp.instance.activity != null)
StateApp.instance.activity
else if(StateApp.instance.isMainActive)
StateApp.instance.contextOrNull;
else null;
if(activity != null) {
if(activity is IWithResultLauncher)
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", "Allows restoring of a backup", backupFiles.first?.parentFile?.uri) {
if(it != null) {
val customFiles = StateBackup.getAutomaticBackupDocumentFiles(activity);
if(customFiles.first != null && customFiles.first!!.isFile && customFiles.first!!.exists() && customFiles.first!!.canRead())
restoreAutomaticBackup(context, scope, password, ifExists);
}
};
read(backupFiles.first) ?: throw IllegalStateException("Could not read stream of [${backupFiles.first?.uri}]")
} catch (ex: Throwable) {
if (ex is FileNotFoundException || ex is SecurityException) {
val activity = (StateApp.instance.activity as? IWithResultLauncher)
?: (if (StateApp.instance.isMainActive) StateApp.instance.contextOrNull as? IWithResultLauncher else null)
if (activity != null) {
permissionRequest = Pair(activity, backupFiles.first?.parentFile?.uri)
return@synchronized null
}
}
// Otherwise, try the .old file
if (backupFiles.second?.exists() == true) {
read(backupFiles.second) ?: throw IllegalStateException("Could not read stream of [${backupFiles.second?.uri}]")
} else {
throw ex
}
}
catch (ex: Throwable) {
Logger.e(TAG, "Failed main AutoBackup restore", ex)
if (backupFiles.second?.exists() != true)
throw ex;
val backupBytesEncrypted = backupFiles.second!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.second?.uri}]");
importEncryptedZipBytes(context, scope, backupBytesEncrypted, password);
Logger.i(TAG, "Finished AutoBackup restore");
}
}
if (backupBytesEncrypted == null && permissionRequest != null) {
val (activity, initialUri) = permissionRequest
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", "Allows restoring of a backup", initialUri) { uri ->
if (uri != null) {
scope.launch(Dispatchers.IO) {
restoreAutomaticBackup(context, scope, password, ifExists)
}
}
}
return
}
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);
if (backupBytesEncrypted.startsWithZipSignature()) {
importZipBytes(context, scope, backupBytesEncrypted)
return
}
importZipBytes(context, scope, backupBytes);
// New unencrypted header (magic + "GJ" + format + flags)
if (backupBytesEncrypted.hasNewAutoHeader()) {
val formatVersion = backupBytesEncrypted[6].toInt()
val flags = backupBytesEncrypted[7].toInt()
var offset = 8
if (formatVersion != AUTO_FORMAT_VERSION.toInt()) {
throw IllegalStateException("Unsupported backup format version: $formatVersion")
}
val isEncrypted = (flags and FLAG_ENCRYPTED.toInt()) != 0
if (!isEncrypted) {
val zipBytes = backupBytesEncrypted.copyOfRange(offset, backupBytesEncrypted.size)
importZipBytes(context, scope, zipBytes)
return
}
throw IllegalStateException("Encrypted backups with new header are not supported")
}
// Old encrypted v1+ header (magic + providerVersion + ciphertext)
if (backupBytesEncrypted.hasAutoMagic()) {
if (backupBytesEncrypted.size < 6) {
throw IllegalStateException("Invalid backup: too small")
}
val version = backupBytesEncrypted[4].toInt()
if (version != GPasswordEncryptionProvider.version) {
throw Exception("Invalid encryption version")
}
val ciphertext = backupBytesEncrypted.copyOfRange(5, backupBytesEncrypted.size)
val plaintextZip = GPasswordEncryptionProvider.instance.decrypt(ciphertext, requireLegacyBackupPassword(password))
importZipBytes(context, scope, plaintextZip)
return
}
// Old encrypted v0 (no magic)
val plaintextZip = GPasswordEncryptionProviderV0(requireLegacyBackupPassword(password).padStart(32, '9')).decrypt(backupBytesEncrypted)
importZipBytes(context, scope, plaintextZip)
}
fun saveExternalBackup(activity: IWithResultLauncher) {
@@ -234,6 +295,47 @@ class StateBackup {
}
}
suspend fun requiresPasswordForAutomaticBackup(context: Context): Boolean = withContext(Dispatchers.IO) {
val files = getAutomaticBackupDocumentFiles(context, create = false)
// Prefer main, fallback to .old
val doc = when {
files.first?.exists() == true -> files.first
files.second?.exists() == true -> files.second
else -> return@withContext true // if nothing exists, keep old behavior
} ?: return@withContext true
val header = try {
context.contentResolver.openInputStream(doc.uri)?.use { input ->
val buf = ByteArray(16)
val n = input.read(buf)
if (n <= 0) ByteArray(0) else buf.copyOf(n)
} ?: return@withContext true
} catch (_: Throwable) {
return@withContext true
}
// Raw zip ("PK") => not encrypted
if (header.size >= 2 && header[0] == 0x50.toByte() && header[1] == 0x4B.toByte()) {
return@withContext false
}
// New unencrypted header (magic + "GJ" + format + flags)
if (header.size >= 8 && header[0] == AUTO_MAGIC[0] && header[1] == AUTO_MAGIC[1] && header[2] == AUTO_MAGIC[2] && header[3] == AUTO_MAGIC[3] && header[4] == AUTO_MARKER[0] && header[5] == AUTO_MARKER[1]) {
val flags = header[7].toInt()
val isEncrypted = (flags and FLAG_ENCRYPTED.toInt()) != 0
return@withContext isEncrypted
}
// Old encrypted v1+ header (magic + providerVersion + ciphertext) => needs password
if (header.size >= 5 && header[0] == AUTO_MAGIC[0] && header[1] == AUTO_MAGIC[1] && header[2] == AUTO_MAGIC[2] && header[3] == AUTO_MAGIC[3]) {
return@withContext true
}
// Otherwise assume legacy v0 encrypted (no magic) => needs password
return@withContext true
}
fun export(): ExportStructure {
val exportInfo = mapOf(
Pair("version", "1")
@@ -303,186 +405,172 @@ class StateBackup {
var doEnablePlugins = false;
var doImportStores = false;
Logger.i(TAG, "Starting import choices");
UIDialogs.multiShowDialog(context, {
Logger.i(TAG, "Starting import");
if(!doImport)
return@multiShowDialog;
val enabledBefore = StatePlatform.instance.getEnabledClients().map { it.id };
val onConclusion = {
scope.launch(Dispatchers.Main) {
UIDialogs.multiShowDialog(context, {
Logger.i(TAG, "Starting import");
if (!doImport)
return@multiShowDialog;
val enabledBefore = StatePlatform.instance.getEnabledClients().map { it.id };
val onConclusion = {
scope.launch(Dispatchers.IO) {
StatePlatform.instance.selectClients(*enabledBefore.toTypedArray());
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_update_success_251dp, "Import has finished", null, null,0, UIDialogs.Action("Ok", {}));
}
}
};
//TODO: Probably restructure this to be less nested
scope.launch(Dispatchers.IO) {
StatePlatform.instance.selectClients(*enabledBefore.toTypedArray());
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_update_success_251dp,
"Import has finished", null, null, 0, UIDialogs.Action("Ok", {}));
}
}
};
//TODO: Probably restructure this to be less nested
scope.launch(Dispatchers.IO) {
try {
if (doImportSettings && export.settings != null) {
Logger.i(TAG, "Importing settings");
try {
Settings.replace(export.settings);
}
catch(ex: Throwable) {
UIDialogs.toast(context, "Failed to import settings\n(" + ex.message + ")");
}
}
val afterPluginInstalls = {
scope.launch(Dispatchers.IO) {
if (doEnablePlugins) {
val availableClients = StatePlatform.instance.getEnabledClients().toMutableList();
availableClients.addAll(StatePlatform.instance.getAvailableClients().filter { !availableClients.contains(it) });
Logger.i(TAG, "Import enabling plugins [${availableClients.map{it.name}.joinToString(", ")}]");
StatePlatform.instance.updateAvailableClients(context, false);
StatePlatform.instance.selectClients(*availableClients.map { it.id }.toTypedArray());
try {
if (doImportSettings && export.settings != null) {
Logger.i(TAG, "Importing settings");
try {
Settings.replace(export.settings);
} catch (ex: Throwable) {
UIDialogs.toast(
context,
"Failed to import settings\n(" + ex.message + ")"
);
}
if(doImportPluginSettings) {
for(settings in export.pluginSettings) {
Logger.i(TAG, "Importing Plugin settings [${settings.key}]");
StatePlugins.instance.setPluginSettings(settings.key, settings.value);
}
val afterPluginInstalls = {
scope.launch(Dispatchers.IO) {
if (doEnablePlugins) {
val availableClients = StatePlatform.instance.getEnabledClients().toMutableList();
availableClients.addAll(StatePlatform.instance.getAvailableClients().filter { !availableClients.contains(it) });
Logger.i(TAG, "Import enabling plugins [${availableClients.map { it.name }.joinToString(", ")}]");
StatePlatform.instance.updateAvailableClients(context, false);
StatePlatform.instance.selectClients(*availableClients.map { it.id }.toTypedArray());
}
}
val toAwait = export.stores.map { it.key }.toMutableList();
if(doImportStores) {
for(store in export.stores) {
Logger.i(TAG, "Importing store [${store.key}]");
if(store.key == "history") {
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import History", "Would you like to import history?", null, 0,
UIDialogs.Action("No", {
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Yes", {
for(historyStr in store.value) {
try {
val histObj = HistoryVideo.fromReconString(historyStr) { url ->
return@fromReconString export.cache?.videos?.firstOrNull { it.url == url };
if (doImportPluginSettings) {
for (settings in export.pluginSettings) {
Logger.i(TAG, "Importing Plugin settings [${settings.key}]");
StatePlugins.instance.setPluginSettings(settings.key, settings.value);
}
}
val toAwait = export.stores.map { it.key }.toMutableList();
if (doImportStores) {
for (store in export.stores) {
Logger.i(TAG, "Importing store [${store.key}]");
if (store.key == "history") {
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import History", "Would you like to import history?", null, 0,
UIDialogs.Action("No", {
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Yes", {
scope.launch(Dispatchers.IO) {
for (historyStr in store.value) {
try {
val histObj = HistoryVideo.fromReconString(historyStr) { url -> return@fromReconString export.cache?.videos?.firstOrNull { it.url == url }; }
val hist = StateHistory.instance.getHistoryByVideo(histObj.video, true, histObj.date);
if (hist != null)
StateHistory.instance.updateHistoryPosition(histObj.video, hist, true, histObj.position, histObj.date, false, histObj.playlistId);
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to import subscription group", ex);
}
}
val hist = StateHistory.instance.getHistoryByVideo(histObj.video, true, histObj.date);
if(hist != null)
StateHistory.instance.updateHistoryPosition(histObj.video, hist, true, histObj.position, histObj.date, false, histObj.playlistId);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to import subscription group", ex);
}, UIDialogs.ActionStyle.PRIMARY)
)
}
} else if (store.key == "subscription_groups") {
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import Subscription Groups", "Would you like to import subscription groups?\nExisting groups with the same id will be overridden!", null, 0,
UIDialogs.Action("No", {
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Yes", {
scope.launch(Dispatchers.IO) {
for (groupStr in store.value) {
try {
val group = Json.decodeFromString<SubscriptionGroup>(groupStr);
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
if (existing != null)
StateSubscriptionGroups.instance.deleteSubscriptionGroup(existing.id, false);
StateSubscriptionGroups.instance.updateSubscriptionGroup(group);
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to import subscription group", ex);
}
}
}
}, UIDialogs.ActionStyle.PRIMARY)
)
}
} else {
val relevantStore = availableStores.find { it.name == store.key };
if (relevantStore == null) {
Logger.w(TAG, "Unknown store [${store.key}] import");
continue;
}
withContext(Dispatchers.Main) {
UIDialogs.showImportDialog(context, relevantStore, store.key, store.value, export.cache) {
synchronized(toAwait) {
toAwait.remove(store.key);
if (toAwait.isEmpty())
onConclusion();
}
}, UIDialogs.ActionStyle.PRIMARY))
}
}
else if(store.key == "subscription_groups") {
withContext(Dispatchers.Main) {
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import Subscription Groups", "Would you like to import subscription groups?\nExisting groups with the same id will be overridden!", null, 0,
UIDialogs.Action("No", {
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Yes", {
for(groupStr in store.value) {
try {
val group = Json.decodeFromString<SubscriptionGroup>(groupStr);
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
if(existing != null)
StateSubscriptionGroups.instance.deleteSubscriptionGroup(existing.id, false);
StateSubscriptionGroups.instance.updateSubscriptionGroup(group);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to import subscription group", ex);
}
}
}, UIDialogs.ActionStyle.PRIMARY))
}
}
else {
val relevantStore = availableStores.find { it.name == store.key };
if (relevantStore == null) {
Logger.w(TAG, "Unknown store [${store.key}] import");
continue;
}
withContext(Dispatchers.Main) {
UIDialogs.showImportDialog(context, relevantStore, store.key, store.value, export.cache) {
synchronized(toAwait) {
toAwait.remove(store.key);
if(toAwait.isEmpty())
onConclusion();
}
};
};
}
}
}
}
}
}
}
if (doImportPlugins) {
Logger.i(TAG, "Importing plugins");
StatePlugins.instance.installPlugins(context, scope, unknownPlugins.map { it.value }) {
if (doImportPlugins) {
Logger.i(TAG, "Importing plugins");
StatePlugins.instance.installPlugins(context, scope, unknownPlugins.map { it.value }) {
afterPluginInstalls();
}
} else
afterPluginInstalls();
}
} catch (ex: Throwable) {
Logger.e(TAG, "Import failed", ex);
UIDialogs.showGeneralErrorDialog(context, "Import failed", ex);
}
else
afterPluginInstalls();
}
catch(ex: Throwable) {
Logger.e(TAG, "Import failed", ex);
UIDialogs.showGeneralErrorDialog(context, "Import failed", ex);
}
}
},
UIDialogs.Descriptor(R.drawable.ic_move_up,
"Do you want to import data?",
"Several dialogs will follow asking individual parts",
"Settings: ${export.settings != null}\n" +
"Plugins: ${unknownPlugins.size}\n" +
"Plugin Settings: ${export.pluginSettings.size}\n" +
export.stores.map { "${it.key}: ${it.value.size}" }.joinToString("\n").trim()
, 1,
UIDialogs.Action("Import", {
doImport = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("Cancel", { doImport = false})
),
if(export.settings != null) UIDialogs.Descriptor(R.drawable.ic_settings,
"Would you like to import settings",
"These are the settings that configure how your app works",
null, 0,
UIDialogs.Action("Yes", {
doImportSettings = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport } else null,
if(unknownPlugins.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources,
"Would you like to import plugins?",
"Your import contains the following plugins",
unknownPlugins.map { it.value }.joinToString("\n"), 1,
UIDialogs.Action("Yes", {
doImportPlugins = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport } else null,
if(export.pluginSettings.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources,
"Would you like to import plugin settings?",
null, null, 1,
UIDialogs.Action("Yes", {
doImportPluginSettings = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport } else null,
UIDialogs.Descriptor(R.drawable.ic_sources,
"Would you like to enable all plugins?",
"Enabling all plugins ensures all required plugins are available during import",
null, 0,
UIDialogs.Action("Yes", {
doEnablePlugins = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport },
if(export.stores.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_move_up,
"Would you like to import stores",
"Stores contain playlists, watch later, subscriptions, etc",
null, 0,
UIDialogs.Action("Yes", {
doImportStores = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport } else null
);
},
UIDialogs.Descriptor(R.drawable.ic_move_up, "Do you want to import data?", "Several dialogs will follow asking individual parts",
"Settings: ${export.settings != null}\n" +
"Plugins: ${unknownPlugins.size}\n" +
"Plugin Settings: ${export.pluginSettings.size}\n" +
export.stores.map { "${it.key}: ${it.value.size}" }.joinToString("\n").trim(),
1,
UIDialogs.Action("Import", {
doImport = true;
}, UIDialogs.ActionStyle.PRIMARY),
UIDialogs.Action("Cancel", { doImport = false })
),
if (export.settings != null) UIDialogs.Descriptor(R.drawable.ic_settings, "Would you like to import settings", "These are the settings that configure how your app works", null, 0,
UIDialogs.Action("Yes", {
doImportSettings = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport } else null,
if (unknownPlugins.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources, "Would you like to import plugins?", "Your import contains the following plugins", unknownPlugins.map { it.value }.joinToString("\n"), 1,
UIDialogs.Action("Yes", {
doImportPlugins = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport } else null,
if (export.pluginSettings.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources, "Would you like to import plugin settings?", null, null, 1,
UIDialogs.Action("Yes", {
doImportPluginSettings = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport } else null,
UIDialogs.Descriptor(R.drawable.ic_sources, "Would you like to enable all plugins?", "Enabling all plugins ensures all required plugins are available during import", null, 0,
UIDialogs.Action("Yes", {
doEnablePlugins = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport },
if (export.stores.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_move_up, "Would you like to import stores", "Stores contain playlists, watch later, subscriptions, etc", null, 0,
UIDialogs.Action("Yes", {
doImportStores = true;
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
).withCondition { doImport } else null
);
}
}
fun importTxt(context: MainActivity, text: String, allowFailure: Boolean = false): Boolean {
@@ -483,9 +483,9 @@ class StateDownloads {
var localVideos = StateDownloads.instance.getDownloadedVideosPlaylist(playlistId);
if(playlist != null) {
val missing = playlist.videos
.filter { vid -> !localVideos.any { it.id.value == null || it.id.value == vid.id.value } }
.map { getCachedVideo(it.id) }
.filterNotNull();
.filter { vid -> !localVideos.any { it.id.value == null || it.id.value == vid.id.value } }
.map { getCachedVideo(it.id) }
.filterNotNull();
if(missing.size > 0)
localVideos = localVideos + missing;
};
@@ -500,7 +500,6 @@ class StateDownloads {
for (video in localVideos) {
withContext(Dispatchers.Main) {
it.setText("Exporting videos...(${i}/${localVideos.size})");
//it.setProgress(i.toDouble() / localVideos.size);
}
try {
@@ -18,7 +18,14 @@ class StatePayment : PaymentState(if(!isTestingPayment) VERIFICATION_PUBLIC_KEY
}
companion object {
private val VERIFICATION_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzJqqETLa42xw4AfbNOLQolMdMiGgg8DAC4RXEcH4/gytLhaqp1XsjiiMkADi1C7sDtGj6kOuAuQkqXQKpZ2dJSZsO+GPyop6DmgfAM6MQgOgFUpwsb3Lt3SvskJcls8MeOC+jg+GjjcuJI8qOfYevj4/7wAOpqzAwocTYnJivlK5nrC+qNtUC2HZX93OVu69aU5yvA1SQe9GiiU7vBld+CbzHxTcABCK/THu/BpLtGx0M7W3HNMKK1Z79dopCL9ZZWbWdkGDY8Zf39Gn/WVrs5elBvPzU+AfNYty77vx2r+sKgyohlbz4KVYpnw8HfawKcwuRE/GUyD3F2hUcXy8dQIDAQAB";
private val VERIFICATION_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzJqqETLa42xw4AfbNOLQ" +
"olMdMiGgg8DAC4RXEcH4/gytLhaqp1XsjiiMkADi1C7sDtGj6kOuAuQkqXQKpZ2d" +
"JSZsO+GPyop6DmgfAM6MQgOgFUpwsb3Lt3SvskJcls8MeOC+jg+GjjcuJI8qOfYe" +
"vj4/7wAOpqzAwocTYnJivlK5nrC+qNtUC2HZX93OVu69aU5yvA1SQe9GiiU7vBld" +
"+CbzHxTcABCK/THu/BpLtGx0M7W3HNMKK1Z79dopCL9ZZWbWdkGDY8Zf39Gn/WVr" +
"s5elBvPzU+AfNYty77vx2r+sKgyohlbz4KVYpnw8HfawKcwuRE/GUyD3F2hUcXy8" +
"dQIDAQAB"
private val VERIFICATION_PUBLIC_KEY_TESTING = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqyDuxsRtD5gmBoLCNoZa" +
"XSRTwyUxgzcPHzLZkvomXVSQqzD+3aOKngcTKAZ83rm4GvoyMlBukxQMLShannSx" +
"k8GQGTCT7VStQKNc4lKVER5ASB6aEaypaFMIYI3rXN1xLF1LqY/j7cu5GgMsvAuU" +
@@ -34,4 +41,4 @@ class StatePayment : PaymentState(if(!isTestingPayment) VERIFICATION_PUBLIC_KEY
return _instance!!;
};
}
}
}
@@ -28,6 +28,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.api.media.platforms.local.LocalClient
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
@@ -181,11 +182,14 @@ class StatePlatform {
}
withContext(Dispatchers.IO) {
var toDisables = mutableListOf<IPlatformClient>();
val toDispose = mutableListOf<IPlatformClient>();
var enabled: Array<String>;
synchronized(_clientsLock) {
for(e in _enabledClients) {
toDisables.add(e);
val previousAvailable = _availableClients.toList();
val reusableByDescriptor = HashMap<SourcePluginDescriptor, JSClient>();
for (prev in previousAvailable) {
if (prev is JSClient)
reusableByDescriptor[prev.descriptor] = prev;
}
_enabledClients.clear();
@@ -199,18 +203,38 @@ class StatePlatform {
StatePlugins.instance.installMissingEmbeddedPlugins(context);
for (plugin in StatePlugins.instance.getPlugins()) {
_icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?:
ImageVariable(plugin.config.absoluteIconUrl, null);
_iconsByName[plugin.config.name.lowercase()] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?:
ImageVariable(plugin.config.absoluteIconUrl, null);
try {
val reused = reusableByDescriptor[plugin];
val isReused = reused != null && reused.descriptor === plugin;
val client: JSClient = if (isReused) {
reused!!;
} else {
JSClient(context, plugin).also { fresh ->
fresh.onCaptchaException.subscribe { c, ex ->
StateApp.instance.handleCaptchaException(c, ex);
}
}
}
val client = JSClient(context, plugin);
client.onCaptchaException.subscribe { c, ex ->
StateApp.instance.handleCaptchaException(c, ex);
_icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?:
ImageVariable(plugin.config.absoluteIconUrl, null);
_iconsByName[plugin.config.name.lowercase()] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?:
ImageVariable(plugin.config.absoluteIconUrl, null);
_availableClients.add(client);
if (isReused)
reusableByDescriptor.remove(plugin);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to initialize plugin [${plugin.config.name}]", ex);
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
UIDialogs.toast("Plugin ${plugin.config.name} failed to load\n${ex.message}");
}
}
_availableClients.add(client);
}
toDispose.addAll(reusableByDescriptor.values);
if(_availableClients.distinctBy { it.id }.count() < _availableClients.size) {
val dups = _availableClients.filter { x-> _availableClients.count { it.id == x.id } > 1 };
val overrideClients = _availableClients.distinctBy { it.id }
@@ -236,7 +260,7 @@ class StatePlatform {
}
selectClients(*enabled);
for(toDisable in toDisables) {
for(toDisable in toDispose) {
launch(Dispatchers.IO) {
try {
toDisable.disable();
@@ -1136,4 +1160,4 @@ class StatePlatform {
}
}
}
}
}
@@ -44,6 +44,7 @@ import kotlin.streams.asSequence
* Used to maintain subscriptions
*/
class StateSubscriptions {
private val _subscriptions = FragmentedStorage.storeJson<Subscription>("subscriptions")
.withUnique { it.channel.url }
.withRestore(object: ReconstructStore<Subscription>(){
@@ -489,4 +490,4 @@ class StateSubscriptions {
}
}
}
}
}
@@ -40,6 +40,8 @@ import java.time.OffsetDateTime
import java.util.concurrent.ExecutionException
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import kotlin.system.measureTimeMillis
abstract class SubscriptionsTaskFetchAlgorithm(
@@ -125,7 +127,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val timeTotal = measureTimeMillis {
for(task in forkTasks) {
try {
val result = task.get();
val result = task.get(TASK_TIMEOUT_S, TimeUnit.SECONDS);
if(result != null) {
if(result.pager != null) {
taskResults.add(result);
@@ -148,6 +150,10 @@ abstract class SubscriptionsTaskFetchAlgorithm(
} else {
throw ex.cause ?: ex;
}
} catch (ex: TimeoutException) {
Logger.w(TAG, "Subscription task timed out after ${TASK_TIMEOUT_S}s, abandoning");
task.cancel(true);
exs.add(ex);
};
}
@@ -382,4 +388,8 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val pager: IPager<IPlatformContent>?,
val exception: Throwable?
)
companion object {
private const val TASK_TIMEOUT_S = 90L;
}
}
@@ -41,7 +41,7 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton {
when (d.connectionState) {
CastConnectionState.CONNECTED -> setColorFilter(activeColor)
CastConnectionState.CONNECTING -> setColorFilter(connectingColor)
CastConnectionState.DISCONNECTED -> setColorFilter(activeColor)
CastConnectionState.DISCONNECTED -> setColorFilter(inactiveColor)
}
} else {
setColorFilter(inactiveColor);
@@ -50,7 +50,7 @@ class FieldForm : LinearLayout {
}
}
fun updateSettingsVisibility(group: GroupField? = null) {
fun updateSettingsVisibility(group: GroupField? = null, allowEmptyGroups: Boolean = false) {
val settings = group?.getFields() ?: _fields;
val query = _editSearch.text.toString().lowercase();
@@ -58,7 +58,8 @@ class FieldForm : LinearLayout {
val isGroupMatch = query.isEmpty() || group?.searchContent?.lowercase()?.contains(query) == true;
for(field in settings) {
if(field is GroupField) {
updateSettingsVisibility(field);
if(!allowEmptyGroups)
updateSettingsVisibility(field);
} else if(field is View && field.descriptor != null) {
if(field.isAdvanced && !_showAdvancedSettings)
{
@@ -73,15 +74,21 @@ class FieldForm : LinearLayout {
}
}
}
else if(field is View) {
if(field.isAdvanced && !_showAdvancedSettings)
field.visibility = View.GONE;
else
field.visibility = VISIBLE;
}
}
if(group != null) {
group.visibility = if (groupVisible) View.VISIBLE else View.GONE;
}
}
fun setShowAdvancedSettings(show: Boolean) {
fun setShowAdvancedSettings(show: Boolean, allowEmptyGroups: Boolean = false) {
_showAdvancedSettings = show;
updateSettingsVisibility();
updateSettingsVisibility(null, allowEmptyGroups);
}
fun setSearchQuery(query: String) {
_editSearch.setText(query);
@@ -141,7 +148,9 @@ class FieldForm : LinearLayout {
}
fun fromPluginSettings(settings: List<SourcePluginConfig.Setting>, values: HashMap<String, String?>, groupTitle: String? = null, groupDescription: String? = null) {
_fieldsContainer.removeAllViews();
val newFields = getFieldsFromPluginSettings(context, settings, values);
val newFields = getFieldsFromPluginSettings(context, settings, values, {
setShowAdvancedSettings(it, true);
});
if (newFields.isEmpty()) {
return;
}
@@ -157,6 +166,7 @@ class FieldForm : LinearLayout {
_fieldsContainer.addView(v);
}
_fields = newFields.map { it.second };
updateSettingsVisibility(null, true);
} else {
for(field in newFields) {
finalizePluginSettingField(field.first, field.second, newFields);
@@ -164,6 +174,8 @@ class FieldForm : LinearLayout {
val group = GroupField(context, groupTitle, groupDescription)
.withFields(newFields.map { it.second });
_fieldsContainer.addView(group as View);
_fields = newFields.map { it.second };
updateSettingsVisibility(null, true);
}
}
private fun finalizePluginSettingField(setting: SourcePluginConfig.Setting, field: IField, others: List<Pair<SourcePluginConfig.Setting, IField>>) {
@@ -234,7 +246,7 @@ class FieldForm : LinearLayout {
private val _json = Json;
fun getFieldsFromPluginSettings(context: Context, settings: List<SourcePluginConfig.Setting>, values: HashMap<String, String?>): List<Pair<SourcePluginConfig.Setting, IField>> {
fun getFieldsFromPluginSettings(context: Context, settings: List<SourcePluginConfig.Setting>, values: HashMap<String, String?>, onAdvancedChanged: ((newVal: Boolean)->Unit)? = null): List<Pair<SourcePluginConfig.Setting, IField>> {
val fields = mutableListOf<Pair<SourcePluginConfig.Setting, IField>>()
for(setting in settings) {
@@ -243,6 +255,7 @@ class FieldForm : LinearLayout {
val field = when(setting.type.lowercase()) {
"header" -> {
val groupField = GroupField(context, setting.name, setting.description);
groupField.isAdvanced = (setting.isAdvanced ?: false);
groupField;
}
"boolean" -> {
@@ -252,6 +265,7 @@ class FieldForm : LinearLayout {
field.onChanged.subscribe { _, v, _ ->
values[setting.variableOrName] = _json.encodeToString (v == 1 || v == true);
}
field.isAdvanced = (setting.isAdvanced ?: false);
field;
}
"dropdown" -> {
@@ -261,6 +275,7 @@ class FieldForm : LinearLayout {
field.onChanged.subscribe { _, v, _ ->
values[setting.variableOrName] = v.toString();
}
field.isAdvanced = (setting.isAdvanced ?: false);
field;
}
else null;
@@ -272,6 +287,17 @@ class FieldForm : LinearLayout {
fields.add(Pair(setting, field));
}
}
if(onAdvancedChanged != null && settings.any { it.isAdvanced == true }) {
val setting = SourcePluginConfig.Setting("Show Advanced", "See advanced settings, which may be counter productive to change", "boolean", "false");
val field = ToggleField(context).withValue(setting.name, setting.description, false);
field.onChanged.subscribe { field, new, old ->
onAdvancedChanged?.invoke(new as Boolean);
}
fields.add(Pair(setting, field));
}
return fields;
}
@@ -24,6 +24,7 @@ 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.LoaderView
import com.futo.platformplayer.views.NoResultsView
import com.futo.platformplayer.views.adapters.AnyAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -31,22 +32,29 @@ import kotlinx.coroutines.launch
class NotificationOverlayView: ConstraintLayout {
lateinit var recycler: RecyclerView;
lateinit var emptyView: NoResultsView;
var adapterNotifications: AnyAdapterView<Announcement, ViewHolder>;
constructor(context: Context) : super(context) {
inflate(context, R.layout.overlay_notifications, this)
recycler = findViewById<RecyclerView>(R.id.container_notifications);
emptyView = findViewById<NoResultsView>(R.id.no_results);
adapterNotifications = recycler.asAny<Announcement, ViewHolder>(RecyclerView.VERTICAL, false, {
});
emptyView.setText("Nothing to see here", "You don't have any notifications", R.drawable.ic_notifications)
}
fun onShown(parameter: Any?) {
val announcements = StateAnnouncement.instance.getVisibleAnnouncements();
adapterNotifications.adapter.setData(announcements);
if(announcements.any())
emptyView.isVisible = false;
else
emptyView.isVisible = true;
StateAnnouncement.instance.onAnnouncementChanged.subscribe(this) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
Logger.i("NotificationOverlayView", "Announcements Changed");
@@ -3,15 +3,10 @@ package com.futo.platformplayer.views.overlays.slideup
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.RelativeLayout
import android.widget.TextView
import com.futo.platformplayer.R
import org.w3c.dom.Text
class SlideUpMenuTextInput : LinearLayout {
private lateinit var _root: LinearLayout;
@@ -41,7 +41,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/set_a_password_for_your_daily_backup"
android:text="@string/enable_daily_backup"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
@@ -54,7 +54,7 @@
android:layout_width="match_parent"
android:textColor="#AAAAAA"
android:fontFamily="@font/inter_regular"
android:text="@string/set_a_password_used_to_encrypt_your_daily_backup_that_is_written_to_external_storage"
android:text="@string/automatic_backup_unencrypted_explanation"
android:textAlignment="center"
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp"
@@ -62,26 +62,6 @@
android:layout_height="wrap_content">
</TextView>
<EditText
android:id="@+id/edit_password"
android:layout_width="match_parent"
android:layout_marginLeft="30dp"
android:layout_marginTop="10dp"
android:layout_marginRight="30dp"
android:layout_height="wrap_content"
android:singleLine="true"
android:inputType="textPassword"
android:hint="@string/backup_password" />
<EditText
android:id="@+id/edit_password2"
android:layout_width="match_parent"
android:layout_marginLeft="30dp"
android:layout_marginTop="10dp"
android:layout_marginRight="30dp"
android:layout_height="wrap_content"
android:singleLine="true"
android:inputType="textPassword"
android:hint="@string/repeat_password" />
<LinearLayout
android:layout_width="match_parent"
@@ -107,7 +87,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/stop"
android:text="@string/disable"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
@@ -128,7 +108,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/start"
android:text="@string/enable"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
@@ -4,7 +4,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:background="@color/gray_1d">
@@ -13,9 +13,11 @@
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:paddingTop="40dp">
android:paddingTop="40dp"
android:paddingBottom="24dp">
<ImageView
android:id="@+id/image_icon"
android:layout_width="80dp"
android:layout_height="80dp"
app:srcCompat="@drawable/ic_lock" />
@@ -31,42 +33,57 @@
android:layout_marginStart="30dp"
android:textAlignment="center"
android:layout_marginEnd="30dp" />
<TextView
android:id="@+id/text_reason"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#AAAAAA"
android:fontFamily="@font/inter_regular"
android:text="@string/it_appears_an_automatic_backup_exists_on_your_device_if_you_would_like_to_restore_enter_your_backup_password"
android:textAlignment="center"
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp"
android:textSize="10dp"
android:layout_height="wrap_content">
android:textSize="10dp" />
</TextView>
<EditText
android:id="@+id/edit_password"
<LinearLayout
android:id="@+id/password_container"
android:layout_width="match_parent"
android:layout_marginLeft="30dp"
android:layout_marginTop="10dp"
android:layout_marginRight="30dp"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:singleLine="true"
android:hint="@string/backup_password" />
android:orientation="vertical">
<EditText
android:id="@+id/edit_password"
android:layout_width="match_parent"
android:layout_marginLeft="30dp"
android:layout_marginTop="10dp"
android:layout_marginRight="30dp"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:singleLine="true"
android:hint="@string/backup_password" />
</LinearLayout>
<ProgressBar
android:id="@+id/progress_restore"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:visibility="gone"
style="?android:attr/progressBarStyleLarge" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginTop="28dp"
android:layout_marginBottom="28dp">
android:layout_marginTop="28dp">
<Space
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<Button
android:id="@+id/button_cancel"
android:layout_width="wrap_content"
@@ -77,6 +94,7 @@
android:fontFamily="@font/inter_regular"
android:textColor="@color/colorPrimary"
android:background="@color/transparent" />
<LinearLayout
android:id="@+id/button_start"
android:layout_width="wrap_content"
@@ -86,6 +104,7 @@
android:clickable="true">
<TextView
android:id="@+id/text_start"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/restore"
@@ -99,4 +118,4 @@
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
+2 -2
View File
@@ -35,7 +35,7 @@
android:maxLines="2"
app:layout_constraintLeft_toRightOf="@id/image_video_thumbnail"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintRight_toLeftOf="@+id/button_trash"
app:layout_constraintBottom_toTopOf="@id/text_metadata"
android:layout_marginStart="10dp" />
@@ -51,7 +51,7 @@
android:maxLines="1"
app:layout_constraintTop_toBottomOf="@id/text_name"
app:layout_constraintLeft_toRightOf="@id/image_video_thumbnail"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintRight_toLeftOf="@+id/button_trash"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginStart="10dp" />
@@ -17,6 +17,11 @@
android:layout_width="match_parent"
android:layout_height="1px"
android:background="#181818" />
<com.futo.platformplayer.views.NoResultsView
android:id="@+id/no_results"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/container_notifications"
app:layout_constraintTop_toBottomOf="@id/separator"
+8 -1
View File
@@ -12,6 +12,7 @@
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/videodetail_up_next"
android:layout_width="wrap_content"
@@ -23,18 +24,23 @@
android:includeFontPadding="false"
android:textSize="17dp"
android:text="@string/up_next" />
<TextView
android:id="@+id/videodetail_queue_type"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textColor="@color/gray_ac"
android:fontFamily="@font/inter_extra_light"
android:includeFontPadding="false"
app:layout_constraintLeft_toRightOf="@id/videodetail_up_next"
app:layout_constraintBottom_toBottomOf="@id/videodetail_up_next"
app:layout_constraintRight_toLeftOf="@id/videodetail_queue_position"
android:layout_marginLeft="6dp"
android:maxLines="1"
android:ellipsize="end"
android:textSize="14dp"
android:text="@string/queue" />
<TextView
android:id="@+id/videodetail_queue_position"
android:layout_width="wrap_content"
@@ -47,6 +53,7 @@
android:layout_marginLeft="10dp"
android:textSize="12dp"
tools:text="1/4" />
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
+344 -26
View File
@@ -2,7 +2,7 @@
<resources>
<string name="cast">Übertragen</string>
<string name="search">Suche</string>
<string name="add_to_query">Zur Anfrage hinzufügen</string>
<string name="add_to_query">Zur Warteschlage hinzufügen</string>
<string name="thumbnail">Vorschaubild</string>
<string name="channel_image">Kanalbild</string>
<string name="add_to">Hinzufügen zu</string>
@@ -15,7 +15,7 @@
<string name="loading">Lädt</string>
<string name="retry">Wiederholen</string>
<string name="cancel">Abbrechen</string>
<string name="failed_to_retrieve_data_are_you_connected">Datenabruf fehlgeschlagen, sind Sie verbunden?</string>
<string name="failed_to_retrieve_data_are_you_connected">Datenabruf fehlgeschlagen, sind Sie online?</string>
<string name="settings">Einstellungen</string>
<string name="history">Verlauf</string>
<string name="sources">Quellen</string>
@@ -28,7 +28,7 @@
<string name="update">Aktualisieren</string>
<string name="close">Schließen</string>
<string name="never">Nie</string>
<string name="there_is_an_update_available_do_you_wish_to_update">Ein Update ist verfügbar. Möchten Sie aktualisieren?</string>
<string name="there_is_an_update_available_do_you_wish_to_update">Ein Update ist verfügbar, möchten Sie aktualisieren?</string>
<string name="downloading_update">Update wird heruntergeladen…</string>
<string name="installing_update">Update wird installiert…</string>
<string name="done">Fertig</string>
@@ -36,10 +36,10 @@
<string name="failed_to_update_with_error">Update fehlgeschlagen mit Fehler</string>
<string name="general_failure">Allgemeiner Fehler</string>
<string name="aborted">Abgebrochen</string>
<string name="blocked">Blockiert</string>
<string name="conflict">Konflikt</string>
<string name="incompatible">Inkompatibel</string>
<string name="invalid">Ungültig</string>
<string name="blocked">Der Vorgang ist fehlgeschlagen, weil er blockiert wurde</string>
<string name="conflict">Der Vorgang ist fehlgeschlagen, weil er mit einem anderen, bereits auf dem Gerät installierten Paket kollidiert (oder inkonsistent damit ist)</string>
<string name="incompatible">Der Vorgang ist fehlgeschlagen, weil er mit diesem Gerät grundsätzlich nicht kompatibel ist</string>
<string name="invalid">Der Vorgang ist fehlgeschlagen, weil eine oder mehrere APKs ungültig waren</string>
<string name="not_enough_storage">Nicht genügend Speicherplatz</string>
<string name="live_capitalized">LIVE</string>
<string name="live">Live</string>
@@ -93,11 +93,11 @@
<string name="add_source">Quelle hinzufügen</string>
<string name="repository_url">Repository-URL</string>
<string name="script_url">Skript-URL</string>
<string name="source_permissions_explanation">Dies sind die Berechtigungen, die das Plugin zur Funktion benötigt</string>
<string name="source_permissions_explanation">Dies sind die Berechtigungen, die das Plugin zum Funktionieren benötigt</string>
<string name="source_explain_eval_access">Das Plugin hat Zugang zur Eval-Kapazität</string>
<string name="source_explain_script_url">Das Plugin hat Zugriff auf die folgenden Domains</string>
<string name="scan_qr">QR-Code scannen</string>
<string name="scan_qr_explain">Scannen Sie einen QR-Code, um zu installieren</string>
<string name="scan_qr_explain">Scannen Sie zum Installieren einen QR-Code</string>
<string name="enter_url">URL eingeben</string>
<string name="install">Installieren</string>
<string name="no_devices_found_it_may_take_a_while_for_your_device_to_show_up_please_be_patient">Keine Geräte gefunden. Es kann eine Weile dauern, bis Ihr Gerät angezeigt wird. Bitte haben Sie Geduld</string>
@@ -130,7 +130,7 @@
<string name="permissions">Berechtigungen</string>
<string name="security_warnings">Sicherheitswarnungen</string>
<string name="these_are_warnings_of_plugin_behavior_and_implementation">Dies sind Warnungen über das Verhalten und die Implementierung von Plugins</string>
<string name="please_enter_the_captcha_and_close_when_finished">Bitte geben Sie das Captcha ein und schließen Sie, wenn Sie fertig sind</string>
<string name="please_enter_the_captcha_and_close_when_finished">Bitte das Captcha eingeben und schließen, wenn fertig</string>
<string name="close_capitalized">SCHLIESSEN</string>
<string name="submit">Absenden</string>
<string name="restart">Neustart</string>
@@ -178,7 +178,7 @@
<string name="i_already_paid">Ich habe bereits bezahlt</string>
<string name="memberships">Mitgliedschaften</string>
<string name="a_monthly_recurring_payment_with_often">Eine monatlich wiederkehrende Zahlung mit häufig</string>
<string name="additional_perks">zusätzliche Vorteile.</string>
<string name="additional_perks">zusätzlichen Vorteilen.</string>
<string name="a_one_time_payment_to_support_the_creator">Eine einmalige Zahlung zur Unterstützung des Erstellers</string>
<string name="donation">Spende</string>
<string name="downloading">Herunterladen</string>
@@ -243,11 +243,11 @@
<string name="announcement">Ankündigung</string>
<string name="attempt_to_utilize_byte_ranges">Versuch, Byte-Bereiche zu nutzen</string>
<string name="auto_update">Automatische Aktualisierung</string>
<string name="automatic_backup">Automatisches Backup</string>
<string name="automatic_backup">Automatische Sicherung</string>
<string name="background_behavior">Hintergrundverhalten</string>
<string name="background_update">Hintergrundaktualisierung</string>
<string name="background_download">Hintergrunddownload</string>
<string name="backup">Backup</string>
<string name="backup">Sicherung</string>
<string name="browsing">Browsen</string>
<string name="byte_range_concurrency">Byte-Bereichs-Gleichzeitigkeit</string>
<string name="byte_range_download">Byte-Bereichs-Download</string>
@@ -265,7 +265,7 @@
<string name="clears_in_app_browser_cookies">In-App-Browser-Cookies löschen</string>
<string name="configure_browsing_behavior">Browsing-Verhalten konfigurieren</string>
<string name="configure_casting">Casting konfigurieren</string>
<string name="configure_daily_backup_in_case_of_catastrophic_failure">Tägliches Backup im Falle eines katastrophalen Ausfalls konfigurieren</string>
<string name="configure_daily_backup_in_case_of_catastrophic_failure">Tägliche Sicherung im Falle eines katastrophalen Ausfalls konfigurieren</string>
<string name="configure_downloading_of_videos">Herunterladen von Videos konfigurieren</string>
<string name="configure_how_your_home_tab_works_and_feels">Konfigurieren, wie Ihr Start-Tab funktioniert und sich anfühlt</string>
<string name="configure_how_your_subscriptions_works_and_feels">Konfigurieren, wie Ihre Abonnements funktionieren und sich anfühlen</string>
@@ -340,7 +340,7 @@
<string name="clear_all_downloaded">Alle Downloads löschen</string>
<string name="clear_downloads">Downloads löschen</string>
<string name="clear_all_cookies_from_the_cookieManager">Alle Cookies aus dem CookieManager löschen</string>
<string name="crash_me">Stürzen Sie mich ab</string>
<string name="crash_me">Anwendung abstürzen lassen</string>
<string name="crashes_the_application_on_purpose">Absichtliches Abstürzen der Anwendung</string>
<string name="delete_announcements">Ankündigungen löschen</string>
<string name="delete_unresolved">Nicht gelöste löschen</string>
@@ -562,9 +562,9 @@
<string name="signature_is_invalid">Die Signatur ist ungültig</string>
<string name="no_signature_available">Keine Signatur verfügbar</string>
<string name="unsubscribed_from">"Abonnement gekündigt bei "</string>
<string name="you_don_t_have_any_automatic_backups">Sie haben keine automatischen Backups</string>
<string name="an_old_backup_is_available">Ein altes Backup ist verfügbar</string>
<string name="would_you_like_to_restore_this_backup">Möchten Sie dieses Backup wiederherstellen?</string>
<string name="you_don_t_have_any_automatic_backups">Sie haben keine automatischen Sicherungen</string>
<string name="an_old_backup_is_available">Ein alte Sicherung ist verfügbar</string>
<string name="would_you_like_to_restore_this_backup">Möchten Sie diese Sicherung wiederherstellen?</string>
<string name="override">Überschreiben</string>
<string name="data_retry">Datenwiederholung</string>
<string name="no_downloads_available">Keine Downloads verfügbar</string>
@@ -592,13 +592,13 @@
<string name="the_playlist_will_restart_after_the_video_is_finished">Die Playlist wird nach dem Ende des Videos neu gestartet</string>
<string name="end_of_playlist_reached">Ende der Playlist erreicht</string>
<string name="enter_url_explain">Geben Sie eine URL ein, um die Plugin-Konfiguration von dort zu laden.</string>
<string name="buy_text">Es ist weder einfache noch günstige, eine App wie Grayjay, zu Entwikeln und zu Warten…</string>
<string name="buy_text">Grayjay ist nicht einfach oder billig zu entwickeln und zu warten. Wir haben Vollzeit-Ingenieure, die an der App und an den sie umgebenden Systemen arbeiten. Und wird wahrscheinlich nicht so bald wieder Geld einbringen, wenn überhaupt.\n\nFUTOs Mission ist es, dass Open-Source-Software und nicht-bösartige Software-Geschäftspraktiken eine nachhaltige Einkommensquelle für Projekte und ihre Entwickler werden. Aus diesem Grund sind wir dafür, dass die Nutzer tatsächlich für die Software bezahlen.\n\nDeshalb möchte Grayjay, dass Sie für die Software bezahlen.</string>
<string name="an_uncaught_exception_was_thrown_we_re_sorry_for_the_inconvenience">Ein unabgefangene Ausnahme wurde ausgelöst, es tut uns leid für die Unannehmlichkeiten.</string>
<string name="set_a_password_for_your_daily_backup">Legen Sie ein Passwort für Ihr tägliches Backup fest</string>
<string name="items_require_migration_or_are_corrupted_would_you_like_to_restore_them_from_backup_now">Elemente erfordern eine Migration oder sind beschädigt. Möchten Sie sie jetzt aus dem Backup wiederherstellen?</string>
<string name="set_a_password_for_your_daily_backup">Ein Passwort für Ihr tägliche Sicherung festlegen</string>
<string name="items_require_migration_or_are_corrupted_would_you_like_to_restore_them_from_backup_now">Elemente erfordern eine Migration oder sind beschädigt. Möchten Sie sie jetzt aus der Sicherung wiederherstellen?</string>
<string name="recently_used_playlist">Kürzlich verwendete Playlist</string>
<string name="make_a_backup_of_your_identity">Ein Backup Ihrer Identität erstellen</string>
<string name="restore_a_previous_automatic_backup">Ein vorheriges automatisches Backup wiederherstellen</string>
<string name="make_a_backup_of_your_identity">Eine Sicherung Ihrer Identität erstellen</string>
<string name="restore_a_previous_automatic_backup">Eine vorherige automatische Sicherung wiederherstellen</string>
<string name="embedded_plugins_reinstalled_a_reboot_is_recommended">Embedded-Plugins neu installiert, ein Neustart der App ist empfohlen</string>
<string name="injects_a_test_source_config_local_into_v8">Fügt eine Testquellenkonfiguration (lokal) in V8 ein</string>
<string name="do_you_want_to_convert_channel_channelname_to_a_playlist">Möchten Sie den Kanal {channelName} in eine Playlist umwandeln?</string>
@@ -607,6 +607,255 @@
<string name="failed_to_retrieve_playlists">Abrufen von Playlisten fehlgeschlagen.</string>
<string name="subscribed_to">"Abonniert bei "</string>
<string name="select_your_pins_in_order">Wählen Sie Ihre Pins in Reihenfolge aus</string>
<string name="a_store_by_the_creator">Ein Shop vom Ersteller</string>
<string name="add_creator">Ersteller hinzufügen</string>
<string name="add_to_history">Zum Verlauf hinzufügen</string>
<string name="add_to_new_playlist">Zu neuer Playlist hinzufügen</string>
<string name="allow_all_certificates">Alle Zertifikate zulassen</string>
<string name="allow_all_certificates_warning">Dies birgt das Risiko, Ihren gesamten Grayjay-Netzwerkverkehr offenzulegen.</string>
<string name="allow_developer_submit">Entwickler-Übermittlungen zulassen</string>
<string name="allow_developer_submit_description">Erlaubt dem Entwickler, Daten an seinen Server zu senden. Seien Sie vorsichtig, da dies sensible Daten enthalten könnte.</string>
<string name="allow_developer_submit_warning">Stellen Sie sicher, dass Sie dem Entwickler vertrauen. Er könnte Zugriff auf sensible Daten erhalten. Aktivieren Sie dies nur, wenn Sie dem Entwickler bei der Behebung eines Fehlers helfen möchten.</string>
<string name="allow_full_screen_portrait">Vollbild im Hochformat beim Ansehen von horizontalen Videos erlauben</string>
<string name="allow_grayjay_to_handle_links">Grayjay erlauben, Links zu verarbeiten</string>
<string name="allow_ipv6">IPV6 erlauben</string>
<string name="allow_ipv6_description">Wenn das Übertragen über IPV6 erlaubt ist, kann dies in einigen Netzwerken Probleme verursachen</string>
<string name="allow_under_cutout">Video unter Bildschirmausschnitt erlauben</string>
<string name="allow_under_cutout_description">Video erlauben, im Vollbildmodus unter den Bildschirmausschnitt zu gelangen.\nNeustart möglicherweise erforderlich</string>
<string name="already_queued">Bereits in der Warteschlange</string>
<string name="always_allow_reverse_landscape_auto_rotate">Automatische Drehung ins umgekehrte Querformat immer erlauben</string>
<string name="always_allow_reverse_landscape_auto_rotate_description">Es erfolgt immer eine automatische Drehung zwischen den beiden Querformatausrichtungen im Vollbildmodus, auch wenn Sie die automatische Drehung in den Systemeinstellungen deaktivieren.</string>
<string name="always_proxy_requests">Anfragen immer über Proxy leiten</string>
<string name="always_proxy_requests_description">Anfragen beim Übertragen von Daten über das Gerät immer über einen Proxy leiten.</string>
<string name="always_reload_from_cache">Immer aus dem Cache neu laden</string>
<string name="always_reload_from_cache_description">Dies wird nicht empfohlen, ist aber eine mögliche Problemumgehung für einige Probleme.</string>
<string name="app_language">App-Sprache</string>
<string name="automatic_update_setting">Automatische Aktualisierung</string>
<string name="automatic_update_setting_description">Beim Start automatisch aktualisieren, wenn keine Berechtigungen geändert wurden und das Plugin aktiviert ist</string>
<string name="autoplay">Nächstes Video standardmäßig automatisch abspielen</string>
<string name="autoplay_description">Die automatische Wiedergabe des nächsten Videos ist standardmäßig aktiviert, wenn Sie ein Video ansehen</string>
<string name="background_switch_audio">Im Hintergrund zu Audio wechseln</string>
<string name="bad_reputation_comments_fading">Ausblenden von Kommentaren mit schlechtem Ruf</string>
<string name="bad_reputation_comments_fading_description">Ob Kommentare mit sehr schlechtem Ruf ausgeblendet werden sollen. Das Deaktivieren kann die Benutzererfahrung verschlechtern.</string>
<string name="brightness_slider">Helligkeitsregler</string>
<string name="brightness_slider_descr">Wischgeste zum Ändern der Helligkeit aktivieren</string>
<string name="broadcast">Übertragung</string>
<string name="broadcast_description">Gerät erlauben, Anwesenheit zu übertragen</string>
<string name="bypass_rotation_prevention">Drehungsverhinderung umgehen</string>
<string name="bypass_rotation_prevention_description">Ermöglicht Drehung in Nicht-Video-Ansichten.\nWARNUNG: Nicht dafür ausgelegt</string>
<string name="bypass_rotation_prevention_warning">Dies kann zu unerwartetem Verhalten führen und ist größtenteils ungetestet.</string>
<string name="cache">Cache</string>
<string name="can_be_disabled_when_you_are_experiencing_issues">Kann bei Problemen deaktiviert werden</string>
<string name="cd_app_icon">App-Symbol</string>
<string name="cd_button_add">Hinzufügen</string>
<string name="cd_button_add_to_watch_later">Zu „Später ansehen“ hinzufügen</string>
<string name="cd_button_autoplay">Autoplay</string>
<string name="cd_button_back">Zurück-Schaltfläche</string>
<string name="cd_button_clear_search">Suche löschen</string>
<string name="cd_button_close">Schließen</string>
<string name="cd_button_create_playlist">Playlist erstellen</string>
<string name="cd_button_delete">Löschen</string>
<string name="cd_button_download">Herunterladen</string>
<string name="cd_button_edit">Bearbeiten</string>
<string name="cd_button_filter">Filter</string>
<string name="cd_button_fullscreen">Vollbild</string>
<string name="cd_button_help">Hilfe</string>
<string name="cd_button_loop">Wiederholen</string>
<string name="cd_button_minimize">Minimieren</string>
<string name="cd_button_next">Weiter</string>
<string name="cd_button_pause">Pause</string>
<string name="cd_button_play">Wiedergabe</string>
<string name="cd_button_previous">Zurück</string>
<string name="cd_button_replies">Antworten</string>
<string name="cd_button_rotate_lock">Drehung sperren</string>
<string name="cd_button_scan_qr">QR-Code scannen</string>
<string name="cd_button_search">Suchen</string>
<string name="cd_button_settings">Einstellungen</string>
<string name="cd_button_share">Teilen</string>
<string name="cd_button_stop">Stopp</string>
<string name="cd_button_subscribe">Abonnieren</string>
<string name="cd_cast_button">Übertragen-Schaltfläche</string>
<string name="cd_creator_thumbnail">Ersteller-Vorschaubild</string>
<string name="cd_donation_amount">Spendenbetrag</string>
<string name="cd_donation_author_image">Bild des Spendenautors</string>
<string name="cd_download_indicator">Download-Anzeige</string>
<string name="cd_drag_drop">Ziehen und Ablegen</string>
<string name="cd_edit_image">Bild bearbeiten</string>
<string name="cd_icon_history">Verlaufssymbol</string>
<string name="cd_image_device">Gerätesymbol</string>
<string name="cd_image_dislike_icon">Nicht mögen</string>
<string name="cd_image_group">Gruppenbild</string>
<string name="cd_image_like_icon">Mögen</string>
<string name="cd_image_loader">Ladeanzeige</string>
<string name="cd_image_polycentric">Polycentric-Profilbild ändern</string>
<string name="cd_incognito_button">Inkognito-Schaltfläche</string>
<string name="cd_minimize_close">Schließen</string>
<string name="cd_minimize_pause">Pause</string>
<string name="cd_minimize_play">Wiedergabe</string>
<string name="cd_platform_indicator">Plattformanzeige</string>
<string name="cd_search_icon">Suchsymbol</string>
<string name="cd_thumbnail_player_unmute">Stummschaltung aufheben</string>
<string name="cd_update_spinner">Aktualisierungs-Ladekreis</string>
<string name="changelog_plugin_description">Zeigt verfügbare Änderungsprotokolle für aktuelle und frühere Versionen an</string>
<string name="changing_this_field_requires_restart">Das Ändern dieses Feldes erfordert einen Neustart der App.</string>
<string name="channel">Kanal</string>
<string name="chapter_update_fps_description">Genauigkeit der Kapitelaktualisierung ändern, höher könnte mehr Leistung kosten</string>
<string name="chapter_update_fps_title">Kapitelaktualisierungs-FPS</string>
<string name="chapters">Kapitel</string>
<string name="check_disabled_plugin_updates">Deaktivierte Plugins auf Updates prüfen</string>
<string name="check_disabled_plugin_updates_description">Deaktivierte Plugins auf Updates prüfen</string>
<string name="check_for_updates_setting">Auf Updates prüfen</string>
<string name="check_for_updates_setting_description">Ob ein Plugin beim Start auf Updates geprüft werden soll</string>
<string name="check_to_see_if_an_update_is_available">Prüfen, ob ein Update verfügbar ist.</string>
<string name="clear_channel_cache">Kanal-Cache leeren</string>
<string name="clear_channel_cache_description">Löscht alle Inhalte aus dem Cache der abonnierten Kanäle</string>
<string name="clear_external_downloads_directory">Externes Download-Verzeichnis leeren</string>
<string name="clear_hidden">Ausgeblendete Elemente löschen</string>
<string name="clear_hidden_description">Entfernt alle ausgeblendeten Ersteller und Videos und zeigt sie wieder an</string>
<string name="clear_the_external_storage_for_download_files">Externen Speicher für heruntergeladene Dateien leeren</string>
<string name="click_to_go_to_battery_optimization_settings_disabling_battery_optimization_will_prevent_the_os_from_killing_media_sessions">Klicken, um zu den Akkuoptimierungseinstellungen zu gelangen. Das Deaktivieren der Akkuoptimierung verhindert, dass das Betriebssystem Mediensitzungen beendet.</string>
<string name="comments_description">Der Kommentarbereich unter dem Inhalt</string>
<string name="config_url">Konfigurations-URL</string>
<string name="configure_if_historical_time_bar_should_be_shown">Konfigurieren, ob historische Zeitbalken angezeigt werden sollen</string>
<string name="connect_discovered">Gefundene verbinden</string>
<string name="connect_discovered_description">Gerät erlauben, nach bekannten gekoppelten Geräten zu suchen und eine Verbindung herzustellen</string>
<string name="connect_last">Letzte Verbindung versuchen</string>
<string name="connect_last_description">Gerät erlauben, sich automatisch mit dem zuletzt bekannten zu verbinden</string>
<string name="create_new_subgroup">Neue Gruppe erstellen</string>
<string name="current_promotions_by_this_creator">Aktuelle Werbeaktionen dieses Erstellers</string>
<string name="default_comment_section">Standard-Kommentarbereich</string>
<string name="default_recommendations">Empfehlungen als Standard</string>
<string name="default_recommendations_description">Empfehlungen standardmäßig anstelle von Kommentaren anzeigen.</string>
<string name="delete_watchlist_on_finish">Aus „Später ansehen“ löschen, wenn angesehen</string>
<string name="delete_watchlist_on_finish_description">Nachdem Sie ein Video verlassen, das Sie größtenteils angesehen haben, wird es aus „Später ansehen“ entfernt.</string>
<string name="dev_info_channel_cache_size">Kanal-Cache-Größe (Start)</string>
<string name="disable_battery_optimization">Akkuoptimierung deaktivieren</string>
<string name="do_not_ask_again">Nicht erneut fragen</string>
<string name="do_you_want_to_see_the_tutorials_you_can_find_them_at_any_time_through_the_more_button">Möchten Sie die Tutorials sehen? Sie können sie jederzeit über die Mehr-Schaltfläche finden.</string>
<string name="enable_polycentric">Polycentric aktivieren</string>
<string name="enabled_description">Funktion aktivieren</string>
<string name="failed_to_parse_text_file">Fehler beim Parsen der Textdatei</string>
<string name="failed_to_show_settings">Einstellungen konnten nicht angezeigt werden</string>
<string name="fcast">FCast</string>
<string name="fcast_technical_documentation">Technische Dokumentation für FCast</string>
<string name="fcast_website">FCast-Webseite</string>
<string name="fetch_on_tab_opened">Beim Öffnen des Tabs abrufen</string>
<string name="fetch_on_tab_opened_description">Neue Ergebnisse abrufen, wenn der Tab geöffnet wird (wenn noch keine Ergebnisse vorhanden sind, wird das Deaktivieren nicht empfohlen, es sei denn, Sie haben Probleme)</string>
<string name="full_autorotate_lock">Vollständige Sperre der automatischen Drehung</string>
<string name="full_autorotate_lock_description">Verhindert jede Drehung, während die Rotationssperre aktiviert ist (auch das Wechseln zwischen Querformat und umgekehrtem Querformat).</string>
<string name="full_screen_portrait">Vollbild Hochformat</string>
<string name="general">Allgemein</string>
<string name="gesture_controls">Gestensteuerung</string>
<string name="go_back_to_casting_add_dialog">Zurück zum Hinzufügen-Dialog für die Übertragung</string>
<string name="guide">Anleitung</string>
<string name="hide_creator_from_home">Ersteller auf Startseite ausblenden</string>
<string name="history_cache_100">Verlaufs-Cache 100</string>
<string name="how_to_use_fcast_guide">Anleitung zur Verwendung von FCast</string>
<string name="import_data">Daten importieren</string>
<string name="import_data_description">Datei zum Importieren auswählen, unterstützt verschiedene Dateien (Alternative zum direkten Öffnen)</string>
<string name="import_options">Wählen Sie eine der folgenden verfügbaren Importoptionen.</string>
<string name="keep_screen_on">Bildschirm eingeschaltet lassen</string>
<string name="keep_screen_on_while_casting">Bildschirm während der Übertragung eingeschaltet lassen</string>
<string name="language">Sprache</string>
<string name="link_handling">Link-Verarbeitung</string>
<string name="load_more">Mehr laden</string>
<string name="locked_content_description">Dieser Inhalt ist gesperrt</string>
<string name="login_required">Anmeldung erforderlich</string>
<string name="login_to_view_your_comments">Melden Sie sich an, um Ihre Kommentare anzuzeigen</string>
<string name="may_require_restart">Neustart möglicherweise erforderlich</string>
<string name="membership">Mitgliedschaft</string>
<string name="merchandise">Fanartikel</string>
<string name="networking">Netzwerk</string>
<string name="new_playlist">Neue Playlist</string>
<string name="no_sources_installed">Sie haben keine Quellen installiert. Bitte fügen Sie Quellen hinzu, um die App wie vorgesehen zu nutzen.</string>
<string name="not_empty_close">Kommentar ist nicht leer, trotzdem schließen?</string>
<string name="notifications">Benachrichtigungen</string>
<string name="open_the_fcast_website">Die FCast-Webseite öffnen</string>
<string name="pan_option">Schwenken aktivieren</string>
<string name="pan_option_descr">Zwei-Finger-Schwenkgeste aktivieren</string>
<string name="peek_channel_contents">Kanalinhalte kurz anzeigen</string>
<string name="peek_channel_contents_description">Kanalinhalte kurz anzeigen, wenn vom Plugin bei ratenbegrenzten Aufrufen unterstützt, kann die Ladezeit von Abonnements verlängern.</string>
<string name="planned_content_notifications">Benachrichtigungen für geplante Inhalte</string>
<string name="planned_content_notifications_description">Plant erkannte geplante Inhalte als Benachrichtigungen, was zu genaueren Benachrichtigungen für diese Inhalte führt.</string>
<string name="platform_url">Plattform-URL</string>
<string name="play_pause">Wiedergabe/Pause</string>
<string name="play_store_version_does_not_support_default_url_handling">Die Play Store-Version unterstützt keine standardmäßige URL-Verarbeitung.</string>
<string name="playlist_delete_confirmation">Bestätigung zum Löschen der Playlist</string>
<string name="playlist_delete_confirmation_description">Bestätigungsdialog beim Löschen von Medien aus einer Playlist anzeigen</string>
<string name="please_use_at_least_1_character">Bitte verwenden Sie mindestens 1 Zeichen</string>
<string name="plus_tax">" + Steuer"</string>
<string name="polycentric_is_disabled">Polycentric ist deaktiviert</string>
<string name="polycentric_local_cache">Lokales Caching für Polycentric aktivieren</string>
<string name="polycentric_local_cache_description">Speichert Polycentric-Ergebnisse auf dem Gerät zwischen, um Ladezeiten zu verkürzen. Änderung erfordert App-Neustart</string>
<string name="position">Position</string>
<string name="prefer_webm">Webm-Video-Codecs bevorzugen</string>
<string name="prefer_webm_audio">Webm-Audio-Codecs bevorzugen</string>
<string name="prefer_webm_audio_description">Ob der Player Webm-Codecs (Opus) gegenüber MP4-Codecs (AAC) bevorzugen soll, kann zu schlechterer Kompatibilität führen.</string>
<string name="prefer_webm_description">Ob der Player Webm-Codecs (VP9/Opus) gegenüber MP4-Codecs (H.264/AAC) bevorzugen soll, kann zu schlechterer Kompatibilität führen.</string>
<string name="preferred_casting_quality_description">Standardqualität beim Übertragen auf ein externes Gerät</string>
<string name="preferred_metered_quality_description">Standardqualität bei getakteten Verbindungen wie Mobilfunk</string>
<string name="preferred_preview_quality_description">Standardqualität bei der Vorschau eines Videos in einem Feed</string>
<string name="preferred_quality_description">Standardqualität zum Ansehen eines Videos</string>
<string name="preview_feed_items">Feed-Elemente vorab anzeigen</string>
<string name="preview_feed_items_description">Wenn der Vorschau-Feedstil verwendet wird, ob Elemente beim Darüber-Scrollen automatisch vorab angezeigt werden sollen</string>
<string name="privacy_mode">Privatsphärenmodus</string>
<string name="progress_bar">Fortschrittsbalken</string>
<string name="progress_bar_description">Ob ein historischer Fortschrittsbalken angezeigt werden soll</string>
<string name="promotions">Werbeaktionen</string>
<string name="ratelimit">Ratenbegrenzung</string>
<string name="ratelimit_description">Einstellungen zur Ratenbegrenzung des Verhaltens dieses Plugins</string>
<string name="ratelimit_sub_setting">Abonnements ratenbegrenzen</string>
<string name="ratelimit_sub_setting_description">Begrenzt die Anzahl der gestellten Abonnementanfragen</string>
<string name="repeat_password">Passwort wiederholen</string>
<string name="restart_after_audio_focus_loss">Neustart nach Verlust des Audiofokus</string>
<string name="restart_after_connectivity_loss">Neustart nach Verbindungsverlust</string>
<string name="restart_playback_when_gaining_audio_focus_after_a_loss">Wiedergabe neu starten, wenn der Audiofokus nach einem Verlust wiedererlangt wird</string>
<string name="restart_playback_when_gaining_connectivity_after_a_loss">Wiedergabe neu starten, wenn die Verbindung nach einem Verlust wiederhergestellt wird</string>
<string name="restore_system_brightness">Systemhelligkeit wiederherstellen</string>
<string name="restore_system_brightness_descr">Systemhelligkeit beim Verlassen des Vollbildmodus wiederherstellen</string>
<string name="reverse_portrait">Umgekehrtes Hochformat zulassen</string>
<string name="reverse_portrait_description">App erlauben, ins umgekehrte Hochformat zu wechseln</string>
<string name="rotation_zone">Rotationszone</string>
<string name="rotation_zone_description">Empfindlichkeit der Rotationszonen festlegen (verringern, um weniger empfindlich zu machen)</string>
<string name="scroll_to_top">Nach oben scrollen</string>
<string name="select">Auswählen</string>
<string name="send_to_device">Video synchronisieren</string>
<string name="show_home_filters">Startseiten-Filter anzeigen</string>
<string name="show_home_filters_description">Ob die Startseiten-Filter über der Startseite angezeigt werden sollen</string>
<string name="show_home_filters_plugin_names">Plugin-Namen der Startseiten-Filter</string>
<string name="show_home_filters_plugin_names_description">Ob Startseiten-Filter vollständige Plugin-Namen oder nur Symbole anzeigen sollen</string>
<string name="show_watch_metrics">Wiedergabemetriken anzeigen</string>
<string name="show_watch_metrics_description">Zeigt die Wiedergabezeit und Aufrufe jedes Erstellers im Ersteller-Tab an</string>
<string name="stability_threshold_time">Stabilitätsschwellenzeit</string>
<string name="stability_threshold_time_description">Geben Sie die Dauer an, für die die Ausrichtung gleich bleiben muss, um eine Drehung auszulösen</string>
<string name="stopped_after_requestcount_to_avoid_rate_limit_click_load_more_to_load_more">Nach {requestCount} gestoppt, um Ratenbegrenzung zu vermeiden. Klicken Sie auf „Mehr laden“, um mehr zu laden.</string>
<string name="subscription_group_menu">Gruppen</string>
<string name="subscriptions_cache_5000">Abonnement-Cache 5000</string>
<string name="sync_grayjay">Grayjay synchronisieren</string>
<string name="sync_grayjay_description">Synchronisieren Sie Ihre Daten über mehrere Geräte hinweg</string>
<string name="synchronization">Synchronisation</string>
<string name="system_brightness">Systemhelligkeit</string>
<string name="system_brightness_descr">Gestensteuerung passt Systemhelligkeit an</string>
<string name="system_volume">Systemlautstärke</string>
<string name="system_volume_descr">Gestensteuerung passt Systemlautstärke an</string>
<string name="test_background_worker">Hintergrund-Worker testen</string>
<string name="test_background_worker_description"></string>
<string name="these_are_all_commentcount_comments_you_have_made_in_grayjay">Dies sind alle {commentCount} Kommentare, die Sie in Grayjay gemacht haben.</string>
<string name="these_creators_in_group">Dies sind die Ersteller, die für diese Gruppe sichtbar sind.</string>
<string name="these_creators_not_in_group">Diese Ersteller sind nicht in dieser Gruppe.</string>
<string name="time_bar">Zeitbalken</string>
<string name="toggle_full_screen">Vollbild umschalten</string>
<string name="toggle_full_screen_descr">Wischgeste zum Umschalten des Vollbildmodus aktivieren</string>
<string name="update_available_exclamation">Update verfügbar!</string>
<string name="url_handling">URL-Verarbeitung</string>
<string name="view_a_video_about_how_to_cast">Video zum Übertragen ansehen</string>
<string name="view_the_fcast_technical_documentation">Die technische Dokumentation von FCast ansehen</string>
<string name="volume_slider">Lautstärkeregler</string>
<string name="volume_slider_descr">Wischgeste zum Ändern der Lautstärke aktivieren</string>
<string name="watched">Angesehen</string>
<string name="zoom">Zoom</string>
<string name="zoom_option">Zoom aktivieren</string>
<string name="zoom_option_descr">Zwei-Finger-Pinch-Zoom-Geste aktivieren</string>
<string-array name="home_screen_array">
<item>Empfehlungen</item>
<item>Abonnements</item>
@@ -702,11 +951,33 @@
<item>Ausführlich</item>
</string-array>
<string-array name="comments_sortby_array">
<item>Newest</item>
<item>Oldest</item>
<item>Neuste</item>
<item>Älteste</item>
</string-array>
<string-array name="downloads_sortby_array">
<item>Name (Aufsteigend)</item>
<item>Name (Absteigend)</item>
<item>Download-Datum (Älteste zuerst)</item>
<item>Download-Datum (Neueste zuerst)</item>
<item>Veröffentlichungsdatum (Älteste zuerst)</item>
<item>Veröffentlichungsdatum (Neueste zuerst)</item>
<item>Größe (Kleinste zuerst)</item>
<item>Größe (Größte zuerst)</item>
<item>Typ (Nur Audio)</item>
<item>Typ (Video)</item>
</string-array>
<string-array name="playlists_sortby_array">
<item>Name (Aufsteigend)</item>
<item>Name (Absteigend)</item>
<item>Änderungsdatum (Älteste)</item>
<item>Änderungsdatum (Neueste)</item>
<item>Erstellungsdatum (Älteste)</item>
<item>Erstellungsdatum (Neueste)</item>
<item>Wiedergabedatum (Älteste)</item>
<item>Wiedergabedatum (Neueste)</item>
</string-array>
<string-array name="app_languages">
<item>System</item>
<item>Systemsprache</item>
<item>Englisch (EN)</item>
<item>Deutsch (DE)</item>
<item>Spanisch (ES)</item>
@@ -720,4 +991,51 @@
<item>Italienisch (IT)</item>
<item>Türkisch (TR)</item>
</string-array>
<string-array name="chapter_fps">
<item>24</item>
<item>30</item>
<item>60</item>
<item>120</item>
</string-array>
<string-array name="comment_sections">
<item>Polycentric</item> <item>Plattform</item>
<item>Zuletzt ausgewählt</item>
</string-array>
<string-array name="restart_playback_after_loss">
<item>Nie</item>
<item>Innerhalb von 10 Sekunden nach Verlust</item>
<item>Innerhalb von 30 Sekunden nach Verlust</item>
<item>Immer</item>
</string-array>
<string-array name="rotation_zone">
<item>15</item>
<item>30</item>
<item>45</item>
</string-array>
<string-array name="rotation_threshold_time">
<item>100</item>
<item>500</item>
<item>750</item>
<item>1000</item>
<item>1500</item>
<item>2000</item>
</string-array>
<string-array name="audio_languages">
<item>Englisch</item>
<item>Spanisch</item>
<item>Deutsch</item>
<item>Französisch</item>
<item>Japanisch</item>
<item>Koreanisch</item>
<item>Thailändisch</item>
<item>Vietnamesisch</item>
<item>Indonesisch</item>
<item>Hindi</item>
<item>Arabisch</item>
<item>Türkisch</item>
<item>Russisch</item>
<item>Portugiesisch</item>
<item>Chinesisch</item>
<item>Italienisch</item>
</string-array>
</resources>
+3
View File
@@ -997,6 +997,8 @@
<item>Data di Rilascio (Più Recente)</item>
<item>Dimensione (Più Piccola)</item>
<item>Dimensione (Più Grande)</item>
<item>Tipo (Solo Audio)</item>
<item>Tipo (Video)</item>
</string-array>
<string-array name="playlists_sortby_array">
<item>Nome (Ascending)</item>
@@ -1064,6 +1066,7 @@
<item>Russo</item>
<item>Portoghese</item>
<item>Cinese</item>
<item>Italiano</item>
</string-array>
<string-array name="casting_device_type_array" translatable="false">
<item>FCast</item>
+3
View File
@@ -960,6 +960,8 @@
<item>Çıkış Tarihi (En Yeni)</item>
<item>Boyut (En Küçük)</item>
<item>Boyut (En Büyük)</item>
<item>Tür (Yalnızca Ses)</item>
<item>Tür (Video)</item>
</string-array>
<string-array name="feed_style">
<item>Önizle</item>
@@ -1017,6 +1019,7 @@
<item>Rusça</item>
<item>Portekizce</item>
<item>Çince</item>
<item>İtalyanca</item>
</string-array>
<string-array name="casting_device_type_array" translatable="false">
<item>FCast</item>
+16 -1
View File
@@ -27,6 +27,11 @@
<string name="retry">Retry</string>
<string name="install_failed_device_installer_broken">Failed to start system installer. Your devices ROM is not compatible with automatic updates.</string>
<string name="cancel">Cancel</string>
<string name="enable_daily_backup">Manage daily backup</string>
<string name="automatic_backup_unencrypted_explanation">Enable or disable your automatic backups here</string>
<string name="continue_anyway">Continue anyway</string>
<string name="automatic_backup_disabled">Automatic backup disabled</string>
<string name="automatic_backup_enabled">Automatic backup enabled</string>
<string name="failed_to_retrieve_data_are_you_connected">Failed to retrieve data, are you connected?</string>
<string name="settings">Settings</string>
<string name="history">History</string>
@@ -338,6 +343,8 @@
<string name="clear_the_external_storage_for_download_files">Clear the external storage for download files</string>
<string name="change_the_external_storage_for_download_files">Change the external storage for download files</string>
<string name="clear_cookies">Clear Cookies</string>
<string name="clear_cookies_after_login">Clear Cookies after Login</string>
<string name="clear_cookies_after_login_desc">Deletes all cookies on the webview after login, this may be required for certain plugins to function properly.</string>
<string name="clear_cookies_on_logout">Clear Cookies on Logout</string>
<string name="test_background_worker">Test Background Worker</string>
<string name="test_background_worker_description"></string>
@@ -535,7 +542,7 @@
<string name="restart_playback_when_gaining_connectivity_after_a_loss">Restart playback when gaining connectivity after a loss</string>
<string name="chapter_update_fps_title">Chapter Update FPS</string>
<string name="chapter_update_fps_description">Change accuracy of chapter updating, higher might cost more performance</string>
<string name="set_automatic_backup">Set Automatic Backup</string>
<string name="set_automatic_backup">Configure Automatic Backup</string>
<string name="shortly_after_opening_the_app_start_fetching_subscriptions">Shortly after opening the app, start fetching subscriptions</string>
<string name="show_faq">Show FAQ</string>
<string name="show_issues">Show Issues</string>
@@ -805,6 +812,10 @@
<string name="not_yet_available_retrying_in_time_s">Not yet available, retrying in {time}s</string>
<string name="failed_to_retry_for_live_stream">Failed to retry for live stream</string>
<string name="this_app_is_in_development_please_submit_bug_reports_and_understand_that_many_features_are_incomplete">This app is in development. Please submit bug reports and understand that many features are incomplete.</string>
<string name="automatic_backup_found_no_password">Automatic backup found. No password is required to restore.</string>
<string name="checking_backup">Checking backup...</string>
<string name="backup_password_length_error">Password must be 432 bytes.</string>
<string name="restoring">Restoring...</string>
<string name="please_use_at_least_1_character">Please use at least 1 character</string>
<string name="are_you_sure_you_want_to_delete_this_video">Are you sure you want to delete this video?</string>
<string name="tap_to_open">Tap to open</string>
@@ -1042,6 +1053,8 @@
<item>Release Date (Newest)</item>
<item>Size (Smallest)</item>
<item>Size (Largest)</item>
<item>Type (Audio Only)</item>
<item>Type (Video)</item>
</string-array>
<string-array name="playlists_sortby_array">
<item>Name (Ascending)</item>
@@ -1109,10 +1122,12 @@
<item>Russian</item>
<item>Portuguese</item>
<item>Chinese</item>
<item>Italian</item>
</string-array>
<string-array name="casting_device_type_array" translatable="false">
<item>FCast</item>
<item>ChromeCast</item>
<item>AirPlay</item>
</string-array>
<string-array name="log_levels">
<item>None</item>
+6 -2
View File
@@ -19,7 +19,7 @@
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="youtu.be" />
<data android:host="www.you.be" />
<data android:host="www.youtu.be" />
<data android:host="youtube.com" />
<data android:host="www.youtube.com" />
<data android:host="m.youtube.com" />
@@ -31,6 +31,8 @@
<data android:host="patreon.com" />
<data android:host="soundcloud.com" />
<data android:host="twitch.tv" />
<data android:host="www.twitch.tv" />
<data android:host="m.twitch.tv" />
<data android:host="bilibili.com" />
<data android:host="bilibili.tv" />
<data android:host="dailymotion.com" />
@@ -51,7 +53,7 @@
<data android:mimeType="text/plain" />
<data android:host="youtu.be" />
<data android:host="www.you.be" />
<data android:host="www.youtu.be" />
<data android:host="youtube.com" />
<data android:host="www.youtube.com" />
<data android:host="m.youtube.com" />
@@ -63,6 +65,8 @@
<data android:host="patreon.com" />
<data android:host="soundcloud.com" />
<data android:host="twitch.tv" />
<data android:host="www.twitch.tv" />
<data android:host="m.twitch.tv" />
<data android:host="bilibili.com" />
<data android:host="bilibili.tv" />
<data android:host="dailymotion.com" />
+4 -1
View File
@@ -16,7 +16,10 @@
"8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json",
"273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json",
"9bb33039-8580-48d4-9849-21319ae845a4": "sources/crunchyroll/CrunchyrollConfig.json",
"84331338-b045-419c-88e4-c86036f4cbf5": "sources/mixcloud/MixcloudConfig.json"
"84331338-b045-419c-88e4-c86036f4cbf5": "sources/mixcloud/MixcloudConfig.json",
"009775f8-9173-48a2-8df3-d730d08d198d": "sources/radiobrowser/RadioBrowserConfig.json",
"5f6658bb-96cc-4965-ba04-c81f8686ab67": "sources/redbull-tv/RedBullTvConfig.json",
"d890ff43-7d9f-4f0e-a52d-239014fd512d": "sources/fosdem/FOSDEMConfig.json"
},
"SOURCES_EMBEDDED_DEFAULT": [
"35ae969a-a7db-11ed-afa1-0242ac120002"
+6 -2
View File
@@ -29,7 +29,7 @@
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="youtu.be" />
<data android:host="www.you.be" />
<data android:host="www.youtu.be" />
<data android:host="youtube.com" />
<data android:host="www.youtube.com" />
<data android:host="m.youtube.com" />
@@ -41,6 +41,8 @@
<data android:host="patreon.com" />
<data android:host="soundcloud.com" />
<data android:host="twitch.tv" />
<data android:host="www.twitch.tv" />
<data android:host="m.twitch.tv" />
<data android:host="bilibili.com" />
<data android:host="bilibili.tv" />
<data android:host="dailymotion.com" />
@@ -61,7 +63,7 @@
<data android:mimeType="text/plain" />
<data android:host="youtu.be" />
<data android:host="www.you.be" />
<data android:host="www.youtu.be" />
<data android:host="youtube.com" />
<data android:host="www.youtube.com" />
<data android:host="m.youtube.com" />
@@ -73,6 +75,8 @@
<data android:host="patreon.com" />
<data android:host="soundcloud.com" />
<data android:host="twitch.tv" />
<data android:host="www.twitch.tv" />
<data android:host="m.twitch.tv" />
<data android:host="bilibili.com" />
<data android:host="bilibili.tv" />
<data android:host="dailymotion.com" />
+4 -1
View File
@@ -16,7 +16,10 @@
"8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json",
"273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json",
"9bb33039-8580-48d4-9849-21319ae845a4": "sources/crunchyroll/CrunchyrollConfig.json",
"84331338-b045-419c-88e4-c86036f4cbf5": "sources/mixcloud/MixcloudConfig.json"
"84331338-b045-419c-88e4-c86036f4cbf5": "sources/mixcloud/MixcloudConfig.json",
"009775f8-9173-48a2-8df3-d730d08d198d": "sources/radiobrowser/RadioBrowserConfig.json",
"5f6658bb-96cc-4965-ba04-c81f8686ab67": "sources/redbull-tv/RedBullTvConfig.json",
"d890ff43-7d9f-4f0e-a52d-239014fd512d": "sources/fosdem/FOSDEMConfig.json"
},
"SOURCES_EMBEDDED_DEFAULT": [
"35ae969a-a7db-11ed-afa1-0242ac120002"
+13 -4
View File
@@ -1,5 +1,13 @@
#!/bin/sh
set -eu
DOCUMENT_ROOT=/var/www/html
MAINT_FILE="$DOCUMENT_ROOT/maintenance.file"
cleanup() {
rm -f "$MAINT_FILE"
}
trap cleanup EXIT INT TERM
# Sign sources
echo "Signing all sources..."
@@ -11,12 +19,12 @@ echo "Building content..."
# Take site offline
echo "Taking site offline..."
touch $DOCUMENT_ROOT/maintenance.file
touch "$MAINT_FILE"
# Swap over the content
echo "Deploying content..."
cp ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab $DOCUMENT_ROOT/app-playstore-release.aab
aws s3 cp ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab s3://artifacts-grayjay-app/app-playstore-release.aab
cp ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab \
"$DOCUMENT_ROOT/app-playstore-release.aab"
# Notify Cloudflare to wipe the CDN cache
echo "Purging Cloudflare cache for zone $CLOUDFLARE_ZONE_ID..."
@@ -29,4 +37,5 @@ sleep 30
# Take site back online
echo "Bringing site back online..."
rm $DOCUMENT_ROOT/maintenance.file
rm -f "$MAINT_FILE"
trap - EXIT INT TERM

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