Compare commits

...

104 Commits

Author SHA1 Message Date
Koen J a792dea4c5 Build fix. 2026-05-08 17:50:30 +02:00
Koen J a7fc549afb Automatic updates for plugins defaults to true and made the loading bar smaller. 2026-05-08 17:40:57 +02:00
Koen J b345ba5ca3 Updated submodules. 2026-05-08 16:12:50 +02:00
Koen J c65cee86b1 Implemented feedback for update flow. 2026-05-07 22:53:26 +02:00
Koen cf3fc61f6a Merge branch 'request-modifiers' into 'master'
Improve request modifier support for casting and downloads

See merge request videostreaming/grayjay!171
2026-04-30 15:32:54 +00:00
Koen J d03019f0b7 Fixes for fdroid and playstore automation. 2026-04-30 12:24:50 +02:00
Koen J f1ce0078fd Switch to rel.grayjay.app 2026-04-30 11:03:44 +02:00
Koen J 32de3649ef Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2026-04-30 10:10:17 +02:00
Koen J 1a301236da Changed background download to default. 2026-04-30 10:04:44 +02:00
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
Stefan cd90497a59 Improve request modifier support for casting and downloads 2026-04-08 13:32:39 +01: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
Kelvin b6b04054b9 Clear cookies on startup & after login 2026-01-31 21:20:31 +01:00
Kelvin 1ea794459c refs 2026-01-31 19:27:57 +01:00
Kelvin c27f5e4096 Cleanup fixes, v8 locking 2026-01-31 19:23:32 +01:00
Kelvin 8469f17b4c Fix threading for callbacks from browser 2026-01-31 13:15:09 +01:00
Kelvin 067abc415b Submods 2026-01-30 16:40:49 +01:00
Kelvin d692533f20 Merge branch 'package-browser' into 'master'
New Notification UI & PackageBrowser support

See merge request videostreaming/grayjay!163
2026-01-30 15:38:48 +00:00
Kelvin 31a6ea0f39 Browser support 2026-01-30 16:17:06 +01:00
Kelvin 5ba2f2be75 Package Browser support for testing 2026-01-27 05:13:15 +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
Kelvin 8536861e09 Update dialogs 2026-01-05 23:56:53 +01:00
Kelvin 71262da3c2 New notification ui 2026-01-02 20:38:43 +01:00
Koen J 60cd5976cc Updated ExoPlayer. 2025-12-31 13:11:16 +01:00
Koen 3ca6a1fd70 Merge branch 'marcus/remove-legacy-casting' into 'master'
casting: remove legacy backend

See merge request videostreaming/grayjay!162
2025-12-26 08:52:13 +00:00
Marcus Hanestad 0d8c8de450 casting: remove legacy backend 2025-12-25 23:04:10 +01:00
Koen J 8ba2fe9972 getOrNull should be used for original everywhere. 2025-12-23 15:52:15 +01:00
koen-futo 7a7ef533cc Merge pull request #2336 from realchrisolin/master
update configChanges so bluetooth keyboards don't recreate activity
2025-12-22 14:28:18 +01:00
Koen 5385549a43 Merge branch 'b23tv-intent-filter' into 'master'
Add b23.tv (BiliBili) to intent filters in AndroidManifest.xml

