mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Compare commits
88 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 | |||
| 8e4ad54de1 | |||
| 6139696714 | |||
| 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
|
||||||
+1
-6
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -787,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 {
|
||||||
@@ -796,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;
|
||||||
@@ -805,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) {
|
||||||
@@ -872,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)
|
||||||
@@ -952,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() {
|
||||||
@@ -1047,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,17 +1,15 @@
|
|||||||
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.models.ImageVariable
|
import com.futo.platformplayer.models.ImageVariable
|
||||||
import com.futo.platformplayer.states.AnnouncementType
|
import com.futo.platformplayer.states.AnnouncementType
|
||||||
import com.futo.platformplayer.states.SessionAnnouncement
|
import com.futo.platformplayer.states.SessionAnnouncement
|
||||||
import com.futo.platformplayer.states.StateAnnouncement
|
import com.futo.platformplayer.states.StateAnnouncement
|
||||||
import com.futo.platformplayer.states.StateApp
|
|
||||||
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
|
||||||
@@ -30,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()
|
||||||
@@ -56,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
|
||||||
@@ -75,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)
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@ class UpdateDownloadService : Service() {
|
|||||||
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)
|
if(onProgress != null)
|
||||||
onProgress.invoke(progress);
|
onProgress.invoke(progress);
|
||||||
@@ -132,7 +134,7 @@ class UpdateDownloadService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
performDownload(StateUpdate.APK_URL, partialFile, version, {
|
performDownload(StateUpdate.getApkUrl(version), partialFile, version, {
|
||||||
try {
|
try {
|
||||||
if (announcement != null)
|
if (announcement != null)
|
||||||
announcement?.setProgress(it);
|
announcement?.setProgress(it);
|
||||||
@@ -159,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)
|
||||||
@@ -264,39 +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));
|
|
||||||
|
|
||||||
try {
|
|
||||||
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", {
|
|
||||||
UpdateNotificationManager.cancelAll(ctx)
|
|
||||||
UpdateInstaller.startInstall(ctx, version, apkFile)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch(ex: Throwable) {
|
|
||||||
|
|
||||||
}
|
|
||||||
} 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 {
|
||||||
|
|||||||
@@ -1543,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)
|
||||||
}
|
}
|
||||||
+4
-3
@@ -170,12 +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")) {
|
/*if(packagesOptional.contains("Browser") || packages.contains("Browser")) {
|
||||||
list.add(Pair(
|
list.add(Pair(
|
||||||
"Browser Interop",
|
"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."
|
"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;
|
||||||
}
|
}
|
||||||
@@ -235,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 {
|
||||||
|
|||||||
+4
-4
@@ -55,7 +55,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
|||||||
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 = obj.getOrNull(config, "original", contextName) ?: 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,79 +95,7 @@ private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// abstract class CastingDevice {
|
|
||||||
class CastingDevice(val device: RsCastingDevice) {
|
class CastingDevice(val device: RsCastingDevice) {
|
||||||
// abstract val isReady: Boolean
|
|
||||||
// abstract val usedRemoteAddress: InetAddress?
|
|
||||||
// abstract val localAddress: InetAddress?
|
|
||||||
// abstract val name: String?
|
|
||||||
// abstract val onConnectionStateChanged: Event1<CastConnectionState>
|
|
||||||
// abstract val onPlayChanged: Event1<Boolean>
|
|
||||||
// abstract val onTimeChanged: Event1<Double>
|
|
||||||
// abstract val onDurationChanged: Event1<Double>
|
|
||||||
// abstract val onVolumeChanged: Event1<Double>
|
|
||||||
// abstract val onSpeedChanged: Event1<Double>
|
|
||||||
// abstract val onMediaItemEnd: Event0
|
|
||||||
// abstract var connectionState: CastConnectionState
|
|
||||||
// abstract val protocolType: CastProtocolType
|
|
||||||
// abstract var isPlaying: Boolean
|
|
||||||
// abstract val expectedCurrentTime: Double
|
|
||||||
// abstract var speed: Double
|
|
||||||
// abstract var time: Double
|
|
||||||
// abstract var duration: Double
|
|
||||||
// abstract var volume: Double
|
|
||||||
// abstract fun canSetVolume(): Boolean
|
|
||||||
// abstract fun canSetSpeed(): Boolean
|
|
||||||
|
|
||||||
// @Throws
|
|
||||||
// abstract fun resumePlayback()
|
|
||||||
|
|
||||||
// @Throws
|
|
||||||
// abstract fun pausePlayback()
|
|
||||||
|
|
||||||
// @Throws
|
|
||||||
// abstract fun stopPlayback()
|
|
||||||
|
|
||||||
// @Throws
|
|
||||||
// abstract fun seekTo(timeSeconds: Double)
|
|
||||||
|
|
||||||
// @Throws
|
|
||||||
// abstract fun changeVolume(timeSeconds: Double)
|
|
||||||
|
|
||||||
// @Throws
|
|
||||||
// abstract fun changeSpeed(speed: Double)
|
|
||||||
|
|
||||||
// @Throws
|
|
||||||
// abstract fun connect()
|
|
||||||
|
|
||||||
// @Throws
|
|
||||||
// abstract fun disconnect()
|
|
||||||
// abstract fun getDeviceInfo(): CastingDeviceInfo
|
|
||||||
// abstract fun getAddresses(): List<InetAddress>
|
|
||||||
|
|
||||||
// @Throws
|
|
||||||
// abstract fun loadVideo(
|
|
||||||
// streamType: String,
|
|
||||||
// contentType: String,
|
|
||||||
// contentId: String,
|
|
||||||
// resumePosition: Double,
|
|
||||||
// duration: Double,
|
|
||||||
// speed: Double?,
|
|
||||||
// metadata: Metadata?
|
|
||||||
// )
|
|
||||||
|
|
||||||
// @Throws
|
|
||||||
// fun loadContent(
|
|
||||||
// contentType: String,
|
|
||||||
// content: String,
|
|
||||||
// resumePosition: Double,
|
|
||||||
// duration: Double,
|
|
||||||
// speed: Double?,
|
|
||||||
// metadata: Metadata?
|
|
||||||
// )
|
|
||||||
|
|
||||||
// fun ensureThreadStarted()
|
|
||||||
|
|
||||||
class EventHandler : RsDeviceEventHandler {
|
class EventHandler : RsDeviceEventHandler {
|
||||||
var onConnectionStateChanged = Event1<DeviceConnectionState>();
|
var onConnectionStateChanged = Event1<DeviceConnectionState>();
|
||||||
var onPlayChanged = Event1<Boolean>()
|
var onPlayChanged = Event1<Boolean>()
|
||||||
|
|||||||
@@ -920,6 +920,7 @@ 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");
|
||||||
@@ -927,6 +928,7 @@ 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");
|
||||||
@@ -968,8 +970,7 @@ 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}" }
|
||||||
|
|
||||||
@@ -1022,7 +1023,7 @@ 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()
|
||||||
@@ -1059,7 +1060,7 @@ 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()
|
||||||
@@ -1190,6 +1191,7 @@ 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");
|
||||||
@@ -1267,6 +1269,7 @@ 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");
|
||||||
@@ -1350,6 +1353,7 @@ 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");
|
||||||
@@ -1357,6 +1361,7 @@ 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");
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,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
|
||||||
|
|
||||||
@@ -87,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>();
|
||||||
@@ -94,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;
|
||||||
@@ -114,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)!!);
|
||||||
@@ -159,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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,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 {
|
||||||
@@ -281,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 {
|
||||||
@@ -428,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);
|
||||||
}
|
}
|
||||||
@@ -565,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(
|
||||||
@@ -113,7 +128,9 @@ class PackageBridge : V8Package {
|
|||||||
@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;
|
||||||
@@ -279,4 +296,4 @@ class PackageBridge : V8Package {
|
|||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
package com.futo.platformplayer.engine.packages
|
package com.futo.platformplayer.engine.packages
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
import android.webkit.ConsoleMessage
|
import android.webkit.ConsoleMessage
|
||||||
|
import android.webkit.CookieManager
|
||||||
import android.webkit.JavascriptInterface
|
import android.webkit.JavascriptInterface
|
||||||
import android.webkit.ValueCallback
|
import android.webkit.ValueCallback
|
||||||
import android.webkit.WebChromeClient
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import androidx.collection.emptyLongSet
|
import androidx.webkit.ScriptHandler
|
||||||
|
import androidx.webkit.WebViewCompat
|
||||||
|
import androidx.webkit.WebViewFeature
|
||||||
import com.caoccao.javet.annotations.V8Function
|
import com.caoccao.javet.annotations.V8Function
|
||||||
|
import com.caoccao.javet.utils.JavetResourceUtils
|
||||||
import com.caoccao.javet.values.reference.V8ValueFunction
|
import com.caoccao.javet.values.reference.V8ValueFunction
|
||||||
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
|
||||||
@@ -22,19 +32,40 @@ import kotlinx.coroutines.TimeoutCancellationException
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeout
|
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 {
|
class PackageBrowser: V8Package {
|
||||||
|
val useAddDocumentStartJavaScript = true
|
||||||
|
|
||||||
override val name: String get() = "Browser";
|
override val name: String get() = "Browser";
|
||||||
override val variableName: String = "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
|
@Transient
|
||||||
private var _readySemaphore: Semaphore? = null;
|
private var _readySemaphore: Semaphore? = null;
|
||||||
@Transient
|
@Transient
|
||||||
private val _callbacks = mutableMapOf<String, (String?)->Unit>();
|
private val _callbacks = mutableMapOf<String, (String?)->Unit>();
|
||||||
@Transient
|
@Transient
|
||||||
private val _interop = JSInterop(this);
|
|
||||||
@Transient
|
|
||||||
private var _browser: WebView? = null;
|
private var _browser: WebView? = null;
|
||||||
private val browser: WebView get() {
|
private val browser: WebView get() {
|
||||||
if(_browser == null)
|
if(_browser == null)
|
||||||
@@ -42,54 +73,197 @@ class PackageBrowser: V8Package {
|
|||||||
return _browser!!;
|
return _browser!!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var _userAgent: String = ""
|
||||||
|
private val http = OkHttpClient.Builder()
|
||||||
|
.followRedirects(false)
|
||||||
|
.followSslRedirects(false)
|
||||||
|
.build()
|
||||||
|
|
||||||
constructor(v8Plugin: V8Plugin): super(v8Plugin) {
|
constructor(v8Plugin: V8Plugin): super(v8Plugin) {
|
||||||
|
|
||||||
}
|
}
|
||||||
@V8Function
|
@V8Function
|
||||||
fun initialize() {
|
fun initialize() {
|
||||||
if(_browser == null){
|
if (_browser != null) return
|
||||||
StateApp.instance.scope.launch(Dispatchers.Main) {
|
|
||||||
_browser = WebView(StateApp.instance.contextOrNull ?: return@launch);
|
onMainBlocking {
|
||||||
_browser?.settings?.javaScriptEnabled = true;
|
_browser = WebView(StateApp.instance.contextOrNull ?: return@onMainBlocking);
|
||||||
_browser?.settings?.blockNetworkImage = false;
|
_userAgent = _browser?.settings?.userAgentString.orEmpty()
|
||||||
_browser?.settings?.blockNetworkLoads = false;
|
_browser?.settings?.javaScriptEnabled = true;
|
||||||
_browser?.settings?.allowContentAccess = false;
|
_browser?.settings?.blockNetworkImage = false;
|
||||||
_browser?.settings?.allowFileAccess = false;
|
_browser?.settings?.blockNetworkLoads = false;
|
||||||
//_browser?.settings?.useWideViewPort = true;
|
_browser?.settings?.allowContentAccess = false;
|
||||||
//_browser?.settings?.loadWithOverviewMode = true;
|
_browser?.settings?.allowFileAccess = false;
|
||||||
_browser?.webViewClient = object : WebViewClient() {
|
//_browser?.settings?.useWideViewPort = true;
|
||||||
override fun onPageCommitVisible(view: WebView?, url: String?) {
|
//_browser?.settings?.loadWithOverviewMode = true;
|
||||||
super.onPageCommitVisible(view, url)
|
_browser?.webViewClient = object : WebViewClient() {
|
||||||
_readySemaphore?.release();
|
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
|
||||||
_readySemaphore = null;
|
if (view == null || request == null) return null
|
||||||
Logger.i("PackageBrowser", "Browser loaded");
|
|
||||||
|
if (useAddDocumentStartJavaScript && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return null
|
||||||
|
if (!request.isForMainFrame) return null
|
||||||
|
if (!request.method.equals("GET", ignoreCase = true)) return null
|
||||||
|
|
||||||
|
val url = request.url?.toString() ?: return null
|
||||||
|
Log.i("PackageBrowser", "shouldInterceptRequest: " + url)
|
||||||
|
val scheme = request.url?.scheme ?: return null
|
||||||
|
if (scheme != "http" && scheme != "https") return null
|
||||||
|
|
||||||
|
val scripts = _pageLoadScriptsFallback.values.toList()
|
||||||
|
if (scripts.isEmpty()) return null
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val cookie = request.requestHeaders["Cookie"] ?: runCatching { CookieManager.getInstance().getCookie(url) }.getOrNull()
|
||||||
|
val ua = request.requestHeaders["User-Agent"] ?: _userAgent
|
||||||
|
|
||||||
|
val okReq = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.get()
|
||||||
|
.header("User-Agent", ua)
|
||||||
|
.apply { if (!cookie.isNullOrEmpty()) header("Cookie", cookie) }
|
||||||
|
.build()
|
||||||
|
|
||||||
|
http.newCall(okReq).execute().use { resp ->
|
||||||
|
val code = resp.code
|
||||||
|
val reason = resp.message.ifBlank { "OK" }
|
||||||
|
if (code in 300..399) return null
|
||||||
|
|
||||||
|
val contentType = resp.header("Content-Type") ?: ""
|
||||||
|
val isHtml =
|
||||||
|
contentType.startsWith("text/html", ignoreCase = true) ||
|
||||||
|
contentType.startsWith("application/xhtml+xml", ignoreCase = true)
|
||||||
|
|
||||||
|
if (!isHtml) return null
|
||||||
|
|
||||||
|
val bodyBytes = resp.body.bytes()
|
||||||
|
val charset = charsetFromContentType(contentType) ?: Charsets.UTF_8
|
||||||
|
val html = bodyBytes.toString(charset)
|
||||||
|
|
||||||
|
val cspHeader = resp.header("Content-Security-Policy")
|
||||||
|
?: resp.header("Content-Security-Policy-Report-Only")
|
||||||
|
|
||||||
|
val nonce = extractNonceFromCsp(cspHeader) ?: extractNonceFromHtml(html)
|
||||||
|
|
||||||
|
val injected = injectIntoHead(html, scripts.joinToString("\n"), nonce)
|
||||||
|
val outBytes = injected.toByteArray(charset)
|
||||||
|
val headers = resp.headers.toMultimap()
|
||||||
|
.mapValues { it.value.joinToString(",") }
|
||||||
|
.toMutableMap()
|
||||||
|
|
||||||
|
headers.remove("Content-Length")
|
||||||
|
val cookieMgr = CookieManager.getInstance()
|
||||||
|
resp.headers.values("Set-Cookie").forEach { sc ->
|
||||||
|
try { cookieMgr.setCookie(url, sc) } catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
try { cookieMgr.flush() } catch (_: Throwable) {}
|
||||||
|
|
||||||
|
WebResourceResponse("text/html", charset.name(), code, reason, headers, ByteArrayInputStream(outBytes))
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_browser?.webChromeClient = object : WebChromeClient() {
|
|
||||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
override fun onPageCommitVisible(view: WebView?, url: String?) {
|
||||||
if(consoleMessage?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
|
super.onPageCommitVisible(view, url)
|
||||||
val msg = "Browser Error:${consoleMessage?.message()} [${consoleMessage?.lineNumber()}]" ?: ""
|
Logger.i("PackageBrowser", "Browser loaded (commit visible): $url")
|
||||||
Logger.e("PackageBrowser", msg);
|
releaseReadyIfCurrent(url)
|
||||||
if(_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
|
}
|
||||||
StateDeveloper.instance.logDevException(StateDeveloper.instance.currentDevID ?: "", msg)
|
|
||||||
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
|
super.onPageFinished(view, url)
|
||||||
|
Logger.i("PackageBrowser", "Browser loaded (finished): $url")
|
||||||
|
releaseReadyIfCurrent(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_browser?.webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
||||||
|
try {
|
||||||
|
val raw = consoleMessage?.message().orEmpty()
|
||||||
|
|
||||||
|
val normalized = raw.trim().let { s ->
|
||||||
|
if (s.length >= 2 && s.first() == '"' && s.last() == '"') {
|
||||||
|
s.substring(1, s.length - 1)
|
||||||
|
} else s
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
val msg = "Browser Log:" + consoleMessage?.message() ?: "";
|
if (normalized.startsWith(CONSOLE_BRIDGE_PREFIX)) {
|
||||||
Logger.e("PackageBrowser", msg);
|
val payload = normalized.substring(CONSOLE_BRIDGE_PREFIX.length)
|
||||||
if(_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
|
if (handleConsoleBridgeMessage(payload)) return true
|
||||||
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", msg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
return super.onConsoleMessage(consoleMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_browser?.addJavascriptInterface(_interop, "__GJ");
|
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val bootstrap = """
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
if (window.__GJ) return;
|
||||||
|
|
||||||
|
const PREFIX = ${CONSOLE_BRIDGE_PREFIX.quoteForJs()};
|
||||||
|
const emit = (obj) => {
|
||||||
|
try {
|
||||||
|
console.info(PREFIX + JSON.stringify(obj));
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(window, "__GJ", {
|
||||||
|
value: {
|
||||||
|
callback: (id, result) => {
|
||||||
|
try {
|
||||||
|
const r = (typeof result === "string")
|
||||||
|
? result
|
||||||
|
: (() => { try { return JSON.stringify(result); } catch (_) { return String(result); } })();
|
||||||
|
emit({ t: "cb", id: String(id), result: r });
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
log: (msg) => {
|
||||||
|
try { emit({ t: "log", msg: String(msg) }); } catch (_) {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enumerable: false,
|
||||||
|
configurable: false,
|
||||||
|
writable: false
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
addScriptOnLoad(bootstrap)
|
||||||
}
|
}
|
||||||
@V8Function
|
@V8Function
|
||||||
fun deinitialize() {
|
fun deinitialize() {
|
||||||
_browser?.destroy();
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
_browser?.destroy();
|
||||||
|
}
|
||||||
_browser = null;
|
_browser = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,13 +300,28 @@ class PackageBrowser: V8Package {
|
|||||||
|
|
||||||
@V8Function
|
@V8Function
|
||||||
fun load(url: String) {
|
fun load(url: String) {
|
||||||
Logger.i("PackageBrowser", "Browser loading url [${url}]");
|
Logger.i("PackageBrowser", "Browser loading url [$url]")
|
||||||
_readySemaphore = Semaphore(1, 1);
|
val token = UUID.randomUUID().toString()
|
||||||
|
_loadToken = token
|
||||||
|
_expectedMainUrl = url
|
||||||
|
_readySemaphore = Semaphore(1, acquiredPermits = 1)
|
||||||
|
|
||||||
StateApp.instance.scope.launch(Dispatchers.Main) {
|
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||||
browser.loadUrl(url);
|
try { browser.loadUrl(url) }
|
||||||
|
catch (t: Throwable) { Logger.e("PackageBrowser", "loadUrl failed", t) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun releaseReadyIfCurrent(url: String?) {
|
||||||
|
if (url == null) return
|
||||||
|
val expected = _expectedMainUrl
|
||||||
|
if (url.trimEnd('/') != expected?.trimEnd('/')) return
|
||||||
|
|
||||||
|
_readySemaphore?.release()
|
||||||
|
_readySemaphore = null
|
||||||
|
_expectedMainUrl = null
|
||||||
|
}
|
||||||
|
|
||||||
@V8Function
|
@V8Function
|
||||||
fun run(js: String, callbackId: String? = null, callback: V8ValueFunction? = null) {
|
fun run(js: String, callbackId: String? = null, callback: V8ValueFunction? = null) {
|
||||||
waitTillLoaded();
|
waitTillLoaded();
|
||||||
@@ -140,18 +329,27 @@ class PackageBrowser: V8Package {
|
|||||||
if(callbackId != null && callback != null) {
|
if(callbackId != null && callback != null) {
|
||||||
synchronized(_callbacks) {
|
synchronized(_callbacks) {
|
||||||
_callbacks.put(callbackId, {
|
_callbacks.put(callbackId, {
|
||||||
funcClone?.callVoid(null, arrayOf(it));
|
_plugin.busy {
|
||||||
|
funcClone?.callVoid(null, arrayOf(it));
|
||||||
|
}
|
||||||
|
if (!_plugin.isStopped)
|
||||||
|
JavetResourceUtils.safeClose(funcClone);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
StateApp.instance.scope.launch(Dispatchers.Main) {
|
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)})");
|
try {
|
||||||
browser.evaluateJavascript(js, object : ValueCallback<String> {
|
Logger.i("PackageBrowser", "Browser running JS with callback [${callbackId}]\n${(if(js.length > 200) (js.substring(0, 200) + "...") else js)})");
|
||||||
override fun onReceiveValue(value: String?) {
|
browser.evaluateJavascript(js, object : ValueCallback<String> {
|
||||||
Logger.i("PackageBrowser", "Browser run finished");
|
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) {
|
catch(ex: Throwable) {
|
||||||
Logger.e("PackageBrowser", "Failed to invoke browser", ex);
|
Logger.e("PackageBrowser", "Failed to invoke browser", ex);
|
||||||
@@ -168,30 +366,189 @@ class PackageBrowser: V8Package {
|
|||||||
browser.evaluateJavascript(js, object : ValueCallback<String> {
|
browser.evaluateJavascript(js, object : ValueCallback<String> {
|
||||||
override fun onReceiveValue(value: String?) {
|
override fun onReceiveValue(value: String?) {
|
||||||
Logger.i("PackageBrowser", "Browser run returned: " + (value ?: ""));
|
Logger.i("PackageBrowser", "Browser run returned: " + (value ?: ""));
|
||||||
funcClone?.callVoid(null, arrayOf(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) {
|
catch(ex: Throwable) {
|
||||||
Logger.e("PackageBrowser", "Failed to invoke browser", ex);
|
Logger.e("PackageBrowser", "Browser Failed to invoke browser", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class JSInterop(private val pack: PackageBrowser) {
|
@V8Function
|
||||||
|
fun addScriptOnLoad(js: String): String {
|
||||||
|
require(js.isNotBlank()) { "Script must be non-empty." }
|
||||||
|
|
||||||
@JavascriptInterface
|
val id = UUID.randomUUID().toString()
|
||||||
fun callback(id: String, result: String) {
|
onMainBlocking {
|
||||||
Logger.i("PackageBrowser", "Browser Callback [${id}]: ${result}");
|
if (useAddDocumentStartJavaScript && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
|
||||||
val callback = synchronized(pack._callbacks) { pack._callbacks.remove(id); };
|
val ref = WebViewCompat.addDocumentStartJavaScript(browser, js, setOf("*"))
|
||||||
if(callback != null)
|
_pageLoadScriptRefs[id] = ref
|
||||||
callback.invoke(result);
|
} else {
|
||||||
|
_pageLoadScriptsFallback[id] = js
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@JavascriptInterface
|
Logger.i("PackageBrowser", "addScriptOnLoad() registered (id=$id)")
|
||||||
fun log(msg: String) {
|
return id
|
||||||
Logger.i("PackageBrowser", "Log: " + msg);
|
}
|
||||||
|
|
||||||
|
@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) {
|
||||||
|
|||||||
+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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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 ->
|
||||||
|
|||||||
+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 {
|
||||||
|
|||||||
+52
-15
@@ -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) {
|
||||||
|
|||||||
+4
-1
@@ -90,7 +90,10 @@ class GeneralTopBarFragment : TopFragment() {
|
|||||||
updateNotifCount();
|
updateNotifCount();
|
||||||
|
|
||||||
_buttonNotifs?.setOnClickListener {
|
_buttonNotifs?.setOnClickListener {
|
||||||
navigate<NotificationOverlayView.Frag>();
|
if(currentMain is NotificationOverlayView.Frag)
|
||||||
|
closeSegment();
|
||||||
|
else
|
||||||
|
navigate<NotificationOverlayView.Frag>();
|
||||||
}
|
}
|
||||||
|
|
||||||
buttonSearch.setOnClickListener {
|
buttonSearch.setOnClickListener {
|
||||||
|
|||||||
@@ -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,5 +1,6 @@
|
|||||||
package com.futo.platformplayer.states
|
package com.futo.platformplayer.states
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
@@ -122,7 +123,7 @@ class StateAnnouncement {
|
|||||||
//Special Announcements
|
//Special Announcements
|
||||||
fun registerPluginUpdate(oldConfig: SourcePluginConfig, newConfig: SourcePluginConfig): SessionAnnouncement {
|
fun registerPluginUpdate(oldConfig: SourcePluginConfig, newConfig: SourcePluginConfig): SessionAnnouncement {
|
||||||
val announcement = SessionAnnouncement(
|
val announcement = SessionAnnouncement(
|
||||||
"update-plugin-" + UUID.randomUUID().toString(),
|
"update-plugin-" + oldConfig.id + "-v" + newConfig.version,
|
||||||
"${newConfig.name} update v${newConfig.version} available!",
|
"${newConfig.name} update v${newConfig.version} available!",
|
||||||
"An update is available to upgrade from ${oldConfig.version} to ${newConfig.version}.",
|
"An update is available to upgrade from ${oldConfig.version} to ${newConfig.version}.",
|
||||||
AnnouncementType.SESSION,
|
AnnouncementType.SESSION,
|
||||||
@@ -130,19 +131,62 @@ class StateAnnouncement {
|
|||||||
null, null,oldConfig.id,
|
null, null,oldConfig.id,
|
||||||
newConfig?.absoluteIconUrl?.let { ImageVariable.fromUrl(it) }
|
newConfig?.absoluteIconUrl?.let { ImageVariable.fromUrl(it) }
|
||||||
).withExtraAction("Changelog", StateAnnouncement.ACTION_CHANGELOG, oldConfig.id);
|
).withExtraAction("Changelog", StateAnnouncement.ACTION_CHANGELOG, oldConfig.id);
|
||||||
|
announcement.extraObj = newConfig;
|
||||||
registerAnnouncementSession(announcement);
|
registerAnnouncementSession(announcement);
|
||||||
return announcement;
|
return announcement;
|
||||||
}
|
}
|
||||||
fun registerPluginUpdated(newConfig: SourcePluginConfig) {
|
fun registerPluginUpdated(newConfig: SourcePluginConfig) {
|
||||||
registerAnnouncementSession(SessionAnnouncement(
|
val announcement = SessionAnnouncement(
|
||||||
"updated-plugin-" + UUID.randomUUID().toString(),
|
"updated-plugin-" + newConfig.id + "-v" + newConfig.version,
|
||||||
"${newConfig.name} updated to v${newConfig.version}!",
|
"${newConfig.name} updated to v${newConfig.version}!",
|
||||||
"You have succesfully been updater to v${newConfig.version}.",
|
"You have succesfully been updated to v${newConfig.version}.",
|
||||||
AnnouncementType.SESSION,
|
AnnouncementType.SESSION,
|
||||||
null, "updates", null, null,
|
null, "updates", null, null,
|
||||||
null, null,null,
|
null, null,null,
|
||||||
newConfig?.absoluteIconUrl?.let { ImageVariable.fromUrl(it) }
|
newConfig?.absoluteIconUrl?.let { ImageVariable.fromUrl(it) }
|
||||||
).withExtraAction("Changelog", StateAnnouncement.ACTION_CHANGELOG, newConfig.id));
|
).withExtraAction("Changelog", StateAnnouncement.ACTION_CHANGELOG, newConfig.id);
|
||||||
|
announcement.extraObj = newConfig;
|
||||||
|
registerAnnouncementSession(announcement);
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tryAutoUpdatePlugin(oldConfig: SourcePluginConfig, newConfig: SourcePluginConfig) {
|
||||||
|
val context = StateApp.instance.contextOrNull;
|
||||||
|
if(context == null) {
|
||||||
|
registerPluginUpdate(oldConfig, newConfig);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val client = ManagedHttpClient();
|
||||||
|
client.setTimeout(10000);
|
||||||
|
val oldScript = StatePlugins.instance.getScript(oldConfig.id) ?: "";
|
||||||
|
val newScript = client.get(newConfig.absoluteScriptUrl)?.body?.string();
|
||||||
|
if(newScript.isNullOrEmpty()) {
|
||||||
|
Logger.w(TAG, "Auto-update for ${oldConfig.name}: no script returned, falling back to notification");
|
||||||
|
withContext(Dispatchers.Main) { registerPluginUpdate(oldConfig, newConfig); }
|
||||||
|
return@launch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!oldConfig.isLowRiskUpdate(oldScript, newConfig, newScript)) {
|
||||||
|
withContext(Dispatchers.Main) { registerPluginUpdate(oldConfig, newConfig); }
|
||||||
|
return@launch;
|
||||||
|
}
|
||||||
|
|
||||||
|
StatePlugins.instance.installPluginBackground(context, StateApp.instance.scope, newConfig, newScript,
|
||||||
|
{ _: String, _: Double -> },
|
||||||
|
{ ex ->
|
||||||
|
if(ex == null) {
|
||||||
|
registerPluginUpdated(newConfig);
|
||||||
|
} else {
|
||||||
|
Logger.e(TAG, "Auto-update for ${newConfig.name} failed during install", ex);
|
||||||
|
UIDialogs.appToast("Update for ${newConfig.name} failed\n" + ex.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch(ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Auto-update for ${oldConfig.name} failed", ex);
|
||||||
|
withContext(Dispatchers.Main) { registerPluginUpdate(oldConfig, newConfig); }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun registerLoading(title: String, description: String, icon: ImageVariable? = null, customId: String? = null): SessionAnnouncement {
|
fun registerLoading(title: String, description: String, icon: ImageVariable? = null, customId: String? = null): SessionAnnouncement {
|
||||||
@@ -282,7 +326,7 @@ class StateAnnouncement {
|
|||||||
when (actionId) {
|
when (actionId) {
|
||||||
ACTION_NEVER -> neverAnnouncement(item.id);
|
ACTION_NEVER -> neverAnnouncement(item.id);
|
||||||
ACTION_SOMETHING -> actionSomething();
|
ACTION_SOMETHING -> actionSomething();
|
||||||
ACTION_CHANGELOG -> actionChangelog(actionData);
|
ACTION_CHANGELOG -> actionChangelog(item, actionData);
|
||||||
ACTION_UPDATE_PLUGIN -> actionUpdatePlugin(item.id, actionData);
|
ACTION_UPDATE_PLUGIN -> actionUpdatePlugin(item.id, actionData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -314,27 +358,35 @@ class StateAnnouncement {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun actionChangelog(id: String?) {
|
private fun actionChangelog(item: Announcement, id: String?) {
|
||||||
if(id == null)
|
val context = StateApp.instance.contextOrNull ?: return;
|
||||||
|
|
||||||
|
val cached = (item as? SessionAnnouncement)?.extraObj as? SourcePluginConfig;
|
||||||
|
if(cached != null) {
|
||||||
|
showPluginChangelog(context, cached);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
StateApp.instance.contextOrNull?.let { context ->
|
if(id == null) return;
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
|
||||||
val plugin = StatePlugins.instance.getPlugin(id);
|
|
||||||
if (plugin == null)
|
|
||||||
return@launch
|
|
||||||
val update = StatePlugins.instance.checkForUpdates(plugin.config);
|
|
||||||
if(update == null)
|
|
||||||
return@launch;
|
|
||||||
|
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
UIDialogs.showChangelogDialog(context, update.version, update.changelog!!.filterKeys { it.toIntOrNull() != null }
|
val plugin = StatePlugins.instance.getPlugin(id) ?: return@launch;
|
||||||
.mapKeys { it.key.toInt() }
|
val update = StatePlugins.instance.checkForUpdates(plugin.config) ?: return@launch;
|
||||||
.mapValues { update.getChangelogString(it.key.toString()) ?: "" });
|
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?) {
|
private fun actionUpdatePlugin(notifId: String?, id: String?) {
|
||||||
if(id == null)
|
if(id == null)
|
||||||
return;
|
return;
|
||||||
@@ -363,7 +415,7 @@ class StateAnnouncement {
|
|||||||
if(newScript.isNullOrEmpty())
|
if(newScript.isNullOrEmpty())
|
||||||
throw IllegalStateException("No script found");
|
throw IllegalStateException("No script found");
|
||||||
|
|
||||||
if(true || plugin.config.isLowRiskUpdate(script, update, newScript)) {
|
if(plugin.config.isLowRiskUpdate(script, update, newScript)) {
|
||||||
StatePlugins.instance.installPluginBackground(context, StateApp.instance.scope, update, newScript,
|
StatePlugins.instance.installPluginBackground(context, StateApp.instance.scope, update, newScript,
|
||||||
{ text: String, progress: Double -> },
|
{ text: String, progress: Double -> },
|
||||||
{ ex ->
|
{ ex ->
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -100,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)
|
||||||
@@ -124,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)
|
||||||
@@ -143,11 +159,80 @@ class StateApp {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
fun isValidStorageUri(context: Context, uri: Uri?): Boolean {
|
|
||||||
if(uri == null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return context.contentResolver.persistedUriPermissions.any { it.uri == uri && it.isReadPermission && it.isWritePermission };
|
private fun hasPersistedStoragePermission(context: Context, uri: Uri?): Boolean {
|
||||||
|
if (uri == null)
|
||||||
|
return false
|
||||||
|
|
||||||
|
return context.contentResolver.persistedUriPermissions.any {
|
||||||
|
it.uri == uri && it.isReadPermission && it.isWritePermission
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAccessibleTreeDirectory(context: Context, uri: Uri?): DocumentFile? {
|
||||||
|
if (uri == null)
|
||||||
|
return null
|
||||||
|
|
||||||
|
if (!hasPersistedStoragePermission(context, uri))
|
||||||
|
return null
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val treeDocumentId = DocumentsContract.getTreeDocumentId(uri)
|
||||||
|
val treeDocumentUri = DocumentsContract.buildDocumentUriUsingTree(uri, treeDocumentId)
|
||||||
|
|
||||||
|
// Force a provider round-trip. If the directory was deleted, storage was removed,
|
||||||
|
// or the URI is otherwise stale, this usually fails here.
|
||||||
|
context.contentResolver.query(
|
||||||
|
treeDocumentUri,
|
||||||
|
arrayOf(
|
||||||
|
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||||
|
DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||||
|
DocumentsContract.Document.COLUMN_FLAGS
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)?.use { cursor ->
|
||||||
|
if (!cursor.moveToFirst())
|
||||||
|
return null
|
||||||
|
|
||||||
|
val mimeTypeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE)
|
||||||
|
if (mimeTypeIndex >= 0) {
|
||||||
|
val mimeType = cursor.getString(mimeTypeIndex)
|
||||||
|
if (mimeType != DocumentsContract.Document.MIME_TYPE_DIR)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
val document = DocumentFile.fromTreeUri(context, uri) ?: return null
|
||||||
|
|
||||||
|
if (!document.exists())
|
||||||
|
return null
|
||||||
|
if (!document.isDirectory)
|
||||||
|
return null
|
||||||
|
if (!document.canRead())
|
||||||
|
return null
|
||||||
|
if (!document.canWrite())
|
||||||
|
return null
|
||||||
|
|
||||||
|
document
|
||||||
|
}
|
||||||
|
catch (e: SecurityException) {
|
||||||
|
Logger.w(TAG, "Storage URI is no longer accessible [$uri]", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
catch (e: IllegalArgumentException) {
|
||||||
|
Logger.w(TAG, "Storage URI is invalid [$uri]", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
catch (e: Throwable) {
|
||||||
|
Logger.w(TAG, "Failed to validate storage URI [$uri]", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isValidStorageUri(context: Context, uri: Uri?): Boolean {
|
||||||
|
return getAccessibleTreeDirectory(context, uri) != null
|
||||||
}
|
}
|
||||||
|
|
||||||
//Scope
|
//Scope
|
||||||
@@ -307,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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,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);
|
||||||
|
|
||||||
@@ -576,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();
|
||||||
@@ -660,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");
|
||||||
@@ -679,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) {
|
||||||
@@ -716,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,
|
||||||
@@ -732,11 +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);
|
|
||||||
StateAnnouncement.instance.registerPluginUpdate(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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,16 +188,16 @@ 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");
|
||||||
@@ -136,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -24,6 +24,7 @@ import com.futo.platformplayer.states.StateApp
|
|||||||
import com.futo.platformplayer.views.AnyAdapterView
|
import com.futo.platformplayer.views.AnyAdapterView
|
||||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||||
import com.futo.platformplayer.views.LoaderView
|
import com.futo.platformplayer.views.LoaderView
|
||||||
|
import com.futo.platformplayer.views.NoResultsView
|
||||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -31,22 +32,29 @@ import kotlinx.coroutines.launch
|
|||||||
class NotificationOverlayView: ConstraintLayout {
|
class NotificationOverlayView: ConstraintLayout {
|
||||||
|
|
||||||
lateinit var recycler: RecyclerView;
|
lateinit var recycler: RecyclerView;
|
||||||
|
lateinit var emptyView: NoResultsView;
|
||||||
var adapterNotifications: AnyAdapterView<Announcement, ViewHolder>;
|
var adapterNotifications: AnyAdapterView<Announcement, ViewHolder>;
|
||||||
|
|
||||||
constructor(context: Context) : super(context) {
|
constructor(context: Context) : super(context) {
|
||||||
inflate(context, R.layout.overlay_notifications, this)
|
inflate(context, R.layout.overlay_notifications, this)
|
||||||
|
|
||||||
recycler = findViewById<RecyclerView>(R.id.container_notifications);
|
recycler = findViewById<RecyclerView>(R.id.container_notifications);
|
||||||
|
emptyView = findViewById<NoResultsView>(R.id.no_results);
|
||||||
adapterNotifications = recycler.asAny<Announcement, ViewHolder>(RecyclerView.VERTICAL, false, {
|
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?) {
|
fun onShown(parameter: Any?) {
|
||||||
val announcements = StateAnnouncement.instance.getVisibleAnnouncements();
|
val announcements = StateAnnouncement.instance.getVisibleAnnouncements();
|
||||||
adapterNotifications.adapter.setData(announcements);
|
adapterNotifications.adapter.setData(announcements);
|
||||||
|
|
||||||
|
if(announcements.any())
|
||||||
|
emptyView.isVisible = false;
|
||||||
|
else
|
||||||
|
emptyView.isVisible = true;
|
||||||
|
|
||||||
StateAnnouncement.instance.onAnnouncementChanged.subscribe(this) {
|
StateAnnouncement.instance.onAnnouncementChanged.subscribe(this) {
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
Logger.i("NotificationOverlayView", "Announcements Changed");
|
Logger.i("NotificationOverlayView", "Announcements Changed");
|
||||||
|
|||||||
-5
@@ -3,15 +3,10 @@ package com.futo.platformplayer.views.overlays.slideup
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.RelativeLayout
|
|
||||||
import android.widget.TextView
|
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import org.w3c.dom.Text
|
|
||||||
|
|
||||||
class SlideUpMenuTextInput : LinearLayout {
|
class SlideUpMenuTextInput : LinearLayout {
|
||||||
private lateinit var _root: LinearLayout;
|
private lateinit var _root: LinearLayout;
|
||||||
|
|||||||
@@ -20,11 +20,20 @@
|
|||||||
app:layout_constraintRight_toRightOf="parent"
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
tools:layout="@layout/fragment_overview_top_bar" />
|
tools:layout="@layout/fragment_overview_top_bar" />
|
||||||
|
|
||||||
|
<com.futo.platformplayer.views.announcements.UpdateBannerView
|
||||||
|
android:id="@+id/update_banner"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/fragment_top_bar"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent" />
|
||||||
|
|
||||||
<androidx.fragment.app.FragmentContainerView
|
<androidx.fragment.app.FragmentContainerView
|
||||||
android:id="@+id/fragment_main"
|
android:id="@+id/fragment_main"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
app:layout_constraintTop_toBottomOf="@id/fragment_top_bar"
|
app:layout_constraintTop_toBottomOf="@id/update_banner"
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
app:layout_constraintRight_toRightOf="parent"
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/set_a_password_for_your_daily_backup"
|
android:text="@string/enable_daily_backup"
|
||||||
android:textSize="14dp"
|
android:textSize="14dp"
|
||||||
android:textColor="@color/white"
|
android:textColor="@color/white"
|
||||||
android:fontFamily="@font/inter_regular"
|
android:fontFamily="@font/inter_regular"
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:textColor="#AAAAAA"
|
android:textColor="#AAAAAA"
|
||||||
android:fontFamily="@font/inter_regular"
|
android:fontFamily="@font/inter_regular"
|
||||||
android:text="@string/set_a_password_used_to_encrypt_your_daily_backup_that_is_written_to_external_storage"
|
android:text="@string/automatic_backup_unencrypted_explanation"
|
||||||
android:textAlignment="center"
|
android:textAlignment="center"
|
||||||
android:layout_marginStart="30dp"
|
android:layout_marginStart="30dp"
|
||||||
android:layout_marginEnd="30dp"
|
android:layout_marginEnd="30dp"
|
||||||
@@ -62,26 +62,6 @@
|
|||||||
android:layout_height="wrap_content">
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
</TextView>
|
</TextView>
|
||||||
<EditText
|
|
||||||
android:id="@+id/edit_password"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_marginLeft="30dp"
|
|
||||||
android:layout_marginTop="10dp"
|
|
||||||
android:layout_marginRight="30dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:singleLine="true"
|
|
||||||
android:inputType="textPassword"
|
|
||||||
android:hint="@string/backup_password" />
|
|
||||||
<EditText
|
|
||||||
android:id="@+id/edit_password2"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_marginLeft="30dp"
|
|
||||||
android:layout_marginTop="10dp"
|
|
||||||
android:layout_marginRight="30dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:singleLine="true"
|
|
||||||
android:inputType="textPassword"
|
|
||||||
android:hint="@string/repeat_password" />
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -107,7 +87,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/stop"
|
android:text="@string/disable"
|
||||||
android:textSize="14dp"
|
android:textSize="14dp"
|
||||||
android:textColor="@color/white"
|
android:textColor="@color/white"
|
||||||
android:fontFamily="@font/inter_regular"
|
android:fontFamily="@font/inter_regular"
|
||||||
@@ -128,7 +108,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/start"
|
android:text="@string/enable"
|
||||||
android:textSize="14dp"
|
android:textSize="14dp"
|
||||||
android:textColor="@color/white"
|
android:textColor="@color/white"
|
||||||
android:fontFamily="@font/inter_regular"
|
android:fontFamily="@font/inter_regular"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:background="@color/gray_1d">
|
android:background="@color/gray_1d">
|
||||||
|
|
||||||
@@ -13,9 +13,11 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:paddingTop="40dp">
|
android:paddingTop="40dp"
|
||||||
|
android:paddingBottom="24dp">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
|
android:id="@+id/image_icon"
|
||||||
android:layout_width="80dp"
|
android:layout_width="80dp"
|
||||||
android:layout_height="80dp"
|
android:layout_height="80dp"
|
||||||
app:srcCompat="@drawable/ic_lock" />
|
app:srcCompat="@drawable/ic_lock" />
|
||||||
@@ -31,42 +33,57 @@
|
|||||||
android:layout_marginStart="30dp"
|
android:layout_marginStart="30dp"
|
||||||
android:textAlignment="center"
|
android:textAlignment="center"
|
||||||
android:layout_marginEnd="30dp" />
|
android:layout_marginEnd="30dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/text_reason"
|
android:id="@+id/text_reason"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
android:textColor="#AAAAAA"
|
android:textColor="#AAAAAA"
|
||||||
android:fontFamily="@font/inter_regular"
|
android:fontFamily="@font/inter_regular"
|
||||||
android:text="@string/it_appears_an_automatic_backup_exists_on_your_device_if_you_would_like_to_restore_enter_your_backup_password"
|
android:text="@string/it_appears_an_automatic_backup_exists_on_your_device_if_you_would_like_to_restore_enter_your_backup_password"
|
||||||
android:textAlignment="center"
|
android:textAlignment="center"
|
||||||
android:layout_marginStart="30dp"
|
android:layout_marginStart="30dp"
|
||||||
android:layout_marginEnd="30dp"
|
android:layout_marginEnd="30dp"
|
||||||
android:textSize="10dp"
|
android:textSize="10dp" />
|
||||||
android:layout_height="wrap_content">
|
|
||||||
|
|
||||||
</TextView>
|
<LinearLayout
|
||||||
<EditText
|
android:id="@+id/password_container"
|
||||||
android:id="@+id/edit_password"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_marginLeft="30dp"
|
|
||||||
android:layout_marginTop="10dp"
|
|
||||||
android:layout_marginRight="30dp"
|
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:inputType="textPassword"
|
android:orientation="vertical">
|
||||||
android:singleLine="true"
|
|
||||||
android:hint="@string/backup_password" />
|
<EditText
|
||||||
|
android:id="@+id/edit_password"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_marginLeft="30dp"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:layout_marginRight="30dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textPassword"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:hint="@string/backup_password" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progress_restore"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
style="?android:attr/progressBarStyleLarge" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:layout_marginTop="28dp"
|
android:layout_marginTop="28dp">
|
||||||
android:layout_marginBottom="28dp">
|
|
||||||
|
|
||||||
<Space
|
<Space
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/button_cancel"
|
android:id="@+id/button_cancel"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
@@ -77,6 +94,7 @@
|
|||||||
android:fontFamily="@font/inter_regular"
|
android:fontFamily="@font/inter_regular"
|
||||||
android:textColor="@color/colorPrimary"
|
android:textColor="@color/colorPrimary"
|
||||||
android:background="@color/transparent" />
|
android:background="@color/transparent" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/button_start"
|
android:id="@+id/button_start"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
@@ -86,6 +104,7 @@
|
|||||||
android:clickable="true">
|
android:clickable="true">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
android:id="@+id/text_start"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/restore"
|
android:text="@string/restore"
|
||||||
@@ -99,4 +118,4 @@
|
|||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
android:maxLines="2"
|
android:maxLines="2"
|
||||||
app:layout_constraintLeft_toRightOf="@id/image_video_thumbnail"
|
app:layout_constraintLeft_toRightOf="@id/image_video_thumbnail"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintRight_toRightOf="parent"
|
app:layout_constraintRight_toLeftOf="@+id/button_trash"
|
||||||
app:layout_constraintBottom_toTopOf="@id/text_metadata"
|
app:layout_constraintBottom_toTopOf="@id/text_metadata"
|
||||||
android:layout_marginStart="10dp" />
|
android:layout_marginStart="10dp" />
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
app:layout_constraintTop_toBottomOf="@id/text_name"
|
app:layout_constraintTop_toBottomOf="@id/text_name"
|
||||||
app:layout_constraintLeft_toRightOf="@id/image_video_thumbnail"
|
app:layout_constraintLeft_toRightOf="@id/image_video_thumbnail"
|
||||||
app:layout_constraintRight_toRightOf="parent"
|
app:layout_constraintRight_toLeftOf="@+id/button_trash"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
android:layout_marginStart="10dp" />
|
android:layout_marginStart="10dp" />
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="1px"
|
android:layout_height="1px"
|
||||||
android:background="#181818" />
|
android:background="#181818" />
|
||||||
|
<com.futo.platformplayer.views.NoResultsView
|
||||||
|
android:id="@+id/no_results"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/container_notifications"
|
android:id="@+id/container_notifications"
|
||||||
app:layout_constraintTop_toBottomOf="@id/separator"
|
app:layout_constraintTop_toBottomOf="@id/separator"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
android:paddingEnd="12dp"
|
android:paddingEnd="12dp"
|
||||||
android:background="@drawable/background_pill"
|
android:background="@drawable/background_pill"
|
||||||
android:layout_marginEnd="6dp"
|
android:layout_marginEnd="6dp"
|
||||||
android:layout_marginTop="17dp"
|
android:layout_marginTop="8dp"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
android:id="@+id/root">
|
android:id="@+id/root">
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
@@ -36,4 +36,4 @@
|
|||||||
tools:text="Tag text" />
|
tools:text="Tag text" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content">
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/videodetail_up_next"
|
android:id="@+id/videodetail_up_next"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
@@ -23,18 +24,23 @@
|
|||||||
android:includeFontPadding="false"
|
android:includeFontPadding="false"
|
||||||
android:textSize="17dp"
|
android:textSize="17dp"
|
||||||
android:text="@string/up_next" />
|
android:text="@string/up_next" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/videodetail_queue_type"
|
android:id="@+id/videodetail_queue_type"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textColor="@color/gray_ac"
|
android:textColor="@color/gray_ac"
|
||||||
android:fontFamily="@font/inter_extra_light"
|
android:fontFamily="@font/inter_extra_light"
|
||||||
android:includeFontPadding="false"
|
android:includeFontPadding="false"
|
||||||
app:layout_constraintLeft_toRightOf="@id/videodetail_up_next"
|
app:layout_constraintLeft_toRightOf="@id/videodetail_up_next"
|
||||||
app:layout_constraintBottom_toBottomOf="@id/videodetail_up_next"
|
app:layout_constraintBottom_toBottomOf="@id/videodetail_up_next"
|
||||||
|
app:layout_constraintRight_toLeftOf="@id/videodetail_queue_position"
|
||||||
android:layout_marginLeft="6dp"
|
android:layout_marginLeft="6dp"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
android:textSize="14dp"
|
android:textSize="14dp"
|
||||||
android:text="@string/queue" />
|
android:text="@string/queue" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/videodetail_queue_position"
|
android:id="@+id/videodetail_queue_position"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
@@ -47,6 +53,7 @@
|
|||||||
android:layout_marginLeft="10dp"
|
android:layout_marginLeft="10dp"
|
||||||
android:textSize="12dp"
|
android:textSize="12dp"
|
||||||
tools:text="1/4" />
|
tools:text="1/4" />
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:id="@+id/root">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/background_16_round_4dp"
|
||||||
|
android:layout_marginLeft="10dp"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:layout_marginRight="10dp"
|
||||||
|
android:layout_marginBottom="0dp"
|
||||||
|
android:minHeight="40dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingLeft="12dp"
|
||||||
|
android:paddingTop="6dp"
|
||||||
|
android:paddingRight="8dp"
|
||||||
|
android:paddingBottom="6dp">
|
||||||
|
|
||||||
|
<ImageView android:id="@+id/icon_update"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:src="@drawable/ic_update"
|
||||||
|
android:layout_marginRight="10dp"
|
||||||
|
android:alpha="0.9"
|
||||||
|
android:importantForAccessibility="no" />
|
||||||
|
|
||||||
|
<TextView android:id="@+id/text_title"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
tools:text="Downloading v123 - 42%"
|
||||||
|
android:fontFamily="@font/inter_semibold"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1" />
|
||||||
|
|
||||||
|
<ProgressBar android:id="@+id/update_banner_progress"
|
||||||
|
style="?android:attr/progressBarStyleHorizontal"
|
||||||
|
android:layout_width="78dp"
|
||||||
|
android:layout_height="4dp"
|
||||||
|
android:layout_marginLeft="10dp"
|
||||||
|
android:layout_marginRight="4dp"
|
||||||
|
android:max="100"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
<FrameLayout android:id="@+id/button_action"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="28dp"
|
||||||
|
android:layout_marginLeft="10dp"
|
||||||
|
android:background="@drawable/background_button_primary_round_4dp">
|
||||||
|
|
||||||
|
<TextView android:id="@+id/text_action"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
tools:text="Install"
|
||||||
|
android:fontFamily="@font/inter_regular"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:paddingLeft="13dp"
|
||||||
|
android:paddingRight="13dp" />
|
||||||
|
</FrameLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="cast">Übertragen</string>
|
<string name="cast">Übertragen</string>
|
||||||
<string name="search">Suche</string>
|
<string name="search">Suche</string>
|
||||||
<string name="add_to_query">Zur Anfrage hinzufügen</string>
|
<string name="add_to_query">Zur Warteschlage hinzufügen</string>
|
||||||
<string name="thumbnail">Vorschaubild</string>
|
<string name="thumbnail">Vorschaubild</string>
|
||||||
<string name="channel_image">Kanalbild</string>
|
<string name="channel_image">Kanalbild</string>
|
||||||
<string name="add_to">Hinzufügen zu</string>
|
<string name="add_to">Hinzufügen zu</string>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<string name="loading">Lädt</string>
|
<string name="loading">Lädt</string>
|
||||||
<string name="retry">Wiederholen</string>
|
<string name="retry">Wiederholen</string>
|
||||||
<string name="cancel">Abbrechen</string>
|
<string name="cancel">Abbrechen</string>
|
||||||
<string name="failed_to_retrieve_data_are_you_connected">Datenabruf fehlgeschlagen, sind Sie verbunden?</string>
|
<string name="failed_to_retrieve_data_are_you_connected">Datenabruf fehlgeschlagen, sind Sie online?</string>
|
||||||
<string name="settings">Einstellungen</string>
|
<string name="settings">Einstellungen</string>
|
||||||
<string name="history">Verlauf</string>
|
<string name="history">Verlauf</string>
|
||||||
<string name="sources">Quellen</string>
|
<string name="sources">Quellen</string>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<string name="update">Aktualisieren</string>
|
<string name="update">Aktualisieren</string>
|
||||||
<string name="close">Schließen</string>
|
<string name="close">Schließen</string>
|
||||||
<string name="never">Nie</string>
|
<string name="never">Nie</string>
|
||||||
<string name="there_is_an_update_available_do_you_wish_to_update">Ein Update ist verfügbar. Möchten Sie aktualisieren?</string>
|
<string name="there_is_an_update_available_do_you_wish_to_update">Ein Update ist verfügbar, möchten Sie aktualisieren?</string>
|
||||||
<string name="downloading_update">Update wird heruntergeladen…</string>
|
<string name="downloading_update">Update wird heruntergeladen…</string>
|
||||||
<string name="installing_update">Update wird installiert…</string>
|
<string name="installing_update">Update wird installiert…</string>
|
||||||
<string name="done">Fertig</string>
|
<string name="done">Fertig</string>
|
||||||
@@ -36,10 +36,10 @@
|
|||||||
<string name="failed_to_update_with_error">Update fehlgeschlagen mit Fehler</string>
|
<string name="failed_to_update_with_error">Update fehlgeschlagen mit Fehler</string>
|
||||||
<string name="general_failure">Allgemeiner Fehler</string>
|
<string name="general_failure">Allgemeiner Fehler</string>
|
||||||
<string name="aborted">Abgebrochen</string>
|
<string name="aborted">Abgebrochen</string>
|
||||||
<string name="blocked">Blockiert</string>
|
<string name="blocked">Der Vorgang ist fehlgeschlagen, weil er blockiert wurde</string>
|
||||||
<string name="conflict">Konflikt</string>
|
<string name="conflict">Der Vorgang ist fehlgeschlagen, weil er mit einem anderen, bereits auf dem Gerät installierten Paket kollidiert (oder inkonsistent damit ist)</string>
|
||||||
<string name="incompatible">Inkompatibel</string>
|
<string name="incompatible">Der Vorgang ist fehlgeschlagen, weil er mit diesem Gerät grundsätzlich nicht kompatibel ist</string>
|
||||||
<string name="invalid">Ungültig</string>
|
<string name="invalid">Der Vorgang ist fehlgeschlagen, weil eine oder mehrere APKs ungültig waren</string>
|
||||||
<string name="not_enough_storage">Nicht genügend Speicherplatz</string>
|
<string name="not_enough_storage">Nicht genügend Speicherplatz</string>
|
||||||
<string name="live_capitalized">LIVE</string>
|
<string name="live_capitalized">LIVE</string>
|
||||||
<string name="live">Live</string>
|
<string name="live">Live</string>
|
||||||
@@ -93,11 +93,11 @@
|
|||||||
<string name="add_source">Quelle hinzufügen</string>
|
<string name="add_source">Quelle hinzufügen</string>
|
||||||
<string name="repository_url">Repository-URL</string>
|
<string name="repository_url">Repository-URL</string>
|
||||||
<string name="script_url">Skript-URL</string>
|
<string name="script_url">Skript-URL</string>
|
||||||
<string name="source_permissions_explanation">Dies sind die Berechtigungen, die das Plugin zur Funktion benötigt</string>
|
<string name="source_permissions_explanation">Dies sind die Berechtigungen, die das Plugin zum Funktionieren benötigt</string>
|
||||||
<string name="source_explain_eval_access">Das Plugin hat Zugang zur Eval-Kapazität</string>
|
<string name="source_explain_eval_access">Das Plugin hat Zugang zur Eval-Kapazität</string>
|
||||||
<string name="source_explain_script_url">Das Plugin hat Zugriff auf die folgenden Domains</string>
|
<string name="source_explain_script_url">Das Plugin hat Zugriff auf die folgenden Domains</string>
|
||||||
<string name="scan_qr">QR-Code scannen</string>
|
<string name="scan_qr">QR-Code scannen</string>
|
||||||
<string name="scan_qr_explain">Scannen Sie einen QR-Code, um zu installieren</string>
|
<string name="scan_qr_explain">Scannen Sie zum Installieren einen QR-Code</string>
|
||||||
<string name="enter_url">URL eingeben</string>
|
<string name="enter_url">URL eingeben</string>
|
||||||
<string name="install">Installieren</string>
|
<string name="install">Installieren</string>
|
||||||
<string name="no_devices_found_it_may_take_a_while_for_your_device_to_show_up_please_be_patient">Keine Geräte gefunden. Es kann eine Weile dauern, bis Ihr Gerät angezeigt wird. Bitte haben Sie Geduld</string>
|
<string name="no_devices_found_it_may_take_a_while_for_your_device_to_show_up_please_be_patient">Keine Geräte gefunden. Es kann eine Weile dauern, bis Ihr Gerät angezeigt wird. Bitte haben Sie Geduld</string>
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
<string name="permissions">Berechtigungen</string>
|
<string name="permissions">Berechtigungen</string>
|
||||||
<string name="security_warnings">Sicherheitswarnungen</string>
|
<string name="security_warnings">Sicherheitswarnungen</string>
|
||||||
<string name="these_are_warnings_of_plugin_behavior_and_implementation">Dies sind Warnungen über das Verhalten und die Implementierung von Plugins</string>
|
<string name="these_are_warnings_of_plugin_behavior_and_implementation">Dies sind Warnungen über das Verhalten und die Implementierung von Plugins</string>
|
||||||
<string name="please_enter_the_captcha_and_close_when_finished">Bitte geben Sie das Captcha ein und schließen Sie, wenn Sie fertig sind</string>
|
<string name="please_enter_the_captcha_and_close_when_finished">Bitte das Captcha eingeben und schließen, wenn fertig</string>
|
||||||
<string name="close_capitalized">SCHLIESSEN</string>
|
<string name="close_capitalized">SCHLIESSEN</string>
|
||||||
<string name="submit">Absenden</string>
|
<string name="submit">Absenden</string>
|
||||||
<string name="restart">Neustart</string>
|
<string name="restart">Neustart</string>
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
<string name="i_already_paid">Ich habe bereits bezahlt</string>
|
<string name="i_already_paid">Ich habe bereits bezahlt</string>
|
||||||
<string name="memberships">Mitgliedschaften</string>
|
<string name="memberships">Mitgliedschaften</string>
|
||||||
<string name="a_monthly_recurring_payment_with_often">Eine monatlich wiederkehrende Zahlung mit häufig</string>
|
<string name="a_monthly_recurring_payment_with_often">Eine monatlich wiederkehrende Zahlung mit häufig</string>
|
||||||
<string name="additional_perks">zusätzliche Vorteile.</string>
|
<string name="additional_perks">zusätzlichen Vorteilen.</string>
|
||||||
<string name="a_one_time_payment_to_support_the_creator">Eine einmalige Zahlung zur Unterstützung des Erstellers</string>
|
<string name="a_one_time_payment_to_support_the_creator">Eine einmalige Zahlung zur Unterstützung des Erstellers</string>
|
||||||
<string name="donation">Spende</string>
|
<string name="donation">Spende</string>
|
||||||
<string name="downloading">Herunterladen</string>
|
<string name="downloading">Herunterladen</string>
|
||||||
@@ -243,11 +243,11 @@
|
|||||||
<string name="announcement">Ankündigung</string>
|
<string name="announcement">Ankündigung</string>
|
||||||
<string name="attempt_to_utilize_byte_ranges">Versuch, Byte-Bereiche zu nutzen</string>
|
<string name="attempt_to_utilize_byte_ranges">Versuch, Byte-Bereiche zu nutzen</string>
|
||||||
<string name="auto_update">Automatische Aktualisierung</string>
|
<string name="auto_update">Automatische Aktualisierung</string>
|
||||||
<string name="automatic_backup">Automatisches Backup</string>
|
<string name="automatic_backup">Automatische Sicherung</string>
|
||||||
<string name="background_behavior">Hintergrundverhalten</string>
|
<string name="background_behavior">Hintergrundverhalten</string>
|
||||||
<string name="background_update">Hintergrundaktualisierung</string>
|
<string name="background_update">Hintergrundaktualisierung</string>
|
||||||
<string name="background_download">Hintergrunddownload</string>
|
<string name="background_download">Hintergrunddownload</string>
|
||||||
<string name="backup">Backup</string>
|
<string name="backup">Sicherung</string>
|
||||||
<string name="browsing">Browsen</string>
|
<string name="browsing">Browsen</string>
|
||||||
<string name="byte_range_concurrency">Byte-Bereichs-Gleichzeitigkeit</string>
|
<string name="byte_range_concurrency">Byte-Bereichs-Gleichzeitigkeit</string>
|
||||||
<string name="byte_range_download">Byte-Bereichs-Download</string>
|
<string name="byte_range_download">Byte-Bereichs-Download</string>
|
||||||
@@ -265,7 +265,7 @@
|
|||||||
<string name="clears_in_app_browser_cookies">In-App-Browser-Cookies löschen</string>
|
<string name="clears_in_app_browser_cookies">In-App-Browser-Cookies löschen</string>
|
||||||
<string name="configure_browsing_behavior">Browsing-Verhalten konfigurieren</string>
|
<string name="configure_browsing_behavior">Browsing-Verhalten konfigurieren</string>
|
||||||
<string name="configure_casting">Casting konfigurieren</string>
|
<string name="configure_casting">Casting konfigurieren</string>
|
||||||
<string name="configure_daily_backup_in_case_of_catastrophic_failure">Tägliches Backup im Falle eines katastrophalen Ausfalls konfigurieren</string>
|
<string name="configure_daily_backup_in_case_of_catastrophic_failure">Tägliche Sicherung im Falle eines katastrophalen Ausfalls konfigurieren</string>
|
||||||
<string name="configure_downloading_of_videos">Herunterladen von Videos konfigurieren</string>
|
<string name="configure_downloading_of_videos">Herunterladen von Videos konfigurieren</string>
|
||||||
<string name="configure_how_your_home_tab_works_and_feels">Konfigurieren, wie Ihr Start-Tab funktioniert und sich anfühlt</string>
|
<string name="configure_how_your_home_tab_works_and_feels">Konfigurieren, wie Ihr Start-Tab funktioniert und sich anfühlt</string>
|
||||||
<string name="configure_how_your_subscriptions_works_and_feels">Konfigurieren, wie Ihre Abonnements funktionieren und sich anfühlen</string>
|
<string name="configure_how_your_subscriptions_works_and_feels">Konfigurieren, wie Ihre Abonnements funktionieren und sich anfühlen</string>
|
||||||
@@ -340,7 +340,7 @@
|
|||||||
<string name="clear_all_downloaded">Alle Downloads löschen</string>
|
<string name="clear_all_downloaded">Alle Downloads löschen</string>
|
||||||
<string name="clear_downloads">Downloads löschen</string>
|
<string name="clear_downloads">Downloads löschen</string>
|
||||||
<string name="clear_all_cookies_from_the_cookieManager">Alle Cookies aus dem CookieManager löschen</string>
|
<string name="clear_all_cookies_from_the_cookieManager">Alle Cookies aus dem CookieManager löschen</string>
|
||||||
<string name="crash_me">Stürzen Sie mich ab</string>
|
<string name="crash_me">Anwendung abstürzen lassen</string>
|
||||||
<string name="crashes_the_application_on_purpose">Absichtliches Abstürzen der Anwendung</string>
|
<string name="crashes_the_application_on_purpose">Absichtliches Abstürzen der Anwendung</string>
|
||||||
<string name="delete_announcements">Ankündigungen löschen</string>
|
<string name="delete_announcements">Ankündigungen löschen</string>
|
||||||
<string name="delete_unresolved">Nicht gelöste löschen</string>
|
<string name="delete_unresolved">Nicht gelöste löschen</string>
|
||||||
@@ -562,9 +562,9 @@
|
|||||||
<string name="signature_is_invalid">Die Signatur ist ungültig</string>
|
<string name="signature_is_invalid">Die Signatur ist ungültig</string>
|
||||||
<string name="no_signature_available">Keine Signatur verfügbar</string>
|
<string name="no_signature_available">Keine Signatur verfügbar</string>
|
||||||
<string name="unsubscribed_from">"Abonnement gekündigt bei "</string>
|
<string name="unsubscribed_from">"Abonnement gekündigt bei "</string>
|
||||||
<string name="you_don_t_have_any_automatic_backups">Sie haben keine automatischen Backups</string>
|
<string name="you_don_t_have_any_automatic_backups">Sie haben keine automatischen Sicherungen</string>
|
||||||
<string name="an_old_backup_is_available">Ein altes Backup ist verfügbar</string>
|
<string name="an_old_backup_is_available">Ein alte Sicherung ist verfügbar</string>
|
||||||
<string name="would_you_like_to_restore_this_backup">Möchten Sie dieses Backup wiederherstellen?</string>
|
<string name="would_you_like_to_restore_this_backup">Möchten Sie diese Sicherung wiederherstellen?</string>
|
||||||
<string name="override">Überschreiben</string>
|
<string name="override">Überschreiben</string>
|
||||||
<string name="data_retry">Datenwiederholung</string>
|
<string name="data_retry">Datenwiederholung</string>
|
||||||
<string name="no_downloads_available">Keine Downloads verfügbar</string>
|
<string name="no_downloads_available">Keine Downloads verfügbar</string>
|
||||||
@@ -592,13 +592,13 @@
|
|||||||
<string name="the_playlist_will_restart_after_the_video_is_finished">Die Playlist wird nach dem Ende des Videos neu gestartet</string>
|
<string name="the_playlist_will_restart_after_the_video_is_finished">Die Playlist wird nach dem Ende des Videos neu gestartet</string>
|
||||||
<string name="end_of_playlist_reached">Ende der Playlist erreicht</string>
|
<string name="end_of_playlist_reached">Ende der Playlist erreicht</string>
|
||||||
<string name="enter_url_explain">Geben Sie eine URL ein, um die Plugin-Konfiguration von dort zu laden.</string>
|
<string name="enter_url_explain">Geben Sie eine URL ein, um die Plugin-Konfiguration von dort zu laden.</string>
|
||||||
<string name="buy_text">Es ist weder einfache noch günstige, eine App wie Grayjay, zu Entwikeln und zu Warten…</string>
|
<string name="buy_text">Grayjay ist nicht einfach oder billig zu entwickeln und zu warten. Wir haben Vollzeit-Ingenieure, die an der App und an den sie umgebenden Systemen arbeiten. Und wird wahrscheinlich nicht so bald wieder Geld einbringen, wenn überhaupt.\n\nFUTOs Mission ist es, dass Open-Source-Software und nicht-bösartige Software-Geschäftspraktiken eine nachhaltige Einkommensquelle für Projekte und ihre Entwickler werden. Aus diesem Grund sind wir dafür, dass die Nutzer tatsächlich für die Software bezahlen.\n\nDeshalb möchte Grayjay, dass Sie für die Software bezahlen.</string>
|
||||||
<string name="an_uncaught_exception_was_thrown_we_re_sorry_for_the_inconvenience">Ein unabgefangene Ausnahme wurde ausgelöst, es tut uns leid für die Unannehmlichkeiten.</string>
|
<string name="an_uncaught_exception_was_thrown_we_re_sorry_for_the_inconvenience">Ein unabgefangene Ausnahme wurde ausgelöst, es tut uns leid für die Unannehmlichkeiten.</string>
|
||||||
<string name="set_a_password_for_your_daily_backup">Legen Sie ein Passwort für Ihr tägliches Backup fest</string>
|
<string name="set_a_password_for_your_daily_backup">Ein Passwort für Ihr tägliche Sicherung festlegen</string>
|
||||||
<string name="items_require_migration_or_are_corrupted_would_you_like_to_restore_them_from_backup_now">Elemente erfordern eine Migration oder sind beschädigt. Möchten Sie sie jetzt aus dem Backup wiederherstellen?</string>
|
<string name="items_require_migration_or_are_corrupted_would_you_like_to_restore_them_from_backup_now">Elemente erfordern eine Migration oder sind beschädigt. Möchten Sie sie jetzt aus der Sicherung wiederherstellen?</string>
|
||||||
<string name="recently_used_playlist">Kürzlich verwendete Playlist</string>
|
<string name="recently_used_playlist">Kürzlich verwendete Playlist</string>
|
||||||
<string name="make_a_backup_of_your_identity">Ein Backup Ihrer Identität erstellen</string>
|
<string name="make_a_backup_of_your_identity">Eine Sicherung Ihrer Identität erstellen</string>
|
||||||
<string name="restore_a_previous_automatic_backup">Ein vorheriges automatisches Backup wiederherstellen</string>
|
<string name="restore_a_previous_automatic_backup">Eine vorherige automatische Sicherung wiederherstellen</string>
|
||||||
<string name="embedded_plugins_reinstalled_a_reboot_is_recommended">Embedded-Plugins neu installiert, ein Neustart der App ist empfohlen</string>
|
<string name="embedded_plugins_reinstalled_a_reboot_is_recommended">Embedded-Plugins neu installiert, ein Neustart der App ist empfohlen</string>
|
||||||
<string name="injects_a_test_source_config_local_into_v8">Fügt eine Testquellenkonfiguration (lokal) in V8 ein</string>
|
<string name="injects_a_test_source_config_local_into_v8">Fügt eine Testquellenkonfiguration (lokal) in V8 ein</string>
|
||||||
<string name="do_you_want_to_convert_channel_channelname_to_a_playlist">Möchten Sie den Kanal {channelName} in eine Playlist umwandeln?</string>
|
<string name="do_you_want_to_convert_channel_channelname_to_a_playlist">Möchten Sie den Kanal {channelName} in eine Playlist umwandeln?</string>
|
||||||
@@ -607,6 +607,255 @@
|
|||||||
<string name="failed_to_retrieve_playlists">Abrufen von Playlisten fehlgeschlagen.</string>
|
<string name="failed_to_retrieve_playlists">Abrufen von Playlisten fehlgeschlagen.</string>
|
||||||
<string name="subscribed_to">"Abonniert bei "</string>
|
<string name="subscribed_to">"Abonniert bei "</string>
|
||||||
<string name="select_your_pins_in_order">Wählen Sie Ihre Pins in Reihenfolge aus</string>
|
<string name="select_your_pins_in_order">Wählen Sie Ihre Pins in Reihenfolge aus</string>
|
||||||
|
<string name="a_store_by_the_creator">Ein Shop vom Ersteller</string>
|
||||||
|
<string name="add_creator">Ersteller hinzufügen</string>
|
||||||
|
<string name="add_to_history">Zum Verlauf hinzufügen</string>
|
||||||
|
<string name="add_to_new_playlist">Zu neuer Playlist hinzufügen</string>
|
||||||
|
<string name="allow_all_certificates">Alle Zertifikate zulassen</string>
|
||||||
|
<string name="allow_all_certificates_warning">Dies birgt das Risiko, Ihren gesamten Grayjay-Netzwerkverkehr offenzulegen.</string>
|
||||||
|
<string name="allow_developer_submit">Entwickler-Übermittlungen zulassen</string>
|
||||||
|
<string name="allow_developer_submit_description">Erlaubt dem Entwickler, Daten an seinen Server zu senden. Seien Sie vorsichtig, da dies sensible Daten enthalten könnte.</string>
|
||||||
|
<string name="allow_developer_submit_warning">Stellen Sie sicher, dass Sie dem Entwickler vertrauen. Er könnte Zugriff auf sensible Daten erhalten. Aktivieren Sie dies nur, wenn Sie dem Entwickler bei der Behebung eines Fehlers helfen möchten.</string>
|
||||||
|
<string name="allow_full_screen_portrait">Vollbild im Hochformat beim Ansehen von horizontalen Videos erlauben</string>
|
||||||
|
<string name="allow_grayjay_to_handle_links">Grayjay erlauben, Links zu verarbeiten</string>
|
||||||
|
<string name="allow_ipv6">IPV6 erlauben</string>
|
||||||
|
<string name="allow_ipv6_description">Wenn das Übertragen über IPV6 erlaubt ist, kann dies in einigen Netzwerken Probleme verursachen</string>
|
||||||
|
<string name="allow_under_cutout">Video unter Bildschirmausschnitt erlauben</string>
|
||||||
|
<string name="allow_under_cutout_description">Video erlauben, im Vollbildmodus unter den Bildschirmausschnitt zu gelangen.\nNeustart möglicherweise erforderlich</string>
|
||||||
|
<string name="already_queued">Bereits in der Warteschlange</string>
|
||||||
|
<string name="always_allow_reverse_landscape_auto_rotate">Automatische Drehung ins umgekehrte Querformat immer erlauben</string>
|
||||||
|
<string name="always_allow_reverse_landscape_auto_rotate_description">Es erfolgt immer eine automatische Drehung zwischen den beiden Querformatausrichtungen im Vollbildmodus, auch wenn Sie die automatische Drehung in den Systemeinstellungen deaktivieren.</string>
|
||||||
|
<string name="always_proxy_requests">Anfragen immer über Proxy leiten</string>
|
||||||
|
<string name="always_proxy_requests_description">Anfragen beim Übertragen von Daten über das Gerät immer über einen Proxy leiten.</string>
|
||||||
|
<string name="always_reload_from_cache">Immer aus dem Cache neu laden</string>
|
||||||
|
<string name="always_reload_from_cache_description">Dies wird nicht empfohlen, ist aber eine mögliche Problemumgehung für einige Probleme.</string>
|
||||||
|
<string name="app_language">App-Sprache</string>
|
||||||
|
<string name="automatic_update_setting">Automatische Aktualisierung</string>
|
||||||
|
<string name="automatic_update_setting_description">Beim Start automatisch aktualisieren, wenn keine Berechtigungen geändert wurden und das Plugin aktiviert ist</string>
|
||||||
|
<string name="autoplay">Nächstes Video standardmäßig automatisch abspielen</string>
|
||||||
|
<string name="autoplay_description">Die automatische Wiedergabe des nächsten Videos ist standardmäßig aktiviert, wenn Sie ein Video ansehen</string>
|
||||||
|
<string name="background_switch_audio">Im Hintergrund zu Audio wechseln</string>
|
||||||
|
<string name="bad_reputation_comments_fading">Ausblenden von Kommentaren mit schlechtem Ruf</string>
|
||||||
|
<string name="bad_reputation_comments_fading_description">Ob Kommentare mit sehr schlechtem Ruf ausgeblendet werden sollen. Das Deaktivieren kann die Benutzererfahrung verschlechtern.</string>
|
||||||
|
<string name="brightness_slider">Helligkeitsregler</string>
|
||||||
|
<string name="brightness_slider_descr">Wischgeste zum Ändern der Helligkeit aktivieren</string>
|
||||||
|
<string name="broadcast">Übertragung</string>
|
||||||
|
<string name="broadcast_description">Gerät erlauben, Anwesenheit zu übertragen</string>
|
||||||
|
<string name="bypass_rotation_prevention">Drehungsverhinderung umgehen</string>
|
||||||
|
<string name="bypass_rotation_prevention_description">Ermöglicht Drehung in Nicht-Video-Ansichten.\nWARNUNG: Nicht dafür ausgelegt</string>
|
||||||
|
<string name="bypass_rotation_prevention_warning">Dies kann zu unerwartetem Verhalten führen und ist größtenteils ungetestet.</string>
|
||||||
|
<string name="cache">Cache</string>
|
||||||
|
<string name="can_be_disabled_when_you_are_experiencing_issues">Kann bei Problemen deaktiviert werden</string>
|
||||||
|
<string name="cd_app_icon">App-Symbol</string>
|
||||||
|
<string name="cd_button_add">Hinzufügen</string>
|
||||||
|
<string name="cd_button_add_to_watch_later">Zu „Später ansehen“ hinzufügen</string>
|
||||||
|
<string name="cd_button_autoplay">Autoplay</string>
|
||||||
|
<string name="cd_button_back">Zurück-Schaltfläche</string>
|
||||||
|
<string name="cd_button_clear_search">Suche löschen</string>
|
||||||
|
<string name="cd_button_close">Schließen</string>
|
||||||
|
<string name="cd_button_create_playlist">Playlist erstellen</string>
|
||||||
|
<string name="cd_button_delete">Löschen</string>
|
||||||
|
<string name="cd_button_download">Herunterladen</string>
|
||||||
|
<string name="cd_button_edit">Bearbeiten</string>
|
||||||
|
<string name="cd_button_filter">Filter</string>
|
||||||
|
<string name="cd_button_fullscreen">Vollbild</string>
|
||||||
|
<string name="cd_button_help">Hilfe</string>
|
||||||
|
<string name="cd_button_loop">Wiederholen</string>
|
||||||
|
<string name="cd_button_minimize">Minimieren</string>
|
||||||
|
<string name="cd_button_next">Weiter</string>
|
||||||
|
<string name="cd_button_pause">Pause</string>
|
||||||
|
<string name="cd_button_play">Wiedergabe</string>
|
||||||
|
<string name="cd_button_previous">Zurück</string>
|
||||||
|
<string name="cd_button_replies">Antworten</string>
|
||||||
|
<string name="cd_button_rotate_lock">Drehung sperren</string>
|
||||||
|
<string name="cd_button_scan_qr">QR-Code scannen</string>
|
||||||
|
<string name="cd_button_search">Suchen</string>
|
||||||
|
<string name="cd_button_settings">Einstellungen</string>
|
||||||
|
<string name="cd_button_share">Teilen</string>
|
||||||
|
<string name="cd_button_stop">Stopp</string>
|
||||||
|
<string name="cd_button_subscribe">Abonnieren</string>
|
||||||
|
<string name="cd_cast_button">Übertragen-Schaltfläche</string>
|
||||||
|
<string name="cd_creator_thumbnail">Ersteller-Vorschaubild</string>
|
||||||
|
<string name="cd_donation_amount">Spendenbetrag</string>
|
||||||
|
<string name="cd_donation_author_image">Bild des Spendenautors</string>
|
||||||
|
<string name="cd_download_indicator">Download-Anzeige</string>
|
||||||
|
<string name="cd_drag_drop">Ziehen und Ablegen</string>
|
||||||
|
<string name="cd_edit_image">Bild bearbeiten</string>
|
||||||
|
<string name="cd_icon_history">Verlaufssymbol</string>
|
||||||
|
<string name="cd_image_device">Gerätesymbol</string>
|
||||||
|
<string name="cd_image_dislike_icon">Nicht mögen</string>
|
||||||
|
<string name="cd_image_group">Gruppenbild</string>
|
||||||
|
<string name="cd_image_like_icon">Mögen</string>
|
||||||
|
<string name="cd_image_loader">Ladeanzeige</string>
|
||||||
|
<string name="cd_image_polycentric">Polycentric-Profilbild ändern</string>
|
||||||
|
<string name="cd_incognito_button">Inkognito-Schaltfläche</string>
|
||||||
|
<string name="cd_minimize_close">Schließen</string>
|
||||||
|
<string name="cd_minimize_pause">Pause</string>
|
||||||
|
<string name="cd_minimize_play">Wiedergabe</string>
|
||||||
|
<string name="cd_platform_indicator">Plattformanzeige</string>
|
||||||
|
<string name="cd_search_icon">Suchsymbol</string>
|
||||||
|
<string name="cd_thumbnail_player_unmute">Stummschaltung aufheben</string>
|
||||||
|
<string name="cd_update_spinner">Aktualisierungs-Ladekreis</string>
|
||||||
|
<string name="changelog_plugin_description">Zeigt verfügbare Änderungsprotokolle für aktuelle und frühere Versionen an</string>
|
||||||
|
<string name="changing_this_field_requires_restart">Das Ändern dieses Feldes erfordert einen Neustart der App.</string>
|
||||||
|
<string name="channel">Kanal</string>
|
||||||
|
<string name="chapter_update_fps_description">Genauigkeit der Kapitelaktualisierung ändern, höher könnte mehr Leistung kosten</string>
|
||||||
|
<string name="chapter_update_fps_title">Kapitelaktualisierungs-FPS</string>
|
||||||
|
<string name="chapters">Kapitel</string>
|
||||||
|
<string name="check_disabled_plugin_updates">Deaktivierte Plugins auf Updates prüfen</string>
|
||||||
|
<string name="check_disabled_plugin_updates_description">Deaktivierte Plugins auf Updates prüfen</string>
|
||||||
|
<string name="check_for_updates_setting">Auf Updates prüfen</string>
|
||||||
|
<string name="check_for_updates_setting_description">Ob ein Plugin beim Start auf Updates geprüft werden soll</string>
|
||||||
|
<string name="check_to_see_if_an_update_is_available">Prüfen, ob ein Update verfügbar ist.</string>
|
||||||
|
<string name="clear_channel_cache">Kanal-Cache leeren</string>
|
||||||
|
<string name="clear_channel_cache_description">Löscht alle Inhalte aus dem Cache der abonnierten Kanäle</string>
|
||||||
|
<string name="clear_external_downloads_directory">Externes Download-Verzeichnis leeren</string>
|
||||||
|
<string name="clear_hidden">Ausgeblendete Elemente löschen</string>
|
||||||
|
<string name="clear_hidden_description">Entfernt alle ausgeblendeten Ersteller und Videos und zeigt sie wieder an</string>
|
||||||
|
<string name="clear_the_external_storage_for_download_files">Externen Speicher für heruntergeladene Dateien leeren</string>
|
||||||
|
<string name="click_to_go_to_battery_optimization_settings_disabling_battery_optimization_will_prevent_the_os_from_killing_media_sessions">Klicken, um zu den Akkuoptimierungseinstellungen zu gelangen. Das Deaktivieren der Akkuoptimierung verhindert, dass das Betriebssystem Mediensitzungen beendet.</string>
|
||||||
|
<string name="comments_description">Der Kommentarbereich unter dem Inhalt</string>
|
||||||
|
<string name="config_url">Konfigurations-URL</string>
|
||||||
|
<string name="configure_if_historical_time_bar_should_be_shown">Konfigurieren, ob historische Zeitbalken angezeigt werden sollen</string>
|
||||||
|
<string name="connect_discovered">Gefundene verbinden</string>
|
||||||
|
<string name="connect_discovered_description">Gerät erlauben, nach bekannten gekoppelten Geräten zu suchen und eine Verbindung herzustellen</string>
|
||||||
|
<string name="connect_last">Letzte Verbindung versuchen</string>
|
||||||
|
<string name="connect_last_description">Gerät erlauben, sich automatisch mit dem zuletzt bekannten zu verbinden</string>
|
||||||
|
<string name="create_new_subgroup">Neue Gruppe erstellen</string>
|
||||||
|
<string name="current_promotions_by_this_creator">Aktuelle Werbeaktionen dieses Erstellers</string>
|
||||||
|
<string name="default_comment_section">Standard-Kommentarbereich</string>
|
||||||
|
<string name="default_recommendations">Empfehlungen als Standard</string>
|
||||||
|
<string name="default_recommendations_description">Empfehlungen standardmäßig anstelle von Kommentaren anzeigen.</string>
|
||||||
|
<string name="delete_watchlist_on_finish">Aus „Später ansehen“ löschen, wenn angesehen</string>
|
||||||
|
<string name="delete_watchlist_on_finish_description">Nachdem Sie ein Video verlassen, das Sie größtenteils angesehen haben, wird es aus „Später ansehen“ entfernt.</string>
|
||||||
|
<string name="dev_info_channel_cache_size">Kanal-Cache-Größe (Start)</string>
|
||||||
|
<string name="disable_battery_optimization">Akkuoptimierung deaktivieren</string>
|
||||||
|
<string name="do_not_ask_again">Nicht erneut fragen</string>
|
||||||
|
<string name="do_you_want_to_see_the_tutorials_you_can_find_them_at_any_time_through_the_more_button">Möchten Sie die Tutorials sehen? Sie können sie jederzeit über die Mehr-Schaltfläche finden.</string>
|
||||||
|
<string name="enable_polycentric">Polycentric aktivieren</string>
|
||||||
|
<string name="enabled_description">Funktion aktivieren</string>
|
||||||
|
<string name="failed_to_parse_text_file">Fehler beim Parsen der Textdatei</string>
|
||||||
|
<string name="failed_to_show_settings">Einstellungen konnten nicht angezeigt werden</string>
|
||||||
|
<string name="fcast">FCast</string>
|
||||||
|
<string name="fcast_technical_documentation">Technische Dokumentation für FCast</string>
|
||||||
|
<string name="fcast_website">FCast-Webseite</string>
|
||||||
|
<string name="fetch_on_tab_opened">Beim Öffnen des Tabs abrufen</string>
|
||||||
|
<string name="fetch_on_tab_opened_description">Neue Ergebnisse abrufen, wenn der Tab geöffnet wird (wenn noch keine Ergebnisse vorhanden sind, wird das Deaktivieren nicht empfohlen, es sei denn, Sie haben Probleme)</string>
|
||||||
|
<string name="full_autorotate_lock">Vollständige Sperre der automatischen Drehung</string>
|
||||||
|
<string name="full_autorotate_lock_description">Verhindert jede Drehung, während die Rotationssperre aktiviert ist (auch das Wechseln zwischen Querformat und umgekehrtem Querformat).</string>
|
||||||
|
<string name="full_screen_portrait">Vollbild Hochformat</string>
|
||||||
|
<string name="general">Allgemein</string>
|
||||||
|
<string name="gesture_controls">Gestensteuerung</string>
|
||||||
|
<string name="go_back_to_casting_add_dialog">Zurück zum Hinzufügen-Dialog für die Übertragung</string>
|
||||||
|
<string name="guide">Anleitung</string>
|
||||||
|
<string name="hide_creator_from_home">Ersteller auf Startseite ausblenden</string>
|
||||||
|
<string name="history_cache_100">Verlaufs-Cache 100</string>
|
||||||
|
<string name="how_to_use_fcast_guide">Anleitung zur Verwendung von FCast</string>
|
||||||
|
<string name="import_data">Daten importieren</string>
|
||||||
|
<string name="import_data_description">Datei zum Importieren auswählen, unterstützt verschiedene Dateien (Alternative zum direkten Öffnen)</string>
|
||||||
|
<string name="import_options">Wählen Sie eine der folgenden verfügbaren Importoptionen.</string>
|
||||||
|
<string name="keep_screen_on">Bildschirm eingeschaltet lassen</string>
|
||||||
|
<string name="keep_screen_on_while_casting">Bildschirm während der Übertragung eingeschaltet lassen</string>
|
||||||
|
<string name="language">Sprache</string>
|
||||||
|
<string name="link_handling">Link-Verarbeitung</string>
|
||||||
|
<string name="load_more">Mehr laden</string>
|
||||||
|
<string name="locked_content_description">Dieser Inhalt ist gesperrt</string>
|
||||||
|
<string name="login_required">Anmeldung erforderlich</string>
|
||||||
|
<string name="login_to_view_your_comments">Melden Sie sich an, um Ihre Kommentare anzuzeigen</string>
|
||||||
|
<string name="may_require_restart">Neustart möglicherweise erforderlich</string>
|
||||||
|
<string name="membership">Mitgliedschaft</string>
|
||||||
|
<string name="merchandise">Fanartikel</string>
|
||||||
|
<string name="networking">Netzwerk</string>
|
||||||
|
<string name="new_playlist">Neue Playlist</string>
|
||||||
|
<string name="no_sources_installed">Sie haben keine Quellen installiert. Bitte fügen Sie Quellen hinzu, um die App wie vorgesehen zu nutzen.</string>
|
||||||
|
<string name="not_empty_close">Kommentar ist nicht leer, trotzdem schließen?</string>
|
||||||
|
<string name="notifications">Benachrichtigungen</string>
|
||||||
|
<string name="open_the_fcast_website">Die FCast-Webseite öffnen</string>
|
||||||
|
<string name="pan_option">Schwenken aktivieren</string>
|
||||||
|
<string name="pan_option_descr">Zwei-Finger-Schwenkgeste aktivieren</string>
|
||||||
|
<string name="peek_channel_contents">Kanalinhalte kurz anzeigen</string>
|
||||||
|
<string name="peek_channel_contents_description">Kanalinhalte kurz anzeigen, wenn vom Plugin bei ratenbegrenzten Aufrufen unterstützt, kann die Ladezeit von Abonnements verlängern.</string>
|
||||||
|
<string name="planned_content_notifications">Benachrichtigungen für geplante Inhalte</string>
|
||||||
|
<string name="planned_content_notifications_description">Plant erkannte geplante Inhalte als Benachrichtigungen, was zu genaueren Benachrichtigungen für diese Inhalte führt.</string>
|
||||||
|
<string name="platform_url">Plattform-URL</string>
|
||||||
|
<string name="play_pause">Wiedergabe/Pause</string>
|
||||||
|
<string name="play_store_version_does_not_support_default_url_handling">Die Play Store-Version unterstützt keine standardmäßige URL-Verarbeitung.</string>
|
||||||
|
<string name="playlist_delete_confirmation">Bestätigung zum Löschen der Playlist</string>
|
||||||
|
<string name="playlist_delete_confirmation_description">Bestätigungsdialog beim Löschen von Medien aus einer Playlist anzeigen</string>
|
||||||
|
<string name="please_use_at_least_1_character">Bitte verwenden Sie mindestens 1 Zeichen</string>
|
||||||
|
<string name="plus_tax">" + Steuer"</string>
|
||||||
|
<string name="polycentric_is_disabled">Polycentric ist deaktiviert</string>
|
||||||
|
<string name="polycentric_local_cache">Lokales Caching für Polycentric aktivieren</string>
|
||||||
|
<string name="polycentric_local_cache_description">Speichert Polycentric-Ergebnisse auf dem Gerät zwischen, um Ladezeiten zu verkürzen. Änderung erfordert App-Neustart</string>
|
||||||
|
<string name="position">Position</string>
|
||||||
|
<string name="prefer_webm">Webm-Video-Codecs bevorzugen</string>
|
||||||
|
<string name="prefer_webm_audio">Webm-Audio-Codecs bevorzugen</string>
|
||||||
|
<string name="prefer_webm_audio_description">Ob der Player Webm-Codecs (Opus) gegenüber MP4-Codecs (AAC) bevorzugen soll, kann zu schlechterer Kompatibilität führen.</string>
|
||||||
|
<string name="prefer_webm_description">Ob der Player Webm-Codecs (VP9/Opus) gegenüber MP4-Codecs (H.264/AAC) bevorzugen soll, kann zu schlechterer Kompatibilität führen.</string>
|
||||||
|
<string name="preferred_casting_quality_description">Standardqualität beim Übertragen auf ein externes Gerät</string>
|
||||||
|
<string name="preferred_metered_quality_description">Standardqualität bei getakteten Verbindungen wie Mobilfunk</string>
|
||||||
|
<string name="preferred_preview_quality_description">Standardqualität bei der Vorschau eines Videos in einem Feed</string>
|
||||||
|
<string name="preferred_quality_description">Standardqualität zum Ansehen eines Videos</string>
|
||||||
|
<string name="preview_feed_items">Feed-Elemente vorab anzeigen</string>
|
||||||
|
<string name="preview_feed_items_description">Wenn der Vorschau-Feedstil verwendet wird, ob Elemente beim Darüber-Scrollen automatisch vorab angezeigt werden sollen</string>
|
||||||
|
<string name="privacy_mode">Privatsphärenmodus</string>
|
||||||
|
<string name="progress_bar">Fortschrittsbalken</string>
|
||||||
|
<string name="progress_bar_description">Ob ein historischer Fortschrittsbalken angezeigt werden soll</string>
|
||||||
|
<string name="promotions">Werbeaktionen</string>
|
||||||
|
<string name="ratelimit">Ratenbegrenzung</string>
|
||||||
|
<string name="ratelimit_description">Einstellungen zur Ratenbegrenzung des Verhaltens dieses Plugins</string>
|
||||||
|
<string name="ratelimit_sub_setting">Abonnements ratenbegrenzen</string>
|
||||||
|
<string name="ratelimit_sub_setting_description">Begrenzt die Anzahl der gestellten Abonnementanfragen</string>
|
||||||
|
<string name="repeat_password">Passwort wiederholen</string>
|
||||||
|
<string name="restart_after_audio_focus_loss">Neustart nach Verlust des Audiofokus</string>
|
||||||
|
<string name="restart_after_connectivity_loss">Neustart nach Verbindungsverlust</string>
|
||||||
|
<string name="restart_playback_when_gaining_audio_focus_after_a_loss">Wiedergabe neu starten, wenn der Audiofokus nach einem Verlust wiedererlangt wird</string>
|
||||||
|
<string name="restart_playback_when_gaining_connectivity_after_a_loss">Wiedergabe neu starten, wenn die Verbindung nach einem Verlust wiederhergestellt wird</string>
|
||||||
|
<string name="restore_system_brightness">Systemhelligkeit wiederherstellen</string>
|
||||||
|
<string name="restore_system_brightness_descr">Systemhelligkeit beim Verlassen des Vollbildmodus wiederherstellen</string>
|
||||||
|
<string name="reverse_portrait">Umgekehrtes Hochformat zulassen</string>
|
||||||
|
<string name="reverse_portrait_description">App erlauben, ins umgekehrte Hochformat zu wechseln</string>
|
||||||
|
<string name="rotation_zone">Rotationszone</string>
|
||||||
|
<string name="rotation_zone_description">Empfindlichkeit der Rotationszonen festlegen (verringern, um weniger empfindlich zu machen)</string>
|
||||||
|
<string name="scroll_to_top">Nach oben scrollen</string>
|
||||||
|
<string name="select">Auswählen</string>
|
||||||
|
<string name="send_to_device">Video synchronisieren</string>
|
||||||
|
<string name="show_home_filters">Startseiten-Filter anzeigen</string>
|
||||||
|
<string name="show_home_filters_description">Ob die Startseiten-Filter über der Startseite angezeigt werden sollen</string>
|
||||||
|
<string name="show_home_filters_plugin_names">Plugin-Namen der Startseiten-Filter</string>
|
||||||
|
<string name="show_home_filters_plugin_names_description">Ob Startseiten-Filter vollständige Plugin-Namen oder nur Symbole anzeigen sollen</string>
|
||||||
|
<string name="show_watch_metrics">Wiedergabemetriken anzeigen</string>
|
||||||
|
<string name="show_watch_metrics_description">Zeigt die Wiedergabezeit und Aufrufe jedes Erstellers im Ersteller-Tab an</string>
|
||||||
|
<string name="stability_threshold_time">Stabilitätsschwellenzeit</string>
|
||||||
|
<string name="stability_threshold_time_description">Geben Sie die Dauer an, für die die Ausrichtung gleich bleiben muss, um eine Drehung auszulösen</string>
|
||||||
|
<string name="stopped_after_requestcount_to_avoid_rate_limit_click_load_more_to_load_more">Nach {requestCount} gestoppt, um Ratenbegrenzung zu vermeiden. Klicken Sie auf „Mehr laden“, um mehr zu laden.</string>
|
||||||
|
<string name="subscription_group_menu">Gruppen</string>
|
||||||
|
<string name="subscriptions_cache_5000">Abonnement-Cache 5000</string>
|
||||||
|
<string name="sync_grayjay">Grayjay synchronisieren</string>
|
||||||
|
<string name="sync_grayjay_description">Synchronisieren Sie Ihre Daten über mehrere Geräte hinweg</string>
|
||||||
|
<string name="synchronization">Synchronisation</string>
|
||||||
|
<string name="system_brightness">Systemhelligkeit</string>
|
||||||
|
<string name="system_brightness_descr">Gestensteuerung passt Systemhelligkeit an</string>
|
||||||
|
<string name="system_volume">Systemlautstärke</string>
|
||||||
|
<string name="system_volume_descr">Gestensteuerung passt Systemlautstärke an</string>
|
||||||
|
<string name="test_background_worker">Hintergrund-Worker testen</string>
|
||||||
|
<string name="test_background_worker_description"></string>
|
||||||
|
<string name="these_are_all_commentcount_comments_you_have_made_in_grayjay">Dies sind alle {commentCount} Kommentare, die Sie in Grayjay gemacht haben.</string>
|
||||||
|
<string name="these_creators_in_group">Dies sind die Ersteller, die für diese Gruppe sichtbar sind.</string>
|
||||||
|
<string name="these_creators_not_in_group">Diese Ersteller sind nicht in dieser Gruppe.</string>
|
||||||
|
<string name="time_bar">Zeitbalken</string>
|
||||||
|
<string name="toggle_full_screen">Vollbild umschalten</string>
|
||||||
|
<string name="toggle_full_screen_descr">Wischgeste zum Umschalten des Vollbildmodus aktivieren</string>
|
||||||
|
<string name="update_available_exclamation">Update verfügbar!</string>
|
||||||
|
<string name="url_handling">URL-Verarbeitung</string>
|
||||||
|
<string name="view_a_video_about_how_to_cast">Video zum Übertragen ansehen</string>
|
||||||
|
<string name="view_the_fcast_technical_documentation">Die technische Dokumentation von FCast ansehen</string>
|
||||||
|
<string name="volume_slider">Lautstärkeregler</string>
|
||||||
|
<string name="volume_slider_descr">Wischgeste zum Ändern der Lautstärke aktivieren</string>
|
||||||
|
<string name="watched">Angesehen</string>
|
||||||
|
<string name="zoom">Zoom</string>
|
||||||
|
<string name="zoom_option">Zoom aktivieren</string>
|
||||||
|
<string name="zoom_option_descr">Zwei-Finger-Pinch-Zoom-Geste aktivieren</string>
|
||||||
<string-array name="home_screen_array">
|
<string-array name="home_screen_array">
|
||||||
<item>Empfehlungen</item>
|
<item>Empfehlungen</item>
|
||||||
<item>Abonnements</item>
|
<item>Abonnements</item>
|
||||||
@@ -702,11 +951,33 @@
|
|||||||
<item>Ausführlich</item>
|
<item>Ausführlich</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
<string-array name="comments_sortby_array">
|
<string-array name="comments_sortby_array">
|
||||||
<item>Newest</item>
|
<item>Neuste</item>
|
||||||
<item>Oldest</item>
|
<item>Älteste</item>
|
||||||
|
</string-array>
|
||||||
|
<string-array name="downloads_sortby_array">
|
||||||
|
<item>Name (Aufsteigend)</item>
|
||||||
|
<item>Name (Absteigend)</item>
|
||||||
|
<item>Download-Datum (Älteste zuerst)</item>
|
||||||
|
<item>Download-Datum (Neueste zuerst)</item>
|
||||||
|
<item>Veröffentlichungsdatum (Älteste zuerst)</item>
|
||||||
|
<item>Veröffentlichungsdatum (Neueste zuerst)</item>
|
||||||
|
<item>Größe (Kleinste zuerst)</item>
|
||||||
|
<item>Größe (Größte zuerst)</item>
|
||||||
|
<item>Typ (Nur Audio)</item>
|
||||||
|
<item>Typ (Video)</item>
|
||||||
|
</string-array>
|
||||||
|
<string-array name="playlists_sortby_array">
|
||||||
|
<item>Name (Aufsteigend)</item>
|
||||||
|
<item>Name (Absteigend)</item>
|
||||||
|
<item>Änderungsdatum (Älteste)</item>
|
||||||
|
<item>Änderungsdatum (Neueste)</item>
|
||||||
|
<item>Erstellungsdatum (Älteste)</item>
|
||||||
|
<item>Erstellungsdatum (Neueste)</item>
|
||||||
|
<item>Wiedergabedatum (Älteste)</item>
|
||||||
|
<item>Wiedergabedatum (Neueste)</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
<string-array name="app_languages">
|
<string-array name="app_languages">
|
||||||
<item>System</item>
|
<item>Systemsprache</item>
|
||||||
<item>Englisch (EN)</item>
|
<item>Englisch (EN)</item>
|
||||||
<item>Deutsch (DE)</item>
|
<item>Deutsch (DE)</item>
|
||||||
<item>Spanisch (ES)</item>
|
<item>Spanisch (ES)</item>
|
||||||
@@ -720,4 +991,51 @@
|
|||||||
<item>Italienisch (IT)</item>
|
<item>Italienisch (IT)</item>
|
||||||
<item>Türkisch (TR)</item>
|
<item>Türkisch (TR)</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
<string-array name="chapter_fps">
|
||||||
|
<item>24</item>
|
||||||
|
<item>30</item>
|
||||||
|
<item>60</item>
|
||||||
|
<item>120</item>
|
||||||
|
</string-array>
|
||||||
|
<string-array name="comment_sections">
|
||||||
|
<item>Polycentric</item> <item>Plattform</item>
|
||||||
|
<item>Zuletzt ausgewählt</item>
|
||||||
|
</string-array>
|
||||||
|
<string-array name="restart_playback_after_loss">
|
||||||
|
<item>Nie</item>
|
||||||
|
<item>Innerhalb von 10 Sekunden nach Verlust</item>
|
||||||
|
<item>Innerhalb von 30 Sekunden nach Verlust</item>
|
||||||
|
<item>Immer</item>
|
||||||
|
</string-array>
|
||||||
|
<string-array name="rotation_zone">
|
||||||
|
<item>15</item>
|
||||||
|
<item>30</item>
|
||||||
|
<item>45</item>
|
||||||
|
</string-array>
|
||||||
|
<string-array name="rotation_threshold_time">
|
||||||
|
<item>100</item>
|
||||||
|
<item>500</item>
|
||||||
|
<item>750</item>
|
||||||
|
<item>1000</item>
|
||||||
|
<item>1500</item>
|
||||||
|
<item>2000</item>
|
||||||
|
</string-array>
|
||||||
|
<string-array name="audio_languages">
|
||||||
|
<item>Englisch</item>
|
||||||
|
<item>Spanisch</item>
|
||||||
|
<item>Deutsch</item>
|
||||||
|
<item>Französisch</item>
|
||||||
|
<item>Japanisch</item>
|
||||||
|
<item>Koreanisch</item>
|
||||||
|
<item>Thailändisch</item>
|
||||||
|
<item>Vietnamesisch</item>
|
||||||
|
<item>Indonesisch</item>
|
||||||
|
<item>Hindi</item>
|
||||||
|
<item>Arabisch</item>
|
||||||
|
<item>Türkisch</item>
|
||||||
|
<item>Russisch</item>
|
||||||
|
<item>Portugiesisch</item>
|
||||||
|
<item>Chinesisch</item>
|
||||||
|
<item>Italienisch</item>
|
||||||
|
</string-array>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -997,6 +997,8 @@
|
|||||||
<item>Data di Rilascio (Più Recente)</item>
|
<item>Data di Rilascio (Più Recente)</item>
|
||||||
<item>Dimensione (Più Piccola)</item>
|
<item>Dimensione (Più Piccola)</item>
|
||||||
<item>Dimensione (Più Grande)</item>
|
<item>Dimensione (Più Grande)</item>
|
||||||
|
<item>Tipo (Solo Audio)</item>
|
||||||
|
<item>Tipo (Video)</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
<string-array name="playlists_sortby_array">
|
<string-array name="playlists_sortby_array">
|
||||||
<item>Nome (Ascending)</item>
|
<item>Nome (Ascending)</item>
|
||||||
@@ -1064,6 +1066,7 @@
|
|||||||
<item>Russo</item>
|
<item>Russo</item>
|
||||||
<item>Portoghese</item>
|
<item>Portoghese</item>
|
||||||
<item>Cinese</item>
|
<item>Cinese</item>
|
||||||
|
<item>Italiano</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
<string-array name="casting_device_type_array" translatable="false">
|
<string-array name="casting_device_type_array" translatable="false">
|
||||||
<item>FCast</item>
|
<item>FCast</item>
|
||||||
|
|||||||
@@ -960,6 +960,8 @@
|
|||||||
<item>Çıkış Tarihi (En Yeni)</item>
|
<item>Çıkış Tarihi (En Yeni)</item>
|
||||||
<item>Boyut (En Küçük)</item>
|
<item>Boyut (En Küçük)</item>
|
||||||
<item>Boyut (En Büyük)</item>
|
<item>Boyut (En Büyük)</item>
|
||||||
|
<item>Tür (Yalnızca Ses)</item>
|
||||||
|
<item>Tür (Video)</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
<string-array name="feed_style">
|
<string-array name="feed_style">
|
||||||
<item>Önizle</item>
|
<item>Önizle</item>
|
||||||
@@ -1017,6 +1019,7 @@
|
|||||||
<item>Rusça</item>
|
<item>Rusça</item>
|
||||||
<item>Portekizce</item>
|
<item>Portekizce</item>
|
||||||
<item>Çince</item>
|
<item>Çince</item>
|
||||||
|
<item>İtalyanca</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
<string-array name="casting_device_type_array" translatable="false">
|
<string-array name="casting_device_type_array" translatable="false">
|
||||||
<item>FCast</item>
|
<item>FCast</item>
|
||||||
|
|||||||
@@ -27,6 +27,11 @@
|
|||||||
<string name="retry">Retry</string>
|
<string name="retry">Retry</string>
|
||||||
<string name="install_failed_device_installer_broken">Failed to start system installer. Your device’s ROM is not compatible with automatic updates.</string>
|
<string name="install_failed_device_installer_broken">Failed to start system installer. Your device’s ROM is not compatible with automatic updates.</string>
|
||||||
<string name="cancel">Cancel</string>
|
<string name="cancel">Cancel</string>
|
||||||
|
<string name="enable_daily_backup">Manage daily backup</string>
|
||||||
|
<string name="automatic_backup_unencrypted_explanation">Enable or disable your automatic backups here</string>
|
||||||
|
<string name="continue_anyway">Continue anyway</string>
|
||||||
|
<string name="automatic_backup_disabled">Automatic backup disabled</string>
|
||||||
|
<string name="automatic_backup_enabled">Automatic backup enabled</string>
|
||||||
<string name="failed_to_retrieve_data_are_you_connected">Failed to retrieve data, are you connected?</string>
|
<string name="failed_to_retrieve_data_are_you_connected">Failed to retrieve data, are you connected?</string>
|
||||||
<string name="settings">Settings</string>
|
<string name="settings">Settings</string>
|
||||||
<string name="history">History</string>
|
<string name="history">History</string>
|
||||||
@@ -338,6 +343,8 @@
|
|||||||
<string name="clear_the_external_storage_for_download_files">Clear the external storage for download files</string>
|
<string name="clear_the_external_storage_for_download_files">Clear the external storage for download files</string>
|
||||||
<string name="change_the_external_storage_for_download_files">Change the external storage for download files</string>
|
<string name="change_the_external_storage_for_download_files">Change the external storage for download files</string>
|
||||||
<string name="clear_cookies">Clear Cookies</string>
|
<string name="clear_cookies">Clear Cookies</string>
|
||||||
|
<string name="clear_cookies_after_login">Clear Cookies after Login</string>
|
||||||
|
<string name="clear_cookies_after_login_desc">Deletes all cookies on the webview after login, this may be required for certain plugins to function properly.</string>
|
||||||
<string name="clear_cookies_on_logout">Clear Cookies on Logout</string>
|
<string name="clear_cookies_on_logout">Clear Cookies on Logout</string>
|
||||||
<string name="test_background_worker">Test Background Worker</string>
|
<string name="test_background_worker">Test Background Worker</string>
|
||||||
<string name="test_background_worker_description"></string>
|
<string name="test_background_worker_description"></string>
|
||||||
@@ -535,7 +542,7 @@
|
|||||||
<string name="restart_playback_when_gaining_connectivity_after_a_loss">Restart playback when gaining connectivity after a loss</string>
|
<string name="restart_playback_when_gaining_connectivity_after_a_loss">Restart playback when gaining connectivity after a loss</string>
|
||||||
<string name="chapter_update_fps_title">Chapter Update FPS</string>
|
<string name="chapter_update_fps_title">Chapter Update FPS</string>
|
||||||
<string name="chapter_update_fps_description">Change accuracy of chapter updating, higher might cost more performance</string>
|
<string name="chapter_update_fps_description">Change accuracy of chapter updating, higher might cost more performance</string>
|
||||||
<string name="set_automatic_backup">Set Automatic Backup</string>
|
<string name="set_automatic_backup">Configure Automatic Backup</string>
|
||||||
<string name="shortly_after_opening_the_app_start_fetching_subscriptions">Shortly after opening the app, start fetching subscriptions</string>
|
<string name="shortly_after_opening_the_app_start_fetching_subscriptions">Shortly after opening the app, start fetching subscriptions</string>
|
||||||
<string name="show_faq">Show FAQ</string>
|
<string name="show_faq">Show FAQ</string>
|
||||||
<string name="show_issues">Show Issues</string>
|
<string name="show_issues">Show Issues</string>
|
||||||
@@ -805,6 +812,10 @@
|
|||||||
<string name="not_yet_available_retrying_in_time_s">Not yet available, retrying in {time}s</string>
|
<string name="not_yet_available_retrying_in_time_s">Not yet available, retrying in {time}s</string>
|
||||||
<string name="failed_to_retry_for_live_stream">Failed to retry for live stream</string>
|
<string name="failed_to_retry_for_live_stream">Failed to retry for live stream</string>
|
||||||
<string name="this_app_is_in_development_please_submit_bug_reports_and_understand_that_many_features_are_incomplete">This app is in development. Please submit bug reports and understand that many features are incomplete.</string>
|
<string name="this_app_is_in_development_please_submit_bug_reports_and_understand_that_many_features_are_incomplete">This app is in development. Please submit bug reports and understand that many features are incomplete.</string>
|
||||||
|
<string name="automatic_backup_found_no_password">Automatic backup found. No password is required to restore.</string>
|
||||||
|
<string name="checking_backup">Checking backup...</string>
|
||||||
|
<string name="backup_password_length_error">Password must be 4–32 bytes.</string>
|
||||||
|
<string name="restoring">Restoring...</string>
|
||||||
<string name="please_use_at_least_1_character">Please use at least 1 character</string>
|
<string name="please_use_at_least_1_character">Please use at least 1 character</string>
|
||||||
<string name="are_you_sure_you_want_to_delete_this_video">Are you sure you want to delete this video?</string>
|
<string name="are_you_sure_you_want_to_delete_this_video">Are you sure you want to delete this video?</string>
|
||||||
<string name="tap_to_open">Tap to open</string>
|
<string name="tap_to_open">Tap to open</string>
|
||||||
@@ -1042,6 +1053,8 @@
|
|||||||
<item>Release Date (Newest)</item>
|
<item>Release Date (Newest)</item>
|
||||||
<item>Size (Smallest)</item>
|
<item>Size (Smallest)</item>
|
||||||
<item>Size (Largest)</item>
|
<item>Size (Largest)</item>
|
||||||
|
<item>Type (Audio Only)</item>
|
||||||
|
<item>Type (Video)</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
<string-array name="playlists_sortby_array">
|
<string-array name="playlists_sortby_array">
|
||||||
<item>Name (Ascending)</item>
|
<item>Name (Ascending)</item>
|
||||||
@@ -1109,10 +1122,12 @@
|
|||||||
<item>Russian</item>
|
<item>Russian</item>
|
||||||
<item>Portuguese</item>
|
<item>Portuguese</item>
|
||||||
<item>Chinese</item>
|
<item>Chinese</item>
|
||||||
|
<item>Italian</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
<string-array name="casting_device_type_array" translatable="false">
|
<string-array name="casting_device_type_array" translatable="false">
|
||||||
<item>FCast</item>
|
<item>FCast</item>
|
||||||
<item>ChromeCast</item>
|
<item>ChromeCast</item>
|
||||||
|
<item>AirPlay</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
<string-array name="log_levels">
|
<string-array name="log_levels">
|
||||||
<item>None</item>
|
<item>None</item>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<data android:scheme="http" />
|
<data android:scheme="http" />
|
||||||
<data android:scheme="https" />
|
<data android:scheme="https" />
|
||||||
<data android:host="youtu.be" />
|
<data android:host="youtu.be" />
|
||||||
<data android:host="www.you.be" />
|
<data android:host="www.youtu.be" />
|
||||||
<data android:host="youtube.com" />
|
<data android:host="youtube.com" />
|
||||||
<data android:host="www.youtube.com" />
|
<data android:host="www.youtube.com" />
|
||||||
<data android:host="m.youtube.com" />
|
<data android:host="m.youtube.com" />
|
||||||
@@ -31,6 +31,8 @@
|
|||||||
<data android:host="patreon.com" />
|
<data android:host="patreon.com" />
|
||||||
<data android:host="soundcloud.com" />
|
<data android:host="soundcloud.com" />
|
||||||
<data android:host="twitch.tv" />
|
<data android:host="twitch.tv" />
|
||||||
|
<data android:host="www.twitch.tv" />
|
||||||
|
<data android:host="m.twitch.tv" />
|
||||||
<data android:host="bilibili.com" />
|
<data android:host="bilibili.com" />
|
||||||
<data android:host="bilibili.tv" />
|
<data android:host="bilibili.tv" />
|
||||||
<data android:host="dailymotion.com" />
|
<data android:host="dailymotion.com" />
|
||||||
@@ -51,7 +53,7 @@
|
|||||||
<data android:mimeType="text/plain" />
|
<data android:mimeType="text/plain" />
|
||||||
|
|
||||||
<data android:host="youtu.be" />
|
<data android:host="youtu.be" />
|
||||||
<data android:host="www.you.be" />
|
<data android:host="www.youtu.be" />
|
||||||
<data android:host="youtube.com" />
|
<data android:host="youtube.com" />
|
||||||
<data android:host="www.youtube.com" />
|
<data android:host="www.youtube.com" />
|
||||||
<data android:host="m.youtube.com" />
|
<data android:host="m.youtube.com" />
|
||||||
@@ -63,6 +65,8 @@
|
|||||||
<data android:host="patreon.com" />
|
<data android:host="patreon.com" />
|
||||||
<data android:host="soundcloud.com" />
|
<data android:host="soundcloud.com" />
|
||||||
<data android:host="twitch.tv" />
|
<data android:host="twitch.tv" />
|
||||||
|
<data android:host="www.twitch.tv" />
|
||||||
|
<data android:host="m.twitch.tv" />
|
||||||
<data android:host="bilibili.com" />
|
<data android:host="bilibili.com" />
|
||||||
<data android:host="bilibili.tv" />
|
<data android:host="bilibili.tv" />
|
||||||
<data android:host="dailymotion.com" />
|
<data android:host="dailymotion.com" />
|
||||||
|
|||||||
Submodule app/src/stable/assets/sources/apple-podcasts updated: 9c65475be1...8d9dee8a49
Submodule app/src/stable/assets/sources/bilibili updated: 9186672f0f...c63c69beec
Submodule app/src/stable/assets/sources/bitchute updated: b213f91c0b...deed10c077
Submodule app/src/stable/assets/sources/crunchyroll updated: a1714790c5...499ab8b438
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user