mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a792dea4c5 | |||
| a7fc549afb | |||
| b345ba5ca3 | |||
| c65cee86b1 | |||
| cf3fc61f6a | |||
| d03019f0b7 | |||
| f1ce0078fd | |||
| 32de3649ef | |||
| 1a301236da | |||
| c695379885 | |||
| 73466892f7 | |||
| bb8a9d4dd7 | |||
| 43ed2b16ab | |||
| 64938dba6c | |||
| 8b7d51cd70 | |||
| ace7ca1551 | |||
| 22b5adc4b8 | |||
| 0f7fb9059b | |||
| 05afa12274 | |||
| b4a280cee8 | |||
| ac5d7eab2a | |||
| b624d45ab6 | |||
| 5340088ada | |||
| fcab0f5ee5 | |||
| 80c9b27d48 | |||
| f54216d52f | |||
| fea69d265a | |||
| 030086e769 | |||
| 81516c31fb | |||
| 3d13a21700 | |||
| cd90497a59 | |||
| c14d2580ee | |||
| 795259564d | |||
| 81d0b08306 | |||
| 9a97a901fb | |||
| d9b23eff62 | |||
| 8591deaf86 | |||
| 22c5581d00 | |||
| 6e815dc868 | |||
| 1ac409561c | |||
| 897ba8a560 | |||
| 8982ea2289 | |||
| f693f1e6b3 | |||
| e118bc09b9 | |||
| 5ba77b60c8 | |||
| 19b63ba372 | |||
| 5fc39d3bb3 | |||
| 1d046538f8 | |||
| 9f10b86861 | |||
| d1336c711a | |||
| 837ee76bdc | |||
| 2a2ed08a3c | |||
| 8a0e49232e | |||
| 624ef3c6e9 | |||
| 3d5b9a94fb | |||
| a8decdb0d9 | |||
| 2609929780 | |||
| 2bcfbf89d3 | |||
| fa1954ceef | |||
| 13aa49726a | |||
| 20bab7d056 | |||
| cbf7ca0181 | |||
| b7477080d2 | |||
| ac5bc27581 | |||
| 748551af2a | |||
| 9ce41bc8d0 | |||
| 8cf542e201 | |||
| 88950843b3 | |||
| 4a08058322 | |||
| 7b76ba1539 | |||
| 6492278e7d | |||
| 9de9440160 | |||
| 372af6cf47 | |||
| 29d08c8554 | |||
| cfeceabe5b | |||
| a51f609a92 | |||
| 15a655f196 | |||
| c6525f1caa | |||
| e147fdd77e | |||
| 6a8ac0bfaa | |||
| 772bff6bc0 | |||
| b6b04054b9 | |||
| 1ea794459c | |||
| c27f5e4096 | |||
| 8469f17b4c | |||
| 067abc415b | |||
| d692533f20 | |||
| 31a6ea0f39 | |||
| 5ba2f2be75 | |||
| 8e4ad54de1 | |||
| 6139696714 | |||
| 8536861e09 | |||
| 71262da3c2 | |||
| 60cd5976cc | |||
| 3ca6a1fd70 | |||
| 0d8c8de450 | |||
| 8ba2fe9972 | |||
| 7a7ef533cc | |||
| 5385549a43 | |||
| 04deffc66e | |||
| 852f563c9a | |||
| c84cea9ea1 | |||
| 09bc180d4f | |||
| 76a42f5f6f |
+43
-16
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:50b53a5d297d0c3008b3359a452a5c9e7e9f530533ec197e3dd1e9f08f9e84ad
|
||||||
|
size 6342128
|
||||||
+8
-13
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()!!);
|
||||||
@@ -399,4 +421,4 @@ fun <T> IPager<T>.toList(): List<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return list.toList();
|
return list.toList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1538,4 +1543,4 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
return sourcesIntent;
|
return sourcesIntent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -891,4 +905,4 @@ open class JSClient : IPlatformClient {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
+8
-6
@@ -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)
|
||||||
}
|
}
|
||||||
+13
-1
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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();
|
||||||
|
|||||||
+2
-1
@@ -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")
|
||||||
|
|||||||
+37
-21
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,4 +192,4 @@ class JSNestedSegment: IJSArticleSegment {
|
|||||||
val nestedObj = obj.getOrThrow<V8ValueObject>(client.config, "nested", contextName, false);
|
val nestedObj = obj.getOrThrow<V8ValueObject>(client.config, "nested", contextName, false);
|
||||||
nested = IJSContent.fromV8(client, nestedObj);
|
nested = IJSContent.fromV8(client, nestedObj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-12
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -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() {
|
||||||
@@ -91,4 +93,4 @@ abstract class JSPager<T> : IPager<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
abstract fun convertResult(obj: V8ValueObject): T;
|
abstract fun convertResult(obj: V8ValueObject): T;
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-21
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-12
@@ -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 {
|
||||||
@@ -163,4 +171,4 @@ class ExecutorParameters {
|
|||||||
var rangeEnd: Int = -1;
|
var rangeEnd: Int = -1;
|
||||||
|
|
||||||
var segment: Int = -1;
|
var segment: Int = -1;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -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;
|
||||||
@@ -53,4 +53,4 @@ class JSRequestModifier: IRequestModifier {
|
|||||||
|
|
||||||
|
|
||||||
data class Request(override val url: String, override val headers: Map<String, String>) : IRequest;
|
data class Request(override val url: String, override val headers: Map<String, String>) : IRequest;
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-7
@@ -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 {
|
||||||
@@ -69,4 +85,4 @@ class JSSubtitleSource : ISubtitleSource {
|
|||||||
return JSSubtitleSource(config, value);
|
return JSSubtitleSource(config, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-24
@@ -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)
|
||||||
@@ -153,4 +190,4 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
|||||||
return@busy JSVODEventPager(_plugin.config, _plugin,
|
return@busy JSVODEventPager(_plugin.config, _plugin,
|
||||||
_content.invokeV8<V8ValueObject>("getVODEvents", arrayOf<Any>()));
|
_content.invokeV8<V8ValueObject>("getVODEvents", arrayOf<Any>()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|
||||||
|
|||||||
+12
-10
@@ -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 {
|
||||||
|
|||||||
+5
-5
@@ -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();
|
||||||
@@ -145,4 +145,4 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-4
@@ -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;
|
||||||
@@ -241,4 +258,4 @@ class JSDashManifestMergingRawSource(
|
|||||||
companion object {
|
companion object {
|
||||||
private val adaptationSetRegex = Regex("<AdaptationSet.*?>.*?<\\/AdaptationSet>", RegexOption.DOT_MATCHES_ALL);
|
private val adaptationSetRegex = Regex("<AdaptationSet.*?>.*?<\\/AdaptationSet>", RegexOption.DOT_MATCHES_ALL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-11
@@ -42,27 +42,29 @@ 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 {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+19
-8
@@ -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") {
|
||||||
@@ -166,4 +177,4 @@ abstract class JSSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-11
@@ -18,25 +18,27 @@ 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 {
|
||||||
val url = getVideoUrl()
|
val url = getVideoUrl()
|
||||||
return "(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url, hasLicenseRequestExecutor=$hasLicenseRequestExecutor, licenseUri=$licenseUri)"
|
return "(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url, hasLicenseRequestExecutor=$hasLicenseRequestExecutor, licenseUri=$licenseUri)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -1603,4 +1622,4 @@ class VideoDownload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
|
|||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.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);
|
||||||
}
|
}
|
||||||
@@ -546,4 +662,4 @@ class V8Plugin {
|
|||||||
return StateAssets.readAsset(context, path) ?: throw java.lang.IllegalStateException("script ${path} not found");
|
return StateAssets.readAsset(context, path) ?: throw java.lang.IllegalStateException("script ${path} not found");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.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;
|
||||||
@@ -274,4 +296,4 @@ class PackageBridge : V8Package {
|
|||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("&", "&").replace("\"", """)
|
||||||
|
|
||||||
|
@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,
|
||||||
@@ -780,4 +792,4 @@ class PackageHttp: V8Package {
|
|||||||
private const val TAG = "PackageHttp";
|
private const val TAG = "PackageHttp";
|
||||||
private val WHITELISTED_RESPONSE_HEADERS = listOf("content-type", "date", "content-length", "last-modified", "etag", "cache-control", "content-encoding", "content-disposition", "connection")
|
private val WHITELISTED_RESPONSE_HEADERS = listOf("content-type", "date", "content-length", "last-modified", "etag", "cache-control", "content-encoding", "content-disposition", "connection")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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) {
|
||||||
|
|||||||
+9
-1
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-14
@@ -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) {
|
||||||
@@ -165,4 +171,4 @@ class BuyFragment : MainFragment() {
|
|||||||
fun newInstance() = BuyFragment().apply {}
|
fun newInstance() = BuyFragment().apply {}
|
||||||
private val TAG = "BuyFragment"
|
private val TAG = "BuyFragment"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -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 ->
|
||||||
|
|||||||
+5
-1
@@ -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>) {
|
||||||
|
|||||||
+5
@@ -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 ->
|
||||||
|
|||||||
+2
-2
@@ -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);
|
||||||
|
|||||||
+6
-2
@@ -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 {
|
||||||
|
|||||||
+1
-1
@@ -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) {
|
||||||
|
|||||||
+13
-1
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-3
@@ -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 {
|
||||||
|
|||||||
+53
-16
@@ -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();
|
||||||
|
|||||||
+52
@@ -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" +
|
||||||
@@ -34,4 +41,4 @@ class StatePayment : PaymentState(if(!isTestingPayment) VERIFICATION_PUBLIC_KEY
|
|||||||
return _instance!!;
|
return _instance!!;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
import com.futo.platformplayer.api.media.platforms.js.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();
|
||||||
@@ -1136,4 +1160,4 @@ class StatePlatform {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>(){
|
||||||
@@ -489,4 +490,4 @@ class StateSubscriptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -130,4 +227,4 @@ class StateUpdate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+246
@@ -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
Reference in New Issue
Block a user