See merge request videostreaming/grayjay!161
2025-12-20 14:06:36 +00:00
Stefan 04deffc66e Add b23.tv (BiliBili) to intent filters in AndroidManifest.xml
related with https://github.com/futo-org/grayjay-android/issues/2537
2025-12-20 12:08:52 +00:00
Koen J 852f563c9a Renamed subtitles-1 2025-12-18 15:23:16 +01:00
Koen J c84cea9ea1 Remove animation for quality selector. 2025-12-18 14:37:44 +01:00
Chris Olin 09bc180d4f update configChanges so bluetooth keyboards don't recreate activity 2025-06-10 13:25:45 -04: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
176 changed files with 5274 additions and 4536 deletions
+43 -16
View File
@@ -1,37 +1,64 @@
variables: variables:
GIT_SUBMODULE_STRATEGY: recursive GIT_SUBMODULE_STRATEGY: recursive
stages:
- buildAndDeployApkUnstable
- buildAndDeployApkStable
- buildAndDeployPlaystore
buildAndDeployApkUnstable: buildAndDeployApkUnstable:
stage: buildAndDeployApkUnstable stage: build
script: script:
- sh deploy-unstable.sh - sh deploy-unstable.sh
only: only:
- tags - tags
except:
- ^(dev)
when: manual when: manual
needs: []
allow_failure: true
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/apk/unstable/release/*.apk
buildAndDeployApkStable: buildAndDeployApkStable:
stage: buildAndDeployApkStable stage: build
script: script:
- sh deploy-stable.sh - sh deploy-stable.sh
only: only:
- tags - tags
except:
- branches
when: manual when: manual
needs: []
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/apk/stable/release/*.apk
buildAndDeployPlaystore: buildAndDeployPlaystore:
stage: buildAndDeployPlaystore stage: deploy
script: script:
- sh deploy-playstore.sh - sh build-playstore.sh
- bash 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: only:
- tags - tags
except: when: on_success
- branches needs:
when: manual - 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
before_script:
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- touch ~/.ssh/known_hosts && chmod 644 ~/.ssh/known_hosts
- ssh-keygen -F gitlab.futo.org >/dev/null 2>&1 || ssh-keyscan -t rsa,ecdsa,ed25519 gitlab.futo.org >> ~/.ssh/known_hosts
script:
- python3 update_fdroid_index.py
+18
View File
@@ -106,3 +106,21 @@
[submodule "app/src/unstable/assets/sources/mixcloud"] [submodule "app/src/unstable/assets/sources/mixcloud"]
path = app/src/unstable/assets/sources/mixcloud path = app/src/unstable/assets/sources/mixcloud
url = ../plugins/mixcloud.git 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
+8 -13
View File
@@ -184,13 +184,13 @@ dependencies {
implementation 'com.caoccao.javet:javet-v8-android:4.1.5' implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
//Exoplayer //Exoplayer
implementation 'androidx.media3:media3-exoplayer:1.8.0' implementation 'androidx.media3:media3-exoplayer:1.9.0'
implementation 'androidx.media3:media3-exoplayer-dash:1.8.0' implementation 'androidx.media3:media3-exoplayer-dash:1.9.0'
implementation 'androidx.media3:media3-ui:1.8.0' implementation 'androidx.media3:media3-ui:1.9.0'
implementation 'androidx.media3:media3-exoplayer-hls:1.8.0' implementation 'androidx.media3:media3-exoplayer-hls:1.9.0'
implementation 'androidx.media3:media3-exoplayer-rtsp:1.8.0' implementation 'androidx.media3:media3-exoplayer-rtsp:1.9.0'
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.8.0' implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.9.0'
implementation 'androidx.media3:media3-transformer:1.8.0' implementation 'androidx.media3:media3-transformer:1.9.0'
implementation 'androidx.navigation:navigation-fragment-ktx:2.9.6' implementation 'androidx.navigation:navigation-fragment-ktx:2.9.6'
implementation 'androidx.navigation:navigation-ui-ktx:2.9.6' implementation 'androidx.navigation:navigation-ui-ktx:2.9.6'
implementation 'androidx.media:media:1.7.1' implementation 'androidx.media:media:1.7.1'
@@ -206,6 +206,7 @@ dependencies {
implementation 'com.google.zxing:core:3.5.3' implementation 'com.google.zxing:core:3.5.3'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0' implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
implementation 'com.caverock:androidsvg-aar:1.4' implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'androidx.webkit:webkit:1.15.0'
//Protobuf //Protobuf
implementation 'com.google.protobuf:protobuf-javalite:4.33.0' implementation 'com.google.protobuf:protobuf-javalite:4.33.0'
@@ -230,10 +231,4 @@ dependencies {
testImplementation "org.mockito:mockito-core:5.20.0" testImplementation "org.mockito:mockito-core:5.20.0"
androidTestImplementation 'androidx.test.ext:junit:1.3.0' androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.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)
}
}
+1 -1
View File
@@ -60,7 +60,7 @@
<activity <activity
android:name=".activities.MainActivity" android:name=".activities.MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode" android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
android:exported="true" android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar" android:theme="@style/Theme.FutoVideo.NoActionBar"
android:windowSoftInputMode="adjustPan" android:windowSoftInputMode="adjustPan"
@@ -118,14 +118,13 @@ inline fun <reified T> V8ValueArray.expectV8Variants(config: IV8PluginConfig, co
inline fun V8Plugin.ensureIsBusy() { inline fun V8Plugin.ensureIsBusy() {
this.let { this.let {
if (!it.isThreadAlreadyBusy()) { if (!it.isThreadAlreadyBusy()) {
//throw IllegalStateException("Tried to access V8Plugin without busy");
val stacktrace = Thread.currentThread().stackTrace; val stacktrace = Thread.currentThread().stackTrace;
Logger.w("Extensions_V8", val message = "V8 USE OUTSIDE BUSY: " + stacktrace.drop(3)?.firstOrNull().toString() +
"V8 USE OUTSIDE BUSY: " + stacktrace.drop(3)?.firstOrNull().toString() + ", " + stacktrace.drop(4)?.firstOrNull().toString() +
", " + stacktrace.drop(4)?.firstOrNull().toString() + ", " + stacktrace.drop(5)?.firstOrNull()?.toString() +
", " + stacktrace.drop(5)?.firstOrNull()?.toString() + ", " + stacktrace.drop(6)?.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 { inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextName: String): T {
if(false) ensureIsBusy();
ensureIsBusy();
return when(T::class) { return when(T::class) {
String::class -> this.expectOrThrow<V8ValueString>(config, contextName).value as T; String::class -> this.expectOrThrow<V8ValueString>(config, contextName).value as T;
Int::class -> { 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"); 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> { fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap<String, String> {
if(obj == null) if(obj == null)
return hashMapOf(); return hashMapOf();
obj.ensureIsBusy();
val map = hashMapOf<String, String>(); val map = hashMapOf<String, String>();
for(prop in obj.ownPropertyNames.keys.map { obj.ownPropertyNames.get<V8Value>(it).toString() }) for(prop in obj.ownPropertyNames.keys.map { obj.ownPropertyNames.get<V8Value>(it).toString() })
map.put(prop, obj.getString(prop)); map.put(prop, obj.getString(prop));
@@ -203,21 +205,27 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
plugin.busy { plugin.busy {
this.register(object: IV8ValuePromise.IListener { this.register(object: IV8ValuePromise.IListener {
override fun onFulfilled(p0: V8Value?) { override fun onFulfilled(p0: V8Value?) {
if(p0 is V8ValueError) plugin.busy {
promiseException = ScriptExecutionException(plugin.config, p0.message); if(p0 is V8ValueError)
else { promiseException = ScriptExecutionException(plugin.config, p0.message);
if(p0 is V8ValueObject) else {
p0.setWeak(); if(p0 is V8ValueObject)
promiseResult = p0 as T; p0.setWeak();
promiseResult = p0 as T;
}
} }
latch.countDown(); latch.countDown();
} }
override fun onRejected(p0: V8Value?) { override fun onRejected(p0: V8Value?) {
promiseException = p0?.toException(plugin.config); plugin.busy {
promiseException = p0?.toException(plugin.config);
}
latch.countDown(); latch.countDown();
} }
override fun onCatch(p0: V8Value?) { override fun onCatch(p0: V8Value?) {
promiseException = p0?.toException(plugin.config); plugin.busy {
promiseException = p0?.toException(plugin.config);
}
latch.countDown(); 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()); //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());
val isPending = plugin.busy {
if(!promise.isPending) { promise.isPending
try { };
Logger.i("V8", "V8Promise resolved synchronously"); if(!isPending) {
if(promise.isFulfilled) plugin.busy {
promiseResult = promise.getResult<T>(); try {
else Logger.i("V8", "V8Promise resolved synchronously");
promiseException = promise.getResult<V8Value>().toException(plugin.config); if(promise.isFulfilled)
promiseResult = promise.getResult<T>();
else
promiseException = promise.getResult<V8Value>().toException(plugin.config);
}
catch(ex: Throwable) {
promiseException = ex;
}
} }
catch(ex: Throwable) { } else {
promiseException = ex;
}
}
else {
plugin.unbusy { plugin.unbusy {
latch.await(); latch.await();
} }
@@ -266,15 +277,19 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
plugin.busy { plugin.busy {
this.register(object: IV8ValuePromise.IListener { this.register(object: IV8ValuePromise.IListener {
override fun onFulfilled(p0: V8Value?) { override fun onFulfilled(p0: V8Value?) {
plugin.resolvePromise(promise); plugin.busy {
underlyingDef.complete(p0 as T); plugin.resolvePromise(promise);
underlyingDef.complete(p0 as T);
}
} }
override fun onRejected(p0: V8Value?) { override fun onRejected(p0: V8Value?) {
try { try {
plugin.resolvePromise(promise); plugin.busy {
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented.."); plugin.resolvePromise(promise);
Logger.i("V8", "Promise rejected, setting exception"); val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented..");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound)); Logger.i("V8", "Promise rejected, setting exception");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
}
} }
catch(ex: Throwable) { catch(ex: Throwable) {
Logger.e("V8", "Rejection handling failed?" , ex); 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?) { override fun onCatch(p0: V8Value?) {
try { try {
plugin.resolvePromise(promise); plugin.busy {
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented.."); plugin.resolvePromise(promise);
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound)); val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented..");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
}
} }
catch(ex: Throwable) { catch(ex: Throwable) {
Logger.e("V8", "Catching handling failed?" , ex); 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 { fun V8Value.toException(config: IV8PluginConfig): Throwable {
ensureIsBusy();
val p0 = this; val p0 = this;
if(p0 is V8ValueObject) { if(p0 is V8ValueObject) {
return V8Plugin.getExceptionFromPlugin(config, p0, null, null, null, "P:"); 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 { fun <T: V8Value> V8ValueObject.invokeV8(method: String, vararg obj: Any?): T {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj); var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) { if(result is V8ValuePromise) {
return result.toV8ValueBlocking(this.getSourcePlugin()!!); return result.toV8ValueBlocking(this.getSourcePlugin()!!);
@@ -356,6 +375,7 @@ fun <T: V8Value> V8ValueObject.invokeV8(method: String, vararg obj: Any?): T {
return result as T; return result as T;
} }
fun <T: V8Value> V8ValueObject.invokeV8Async(method: String, vararg obj: Any?): V8Deferred<T> { fun <T: V8Value> V8ValueObject.invokeV8Async(method: String, vararg obj: Any?): V8Deferred<T> {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj); var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) { if(result is V8ValuePromise) {
return result.toV8ValueAsync(this.getSourcePlugin()!!); 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)); return V8Deferred(CompletableDeferred(result as T));
} }
fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value { fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj); var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) { if(result is V8ValuePromise) {
return result.toV8ValueBlocking(this.getSourcePlugin()!!); return result.toV8ValueBlocking(this.getSourcePlugin()!!);
@@ -370,6 +391,7 @@ fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value {
return result; return result;
} }
fun V8ValueObject.invokeV8VoidAsync(method: String, vararg obj: Any?): V8Deferred<V8Value> { fun V8ValueObject.invokeV8VoidAsync(method: String, vararg obj: Any?): V8Deferred<V8Value> {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj); var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) { if(result is V8ValuePromise) {
val result = result.toV8ValueAsync<V8Value>(this.getSourcePlugin()!!); val result = result.toV8ValueAsync<V8Value>(this.getSourcePlugin()!!);
@@ -6,6 +6,7 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.webkit.CookieManager import android.webkit.CookieManager
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.ManageTabsActivity import com.futo.platformplayer.activities.ManageTabsActivity
import com.futo.platformplayer.activities.PolycentricHomeActivity import com.futo.platformplayer.activities.PolycentricHomeActivity
@@ -312,7 +313,7 @@ class Settings : FragmentedStorageFileJson() {
var showSubscriptionGroups: Boolean = true; var showSubscriptionGroups: Boolean = true;
@FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6) @FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6)
var useSubscriptionExchange: Boolean = false; var useSubscriptionExchange: Boolean = true;
@AdvancedField @AdvancedField
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6) @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
@@ -400,10 +401,11 @@ class Settings : FragmentedStorageFileJson() {
8 -> "id"; 8 -> "id";
9 -> "hi"; 9 -> "hi";
10 -> "ar"; 10 -> "ar";
11 -> "tu"; 11 -> "tr";
12 -> "ru"; 12 -> "ru";
13 -> "pt"; 13 -> "pt";
14 -> "zh"; 14 -> "zh";
15 -> "it";
else -> null else -> null
} }
} }
@@ -725,11 +727,6 @@ class Settings : FragmentedStorageFileJson() {
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var allowLinkLocalIpv4: Boolean = false; var allowLinkLocalIpv4: Boolean = false;
@AdvancedField
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
@Serializable(with = FlexibleBooleanSerializer::class)
var experimentalCasting: Boolean = true
/*TODO: Should we have a different casting quality? /*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@DropdownFieldOptionsId(R.array.preferred_quality_array) @DropdownFieldOptionsId(R.array.preferred_quality_array)
@@ -792,7 +789,6 @@ class Settings : FragmentedStorageFileJson() {
} }
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13) @FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
@Transient
var plugins = Plugins(); var plugins = Plugins();
@Serializable @Serializable
class Plugins { class Plugins {
@@ -801,6 +797,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1) @FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
var checkDisabledPluginsForUpdates: Boolean = false; 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 @AdvancedField
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0) @FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
var clearCookiesOnLogout: Boolean = true; var clearCookiesOnLogout: Boolean = true;
@@ -810,6 +809,12 @@ class Settings : FragmentedStorageFileJson() {
val cookieManager: CookieManager = CookieManager.getInstance(); val cookieManager: CookieManager = CookieManager.getInstance();
cookieManager.removeAllCookies(null); 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) /*@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
fun reinstallEmbedded() { fun reinstallEmbedded() {
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
@@ -877,7 +882,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.background_download, FieldForm.TOGGLE, R.string.configure_if_background_download_should_be_used, 1) @FormField(R.string.background_download, FieldForm.TOGGLE, R.string.configure_if_background_download_should_be_used, 1)
//@DropdownFieldOptionsId(R.array.background_download) //@DropdownFieldOptionsId(R.array.background_download)
var shouldBackgroundDownload: Boolean = false; var shouldBackgroundDownload: Boolean = true;
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2) @FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
@DropdownFieldOptionsId(R.array.when_download) @DropdownFieldOptionsId(R.array.when_download)
@@ -957,18 +962,31 @@ class Settings : FragmentedStorageFileJson() {
class Backup { class Backup {
@Serializable(with = OffsetDateTimeSerializer::class) @Serializable(with = OffsetDateTimeSerializer::class)
var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN; var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN;
var didAskAutoBackup: Boolean = true; var didAskAutoBackup: Boolean = false;
var autoBackupEnabled: Boolean = false
var autoBackupPassword: String? = null; var autoBackupPassword: String? = null;
fun shouldAutomaticBackup() = autoBackupPassword != null; fun shouldAutomaticBackup() = autoBackupEnabled
@FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0) @FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0)
val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day"; 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) @FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
fun configureAutomaticBackup() { fun configureAutomaticBackup() {
UIDialogs.showAutomaticBackupDialog(StateApp.instance.activity!!, autoBackupPassword != null) { StateApp.instance.activity?.let { activity ->
SettingsFragment.currentView?.reloadSettings(); 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) @FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
fun restoreAutomaticBackup() { fun restoreAutomaticBackup() {
@@ -1052,7 +1070,6 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7) @FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
var polycentricLocalCache: Boolean = true; var polycentricLocalCache: Boolean = true;
var showPrivacyModeDialog: Boolean = true; var showPrivacyModeDialog: Boolean = true;
} }
@@ -19,6 +19,7 @@ import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -165,27 +166,42 @@ class UIDialogs {
dialog.show() dialog.show()
} }
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) { fun showAutomaticBackupDialog(context: Context, onClosed: (() -> Unit)? = null) {
val dialogAction: ()->Unit = { val dialogAction: () -> Unit = {
val dialog = AutomaticBackupDialog(context); val dialog = AutomaticBackupDialog(context)
registerDialogOpened(dialog); registerDialogOpened(dialog)
dialog.setOnDismissListener { registerDialogClosed(dialog); onClosed?.invoke() }; dialog.setOnDismissListener {
dialog.show(); registerDialogClosed(dialog)
}; onClosed?.invoke()
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, dialog.show()
UIDialogs.Action(context.getString(R.string.cancel), {}), //To nothing }
UIDialogs.Action(context.getString(R.string.override), {
dialogAction(); 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.ActionStyle.DANGEROUS),
UIDialogs.Action(context.getString(R.string.restore), { 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) }, UIDialogs.ActionStyle.PRIMARY)
); )
else { } else {
dialogAction(); dialogAction()
} }
} }
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) { fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
val dialog = AutomaticRestoreDialog(context, scope); val dialog = AutomaticRestoreDialog(context, scope);
registerDialogOpened(dialog); registerDialogOpened(dialog);
@@ -32,6 +32,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.downloads.VideoLocal
@@ -382,7 +383,8 @@ class UISlideOverlays {
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items) val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl) val modifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl, HashMap(), modifier)
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
val masterPlaylistContent = masterPlaylistResponse.body?.string() val masterPlaylistContent = masterPlaylistResponse.body?.string()
@@ -515,7 +517,7 @@ class UISlideOverlays {
slideUpMenuOverlay.onOK.subscribe { slideUpMenuOverlay.onOK.subscribe {
//TODO: Fix SubtitleRawSource issue //TODO: Fix SubtitleRawSource issue
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null); StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null, videoModifier = modifier, audioModifier = modifier);
slideUpMenuOverlay.hide() slideUpMenuOverlay.hide()
} }
@@ -526,11 +528,11 @@ class UISlideOverlays {
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (source is IHLSManifestSource) { if (source is IHLSManifestSource) {
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null) StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null, videoModifier = modifier)
UIDialogs.toast(container.context, "Variant video HLS playlist download started") UIDialogs.toast(container.context, "Variant video HLS playlist download started")
slideUpMenuOverlay.hide() slideUpMenuOverlay.hide()
} else if (source is IHLSManifestAudioSource) { } else if (source is IHLSManifestAudioSource) {
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null) StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null, audioModifier = modifier)
UIDialogs.toast(container.context, "Variant audio HLS playlist download started") UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
slideUpMenuOverlay.hide() slideUpMenuOverlay.hide()
} else { } else {
@@ -5,50 +5,14 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.states.StateApp
import java.io.File
class UpdateActionReceiver : BroadcastReceiver() { class UpdateActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
when (intent.action) { when (intent.action) {
UpdateNotificationManager.ACTION_UPDATE_YES -> handleUpdateYes(context, intent)
UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context)
UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context)
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent) UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
} }
} }
private fun handleUpdateYes(context: Context, intent: Intent) {
AutoUpdateDialog.currentDialog?.dismiss()
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
if (version == 0) {
return
}
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
}
ContextCompat.startForegroundService(context, serviceIntent)
}
private fun handleUpdateNo(context: Context) {
AutoUpdateDialog.currentDialog?.dismiss()
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
}
private fun handleUpdateNever(context: Context) {
AutoUpdateDialog.currentDialog?.dismiss()
Settings.instance.autoUpdate.check = 1
Settings.instance.save()
UpdateNotificationManager.cancelAll(context)
}
private fun handleDownloadCancel(context: Context, intent: Intent) { private fun handleDownloadCancel(context: Context, intent: Intent) {
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0) val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
@@ -1,11 +1,12 @@
package com.futo.platformplayer package com.futo.platformplayer
import android.content.Context import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -35,18 +36,16 @@ class UpdateCheckWorker(appContext: Context, workerParams: WorkerParameters) : C
return@withContext Result.success() return@withContext Result.success()
} }
UpdateNotificationManager.showUpdateAvailableNotification(applicationContext, latestVersion) StateUpdate.Companion.instance.setUiAvailable(latestVersion)
if (StateApp.instance.isMainActive) { try {
withContext(Dispatchers.Main) { val serviceIntent = Intent(applicationContext, UpdateDownloadService::class.java).apply {
StateApp.withContext { ctx -> putExtra(UpdateDownloadService.EXTRA_VERSION, latestVersion)
try {
UIDialogs.showUpdateAvailableDialog(ctx, latestVersion, false)
} catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update dialog from worker", t)
}
}
} }
ContextCompat.startForegroundService(applicationContext, serviceIntent)
} catch (t: Throwable) {
Logger.w(TAG, "Failed to start UpdateDownloadService", t)
StateUpdate.Companion.instance.setUiFailed(latestVersion, t.message)
} }
Result.success() Result.success()
@@ -1,19 +1,22 @@
package com.futo.platformplayer package com.futo.platformplayer
import android.app.Dialog
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import android.os.SystemClock import android.os.SystemClock
import com.futo.platformplayer.UIDialogs.ActionStyle import androidx.core.app.NotificationManagerCompat
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.SessionAnnouncement
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateUpdate import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.time.OffsetDateTime
class UpdateDownloadService : Service() { class UpdateDownloadService : Service() {
@@ -25,8 +28,6 @@ class UpdateDownloadService : Service() {
private const val INITIAL_BACKOFF_MS = 5_000L private const val INITIAL_BACKOFF_MS = 5_000L
private const val BUFFER_SIZE = 8 * 1024 private const val BUFFER_SIZE = 8 * 1024
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
var updateDownloadedDialog: Dialog? = null
} }
private val job = SupervisorJob() private val job = SupervisorJob()
@@ -51,6 +52,7 @@ class UpdateDownloadService : Service() {
if (intent.getBooleanExtra(EXTRA_CANCEL, false)) { if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
cancelRequested = true cancelRequested = true
Logger.i(TAG, "Download cancel requested") Logger.i(TAG, "Download cancel requested")
StateUpdate.Companion.instance.clearUi()
stopForeground(Service.STOP_FOREGROUND_REMOVE) stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
@@ -70,6 +72,10 @@ class UpdateDownloadService : Service() {
isDownloading = true isDownloading = true
cancelRequested = false cancelRequested = false
StateUpdate.Companion.instance.setUiDownloading(version, 0, indeterminate = true)
NotificationManagerCompat.from(this).cancel(UpdateNotificationManager.NOTIF_ID_READY)
val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true) val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true)
startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification) startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
@@ -85,13 +91,17 @@ class UpdateDownloadService : Service() {
job.cancel() job.cancel()
} }
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean) { private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean, onProgress: ((Int) -> Unit)? = null) {
val now = SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()
val force = progress == 100 && !indeterminate val force = progress == 100 && !indeterminate
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) { if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
lastProgressUpdateElapsedMs = now lastProgressUpdateElapsedMs = now
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate) UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate);
StateUpdate.Companion.instance.setUiDownloading(version, progress, indeterminate)
if(onProgress != null)
onProgress.invoke(progress);
} }
} }
@@ -99,6 +109,7 @@ class UpdateDownloadService : Service() {
val apkFile = StateUpdate.getApkFile(this, version) val apkFile = StateUpdate.getApkFile(this, version)
val partialFile = StateUpdate.getPartialApkFile(this, version) val partialFile = StateUpdate.getPartialApkFile(this, version)
var announcement: SessionAnnouncement? = null;
try { try {
if (apkFile.exists() && apkFile.length() > 0L) { if (apkFile.exists() && apkFile.length() > 0L) {
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}") Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
@@ -106,6 +117,14 @@ class UpdateDownloadService : Service() {
return return
} }
try {
announcement = StateAnnouncement.instance.registerLoading("Downloading new version [${version}]", "New version is being downloaded..",
ImageVariable.fromResource(R.drawable.foreground));
}
catch(ex: Exception){
Logger.e(TAG, "Failed to set progress announcement", ex);
}
var backoffMs = INITIAL_BACKOFF_MS var backoffMs = INITIAL_BACKOFF_MS
for (attempt in 0 until MAX_RETRIES) { for (attempt in 0 until MAX_RETRIES) {
@@ -115,7 +134,13 @@ class UpdateDownloadService : Service() {
} }
try { try {
performDownload(StateUpdate.APK_URL, partialFile, version) performDownload(StateUpdate.getApkUrl(version), partialFile, version, {
try {
if (announcement != null)
announcement?.setProgress(it);
}
catch(ex: Throwable) {}
})
if (!cancelRequested) { if (!cancelRequested) {
if (apkFile.exists()) { if (apkFile.exists()) {
@@ -136,6 +161,7 @@ class UpdateDownloadService : Service() {
if (attempt == MAX_RETRIES - 1) { if (attempt == MAX_RETRIES - 1) {
Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t) Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
UpdateNotificationManager.showDownloadFailedNotification(this, version, t) UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
StateUpdate.Companion.instance.setUiFailed(version, t.message)
break break
} else { } else {
Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t) Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t)
@@ -145,6 +171,12 @@ class UpdateDownloadService : Service() {
} }
} }
} finally { } finally {
try {
if (announcement != null) {
StateAnnouncement.instance.closeAnnouncement(announcement.id);
}
}
catch(ex: Throwable){}
isDownloading = false isDownloading = false
cancelRequested = false cancelRequested = false
stopForeground(Service.STOP_FOREGROUND_REMOVE) stopForeground(Service.STOP_FOREGROUND_REMOVE)
@@ -152,7 +184,7 @@ class UpdateDownloadService : Service() {
} }
} }
private fun performDownload(url: String, partialFile: File, version: Int) { private fun performDownload(url: String, partialFile: File, version: Int, onProgress: ((Int)->Unit)? = null) {
var startOffset = if (partialFile.exists()) partialFile.length() else 0L var startOffset = if (partialFile.exists()) partialFile.length() else 0L
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset") Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
@@ -204,7 +236,7 @@ class UpdateDownloadService : Service() {
progress > 100 -> 100 progress > 100 -> 100
else -> progress else -> progress
} }
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false) throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false, onProgress)
} }
} else { } else {
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true) throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
@@ -235,27 +267,16 @@ class UpdateDownloadService : Service() {
private fun onDownloadComplete(version: Int, apkFile: File) { private fun onDownloadComplete(version: Int, apkFile: File) {
Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}") Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}")
UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile) UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile)
StateUpdate.Companion.instance.setUiReady(version, apkFile)
if (StateApp.instance.isMainActive) { try {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { val ctx = applicationContext
StateApp.withContext { ctx -> StateAnnouncement.instance.registerAnnouncement("install-update-apk", "Grayjay v${version} is ready!", "You can now install the new Grayjay version.", AnnouncementType.SESSION, OffsetDateTime.now(), "update", "Install") {
try { UpdateNotificationManager.cancelAll(ctx)
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground, UpdateInstaller.startInstall(ctx, version, apkFile)
"Update downloaded",
"Would you like to install it now?", null, 0,
UIDialogs.Action("Not now", {
updateDownloadedDialog = null
}, ActionStyle.NONE, true),
UIDialogs.Action("Install", {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, version, apkFile)
}, ActionStyle.PRIMARY, true));
} catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
updateDownloadedDialog = null
}
}
} }
} catch (ex: Throwable) {
Logger.w(TAG, "Failed to register install announcement", ex)
} }
} }
} }
@@ -21,6 +21,7 @@ import java.io.InputStream
import androidx.core.net.toUri import androidx.core.net.toUri
import com.futo.platformplayer.dialogs.AutoUpdateDialog import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
object UpdateInstaller { object UpdateInstaller {
private const val TAG = "UpdateInstaller" private const val TAG = "UpdateInstaller"
@@ -61,6 +62,17 @@ object UpdateInstaller {
var inputStream: InputStream? = null var inputStream: InputStream? = null
var session: PackageInstaller.Session? = null var session: PackageInstaller.Session? = null
try { try {
val dataLength = apkFile.length()
val usable = try { context.filesDir.usableSpace } catch (_: Throwable) { -1L }
if (usable in 0 until dataLength) {
val msg = "Not enough storage to install update. Need ${dataLength / 1_048_576L}MB, have ${usable / 1_048_576L}MB free."
Logger.w(TAG, msg)
withContext(Dispatchers.Main) {
UIDialogs.toast(context, msg)
}
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, msg)
return@launch
}
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
@@ -68,7 +80,6 @@ object UpdateInstaller {
session = packageInstaller.openSession(sessionId) session = packageInstaller.openSession(sessionId)
inputStream = apkFile.inputStream() inputStream = apkFile.inputStream()
val dataLength = apkFile.length()
session.openWrite("package", 0, dataLength).use { sessionStream -> session.openWrite("package", 0, dataLength).use { sessionStream ->
inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> } inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
@@ -91,11 +102,18 @@ object UpdateInstaller {
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Exception while installing update", e) Logger.w(TAG, "Exception while installing update", e)
session?.abandon() session?.abandon()
val raw = e.message ?: ""
val friendly = if (raw.contains("Failed to allocate") || raw.contains("allocatable") || raw.contains("ENOSPC", ignoreCase = true)) {
"Not enough storage to install update. Free up some space and try again."
} else {
"Failed to install update: $raw"
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to install update: ${e.message}") UIDialogs.toast(context, friendly)
} }
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message) UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, friendly)
} finally { } finally {
session?.close() session?.close()
inputStream?.close() inputStream?.close()
@@ -110,10 +128,12 @@ object UpdateInstaller {
if (result.isNullOrEmpty()) { if (result.isNullOrEmpty()) {
Logger.i(TAG, "Update install finished successfully") Logger.i(TAG, "Update install finished successfully")
UpdateNotificationManager.showInstallSucceededNotification(context, version) UpdateNotificationManager.showInstallSucceededNotification(context, version)
StateUpdate.instance.clearUi()
} else { } else {
Logger.w(TAG, "Update install failed: $result") Logger.w(TAG, "Update install failed: $result")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result) UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result") UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
StateUpdate.instance.setUiReady(version, apkFile)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to handle install result", e) Logger.e(TAG, "Failed to handle install result", e)
@@ -22,9 +22,6 @@ object UpdateNotificationManager {
private const val CHANNEL_NAME = "App updates" private const val CHANNEL_NAME = "App updates"
private const val CHANNEL_DESCRIPTION = "Notifications about new app versions" private const val CHANNEL_DESCRIPTION = "Notifications about new app versions"
const val ACTION_UPDATE_YES = "com.futo.platformplayer.UPDATE_YES"
const val ACTION_UPDATE_NO = "com.futo.platformplayer.UPDATE_NO"
const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER"
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL" const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL" const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
private const val REQUEST_CODE_INSTALL = 1001 private const val REQUEST_CODE_INSTALL = 1001
@@ -32,7 +29,6 @@ object UpdateNotificationManager {
const val EXTRA_VERSION = "version" const val EXTRA_VERSION = "version"
const val EXTRA_APK_PATH = "apk_path" const val EXTRA_APK_PATH = "apk_path"
const val NOTIF_ID_AVAILABLE = 2001
const val NOTIF_ID_DOWNLOADING = 2002 const val NOTIF_ID_DOWNLOADING = 2002
const val NOTIF_ID_READY = 2003 const val NOTIF_ID_READY = 2003
const val NOTIF_ID_INSTALL_FAILED = 2004 const val NOTIF_ID_INSTALL_FAILED = 2004
@@ -84,43 +80,6 @@ object UpdateNotificationManager {
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build()) NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build())
} }
fun showUpdateAvailableNotification(context: Context, version: Int) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val yesIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_YES
putExtra(EXTRA_VERSION, version)
}
val yesPendingIntent = getBroadcast(context, 0, yesIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val noIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_NO
putExtra(EXTRA_VERSION, version)
}
val noPendingIntent = getBroadcast(context, 1, noIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val neverIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_NEVER
putExtra(EXTRA_VERSION, version)
}
val neverPendingIntent = getBroadcast(context, 2, neverIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Update available")
.setContentText("A new version ($version) is available.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setContentIntent(yesPendingIntent)
.setSilent(true)
.addAction(0, "Never", neverPendingIntent)
.addAction(0, "Not now", noPendingIntent)
.addAction(0, "Download", yesPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
}
fun buildDownloadProgressNotification(context: Context, version: Int, progress: Int, indeterminate: Boolean): Notification { fun buildDownloadProgressNotification(context: Context, version: Int, progress: Int, indeterminate: Boolean): Notification {
ensureChannel(context) ensureChannel(context)
@@ -223,11 +182,9 @@ object UpdateNotificationManager {
} }
fun cancelAll(context: Context) { fun cancelAll(context: Context) {
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING) NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY) NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED) NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED) NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
} }
} }
@@ -68,11 +68,15 @@ class CaptchaActivity : AppCompatActivity() {
intent.getStringExtra("body"); intent.getStringExtra("body");
else null; 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.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = 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 -> webViewClient.onCaptchaFinished.subscribe { captcha ->
_callback?.let { _callback?.let {
_callback = null; _callback = null;
@@ -61,11 +61,15 @@ class LoginActivity : AppCompatActivity() {
else throw IllegalStateException("No valid configuration?"); else throw IllegalStateException("No valid configuration?");
//TODO: Backwards compat removal? //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.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = 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 -> webViewClient.onLogin.subscribe { auth ->
_callback?.let { _callback?.let {
@@ -110,6 +110,7 @@ import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.stores.SubscriptionStorage import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView import com.futo.platformplayer.views.ToastView
import com.futo.platformplayer.views.notification.NotificationOverlayView
import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ApiMethods
import com.google.gson.JsonParser import com.google.gson.JsonParser
import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentIntegrator
@@ -201,6 +202,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragLibraryVideos: LibraryVideosFragment; lateinit var _fragLibraryVideos: LibraryVideosFragment;
lateinit var _fragLibrarySearch: LibrarySearchFragment; lateinit var _fragLibrarySearch: LibrarySearchFragment;
lateinit var _fragLibraryFiles: LibraryFilesFragment; lateinit var _fragLibraryFiles: LibraryFilesFragment;
lateinit var _fragNotifications: NotificationOverlayView.Frag;
lateinit var _fragSettings: SettingsFragment; lateinit var _fragSettings: SettingsFragment;
lateinit var _fragDeveloper: DeveloperFragment; lateinit var _fragDeveloper: DeveloperFragment;
lateinit var _fragLogin: LoginFragment; lateinit var _fragLogin: LoginFragment;
@@ -389,6 +391,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragLibraryVideos = LibraryVideosFragment.newInstance(); _fragLibraryVideos = LibraryVideosFragment.newInstance();
_fragLibraryFiles = LibraryFilesFragment.newInstance(); _fragLibraryFiles = LibraryFilesFragment.newInstance();
_fragLibrarySearch = LibrarySearchFragment.newInstance(); _fragLibrarySearch = LibrarySearchFragment.newInstance();
_fragNotifications = NotificationOverlayView.Frag();
_fragSettings = SettingsFragment.newInstance(); _fragSettings = SettingsFragment.newInstance();
_fragDeveloper = DeveloperFragment.newInstance(); _fragDeveloper = DeveloperFragment.newInstance();
_fragLogin = LoginFragment.newInstance(); _fragLogin = LoginFragment.newInstance();
@@ -538,6 +541,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragLibrarySearch.topBar = _fragTopBarSearch; _fragLibrarySearch.topBar = _fragTopBarSearch;
_fragSettings.topBar = _fragTopBarNavigation; _fragSettings.topBar = _fragTopBarNavigation;
_fragDeveloper.topBar = _fragTopBarNavigation; _fragDeveloper.topBar = _fragTopBarNavigation;
_fragNotifications.topBar = _fragTopBarGeneral;
_fragBrowser.topBar = _fragTopBarNavigation; _fragBrowser.topBar = _fragTopBarNavigation;
@@ -1368,6 +1372,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
LibraryVideosFragment::class -> _fragLibraryVideos as T; LibraryVideosFragment::class -> _fragLibraryVideos as T;
LibraryFilesFragment::class -> _fragLibraryFiles as T; LibraryFilesFragment::class -> _fragLibraryFiles as T;
LibrarySearchFragment::class -> _fragLibrarySearch as T; LibrarySearchFragment::class -> _fragLibrarySearch as T;
NotificationOverlayView.Frag::class -> _fragNotifications as T;
SettingsFragment:: class -> _fragSettings as T; SettingsFragment:: class -> _fragSettings as T;
DeveloperFragment::class -> _fragDeveloper as T; DeveloperFragment::class -> _fragDeveloper as T;
LoginFragment::class -> _fragLogin as T; LoginFragment::class -> _fragLogin as T;
@@ -23,6 +23,7 @@ import java.time.Duration
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager import javax.net.ssl.X509TrustManager
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
open class ManagedHttpClient { open class ManagedHttpClient {
@@ -89,10 +90,16 @@ open class ManagedHttpClient {
return clonedClient; return clonedClient;
} }
fun tryHead(url: String): Map<String, String>? { private fun applyModifier(url: String, headers: MutableMap<String, String>, modifier: IRequestModifier?): Pair<String, MutableMap<String, String>> {
if (modifier == null) return Pair(url, headers)
val modified = modifier.modifyRequest(url, headers)
return Pair(modified.url ?: url, modified.headers.toMutableMap())
}
fun tryHead(url: String, modifier: IRequestModifier? = null): Map<String, String>? {
ensureNotMainThread() ensureNotMainThread()
try { try {
val result = head(url); val result = head(url, HashMap(), modifier);
if(result.isOk) if(result.isOk)
return result.getHeadersFlat(); return result.getHeadersFlat();
else else
@@ -141,12 +148,14 @@ open class ManagedHttpClient {
return Socket(websocket); return Socket(websocket);
} }
fun get(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response { fun get(url : String, headers : MutableMap<String, String> = HashMap<String, String>(), modifier: IRequestModifier? = null) : Response {
return execute(Request(url, "GET", null, headers)); val (finalUrl, finalHeaders) = applyModifier(url, headers, modifier)
return execute(Request(finalUrl, "GET", null, finalHeaders));
} }
fun head(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response { fun head(url : String, headers : MutableMap<String, String> = HashMap<String, String>(), modifier: IRequestModifier? = null) : Response {
return execute(Request(url, "HEAD", null, headers)); val (finalUrl, finalHeaders) = applyModifier(url, headers, modifier)
return execute(Request(finalUrl, "HEAD", null, finalHeaders));
} }
fun post(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response { fun post(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
@@ -55,6 +55,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptException import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptValidationException import com.futo.platformplayer.engine.exceptions.ScriptValidationException
import com.futo.platformplayer.engine.packages.PackageBridge
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.states.AnnouncementType import com.futo.platformplayer.states.AnnouncementType
@@ -156,6 +157,7 @@ open class JSClient : IPlatformClient {
_httpClient = JSHttpClient(this, null, _captcha, config); _httpClient = JSHttpClient(this, null, _captcha, config);
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config); _httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth); _plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
_plugin.bridge.descriptor = descriptor;
_plugin.withDependency(context, "scripts/polyfil.js"); _plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js"); _plugin.withDependency(context, "scripts/source.js");
@@ -189,6 +191,7 @@ open class JSClient : IPlatformClient {
_httpClient = JSHttpClient(this, null, _captcha, config); _httpClient = JSHttpClient(this, null, _captcha, config);
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config); _httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth); _plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
_plugin.bridge.descriptor = descriptor;
_plugin.withDependency(context, "scripts/polyfil.js"); _plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js"); _plugin.withDependency(context, "scripts/source.js");
_plugin.withScript(script); _plugin.withScript(script);
@@ -485,13 +488,14 @@ open class JSClient : IPlatformClient {
if (_peekChannelTypes != null) { if (_peekChannelTypes != null) {
return _peekChannelTypes!!; return _peekChannelTypes!!;
} }
val arr: V8ValueArray = plugin.executeTyped("source.getPeekChannelTypes()"); return busy {
val arr: V8ValueArray = plugin.executeTyped("source.getPeekChannelTypes()");
_peekChannelTypes = arr.keys.mapNotNull { _peekChannelTypes = arr.keys.mapNotNull {
val str = arr.get<V8ValueString>(it); val str = arr.get<V8ValueString>(it);
return@mapNotNull str.value; return@mapNotNull str.value;
}; };
return _peekChannelTypes ?: listOf(); return@busy _peekChannelTypes ?: listOf();
}
} }
catch(ex: Throwable) { catch(ex: Throwable) {
announcePluginUnhandledException("getPeekChannelTypes", ex); announcePluginUnhandledException("getPeekChannelTypes", ex);
@@ -520,10 +524,12 @@ open class JSClient : IPlatformClient {
if(!capabilities.hasGetChannelUrlByClaim) if(!capabilities.hasGetChannelUrlByClaim)
throw IllegalStateException("This plugin does not support channel url by claim"); throw IllegalStateException("This plugin does not support channel url by claim");
val value = plugin.executeTyped<V8Value>("source.getChannelUrlByClaim(${claimType}, ${Json.encodeToString(claimValues)})"); return busy {
if(value !is V8ValueString) val value = plugin.executeTyped<V8Value>("source.getChannelUrlByClaim(${claimType}, ${Json.encodeToString(claimValues)})");
return null; if(value !is V8ValueString)
return value.value; return@busy null;
return@busy value.value;
}
} }
@JSOptional @JSOptional
@JSDocs(12, "source.getChannelTemplateByClaimMap()", "Get a map for every supported claimtype mapping field to urls") @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) if(!capabilities.hasGetChannelTemplateByClaimMap)
throw IllegalStateException("This plugin does not support channel template by claim map"); throw IllegalStateException("This plugin does not support channel template by claim map");
val value = plugin.executeTyped<V8Value>("source.getChannelTemplateByClaimMap()"); return busy {
if(value !is V8ValueObject) val value = plugin.executeTyped<V8Value>("source.getChannelTemplateByClaimMap()");
return mapOf(); 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; val keys = value.ownPropertyNames;
for(key in keys.toArray()) { for(key in keys.toArray()) {
if(key is V8ValueInteger) { if(key is V8ValueInteger) {
val map = value.get<V8ValueObject>(key); val map = value.get<V8ValueObject>(key);
val mapKeys = map.ownPropertyNames; val mapKeys = map.ownPropertyNames;
claimTypes[key.value] = mapKeys.toArray().filter { claimTypes[key.value] = mapKeys.toArray().filter {
it is V8ValueInteger it is V8ValueInteger
}.associate { }.associate {
val mapKey = (it as V8ValueInteger).value; val mapKey = (it as V8ValueInteger).value;
return@associate Pair(mapKey, map.getString(mapKey)); 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") @JSDocs(22, "source.getUserPlaylists()", "Gets the playlist of the current user")
override fun getUserPlaylists(): Array<String> { override fun getUserPlaylists(): Array<String> {
ensureEnabled(); ensureEnabled();
return plugin.executeTyped<V8ValueArray>("source.getUserPlaylists()") return busy {
.toArray() return@busy plugin.executeTyped<V8ValueArray>("source.getUserPlaylists()")
.map { (it as V8ValueString).value } .toArray()
.toTypedArray(); .map { (it as V8ValueString).value }
.toTypedArray();
}
} }
@JSOptional @JSOptional
@JSDocs(23, "source.getUserSubscriptions()", "Gets the subscriptions of the current user") @JSDocs(23, "source.getUserSubscriptions()", "Gets the subscriptions of the current user")
override fun getUserSubscriptions(): Array<String> { override fun getUserSubscriptions(): Array<String> {
ensureEnabled(); ensureEnabled();
return plugin.executeTyped<V8ValueArray>("source.getUserSubscriptions()") return busy {
.toArray() return@busy plugin.executeTyped<V8ValueArray>("source.getUserSubscriptions()")
.map { (it as V8ValueString).value } .toArray()
.toTypedArray(); .map { (it as V8ValueString).value }
.toTypedArray();
}
} }
@JSOptional @JSOptional
@JSDocs(23, "source.getUserHistory()", "Gets the history of the current user") @JSDocs(23, "source.getUserHistory()", "Gets the history of the current user")
override fun getUserHistory(): IPager<IPlatformContent> { override fun getUserHistory(): IPager<IPlatformContent> {
ensureEnabled(); ensureEnabled();
return JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()")); return isBusyWith("getUserHistory") {
return@isBusyWith JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
}
} }
fun validate() { fun validate() {
@@ -5,9 +5,9 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json 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 { override fun toString(): String {
return "(headers: '$headers', cookieString: '$cookieMap')"; return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
} }
fun toEncrypted(): String{ fun toEncrypted(): String{
@@ -15,23 +15,25 @@ data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? =
} }
private fun serialize(): String { private fun serialize(): String {
return Json.encodeToString(SerializedAuth(cookieMap, headers)); return Json.encodeToString(SerializedAuth(cookieMap, headers, userAgent));
} }
companion object { companion object {
val TAG = "SourceAuth"; val TAG = "SourceAuth";
private val _json = Json { ignoreUnknownKeys = true };
fun fromEncrypted(encrypted: String?): SourceAuth? { fun fromEncrypted(encrypted: String?): SourceAuth? {
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) }; return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
} }
private fun deserialize(str: String): SourceAuth { private fun deserialize(str: String): SourceAuth {
val data = Json.decodeFromString<SerializedAuth>(str); val data = _json.decodeFromString<SerializedAuth>(str);
return SourceAuth(data.cookieMap, data.headers); return SourceAuth(data.cookieMap, data.headers, data.userAgent);
} }
} }
@Serializable @Serializable
data class SerializedAuth(val cookieMap: HashMap<String, HashMap<String, String>>?, 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.encodeToString
import kotlinx.serialization.json.Json 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 { override fun toString(): String {
return "(headers: '$headers', cookieString: '$cookieMap')"; return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
} }
fun toEncrypted(): String{ fun toEncrypted(): String{
@@ -15,23 +15,25 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
} }
private fun serialize(): String { private fun serialize(): String {
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers)); return Json.encodeToString(SerializedCaptchaData(cookieMap, headers, userAgent));
} }
companion object { companion object {
val TAG = "SourceCaptchaData"; val TAG = "SourceCaptchaData";
private val _json = Json { ignoreUnknownKeys = true };
fun fromEncrypted(encrypted: String?): SourceCaptchaData? { fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) }; return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
} }
fun deserialize(str: String): SourceCaptchaData { fun deserialize(str: String): SourceCaptchaData {
val data = Json.decodeFromString<SerializedCaptchaData>(str); val data = _json.decodeFromString<SerializedCaptchaData>(str);
return SourceCaptchaData(data.cookieMap, data.headers); return SourceCaptchaData(data.cookieMap, data.headers, data.userAgent);
} }
} }
@Serializable @Serializable
data class SerializedCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>?, 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)
} }
@@ -61,6 +61,11 @@ class SourcePluginConfig(
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl); val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
val absoluteScriptUrl: String get() = resolveAbsoluteUrl(scriptUrl, sourceUrl)!!; val absoluteScriptUrl: String get() = resolveAbsoluteUrl(scriptUrl, sourceUrl)!!;
fun isOfficialAuthor(): Boolean {
return scriptSignature != null &&
scriptPublicKey == "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsoFJU4AReDyUnSQI9A99UjLCwkY8OH+1o8cdtf2EjSb+fO2qmP8MGMTAvfvgmq5d2QBJE2XHRkRO3JKcTlcc1j0WlOlU8P9W272DYCeX6oYaavpKNqGKoGEuodp9wtiyNwyH46++JfpU/uIUacZbZKkHv9gIGchmNvpKYZQjFd/8pUqXGpcXZP54tGSC9PLcY+5TozZThK7Oy1+3YEf1bZ44UinRYYATbLk/wNuAfsupvlt6nxZOcJhABhdo9V+gY0FE6Ayg5+1cd1noWhnRtLF+sPdEr3z8Nt15JEK5a/524t25FMhwz8yKxlGW5qW3QLJHSUgLQncL6a1zlZ1s8QIDAQAB"
}
private fun resolveAbsoluteUrl(url: String?, sourceUrl: String?): String? { private fun resolveAbsoluteUrl(url: String?, sourceUrl: String?): String? {
if(url == null) if(url == null)
return null; return null;
@@ -165,6 +170,12 @@ class SourcePluginConfig(
"Unrestricted Http Header access", "Unrestricted Http Header access",
"Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests." "Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests."
)) ))
/*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; return list;
} }
@@ -224,7 +235,8 @@ class SourcePluginConfig(
val variable: String? = null, val variable: String? = null,
val dependency: String? = null, val dependency: String? = null,
val warningDialog: 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; val variableOrName: String get() = variable ?: name;
} }
@@ -100,7 +100,7 @@ class SourcePluginDescriptor {
@FormField(R.string.check_for_updates_setting, FieldForm.TOGGLE, R.string.check_for_updates_setting_description, -1) @FormField(R.string.check_for_updates_setting, FieldForm.TOGGLE, R.string.check_for_updates_setting_description, -1)
var checkForUpdates: Boolean = true; var checkForUpdates: Boolean = true;
@FormField(R.string.automatic_update_setting, FieldForm.TOGGLE, R.string.automatic_update_setting_description, 0) @FormField(R.string.automatic_update_setting, FieldForm.TOGGLE, R.string.automatic_update_setting_description, 0)
var automaticUpdate: Boolean = false; var automaticUpdate: Boolean = true;
@FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2) @FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2)
var tabEnabled = TabEnabled(); var tabEnabled = TabEnabled();
@@ -21,6 +21,7 @@ import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
open class JSArticle( open class JSArticle(
@@ -34,7 +35,7 @@ open class JSArticle(
obj.getOrDefault<String>(config, "summary", "PlatformArticle", "") ?: "" obj.getOrDefault<String>(config, "summary", "PlatformArticle", "") ?: ""
override val thumbnails: Thumbnails? = override val thumbnails: Thumbnails? =
if (obj.has("thumbnails")) if (obj.getSourcePlugin()?.busy { obj.has("thumbnails") } ?: obj.has("thumbnails"))
Thumbnails.fromV8( Thumbnails.fromV8(
config, config,
obj.getOrThrow<V8ValueObject>(config, "thumbnails", "PlatformArticle") obj.getOrThrow<V8ValueObject>(config, "thumbnails", "PlatformArticle")
@@ -31,18 +31,20 @@ open class JSArticleDetails(
final override val contentType: ContentType = ContentType.ARTICLE final override val contentType: ContentType = ContentType.ARTICLE
private val _hasGetComments: Boolean = _content.has("getComments") private val _hasGetComments: Boolean = client.busy { _content.has("getComments") }
private val _hasGetContentRecommendations: Boolean = _content.has("getContentRecommendations") 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) obj.getOrDefault<V8ValueObject>(client.config, "rating", "PlatformArticle", null)
?.let { IRating.fromV8(client.config, it, "PlatformArticle") } ?.let { IRating.fromV8(client.config, it, "PlatformArticle") }
?: RatingLikes(0) ?: RatingLikes(0)
}
override val summary: String = override val summary: String = client.busy {
_content.getOrThrow(client.config, "summary", "PlatformArticle") _content.getOrThrow(client.config, "summary", "PlatformArticle")
}
override val thumbnails: Thumbnails? = override val thumbnails: Thumbnails? = client.busy {
if (_content.has("thumbnails")) if (_content.has("thumbnails"))
Thumbnails.fromV8( Thumbnails.fromV8(
client.config, client.config,
@@ -50,14 +52,19 @@ open class JSArticleDetails(
) )
else else
null null
}
override val segments: List<IJSArticleSegment> = override val segments: List<IJSArticleSegment> = client.busy {
obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", "PlatformArticle") obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", "PlatformArticle")
?.mapNotNull { fromV8Segment(client, it) } ?.mapNotNull { fromV8Segment(client, it) }
?: emptyList() ?: emptyList()
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? { override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
if(!_hasGetComments || _content.isClosed) val canGetComments = this.client.busy {
_hasGetComments && !_content.isClosed
}
if(!canGetComments)
return null; return null;
if(client is DevJSClient) if(client is DevJSClient)
@@ -73,7 +80,10 @@ open class JSArticleDetails(
override fun getPlaybackTracker(): IPlaybackTracker? = null; override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? { override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
if(!_hasGetContentRecommendations || _content.isClosed) val canGetContentRecommendations = this.client.busy {
_hasGetContentRecommendations && !_content.isClosed
}
if(!canGetContentRecommendations)
return null; return null;
if(client is DevJSClient) if(client is DevJSClient)
@@ -87,25 +97,31 @@ open class JSArticleDetails(
} }
private fun getContentRecommendationsJS(client: JSClient): JSContentPager { private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>()); return client.busy {
return JSContentPager(_pluginConfig, client, contentPager); val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return@busy JSContentPager(_pluginConfig, client, contentPager);
}
} }
private fun getCommentsJS(client: JSClient): JSCommentPager { private fun getCommentsJS(client: JSClient): JSCommentPager {
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>()); return client.busy {
return JSCommentPager(_pluginConfig, client, commentPager); val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return@busy JSCommentPager(_pluginConfig, client, commentPager);
}
} }
companion object { companion object {
fun fromV8Segment(client: JSClient, obj: V8ValueObject): IJSArticleSegment? { fun fromV8Segment(client: JSClient, obj: V8ValueObject): IJSArticleSegment? {
if(!obj.has("type")) return client.busy {
throw IllegalArgumentException("Object missing type field"); if(!obj.has("type"))
return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) { throw IllegalArgumentException("Object missing type field");
SegmentType.TEXT -> JSTextSegment(client, obj); return@busy when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
SegmentType.IMAGES -> JSImagesSegment(client, obj); SegmentType.TEXT -> JSTextSegment(client, obj);
SegmentType.HEADER -> JSHeaderSegment(client, obj); SegmentType.IMAGES -> JSImagesSegment(client, obj);
SegmentType.NESTED -> JSNestedSegment(client, obj); SegmentType.HEADER -> JSHeaderSegment(client, obj);
else -> null; SegmentType.NESTED -> JSNestedSegment(client, obj);
else -> null;
}
} }
} }
} }
@@ -46,23 +46,45 @@ class JSComment : IPlatformComment {
_comment = obj; _comment = obj;
_plugin = plugin; _plugin = plugin;
val contextName = "Comment"; var parsedContextUrl: String? = null;
contextUrl = _comment!!.getOrThrow(config, "contextUrl", contextName); var parsedAuthor: PlatformAuthorLink? = null;
author = PlatformAuthorLink.fromV8(_config!!, _comment!!.getOrThrow(config, "author", contextName)); var parsedMessage: String? = null;
message = _comment!!.getOrThrow(config, "message", contextName); var parsedRating: IRating? = null;
rating = IRating.fromV8(config, _comment!!.getOrThrow(config, "rating", contextName)); var parsedDate: OffsetDateTime? = null;
date = _comment!!.getOrThrowNullable<Int>(config, "date", contextName)?.let { OffsetDateTime.of(LocalDateTime.ofEpochSecond(it.toLong(), 0, ZoneOffset.UTC), ZoneOffset.UTC) } var parsedReplyCount: Int? = null;
replyCount = _comment!!.getOrThrowNullable(config, "replyCount", contextName); var parsedContext: Map<String, String>? = null;
context = _comment!!.getOrDefault(config, "context", contextName, hashMapOf()) ?: hashMapOf(); var parsedHasGetReplies = false;
_hasGetReplies = _comment!!.has("getReplies");
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>? { override fun getReplies(client: IPlatformClient): IPager<IPlatformComment>? {
if(!_hasGetReplies) if(!_hasGetReplies)
return null; return null;
val obj = _comment!!.invokeV8<V8ValueObject>("getReplies", arrayOf<Any>());
val plugin = if(client is JSClient) client else throw NotImplementedError("Only implemented for JSClient"); 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.decodeUnicode
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
@@ -23,7 +24,8 @@ open class JSContent(
override val contentType: ContentType = ContentType.UNKNOWN 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 = override val id: PlatformID =
PlatformID.fromV8(_pluginConfig, _content.getOrThrow(_pluginConfig, "id", CTX)) PlatformID.fromV8(_pluginConfig, _content.getOrThrow(_pluginConfig, "id", CTX))
@@ -41,7 +41,9 @@ abstract class JSPager<T> : IPager<T> {
} }
override fun hasMorePages(): Boolean { override fun hasMorePages(): Boolean {
return _hasMorePages && !pager.isClosed; return plugin.getUnderlyingPlugin().busy {
_hasMorePages && !pager.isClosed;
}
} }
override fun nextPage() { override fun nextPage() {
@@ -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.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
@@ -30,52 +31,79 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { 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); val parse = {
textType = TextType.fromInt((obj.getOrDefault<Int>(config, "textType", contextName, null) ?: 0)); val contextName = "PlatformPostDetails";
content = obj.getOrDefault(config, "content", contextName, "") ?: "";
_hasGetComments = _content.has("getComments"); parsedRating = obj.getOrDefault<V8ValueObject>(config, "rating", contextName, null)?.let { IRating.fromV8(config, it, contextName) } ?: RatingLikes(0);
_hasGetContentRecommendations = _content.has("getContentRecommendations"); 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>? { 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; return null;
if(client is DevJSClient) if(client is DevJSClient)
return StateDeveloper.instance.handleDevCall(client.devID, "videoDetail.getComments()") { return StateDeveloper.instance.handleDevCall(client.devID, "videoDetail.getComments()") {
return@handleDevCall getCommentsJS(client); return@handleDevCall getCommentsJS(client);
} }
else if(client is JSClient) return getCommentsJS(jsClient);
return getCommentsJS(client);
return null;
} }
override fun getPlaybackTracker(): IPlaybackTracker? = null; override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? { 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; return null;
if(client is DevJSClient) if(client is DevJSClient)
return StateDeveloper.instance.handleDevCall(client.devID, "postDetail.getContentRecommendations()") { return StateDeveloper.instance.handleDevCall(client.devID, "postDetail.getContentRecommendations()") {
return@handleDevCall getContentRecommendationsJS(client); return@handleDevCall getContentRecommendationsJS(client);
} }
else if(client is JSClient) return getContentRecommendationsJS(jsClient);
return getContentRecommendationsJS(client);
return null;
} }
private fun getContentRecommendationsJS(client: JSClient): JSContentPager { private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>()); return client.busy {
return JSContentPager(_pluginConfig, client, contentPager); val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return@busy JSContentPager(_pluginConfig, client, contentPager);
}
} }
private fun getCommentsJS(client: JSClient): JSCommentPager { private fun getCommentsJS(client: JSClient): JSCommentPager {
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>()); return client.busy {
return JSCommentPager(_pluginConfig, client, commentPager); 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; this._config = plugin.config;
val 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")) if(!executor.has("executeRequest"))
throw ScriptImplementationException(config, "RequestExecutor is missing executeRequest", null); throw ScriptImplementationException(config, "RequestExecutor is missing executeRequest", null);
hasCleanup = executor.has("cleanup"); parsedHasCleanup = executor.has("cleanup");
}
urlPrefix = parsedUrlPrefix;
hasCleanup = parsedHasCleanup;
} }
//TODO: Executor properties? //TODO: Executor properties?
@Throws(ScriptException::class) @Throws(ScriptException::class)
open fun executeRequest(method: String, url: String, body: ByteArray?, headers: Map<String, String>): ByteArray { 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 { return _plugin.getUnderlyingPlugin().busy {
if (_executor.isClosed)
throw IllegalStateException("Executor object is closed");
val result = if(_plugin is DevJSClient) val result = if(_plugin is DevJSClient)
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") { StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
@@ -108,10 +114,12 @@ class JSRequestExecutor: AutoCloseable {
open fun cleanup() { open fun cleanup() {
synchronized(_cleanLock) { _plugin.busy {
if (!hasCleanup || _executor.isClosed || _cleaned) synchronized(_cleanLock) {
return; if (!hasCleanup || _executor.isClosed || _cleaned)
_cleaned = true; return@busy;
_cleaned = true;
}
} }
Logger.i("JSRequestExecutor", "JSRequestExecutor cleanup requested"); Logger.i("JSRequestExecutor", "JSRequestExecutor cleanup requested");
_plugin.busy { _plugin.busy {
@@ -36,11 +36,11 @@ class JSRequestModifier: IRequestModifier {
} }
override fun modifyRequest(url: String, headers: Map<String, String>): IRequest { override fun modifyRequest(url: String, headers: Map<String, String>): IRequest {
if (_modifier.isClosed) {
return Request(url, headers);
}
return _plugin.busy { return _plugin.busy {
if (_modifier.isClosed) {
return@busy Request(url, headers);
}
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") { val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") {
_modifier.invokeV8("modifyRequest", url, headers); _modifier.invokeV8("modifyRequest", url, headers);
} as V8ValueObject; } as V8ValueObject;
@@ -29,12 +29,28 @@ class JSSubtitleSource : ISubtitleSource {
constructor(config: SourcePluginConfig, v8Value: V8ValueObject) { constructor(config: SourcePluginConfig, v8Value: V8ValueObject) {
_obj = v8Value; _obj = v8Value;
val context = "JSSubtitles"; var parsedName: String? = null;
name = v8Value.getOrThrow(config, "name", context, false); var parsedLanguage: String? = null;
language = v8Value.getOrDefault(config, "language", context, null); var parsedUrl: String? = null;
url = v8Value.getOrThrow(config, "url", context, true); var parsedFormat: String? = null;
format = v8Value.getOrThrow(config, "format", context, true); var parsedHasFetch = false;
hasFetch = v8Value.has("getSubtitles"); 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 { override fun getSubtitles(): String {
@@ -52,34 +52,63 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
override val subtitles: List<ISubtitleSource>; override val subtitles: List<ISubtitleSource>;
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) { constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) {
val contextName = "VideoDetails";
_plugin = plugin; _plugin = plugin;
val config = plugin.config; var parsedDescription: String? = null;
description = _content.getOrThrow(config, "description", contextName); var parsedVideo: IVideoSourceDescriptor? = null;
video = JSVideoSourceDescriptor.fromV8(plugin, _content.getOrThrow(config, "video", contextName)); var parsedDash: IDashManifestSource? = null;
dash = JSSource.fromV8DashNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "dash", contextName)); var parsedHls: IHLSManifestSource? = null;
hls = JSSource.fromV8HLSNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "hls", contextName)); var parsedLive: IVideoSource? = null;
live = JSSource.fromV8VideoNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "live", contextName)); var parsedRating: IRating? = null;
rating = IRating.fromV8OrDefault(config, _content.getOrDefault<V8ValueObject>(config, "rating", contextName, null), RatingLikes(0)); var parsedSubtitles: List<ISubtitleSource>? = null;
var parsedHasGetComments = false;
var parsedHasGetPlaybackTracker = false;
var parsedHasGetContentRecommendations = false;
var parsedHasGetVODEvents = false;
if(!_content.has("subtitles")) plugin.busy {
subtitles = listOf(); val contextName = "VideoDetails";
else { val config = plugin.config;
val subArrs = _content.getOrThrowNullable<V8ValueArray>(config, "subtitles", contextName); parsedDescription = _content.getOrThrow(config, "description", contextName);
if(subArrs != null) parsedVideo = JSVideoSourceDescriptor.fromV8(plugin, _content.getOrThrow(config, "video", contextName));
subtitles = subArrs.keys.map { JSSubtitleSource.fromV8(config, subArrs.get(it)) }; parsedDash = JSSource.fromV8DashNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "dash", contextName));
else parsedHls = JSSource.fromV8HLSNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "hls", contextName));
subtitles = listOf(); 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"); description = parsedDescription ?: "";
_hasGetPlaybackTracker = _content.has("getPlaybackTracker"); video = parsedVideo ?: throw IllegalStateException("Missing video source descriptor");
_hasGetContentRecommendations = _content.has("getContentRecommendations"); dash = parsedDash;
_hasGetVODEvents = _content.has("getVODEvents"); hls = parsedHls;
live = parsedLive;
rating = parsedRating ?: RatingLikes(0);
subtitles = parsedSubtitles ?: listOf();
_hasGetComments = parsedHasGetComments;
_hasGetPlaybackTracker = parsedHasGetPlaybackTracker;
_hasGetContentRecommendations = parsedHasGetContentRecommendations;
_hasGetVODEvents = parsedHasGetVODEvents;
} }
override fun getPlaybackTracker(): IPlaybackTracker? { override fun getPlaybackTracker(): IPlaybackTracker? {
if(!_hasGetPlaybackTracker || _content.isClosed) val canGetPlaybackTracker = _plugin.busy {
_hasGetPlaybackTracker && !_content.isClosed
}
if(!canGetPlaybackTracker)
return null; return null;
if(_pluginConfig.id == StateDeveloper.DEV_ID) if(_pluginConfig.id == StateDeveloper.DEV_ID)
return StateDeveloper.instance.handleDevCall(_pluginConfig.id, "videoDetail.getComments()") { return StateDeveloper.instance.handleDevCall(_pluginConfig.id, "videoDetail.getComments()") {
@@ -102,7 +131,10 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
} }
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? { override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
if(!_hasGetContentRecommendations || _content.isClosed) val canGetContentRecommendations = _plugin.busy {
_hasGetContentRecommendations && !_content.isClosed
}
if(!canGetContentRecommendations)
return null; return null;
if(client is DevJSClient) if(client is DevJSClient)
@@ -122,7 +154,12 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
} }
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? { 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; return null;
if(client is DevJSClient) if(client is DevJSClient)
@@ -39,10 +39,10 @@ open class JSAudioUrlSource(
?: "$container $bitrate" ?: "$container $bitrate"
override var priority: Boolean = 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 = 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 override fun getAudioUrl(): String = url
@@ -19,21 +19,23 @@ class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource {
val config = plugin.config val config = plugin.config
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName) licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor") hasLicenseRequestExecutor = plugin.busy { obj.has("getLicenseRequestExecutor") }
} }
override fun getLicenseRequestExecutor(): JSRequestExecutor? { override fun getLicenseRequestExecutor(): JSRequestExecutor? {
if (!hasLicenseRequestExecutor || _obj.isClosed) return _plugin.busy {
return null if (!hasLicenseRequestExecutor || _obj.isClosed)
return@busy null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") { val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>()) _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 { override fun toString(): String {
@@ -54,8 +54,8 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0; duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false; priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN; language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false; original = obj.getOrNull(config, "original", contextName) ?: false;
hasGenerate = _obj.has("generate"); hasGenerate = plugin.busy { _obj.has("generate") };
} }
private var _pregenerate: V8Deferred<String?>? = null; private var _pregenerate: V8Deferred<String?>? = null;
@@ -67,7 +67,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> { override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
if(!hasGenerate) if(!hasGenerate)
return V8Deferred(CompletableDeferred(manifest)); return V8Deferred(CompletableDeferred(manifest));
if(_obj.isClosed) if(_plugin.busy { _obj.isClosed })
throw IllegalStateException("Source object already closed"); throw IllegalStateException("Source object already closed");
val pregenerated = _pregenerate; val pregenerated = _pregenerate;
@@ -111,7 +111,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
override fun generate(): String? { override fun generate(): String? {
if(!hasGenerate) if(!hasGenerate)
return manifest; return manifest;
if(_obj.isClosed) if(_plugin.busy { _obj.isClosed })
throw IllegalStateException("Source object already closed"); throw IllegalStateException("Source object already closed");
val plugin = _plugin.getUnderlyingPlugin(); val plugin = _plugin.getUnderlyingPlugin();
@@ -73,12 +73,13 @@ open class JSDashManifestRawSource(
override var manifest: String? = override var manifest: String? =
_obj.getOrDefault<String>(cfg, "manifest", ctx, null) _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 = val canMerge: Boolean =
_obj.getOrDefault<Boolean>(cfg, "canMerge", ctx, false) ?: false _obj.getOrDefault<Boolean>(cfg, "canMerge", ctx, false) ?: false
override var streamMetaData: StreamMetaData? = null override var streamMetaData: StreamMetaData? = null
var audioStreamMetaData: StreamMetaData? = null
private var _pregenerate: V8Deferred<String?>? = null private var _pregenerate: V8Deferred<String?>? = null
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? { fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
@@ -89,7 +90,7 @@ open class JSDashManifestRawSource(
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> { override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
if(!hasGenerate) if(!hasGenerate)
return V8Deferred(CompletableDeferred(manifest)); return V8Deferred(CompletableDeferred(manifest));
if(_obj.isClosed) if(_plugin.busy { _obj.isClosed })
throw IllegalStateException("Source object already closed"); throw IllegalStateException("Source object already closed");
val pregenerated = _pregenerate; val pregenerated = _pregenerate;
if(pregenerated != null) { if(pregenerated != null) {
@@ -125,6 +126,14 @@ open class JSDashManifestRawSource(
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd); 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 { return@busy result.convert {
it.value it.value
}; };
@@ -133,7 +142,7 @@ open class JSDashManifestRawSource(
override open fun generate(): String? { override open fun generate(): String? {
if(!hasGenerate) if(!hasGenerate)
return manifest; return manifest;
if(_obj.isClosed) if(_plugin.busy { _obj.isClosed })
throw IllegalStateException("Source object already closed"); throw IllegalStateException("Source object already closed");
var result: String? = null; var result: String? = null;
@@ -162,6 +171,14 @@ open class JSDashManifestRawSource(
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) { if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd); 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; return result;
@@ -42,24 +42,26 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
priority = obj.getOrNull(config, "priority", contextName) ?: false priority = obj.getOrNull(config, "priority", contextName) ?: false
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName) licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor") hasLicenseRequestExecutor = plugin.busy { obj.has("getLicenseRequestExecutor") }
language = _obj.getOrNull(config, "language", contextName); language = _obj.getOrNull(config, "language", contextName);
original = _obj.getOrNull(config, "original", contextName); original = _obj.getOrNull(config, "original", contextName);
} }
override fun getLicenseRequestExecutor(): JSRequestExecutor? { override fun getLicenseRequestExecutor(): JSRequestExecutor? {
if (!hasLicenseRequestExecutor || _obj.isClosed) return _plugin.busy {
return null if (!hasLicenseRequestExecutor || _obj.isClosed)
return@busy null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") { val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>()) _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 { override fun getVideoUrl(): String {
@@ -34,7 +34,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
language = _obj.getOrThrow(config, "language", contextName); language = _obj.getOrThrow(config, "language", contextName);
priority = obj.getOrNull(config, "priority", contextName) ?: false; priority = obj.getOrNull(config, "priority", contextName) ?: false;
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false; original = obj.getOrNull(config, "original", contextName) ?: false;
} }
@@ -44,15 +44,26 @@ abstract class JSSource {
this._obj = obj; this._obj = obj;
this.type = type; this.type = type;
_requestModifier = obj.getOrDefault<V8ValueObject>(_config, "requestModifier", "JSSource.requestModifier", null)?.let { var parsedRequestModifier: JSRequest? = null;
JSRequest(plugin, it, null, null, true); var parsedHasRequestModifier = false;
} var parsedRequestExecutor: JSRequest? = null;
hasRequestModifier = _requestModifier != null || obj.has("getRequestModifier"); 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 { parsedRequestExecutor = obj.getOrDefault<V8ValueObject>(_config, "requestExecutor", "JSSource.requestExecutor", null)?.let {
JSRequest(plugin, it, null, null, true); 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") { fun getRequestModifier(): IRequestModifier? = _plugin.isBusyWith("getRequestModifier") {
@@ -18,21 +18,23 @@ class JSVideoUrlWidevineSource : JSVideoUrlSource, IVideoUrlWidevineSource {
val config = plugin.config val config = plugin.config
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName) licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor") hasLicenseRequestExecutor = plugin.busy { obj.has("getLicenseRequestExecutor") }
} }
override fun getLicenseRequestExecutor(): JSRequestExecutor? { override fun getLicenseRequestExecutor(): JSRequestExecutor? {
if (!hasLicenseRequestExecutor || _obj.isClosed) return _plugin.busy {
return null if (!hasLicenseRequestExecutor || _obj.isClosed)
return@busy null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") { val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>()) _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 { override fun toString(): String {
@@ -1,330 +0,0 @@
package com.futo.platformplayer.casting
import android.os.Looper
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.net.InetAddress
import java.util.UUID
class AirPlayCastingDevice : CastingDeviceLegacy {
//See for more info: https://nto.github.io/AirPlay
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = false;
override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
private var _scopeIO: CoroutineScope? = null;
private var _started: Boolean = false;
private var _sessionId: String? = null;
private val _client = ManagedHttpClient();
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
this.addresses = addresses;
this.port = port;
}
constructor(deviceInfo: CastingDeviceInfo) : super() {
this.name = deviceInfo.name;
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
this.port = deviceInfo.port;
}
override fun getAddresses(): List<InetAddress> {
return addresses?.toList() ?: listOf();
}
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
return;
}
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
setTime(resumePosition);
setDuration(duration);
if (resumePosition > 0.0) {
val pos = resumePosition / duration;
Logger.i(TAG, "resumePosition: $resumePosition, duration: ${duration}, pos: $pos")
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: $pos");
} else {
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0");
}
if (speed != null) {
changeSpeed(speed)
}
}
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
throw NotImplementedError();
}
override fun seekVideo(timeSeconds: Double) {
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
return;
}
post("scrub?position=${timeSeconds}");
}
override fun resumeVideo() {
if (invokeInIOScopeIfRequired(::resumeVideo)) {
return;
}
isPlaying = true;
post("rate?value=1.000000");
}
override fun pauseVideo() {
if (invokeInIOScopeIfRequired(::pauseVideo)) {
return;
}
isPlaying = false;
post("rate?value=0.000000");
}
override fun stopVideo() {
if (invokeInIOScopeIfRequired(::stopVideo)) {
return;
}
post("stop");
}
override fun stopCasting() {
if (invokeInIOScopeIfRequired(::stopCasting)) {
return;
}
post("stop");
stop();
}
override fun start() {
val adrs = addresses ?: return;
if (_started) {
return;
}
_started = true;
_scopeIO?.cancel();
_scopeIO = CoroutineScope(Dispatchers.IO);
Logger.i(TAG, "Starting...");
_scopeIO?.launch {
try {
connectionState = CastConnectionState.CONNECTING;
while (_scopeIO?.isActive == true) {
try {
val connectedSocket = getConnectedSocket(adrs.toList(), port);
if (connectedSocket == null) {
delay(1000);
continue;
}
usedRemoteAddress = connectedSocket.inetAddress;
localAddress = connectedSocket.localAddress;
connectedSocket.close();
_sessionId = UUID.randomUUID().toString();
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
delay(1000);
}
}
while (_scopeIO?.isActive == true) {
try {
val progressInfo = getProgress();
if (progressInfo == null) {
connectionState = CastConnectionState.CONNECTING;
Logger.i(TAG, "Failed to retrieve progress from AirPlay device.");
delay(1000);
continue;
}
connectionState = CastConnectionState.CONNECTED;
val progressIndex = progressInfo.lowercase().indexOf("position: ");
if (progressIndex == -1) {
delay(1000);
continue;
}
val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue;
setTime(progress);
val durationIndex = progressInfo.lowercase().indexOf("duration: ");
if (durationIndex == -1) {
delay(1000);
continue;
}
val duration = progressInfo.substring(durationIndex + "duration: ".length).toDoubleOrNull() ?: continue;
setDuration(duration);
delay(1000);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get server info from AirPlay device.", e)
}
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to setup AirPlay device connection.", e)
}
};
Logger.i(TAG, "Started.");
}
override fun stop() {
Logger.i(TAG, "Stopping...");
connectionState = CastConnectionState.DISCONNECTED;
usedRemoteAddress = null;
localAddress = null;
_started = false;
_scopeIO?.cancel();
_scopeIO = null;
}
override fun changeSpeed(speed: Double) {
setSpeed(speed)
post("rate?value=$speed")
}
override fun getDeviceInfo(): CastingDeviceInfo {
return CastingDeviceInfo(name!!, CastProtocolType.AIRPLAY, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
}
private fun getProgress(): String? {
val info = get("scrub");
Logger.i(TAG, "Progress: ${info ?: "null"}");
return info;
}
private fun getPlaybackInfo(): String? {
val playbackInfo = get("playback-info");
Logger.i(TAG, "Playback info: ${playbackInfo ?: "null"}");
return playbackInfo;
}
private fun getServerInfo(): String? {
val serverInfo = get("server-info");
Logger.i(TAG, "Server info: ${serverInfo ?: "null"}");
return serverInfo;
}
private fun post(path: String): Boolean {
try {
val sessionId = _sessionId ?: return false;
val headers = hashMapOf(
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
"User-Agent" to "MediaControl/1.0",
"Content-Length" to "0",
"X-Apple-Session-ID" to sessionId
);
val url = "http://${usedRemoteAddress}:${port}/${path}";
Logger.i(TAG, "POST $url");
val response = _client.post(url, headers);
if (!response.isOk) {
return false;
}
return true;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to POST $path");
return false;
}
}
private fun post(path: String, contentType: String, body: String): Boolean {
try {
val sessionId = _sessionId ?: return false;
val headers = hashMapOf(
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
"User-Agent" to "MediaControl/1.0",
"X-Apple-Session-ID" to sessionId,
"Content-Type" to contentType
);
val url = "http://${usedRemoteAddress}:${port}/${path}";
Logger.i(TAG, "POST $url:\n$body");
val response = _client.post(url, body, headers);
if (!response.isOk) {
return false;
}
return true;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to POST $path $body");
return false;
}
}
private fun get(path: String): String? {
val sessionId = _sessionId ?: return null;
try {
val headers = hashMapOf(
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
"Content-Length" to "0",
"User-Agent" to "MediaControl/1.0",
"X-Apple-Session-ID" to sessionId
);
val url = "http://${usedRemoteAddress}:${port}/${path}";
Logger.i(TAG, "GET $url");
val response = _client.get(url, headers);
if (!response.isOk) {
return null;
}
if (response.body == null) {
return null;
}
return response.body.string();
} catch (e: Throwable) {
Logger.w(TAG, "Failed to GET $path");
return null;
}
}
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
if(Looper.getMainLooper().thread == Thread.currentThread()) {
_scopeIO?.launch { action(); }
return true;
}
return false;
}
companion object {
val TAG = "AirPlayCastingDevice";
}
}
@@ -1,62 +1,217 @@
package com.futo.platformplayer.casting package com.futo.platformplayer.casting
import android.os.Build
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import org.fcast.sender_sdk.Metadata import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.fcast.sender_sdk.ApplicationInfo
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice
import org.fcast.sender_sdk.KeyEvent
import org.fcast.sender_sdk.MediaEvent
import java.net.InetAddress import java.net.InetAddress
import org.fcast.sender_sdk.PlaybackState
import org.fcast.sender_sdk.Source
import org.fcast.sender_sdk.urlFormatIpAddr
import java.net.Inet4Address
import java.net.Inet6Address
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
import org.fcast.sender_sdk.DeviceConnectionState
import org.fcast.sender_sdk.DeviceFeature
import org.fcast.sender_sdk.EventSubscription
import org.fcast.sender_sdk.IpAddr
import org.fcast.sender_sdk.LoadRequest
import org.fcast.sender_sdk.MediaItemEventType
import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.ProtocolType
abstract class CastingDevice { enum class CastConnectionState {
abstract val isReady: Boolean DISCONNECTED,
abstract val usedRemoteAddress: InetAddress? CONNECTING,
abstract val localAddress: InetAddress? CONNECTED
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 @Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
abstract fun resumePlayback() enum class CastProtocolType {
CHROMECAST,
AIRPLAY,
FCAST;
@Throws object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
abstract fun pausePlayback() override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
@Throws override fun serialize(encoder: Encoder, value: CastProtocolType) {
abstract fun stopPlayback() encoder.encodeString(value.name)
}
@Throws override fun deserialize(decoder: Decoder): CastProtocolType {
abstract fun seekTo(timeSeconds: Double) val name = decoder.decodeString()
return when (name) {
"FASTCAST" -> FCAST // Handle the renamed case
else -> CastProtocolType.valueOf(name)
}
}
}
}
@Throws private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
abstract fun changeVolume(timeSeconds: Double) is IpAddr.V4 -> Inet4Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte()
)
)
@Throws is IpAddr.V6 -> Inet6Address.getByAddress(
abstract fun changeSpeed(speed: Double) byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte(),
addr.o5.toByte(),
addr.o6.toByte(),
addr.o7.toByte(),
addr.o8.toByte(),
addr.o9.toByte(),
addr.o10.toByte(),
addr.o11.toByte(),
addr.o12.toByte(),
addr.o13.toByte(),
addr.o14.toByte(),
addr.o15.toByte(),
addr.o16.toByte()
)
)
}
@Throws class CastingDevice(val device: RsCastingDevice) {
abstract fun connect() class EventHandler : RsDeviceEventHandler {
var onConnectionStateChanged = Event1<DeviceConnectionState>();
var onPlayChanged = Event1<Boolean>()
var onTimeChanged = Event1<Double>()
var onDurationChanged = Event1<Double>()
var onVolumeChanged = Event1<Double>()
var onSpeedChanged = Event1<Double>()
var onMediaItemEnd = Event0()
@Throws override fun connectionStateChanged(state: DeviceConnectionState) {
abstract fun disconnect() onConnectionStateChanged.emit(state)
abstract fun getDeviceInfo(): CastingDeviceInfo }
abstract fun getAddresses(): List<InetAddress>
@Throws override fun volumeChanged(volume: Double) {
abstract fun loadVideo( onVolumeChanged.emit(volume)
}
override fun timeChanged(time: Double) {
onTimeChanged.emit(time)
}
override fun playbackStateChanged(state: PlaybackState) {
onPlayChanged.emit(state == PlaybackState.PLAYING)
}
override fun durationChanged(duration: Double) {
onDurationChanged.emit(duration)
}
override fun speedChanged(speed: Double) {
onSpeedChanged.emit(speed)
}
override fun sourceChanged(source: Source) {
// TODO
}
override fun keyEvent(event: KeyEvent) {
// Unreachable
}
override fun mediaEvent(event: MediaEvent) {
if (event.type == MediaItemEventType.END) {
onMediaItemEnd.emit()
}
}
override fun playbackError(message: String) {
Logger.e(TAG, "Playback error: $message")
}
}
val eventHandler = EventHandler()
val isReady: Boolean
get() = device.isReady()
val name: String
get() = device.name()
var usedRemoteAddress: InetAddress? = null
var localAddress: InetAddress? = null
fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
val onConnectionStateChanged =
Event1<CastConnectionState>()
val onPlayChanged: Event1<Boolean>
get() = eventHandler.onPlayChanged
val onTimeChanged: Event1<Double>
get() = eventHandler.onTimeChanged
val onDurationChanged: Event1<Double>
get() = eventHandler.onDurationChanged
val onVolumeChanged: Event1<Double>
get() = eventHandler.onVolumeChanged
val onSpeedChanged: Event1<Double>
get() = eventHandler.onSpeedChanged
val onMediaItemEnd: Event0
get() = eventHandler.onMediaItemEnd
fun resumePlayback() = device.resumePlayback()
fun pausePlayback() = device.pausePlayback()
fun stopPlayback() = device.stopPlayback()
fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
fun changeVolume(newVolume: Double) {
device.changeVolume(newVolume)
volume = newVolume
}
fun changeSpeed(speed: Double) = device.changeSpeed(speed)
fun connect() = device.connect(
ApplicationInfo(
"Grayjay Android",
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
"${Build.MANUFACTURER} ${Build.MODEL}"
),
eventHandler,
1000.toULong()
)
fun disconnect() = device.disconnect()
fun getDeviceInfo(): CastingDeviceInfo {
val info = device.getDeviceInfo()
return CastingDeviceInfo(
info.name,
when (info.protocol) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
},
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
port = info.port.toInt(),
)
}
fun getAddresses(): List<InetAddress> = device.getAddresses().map {
ipAddrToInetAddress(it)
}
fun loadVideo(
streamType: String, streamType: String,
contentType: String, contentType: String,
contentId: String, contentId: String,
@@ -64,18 +219,107 @@ abstract class CastingDevice {
duration: Double, duration: Double,
speed: Double?, speed: Double?,
metadata: Metadata? metadata: Metadata?
) = device.load(
LoadRequest.Video(
contentType = contentType,
url = contentId,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata,
requestHeaders = null,
)
) )
@Throws fun loadContent(
abstract fun loadContent(
contentType: String, contentType: String,
content: String, content: String,
resumePosition: Double, resumePosition: Double,
duration: Double, duration: Double,
speed: Double?, speed: Double?,
metadata: Metadata? metadata: Metadata?
) = device.load(
LoadRequest.Content(
contentType = contentType,
content = content,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata,
requestHeaders = null,
)
) )
abstract fun ensureThreadStarted() var connectionState = CastConnectionState.DISCONNECTED
} val protocolType: CastProtocolType
get() = when (device.castingProtocol()) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
}
var volume: Double = 1.0
var duration: Double = 0.0
private var lastTimeChangeTime_ms: Long = 0
var time: Double = 0.0
var speed: Double = 0.0
var isPlaying: Boolean = false
val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff
}
init {
eventHandler.onConnectionStateChanged.subscribe { newState ->
when (newState) {
is DeviceConnectionState.Connected -> {
if (device.supportsFeature(DeviceFeature.MEDIA_EVENT_SUBSCRIPTION)) {
try {
device.subscribeEvent(EventSubscription.MediaItemEnd)
} catch (e: Exception) {
Logger.e(TAG, "Failed to subscribe to MediaItemEnd events: $e")
}
}
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
localAddress = ipAddrToInetAddress(newState.localAddr)
connectionState = CastConnectionState.CONNECTED
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
}
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
connectionState = CastConnectionState.CONNECTING
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
}
DeviceConnectionState.Disconnected -> {
connectionState = CastConnectionState.DISCONNECTED
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
}
}
if (newState == DeviceConnectionState.Disconnected) {
try {
Logger.i(TAG, "Stopping device")
device.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to stop device: $e")
}
}
}
eventHandler.onPlayChanged.subscribe { isPlaying = it }
eventHandler.onTimeChanged.subscribe {
lastTimeChangeTime_ms = System.currentTimeMillis()
time = it
}
eventHandler.onDurationChanged.subscribe { duration = it }
eventHandler.onVolumeChanged.subscribe { volume = it }
eventHandler.onSpeedChanged.subscribe { speed = it }
}
fun ensureThreadStarted() {}
companion object {
private val TAG = "CastingDeviceExp"
}
}
@@ -1,289 +0,0 @@
package com.futo.platformplayer.casting
import android.os.Build
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.polycentric.core.Event
import org.fcast.sender_sdk.ApplicationInfo
import org.fcast.sender_sdk.KeyEvent
import org.fcast.sender_sdk.MediaEvent
import org.fcast.sender_sdk.PlaybackState
import org.fcast.sender_sdk.Source
import java.net.InetAddress
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice;
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
import org.fcast.sender_sdk.DeviceConnectionState
import org.fcast.sender_sdk.DeviceFeature
import org.fcast.sender_sdk.EventSubscription
import org.fcast.sender_sdk.IpAddr
import org.fcast.sender_sdk.LoadRequest
import org.fcast.sender_sdk.MediaItemEventType
import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.ProtocolType
import org.fcast.sender_sdk.urlFormatIpAddr
import java.net.Inet4Address
import java.net.Inet6Address
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
is IpAddr.V4 -> Inet4Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte()
)
)
is IpAddr.V6 -> Inet6Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte(),
addr.o5.toByte(),
addr.o6.toByte(),
addr.o7.toByte(),
addr.o8.toByte(),
addr.o9.toByte(),
addr.o10.toByte(),
addr.o11.toByte(),
addr.o12.toByte(),
addr.o13.toByte(),
addr.o14.toByte(),
addr.o15.toByte(),
addr.o16.toByte()
)
)
}
class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
class EventHandler : RsDeviceEventHandler {
var onConnectionStateChanged = Event1<DeviceConnectionState>();
var onPlayChanged = Event1<Boolean>()
var onTimeChanged = Event1<Double>()
var onDurationChanged = Event1<Double>()
var onVolumeChanged = Event1<Double>()
var onSpeedChanged = Event1<Double>()
var onMediaItemEnd = Event0()
override fun connectionStateChanged(state: DeviceConnectionState) {
onConnectionStateChanged.emit(state)
}
override fun volumeChanged(volume: Double) {
onVolumeChanged.emit(volume)
}
override fun timeChanged(time: Double) {
onTimeChanged.emit(time)
}
override fun playbackStateChanged(state: PlaybackState) {
onPlayChanged.emit(state == PlaybackState.PLAYING)
}
override fun durationChanged(duration: Double) {
onDurationChanged.emit(duration)
}
override fun speedChanged(speed: Double) {
onSpeedChanged.emit(speed)
}
override fun sourceChanged(source: Source) {
// TODO
}
override fun keyEvent(event: KeyEvent) {
// Unreachable
}
override fun mediaEvent(event: MediaEvent) {
if (event.type == MediaItemEventType.END) {
onMediaItemEnd.emit()
}
}
override fun playbackError(message: String) {
Logger.e(TAG, "Playback error: $message")
}
}
val eventHandler = EventHandler()
override val isReady: Boolean
get() = device.isReady()
override val name: String
get() = device.name()
override var usedRemoteAddress: InetAddress? = null
override var localAddress: InetAddress? = null
override fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
override fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
override val onConnectionStateChanged =
Event1<CastConnectionState>()
override val onPlayChanged: Event1<Boolean>
get() = eventHandler.onPlayChanged
override val onTimeChanged: Event1<Double>
get() = eventHandler.onTimeChanged
override val onDurationChanged: Event1<Double>
get() = eventHandler.onDurationChanged
override val onVolumeChanged: Event1<Double>
get() = eventHandler.onVolumeChanged
override val onSpeedChanged: Event1<Double>
get() = eventHandler.onSpeedChanged
override val onMediaItemEnd: Event0
get() = eventHandler.onMediaItemEnd
override fun resumePlayback() = device.resumePlayback()
override fun pausePlayback() = device.pausePlayback()
override fun stopPlayback() = device.stopPlayback()
override fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
override fun changeVolume(newVolume: Double) {
device.changeVolume(newVolume)
volume = newVolume
}
override fun changeSpeed(speed: Double) = device.changeSpeed(speed)
override fun connect() = device.connect(
ApplicationInfo(
"Grayjay Android",
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
"${Build.MANUFACTURER} ${Build.MODEL}"
),
eventHandler,
1000.toULong()
)
override fun disconnect() = device.disconnect()
override fun getDeviceInfo(): CastingDeviceInfo {
val info = device.getDeviceInfo()
return CastingDeviceInfo(
info.name,
when (info.protocol) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
},
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
port = info.port.toInt(),
)
}
override fun getAddresses(): List<InetAddress> = device.getAddresses().map {
ipAddrToInetAddress(it)
}
override fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Video(
contentType = contentType,
url = contentId,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata,
requestHeaders = null,
)
)
override fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Content(
contentType = contentType,
content = content,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata,
requestHeaders = null,
)
)
override var connectionState = CastConnectionState.DISCONNECTED
override val protocolType: CastProtocolType
get() = when (device.castingProtocol()) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
}
override var volume: Double = 1.0
override var duration: Double = 0.0
private var lastTimeChangeTime_ms: Long = 0
override var time: Double = 0.0
override var speed: Double = 0.0
override var isPlaying: Boolean = false
override val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff
}
init {
eventHandler.onConnectionStateChanged.subscribe { newState ->
when (newState) {
is DeviceConnectionState.Connected -> {
if (device.supportsFeature(DeviceFeature.MEDIA_EVENT_SUBSCRIPTION)) {
try {
device.subscribeEvent(EventSubscription.MediaItemEnd)
} catch (e: Exception) {
Logger.e(TAG, "Failed to subscribe to MediaItemEnd events: $e")
}
}
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
localAddress = ipAddrToInetAddress(newState.localAddr)
connectionState = CastConnectionState.CONNECTED
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
}
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
connectionState = CastConnectionState.CONNECTING
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
}
DeviceConnectionState.Disconnected -> {
connectionState = CastConnectionState.DISCONNECTED
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
}
}
if (newState == DeviceConnectionState.Disconnected) {
try {
Logger.i(TAG, "Stopping device")
device.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to stop device: $e")
}
}
}
eventHandler.onPlayChanged.subscribe { isPlaying = it }
eventHandler.onTimeChanged.subscribe {
lastTimeChangeTime_ms = System.currentTimeMillis()
time = it
}
eventHandler.onDurationChanged.subscribe { duration = it }
eventHandler.onVolumeChanged.subscribe { volume = it }
eventHandler.onSpeedChanged.subscribe { speed = it }
}
override fun ensureThreadStarted() {}
companion object {
private val TAG = "CastingDeviceExp"
}
}
@@ -1,244 +0,0 @@
package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.fcast.sender_sdk.Metadata
import java.net.InetAddress
enum class CastConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED
}
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
enum class CastProtocolType {
CHROMECAST,
AIRPLAY,
FCAST;
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: CastProtocolType) {
encoder.encodeString(value.name)
}
override fun deserialize(decoder: Decoder): CastProtocolType {
val name = decoder.decodeString()
return when (name) {
"FASTCAST" -> FCAST // Handle the renamed case
else -> CastProtocolType.valueOf(name)
}
}
}
}
abstract class CastingDeviceLegacy {
abstract val protocol: CastProtocolType;
abstract val isReady: Boolean;
abstract var usedRemoteAddress: InetAddress?;
abstract var localAddress: InetAddress?;
abstract val canSetVolume: Boolean;
abstract val canSetSpeed: Boolean;
var name: String? = null;
var isPlaying: Boolean = false
set(value) {
val changed = value != field;
field = value;
if (changed) {
onPlayChanged.emit(value);
}
};
private var lastTimeChangeTime_ms: Long = 0
var time: Double = 0.0
private set
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
time = value
lastTimeChangeTime_ms = changeTime_ms
onTimeChanged.emit(value)
}
}
private var lastDurationChangeTime_ms: Long = 0
var duration: Double = 0.0
private set
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
duration = value
lastDurationChangeTime_ms = changeTime_ms
onDurationChanged.emit(value)
}
}
private var lastVolumeChangeTime_ms: Long = 0
var volume: Double = 1.0
private set
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
volume = value
lastVolumeChangeTime_ms = changeTime_ms
onVolumeChanged.emit(value)
}
}
private var lastSpeedChangeTime_ms: Long = 0
var speed: Double = 1.0
private set
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
speed = value
lastSpeedChangeTime_ms = changeTime_ms
onSpeedChanged.emit(value)
}
}
val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff;
};
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
set(value) {
val changed = value != field;
field = value;
if (changed) {
onConnectionStateChanged.emit(value);
}
};
var onConnectionStateChanged = Event1<CastConnectionState>();
var onPlayChanged = Event1<Boolean>();
var onTimeChanged = Event1<Double>();
var onDurationChanged = Event1<Double>();
var onVolumeChanged = Event1<Double>();
var onSpeedChanged = Event1<Double>();
abstract fun stopCasting();
abstract fun seekVideo(timeSeconds: Double);
abstract fun stopVideo();
abstract fun pauseVideo();
abstract fun resumeVideo();
abstract fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?
);
abstract fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?
);
open fun changeVolume(volume: Double) {
throw NotImplementedError()
}
open fun changeSpeed(speed: Double) {
throw NotImplementedError()
}
abstract fun start();
abstract fun stop();
abstract fun getDeviceInfo(): CastingDeviceInfo;
abstract fun getAddresses(): List<InetAddress>;
}
class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice() {
override val isReady: Boolean get() = inner.isReady
override val usedRemoteAddress: InetAddress? get() = inner.usedRemoteAddress
override val localAddress: InetAddress? get() = inner.localAddress
override val name: String? get() = inner.name
override val onConnectionStateChanged: Event1<CastConnectionState> get() = inner.onConnectionStateChanged
override val onPlayChanged: Event1<Boolean> get() = inner.onPlayChanged
override val onTimeChanged: Event1<Double> get() = inner.onTimeChanged
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
override val onMediaItemEnd: Event0 = Event0()
override var connectionState: CastConnectionState
get() = inner.connectionState
set(_) = Unit
override val protocolType: CastProtocolType get() = inner.protocol
override var isPlaying: Boolean
get() = inner.isPlaying
set(_) = Unit
override val expectedCurrentTime: Double
get() = inner.expectedCurrentTime
override var speed: Double
get() = inner.speed
set(_) = Unit
override var time: Double
get() = inner.time
set(_) = Unit
override var duration: Double
get() = inner.duration
set(_) = Unit
override var volume: Double
get() = inner.volume
set(_) = Unit
override fun canSetVolume(): Boolean = inner.canSetVolume
override fun canSetSpeed(): Boolean = inner.canSetSpeed
override fun resumePlayback() = inner.resumeVideo()
override fun pausePlayback() = inner.pauseVideo()
override fun stopPlayback() = inner.stopVideo()
override fun seekTo(timeSeconds: Double) = inner.seekVideo(timeSeconds)
override fun changeVolume(timeSeconds: Double) = inner.changeVolume(timeSeconds)
override fun changeSpeed(speed: Double) = inner.changeSpeed(speed)
override fun connect() = inner.start()
override fun disconnect() = inner.stop()
override fun getDeviceInfo(): CastingDeviceInfo = inner.getDeviceInfo()
override fun getAddresses(): List<InetAddress> = inner.getAddresses()
override fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = inner.loadVideo(streamType, contentType, contentId, resumePosition, duration, speed)
override fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = inner.loadContent(contentType, content, resumePosition, duration, speed)
override fun ensureThreadStarted() = when (inner) {
is FCastCastingDevice -> inner.ensureThreadStarted()
is ChromecastCastingDevice -> inner.ensureThreadsStarted()
else -> {}
}
}
@@ -1,736 +0,0 @@
package com.futo.platformplayer.casting
import android.os.Looper
import android.util.Log
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.protos.ChromeCast
import com.futo.platformplayer.toHexString
import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.io.DataInputStream
import java.io.DataOutputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
class ChromecastCastingDevice : CastingDeviceLegacy {
//See for more info: https://developers.google.com/cast/docs/media/messages
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = true;
override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
private var _streamType: String? = null;
private var _contentType: String? = null;
private var _contentId: String? = null;
private var _socket: SSLSocket? = null;
private var _outputStream: DataOutputStream? = null;
private var _outputStreamLock = Object();
private var _inputStream: DataInputStream? = null;
private var _inputStreamLock = Object();
private var _scopeIO: CoroutineScope? = null;
private var _requestId = 1;
private var _started: Boolean = false;
private var _sessionId: String? = null;
private var _transportId: String? = null;
private var _launching = false;
private var _mediaSessionId: Int? = null;
private var _thread: Thread? = null;
private var _pingThread: Thread? = null;
private var _launchRetries = 0
private val MAX_LAUNCH_RETRIES = 3
private var _lastLaunchTime_ms = 0L
private var _retryJob: Job? = null
private var _autoLaunchEnabled = true
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
this.addresses = addresses;
this.port = port;
}
constructor(deviceInfo: CastingDeviceInfo) : super() {
this.name = deviceInfo.name;
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
this.port = deviceInfo.port;
}
override fun getAddresses(): List<InetAddress> {
return addresses?.toList() ?: listOf();
}
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
return;
}
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
setTime(resumePosition);
setDuration(duration);
_streamType = streamType;
_contentType = contentType;
_contentId = contentId;
playVideo();
}
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
//TODO: Can maybe be implemented by sending data:contentType,base64...
throw NotImplementedError();
}
private fun connectMediaChannel(transportId: String) {
val connectObject = JSONObject();
connectObject.put("type", "CONNECT");
connectObject.put("connType", 0);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
}
private fun requestMediaStatus() {
val transportId = _transportId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "GET_STATUS");
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
private fun playVideo() {
val transportId = _transportId ?: return;
val contentId = _contentId ?: return;
val streamType = _streamType ?: return;
val contentType = _contentType ?: return;
val loadObject = JSONObject();
loadObject.put("type", "LOAD");
val mediaObject = JSONObject();
mediaObject.put("contentId", contentId);
mediaObject.put("streamType", streamType);
mediaObject.put("contentType", contentType);
if (time > 0.0) {
val seekTime = time;
loadObject.put("currentTime", seekTime);
}
loadObject.put("media", mediaObject);
loadObject.put("requestId", _requestId++);
//TODO: This replace is necessary to get rid of backward slashes added by the JSON Object serializer
val json = loadObject.toString().replace("\\/","/");
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", json);
}
override fun changeSpeed(speed: Double) {
if (invokeInIOScopeIfRequired { changeSpeed(speed) }) return
val speedClamped = speed.coerceAtLeast(1.0).coerceAtLeast(1.0).coerceAtMost(2.0)
setSpeed(speedClamped)
val mediaSessionId = _mediaSessionId ?: return
val transportId = _transportId ?: return
val setSpeedObject = JSONObject().apply {
put("type", "SET_PLAYBACK_RATE")
put("mediaSessionId", mediaSessionId)
put("playbackRate", speedClamped)
put("requestId", _requestId++)
}
sendChannelMessage(sourceId = "sender-0", destinationId = transportId, namespace = "urn:x-cast:com.google.cast.media", json = setSpeedObject.toString())
}
override fun changeVolume(volume: Double) {
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
return;
}
setVolume(volume)
val setVolumeObject = JSONObject();
setVolumeObject.put("type", "SET_VOLUME");
val volumeObject = JSONObject();
volumeObject.put("level", volume)
setVolumeObject.put("volume", volumeObject);
setVolumeObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", setVolumeObject.toString());
}
override fun seekVideo(timeSeconds: Double) {
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "SEEK");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
loadObject.put("currentTime", timeSeconds);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
override fun resumeVideo() {
if (invokeInIOScopeIfRequired(::resumeVideo)) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "PLAY");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
override fun pauseVideo() {
if (invokeInIOScopeIfRequired(::pauseVideo)) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "PAUSE");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
override fun stopVideo() {
if (invokeInIOScopeIfRequired(::stopVideo)) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
_contentId = null;
_contentType = null;
_streamType = null;
val loadObject = JSONObject();
loadObject.put("type", "STOP");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
private fun launchPlayer() {
if (invokeInIOScopeIfRequired(::launchPlayer)) {
return;
}
val launchObject = JSONObject();
launchObject.put("type", "LAUNCH");
launchObject.put("appId", "CC1AD845");
launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
_lastLaunchTime_ms = System.currentTimeMillis()
}
private fun getStatus() {
if (invokeInIOScopeIfRequired(::getStatus)) {
return;
}
val launchObject = JSONObject();
launchObject.put("type", "GET_STATUS");
launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
}
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
if(Looper.getMainLooper().thread == Thread.currentThread()) {
_scopeIO?.launch { action(); }
return true;
}
return false;
}
override fun stopCasting() {
if (invokeInIOScopeIfRequired(::stopCasting)) {
return;
}
val sessionId = _sessionId;
if (sessionId != null) {
val launchObject = JSONObject();
launchObject.put("type", "STOP");
launchObject.put("sessionId", sessionId);
launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
_contentId = null;
_contentType = null;
_streamType = null;
_sessionId = null;
_launchRetries = 0
_transportId = null;
}
Logger.i(TAG, "Stopping active device because stopCasting was called.")
stop();
}
override fun start() {
if (_started) {
return;
}
_autoLaunchEnabled = true
_started = true;
_sessionId = null;
_launchRetries = 0
_mediaSessionId = null;
Logger.i(TAG, "Starting...");
_launching = true;
ensureThreadsStarted();
Logger.i(TAG, "Started.");
}
fun ensureThreadsStarted() {
val adrs = addresses ?: return;
val thread = _thread
val pingThread = _pingThread
if (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive) {
Log.i(TAG, "Restarting threads because one of the threads has died")
_scopeIO?.cancel();
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
_scopeIO = CoroutineScope(Dispatchers.IO);
_thread = Thread {
connectionState = CastConnectionState.CONNECTING;
var connectedSocket: Socket? = null
while (_scopeIO?.isActive == true) {
try {
val resultSocket = getConnectedSocket(adrs.toList(), port);
if (resultSocket == null) {
Thread.sleep(1000);
continue;
}
connectedSocket = resultSocket
usedRemoteAddress = connectedSocket.inetAddress;
localAddress = connectedSocket.localAddress;
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
Thread.sleep(1000);
}
}
val sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, null);
val factory = sslContext.socketFactory;
val address = InetSocketAddress(usedRemoteAddress, port)
//Connection loop
while (_scopeIO?.isActive == true) {
_sessionId = null;
_launchRetries = 0
_mediaSessionId = null;
Logger.i(TAG, "Connecting to Chromecast.");
connectionState = CastConnectionState.CONNECTING;
try {
_socket?.close()
if (connectedSocket != null) {
Logger.i(TAG, "Using connected socket.")
_socket = factory.createSocket(connectedSocket, connectedSocket.inetAddress.hostAddress, connectedSocket.port, true) as SSLSocket
connectedSocket = null
} else {
Logger.i(TAG, "Using new socket.")
val s = Socket().apply { this.connect(address, 2000) }
_socket = factory.createSocket(s, s.inetAddress.hostAddress, s.port, true) as SSLSocket
}
_socket?.startHandshake();
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
try {
_outputStream = DataOutputStream(_socket?.outputStream);
_inputStream = DataInputStream(_socket?.inputStream);
} catch (e: Throwable) {
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
}
} catch (e: Throwable) {
_socket?.close();
Logger.i(TAG, "Failed to connect to Chromecast.", e);
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
continue;
}
localAddress = _socket?.localAddress;
try {
val connectObject = JSONObject();
connectObject.put("type", "CONNECT");
connectObject.put("connType", 0);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
} catch (e: Throwable) {
Logger.i(TAG, "Failed to send connect message to Chromecast.", e);
_socket?.close();
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
continue;
}
getStatus();
val buffer = ByteArray(409600);
Logger.i(TAG, "Started receiving.");
while (_scopeIO?.isActive == true) {
try {
val inputStream = _inputStream ?: break;
val message = synchronized(_inputStreamLock)
{
Log.d(TAG, "Receiving next packet...");
val b1 = inputStream.readUnsignedByte();
val b2 = inputStream.readUnsignedByte();
val b3 = inputStream.readUnsignedByte();
val b4 = inputStream.readUnsignedByte();
val size =
((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt();
if (size > buffer.size) {
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
inputStream.skip(size.toLong());
return@synchronized null
}
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
inputStream.read(buffer, 0, size);
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val msg = ChromeCast.CastMessage.parseFrom(messageBytes);
if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $msg");
}
return@synchronized msg
}
if (message != null) {
try {
handleMessage(message);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to handle message.", e);
break
}
}
} catch (e: java.net.SocketException) {
Logger.e(TAG, "Socket exception while receiving.", e);
break;
} catch (e: Throwable) {
Logger.e(TAG, "Exception while receiving.", e);
break;
}
}
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
}
Logger.i(TAG, "Stopped connection loop.");
connectionState = CastConnectionState.DISCONNECTED;
}.apply { start() };
//Start ping loop
_pingThread = Thread {
Logger.i(TAG, "Started ping loop.")
val pingObject = JSONObject();
pingObject.put("type", "PING");
while (_scopeIO?.isActive == true) {
try {
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString());
} catch (e: Throwable) {
Log.w(TAG, "Failed to send ping.");
}
Thread.sleep(5000);
}
Logger.i(TAG, "Stopped ping loop.");
}.apply { start() };
} else {
Log.i(TAG, "Threads still alive, not restarted")
}
}
private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) {
try {
val castMessage = ChromeCast.CastMessage.newBuilder()
.setProtocolVersion(ChromeCast.CastMessage.ProtocolVersion.CASTV2_1_0)
.setSourceId(sourceId)
.setDestinationId(destinationId)
.setNamespace(namespace)
.setPayloadType(ChromeCast.CastMessage.PayloadType.STRING)
.setPayloadUtf8(json)
.build();
sendMessage(castMessage.toByteArray());
if (namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
//Log.d(TAG, "Sent channel message: $castMessage");
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e);
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
connectionState = CastConnectionState.CONNECTING;
}
}
private fun handleMessage(message: ChromeCast.CastMessage) {
if (message.payloadType == ChromeCast.CastMessage.PayloadType.STRING) {
val jsonObject = JSONObject(message.payloadUtf8);
val type = jsonObject.getString("type");
if (type == "RECEIVER_STATUS") {
val status = jsonObject.getJSONObject("status");
var sessionIsRunning = false;
if (status.has("applications")) {
val applications = status.getJSONArray("applications");
for (i in 0 until applications.length()) {
val applicationUpdate = applications.getJSONObject(i);
val appId = applicationUpdate.getString("appId");
Logger.i(TAG, "Status update received appId (appId: $appId)");
if (appId == "CC1AD845") {
sessionIsRunning = true;
_autoLaunchEnabled = false
if (_sessionId == null) {
connectionState = CastConnectionState.CONNECTED;
_sessionId = applicationUpdate.getString("sessionId");
_launchRetries = 0
val transportId = applicationUpdate.getString("transportId");
connectMediaChannel(transportId);
Logger.i(TAG, "Connected to media channel $transportId");
_transportId = transportId;
requestMediaStatus();
}
}
}
}
if (!sessionIsRunning) {
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
_sessionId = null
_mediaSessionId = null
_transportId = null
if (_autoLaunchEnabled) {
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
_launchRetries++
launchPlayer()
} else {
// Maybe the first GET_STATUS came back empty; still try launching
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
_launching = true
_launchRetries++
launchPlayer()
}
} else {
Logger.e(TAG, "Player not found ($_launchRetries, _autoLaunchEnabled = $_autoLaunchEnabled); giving up.")
Logger.i(TAG, "Unable to start media receiver on device")
stop()
}
} else {
if (_retryJob == null) {
Logger.i(TAG, "Scheduled retry job over 5 seconds")
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
delay(5000)
getStatus()
_retryJob = null
}
}
}
} else {
_launching = false
_launchRetries = 0
_autoLaunchEnabled = false
}
val volume = status.getJSONObject("volume");
//val volumeControlType = volume.getString("controlType");
val volumeLevel = volume.getString("level").toDouble();
val volumeMuted = volume.getBoolean("muted");
//val volumeStepInterval = volume.getString("stepInterval").toFloat();
setVolume(if (volumeMuted) 0.0 else volumeLevel);
Logger.i(TAG, "Status update received volume (level: $volumeLevel, muted: $volumeMuted)");
} else if (type == "MEDIA_STATUS") {
val statuses = jsonObject.getJSONArray("status");
for (i in 0 until statuses.length()) {
val status = statuses.getJSONObject(i);
_mediaSessionId = status.getInt("mediaSessionId");
val playerState = status.getString("playerState");
val currentTime = status.getDouble("currentTime");
if (status.has("media")) {
val media = status.getJSONObject("media")
if (media.has("duration")) {
setDuration(media.getDouble("duration"))
}
}
isPlaying = playerState == "PLAYING";
if (isPlaying || playerState == "PAUSED") {
setTime(currentTime);
}
val playbackRate = status.getInt("playbackRate");
Logger.i(TAG, "Media update received (mediaSessionId: $_mediaSessionId, playedState: $playerState, currentTime: $currentTime, playbackRate: $playbackRate)");
if (_contentType == null) {
stopVideo();
}
}
val needsLoad = statuses.length() == 0 || (statuses.getJSONObject(0).getString("playerState") == "IDLE")
if (needsLoad && _contentId != null && _mediaSessionId == null) {
Logger.i(TAG, "Receiver idle, sending initial LOAD")
playVideo()
}
} else if (type == "CLOSE") {
if (message.sourceId == "receiver-0") {
Logger.i(TAG, "Close received.");
stopCasting();
} else if (_transportId == message.sourceId) {
throw Exception("Transport id closed.")
}
}
} else {
throw Exception("Payload type ${message.payloadType} is not implemented.");
}
}
private fun sendMessage(data: ByteArray) {
val outputStream = _outputStream;
if (outputStream == null) {
Logger.w(TAG, "Failed to send ${data.size} bytes, output stream is null.");
return;
}
synchronized(_outputStreamLock)
{
val serializedSizeBE = ByteArray(4);
serializedSizeBE[0] = (data.size shr 24 and 0xff).toByte();
serializedSizeBE[1] = (data.size shr 16 and 0xff).toByte();
serializedSizeBE[2] = (data.size shr 8 and 0xff).toByte();
serializedSizeBE[3] = (data.size and 0xff).toByte();
outputStream.write(serializedSizeBE);
outputStream.write(data);
}
//Log.d(TAG, "Sent ${data.size} bytes.");
}
override fun stop() {
Logger.i(TAG, "Stopping...");
usedRemoteAddress = null;
localAddress = null;
_started = false;
_contentId = null
_contentType = null
_streamType = null
_retryJob?.cancel()
_retryJob = null
val socket = _socket;
val scopeIO = _scopeIO;
if (scopeIO != null && socket != null) {
Logger.i(TAG, "Cancelling scopeIO with open socket.")
scopeIO.launch {
socket.close();
connectionState = CastConnectionState.DISCONNECTED;
scopeIO.cancel();
Logger.i(TAG, "Cancelled scopeIO with open socket.")
}
} else {
scopeIO?.cancel();
Logger.i(TAG, "Cancelled scopeIO without open socket.")
}
_pingThread = null;
_thread = null;
_scopeIO = null;
_socket = null;
_outputStream = null;
_inputStream = null;
_mediaSessionId = null;
connectionState = CastConnectionState.DISCONNECTED;
}
override fun getDeviceInfo(): CastingDeviceInfo {
return CastingDeviceInfo(name!!, CastProtocolType.CHROMECAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
}
companion object {
val TAG = "ChromecastCastingDevice";
val trustAllCerts: Array<TrustManager> = arrayOf<TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
override fun getAcceptedIssuers(): Array<X509Certificate> { return emptyArray(); }
});
}
}
@@ -1,636 +0,0 @@
package com.futo.platformplayer.casting
import android.os.Looper
import android.util.Base64
import android.util.Log
import com.futo.platformplayer.UIDialogs
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 com.futo.platformplayer.casting.models.FCastPlaybackErrorMessage
import com.futo.platformplayer.casting.models.FCastPlaybackUpdateMessage
import com.futo.platformplayer.casting.models.FCastSeekMessage
import com.futo.platformplayer.casting.models.FCastSetSpeedMessage
import com.futo.platformplayer.casting.models.FCastSetVolumeMessage
import com.futo.platformplayer.casting.models.FCastVersionMessage
import com.futo.platformplayer.casting.models.FCastVolumeUpdateMessage
import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.toHexString
import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.math.BigInteger
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.PrivateKey
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.KeyAgreement
import javax.crypto.spec.DHParameterSpec
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
enum class Opcode(val value: Byte) {
None(0),
Play(1),
Pause(2),
Resume(3),
Stop(4),
Seek(5),
PlaybackUpdate(6),
VolumeUpdate(7),
SetVolume(8),
PlaybackError(9),
SetSpeed(10),
Version(11),
Ping(12),
Pong(13);
companion object {
private val _map = entries.associateBy { it.value }
fun find(value: Byte): Opcode = _map[value] ?: Opcode.None
}
}
class FCastCastingDevice : CastingDeviceLegacy {
//See for more info: TODO
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = true;
override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
private var _socket: Socket? = null;
private var _outputStream: OutputStream? = null;
private var _inputStream: InputStream? = null;
private var _scopeIO: CoroutineScope? = null;
private var _started: Boolean = false;
private var _version: Long = 1;
private var _thread: Thread? = null
private var _pingThread: Thread? = null
@Volatile private var _lastPongTime = System.currentTimeMillis()
private var _outputStreamLock = Object()
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
this.addresses = addresses;
this.port = port;
}
constructor(deviceInfo: CastingDeviceInfo) : super() {
this.name = deviceInfo.name;
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
this.port = deviceInfo.port;
}
override fun getAddresses(): List<InetAddress> {
return addresses?.toList() ?: listOf();
}
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
return;
}
//TODO: Remove this later, temporary for the transition
if (_version <= 1L) {
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
}
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
setTime(resumePosition);
setDuration(duration);
send(Opcode.Play, FCastPlayMessage(
container = contentType,
url = contentId,
time = resumePosition,
speed = speed
));
setSpeed(speed ?: 1.0);
}
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadContent(contentType, content, resumePosition, duration, speed) })) {
return;
}
//TODO: Remove this later, temporary for the transition
if (_version <= 1L) {
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
}
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
setTime(resumePosition);
setDuration(duration);
send(Opcode.Play, FCastPlayMessage(
container = contentType,
content = content,
time = resumePosition,
speed = speed
));
setSpeed(speed ?: 1.0);
}
override fun changeVolume(volume: Double) {
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
return;
}
setVolume(volume);
send(Opcode.SetVolume, FCastSetVolumeMessage(volume))
}
override fun changeSpeed(speed: Double) {
if (invokeInIOScopeIfRequired({ changeSpeed(speed) })) {
return;
}
setSpeed(speed);
send(Opcode.SetSpeed, FCastSetSpeedMessage(speed))
}
override fun seekVideo(timeSeconds: Double) {
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
return;
}
send(Opcode.Seek, FCastSeekMessage(
time = timeSeconds
));
}
override fun resumeVideo() {
if (invokeInIOScopeIfRequired(::resumeVideo)) {
return;
}
send(Opcode.Resume);
}
override fun pauseVideo() {
if (invokeInIOScopeIfRequired(::pauseVideo)) {
return;
}
send(Opcode.Pause);
}
override fun stopVideo() {
if (invokeInIOScopeIfRequired(::stopVideo)) {
return;
}
send(Opcode.Stop);
}
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
if(Looper.getMainLooper().thread == Thread.currentThread()) {
_scopeIO?.launch {
try {
action();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to invoke in IO scope.", e)
}
}
return true;
}
return false;
}
override fun stopCasting() {
if (invokeInIOScopeIfRequired(::stopCasting)) {
return;
}
stopVideo();
Logger.i(TAG, "Stopping active device because stopCasting was called.")
stop();
}
override fun start() {
if (_started) {
return;
}
_started = true;
Logger.i(TAG, "Starting...");
ensureThreadStarted();
Logger.i(TAG, "Started.");
}
fun ensureThreadStarted() {
val adrs = addresses ?: return;
val thread = _thread
val pingThread = _pingThread
if (_started && (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive)) {
Log.i(TAG, "(Re)starting thread because the thread has died")
_scopeIO?.let {
it.cancel()
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
}
_scopeIO = CoroutineScope(Dispatchers.IO);
_thread = Thread {
connectionState = CastConnectionState.CONNECTING;
Log.i(TAG, "Connection thread started.")
var connectedSocket: Socket? = null
while (_scopeIO?.isActive == true) {
try {
Log.i(TAG, "getConnectedSocket (adrs = [ ${adrs.joinToString(", ")} ], port = ${port}).")
val resultSocket = getConnectedSocket(adrs.toList(), port);
if (resultSocket == null) {
Log.i(TAG, "Connection failed, waiting 1 seconds.")
Thread.sleep(1000);
continue;
}
Log.i(TAG, "Connection succeeded.")
connectedSocket = resultSocket
usedRemoteAddress = connectedSocket.inetAddress
localAddress = connectedSocket.localAddress
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
Thread.sleep(1000);
}
}
val address = InetSocketAddress(usedRemoteAddress, port)
//Connection loop
while (_scopeIO?.isActive == true) {
Logger.i(TAG, "Connecting to FastCast.");
connectionState = CastConnectionState.CONNECTING;
try {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
if (connectedSocket != null) {
Logger.i(TAG, "Using connected socket.");
_socket = connectedSocket
connectedSocket = null
} else {
Logger.i(TAG, "Using new socket.");
_socket = Socket().apply { this.connect(address, 2000) };
}
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
_outputStream = _socket?.outputStream;
_inputStream = _socket?.inputStream;
} catch (e: IOException) {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
Logger.i(TAG, "Failed to connect to FastCast.", e);
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
continue;
}
localAddress = _socket?.localAddress
_lastPongTime = System.currentTimeMillis()
connectionState = CastConnectionState.CONNECTED
val buffer = ByteArray(4096);
Logger.i(TAG, "Started receiving.");
while (_scopeIO?.isActive == true) {
try {
val inputStream = _inputStream ?: break;
Log.d(TAG, "Receiving next packet...");
var headerBytesRead = 0
while (headerBytesRead < 4) {
val read = inputStream.read(buffer, headerBytesRead, 4 - headerBytesRead)
if (read == -1)
throw Exception("Stream closed")
headerBytesRead += read
}
val size = ((buffer[3].toUByte().toLong() shl 24) or (buffer[2].toUByte().toLong() shl 16) or (buffer[1].toUByte().toLong() shl 8) or buffer[0].toUByte().toLong()).toInt();
if (size > buffer.size) {
Logger.w(TAG, "Packets larger than $size bytes are not supported.")
break
}
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
var bytesRead = 0
while (bytesRead < size) {
val read = inputStream.read(buffer, bytesRead, size - bytesRead)
if (read == -1)
throw Exception("Stream closed")
bytesRead += read
}
val messageBytes = buffer.sliceArray(IntRange(0, size));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val opcode = messageBytes[0];
var json: String? = null;
if (size > 1) {
json = messageBytes.sliceArray(IntRange(1, size - 1)).decodeToString();
}
try {
handleMessage(Opcode.find(opcode), json);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to handle message.", e)
break
}
} catch (e: java.net.SocketException) {
Logger.e(TAG, "Socket exception while receiving.", e);
break
} catch (e: Throwable) {
Logger.e(TAG, "Exception while receiving.", e);
break
}
}
try {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
Logger.i(TAG, "Socket disconnected.");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to close socket.", e)
}
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
}
Logger.i(TAG, "Stopped connection loop.");
connectionState = CastConnectionState.DISCONNECTED;
}.apply { start() }
_pingThread = Thread {
Logger.i(TAG, "Started ping loop.")
while (_scopeIO?.isActive == true) {
if (connectionState == CastConnectionState.CONNECTED) {
try {
send(Opcode.Ping)
if (System.currentTimeMillis() - _lastPongTime > 15000) {
Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.")
try {
_socket?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}
} catch (e: Throwable) {
Log.w(TAG, "Failed to send ping.")
try {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}
}
Thread.sleep(5000)
}
Logger.i(TAG, "Stopped ping loop.")
}.apply { start() }
} else {
Log.i(TAG, "Thread was still alive, not restarted")
}
}
private fun handleMessage(opcode: Opcode, json: String? = null) {
Log.i(TAG, "Processing packet (opcode: $opcode, size: ${json?.length ?: 0})")
when (opcode) {
Opcode.PlaybackUpdate -> {
if (json == null) {
Logger.w(TAG, "Got playback update without JSON, ignoring.");
return;
}
val playbackUpdate = FCastCastingDevice.json.decodeFromString<FCastPlaybackUpdateMessage>(json);
setTime(playbackUpdate.time, playbackUpdate.generationTime);
setDuration(playbackUpdate.duration, playbackUpdate.generationTime);
isPlaying = when (playbackUpdate.state) {
1 -> true
else -> false
}
}
Opcode.VolumeUpdate -> {
if (json == null) {
Logger.w(TAG, "Got volume update without JSON, ignoring.");
return;
}
val volumeUpdate = FCastCastingDevice.json.decodeFromString<FCastVolumeUpdateMessage>(json);
setVolume(volumeUpdate.volume, volumeUpdate.generationTime);
}
Opcode.PlaybackError -> {
if (json == null) {
Logger.w(TAG, "Got playback error without JSON, ignoring.");
return;
}
val playbackError = FCastCastingDevice.json.decodeFromString<FCastPlaybackErrorMessage>(json);
Logger.e(TAG, "Remote casting playback error received: $playbackError")
}
Opcode.Version -> {
if (json == null) {
Logger.w(TAG, "Got version without JSON, ignoring.");
return;
}
val version = FCastCastingDevice.json.decodeFromString<FCastVersionMessage>(json);
_version = version.version;
Logger.i(TAG, "Remote version received: $version")
}
Opcode.Ping -> send(Opcode.Pong)
Opcode.Pong -> _lastPongTime = System.currentTimeMillis()
else -> { }
}
}
private fun send(opcode: Opcode, message: String? = null) {
ensureNotMainThread()
synchronized (_outputStreamLock) {
try {
val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
val size = 1 + data.size
val outputStream = _outputStream
if (outputStream == null) {
Log.w(TAG, "Failed to send $size bytes, output stream is null.")
return
}
val serializedSizeLE = ByteArray(4)
serializedSizeLE[0] = (size and 0xff).toByte()
serializedSizeLE[1] = (size shr 8 and 0xff).toByte()
serializedSizeLE[2] = (size shr 16 and 0xff).toByte()
serializedSizeLE[3] = (size shr 24 and 0xff).toByte()
outputStream.write(serializedSizeLE)
val opcodeBytes = ByteArray(1)
opcodeBytes[0] = opcode.value
outputStream.write(opcodeBytes)
if (data.isNotEmpty()) {
outputStream.write(data)
}
Log.d(TAG, "Sent $size bytes: (opcode: $opcode, body: $message).")
} catch (e: Throwable) {
Log.i(TAG, "Failed to send message.", e)
throw e
}
}
}
private inline fun <reified T> send(opcode: Opcode, message: T) {
try {
send(opcode, message?.let { Json.encodeToString(it) })
} catch (e: Throwable) {
Log.i(TAG, "Failed to encode message to string.", e)
throw e
}
}
override fun stop() {
Logger.i(TAG, "Stopping...");
usedRemoteAddress = null;
localAddress = null;
_started = false;
//TODO: Kill and/or join thread?
_thread = null;
_pingThread = null;
val socket = _socket;
val scopeIO = _scopeIO;
if (scopeIO != null && socket != null) {
Logger.i(TAG, "Cancelling scopeIO with open socket.")
scopeIO.launch {
socket.close();
_inputStream?.close()
_outputStream?.close()
connectionState = CastConnectionState.DISCONNECTED;
scopeIO.cancel();
Logger.i(TAG, "Cancelled scopeIO with open socket.")
}
} else {
scopeIO?.cancel();
Logger.i(TAG, "Cancelled scopeIO without open socket.")
}
_scopeIO = null;
_socket = null;
_outputStream = null;
_inputStream = null;
connectionState = CastConnectionState.DISCONNECTED;
}
override fun getDeviceInfo(): CastingDeviceInfo {
return CastingDeviceInfo(name!!, CastProtocolType.FCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
}
companion object {
val TAG = "FCastCastingDevice";
private val json = Json { ignoreUnknownKeys = true }
fun getKeyExchangeMessage(keyPair: KeyPair): FCastKeyExchangeMessage {
return FCastKeyExchangeMessage(1, Base64.encodeToString(keyPair.public.encoded, Base64.NO_WRAP))
}
fun generateKeyPair(): KeyPair {
//modp14
val p = BigInteger("ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff", 16)
val g = BigInteger("2", 16)
val dhSpec = DHParameterSpec(p, g)
val keyGen = KeyPairGenerator.getInstance("DH")
keyGen.initialize(dhSpec)
return keyGen.generateKeyPair()
}
fun computeSharedSecret(privateKey: PrivateKey, keyExchangeMessage: FCastKeyExchangeMessage): SecretKeySpec {
val keyFactory = KeyFactory.getInstance("DH")
val receivedPublicKeyBytes = Base64.decode(keyExchangeMessage.publicKey, Base64.NO_WRAP)
val receivedPublicKeySpec = X509EncodedKeySpec(receivedPublicKeyBytes)
val receivedPublicKey = keyFactory.generatePublic(receivedPublicKeySpec)
val keyAgreement = KeyAgreement.getInstance("DH")
keyAgreement.init(privateKey)
keyAgreement.doPhase(receivedPublicKey, true)
val sharedSecret = keyAgreement.generateSecret()
Log.i(TAG, "sharedSecret ${Base64.encodeToString(sharedSecret, Base64.NO_WRAP)}")
val sha256 = MessageDigest.getInstance("SHA-256")
val hashedSecret = sha256.digest(sharedSecret)
Log.i(TAG, "hashedSecret ${Base64.encodeToString(hashedSecret, Base64.NO_WRAP)}")
return SecretKeySpec(hashedSecret, "AES")
}
fun encryptMessage(aesKey: SecretKeySpec, decryptedMessage: FCastDecryptedMessage): FCastEncryptedMessage {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, aesKey)
val iv = cipher.iv
val json = Json.encodeToString(decryptedMessage)
val encrypted = cipher.doFinal(json.toByteArray(Charsets.UTF_8))
return FCastEncryptedMessage(
version = 1,
iv = Base64.encodeToString(iv, Base64.NO_WRAP),
blob = Base64.encodeToString(encrypted, Base64.NO_WRAP)
)
}
fun decryptMessage(aesKey: SecretKeySpec, encryptedMessage: FCastEncryptedMessage): FCastDecryptedMessage {
val iv = Base64.decode(encryptedMessage.iv, Base64.NO_WRAP)
val encrypted = Base64.decode(encryptedMessage.blob, Base64.NO_WRAP)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
val decryptedJson = cipher.doFinal(encrypted)
return Json.decodeFromString(String(decryptedJson, Charsets.UTF_8))
}
}
}
@@ -8,6 +8,7 @@ import android.util.Log
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
@@ -57,16 +58,22 @@ import com.futo.platformplayer.views.casting.CastView.Companion
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.fcast.sender_sdk.CastContext
import org.fcast.sender_sdk.DeviceInfo
import org.fcast.sender_sdk.Metadata import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.NsdDeviceDiscoverer
import org.fcast.sender_sdk.ProtocolType
import java.net.Inet6Address import java.net.Inet6Address
import java.net.URLDecoder import java.net.URLDecoder
import java.net.URLEncoder import java.net.URLEncoder
import java.util.UUID import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
abstract class StateCasting { class StateCasting {
val _scopeIO = CoroutineScope(Dispatchers.IO); val _scopeIO = CoroutineScope(Dispatchers.IO);
val _scopeMain = CoroutineScope(Dispatchers.Main); val _scopeMain = CoroutineScope(Dispatchers.Main);
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get(); private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
@@ -92,15 +99,163 @@ abstract class StateCasting {
val isCasting: Boolean get() = activeDevice != null; val isCasting: Boolean get() = activeDevice != null;
private val _castId = AtomicInteger(0) private val _castId = AtomicInteger(0)
abstract fun handleUrl(url: String) private val _context = CastContext()
abstract fun onStop() var _deviceDiscoverer: NsdDeviceDiscoverer? = null
abstract fun start(context: Context)
abstract fun stop()
abstract fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice? class DiscoveryEventHandler(
abstract fun startUpdateTimeJob( private val onDeviceAdded: (RsDeviceInfo) -> Unit,
onTimeJobTimeChanged_s: Event1<Long>, setTime: (Long) -> Unit private val onDeviceRemoved: (String) -> Unit,
): Job? private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
onDeviceAdded(deviceInfo)
}
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
onDeviceUpdated(deviceInfo)
}
override fun deviceRemoved(deviceName: String) {
onDeviceRemoved(deviceName)
}
}
init {
if (BuildConfig.DEBUG) {
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
}
}
fun handleUrl(url: String) {
try {
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
connectDevice(CastingDevice(foundDevice))
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle URL: $e")
}
}
fun onStop() {
val ad = activeDevice ?: return
_resumeCastingDevice = ad.getDeviceInfo()
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
Logger.i(TAG, "Stopping active device because of onStop.")
try {
ad.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect from device: $e")
}
}
@Synchronized
fun start(context: Context) {
if (_started)
return
_started = true
Log.i(TAG, "_resumeCastingDevice set null start")
_resumeCastingDevice = null
Logger.i(TAG, "CastingService starting...")
_castServer.start()
enableDeveloper(true)
Logger.i(TAG, "CastingService started.")
_deviceDiscoverer = NsdDeviceDiscoverer(
context,
DiscoveryEventHandler(
{ deviceInfo -> // Added
Logger.i(TAG, "Device added: ${deviceInfo.name}")
val device = _context.createDeviceFromInfo(deviceInfo)
val deviceHandle = CastingDevice(device)
devices[deviceHandle.device.name()] = deviceHandle
invokeInMainScopeIfRequired {
onDeviceAdded.emit(deviceHandle)
}
},
{ deviceName -> // Removed
invokeInMainScopeIfRequired {
if (devices.containsKey(deviceName)) {
val device = devices.remove(deviceName)
if (device != null) {
onDeviceRemoved.emit(device)
}
}
}
},
{ deviceInfo -> // Updated
Logger.i(TAG, "Device updated: $deviceInfo")
val handle = devices[deviceInfo.name]
if (handle != null && handle is CastingDevice) {
handle.device.setPort(deviceInfo.port)
handle.device.setAddresses(deviceInfo.addresses)
invokeInMainScopeIfRequired {
onDeviceChanged.emit(handle)
}
}
},
)
)
}
@Synchronized
fun stop() {
if (!_started) {
return
}
_started = false
Logger.i(TAG, "CastingService stopping.")
_scopeIO.cancel()
_scopeMain.cancel()
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
val d = activeDevice
activeDevice = null
try {
d?.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect device: $e")
}
_castServer.stop()
_castServer.removeAllHandlers()
Logger.i(TAG, "CastingService stopped.")
_deviceDiscoverer = null
}
fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>,
setTime: (Long) -> Unit
): Job? = null
fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice? {
try {
val rsAddrs =
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
val rsDeviceInfo = RsDeviceInfo(
name = deviceInfo.name,
protocol = when (deviceInfo.type) {
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
else -> throw IllegalArgumentException()
},
addresses = rsAddrs,
port = deviceInfo.port.toUShort(),
)
return CastingDevice(_context.createDeviceFromInfo(rsDeviceInfo))
} catch (_: Throwable) {
return null
}
}
fun onResume() { fun onResume() {
val ad = activeDevice val ad = activeDevice
@@ -765,6 +920,7 @@ abstract class StateCasting {
if (videoSource != null) { if (videoSource != null) {
_castServer.addHandlerWithAllowAllOptions( _castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
.withInjectedHost() .withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true .withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast"); ).withTag("cast");
@@ -772,6 +928,7 @@ abstract class StateCasting {
if (audioSource != null) { if (audioSource != null) {
_castServer.addHandlerWithAllowAllOptions( _castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
.withInjectedHost() .withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true .withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast"); ).withTag("cast");
@@ -813,8 +970,7 @@ abstract class StateCasting {
val headers = masterContext.headers.clone() val headers = masterContext.headers.clone()
headers["Content-Type"] = "application/vnd.apple.mpegurl"; headers["Content-Type"] = "application/vnd.apple.mpegurl";
val req = requestModifier?.modifyRequest(sourceUrl, mapOf()) val masterPlaylistResponse = _client.get(sourceUrl, mutableMapOf(), requestModifier)
val masterPlaylistResponse = _client.get(req?.url ?: sourceUrl, (req?.headers ?: mapOf()).toMutableMap())
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
@@ -867,7 +1023,7 @@ abstract class StateCasting {
val vpHeaders = vpContext.headers.clone() val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val response = _client.get(variantPlaylistRef.url) val response = _client.get(variantPlaylistRef.url, mutableMapOf(), requestModifier)
check(response.isOk) { "Failed to get variant playlist: ${response.code}" } check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
val vpContent = response.body?.string() val vpContent = response.body?.string()
@@ -904,7 +1060,7 @@ abstract class StateCasting {
val vpHeaders = vpContext.headers.clone() val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val response = _client.get(mediaRendition.uri) val response = _client.get(mediaRendition.uri, mutableMapOf(), requestModifier)
check(response.isOk) { "Failed to get variant playlist: ${response.code}" } check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
val vpContent = response.body?.string() val vpContent = response.body?.string()
@@ -1035,6 +1191,7 @@ abstract class StateCasting {
_castServer.addHandlerWithAllowAllOptions( _castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
.withInjectedHost() .withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true .withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant"); ).withTag("castHlsIndirectVariant");
@@ -1112,6 +1269,7 @@ abstract class StateCasting {
_castServer.addHandlerWithAllowAllOptions( _castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
.withInjectedHost() .withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true .withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant"); ).withTag("castHlsIndirectVariant");
@@ -1195,6 +1353,7 @@ abstract class StateCasting {
if (videoSource != null) { if (videoSource != null) {
_castServer.addHandlerWithAllowAllOptions( _castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
.withInjectedHost() .withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true .withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast"); ).withTag("cast");
@@ -1202,6 +1361,7 @@ abstract class StateCasting {
if (audioSource != null) { if (audioSource != null) {
_castServer.addHandlerWithAllowAllOptions( _castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
.withInjectedHost() .withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true .withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast"); ).withTag("cast");
@@ -1264,10 +1424,10 @@ abstract class StateCasting {
val codecsAttr = codecs?.let { " codecs=\"${escapeXml(it)}\"" } ?: "" val codecsAttr = codecs?.let { " codecs=\"${escapeXml(it)}\"" } ?: ""
val adaptation = """ val adaptation = """
<AdaptationSet id="subtitles-1" contentType="text" mimeType="${escapeXml(mimeType)}" lang="${escapeXml(lang)}"> <AdaptationSet id="123456" contentType="text" mimeType="${escapeXml(mimeType)}" lang="${escapeXml(lang)}">
<Role schemeIdUri="urn:mpeg:dash:role:2011" value="subtitle"/> <Role schemeIdUri="urn:mpeg:dash:role:2011" value="subtitle"/>
<Label>${escapeXml(label)}</Label> <Label>${escapeXml(label)}</Label>
<Representation id="subtitles-1"$codecsAttr bandwidth="256" mimeType="${escapeXml(mimeType)}"> <Representation id="123457"$codecsAttr bandwidth="256" mimeType="${escapeXml(mimeType)}">
<BaseURL>${escapeXml(subtitleUrl)}</BaseURL> <BaseURL>${escapeXml(subtitleUrl)}</BaseURL>
</Representation> </Representation>
</AdaptationSet> </AdaptationSet>
@@ -1532,11 +1692,7 @@ abstract class StateCasting {
} }
companion object { companion object {
var instance: StateCasting = if (Settings.instance.casting.experimentalCasting) { var instance = StateCasting()
StateCastingExp()
} else {
StateCastingLegacy()
}
private val representationRegex = Regex( private val representationRegex = Regex(
"<Representation .*?mimeType=\"(.*?)\".*?>(.*?)<\\/Representation>", "<Representation .*?mimeType=\"(.*?)\".*?>(.*?)<\\/Representation>",
RegexOption.DOT_MATCHES_ALL RegexOption.DOT_MATCHES_ALL
@@ -1,178 +0,0 @@
package com.futo.platformplayer.casting
import android.content.Context
import android.util.Log
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
import org.fcast.sender_sdk.ProtocolType
import org.fcast.sender_sdk.CastContext
import org.fcast.sender_sdk.NsdDeviceDiscoverer
class StateCastingExp : StateCasting() {
private val _context = CastContext()
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
class DiscoveryEventHandler(
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
private val onDeviceRemoved: (String) -> Unit,
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
onDeviceAdded(deviceInfo)
}
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
onDeviceUpdated(deviceInfo)
}
override fun deviceRemoved(deviceName: String) {
onDeviceRemoved(deviceName)
}
}
init {
if (BuildConfig.DEBUG) {
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
}
}
override fun handleUrl(url: String) {
try {
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
connectDevice(CastingDeviceExp(foundDevice))
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle URL: $e")
}
}
override fun onStop() {
val ad = activeDevice ?: return
_resumeCastingDevice = ad.getDeviceInfo()
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
Logger.i(TAG, "Stopping active device because of onStop.")
try {
ad.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect from device: $e")
}
}
@Synchronized
override fun start(context: Context) {
if (_started)
return
_started = true
Log.i(TAG, "_resumeCastingDevice set null start")
_resumeCastingDevice = null
Logger.i(TAG, "CastingService starting...")
_castServer.start()
enableDeveloper(true)
Logger.i(TAG, "CastingService started.")
_deviceDiscoverer = NsdDeviceDiscoverer(
context,
DiscoveryEventHandler(
{ deviceInfo -> // Added
Logger.i(TAG, "Device added: ${deviceInfo.name}")
val device = _context.createDeviceFromInfo(deviceInfo)
val deviceHandle = CastingDeviceExp(device)
devices[deviceHandle.device.name()] = deviceHandle
invokeInMainScopeIfRequired {
onDeviceAdded.emit(deviceHandle)
}
},
{ deviceName -> // Removed
invokeInMainScopeIfRequired {
if (devices.containsKey(deviceName)) {
val device = devices.remove(deviceName)
if (device != null) {
onDeviceRemoved.emit(device)
}
}
}
},
{ deviceInfo -> // Updated
Logger.i(TAG, "Device updated: $deviceInfo")
val handle = devices[deviceInfo.name]
if (handle != null && handle is CastingDeviceExp) {
handle.device.setPort(deviceInfo.port)
handle.device.setAddresses(deviceInfo.addresses)
invokeInMainScopeIfRequired {
onDeviceChanged.emit(handle)
}
}
},
)
)
}
@Synchronized
override fun stop() {
if (!_started) {
return
}
_started = false
Logger.i(TAG, "CastingService stopping.")
_scopeIO.cancel()
_scopeMain.cancel()
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
val d = activeDevice
activeDevice = null
try {
d?.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect device: $e")
}
_castServer.stop()
_castServer.removeAllHandlers()
Logger.i(TAG, "CastingService stopped.")
_deviceDiscoverer = null
}
override fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>,
setTime: (Long) -> Unit
): Job? = null
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDeviceExp? {
try {
val rsAddrs =
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
val rsDeviceInfo = RsDeviceInfo(
name = deviceInfo.name,
protocol = when (deviceInfo.type) {
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
else -> throw IllegalArgumentException()
},
addresses = rsAddrs,
port = deviceInfo.port.toUShort(),
)
return CastingDeviceExp(_context.createDeviceFromInfo(rsDeviceInfo))
} catch (_: Throwable) {
return null
}
}
companion object {
private val TAG = "StateCastingExp"
}
}
@@ -1,399 +0,0 @@
package com.futo.platformplayer.casting
import android.content.Context
import android.net.Uri
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.util.Base64
import android.util.Log
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.net.InetAddress
import kotlinx.coroutines.delay
class StateCastingLegacy : StateCasting() {
private var _nsdManager: NsdManager? = null
private val _discoveryListeners = mapOf(
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
)
override fun handleUrl(url: String) {
val uri = Uri.parse(url)
if (uri.scheme != "fcast") {
throw Exception("Expected scheme to be FCast")
}
val type = uri.host
if (type != "r") {
throw Exception("Expected type r")
}
val connectionInfo = uri.pathSegments[0]
val json =
Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
.toString(Charsets.UTF_8)
val networkConfig = Json.decodeFromString<FCastNetworkConfig>(json)
val tcpService = networkConfig.services.first { v -> v.type == 0 }
val foundInfo = addRememberedDevice(
CastingDeviceInfo(
name = networkConfig.name,
type = CastProtocolType.FCAST,
addresses = networkConfig.addresses.toTypedArray(),
port = tcpService.port
)
)
if (foundInfo != null) {
connectDevice(deviceFromInfo(foundInfo))
}
}
override fun onStop() {
val ad = activeDevice ?: return;
_resumeCastingDevice = ad.getDeviceInfo()
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
Logger.i(TAG, "Stopping active device because of onStop.");
ad.disconnect();
}
@Synchronized
override fun start(context: Context) {
if (_started)
return;
_started = true;
Log.i(TAG, "_resumeCastingDevice set null start")
_resumeCastingDevice = null;
Logger.i(TAG, "CastingService starting...");
_castServer.start();
enableDeveloper(true);
Logger.i(TAG, "CastingService started.");
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
startDiscovering()
}
@Synchronized
private fun startDiscovering() {
_nsdManager?.apply {
_discoveryListeners.forEach {
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
}
}
}
@Synchronized
private fun stopDiscovering() {
_nsdManager?.apply {
_discoveryListeners.forEach {
try {
stopServiceDiscovery(it.value)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
}
}
@Synchronized
override fun stop() {
if (!_started)
return;
_started = false;
Logger.i(TAG, "CastingService stopping.")
stopDiscovering()
_scopeIO.cancel();
_scopeMain.cancel();
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
val d = activeDevice;
activeDevice = null;
d?.disconnect();
_castServer.stop();
_castServer.removeAllHandlers();
Logger.i(TAG, "CastingService stopped.")
_nsdManager = null
}
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
return object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(regType: String) {
Log.d(TAG, "Service discovery started for $regType")
}
override fun onDiscoveryStopped(serviceType: String) {
Log.i(TAG, "Discovery stopped: $serviceType")
}
override fun onServiceLost(service: NsdServiceInfo) {
Log.e(TAG, "service lost: $service")
// TODO: Handle service lost, e.g., remove device
}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onServiceFound(service: NsdServiceInfo) {
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
service.hostAddresses.toTypedArray()
} else {
arrayOf(service.host)
}
addOrUpdate(service.serviceName, addresses, service.port)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
_nsdManager?.registerServiceInfoCallback(
service,
{ it.run() },
object : NsdManager.ServiceInfoCallback {
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "onServiceUpdated: $serviceInfo")
addOrUpdate(
serviceInfo.serviceName,
serviceInfo.hostAddresses.toTypedArray(),
serviceInfo.port
)
}
override fun onServiceLost() {
Log.v(TAG, "onServiceLost: $service")
// TODO: Handle service lost
}
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
}
override fun onServiceInfoCallbackUnregistered() {
Log.v(TAG, "onServiceInfoCallbackUnregistered")
}
})
} else {
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Log.v(TAG, "Resolve failed: $errorCode")
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
addOrUpdate(
serviceInfo.serviceName,
arrayOf(serviceInfo.host),
serviceInfo.port
)
}
})
}
}
}
}
override fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>,
setTime: (Long) -> Unit
): Job? {
val d = activeDevice;
if (d is CastingDeviceLegacyWrapper && (d.inner is AirPlayCastingDevice || d.inner is ChromecastCastingDevice)) {
return _scopeMain.launch {
while (true) {
val device = instance.activeDevice
if (device == null || !device.isPlaying) {
break
}
delay(1000)
val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
setTime(time_ms)
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
}
}
}
return null
}
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice {
return CastingDeviceLegacyWrapper(
when (deviceInfo.type) {
CastProtocolType.CHROMECAST -> {
ChromecastCastingDevice(deviceInfo);
}
CastProtocolType.AIRPLAY -> {
AirPlayCastingDevice(deviceInfo);
}
CastProtocolType.FCAST -> {
FCastCastingDevice(deviceInfo);
}
}
)
}
private fun addOrUpdateChromeCastDevice(
name: String,
addresses: Array<InetAddress>,
port: Int
) {
return addOrUpdateCastDevice(
name,
deviceFactory = {
CastingDeviceLegacyWrapper(
ChromecastCastingDevice(
name,
addresses,
port
)
)
},
deviceUpdater = { d ->
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is ChromecastCastingDevice) {
return@addOrUpdateCastDevice false;
}
val changed =
addresses.contentEquals(d.inner.addresses) || d.name != name || d.inner.port != port;
if (changed) {
d.inner.name = name;
d.inner.addresses = addresses;
d.inner.port = port;
}
return@addOrUpdateCastDevice changed;
}
);
}
private fun addOrUpdateAirPlayDevice(name: String, addresses: Array<InetAddress>, port: Int) {
return addOrUpdateCastDevice(
name,
deviceFactory = {
CastingDeviceLegacyWrapper(
AirPlayCastingDevice(
name,
addresses,
port
)
)
},
deviceUpdater = { d ->
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is AirPlayCastingDevice) {
return@addOrUpdateCastDevice false;
}
val changed =
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
if (changed) {
d.inner.name = name;
d.inner.port = port;
d.inner.addresses = addresses;
}
return@addOrUpdateCastDevice changed;
}
);
}
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
return addOrUpdateCastDevice(
name,
deviceFactory = { CastingDeviceLegacyWrapper(FCastCastingDevice(name, addresses, port)) },
deviceUpdater = { d ->
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is FCastCastingDevice) {
return@addOrUpdateCastDevice false;
}
val changed =
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
if (changed) {
d.inner.name = name;
d.inner.port = port;
d.inner.addresses = addresses;
}
return@addOrUpdateCastDevice changed;
}
);
}
private inline fun addOrUpdateCastDevice(
name: String,
deviceFactory: () -> CastingDevice,
deviceUpdater: (device: CastingDevice) -> Boolean
) {
var invokeEvents: (() -> Unit)? = null;
synchronized(devices) {
val device = devices[name];
if (device != null) {
val changed = deviceUpdater(device);
if (changed) {
invokeEvents = {
onDeviceChanged.emit(device);
}
}
} else {
val newDevice = deviceFactory();
this.devices[name] = newDevice
invokeEvents = {
onDeviceAdded.emit(newDevice);
};
}
}
invokeEvents?.let { _scopeMain.launch { it(); }; };
}
@Serializable
private data class FCastNetworkConfig(
val name: String,
val addresses: List<String>,
val services: List<FCastService>
)
@Serializable
private data class FCastService(
val port: Int,
val type: Int
)
companion object {
private val TAG = "StateCastingLegacy"
}
}
@@ -1,72 +0,0 @@
package com.futo.platformplayer.casting.models
import kotlinx.serialization.Serializable
@Serializable
data class FCastPlayMessage(
val container: String,
val url: String? = null,
val content: String? = null,
val time: Double? = null,
val speed: Double? = null
) { }
@Serializable
data class FCastSeekMessage(
val time: Double
) { }
@Serializable
data class FCastPlaybackUpdateMessage(
val generationTime: Long,
val time: Double,
val duration: Double,
val state: Int,
val speed: Double
) { }
@Serializable
data class FCastVolumeUpdateMessage(
val generationTime: Long,
val volume: Double
)
@Serializable
data class FCastSetVolumeMessage(
val volume: Double
)
@Serializable
data class FCastSetSpeedMessage(
val speed: Double
)
@Serializable
data class FCastPlaybackErrorMessage(
val message: String
)
@Serializable
data class FCastVersionMessage(
val version: Long
)
@Serializable
data class FCastKeyExchangeMessage(
val version: Long,
val publicKey: String
)
@Serializable
data class FCastDecryptedMessage(
val opcode: Long,
val message: String?
)
@Serializable
data class FCastEncryptedMessage(
val version: Long,
val iv: String?,
val blob: String
)
@@ -71,16 +71,14 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
fun emit() : Boolean { fun emit() : Boolean {
var handled = false; var handled = false;
synchronized(_conditionalListeners) { val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in _conditionalListeners) for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke(); handled = handled || conditional.handler.invoke();
}
synchronized(_listeners) { val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || _listeners.isNotEmpty(); handled = handled || snapshot.isNotEmpty();
for (handler in _listeners) for (handler in snapshot)
handler.handler.invoke(); handler.handler.invoke();
}
return handled; return handled;
} }
@@ -88,16 +86,15 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
class Event1<T1>() : EventBase<((T1)->Unit), ((T1)->Boolean)>() { class Event1<T1>() : EventBase<((T1)->Unit), ((T1)->Boolean)>() {
fun emit(value : T1): Boolean { fun emit(value : T1): Boolean {
var handled = false; var handled = false;
synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners)
handled = handled || conditional.handler.invoke(value);
}
synchronized(_listeners) { val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
handled = handled || _listeners.isNotEmpty(); for (conditional in condSnapshot)
for (handler in _listeners) handled = handled || conditional.handler.invoke(value);
handler.handler.invoke(value);
} val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || snapshot.isNotEmpty();
for (handler in snapshot)
handler.handler.invoke(value);
return handled; return handled;
} }
@@ -106,16 +103,14 @@ class Event2<T1, T2>() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() {
fun emit(value1 : T1, value2 : T2): Boolean { fun emit(value1 : T1, value2 : T2): Boolean {
var handled = false; var handled = false;
synchronized(_conditionalListeners) { val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in _conditionalListeners) for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke(value1, value2); handled = handled || conditional.handler.invoke(value1, value2);
}
synchronized(_listeners) { val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || _listeners.isNotEmpty(); handled = handled || snapshot.isNotEmpty();
for (handler in _listeners) for (handler in snapshot)
handler.handler.invoke(value1, value2); handler.handler.invoke(value1, value2);
}
return handled; return handled;
} }
@@ -125,16 +120,14 @@ class Event3<T1, T2, T3>() : EventBase<((T1, T2, T3)->Unit), ((T1, T2, T3)->Bool
fun emit(value1 : T1, value2 : T2, value3 : T3): Boolean { fun emit(value1 : T1, value2 : T2, value3 : T3): Boolean {
var handled = false; var handled = false;
synchronized(_conditionalListeners) { val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in _conditionalListeners) for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke(value1, value2, value3); handled = handled || conditional.handler.invoke(value1, value2, value3);
}
synchronized(_listeners) { val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || _listeners.isNotEmpty(); handled = handled || snapshot.isNotEmpty();
for (handler in _listeners) for (handler in snapshot)
handler.handler.invoke(value1, value2, value3); handler.handler.invoke(value1, value2, value3);
}
return handled; return handled;
} }
@@ -134,7 +134,7 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
var inputStream: InputStream? = null; var inputStream: InputStream? = null;
try { try {
val client = ManagedHttpClient(); val client = ManagedHttpClient();
val response = client.get(StateUpdate.APK_URL); val response = client.get(StateUpdate.getApkUrl(_maxVersion));
if (response.isOk && response.body != null) { if (response.isOk && response.body != null) {
inputStream = response.body.byteStream(); inputStream = response.body.byteStream();
val dataLength = response.body.contentLength(); val dataLength = response.body.contentLength();
@@ -1,9 +1,13 @@
package com.futo.platformplayer.dialogs package com.futo.platformplayer.dialogs
import android.app.Activity
import android.app.AlertDialog import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.* import android.widget.*
@@ -11,88 +15,88 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup import com.futo.platformplayer.states.StateBackup
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
class AutomaticBackupDialog(context: Context) : AlertDialog(context) { class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
private lateinit var _buttonStart: LinearLayout; private lateinit var _buttonStart: LinearLayout
private lateinit var _buttonStop: LinearLayout; private lateinit var _buttonStop: LinearLayout
private lateinit var _buttonCancel: ImageButton; private lateinit var _buttonCancel: ImageButton
private lateinit var _imm: InputMethodManager
private lateinit var _editPassword: EditText;
private lateinit var _editPassword2: EditText;
private lateinit var _inputMethodManager: InputMethodManager;
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState)
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null)); setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null))
_buttonCancel = findViewById(R.id.button_cancel); _buttonCancel = findViewById(R.id.button_cancel)
_buttonStop = findViewById(R.id.button_stop); _buttonStop = findViewById(R.id.button_stop)
_buttonStart = findViewById(R.id.button_start); _buttonStart = findViewById(R.id.button_start)
_editPassword = findViewById(R.id.edit_password);
_editPassword2 = findViewById(R.id.edit_password2);
_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 { _buttonCancel.setOnClickListener {
clearFocus(); dismiss()
dismiss(); }
};
_buttonStop.setOnClickListener {
clearFocus();
dismiss();
Settings.instance.backup.autoBackupPassword = null;
Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save();
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 { _buttonStart.setOnClickListener {
val p1 = _editPassword.text.toString(); dismiss()
val p2 = _editPassword2.text.toString(); Logger.i(TAG, "Enable AutoBackup (unencrypted)")
if(!(p1?.equals(p2) ?: false)) {
UIDialogs.toast(context, "Password fields do not match, confirm that you typed it correctly."); val activity = StateApp.instance.activity as? Activity
return@setOnClickListener; if (activity == null) {
UIDialogs.toast(context, "No activity available")
return@setOnClickListener
} }
val pbytes = _editPassword.text.toString().toByteArray(); dismiss()
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();
Logger.i(TAG, "Set AutoBackupPassword"); Logger.i(TAG, "Enable AutoBackup")
Settings.instance.backup.autoBackupPassword = _editPassword.text.toString(); Settings.instance.backup.autoBackupPassword = null
Settings.instance.backup.didAskAutoBackup = true; Settings.instance.backup.didAskAutoBackup = true
Settings.instance.save(); Settings.instance.save()
UIDialogs.toast(context, "AutoBackup enabled"); UIDialogs.toast(context, "AutoBackup enabled")
try { 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); Settings.instance.backup.autoBackupEnabled = true
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message); 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); 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) };
} }
companion object { 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.app.AlertDialog
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.view.inputmethod.InputMethodManager 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.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs 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.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.StateBackup
import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.*
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import userpackage.Protocol import kotlinx.coroutines.withContext
import java.time.OffsetDateTime
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 _buttonStart: LinearLayout; private lateinit var _buttonCancel: MaterialButton
private lateinit var _buttonCancel: MaterialButton; private lateinit var _textReason: TextView
private lateinit var _editPassword: EditText
private lateinit var _editPassword: EditText; private lateinit var _passwordContainer: LinearLayout
private lateinit var _icon: ImageView
private lateinit var _inputMethodManager: InputMethodManager; 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState)
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup_restore, null)); setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup_restore, null))
_buttonCancel = findViewById(R.id.button_cancel); _buttonCancel = findViewById(R.id.button_cancel)
_buttonStart = findViewById(R.id.button_start); _buttonStart = findViewById(R.id.button_start)
_editPassword = findViewById(R.id.edit_password); _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 { _buttonCancel.setOnClickListener {
clearFocus(); clearFocus()
dismiss(); dismiss()
}; }
_buttonStart.setOnClickListener { onStartClicked() }
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
}
_buttonStart.setOnClickListener { override fun onStart() {
val pbytes = _editPassword.text.toString().toByteArray(); super.onStart()
if(pbytes.size < 4 || pbytes.size > 32) {
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and less than 32 bytes", false); _detectJob?.cancel()
return@setOnClickListener; _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 { try {
StateBackup.restoreAutomaticBackup(context, scope, _editPassword.text.toString(), true); StateBackup.restoreAutomaticBackup(context, scope, if (_needsPassword) password else "", true)
dismiss(); 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() { private fun clearFocus() {
_editPassword.clearFocus(); _editPassword.clearFocus()
currentFocus?.let { _inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) }; currentFocus?.let { _imm.hideSoftInputFromWindow(it.windowToken, 0) }
} }
companion object { companion object {
private val TAG = "AutomaticRestoreDialog"; private const val TAG = "AutomaticRestoreDialog"
} }
} }
@@ -40,13 +40,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_buttonConfirm = findViewById(R.id.button_confirm); _buttonConfirm = findViewById(R.id.button_confirm);
_buttonTutorial = findViewById(R.id.button_tutorial) _buttonTutorial = findViewById(R.id.button_tutorial)
val deviceTypeArray = if (Settings.instance.casting.experimentalCasting) { ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
R.array.exp_casting_device_type_array
} else {
R.array.casting_device_type_array
}
ArrayAdapter.createFromResource(context, deviceTypeArray, R.layout.spinner_item_simple).also { adapter ->
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
_spinnerType.adapter = adapter; _spinnerType.adapter = adapter;
}; };
@@ -12,7 +12,6 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastProtocolType import com.futo.platformplayer.casting.CastProtocolType
@@ -174,13 +173,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_textType.text = "AirPlay"; _textType.text = "AirPlay";
} }
CastProtocolType.FCAST -> { CastProtocolType.FCAST -> {
_imageDevice.setImageResource( _imageDevice.setImageResource(R.drawable.ic_fc)
if (Settings.instance.casting.experimentalCasting) {
R.drawable.ic_exp_fc
} else {
R.drawable.ic_fc
}
)
_textType.text = "FCast"; _textType.text = "FCast";
} }
} }
@@ -139,19 +139,35 @@ class VideoDownload {
@Contextual @Contextual
@kotlinx.serialization.Transient @kotlinx.serialization.Transient
var videoSourceLive: JSSource? = null; 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; var requiresLiveAudioSource: Boolean = false;
@Contextual @Contextual
@kotlinx.serialization.Transient @kotlinx.serialization.Transient
var audioSourceLive: JSSource? = null; 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 hasVideoRequestExecutor: Boolean = false;
var hasAudioRequestExecutor: Boolean = false; var hasAudioRequestExecutor: Boolean = false;
var hasVideoRequestModifier: Boolean = false; var hasVideoRequestModifier: Boolean = false;
var hasAudioRequestModifier: Boolean = false; var hasAudioRequestModifier: Boolean = false;
// Transient: IRequestModifier is a runtime object from the JS plugin engine and cannot be
// serialized. After deserialization these are null - DownloadService must re-prepare to
// recapture them from the live plugin source (see needsReprepareForAuth).
@kotlinx.serialization.Transient
private var preparedVideoRequestModifier: IRequestModifier? = null;
@kotlinx.serialization.Transient
private var preparedAudioRequestModifier: IRequestModifier? = null;
val needsReprepareForAuth: Boolean get() =
(hasVideoRequestModifier && preparedVideoRequestModifier == null && videoSourceLive == null) ||
(hasAudioRequestModifier && preparedAudioRequestModifier == null && audioSourceLive == null);
var progress: Double = 0.0; var progress: Double = 0.0;
var isCancelled = false; var isCancelled = false;
@@ -207,7 +223,7 @@ class VideoDownload {
this.requireAudioSource = targetBitrate != null; //TODO: May not be a valid check.. can only be determined after live fetch? this.requireAudioSource = targetBitrate != null; //TODO: May not be a valid check.. can only be determined after live fetch?
this.requiredCheck = optionalSources; this.requiredCheck = optionalSources;
} }
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) { constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?, videoModifier: IRequestModifier? = null, audioModifier: IRequestModifier? = null) {
this.video = SerializedPlatformVideo.fromVideo(video); this.video = SerializedPlatformVideo.fromVideo(video);
this.videoDetails = SerializedPlatformVideoDetails.fromVideo(video, if (subtitleSource != null) listOf(subtitleSource) else listOf()); this.videoDetails = SerializedPlatformVideoDetails.fromVideo(video, if (subtitleSource != null) listOf(subtitleSource) else listOf());
this.videoSource = if(videoSource is IVideoUrlSource) VideoUrlSource.fromUrlSource(videoSource) else null; this.videoSource = if(videoSource is IVideoUrlSource) VideoUrlSource.fromUrlSource(videoSource) else null;
@@ -216,12 +232,22 @@ class VideoDownload {
this.audioSourceLive = if(audioSource is JSSource) audioSource else null; this.audioSourceLive = if(audioSource is JSSource) audioSource else null;
this.subtitleSource = subtitleSource; this.subtitleSource = subtitleSource;
this.prepareTime = OffsetDateTime.now(); this.prepareTime = OffsetDateTime.now();
this.preparedVideoRequestModifier = videoModifier ?: (if (videoSource is JSSource && videoSource.hasRequestModifier) videoSource.getRequestModifier() else null);
this.preparedAudioRequestModifier = audioModifier ?: (if (audioSource is JSSource && audioSource.hasRequestModifier) audioSource.getRequestModifier() else null);
this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor; this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor; this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
this.hasVideoRequestModifier = videoSource is JSSource && videoSource.hasRequestModifier; // Set modifier flags from either the source or an explicitly provided modifier
this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier; // (e.g. from the HLS picker, where the source is an HLSVariant, not JSSource).
this.requiresLiveVideoSource = this.hasVideoRequestModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate); // These flags are serialized and used by needsReprepareForAuth after restore.
this.requiresLiveAudioSource = this.hasAudioRequestModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate); this.hasVideoRequestModifier = preparedVideoRequestModifier != null;
this.hasAudioRequestModifier = preparedAudioRequestModifier != null;
// requiresLiveVideoSource means a live JSSource is needed at download time (for executors
// or DASH generation). Modifiers alone don't require a live source - they're already
// captured in preparedVideoRequestModifier and recaptured via needsReprepareForAuth.
val sourceHasVideoModifier = videoSource is JSSource && videoSource.hasRequestModifier;
val sourceHasAudioModifier = audioSource is JSSource && audioSource.hasRequestModifier;
this.requiresLiveVideoSource = sourceHasVideoModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
this.requiresLiveAudioSource = sourceHasAudioModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
this.targetVideoName = videoSource?.name; this.targetVideoName = videoSource?.name;
this.targetAudioName = audioSource?.name; this.targetAudioName = audioSource?.name;
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null; this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
@@ -317,8 +343,10 @@ class VideoDownload {
val videoSources = arrayListOf<IVideoSource>() val videoSources = arrayListOf<IVideoSource>()
for (source in original.video.videoSources) { for (source in original.video.videoSources) {
if (source is IHLSManifestSource) { if (source is IHLSManifestSource) {
val sourceModifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
if (sourceModifier != null) preparedVideoRequestModifier = sourceModifier
try { try {
val playlistResponse = client.get(source.url) val playlistResponse = client.get(source.url, HashMap(), sourceModifier)
if (playlistResponse.isOk) { if (playlistResponse.isOk) {
val resolvedPlaylistUrl = playlistResponse.url val resolvedPlaylistUrl = playlistResponse.url
val playlistContent = playlistResponse.body?.string() val playlistContent = playlistResponse.body?.string()
@@ -345,6 +373,8 @@ class VideoDownload {
if(vsource is JSSource) { if(vsource is JSSource) {
this.hasVideoRequestExecutor = this.hasVideoRequestExecutor || vsource.hasRequestExecutor; this.hasVideoRequestExecutor = this.hasVideoRequestExecutor || vsource.hasRequestExecutor;
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (vsource is JSDashManifestRawSource && vsource.hasGenerate); this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (vsource is JSDashManifestRawSource && vsource.hasGenerate);
if (vsource.hasRequestModifier && preparedVideoRequestModifier == null)
preparedVideoRequestModifier = vsource.getRequestModifier()
} }
if(vsource == null) { if(vsource == null) {
@@ -366,8 +396,10 @@ class VideoDownload {
if (video is VideoUnMuxedSourceDescriptor) { if (video is VideoUnMuxedSourceDescriptor) {
for (source in video.audioSources) { for (source in video.audioSources) {
if (source is IHLSManifestAudioSource) { if (source is IHLSManifestAudioSource) {
val sourceModifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
if (sourceModifier != null) preparedAudioRequestModifier = sourceModifier
try { try {
val playlistResponse = client.get(source.url) val playlistResponse = client.get(source.url, HashMap(), sourceModifier)
if (playlistResponse.isOk) { if (playlistResponse.isOk) {
val resolvedPlaylistUrl = playlistResponse.url val resolvedPlaylistUrl = playlistResponse.url
val playlistContent = playlistResponse.body?.string() val playlistContent = playlistResponse.body?.string()
@@ -402,6 +434,8 @@ class VideoDownload {
if(asource is JSSource) { if(asource is JSSource) {
this.hasAudioRequestExecutor = this.hasAudioRequestExecutor || asource.hasRequestExecutor; this.hasAudioRequestExecutor = this.hasAudioRequestExecutor || asource.hasRequestExecutor;
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (asource is JSDashManifestRawSource && asource.hasGenerate); this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (asource is JSDashManifestRawSource && asource.hasGenerate);
if (asource.hasRequestModifier && preparedAudioRequestModifier == null)
preparedAudioRequestModifier = asource.getRequestModifier()
} }
if(asource == null) { if(asource == null) {
@@ -498,10 +532,16 @@ class VideoDownload {
} }
} }
val videoModifier = preparedVideoRequestModifier
if(actualVideoSource is IVideoUrlSource) if(actualVideoSource is IVideoUrlSource)
videoFileSize = when (videoSource!!.container) { videoFileSize = when (videoSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback) "application/vnd.apple.mpegurl" -> {
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback) // HLS segments are concatenated into an MP4 file during download,
// so override the container for local playback/casting
videoOverrideContainer = "video/mp4";
downloadHlsSource(context, "Video", client, videoModifier, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
}
else -> downloadFileSource("Video", client, videoModifier, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
} }
else if(actualVideoSource is JSDashManifestRawSource) { else if(actualVideoSource is JSDashManifestRawSource) {
if(actualAudioSource == null) if(actualAudioSource == null)
@@ -542,10 +582,16 @@ class VideoDownload {
} }
} }
val audioModifier = preparedAudioRequestModifier
if(actualAudioSource is IAudioUrlSource) if(actualAudioSource is IAudioUrlSource)
audioFileSize = when (audioSource!!.container) { audioFileSize = when (audioSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback) "application/vnd.apple.mpegurl" -> {
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback) // HLS segments are concatenated into an MP4 file during download,
// so override the container for local playback/casting
audioOverrideContainer = "audio/mp4";
downloadHlsSource(context, "Audio", client, audioModifier, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
}
else -> downloadFileSource("Audio", client, audioModifier, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
} }
else if(actualAudioSource is JSDashManifestRawAudioSource) { else if(actualAudioSource is JSDashManifestRawAudioSource) {
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback, 2); audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback, 2);
@@ -659,15 +705,11 @@ class VideoDownload {
} }
} }
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, modifier: IRequestModifier?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if (targetFile.exists()) if (targetFile.exists())
targetFile.delete() targetFile.delete()
var downloadedTotalLength = 0L var downloadedTotalLength = 0L
val modifier = if (source is JSSource && source.hasRequestModifier)
source.getRequestModifier()
else
null
fun downloadBytes(url: String, rangeStart: Long? = null, rangeLength: Long? = null): ByteArray { fun downloadBytes(url: String, rangeStart: Long? = null, rangeLength: Long? = null): ByteArray {
val headers = mutableMapOf<String, String>() val headers = mutableMapOf<String, String>()
@@ -681,17 +723,13 @@ class VideoDownload {
} }
} }
val modified = modifier?.modifyRequest(url, headers) val resp = client.get(url, headers, modifier)
val finalUrl = modified?.url ?: url
val finalHeaders = modified?.headers?.toMutableMap() ?: headers
val resp = client.get(finalUrl, finalHeaders)
if (!resp.isOk) { if (!resp.isOk) {
resp.body?.close() resp.body?.close()
throw IllegalStateException("Failed to download HLS resource ($finalUrl): HTTP ${resp.code}") throw IllegalStateException("Failed to download HLS resource ($url): HTTP ${resp.code}")
} }
val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($finalUrl): Empty body") val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($url): Empty body")
val bytes = body.bytes() val bytes = body.bytes()
body.close() body.close()
return bytes return bytes
@@ -706,12 +744,7 @@ class VideoDownload {
val segmentFiles = arrayListOf<File>() val segmentFiles = arrayListOf<File>()
try { try {
val playlistHeaders = mutableMapOf<String, String>() val playlistResp = client.get(hlsUrl, mutableMapOf(), modifier)
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
val playlistResp = client.get(
modifiedPlaylistReq?.url ?: hlsUrl,
modifiedPlaylistReq?.headers?.toMutableMap() ?: playlistHeaders
)
check(playlistResp.isOk) { "Failed to get variant playlist: ${playlistResp.code}" } check(playlistResp.isOk) { "Failed to get variant playlist: ${playlistResp.code}" }
@@ -960,16 +993,7 @@ class VideoDownload {
Logger.i(TAG, "Downloading cue ${indexCounter}") Logger.i(TAG, "Downloading cue ${indexCounter}")
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString()); val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
val modified = modifier?.modifyRequest(url, mapOf()); val data = executeOrGet(client, executor, modifier, url)
val data = if(executor != null)
executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: mapOf());
else {
val resp = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: mutableMapOf());
if(!resp.isOk)
throw IllegalStateException("Dash request failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
resp.body!!.bytes()
}
fileStream.write(data, 0, data.size); fileStream.write(data, 0, data.size);
speedTracker.addWork(data.size.toLong()); speedTracker.addWork(data.size.toLong());
written += data.size; written += data.size;
@@ -989,16 +1013,7 @@ class VideoDownload {
val t2 = cue2.groupValues[1]; val t2 = cue2.groupValues[1];
val d2 = cue2.groupValues[2]; val d2 = cue2.groupValues[2];
val url2 = foundTemplateUrl2!!.replace("\$Number\$", (index2).toString()); val url2 = foundTemplateUrl2!!.replace("\$Number\$", (index2).toString());
val modified2 = modifier?.modifyRequest(url, mapOf()); val data = executeOrGet(client, executor, modifier, url2)
val data = if(executor != null)
executor.executeRequest("GET", modified2?.url ?: url2, null, modified2?.headers ?: mapOf());
else {
val resp = client.get(modified2?.url ?: url, modified2?.headers?.toMutableMap() ?: mutableMapOf());
if(!resp.isOk)
throw IllegalStateException("Dash request2 failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
resp.body!!.bytes()
}
fileStream2.write(data, 0, data.size); fileStream2.write(data, 0, data.size);
speedTracker.addWork(data.size.toLong()); speedTracker.addWork(data.size.toLong());
written2 += data.size; written2 += data.size;
@@ -1067,7 +1082,7 @@ class VideoDownload {
} }
} }
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { private fun downloadFileSource(name: String, client: ManagedHttpClient, modifier: IRequestModifier?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists()) if(targetFile.exists())
targetFile.delete(); targetFile.delete();
@@ -1076,13 +1091,8 @@ class VideoDownload {
val sourceLength: Long?; val sourceLength: Long?;
val fileStream = FileOutputStream(targetFile); val fileStream = FileOutputStream(targetFile);
val modifier = if (source is JSSource && source.hasRequestModifier)
source.getRequestModifier();
else
null;
try { try {
val head = client.tryHead(videoUrl); val head = client.tryHead(videoUrl, modifier);
val relatedPlugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null }; val relatedPlugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null };
if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length")) if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length"))
{ {
@@ -1157,12 +1167,7 @@ class VideoDownload {
var lastSpeed: Long = 0; var lastSpeed: Long = 0;
val result = if (modifier != null) { val result = client.get(url, HashMap(), modifier)
val modified = modifier.modifyRequest(url, mapOf())
client.get(modified.url!!, modified.headers.toMutableMap())
} else {
client.get(url)
}
if (!result.isOk) { if (!result.isOk) {
result.body?.close() result.body?.close()
throw IllegalStateException("Failed to download source. Web[${result.code}] Error"); throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
@@ -1375,13 +1380,12 @@ class VideoDownload {
var lastException: Throwable? = null; var lastException: Throwable? = null;
val headers = mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}")); val headers = mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}"));
val modified = modifier?.modifyRequest(url, headers);
while (retryCount <= 3) { while (retryCount <= 3) {
try { try {
val toRead = rangeEnd - rangeStart; val toRead = rangeEnd - rangeStart;
val req = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: headers); val req = client.get(url, headers.toMutableMap(), modifier);
if (!req.isOk) { if (!req.isOk) {
val bodyString = req.body?.string() val bodyString = req.body?.string()
req.body?.close() req.body?.close()
@@ -1458,6 +1462,9 @@ class VideoDownload {
if(localAudioSource != null && audioSourceToUse != null && audioSourceToUse is IStreamMetaDataSource) if(localAudioSource != null && audioSourceToUse != null && audioSourceToUse is IStreamMetaDataSource)
localAudioSource.streamMetaData = (audioSourceToUse as IStreamMetaDataSource).streamMetaData; localAudioSource.streamMetaData = (audioSourceToUse as IStreamMetaDataSource).streamMetaData;
if(localAudioSource != null && localAudioSource.streamMetaData == null && videoSourceToUse is JSDashManifestRawSource)
localAudioSource.streamMetaData = (videoSourceToUse as JSDashManifestRawSource).audioStreamMetaData;
if(existing != null) { if(existing != null) {
existing.videoSerialized = videoDetails!!; existing.videoSerialized = videoDetails!!;
if(localVideoSource != null) { if(localVideoSource != null) {
@@ -1512,6 +1519,18 @@ class VideoDownload {
} }
} }
private fun executeOrGet(client: ManagedHttpClient, executor: JSRequestExecutor?, modifier: IRequestModifier?, url: String, headers: Map<String, String> = mapOf()): ByteArray {
if (executor != null) {
val modified = modifier?.modifyRequest(url, headers)
return executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: headers)
} else {
val resp = client.get(url, headers.toMutableMap(), modifier)
if (!resp.isOk)
throw IllegalStateException("Request failed for ($url) with code: ${resp.code}")
return resp.body!!.bytes()
}
}
companion object { companion object {
const val TAG = "VideoDownload"; const val TAG = "VideoDownload";
const val GROUP_PLAYLIST = "Playlist"; const val GROUP_PLAYLIST = "Playlist";
@@ -13,8 +13,10 @@ import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBitrate
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
@@ -51,8 +53,12 @@ class VideoExport {
val outputFile: DocumentFile?; val outputFile: DocumentFile?;
val downloadRoot = documentRoot ?: StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set"); 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) { 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) val f = downloadRoot.createFile("video/mp4", outputFileName)
?: throw Exception("Failed to create file in external directory."); ?: throw Exception("Failed to create file in external directory.");
@@ -60,7 +66,9 @@ class VideoExport {
val tempFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4"); val tempFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4");
try { try {
combine(a?.filePath, v?.filePath, s?.filePath, tempFile.absolutePath, videoLocal.duration.toDouble()) { progress -> onProgress?.invoke(progress) }; 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) }; copy(tempFile.absolutePath, outputStream) { progress -> onProgress?.invoke(progress) };
} }
} finally { } finally {
@@ -68,25 +76,29 @@ class VideoExport {
} }
outputFile = f; outputFile = f;
} else if (v != null) { } 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) 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."); ?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying video."); 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) }; copy(v.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
} }
outputFile = f; outputFile = f;
} else if (a != null) { } 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) 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."); ?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying audio."); 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) }; copy(a.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
} }
@@ -98,29 +110,48 @@ class VideoExport {
return@coroutineScope outputFile; 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) { private suspend fun combine(inputPathAudio: String?, inputPathVideo: String?, inputPathSubtitles: String?, outputPath: String, duration: Double, onProgress: ((Double) -> Unit)? = null) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation -> 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") val cmdBuilder = StringBuilder("-y")
var counter = 0 var counter = 0
if (inputPathVideo != null) { if (inputPathVideo != null) {
cmdBuilder.append(" -i $inputPathVideo") cmdBuilder.append(" -i ${ffmpegArg(inputPathVideo)}")
} }
if (inputPathAudio != null) { if (inputPathAudio != null) {
cmdBuilder.append(" -i $inputPathAudio") cmdBuilder.append(" -i ${ffmpegArg(inputPathAudio)}")
} }
if (inputPathSubtitles != null) { if (inputPathSubtitles != null) {
val subtitleExtension = File(inputPathSubtitles).extension val subtitleExtension = File(inputPathSubtitles).extension.lowercase()
when (subtitleExtension) {
val codec = when (subtitleExtension.lowercase()) { "srt", "vtt" -> {}
"srt" -> "mov_text"
"vtt" -> "webvtt"
else -> throw Exception("Unsupported subtitle format: $subtitleExtension") else -> throw Exception("Unsupported subtitle format: $subtitleExtension")
} }
cmdBuilder.append(" -scodec $codec -i $inputPathSubtitles") cmdBuilder.append(" -i ${ffmpegArg(inputPathSubtitles)}")
} }
if (inputPathVideo != null) { if (inputPathVideo != null) {
@@ -132,6 +163,7 @@ class VideoExport {
if (inputPathSubtitles != null) { if (inputPathSubtitles != null) {
cmdBuilder.append(" -map ${counter++}") cmdBuilder.append(" -map ${counter++}")
cmdBuilder.append(" -c:s mov_text")
} }
if (inputPathVideo != null) { if (inputPathVideo != null) {
@@ -140,33 +172,44 @@ class VideoExport {
if (inputPathAudio != null) { if (inputPathAudio != null) {
cmdBuilder.append(" -c:a copy") 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() val cmd = cmdBuilder.toString()
Logger.i(TAG, "Used command: $cmd"); Logger.i(TAG, "Used command: $cmd");
val statisticsCallback = StatisticsCallback { statistics -> val statisticsCallback = StatisticsCallback { statistics ->
val time = statistics.time.toDouble() / 1000.0 val time = statistics.time.toDouble() / 1000.0
val progressPercentage = (time / duration) val progressPercentage = if (duration > 0.0) {
onProgress?.invoke(progressPercentage) (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 executorService = Executors.newSingleThreadExecutor()
val session = FFmpegKit.executeAsync(cmd, val session = FFmpegKit.executeAsync(cmd,
{ session -> { session ->
if (ReturnCode.isSuccess(session.returnCode)) { try {
continuation.resumeWith(Result.success(Unit)) if (ReturnCode.isSuccess(session.returnCode)) {
} else { resumeSuccessSafely(continuation)
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
"Command cancelled"
} else { } 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) }, LogCallback { Logger.v(TAG, it.message) },
@@ -176,6 +219,7 @@ class VideoExport {
continuation.invokeOnCancellation { continuation.invokeOnCancellation {
session.cancel() session.cancel()
executorService.shutdown()
} }
} }
} }
@@ -196,14 +240,24 @@ class VideoExport {
val totalBytes = srcFile.length() val totalBytes = srcFile.length()
var bytesCopied: Long = 0 var bytesCopied: Long = 0
if (totalBytes == 0L) {
onProgress?.let {
withContext(Dispatchers.Main) {
it(1.0)
}
}
return@withContext
}
var bytesRead: Int var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) { while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead) outputStream.write(buffer, 0, bytesRead)
bytesCopied += bytesRead.toLong() bytesCopied += bytesRead.toLong()
onProgress?.let { onProgress?.let {
val progress = (bytesCopied / totalBytes.toDouble()).coerceIn(0.0, 1.0)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
it(bytesCopied / totalBytes.toDouble()) it(progress)
} }
} }
} }
@@ -15,7 +15,9 @@ import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.IV8ValuePromise import com.caoccao.javet.values.reference.IV8ValuePromise
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.caoccao.javet.values.reference.V8ValuePromise import com.caoccao.javet.values.reference.V8ValuePromise
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.engine.exceptions.NoInternetException import com.futo.platformplayer.engine.exceptions.NoInternetException
@@ -34,6 +36,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptTimeoutException
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.engine.internal.V8Converter import com.futo.platformplayer.engine.internal.V8Converter
import com.futo.platformplayer.engine.packages.PackageBridge import com.futo.platformplayer.engine.packages.PackageBridge
import com.futo.platformplayer.engine.packages.PackageBrowser
import com.futo.platformplayer.engine.packages.PackageDOMParser import com.futo.platformplayer.engine.packages.PackageDOMParser
import com.futo.platformplayer.engine.packages.PackageHttp import com.futo.platformplayer.engine.packages.PackageHttp
import com.futo.platformplayer.engine.packages.PackageHttpImp import com.futo.platformplayer.engine.packages.PackageHttpImp
@@ -44,6 +47,7 @@ import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateAssets import com.futo.platformplayer.states.StateAssets
import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.toList import com.futo.platformplayer.toList
import com.futo.platformplayer.toV8ValueBlocking import com.futo.platformplayer.toV8ValueBlocking
import com.futo.platformplayer.toV8ValueAsync import com.futo.platformplayer.toV8ValueAsync
@@ -54,6 +58,7 @@ import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock import kotlin.concurrent.withLock
@@ -83,6 +88,7 @@ class V8Plugin {
private val _deps : LinkedHashMap<String, String> = LinkedHashMap(); private val _deps : LinkedHashMap<String, String> = LinkedHashMap();
private val _depsPackages : MutableList<V8Package> = mutableListOf(); private val _depsPackages : MutableList<V8Package> = mutableListOf();
private var _script : String? = null; private var _script : String? = null;
val bridge: PackageBridge;
var isStopped = true; var isStopped = true;
val onStopped = Event1<V8Plugin>(); val onStopped = Event1<V8Plugin>();
@@ -90,6 +96,9 @@ class V8Plugin {
private val _busyLock = ReentrantLock() private val _busyLock = ReentrantLock()
val isBusy get() = _busyLock.isLocked; val isBusy get() = _busyLock.isLocked;
@Volatile
private var _busyHolder: Thread? = null;
var allowDevSubmit: Boolean = false var allowDevSubmit: Boolean = false
private set(value) { private set(value) {
field = value; field = value;
@@ -110,7 +119,8 @@ class V8Plugin {
this._clientAuth = clientAuth; this._clientAuth = clientAuth;
this.config = config; this.config = config;
this._script = script; this._script = script;
withDependency(PackageBridge(this, config)); bridge = PackageBridge(this, config);
withDependency(bridge);
for(pack in config.packages) for(pack in config.packages)
withDependency(getPackage(pack)!!); withDependency(getPackage(pack)!!);
@@ -155,51 +165,53 @@ class V8Plugin {
fun start() { fun start() {
val script = _script ?: throw IllegalStateException("Attempted to start V8 without script"); val script = _script ?: throw IllegalStateException("Attempted to start V8 without script");
synchronized(_runtimeLock) { tryBusy(BUSY_STARTUP_MS) {
if (_runtime != null) synchronized(_runtimeLock) {
return; if (_runtime != null)
runtimeId = runtimeId + 1; return@tryBusy;
//V8RuntimeOptions.V8_FLAGS.setUseStrict(true); runtimeId = runtimeId + 1;
val host = V8Host.getV8Instance(); //V8RuntimeOptions.V8_FLAGS.setUseStrict(true);
val options = host.jsRuntimeType.getRuntimeOptions(); val host = V8Host.getV8Instance();
val options = host.jsRuntimeType.getRuntimeOptions();
_runtime = host.createV8Runtime(options); _runtime = host.createV8Runtime(options);
if (!host.isIsolateCreated) if (!host.isIsolateCreated)
throw IllegalStateException("Isolate not created"); throw IllegalStateException("Isolate not created");
_runtimeMap.put(_runtime!!, this); _runtimeMap.put(_runtime!!, this);
//Setup bridge //Setup bridge
_runtime?.let { _runtime?.let {
it.converter = V8Converter(); it.converter = V8Converter();
for (pack in _depsPackages) { for (pack in _depsPackages) {
if (pack.variableName != null) if (pack.variableName != null)
it.createV8ValueObject().use { v8valueObject -> it.createV8ValueObject().use { v8valueObject ->
it.globalObject.set(pack.variableName, v8valueObject); it.globalObject.set(pack.variableName, v8valueObject);
v8valueObject.bind(pack); v8valueObject.bind(pack);
}; };
catchScriptErrors("Package Dep[${pack.name}]") { catchScriptErrors("Package Dep[${pack.name}]") {
for (packScript in pack.getScripts()) for (packScript in pack.getScripts())
it.getExecutor(packScript).executeVoid(); it.getExecutor(packScript).executeVoid();
}
} }
}
//Load deps //Load deps
for (dep in _deps) for (dep in _deps)
catchScriptErrors("Dep[${dep.key}]") { catchScriptErrors("Dep[${dep.key}]") {
it.getExecutor(dep.value).executeVoid() it.getExecutor(dep.value).executeVoid()
};
if (config.allowEval)
it.allowEval(true);
//Load plugin
catchScriptErrors("Plugin[${config.name}]") {
it.getExecutor(script).executeVoid()
}; };
isStopped = false;
}
if (config.allowEval)
it.allowEval(true);
//Load plugin
catchScriptErrors("Plugin[${config.name}]") {
it.getExecutor(script).executeVoid()
};
isStopped = false;
} }
} }
} }
@@ -218,6 +230,9 @@ class V8Plugin {
if(pack is PackageHttp) { if(pack is PackageHttp) {
pack.cleanup(); pack.cleanup();
} }
else if(pack is PackageBrowser) {
pack.deinitialize();
}
} }
_runtime?.let { _runtime?.let {
@@ -245,27 +260,30 @@ class V8Plugin {
fun isThreadAlreadyBusy(): Boolean { fun isThreadAlreadyBusy(): Boolean {
return _busyLock.isHeldByCurrentThread; return _busyLock.isHeldByCurrentThread;
} }
fun <T> busy(handle: ()->T): T { fun <T> busy(handle: ()->T): T = busyInternal(BUSY_FATAL_MS, true, "busy(enter)", handle)
_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> 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 { try {
return handle(); return handle();
} }
finally { 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()) if (_busyLock.isHeldByCurrentThread) {
_busyLock.unlock(); 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 { fun <T> unbusy(handle: ()->T): T {
val wasLocked = isThreadAlreadyBusy(); val wasLocked = isThreadAlreadyBusy();
if(!wasLocked) if(!wasLocked)
return handle(); return handle();
val lockCount = _busyLock.holdCount; val lockCount = _busyLock.holdCount;
_busyHolder = null;
for(i in 1..lockCount) for(i in 1..lockCount)
_busyLock.unlock(); _busyLock.unlock();
try { try {
@@ -274,9 +292,90 @@ class V8Plugin {
} }
finally { finally {
Logger.w(TAG, "Relocking V8 thread for [${config.name}] for a blocking resolve of a promise") 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) private fun acquireBusyOrThrow(context: String, maxWaitMs: Long = BUSY_FATAL_MS, allowUnwedge: Boolean = true) {
_busyLock.lock(); 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 { fun execute(js: String) : V8Value {
@@ -387,6 +486,18 @@ class V8Plugin {
"HttpImp" -> PackageHttpImp(this, config) "HttpImp" -> PackageHttpImp(this, config)
"Utilities" -> PackageUtilities(this, config) "Utilities" -> PackageUtilities(this, config)
"JSDOM" -> PackageJSDOM(this, config) "JSDOM" -> PackageJSDOM(this, config)
"Browser" -> {
val isOfficial = (config is SourcePluginConfig && config.isOfficialAuthor());
if(BuildConfig.DEBUG)
PackageBrowser(this)
else if(isOfficial)
PackageBrowser(this)
else if(config is SourcePluginConfig && config.id == StateDeveloper.DEV_ID)
PackageBrowser(this)
else
throw IllegalArgumentException("Browser is only allowed for debug and official plugins due to security");
};
else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}"); else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}");
}; };
} }
@@ -409,6 +520,11 @@ class V8Plugin {
val TAG = "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? { fun getPluginFromRuntime(runtime: V8Runtime): V8Plugin? {
return _runtimeMap.getOrDefault(runtime, null); return _runtimeMap.getOrDefault(runtime, null);
} }
@@ -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.JSClient
import com.futo.platformplayer.api.media.platforms.js.JSClientConstants 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.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
@@ -36,6 +37,9 @@ class PackageBridge : V8Package {
private val _client: ManagedHttpClient private val _client: ManagedHttpClient
@Transient @Transient
private val _clientAuth: ManagedHttpClient 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"; override val name: String get() = "Bridge";
@@ -80,6 +84,17 @@ class PackageBridge : V8Package {
return "android"; 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 @V8Property
fun supportedFeatures(): Array<String> { fun supportedFeatures(): Array<String> {
return arrayOf( return arrayOf(
@@ -105,10 +120,17 @@ class PackageBridge : V8Package {
) )
} }
@V8Function
fun hasPackage(str: String): Boolean {
return _plugin.getPackages().any { it.name == str };
}
@V8Function @V8Function
fun dispose(value: V8Value) { fun dispose(value: V8Value) {
Logger.e(TAG, "Manual dispose: " + value.javaClass.name); Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
value.close(); _plugin.busy {
value.close();
}
} }
var timeoutCounter = 0; var timeoutCounter = 0;
@@ -0,0 +1,554 @@
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.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
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDeveloper
import kotlinx.coroutines.Dispatchers
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 var _browser: WebView? = null;
private val browser: WebView get() {
if(_browser == null)
throw IllegalStateException("Browser not initialized");
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) return
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
}
}
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
}
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)
}
}
}
}
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() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
_browser?.destroy();
}
_browser = null;
}
@V8Function
fun getCurrentUrl(): String? {
return browser.url;
}
@V8Function
fun waitTillLoaded(timeout: Int = 1000): Boolean {
val acquired = _readySemaphore?.let {
if(!it.tryAcquire()) {
Logger.i("PackageBrowser", "Waiting for browser to be ready");
if(!runBlocking {
try {
return@runBlocking withTimeout(timeout.toLong(), {
it.acquire()
return@withTimeout true;
});
}
catch(ex: TimeoutCancellationException) {
return@runBlocking false;
}
}) return@let false;
}
it.release();
return@let true;
} ?: true;
if(acquired)
Logger.i("PackageBrowser", "Browser is ready");
else
Logger.i("PackageBrowser", "Browser failed wait ready");
return acquired;
}
@V8Function
fun load(url: String) {
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) {
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();
val funcClone = callback?.toClone<V8ValueFunction>()
if(callbackId != null && callback != null) {
synchronized(_callbacks) {
_callbacks.put(callbackId, {
_plugin.busy {
funcClone?.callVoid(null, arrayOf(it));
}
if (!_plugin.isStopped)
JavetResourceUtils.safeClose(funcClone);
});
}
}
StateApp.instance.scope.launch(Dispatchers.Main) {
try {
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);
}
}
}
@V8Function
fun runWithReturn(js: String, callback: V8ValueFunction? = null) {
waitTillLoaded();
val funcClone = callback?.toClone<V8ValueFunction>()
StateApp.instance.scope.launch(Dispatchers.Main) {
try {
Logger.i("PackageBrowser", "Browser running JS with callback [sync]\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 returned: " + (value ?: ""));
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
Logger.i("PackageBrowser", "Invoking V8 with result (${funcClone != null})");
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);
}
}
}
})
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Browser Failed to invoke browser", ex);
}
}
}
@V8Function
fun addScriptOnLoad(js: String): String {
require(js.isNotBlank()) { "Script must be non-empty." }
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
}
}
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 @V8Function
fun connect(socketObj: V8ValueObject) { fun connect(socketObj: V8ValueObject) {
val hasOpen = socketObj.has("open"); val (hasOpen, hasMessage, hasClosing, hasClosed, hasFailure) = _package._plugin.busy {
val hasMessage = socketObj.has("message"); val open = socketObj.has("open");
val hasClosing = socketObj.has("closing"); val message = socketObj.has("message");
val hasClosed = socketObj.has("closed"); val closing = socketObj.has("closing");
val hasFailure = socketObj.has("failure"); val closed = socketObj.has("closed");
val failure = socketObj.has("failure");
socketObj.setWeak(); //We have to manage this lifecycle socketObj.setWeak(); //We have to manage this lifecycle
_listeners = socketObj; _listeners = socketObj;
Quintuple(open, message, closing, closed, failure);
};
_socket = _packageClient.logExceptions { _socket = _packageClient.logExceptions {
val client = _client; val client = _client;
@@ -666,51 +669,50 @@ class PackageHttp: V8Package {
override fun open() { override fun open() {
Logger.i(TAG, "Websocket opened: " + _url); Logger.i(TAG, "Websocket opened: " + _url);
_isOpen = true; _isOpen = true;
if(hasOpen && _listeners?.isClosed != true) { try {
try { _package._plugin.busy {
_package._plugin.busy { if(hasOpen && _listeners?.isClosed != true) {
_listeners?.invokeV8Void("open", arrayOf<Any>()); _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) { override fun message(msg: String) {
if(hasMessage && _listeners?.isClosed != true) { try {
try { _package._plugin.busy {
_package._plugin.busy { if(hasMessage && _listeners?.isClosed != true) {
_listeners?.invokeV8Void("message", msg); _listeners?.invokeV8Void("message", msg);
} }
} }
catch(ex: Throwable) {}
} }
catch(ex: Throwable) {}
} }
override fun closing(code: Int, reason: String) { override fun closing(code: Int, reason: String) {
if(hasClosing && _listeners?.isClosed != true) try {
{ _package._plugin.busy {
try { if(hasClosing && _listeners?.isClosed != true) {
_package._plugin.busy {
_listeners?.invokeV8Void("closing", code, reason); _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) { override fun closed(code: Int, reason: String) {
_isOpen = false; _isOpen = false;
if(hasClosed && _listeners?.isClosed != true) { try {
try { _package._plugin.busy {
_package._plugin.busy { if(hasClosed && _listeners?.isClosed != true) {
_listeners?.invokeV8Void("closed", code, reason); _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"); Logger.w(TAG, "PackageHttp Socket removed");
synchronized(_package.aliveSockets) { synchronized(_package.aliveSockets) {
@@ -720,15 +722,15 @@ class PackageHttp: V8Package {
override fun failure(exception: Throwable) { override fun failure(exception: Throwable) {
_isOpen = false; _isOpen = false;
Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception); Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception);
if(hasFailure && _listeners?.isClosed != true) { try {
try { _package._plugin.busy {
_package._plugin.busy { if(hasFailure && _listeners?.isClosed != true) {
_listeners?.invokeV8Void("failure", exception.message); _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 @V8Function
fun close(code: Int?, reason: String?) { fun close(code: Int?, reason: String?) {
_socket?.close(code ?: 1000, reason ?: ""); _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( data class RequestDescriptor(
val method: String, val method: String,
val url: String, val url: String,
@@ -367,7 +367,7 @@ class ArticleDetailFragment : MainFragment {
_rating.visibility = VISIBLE; _rating.visibility = VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked); _rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { args -> _rating.onLikeDislikeUpdated.subscribe(this@ArticleDetailView) { args ->
if (args.hasLiked) { if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like); args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) { } else if (args.hasDisliked) {
@@ -10,6 +10,7 @@ import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.LinearLayout
import android.widget.Spinner import android.widget.Spinner
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@@ -21,6 +22,7 @@ class BrowserFragment : MainFragment() {
override val isTab: Boolean = false; override val isTab: Boolean = false;
override val hasBottomBar: Boolean get() = true; override val hasBottomBar: Boolean get() = true;
private var _root: LinearLayout? = null;
private var _webview: WebView? = null; private var _webview: WebView? = null;
private val _webviewWithoutHandling = object: WebViewClient() { private val _webviewWithoutHandling = object: WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
@@ -31,6 +33,7 @@ class BrowserFragment : MainFragment() {
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.fragment_browser, container, false); val view = inflater.inflate(R.layout.fragment_browser, container, false);
_root = view.findViewById<LinearLayout>(R.id.root);
_webview = view.findViewById<WebView?>(R.id.webview).apply { _webview = view.findViewById<WebView?>(R.id.webview).apply {
this.webViewClient = _webviewWithoutHandling; this.webViewClient = _webviewWithoutHandling;
this.settings.javaScriptEnabled = true; this.settings.javaScriptEnabled = true;
@@ -43,7 +46,12 @@ class BrowserFragment : MainFragment() {
override fun onShownWithView(parameter: Any?, isBack: Boolean) { override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack) super.onShownWithView(parameter, isBack)
if(parameter is String) { if(parameter is WebView) {
_root?.removeView(_webview);
_root?.addView(parameter);
_webview = parameter;
}
else if(parameter is String) {
_webview?.webViewClient = _webviewWithoutHandling; _webview?.webViewClient = _webviewWithoutHandling;
_webview?.loadUrl(parameter); _webview?.loadUrl(parameter);
} }
@@ -1,8 +1,6 @@
package com.futo.platformplayer.fragment.mainactivity.main package com.futo.platformplayer.fragment.mainactivity.main
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@@ -16,6 +14,8 @@ import com.futo.futopay.formatMoney
import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs 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.logging.Logger
import com.futo.platformplayer.states.StatePayment import com.futo.platformplayer.states.StatePayment
import com.futo.platformplayer.views.overlays.LoaderOverlay import com.futo.platformplayer.views.overlays.LoaderOverlay
@@ -68,8 +68,11 @@ class BuyFragment : MainFragment() {
_paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, _, exception -> _paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, _, exception ->
if(success) { 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)); 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,
_fragment.close(true); UIDialogs.Action("Ok", {
(fragment.activity as? MainActivity)?.navigate<SettingsFragment>(withHistory = false);
}, UIDialogs.ActionStyle.PRIMARY)
);
} }
else { else {
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.payment_failed), exception); UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.payment_failed), exception);
@@ -89,16 +92,19 @@ class BuyFragment : MainFragment() {
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
//Calling this function will cache first call //Calling this function will cache first call
try { try {
val currencies = StatePayment.instance.getAvailableCurrencies("grayjay"); // TODO: Restore multi-currency support when payment backend supports it
val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay"); // val currencies = StatePayment.instance.getAvailableCurrencies("grayjay");
val country = StatePayment.instance.getPaymentCountryFromIP(true)?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } }; // val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay");
val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } }; // 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 priceCents = StatePayment.instance.getPolarProductPrice(PaymentConfigurations.PolarConfig.PRODUCT_SLUG)
val price = prices[currency.id]!!; withContext(Dispatchers.Main) {
withContext(Dispatchers.Main) { _buttonBuyText.text = formatMoney("US", "usd", priceCents) + context.getString(R.string.plus_tax)
_buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax);
}
} }
} }
catch(ex: Throwable) { catch(ex: Throwable) {
@@ -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.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities 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.IPlatformChannel
import com.futo.platformplayer.api.media.models.channels.SerializedChannel import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.ContentType
@@ -215,6 +216,10 @@ class ChannelFragment : MainFragment() {
is IPlatformPost -> { is IPlatformPost -> {
fragment.navigate<PostDetailFragment>(v) fragment.navigate<PostDetailFragment>(v)
} }
is IPlatformArticle -> {
fragment.navigate<ArticleDetailFragment>(v)
}
} }
} }
adapter.onShortClicked.subscribe { v, _, pagerPair -> 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 { spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); 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 { spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
when(pos) { when(pos) {
@@ -162,6 +162,8 @@ class DownloadsFragment : MainFragment() {
5 -> ordering.setAndSave("releasedDesc") 5 -> ordering.setAndSave("releasedDesc")
6 -> ordering.setAndSave("sizeAsc") 6 -> ordering.setAndSave("sizeAsc")
7 -> ordering.setAndSave("sizeDesc") 7 -> ordering.setAndSave("sizeDesc")
8 -> ordering.setAndSave("typeAudio")
9 -> ordering.setAndSave("typeVideo")
else -> ordering.setAndSave("") else -> ordering.setAndSave("")
} }
updateContentFilters() updateContentFilters()
@@ -261,6 +263,8 @@ class DownloadsFragment : MainFragment() {
"releasedDesc" -> vidsToReturn.sortedByDescending { it.datetime ?: OffsetDateTime.MIN } "releasedDesc" -> vidsToReturn.sortedByDescending { it.datetime ?: OffsetDateTime.MIN }
"sizeAsc" -> vidsToReturn.sortedBy { it.videoSource.sumOf { it.fileSize } + it.audioSource.sumOf { it.fileSize } } "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 } } "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 else -> vidsToReturn
} }
} }
@@ -47,7 +47,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private val _progressBar: ProgressBar; private val _progressBar: ProgressBar;
private val _spinnerSortBy: Spinner; private val _spinnerSortBy: Spinner;
private val _containerSortBy: LinearLayout; private val _containerSortBy: LinearLayout;
private val _announcementView: AnnouncementView; //private val _announcementView: AnnouncementView;
private val _tagsView: TagsView; private val _tagsView: TagsView;
private val _textCentered: TextView; private val _textCentered: TextView;
private val _emptyPagerContainer: FrameLayout; private val _emptyPagerContainer: FrameLayout;
@@ -87,7 +87,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_textCentered = findViewById(R.id.text_centered); _textCentered = findViewById(R.id.text_centered);
_emptyPagerContainer = findViewById(R.id.empty_pager_container); _emptyPagerContainer = findViewById(R.id.empty_pager_container);
_progressBar = findViewById(R.id.progress_bar); _progressBar = findViewById(R.id.progress_bar);
_announcementView = findViewById(R.id.announcement_view) //_announcementView = findViewById(R.id.announcement_view)
_progressBar.inactiveColor = Color.TRANSPARENT; _progressBar.inactiveColor = Color.TRANSPARENT;
_swipeRefresh = findViewById(R.id.swipe_refresh); _swipeRefresh = findViewById(R.id.swipe_refresh);
@@ -192,7 +192,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
} }
protected fun showAnnouncementView() { protected fun showAnnouncementView() {
_announcementView.visibility = View.VISIBLE //_announcementView.visibility = View.VISIBLE
} }
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) { private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
@@ -28,6 +28,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.PlatformAuthorLink 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.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
@@ -176,6 +177,10 @@ class LibraryArtistFragment : MainFragment() {
is IPlatformPost -> { is IPlatformPost -> {
fragment.navigate<PostDetailFragment>(v) fragment.navigate<PostDetailFragment>(v)
} }
is IPlatformArticle -> {
fragment.navigate<ArticleDetailFragment>(v)
}
} }
} }
adapter.onShortClicked.subscribe { v, _, pagerPair -> adapter.onShortClicked.subscribe { v, _, pagerPair ->
@@ -266,7 +266,7 @@ class LibraryFragment : MainFragment() {
}); });
if(this.allowMusic) { if(this.allowMusic) {
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount); val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount)
adapterArtists.setData(artists); adapterArtists.setData(artists);
if (artists.size == 0) if (artists.size == 0)
sectionArtists.setEmpty( sectionArtists.setEmpty(
@@ -289,7 +289,7 @@ class LibraryFragment : MainFragment() {
} }
if(this.allowMusic) { if(this.allowMusic) {
val albums = StateLibrary.instance.getAlbums(); val albums = StateLibrary.instance.getAlbums()
adapterAlbums.setData(albums); adapterAlbums.setData(albums);
if (albums.size == 0) if (albums.size == 0)
sectionAlbums.setEmpty("No albums", "No albums were found on your device", -1); sectionAlbums.setEmpty("No albums", "No albums were found on your device", -1);
@@ -96,11 +96,15 @@ class LoginFragment : MainFragment() {
else throw IllegalStateException("No valid configuration?"); else throw IllegalStateException("No valid configuration?");
//TODO: Backwards compat removal? //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.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = 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 -> webViewClient.onLogin.subscribe { auth ->
_callback?.let { _callback?.let {
@@ -370,7 +370,7 @@ class PostDetailFragment : MainFragment {
_rating.visibility = VISIBLE; _rating.visibility = VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked); _rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { args -> _rating.onLikeDislikeUpdated.subscribe(this@PostDetailView) { args ->
if (args.hasLiked) { if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like); args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) { } else if (args.hasDisliked) {
@@ -688,6 +688,7 @@ class ShortView : FrameLayout {
dislikeButton.visibility = GONE dislikeButton.visibility = GONE
loadLikesTask?.cancel() loadLikesTask?.cancel()
onLikeDislikeUpdated.remove(this@ShortView)
loadLikesTask = loadLikesTask =
TaskHandler<IPlatformVideo, Pair<Protocol.Reference, Protocol.QueryReferencesResponse>>( TaskHandler<IPlatformVideo, Pair<Protocol.Reference, Protocol.QueryReferencesResponse>>(
StateApp.instance.scopeGetter, { StateApp.instance.scopeGetter, {
@@ -715,7 +716,7 @@ class ShortView : FrameLayout {
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray()) val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray()) val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())
onLikesLoaded.emit(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked) onLikesLoaded.emit(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked)
onLikeDislikeUpdated.subscribe(this) { args -> onLikeDislikeUpdated.subscribe(this@ShortView) { args ->
if (args.hasLiked) { if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like) args.processHandle.opinion(ref, Opinion.like)
} else if (args.hasDisliked) { } else if (args.hasDisliked) {
@@ -309,13 +309,14 @@ class SourceDetailFragment : MainFragment() {
BigButton(c, context.getString(R.string.logout), context.getString(R.string.sign_out_of_the_platform), R.drawable.ic_logout) { BigButton(c, context.getString(R.string.logout), context.getString(R.string.sign_out_of_the_platform), R.drawable.ic_logout) {
logoutSource(); logoutSource();
}, },
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) { BigButton(c, "Logout without Clear", "Logout but keep the browser cookies.\nThis allows for quick re-logging.", R.drawable.ic_logout) {
logoutSource(false); logoutSource(false);
}.apply { }.apply {
this.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { this.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0); setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
}; };
} } else null
) )
); );
@@ -518,6 +519,17 @@ class SourceDetailFragment : MainFragment() {
} }
Logger.e(TAG, "Failed to set plugin authentication (loginSource, loginWarning)", e) Logger.e(TAG, "Failed to set plugin authentication (loginSource, loginWarning)", e)
} }
finally {
if(Settings.instance.plugins.shouldClearWebviewCookies()) {
try {
val cookieManager: CookieManager =
CookieManager.getInstance();
cookieManager.removeAllCookies(null);
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to clear cookies", ex);
}
}
}
}; };
}, UIDialogs.ActionStyle.PRIMARY)) }, UIDialogs.ActionStyle.PRIMARY))
} }
@@ -459,7 +459,14 @@ class VideoDetailFragment() : MainFragment() {
val params = _viewDetail?.getPictureInPictureParams(); val params = _viewDetail?.getPictureInPictureParams();
if(params != null) { if(params != null) {
Logger.i(TAG, "enterPictureInPictureMode") 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() { fun forcePictureInPicture() {
val params = _viewDetail?.getPictureInPictureParams(); val params = _viewDetail?.getPictureInPictureParams();
if(params != null) if(params != null) {
activity?.enterPictureInPictureMode(params); 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) { fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, isStop: Boolean, newConfig: Configuration) {
try { try {
@@ -216,6 +216,7 @@ class VideoDetailView : ConstraintLayout {
private val _playerProgress: PlayerControlView; private val _playerProgress: PlayerControlView;
private val _timeBar: TimeBar; private val _timeBar: TimeBar;
private var _upNext: UpNextView; private var _upNext: UpNextView;
private var _artworkTarget: CustomTarget<Bitmap>? = null
private val rootView: ConstraintLayout; private val rootView: ConstraintLayout;
@@ -693,7 +694,14 @@ class VideoDetailView : ConstraintLayout {
onShouldEnterPictureInPictureChanged.subscribe { onShouldEnterPictureInPictureChanged.subscribe {
val params = getPictureInPictureParams() 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) { if (!isInEditMode) {
@@ -882,6 +890,9 @@ class VideoDetailView : ConstraintLayout {
}; };
onClose.subscribe { onClose.subscribe {
_artworkTarget?.let { Glide.with(context).clear(it) }
_artworkTarget = null
_player.setArtwork(null)
checkAndRemoveWatchLater(); checkAndRemoveWatchLater();
_lastVideoSource = null; _lastVideoSource = null;
_lastAudioSource = null; _lastAudioSource = null;
@@ -894,6 +905,7 @@ class VideoDetailView : ConstraintLayout {
cleanupPlaybackTracker(); cleanupPlaybackTracker();
Logger.i(TAG, "Keep screen on unset onClose") Logger.i(TAG, "Keep screen on unset onClose")
fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
clearChapters()
}; };
StatePlayer.instance.autoplayChanged.subscribe(this) { StatePlayer.instance.autoplayChanged.subscribe(this) {
@@ -988,6 +1000,12 @@ class VideoDetailView : ConstraintLayout {
_cast.stopAllGestures(); _cast.stopAllGestures();
} }
private fun clearChapters() {
_chapters = null
_player.setChapters(null)
_cast.setChapters(null)
}
fun showChaptersUI(){ fun showChaptersUI(){
video?.let { video?.let {
try { try {
@@ -1195,6 +1213,7 @@ class VideoDetailView : ConstraintLayout {
else if(_didStop) { else if(_didStop) {
_didStop = false; _didStop = false;
Logger.i(TAG, "loadCurrentVideo _lastPosition=${lastPositionMilliseconds}"); Logger.i(TAG, "loadCurrentVideo _lastPosition=${lastPositionMilliseconds}");
StatePlayer.instance.startOrUpdateMediaSession(context, video);
loadCurrentVideo(lastPositionMilliseconds); loadCurrentVideo(lastPositionMilliseconds);
handlePause(); handlePause();
} }
@@ -1264,6 +1283,9 @@ class VideoDetailView : ConstraintLayout {
fun onDestroy() { fun onDestroy() {
Logger.i(TAG, "onDestroy"); Logger.i(TAG, "onDestroy");
_destroyed = true; _destroyed = true;
_artworkTarget?.let { Glide.with(context).clear(it) }
_artworkTarget = null
_player.setArtwork(null)
_taskLoadVideo.cancel(); _taskLoadVideo.cancel();
_commentsList.cancel(); _commentsList.cancel();
_player.clear(); _player.clear();
@@ -1315,6 +1337,7 @@ class VideoDetailView : ConstraintLayout {
_lastVideoSource = null; _lastVideoSource = null;
_lastAudioSource = null; _lastAudioSource = null;
_lastSubtitleSource = null; _lastSubtitleSource = null;
clearChapters()
} }
fun setVideo(url: String, resumeSeconds: Long = 0, playWhenReady: Boolean = true) { fun setVideo(url: String, resumeSeconds: Long = 0, playWhenReady: Boolean = true) {
Logger.i(TAG, "setVideo url=$url resumeSeconds=$resumeSeconds playWhenReady=$playWhenReady") Logger.i(TAG, "setVideo url=$url resumeSeconds=$resumeSeconds playWhenReady=$playWhenReady")
@@ -1325,6 +1348,7 @@ class VideoDetailView : ConstraintLayout {
_searchVideo = null; _searchVideo = null;
video = null; video = null;
cleanupPlaybackTracker(); cleanupPlaybackTracker();
clearChapters()
_url = url; _url = url;
_videoResumePositionMilliseconds = resumeSeconds * 1000; _videoResumePositionMilliseconds = resumeSeconds * 1000;
_rating.visibility = View.GONE; _rating.visibility = View.GONE;
@@ -1535,6 +1559,7 @@ class VideoDetailView : ConstraintLayout {
} }
val me = this; val me = this;
clearChapters()
if (video is JSVideoDetails) { if (video is JSVideoDetails) {
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
@@ -1731,7 +1756,7 @@ class VideoDetailView : ConstraintLayout {
hasLiked, hasLiked,
hasDisliked hasDisliked
); );
_rating.onLikeDislikeUpdated.subscribe(this) { args -> _rating.onLikeDislikeUpdated.subscribe(this@VideoDetailView) { args ->
if (args.hasLiked) { if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like); args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) { } else if (args.hasDisliked) {
@@ -2053,19 +2078,31 @@ class VideoDetailView : ConstraintLayout {
_player.switchToVideoMode() _player.switchToVideoMode()
isAudioOnlyUserAction = false; isAudioOnlyUserAction = false;
} else { } else {
val thumbnail = video.thumbnails.getHQThumbnail(); _artworkTarget?.let { Glide.with(context).clear(it) }
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode _artworkTarget = null
Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
.into(object: CustomTarget<Bitmap>() { val thumbnail = video.thumbnails.getHQThumbnail()
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { val showArtwork = _player.isAudioMode || isAudioOnlyUserAction || (videoSource == null)
_player.setArtwork(BitmapDrawable(resources, resource));
} if (showArtwork && !thumbnail.isNullOrBlank()) {
override fun onLoadCleared(placeholder: Drawable?) { val target = object : CustomTarget<Bitmap>() {
_player.setArtwork(null); override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
} _player.setArtwork(BitmapDrawable(resources, resource))
}); }
else override fun onLoadCleared(placeholder: Drawable?) {
_player.setArtwork(null); _player.setArtwork(null)
}
}
_artworkTarget = target
Glide.with(context)
.asBitmap()
.load(thumbnail)
.withMaxSizePx()
.into(target)
} else {
_player.setArtwork(null)
}
} }
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
@@ -2487,7 +2524,7 @@ class VideoDetailView : ConstraintLayout {
val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate() val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()
val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null; val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null;
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString( _overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
R.string.quality), null, true, R.string.quality), null, false,
qualityPlaybackSpeedTitle, qualityPlaybackSpeedTitle,
if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply { if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds(); val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds();
@@ -7,6 +7,9 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
@@ -17,18 +20,54 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragmentData import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragmentData
import com.futo.platformplayer.models.SearchType import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.views.casting.CastButton import com.futo.platformplayer.views.casting.CastButton
import com.futo.platformplayer.views.notification.NotificationOverlayView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class GeneralTopBarFragment : TopFragment() { class GeneralTopBarFragment : TopFragment() {
private var _buttonSearch: ImageButton? = null; private var _buttonSearch: ImageButton? = null;
private var _buttonCast: CastButton? = null; private var _buttonCast: CastButton? = null;
private var _buttonNotifs: ConstraintLayout? = null;
private var _buttonNotifIcon: ImageView? = null;
private var _buttonNotifCount: TextView? = null;
init {
StateAnnouncement.instance.onAnnouncementChanged.subscribe {
lifecycleScope?.launch(Dispatchers.Main) {
updateNotifCount();
}
}
}
fun updateNotifCount() {
val currentAnnouncements = StateAnnouncement.instance.getVisibleAnnouncements();
if(currentAnnouncements.any())
_buttonNotifCount?.let {
it.text = currentAnnouncements.size.toString();
it.visibility = View.VISIBLE;
}
else
_buttonNotifCount?.let {
it.text = currentAnnouncements.size.toString();
it.visibility = View.GONE;
}
}
override fun onShown(parameter: Any?) { override fun onShown(parameter: Any?) {
if(currentMain is CreatorsFragment) { if(currentMain is CreatorsFragment) {
_buttonSearch?.setImageResource(R.drawable.ic_person_search_300w); _buttonSearch?.setImageResource(R.drawable.ic_person_search_300w);
} else { } else {
_buttonSearch?.setImageResource(R.drawable.ic_search_300w); _buttonSearch?.setImageResource(R.drawable.ic_search_300w);
} }
if(currentMain is NotificationOverlayView.Frag) {
_buttonNotifIcon?.setImageResource(R.drawable.ic_notifications_filled)
}
else {
_buttonNotifIcon?.setImageResource(R.drawable.ic_notifications)
}
} }
override fun onHide() { override fun onHide() {
@@ -44,6 +83,19 @@ class GeneralTopBarFragment : TopFragment() {
val buttonSearch: ImageButton = view.findViewById(R.id.button_search); val buttonSearch: ImageButton = view.findViewById(R.id.button_search);
_buttonCast = view.findViewById(R.id.button_cast); _buttonCast = view.findViewById(R.id.button_cast);
_buttonNotifs = view.findViewById(R.id.button_notifs);
_buttonNotifIcon = view.findViewById(R.id.button_notifs_icon);
_buttonNotifCount = view.findViewById(R.id.button_notifs_count);
updateNotifCount();
_buttonNotifs?.setOnClickListener {
if(currentMain is NotificationOverlayView.Frag)
closeSegment();
else
navigate<NotificationOverlayView.Frag>();
}
buttonSearch.setOnClickListener { buttonSearch.setOnClickListener {
if(currentMain is CreatorsFragment) { if(currentMain is CreatorsFragment) {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.CREATOR)); navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.CREATOR));
@@ -1,18 +1,35 @@
package com.futo.platformplayer.helpers package com.futo.platformplayer.helpers
import java.text.Normalizer
class FileHelper { class FileHelper {
companion object { companion object {
fun String.sanitizeFileName(allowSpace: Boolean = false): String { fun String.sanitizeFileName(allowSpace: Boolean = false): String {
return this.filter { val normalized = Normalizer.normalize(this, Normalizer.Form.NFC)
(it in '0' .. '9') ||
(it in 'a'..'z') || val cleaned = buildString(normalized.length) {
(it in 'A'..'Z') || for (ch in normalized) {
(it == '-' || it == '.' || it == '_' || (it == ' ' && allowSpace)) || when {
(it in '丁'..'龤') || //Chinese/Kanji ch == '\u0000' -> {}
(it in '\u3040'..'\u309f') || //Hiragana Character.isISOControl(ch) -> {}
(it in '\u30A0'..'\u30ff') || //Katakana ch == '/' || ch == '\\' || ch == ':' || ch == '*' ||
(it in '\u0600'..'\u06FF') //Arabic ch == '?' || ch == '"' || ch == '<' || ch == '>' || ch == '|' -> append('_')
}; //Chinese 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 _pluginConfig: SourcePluginConfig?;
private val _captchaConfig: SourcePluginCaptchaConfig; private val _captchaConfig: SourcePluginCaptchaConfig;
private val _userAgent: String?;
private var _didNotify = false; private var _didNotify = false;
private val _extractor: WebViewRequirementExtractor; private val _extractor: WebViewRequirementExtractor;
constructor(config: SourcePluginConfig) : super() { constructor(config: SourcePluginConfig, userAgent: String? = null) : super() {
_pluginConfig = config; _pluginConfig = config;
_captchaConfig = config.captcha!!; _captchaConfig = config.captcha!!;
_userAgent = userAgent;
_extractor = WebViewRequirementExtractor( _extractor = WebViewRequirementExtractor(
config.allowUrls, config.allowUrls,
null, null,
@@ -34,9 +36,10 @@ class CaptchaWebViewClient : WebViewClient {
Logger.i(TAG, "Captcha [${config.name}]" + Logger.i(TAG, "Captcha [${config.name}]" +
"\nRequired Cookies: ${Serializer.json.encodeToString(config.captcha!!.cookiesToFind)}",); "\nRequired Cookies: ${Serializer.json.encodeToString(config.captcha!!.cookiesToFind)}",);
} }
constructor(captcha: SourcePluginCaptchaConfig) : super() { constructor(captcha: SourcePluginCaptchaConfig, userAgent: String? = null) : super() {
_pluginConfig = null; _pluginConfig = null;
_captchaConfig = captcha; _captchaConfig = captcha;
_userAgent = userAgent;
_extractor = WebViewRequirementExtractor( _extractor = WebViewRequirementExtractor(
null, null,
null, null,
@@ -62,7 +65,8 @@ class CaptchaWebViewClient : WebViewClient {
_didNotify = true; _didNotify = true;
onCaptchaFinished.emit(SourceCaptchaData( onCaptchaFinished.emit(SourceCaptchaData(
extracted.cookies, extracted.cookies,
extracted.headers extracted.headers,
_userAgent
)); ));
} }
@@ -24,23 +24,26 @@ class LoginWebViewClient : WebViewClient {
private val _pluginConfig: SourcePluginConfig?; private val _pluginConfig: SourcePluginConfig?;
private val _authConfig: SourcePluginAuthConfig; private val _authConfig: SourcePluginAuthConfig;
private val _userAgent: String?;
private val _client = ManagedHttpClient(); private val _client = ManagedHttpClient();
val onLogin = Event1<SourceAuth>(); val onLogin = Event1<SourceAuth>();
val onPageLoaded = Event2<WebView?, String?>() val onPageLoaded = Event2<WebView?, String?>()
constructor(config: SourcePluginConfig) : super() { constructor(config: SourcePluginConfig, userAgent: String? = null) : super() {
_pluginConfig = config; _pluginConfig = config;
_authConfig = config.authentication!!; _authConfig = config.authentication!!;
_userAgent = userAgent;
Logger.i(TAG, "Login [${config.name}]" + Logger.i(TAG, "Login [${config.name}]" +
"\nRequired Headers: ${config.authentication.headersToFind?.joinToString(", ")}" + "\nRequired Headers: ${config.authentication.headersToFind?.joinToString(", ")}" +
"\nRequired Domain Headers: ${Serializer.json.encodeToString(config.authentication.domainHeadersToFind)}" + "\nRequired Domain Headers: ${Serializer.json.encodeToString(config.authentication.domainHeadersToFind)}" +
"\nRequired Cookies: ${Serializer.json.encodeToString(config.authentication.cookiesToFind)}",); "\nRequired Cookies: ${Serializer.json.encodeToString(config.authentication.cookiesToFind)}",);
} }
constructor(auth: SourcePluginAuthConfig) : super() { constructor(auth: SourcePluginAuthConfig, userAgent: String? = null) : super() {
_pluginConfig = null; _pluginConfig = null;
_authConfig = auth; _authConfig = auth;
_userAgent = userAgent;
} }
private val headersFoundMap: HashMap<String, HashMap<String, String>> = hashMapOf(); private val headersFoundMap: HashMap<String, HashMap<String, String>> = hashMapOf();
@@ -192,13 +195,14 @@ class LoginWebViewClient : WebViewClient {
if (urlFound && headersFound && domainHeadersFound && cookiesFound) { if (urlFound && headersFound && domainHeadersFound && cookiesFound) {
onLogin.emit(SourceAuth( onLogin.emit(SourceAuth(
cookieMap = cookiesFoundMap, cookieMap = cookiesFoundMap,
headers = headersFoundMap /*.associate { headerToFind -> headers = headersFoundMap, /*.associate { headerToFind ->
headerToFind to headersFoundMap.firstNotNullOf { requestHeader -> headerToFind to headersFoundMap.firstNotNullOf { requestHeader ->
if (requestHeader.key.equals(headerToFind, ignoreCase = true)) if (requestHeader.key.equals(headerToFind, ignoreCase = true))
requestHeader.value requestHeader.value
else null; else null;
} }
} ?: mapOf()*/ } ?: mapOf()*/
userAgent = _userAgent
)); ));
} }
@@ -28,6 +28,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.net.InetAddress import java.net.InetAddress
@@ -41,22 +42,16 @@ class DownloadService : Service() {
private val TAG = "DownloadService"; private val TAG = "DownloadService";
private val DOWNLOAD_NOTIF_ID = 3; private val DOWNLOAD_NOTIF_ID = 3;
private val DOWNLOAD_NOTIF_TAG = "download";
private val DOWNLOAD_NOTIF_CHANNEL_ID = "downloadChannel"; private val DOWNLOAD_NOTIF_CHANNEL_ID = "downloadChannel";
private val DOWNLOAD_NOTIF_CHANNEL_NAME = "Downloads"; private val DOWNLOAD_NOTIF_CHANNEL_NAME = "Downloads";
//Context //Context
private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default); private val _scope: CoroutineScope = CoroutineScope(Dispatchers.IO);
private var _notificationManager: NotificationManager? = null; private var _notificationManager: NotificationManager? = null;
private var _notificationChannel: NotificationChannel? = null; private var _notificationChannel: NotificationChannel? = null;
private var _isForeground = false private var _isForeground = false
private val _client = ManagedHttpClient(OkHttpClient.Builder() private lateinit var _client: ManagedHttpClient
//.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 var _started = false; private var _started = false;
@@ -76,11 +71,23 @@ class DownloadService : Service() {
setupNotificationRequirements(); setupNotificationRequirements();
notifyDownload(null); notifyDownload(null);
if (StateDownloads.instance.getDownloading().isEmpty()) {
Logger.i(TAG, "No downloads queued, stopping service")
closeDownloadSession()
return START_NOT_STICKY
}
_callOnStarted?.invoke(this); _callOnStarted?.invoke(this);
_instance = this; _instance = this;
_scope.launch { _scope.launch {
try { 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(); doDownloading();
} }
catch(ex: Throwable) { catch(ex: Throwable) {
@@ -167,7 +174,7 @@ class DownloadService : Service() {
ignore.add(currentVideo); ignore.add(currentVideo);
//Give it a sec //Give it a sec
Thread.sleep(500); delay(500);
} }
catch(ex: Throwable) { catch(ex: Throwable) {
//if(ex is ScriptReloadRequiredException) //if(ex is ScriptReloadRequiredException)
@@ -200,7 +207,7 @@ class DownloadService : Service() {
} }
//Give it a sec //Give it a sec
Thread.sleep(500); delay(500);
} }
StateDownloads.instance.updateDownloading(currentVideo); StateDownloads.instance.updateDownloading(currentVideo);
@@ -231,6 +238,16 @@ class DownloadService : Service() {
download.targetBitrate = download.audioSource!!.bitrate.toLong(); download.targetBitrate = download.audioSource!!.bitrate.toLong();
download.audioSource = null; download.audioSource = null;
} }
// Force re-prepare if auth modifiers are needed but lost (e.g. after deserialization,
// since IRequestModifier is transient and cannot survive serialization).
// Must also clear sources so prepare() enters the source selection branches where
// modifiers are recaptured from the live plugin JSSource.
if(download.needsReprepareForAuth) {
Logger.w(TAG, "Video Download [${download.name}] needs re-prepare for auth modifiers");
download.videoDetails = null;
download.videoSource = null;
download.audioSource = null;
}
if(download.videoDetails == null || (!download.isVideoDownloadReady || !download.isAudioDownloadReady)) if(download.videoDetails == null || (!download.isVideoDownloadReady || !download.isAudioDownloadReady))
download.changeState(VideoDownload.State.PREPARING); download.changeState(VideoDownload.State.PREPARING);
notifyDownload(download); notifyDownload(download);
@@ -338,6 +355,8 @@ class DownloadService : Service() {
fun getOrCreateService(context: Context, handle: ((DownloadService)->Unit)? = null) { fun getOrCreateService(context: Context, handle: ((DownloadService)->Unit)? = null) {
if(!FragmentedStorage.isInitialized) if(!FragmentedStorage.isInitialized)
return; return;
if(StateDownloads.instance.getDownloading().isEmpty())
return
if(_instance == null) { if(_instance == null) {
_callOnStarted = handle; _callOnStarted = handle;
val intent = Intent(context, DownloadService::class.java); val intent = Intent(context, DownloadService::class.java);
@@ -456,26 +456,19 @@ class MediaPlaybackService : Service() {
val audioFocusLossDuration = _audioFocusLossTime_ms?.let { System.currentTimeMillis() - it } val audioFocusLossDuration = _audioFocusLossTime_ms?.let { System.currentTimeMillis() - it }
_audioFocusLossTime_ms = null _audioFocusLossTime_ms = null
Log.i(TAG, "Audio focus gained (restartPlaybackAfterLoss = ${Settings.instance.playback.restartPlaybackAfterLoss}, _audioFocusLossTime_ms = $_audioFocusLossTime_ms, audioFocusLossDuration = ${audioFocusLossDuration})"); 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) return@OnAudioFocusChangeListener
if (audioFocusLossDuration != null && audioFocusLossDuration < 1000 * 10) { when (Settings.instance.playback.restartPlaybackAfterLoss) {
MediaControlReceiver.onPlayReceived.emit() 1 -> if (audioFocusLossDuration < 10_000) MediaControlReceiver.onPlayReceived.emit()
} 2 -> if (audioFocusLossDuration < 30_000) MediaControlReceiver.onPlayReceived.emit()
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 2) { 3 -> MediaControlReceiver.onPlayReceived.emit()
if (audioFocusLossDuration != null && audioFocusLossDuration < 1000 * 30) {
MediaControlReceiver.onPlayReceived.emit()
}
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 3) {
MediaControlReceiver.onPlayReceived.emit()
} }
} }
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
_audioFocusLossTime_ms = if (isPlaying) { val wasPlaying = isPlaying
System.currentTimeMillis() _audioFocusLossTime_ms = if (wasPlaying) System.currentTimeMillis() else null
} else {
null
}
_hasFocus = false; _hasFocus = false;
_isTransientLoss = true; _isTransientLoss = true;
@@ -488,11 +481,8 @@ class MediaPlaybackService : Service() {
_isTransientLoss = true; _isTransientLoss = true;
} }
AudioManager.AUDIOFOCUS_LOSS -> { AudioManager.AUDIOFOCUS_LOSS -> {
_audioFocusLossTime_ms = if (isPlaying) { val wasPlaying = isPlaying
System.currentTimeMillis() _audioFocusLossTime_ms = if (wasPlaying) System.currentTimeMillis() else null
} else {
null
}
MediaControlReceiver.onPauseReceived.emit(); MediaControlReceiver.onPauseReceived.emit();
abandonAudioFocus(); abandonAudioFocus();
@@ -1,13 +1,23 @@
package com.futo.platformplayer.states package com.futo.platformplayer.states
import android.content.Context
import android.view.View
import android.view.WindowManager
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.dialogs.PluginUpdateDialog
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringHashSetStorage import com.futo.platformplayer.stores.StringHashSetStorage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -110,6 +120,91 @@ class StateAnnouncement {
onAnnouncementChanged.emit(); onAnnouncementChanged.emit();
} }
//Special Announcements
fun registerPluginUpdate(oldConfig: SourcePluginConfig, newConfig: SourcePluginConfig): SessionAnnouncement {
val announcement = SessionAnnouncement(
"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,
null, "updates", "Update", StateAnnouncement.ACTION_UPDATE_PLUGIN,
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) {
val announcement = SessionAnnouncement(
"updated-plugin-" + newConfig.id + "-v" + newConfig.version,
"${newConfig.name} updated 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);
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 {
val id = "loading-" + UUID.randomUUID().toString();
val announcement = SessionAnnouncement(
customId ?: id,
title,
description,
AnnouncementType.ONGOING,
null, "loading", null, null,
null, null,null, icon
);
registerAnnouncementSession(announcement);
return announcement;
}
fun getVisibleAnnouncements(category: String? = null): List<Announcement> { fun getVisibleAnnouncements(category: String? = null): List<Announcement> {
synchronized(_lock) { synchronized(_lock) {
if (category != null) { if (category != null) {
@@ -122,7 +217,9 @@ class StateAnnouncement {
} }
} }
fun closeAnnouncement(id: String) { fun closeAnnouncement(id: String?) {
if(id == null)
return;
val item: Announcement?; val item: Announcement?;
synchronized(_lock) { synchronized(_lock) {
item = _announcementsStore.findItem { it.id == id }; item = _announcementsStore.findItem { it.id == id };
@@ -164,6 +261,7 @@ class StateAnnouncement {
cancelAction?.invoke(item); cancelAction?.invoke(item);
} }
} }
onAnnouncementChanged?.emit();
} }
fun deleteAllAnnouncements() { fun deleteAllAnnouncements() {
@@ -194,7 +292,9 @@ class StateAnnouncement {
onAnnouncementChanged.emit(); onAnnouncementChanged.emit();
} }
fun neverAnnouncement(id: String) { fun neverAnnouncement(id: String?) {
if(id == null)
return;
synchronized(_lock) { synchronized(_lock) {
val item = _announcementsStore.findItem { it.id == id }; val item = _announcementsStore.findItem { it.id == id };
if (item != null && !_announcementsNever.contains(id)) if (item != null && !_announcementsNever.contains(id))
@@ -208,19 +308,26 @@ class StateAnnouncement {
_announcementsNever.save(); _announcementsNever.save();
onAnnouncementChanged.emit(); onAnnouncementChanged.emit();
} }
fun actionAnnouncement(id: String) { fun actionAnnouncement(id: String?, extra: Boolean = false) {
if(id == null)
return;
val item = _announcementsStore.findItem { it.id == id } ?: _sessionAnnouncements[id]; val item = _announcementsStore.findItem { it.id == id } ?: _sessionAnnouncements[id];
if(item != null) if(item != null)
actionAnnouncement(item); actionAnnouncement(item, extra);
} }
fun actionAnnouncement(item: Announcement) { fun actionAnnouncement(item: Announcement, extra: Boolean = false) {
val actionId = if(!extra) item.actionId else if(item is SessionAnnouncement) item.extraActionId else null;
val actionData = if(!extra) item.actionData else if(item is SessionAnnouncement) item.extraActionData else null;
val action = _sessionActions[item.id]; val action = _sessionActions[item.id];
if (action != null) { if (action != null) {
action(item); action(item);
} else { } else {
when (item.actionId) { when (actionId) {
ACTION_NEVER -> neverAnnouncement(item.id); ACTION_NEVER -> neverAnnouncement(item.id);
ACTION_SOMETHING -> actionSomething(); ACTION_SOMETHING -> actionSomething();
ACTION_CHANGELOG -> actionChangelog(item, actionData);
ACTION_UPDATE_PLUGIN -> actionUpdatePlugin(item.id, actionData);
} }
} }
} }
@@ -251,6 +358,92 @@ class StateAnnouncement {
} }
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;
}
if(id == null) return;
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;
val plugin = StatePlugins.instance.getPlugin(id);
if (plugin == null)
return
closeAnnouncement(notifId);
val loadingAnnouncement = registerLoading("Updating ${plugin.config.name}..", "An update is in progress for ${plugin.config.name}.",
if(plugin.config.absoluteIconUrl != null) ImageVariable.fromUrl(plugin.config.absoluteIconUrl!!) else null);
val loadingId = loadingAnnouncement.id;
StateApp.instance.contextOrNull?.let { context ->
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
val update = StatePlugins.instance.checkForUpdates(plugin.config);
if (update == null)
return@launch;
val client = ManagedHttpClient();
client.setTimeout(10000);
val script = StatePlugins.instance.getScript(plugin.config.id) ?: "";
val newScript = client.get(update.absoluteScriptUrl)?.body?.string();
if(newScript.isNullOrEmpty())
throw IllegalStateException("No script found");
if(plugin.config.isLowRiskUpdate(script, update, newScript)) {
StatePlugins.instance.installPluginBackground(context, StateApp.instance.scope, update, newScript,
{ text: String, progress: Double -> },
{ ex ->
if(ex == null) {
registerPluginUpdated(update);
}
else {
UIDialogs.appToast("Update for ${update.name} failed\n" + ex.message);
}
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
closeAnnouncement(loadingId);
}
});
}
else {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
closeAnnouncement(loadingId);
UIDialogs.showPluginUpdateDialog(context, plugin.config, update);
}
}
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to trigger update from announcement", ex);
}
}
}
}
fun registerDefaultHandlerAnnouncement() { fun registerDefaultHandlerAnnouncement() {
registerAnnouncement( registerAnnouncement(
"default-url-handler", "default-url-handler",
@@ -279,6 +472,8 @@ class StateAnnouncement {
const val ACTION_SOMETHING = "SOMETHING"; const val ACTION_SOMETHING = "SOMETHING";
const val ACTION_CHANGELOG = "CHANGELOG";
const val ACTION_UPDATE_PLUGIN = "UPDATE_PLUGIN";
const val ACTION_NEVER = "NEVER"; const val ACTION_NEVER = "NEVER";
private const val TAG = "StateAnnouncement"; private const val TAG = "StateAnnouncement";
} }
@@ -294,7 +489,8 @@ open class Announcement(
val time: OffsetDateTime? = null, val time: OffsetDateTime? = null,
val category: String? = null, val category: String? = null,
val actionName: String? = null, val actionName: String? = null,
val actionId: String? = null val actionId: String? = null,
val actionData: String? = null
); );
class SessionAnnouncement( class SessionAnnouncement(
id: String, id: String,
@@ -306,7 +502,9 @@ class SessionAnnouncement(
actionName: String? = null, actionName: String? = null,
actionId: String? = null, actionId: String? = null,
val cancelName: String? = null, val cancelName: String? = null,
val cancelActionId: String? = null val cancelActionId: String? = null,
actionData: String? = null,
val icon: ImageVariable? = null
): Announcement( ): Announcement(
id= id, id= id,
title = title, title = title,
@@ -315,13 +513,40 @@ class SessionAnnouncement(
time = time, time = time,
category = category, category = category,
actionName = actionName, actionName = actionName,
actionId = actionId actionId = actionId,
); actionData = actionData
) {
var extraActionName: String? = null;
var extraActionId: String? = null;
var extraActionData: String? = null;
var extraObj: Any? = null;
var progress: Double? = null;
val onProgressChanged = Event1<SessionAnnouncement>();
fun withExtraAction(name: String, id: String, data: String? = null): SessionAnnouncement {
extraActionName = name;
extraActionId = id;
extraActionData = data;
return this;
}
fun setProgress(progress: Double) {
this.progress = progress;
onProgressChanged?.emit(this);
}
fun setProgress(progress: Int) {
this.progress = progress.toDouble().div(100);
onProgressChanged?.emit(this);
}
}
enum class AnnouncementType(val value : Int) { enum class AnnouncementType(val value : Int) {
DELETABLE(0), //Close button deletes announcement (generally for actions) DELETABLE(0), //Close button deletes announcement (generally for actions)
RECURRING(1), //Shows up till never is pressed (generally for patchnotes etc) RECURRING(1), //Shows up till never is pressed (generally for patchnotes etc)
PERMANENT(2), //Shows up until deleted through other means (action) PERMANENT(2), //Shows up until deleted through other means (action)
SESSION(3), //Not persistent, only during this session SESSION(3), //Not persistent, only during this session
SESSION_RECURRING(4); //Not persistent, only during this session, recurring id SESSION_RECURRING(4), //Not persistent, only during this session, recurring id
ONGOING(5);
} }
@@ -13,9 +13,12 @@ import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import android.net.Uri import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.util.DisplayMetrics import android.util.DisplayMetrics
import android.util.Log import android.util.Log
import android.webkit.CookieManager
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -31,6 +34,7 @@ import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.platforms.js.DevJSClient 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.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.background.BackgroundWorker import com.futo.platformplayer.background.BackgroundWorker
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
@@ -43,6 +47,7 @@ import com.futo.platformplayer.logging.AndroidLogConsumer
import com.futo.platformplayer.logging.FileLogConsumer import com.futo.platformplayer.logging.FileLogConsumer
import com.futo.platformplayer.logging.LogLevel import com.futo.platformplayer.logging.LogLevel
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.receivers.AudioNoisyReceiver import com.futo.platformplayer.receivers.AudioNoisyReceiver
import com.futo.platformplayer.services.DownloadService import com.futo.platformplayer.services.DownloadService
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
@@ -99,10 +104,16 @@ class StateApp {
var hasMediaStoreVideoPermission: Boolean = false; var hasMediaStoreVideoPermission: Boolean = false;
fun getExternalGeneralDirectory(context: Context): DocumentFile? { fun getExternalGeneralDirectory(context: Context): DocumentFile? {
val generalUri = Settings.instance.storage.getStorageGeneralUri(); val generalUri = Settings.instance.storage.getStorageGeneralUri()
if(isValidStorageUri(context, generalUri)) val document = getAccessibleTreeDirectory(context, generalUri)
return DocumentFile.fromTreeUri(context, generalUri!!);
return null; 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) { fun changeExternalGeneralDirectory(context: IWithResultLauncher, onChanged: ((DocumentFile?)->Unit)? = null) {
if(context is Context) if(context is Context)
@@ -123,10 +134,16 @@ class StateApp {
}; };
} }
fun getExternalDownloadDirectory(context: Context): DocumentFile? { fun getExternalDownloadDirectory(context: Context): DocumentFile? {
val downloadUri = Settings.instance.storage.storage_download?.let { Uri.parse(it) }; val downloadUri = Settings.instance.storage.storage_download?.let { Uri.parse(it) }
if(isValidStorageUri(context, downloadUri)) val document = getAccessibleTreeDirectory(context, downloadUri)
return DocumentFile.fromTreeUri(context, downloadUri!!);
return null; 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) { fun changeExternalDownloadDirectory(context: IWithResultLauncher, onChanged: ((DocumentFile?)->Unit)? = null) {
if(context is Context) if(context is Context)
@@ -142,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 //Scope
@@ -306,49 +392,45 @@ class StateApp {
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit) { fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit) {
return requestDirectoryAccess(activity, name, purpose, path, handle, false); 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) fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?) -> Unit, skipDialog: Boolean = false) {
{ if (Looper.myLooper() != Looper.getMainLooper()) {
if(activity is Context) Handler(Looper.getMainLooper()).post {
{ requestDirectoryAccess(activity, name, purpose, path, handle, skipDialog)
if(skipDialog) { }
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); return
if(path != null) }
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
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 intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION) .or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PERSISTABLE_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) { activity.launchForResult(intent, 99) {
if(it.resultCode == Activity.RESULT_OK) { if (it.resultCode == Activity.RESULT_OK) handle(it.data?.data)
handle(it.data?.data); else UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted")
} }
else } 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.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("Cancel", {}),
UIDialogs.Action("Ok", { UIDialogs.Action("Ok", {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if(path != null) if (path != null) intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION) .or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PERSISTABLE_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) { activity.launchForResult(intent, 99) {
if(it.resultCode == Activity.RESULT_OK) { if (it.resultCode == Activity.RESULT_OK) handle(it.data?.data)
handle(it.data?.data); else UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted")
} }
else }, UIDialogs.ActionStyle.PRIMARY)
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted"); )
};
}, UIDialogs.ActionStyle.PRIMARY));
} }
} }
} }
@@ -447,6 +529,17 @@ class StateApp {
_cacheDirectory?.let { ApiMethods.initCache(it) }; _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]"); Logger.i(TAG, "MainApp Starting: Initializing [ModerationsManager]");
ModerationsManager.initialize(context); ModerationsManager.initialize(context);
@@ -575,6 +668,9 @@ class StateApp {
if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) { if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
if (Settings.instance.autoUpdate.shouldBackgroundDownload) { if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]"); Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.seedUiFromDisk(context)
}
val constraints = Constraints.Builder() val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) .setRequiredNetworkType(NetworkType.CONNECTED)
.build(); .build();
@@ -659,9 +755,7 @@ class StateApp {
scheduleBackgroundWork(context, interval != 0, interval); scheduleBackgroundWork(context, interval != 0, interval);
Logger.i(TAG, "MainApp Started: Initialize [AutoBackup]"); 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()) { 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", { 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)) { if(context is IWithResultLauncher && !Settings.instance.storage.isStorageMainValid(context)) {
UIDialogs.toast("Missing general directory"); UIDialogs.toast("Missing general directory");
@@ -678,7 +772,6 @@ class StateApp {
Settings.instance.backup.didAskAutoBackup = true; Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save(); Settings.instance.save();
}); });
*/
} }
else if(Settings.instance.backup.didAskAutoBackup && Settings.instance.backup.shouldAutomaticBackup() && !Settings.instance.storage.isStorageMainValid(context)) { else if(Settings.instance.backup.didAskAutoBackup && Settings.instance.backup.shouldAutomaticBackup() && !Settings.instance.storage.isStorageMainValid(context)) {
if(context is IWithResultLauncher) { if(context is IWithResultLauncher) {
@@ -715,15 +808,24 @@ class StateApp {
if(StateHistory.instance.shouldMigrateLegacyHistory()) if(StateHistory.instance.shouldMigrateLegacyHistory())
StateHistory.instance.migrateLegacyHistory(); StateHistory.instance.migrateLegacyHistory();
StateAnnouncement.instance.deleteAnnouncement("plugin-update")
scopeOrNull?.launch(Dispatchers.IO) { scopeOrNull?.launch(Dispatchers.IO) {
val updateAvailable = StatePlugins.instance.checkForUpdates() val updateAvailable = StatePlugins.instance.checkForUpdates()
withContext(Dispatchers.Main) { 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( UIDialogs.appToast(
ToastView.Toast(updateAvailable ToastView.Toast(toNotify
.map { " - " + it.first.name } .map { " - " + it.first.name }
.joinToString("\n"), .joinToString("\n"),
true, true,
@@ -731,9 +833,8 @@ class StateApp {
"Plugin updates available" "Plugin updates available"
)); ));
for(update in updateAvailable) for(update in toNotify)
if(StatePlatform.instance.isClientEnabled(update.first.id)) StateAnnouncement.instance.registerPluginUpdate(update.first, update.second);
UIDialogs.showPluginUpdateDialog(context, update.first, update.second);
} }
} }
} }
@@ -49,6 +49,33 @@ class StateBackup {
private val _autoBackupLock = Object(); 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?> { private fun getAutomaticBackupDocumentFiles(context: Context, create: Boolean = false): Pair<DocumentFile?, DocumentFile?> {
if(!Settings.instance.storage.isStorageMainValid(context)) if(!Settings.instance.storage.isStorageMainValid(context))
return Pair(null, null); return Pair(null, null);
@@ -76,14 +103,13 @@ class StateBackup {
); );
} }
private fun requireLegacyBackupPassword(password: String): String {
private fun getAutomaticBackupPassword(customPassword: String? = null): String { val pbytes = password.toByteArray()
val password = customPassword ?: Settings.instance.backup.autoBackupPassword ?: ""; if (pbytes.size < 4 || pbytes.size > 32)
val pbytes = password.toByteArray(); throw IllegalStateException("Password must be at least 4 bytes and smaller than 32 bytes")
if(pbytes.size < 4 || pbytes.size > 32) return password
throw IllegalStateException("Automatic backup passwords should atleast be 4 character and smaller than 32");
return password;
} }
fun hasAutomaticBackup(): Boolean { fun hasAutomaticBackup(): Boolean {
val context = StateApp.instance.contextOrNull ?: return false; val context = StateApp.instance.contextOrNull ?: return false;
if(!Settings.instance.storage.isStorageMainValid(context)) if(!Settings.instance.storage.isStorageMainValid(context))
@@ -106,8 +132,6 @@ class StateBackup {
val data = export(); val data = export();
val zip = data.asZip(); 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)) { if(!Settings.instance.storage.isStorageMainValid(context)) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
UIDialogs.toast("Missing permissions for auto-backup, please set the external general directory in settings"); 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; val exportFile = backupFiles.first;
if (exportFile?.exists() == true && backupFiles.second != null) if (exportFile?.exists() == true && backupFiles.second != null)
exportFile.copyTo(context, backupFiles.second!!); 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.backup.lastAutoBackupTime = OffsetDateTime.now(); //OffsetDateTime.now();
Settings.instance.save(); 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. //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) { fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false) {
if(ifExists && !hasAutomaticBackup()) { if (ifExists && !hasAutomaticBackup()) {
Logger.i(TAG, "No AutoBackup exists, not restoring"); Logger.i(TAG, "No AutoBackup exists, not restoring")
return; return
} }
Logger.i(TAG, "Starting AutoBackup restore"); Logger.i(TAG, "Starting AutoBackup restore")
synchronized(_autoBackupLock) { 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 { try {
if (backupFiles.first?.exists() != true) 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}]"); read(backupFiles.first) ?: throw IllegalStateException("Could not read stream of [${backupFiles.first?.uri}]")
importEncryptedZipBytes(context, scope, backupBytesEncrypted, password); } catch (ex: Throwable) {
Logger.i(TAG, "Finished AutoBackup restore"); if (ex is FileNotFoundException || ex is SecurityException) {
} val activity = (StateApp.instance.activity as? IWithResultLauncher)
catch (exSec: FileNotFoundException) { ?: (if (StateApp.instance.isMainActive) StateApp.instance.contextOrNull as? IWithResultLauncher else null)
Logger.e(TAG, "Failed to access backup file", exSec);
val activity = if(StateApp.instance.activity != null) if (activity != null) {
StateApp.instance.activity permissionRequest = Pair(activity, backupFiles.first?.parentFile?.uri)
else if(StateApp.instance.isMainActive) return@synchronized null
StateApp.instance.contextOrNull; }
else null; }
if(activity != null) {
if(activity is IWithResultLauncher) // Otherwise, try the .old file
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", "Allows restoring of a backup", backupFiles.first?.parentFile?.uri) { if (backupFiles.second?.exists() == true) {
if(it != null) { read(backupFiles.second) ?: throw IllegalStateException("Could not read stream of [${backupFiles.second?.uri}]")
val customFiles = StateBackup.getAutomaticBackupDocumentFiles(activity); } else {
if(customFiles.first != null && customFiles.first!!.isFile && customFiles.first!!.exists() && customFiles.first!!.canRead()) throw ex
restoreAutomaticBackup(context, scope, password, ifExists);
}
};
} }
} }
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) { private fun importEncryptedZipBytes(context: Context, scope: CoroutineScope, backupBytesEncrypted: ByteArray, password: String) {
val backupBytes: ByteArray; if (backupBytesEncrypted.startsWithZipSignature()) {
//Check magic bytes indicating version 1 and up importZipBytes(context, scope, backupBytesEncrypted)
if (backupBytesEncrypted[0] == 0x11.toByte() && backupBytesEncrypted[1] == 0x22.toByte() && backupBytesEncrypted[2] == 0x33.toByte() && backupBytesEncrypted[3] == 0x44.toByte()) { return
val version = backupBytesEncrypted[4].toInt();
if (version != GPasswordEncryptionProvider.version) {
throw Exception("Invalid encryption version");
}
backupBytes = GPasswordEncryptionProvider.instance.decrypt(backupBytesEncrypted.sliceArray(IntRange(5, backupBytesEncrypted.size - 1)), getAutomaticBackupPassword(password))
} else {
//Else its a version 0
backupBytes = GPasswordEncryptionProviderV0(getAutomaticBackupPassword(password).padStart(32, '9')).decrypt(backupBytesEncrypted);
} }
importZipBytes(context, scope, backupBytes); // 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) { 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 { fun export(): ExportStructure {
val exportInfo = mapOf( val exportInfo = mapOf(
Pair("version", "1") Pair("version", "1")
@@ -303,186 +405,172 @@ class StateBackup {
var doEnablePlugins = false; var doEnablePlugins = false;
var doImportStores = false; var doImportStores = false;
Logger.i(TAG, "Starting import choices"); 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) { scope.launch(Dispatchers.IO) {
StatePlatform.instance.selectClients(*enabledBefore.toTypedArray()); try {
if (doImportSettings && export.settings != null) {
withContext(Dispatchers.Main) { Logger.i(TAG, "Importing settings");
UIDialogs.showDialog(context, R.drawable.ic_update_success_251dp, try {
"Import has finished", null, null, 0, UIDialogs.Action("Ok", {})); Settings.replace(export.settings);
} } catch (ex: Throwable) {
} UIDialogs.toast(
}; context,
//TODO: Probably restructure this to be less nested "Failed to import settings\n(" + ex.message + ")"
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());
} }
if(doImportPluginSettings) { }
for(settings in export.pluginSettings) {
Logger.i(TAG, "Importing Plugin settings [${settings.key}]"); val afterPluginInstalls = {
StatePlugins.instance.setPluginSettings(settings.key, settings.value); 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());
} }
} if (doImportPluginSettings) {
val toAwait = export.stores.map { it.key }.toMutableList(); for (settings in export.pluginSettings) {
if(doImportStores) { Logger.i(TAG, "Importing Plugin settings [${settings.key}]");
for(store in export.stores) { StatePlugins.instance.setPluginSettings(settings.key, settings.value);
Logger.i(TAG, "Importing store [${store.key}]"); }
if(store.key == "history") { }
withContext(Dispatchers.Main) { val toAwait = export.stores.map { it.key }.toMutableList();
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import History", "Would you like to import history?", null, 0, if (doImportStores) {
UIDialogs.Action("No", { for (store in export.stores) {
}, UIDialogs.ActionStyle.NONE), Logger.i(TAG, "Importing store [${store.key}]");
UIDialogs.Action("Yes", { if (store.key == "history") {
for(historyStr in store.value) { withContext(Dispatchers.Main) {
try { UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import History", "Would you like to import history?", null, 0,
val histObj = HistoryVideo.fromReconString(historyStr) { url -> UIDialogs.Action("No", {
return@fromReconString export.cache?.videos?.firstOrNull { it.url == url }; }, 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) { }, UIDialogs.ActionStyle.PRIMARY)
Logger.e(TAG, "Failed to import subscription group", ex); )
}
} 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) { if (doImportPlugins) {
Logger.i(TAG, "Importing plugins"); Logger.i(TAG, "Importing plugins");
StatePlugins.instance.installPlugins(context, scope, unknownPlugins.map { it.value }) { StatePlugins.instance.installPlugins(context, scope, unknownPlugins.map { it.value }) {
afterPluginInstalls();
}
} else
afterPluginInstalls(); 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.Descriptor(R.drawable.ic_move_up, "Do you want to import data?", "Several dialogs will follow asking individual parts",
UIDialogs.showGeneralErrorDialog(context, "Import failed", ex); "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(),
UIDialogs.Descriptor(R.drawable.ic_move_up, 1,
"Do you want to import data?", UIDialogs.Action("Import", {
"Several dialogs will follow asking individual parts", doImport = true;
"Settings: ${export.settings != null}\n" + }, UIDialogs.ActionStyle.PRIMARY),
"Plugins: ${unknownPlugins.size}\n" + UIDialogs.Action("Cancel", { doImport = false })
"Plugin Settings: ${export.pluginSettings.size}\n" + ),
export.stores.map { "${it.key}: ${it.value.size}" }.joinToString("\n").trim() 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,
, 1, UIDialogs.Action("Yes", {
UIDialogs.Action("Import", { doImportSettings = true;
doImport = true; }, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("Cancel", { doImport = false}) ).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,
if(export.settings != null) UIDialogs.Descriptor(R.drawable.ic_settings, UIDialogs.Action("Yes", {
"Would you like to import settings", doImportPlugins = true;
"These are the settings that configure how your app works", }, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
null, 0, ).withCondition { doImport } else null,
UIDialogs.Action("Yes", { if (export.pluginSettings.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources, "Would you like to import plugin settings?", null, null, 1,
doImportSettings = true; UIDialogs.Action("Yes", {
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {}) doImportPluginSettings = true;
).withCondition { doImport } else null, }, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
if(unknownPlugins.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources, ).withCondition { doImport } else null,
"Would you like to import plugins?", 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,
"Your import contains the following plugins", UIDialogs.Action("Yes", {
unknownPlugins.map { it.value }.joinToString("\n"), 1, doEnablePlugins = true;
UIDialogs.Action("Yes", { }, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
doImportPlugins = true; ).withCondition { doImport },
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {}) 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,
).withCondition { doImport } else null, UIDialogs.Action("Yes", {
if(export.pluginSettings.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources, doImportStores = true;
"Would you like to import plugin settings?", }, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
null, null, 1, ).withCondition { doImport } else null
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 { fun importTxt(context: MainActivity, text: String, allowFailure: Boolean = false): Boolean {
@@ -10,6 +10,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
@@ -341,8 +342,8 @@ class StateDownloads {
fun download(video: IPlatformVideo, targetPixelcount: Long?, targetBitrate: Long?) { fun download(video: IPlatformVideo, targetPixelcount: Long?, targetBitrate: Long?) {
download(VideoDownload(video, targetPixelcount, targetBitrate)); download(VideoDownload(video, targetPixelcount, targetBitrate));
} }
fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) { fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?, videoModifier: IRequestModifier? = null, audioModifier: IRequestModifier? = null) {
download(VideoDownload(video, videoSource, audioSource, subtitleSource)); download(VideoDownload(video, videoSource, audioSource, subtitleSource, videoModifier, audioModifier));
} }
private fun download(videoState: VideoDownload, notify: Boolean = true) { private fun download(videoState: VideoDownload, notify: Boolean = true) {
@@ -483,9 +484,9 @@ class StateDownloads {
var localVideos = StateDownloads.instance.getDownloadedVideosPlaylist(playlistId); var localVideos = StateDownloads.instance.getDownloadedVideosPlaylist(playlistId);
if(playlist != null) { if(playlist != null) {
val missing = playlist.videos val missing = playlist.videos
.filter { vid -> !localVideos.any { it.id.value == null || it.id.value == vid.id.value } } .filter { vid -> !localVideos.any { it.id.value == null || it.id.value == vid.id.value } }
.map { getCachedVideo(it.id) } .map { getCachedVideo(it.id) }
.filterNotNull(); .filterNotNull();
if(missing.size > 0) if(missing.size > 0)
localVideos = localVideos + missing; localVideos = localVideos + missing;
}; };
@@ -500,7 +501,6 @@ class StateDownloads {
for (video in localVideos) { for (video in localVideos) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
it.setText("Exporting videos...(${i}/${localVideos.size})"); it.setText("Exporting videos...(${i}/${localVideos.size})");
//it.setProgress(i.toDouble() / localVideos.size);
} }
try { try {
@@ -18,7 +18,14 @@ class StatePayment : PaymentState(if(!isTestingPayment) VERIFICATION_PUBLIC_KEY
} }
companion object { 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" + private val VERIFICATION_PUBLIC_KEY_TESTING = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqyDuxsRtD5gmBoLCNoZa" +
"XSRTwyUxgzcPHzLZkvomXVSQqzD+3aOKngcTKAZ83rm4GvoyMlBukxQMLShannSx" + "XSRTwyUxgzcPHzLZkvomXVSQqzD+3aOKngcTKAZ83rm4GvoyMlBukxQMLShannSx" +
"k8GQGTCT7VStQKNc4lKVER5ASB6aEaypaFMIYI3rXN1xLF1LqY/j7cu5GgMsvAuU" + "k8GQGTCT7VStQKNc4lKVER5ASB6aEaypaFMIYI3rXN1xLF1LqY/j7cu5GgMsvAuU" +
@@ -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.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig 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.platforms.local.LocalClient
import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
@@ -181,11 +182,14 @@ class StatePlatform {
} }
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
var toDisables = mutableListOf<IPlatformClient>(); val toDispose = mutableListOf<IPlatformClient>();
var enabled: Array<String>; var enabled: Array<String>;
synchronized(_clientsLock) { synchronized(_clientsLock) {
for(e in _enabledClients) { val previousAvailable = _availableClients.toList();
toDisables.add(e); val reusableByDescriptor = HashMap<SourcePluginDescriptor, JSClient>();
for (prev in previousAvailable) {
if (prev is JSClient)
reusableByDescriptor[prev.descriptor] = prev;
} }
_enabledClients.clear(); _enabledClients.clear();
@@ -199,18 +203,38 @@ class StatePlatform {
StatePlugins.instance.installMissingEmbeddedPlugins(context); StatePlugins.instance.installMissingEmbeddedPlugins(context);
for (plugin in StatePlugins.instance.getPlugins()) { for (plugin in StatePlugins.instance.getPlugins()) {
_icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?: try {
ImageVariable(plugin.config.absoluteIconUrl, null); val reused = reusableByDescriptor[plugin];
_iconsByName[plugin.config.name.lowercase()] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?: val isReused = reused != null && reused.descriptor === plugin;
ImageVariable(plugin.config.absoluteIconUrl, null); 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); _icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?:
client.onCaptchaException.subscribe { c, ex -> ImageVariable(plugin.config.absoluteIconUrl, null);
StateApp.instance.handleCaptchaException(c, ex); _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) { if(_availableClients.distinctBy { it.id }.count() < _availableClients.size) {
val dups = _availableClients.filter { x-> _availableClients.count { it.id == x.id } > 1 }; val dups = _availableClients.filter { x-> _availableClients.count { it.id == x.id } > 1 };
val overrideClients = _availableClients.distinctBy { it.id } val overrideClients = _availableClients.distinctBy { it.id }
@@ -236,7 +260,7 @@ class StatePlatform {
} }
selectClients(*enabled); selectClients(*enabled);
for(toDisable in toDisables) { for(toDisable in toDispose) {
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
try { try {
toDisable.disable(); toDisable.disable();
@@ -116,7 +116,7 @@ class StatePlugins {
_updatesAvailableMap = updatesAvailableFor _updatesAvailableMap = updatesAvailableFor
return@withContext configs; return@withContext configs;
} }
private suspend fun checkForUpdates(c: SourcePluginConfig): SourcePluginConfig? = withContext(Dispatchers.IO) { suspend fun checkForUpdates(c: SourcePluginConfig): SourcePluginConfig? = withContext(Dispatchers.IO) {
val sourceUrl = c.sourceUrl ?: return@withContext null; val sourceUrl = c.sourceUrl ?: return@withContext null;
Logger.i(TAG, "Check for source updates '${c.name}'."); Logger.i(TAG, "Check for source updates '${c.name}'.");
@@ -44,6 +44,7 @@ import kotlin.streams.asSequence
* Used to maintain subscriptions * Used to maintain subscriptions
*/ */
class StateSubscriptions { class StateSubscriptions {
private val _subscriptions = FragmentedStorage.storeJson<Subscription>("subscriptions") private val _subscriptions = FragmentedStorage.storeJson<Subscription>("subscriptions")
.withUnique { it.channel.url } .withUnique { it.channel.url }
.withRestore(object: ReconstructStore<Subscription>(){ .withRestore(object: ReconstructStore<Subscription>(){
@@ -5,6 +5,7 @@ import android.os.Build
import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.copyToOutputStream import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -14,7 +15,97 @@ import java.io.File
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
enum class UpdateUiState { NONE, AVAILABLE, DOWNLOADING, READY, FAILED }
class StateUpdate { class StateUpdate {
@Volatile var uiState: UpdateUiState = UpdateUiState.NONE
private set
@Volatile var uiVersion: Int = 0
private set
@Volatile var uiProgress: Int = 0
private set
@Volatile var uiIndeterminate: Boolean = true
private set
@Volatile var uiApkFile: File? = null
private set
@Volatile var uiError: String? = null
private set
val onUiChanged = Event0()
fun setUiAvailable(version: Int) {
uiState = UpdateUiState.AVAILABLE
uiVersion = version
uiError = null
onUiChanged.emit()
}
fun setUiDownloading(version: Int, progress: Int, indeterminate: Boolean) {
uiState = UpdateUiState.DOWNLOADING
uiVersion = version
uiProgress = progress
uiIndeterminate = indeterminate
uiError = null
onUiChanged.emit()
}
fun setUiReady(version: Int, apkFile: File) {
uiState = UpdateUiState.READY
uiVersion = version
uiApkFile = apkFile
uiError = null
onUiChanged.emit()
}
fun setUiFailed(version: Int, error: String?) {
uiState = UpdateUiState.FAILED
uiVersion = version
uiError = error
onUiChanged.emit()
}
fun clearUi() {
uiState = UpdateUiState.NONE
uiVersion = 0
uiProgress = 0
uiIndeterminate = true
uiApkFile = null
uiError = null
onUiChanged.emit()
}
fun seedUiFromDisk(context: Context) {
if (uiState != UpdateUiState.NONE) return
try {
val dir = File(context.filesDir, "updates")
if (!dir.exists()) return
val abi = try { DESIRED_ABI } catch (t: Throwable) { return }
val prefix = "app-$abi-"
val suffix = ".apk"
val candidates = dir.listFiles { f ->
f.isFile && f.name.startsWith(prefix) && f.name.endsWith(suffix)
} ?: return
var bestVersion = BuildConfig.VERSION_CODE
var bestFile: File? = null
for (f in candidates) {
val versionStr = f.name.removePrefix(prefix).removeSuffix(suffix)
val v = versionStr.toIntOrNull() ?: continue
if (v > bestVersion && f.length() > 0L) {
bestVersion = v
bestFile = f
}
}
val ready = bestFile
if (ready != null) {
Logger.i(TAG, "Seeding UI ready from disk: v=$bestVersion file=${ready.absolutePath}")
setUiReady(bestVersion, ready)
}
} catch (t: Throwable) {
Logger.w(TAG, "Failed to seed UI from disk", t)
}
}
suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) { suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) {
try { try {
val client = ManagedHttpClient(); val client = ManagedHttpClient();
@@ -97,23 +188,26 @@ class StateUpdate {
throw Exception("App is not compatible. Supported ABIS: ${Build.SUPPORTED_ABIS.joinToString()}}."); throw Exception("App is not compatible. Supported ABIS: ${Build.SUPPORTED_ABIS.joinToString()}}.");
}; };
val VERSION_URL = if (BuildConfig.IS_UNSTABLE_BUILD) { val VERSION_URL = if (BuildConfig.IS_UNSTABLE_BUILD) {
"https://releases.grayjay.app/version-unstable.txt" "https://rel.grayjay.app/version-unstable.txt"
} else { } else {
"https://releases.grayjay.app/version.txt" "https://rel.grayjay.app/version.txt"
} }
val APK_URL = if (BuildConfig.IS_UNSTABLE_BUILD) { fun getApkUrl(version: Int): String = if (BuildConfig.IS_UNSTABLE_BUILD) {
"https://releases.grayjay.app/app-$DESIRED_ABI-release-unstable.apk" "https://rel.grayjay.app/$version/app-$DESIRED_ABI-release-unstable.apk"
} else { } else {
"https://releases.grayjay.app/app-$DESIRED_ABI-release.apk" "https://rel.grayjay.app/$version/app-$DESIRED_ABI-release.apk"
} }
val CHANGELOG_BASE_URL = "https://releases.grayjay.app/changelogs"; val CHANGELOG_BASE_URL = "https://rel.grayjay.app/changelogs";
fun getApkFile(context: Context, version: Int): File { fun getApkFile(context: Context, version: Int): File {
val dir = File(context.filesDir, "updates"); val dir = File(context.filesDir, "updates");
if (!dir.exists()) { if (!dir.exists()) {
dir.mkdirs(); dir.mkdirs();
} }
return File(dir, "app-${DESIRED_ABI}-${version}.apk"); val result = File(dir, "app-${DESIRED_ABI}-${version}.apk");
//if(result.exists())
// result.delete();
return result;
} }
fun getPartialApkFile(context: Context, version: Int): File { fun getPartialApkFile(context: Context, version: Int): File {
@@ -121,7 +215,10 @@ class StateUpdate {
if (!dir.exists()) { if (!dir.exists()) {
dir.mkdirs(); dir.mkdirs();
} }
return File(dir, "app-${DESIRED_ABI}-${version}.apk.part"); val result = File(dir, "app-${DESIRED_ABI}-${version}.apk.part");
//if(result.exists())
// result.delete();
return result;
} }
fun finish() { fun finish() {
@@ -40,6 +40,8 @@ import java.time.OffsetDateTime
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask import java.util.concurrent.ForkJoinTask
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
abstract class SubscriptionsTaskFetchAlgorithm( abstract class SubscriptionsTaskFetchAlgorithm(
@@ -125,7 +127,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val timeTotal = measureTimeMillis { val timeTotal = measureTimeMillis {
for(task in forkTasks) { for(task in forkTasks) {
try { try {
val result = task.get(); val result = task.get(TASK_TIMEOUT_S, TimeUnit.SECONDS);
if(result != null) { if(result != null) {
if(result.pager != null) { if(result.pager != null) {
taskResults.add(result); taskResults.add(result);
@@ -148,6 +150,10 @@ abstract class SubscriptionsTaskFetchAlgorithm(
} else { } else {
throw ex.cause ?: ex; 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 pager: IPager<IPlatformContent>?,
val exception: Throwable? val exception: Throwable?
) )
companion object {
private const val TASK_TIMEOUT_S = 90L;
}
} }
@@ -9,7 +9,6 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastProtocolType import com.futo.platformplayer.casting.CastProtocolType
@@ -91,13 +90,7 @@ class DeviceViewHolder : ViewHolder {
_textType.text = "AirPlay"; _textType.text = "AirPlay";
} }
CastProtocolType.FCAST -> { CastProtocolType.FCAST -> {
_imageDevice.setImageResource( _imageDevice.setImageResource(R.drawable.ic_fc)
if (Settings.instance.casting.experimentalCasting) {
R.drawable.ic_exp_fc
} else {
R.drawable.ic_fc
}
)
_textType.text = "FCast"; _textType.text = "FCast";
} }
} }
@@ -6,12 +6,10 @@ import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.Announcement import com.futo.platformplayer.states.Announcement
import com.futo.platformplayer.states.AnnouncementType import com.futo.platformplayer.states.AnnouncementType
@@ -162,6 +160,10 @@ class AnnouncementView : LinearLayout {
_textClose.visibility = View.VISIBLE; _textClose.visibility = View.VISIBLE;
_textNever.visibility = View.VISIBLE; _textNever.visibility = View.VISIBLE;
} }
AnnouncementType.ONGOING -> {
_textClose.visibility = View.GONE;
_textNever.visibility = View.GONE;
}
} }
if (announcement.time != null) { if (announcement.time != null) {
@@ -0,0 +1,158 @@
package com.futo.platformplayer.views.announcements
import android.content.Context
import android.content.Intent
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UpdateDownloadService
import com.futo.platformplayer.UpdateInstaller
import com.futo.platformplayer.UpdateNotificationManager
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import com.futo.platformplayer.states.UpdateUiState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class UpdateBannerView : LinearLayout {
private val _root: FrameLayout
private val _iconUpdate: ImageView
private val _textTitle: TextView
private val _progressBar: ProgressBar
private val _buttonAction: FrameLayout
private val _textAction: TextView
private val _scope: CoroutineScope?
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_update_banner, this)
_scope = findViewTreeLifecycleOwner()?.lifecycleScope ?: StateApp.instance.scopeOrNull
_root = findViewById(R.id.root)
_iconUpdate = findViewById(R.id.icon_update)
_textTitle = findViewById(R.id.text_title)
_progressBar = findViewById(R.id.update_banner_progress)
_buttonAction = findViewById(R.id.button_action)
_textAction = findViewById(R.id.text_action)
_buttonAction.setOnClickListener {
onActionClicked()
}
refresh()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
StateUpdate.instance.onUiChanged.subscribe(this) {
_scope?.launch(Dispatchers.Main) {
refresh()
}
}
refresh()
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
StateUpdate.instance.onUiChanged.remove(this)
}
private fun onActionClicked() {
val st = StateUpdate.instance
when (st.uiState) {
UpdateUiState.READY -> {
val apk = st.uiApkFile ?: return
UpdateNotificationManager.cancelAll(context)
UpdateInstaller.startInstall(context, st.uiVersion, apk)
}
UpdateUiState.FAILED -> {
if (st.uiVersion == 0) return
val intent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, st.uiVersion)
}
try {
ContextCompat.startForegroundService(context, intent)
} catch (t: Throwable) {
Logger.w(TAG, "Retry start service failed", t)
}
}
UpdateUiState.AVAILABLE -> {
if (st.uiVersion == 0) return
val intent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, st.uiVersion)
}
try {
ContextCompat.startForegroundService(context, intent)
} catch (t: Throwable) {
Logger.w(TAG, "Download start service failed", t)
}
}
UpdateUiState.DOWNLOADING -> {}
UpdateUiState.NONE -> {}
}
}
private fun refresh() {
val st = StateUpdate.instance
val gateOpen = Settings.instance.autoUpdate.shouldBackgroundDownload
val visible = gateOpen && st.uiState != UpdateUiState.NONE
if (!visible) {
_root.visibility = View.GONE
return
}
_root.visibility = View.VISIBLE
when (st.uiState) {
UpdateUiState.AVAILABLE -> {
_textTitle.text = "Update v${st.uiVersion}"
_progressBar.visibility = View.GONE
_textAction.text = "Download"
_buttonAction.visibility = View.VISIBLE
}
UpdateUiState.DOWNLOADING -> {
if (st.uiIndeterminate) {
_textTitle.text = "Downloading v${st.uiVersion}"
_progressBar.isIndeterminate = true
} else {
_textTitle.text = "Downloading v${st.uiVersion} - ${st.uiProgress}%"
_progressBar.isIndeterminate = false
_progressBar.progress = st.uiProgress
}
_progressBar.visibility = View.VISIBLE
_buttonAction.visibility = View.GONE
}
UpdateUiState.READY -> {
_textTitle.text = "Ready v${st.uiVersion}"
_progressBar.visibility = View.GONE
_textAction.text = "Install"
_buttonAction.visibility = View.VISIBLE
}
UpdateUiState.FAILED -> {
_textTitle.text = "Update failed"
_progressBar.visibility = View.GONE
_textAction.text = "Retry"
_buttonAction.visibility = View.VISIBLE
}
UpdateUiState.NONE -> {
_root.visibility = View.GONE
}
}
}
companion object {
const val TAG = "UpdateBannerView"
}
}
@@ -41,7 +41,7 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton {
when (d.connectionState) { when (d.connectionState) {
CastConnectionState.CONNECTED -> setColorFilter(activeColor) CastConnectionState.CONNECTED -> setColorFilter(activeColor)
CastConnectionState.CONNECTING -> setColorFilter(connectingColor) CastConnectionState.CONNECTING -> setColorFilter(connectingColor)
CastConnectionState.DISCONNECTED -> setColorFilter(activeColor) CastConnectionState.DISCONNECTED -> setColorFilter(inactiveColor)
} }
} else { } else {
setColorFilter(inactiveColor); 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 settings = group?.getFields() ?: _fields;
val query = _editSearch.text.toString().lowercase(); val query = _editSearch.text.toString().lowercase();
@@ -58,7 +58,8 @@ class FieldForm : LinearLayout {
val isGroupMatch = query.isEmpty() || group?.searchContent?.lowercase()?.contains(query) == true; val isGroupMatch = query.isEmpty() || group?.searchContent?.lowercase()?.contains(query) == true;
for(field in settings) { for(field in settings) {
if(field is GroupField) { if(field is GroupField) {
updateSettingsVisibility(field); if(!allowEmptyGroups)
updateSettingsVisibility(field);
} else if(field is View && field.descriptor != null) { } else if(field is View && field.descriptor != null) {
if(field.isAdvanced && !_showAdvancedSettings) 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) { if(group != null) {
group.visibility = if (groupVisible) View.VISIBLE else View.GONE; group.visibility = if (groupVisible) View.VISIBLE else View.GONE;
} }
} }
fun setShowAdvancedSettings(show: Boolean) { fun setShowAdvancedSettings(show: Boolean, allowEmptyGroups: Boolean = false) {
_showAdvancedSettings = show; _showAdvancedSettings = show;
updateSettingsVisibility(); updateSettingsVisibility(null, allowEmptyGroups);
} }
fun setSearchQuery(query: String) { fun setSearchQuery(query: String) {
_editSearch.setText(query); _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) { fun fromPluginSettings(settings: List<SourcePluginConfig.Setting>, values: HashMap<String, String?>, groupTitle: String? = null, groupDescription: String? = null) {
_fieldsContainer.removeAllViews(); _fieldsContainer.removeAllViews();
val newFields = getFieldsFromPluginSettings(context, settings, values); val newFields = getFieldsFromPluginSettings(context, settings, values, {
setShowAdvancedSettings(it, true);
});
if (newFields.isEmpty()) { if (newFields.isEmpty()) {
return; return;
} }
@@ -157,6 +166,7 @@ class FieldForm : LinearLayout {
_fieldsContainer.addView(v); _fieldsContainer.addView(v);
} }
_fields = newFields.map { it.second }; _fields = newFields.map { it.second };
updateSettingsVisibility(null, true);
} else { } else {
for(field in newFields) { for(field in newFields) {
finalizePluginSettingField(field.first, field.second, newFields); finalizePluginSettingField(field.first, field.second, newFields);
@@ -164,6 +174,8 @@ class FieldForm : LinearLayout {
val group = GroupField(context, groupTitle, groupDescription) val group = GroupField(context, groupTitle, groupDescription)
.withFields(newFields.map { it.second }); .withFields(newFields.map { it.second });
_fieldsContainer.addView(group as View); _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>>) { 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; 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>>() val fields = mutableListOf<Pair<SourcePluginConfig.Setting, IField>>()
for(setting in settings) { for(setting in settings) {
@@ -243,6 +255,7 @@ class FieldForm : LinearLayout {
val field = when(setting.type.lowercase()) { val field = when(setting.type.lowercase()) {
"header" -> { "header" -> {
val groupField = GroupField(context, setting.name, setting.description); val groupField = GroupField(context, setting.name, setting.description);
groupField.isAdvanced = (setting.isAdvanced ?: false);
groupField; groupField;
} }
"boolean" -> { "boolean" -> {
@@ -252,6 +265,7 @@ class FieldForm : LinearLayout {
field.onChanged.subscribe { _, v, _ -> field.onChanged.subscribe { _, v, _ ->
values[setting.variableOrName] = _json.encodeToString (v == 1 || v == true); values[setting.variableOrName] = _json.encodeToString (v == 1 || v == true);
} }
field.isAdvanced = (setting.isAdvanced ?: false);
field; field;
} }
"dropdown" -> { "dropdown" -> {
@@ -261,6 +275,7 @@ class FieldForm : LinearLayout {
field.onChanged.subscribe { _, v, _ -> field.onChanged.subscribe { _, v, _ ->
values[setting.variableOrName] = v.toString(); values[setting.variableOrName] = v.toString();
} }
field.isAdvanced = (setting.isAdvanced ?: false);
field; field;
} }
else null; else null;
@@ -272,6 +287,17 @@ class FieldForm : LinearLayout {
fields.add(Pair(setting, field)); 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; return fields;
} }
@@ -0,0 +1,246 @@
package com.futo.platformplayer.views.notification
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.Announcement
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.SessionAnnouncement
import com.futo.platformplayer.states.StateAnnouncement
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
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");
val adapter = adapterNotifications;
val announcements = StateAnnouncement.instance.getVisibleAnnouncements();
adapter.adapter.setData(announcements);
}
}
}
fun onResume() {
}
fun onPause() {
StateAnnouncement.instance.onAnnouncementChanged.remove(this);
}
class ViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<Announcement>(
LayoutInflater.from(_viewGroup.context).inflate(
R.layout.list_announcement,
_viewGroup, false)) {
protected var _announcement: Announcement? = null;
protected val _textName: TextView
protected val _textMetadata: TextView;
protected val _icon: ImageView;
protected val _buttonIgnore: ImageView
protected val _buttonNever: LinearLayout
protected val _buttonAction: LinearLayout
protected val _buttonActionText: TextView
protected val _buttonExtra: LinearLayout
protected val _buttonExtraText: TextView
protected val _loader: LoaderView;
protected val _progress: ProgressBar;
init {
_textName = _view.findViewById(R.id.text_name);
_textMetadata = _view.findViewById(R.id.text_metadata);
_buttonIgnore = _view.findViewById(R.id.button_ignore);
_buttonNever = _view.findViewById(R.id.button_never);
_buttonAction = _view.findViewById(R.id.button_action);
_buttonActionText = _view.findViewById(R.id.button_action_text);
_buttonExtra = _view.findViewById(R.id.button_extra);
_buttonExtraText = _view.findViewById(R.id.button_extra_text);
_icon = _view.findViewById(R.id.icon);
_loader = _view.findViewById(R.id.loader);
_progress = _view.findViewById(R.id.progress);
_buttonIgnore.setOnClickListener {
_announcement.let {
StateAnnouncement.instance.closeAnnouncement(it?.id);
}
}
_buttonNever.setOnClickListener {
_announcement.let {
StateAnnouncement.instance.neverAnnouncement(it?.id);
}
}
_buttonExtra.setOnClickListener {
_announcement.let {
StateAnnouncement.instance.actionAnnouncement(it?.id, true)
}
}
_buttonAction.setOnClickListener {
_announcement.let {
StateAnnouncement.instance.actionAnnouncement(it?.id);
}
}
}
override fun bind(value: Announcement) {
val oldAnnouncement = _announcement;
_announcement = value;
if(oldAnnouncement is SessionAnnouncement)
oldAnnouncement.onProgressChanged.clear();
_textName.text = value.title;
_textMetadata.text = value.msg;
if(value is SessionAnnouncement) {
if(value.icon != null) {
value.icon.setImageView(_icon);
_icon.visibility = View.VISIBLE;
}
else
_icon.visibility = View.GONE;
if(value.extraActionName != null && value.extraActionId != null) {
_buttonExtraText.text = value.extraActionName;
_buttonExtra.visibility = View.VISIBLE;
}
else
_buttonExtra.visibility = View.GONE;
if(value.announceType == AnnouncementType.ONGOING) {
_buttonIgnore.visibility = View.GONE;
}
else {
_buttonIgnore.visibility = View.VISIBLE;
}
if(value.progress != null && value.announceType == AnnouncementType.ONGOING) {
_progress.isVisible = true;
_progress.min = 0;
_progress.max = 100;
value.onProgressChanged.subscribe {
val prog = it.progress;
if(prog == 0.toDouble() || prog == 100.toDouble()) {
_progress.isIndeterminate = true;
}
else {
_progress.isIndeterminate = false;
_progress.setProgress(it.progress?.times(100)?.toInt() ?: 0, false);
}
}
}
else
_progress.isVisible = false;
}
else {
_buttonExtra.visibility = View.GONE;
_icon.visibility = View.GONE;
_buttonIgnore.visibility = View.VISIBLE;
}
if(value.announceType == AnnouncementType.ONGOING) {
_loader.visibility = View.VISIBLE;
_loader.start();
}
else {
_loader.visibility = View.GONE;
_loader.stop();
}
_buttonNever.visibility =
if (value.announceType == AnnouncementType.RECURRING || value.announceType == AnnouncementType.SESSION_RECURRING)
View.VISIBLE
else
View.GONE;
_buttonAction.visibility =
if(value.actionId != null && value.actionName != null)
View.VISIBLE;
else View.GONE;
if(value.actionId != null && value.actionName != null) {
_buttonActionText.text = value.actionName;
}
}
}
class Frag : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _view: NotificationOverlayView? = null;
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
_view?.onShown(parameter);
}
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = NotificationOverlayView(requireContext());
_view = view;
return view;
}
override fun onDestroyMainView() {
super.onDestroyMainView();
_view = null;
}
override fun onResume() {
super.onResume()
_view?.onResume();
}
override fun onPause() {
super.onPause()
_view?.onPause();
}
}
}

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