Compare commits

..

2 Commits

405 changed files with 6425 additions and 22943 deletions
-4
View File
@@ -1,6 +1,2 @@
aar/* filter=lfs diff=lfs merge=lfs -text
app/aar/* filter=lfs diff=lfs merge=lfs -text
app/src/main/jniLibs/arm64-v8a filter=lfs diff=lfs merge=lfs -text
app/src/main/jniLibs/armeabi-v7a filter=lfs diff=lfs merge=lfs -text
app/src/main/jniLibs/x86 filter=lfs diff=lfs merge=lfs -text
app/src/main/jniLibs/x86_64 filter=lfs diff=lfs merge=lfs -text
+19 -39
View File
@@ -1,57 +1,37 @@
variables:
GIT_SUBMODULE_STRATEGY: recursive
stages:
- buildAndDeployApkUnstable
- buildAndDeployApkStable
- buildAndDeployPlaystore
buildAndDeployApkUnstable:
stage: build
stage: buildAndDeployApkUnstable
script:
- sh deploy-unstable.sh
only:
- tags
except:
- ^(dev)
when: manual
needs: []
allow_failure: true
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/apk/unstable/release/*.apk
buildAndDeployApkStable:
stage: build
stage: buildAndDeployApkStable
script:
- sh deploy-stable.sh
only:
- tags
except:
- branches
when: manual
needs: []
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/apk/stable/release/*.apk
buildAndDeployPlaystore:
stage: deploy
stage: buildAndDeployPlaystore
script:
- sh build-playstore.sh
- bash tools/venv_playstore.sh
- . .venv-playstore/bin/activate
- python publish_playstore.py --sa /root/grayjay.json --package com.futo.platformplayer.playstore --aab ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab --track production --status completed
- sh deploy-playstore.sh
only:
- tags
when: on_success
needs:
- buildAndDeployApkStable
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/bundle/playstoreRelease/*.aab
updateFdroidRepo:
stage: deploy
only:
- tags
when: on_success
needs:
- job: buildAndDeployApkStable
artifacts: true
script:
- python3 update_fdroid_index.py
except:
- branches
when: manual
+6 -18
View File
@@ -64,6 +64,12 @@
[submodule "app/src/stable/assets/sources/bilibili"]
path = app/src/stable/assets/sources/bilibili
url = ../plugins/bilibili.git
[submodule "app/src/stable/assets/sources/spotify"]
path = app/src/stable/assets/sources/spotify
url = ../plugins/spotify.git
[submodule "app/src/unstable/assets/sources/spotify"]
path = app/src/unstable/assets/sources/spotify
url = ../plugins/spotify.git
[submodule "app/src/stable/assets/sources/bitchute"]
path = app/src/stable/assets/sources/bitchute
url = ../plugins/bitchute.git
@@ -106,21 +112,3 @@
[submodule "app/src/unstable/assets/sources/mixcloud"]
path = app/src/unstable/assets/sources/mixcloud
url = ../plugins/mixcloud.git
[submodule "app/src/unstable/assets/sources/radiobrowser"]
path = app/src/unstable/assets/sources/radiobrowser
url = ../plugins/radiobrowser.git
[submodule "app/src/stable/assets/sources/radiobrowser"]
path = app/src/stable/assets/sources/radiobrowser
url = ../plugins/radiobrowser.git
[submodule "app/src/stable/assets/sources/redbull-tv"]
path = app/src/stable/assets/sources/redbull-tv
url = ../plugins/redbull-tv.git
[submodule "app/src/unstable/assets/sources/redbull-tv"]
path = app/src/unstable/assets/sources/redbull-tv
url = ../plugins/redbull-tv.git
[submodule "app/src/unstable/assets/sources/fosdem"]
path = app/src/unstable/assets/sources/fosdem
url = ../plugins/fosdem.git
[submodule "app/src/stable/assets/sources/fosdem"]
path = app/src/stable/assets/sources/fosdem
url = ../plugins/fosdem.git
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ea10d3c5562c9f449a4e89e9c3dfcf881ed79a952f3409bc005bcc62c2cf4b81
size 65512557
-3
View File
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:22c06ca0d1a5808b2fc0a12227d5915b3126bc0b9b1305cf6bab855f2ec6fcbb
size 36133152
-3
View File
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:50b53a5d297d0c3008b3359a452a5c9e7e9f530533ec197e3dd1e9f08f9e84ad
size 6342128
+47 -47
View File
@@ -1,8 +1,8 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.21'
id 'org.ajoberstar.grgit' version '5.3.3'
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.21'
id 'org.ajoberstar.grgit' version '5.2.2'
id 'com.google.protobuf'
id 'kotlin-parcelize'
id 'com.google.devtools.ksp'
@@ -39,7 +39,7 @@ protobuf {
android {
namespace 'com.futo.platformplayer'
compileSdk 36
compileSdk 34
flavorDimensions "buildType"
productFlavors {
stable {
@@ -97,7 +97,7 @@ android {
defaultConfig {
minSdk 28
targetSdk 36
targetSdk 34
versionCode gitVersionCode
versionName gitVersionName
@@ -146,7 +146,6 @@ android {
}
sourceSets {
main {
jniLibs.srcDirs = ['src/main/jniLibs']
assets {
srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets'
}
@@ -155,80 +154,81 @@ android {
}
dependencies {
//implementation 'com.google.dagger:dagger:2.48'
implementation 'androidx.test:monitor:1.8.0'
implementation 'com.google.android.material:material:1.13.0'
//annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
implementation 'com.google.dagger:dagger:2.48'
implementation 'androidx.test:monitor:1.7.2'
implementation 'com.google.android.material:material:1.12.0'
annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
//Core
implementation 'androidx.core:core-ktx:1.17.0'
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation 'androidx.documentfile:documentfile:1.1.0'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
//Images
annotationProcessor 'com.github.bumptech.glide:compiler:5.0.5'
implementation 'com.github.bumptech.glide:glide:5.0.5'
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
implementation 'com.github.bumptech.glide:glide:4.16.0'
//Async
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
//HTTP
implementation "com.squareup.okhttp3:okhttp:5.3.0"
implementation "com.squareup.okhttp3:okhttp:4.11.0"
//JSON
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" //Used for structured json
implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2" //Used for structured json
implementation 'com.google.code.gson:gson:2.10.1' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
//JS
implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
implementation("com.caoccao.javet:javet-android:3.0.2")
//implementation 'com.caoccao.javet:javet-v8-android:4.1.4' //Change after extensive testing the freezing edge cases are solved.
//Exoplayer
implementation 'androidx.media3:media3-exoplayer:1.9.0'
implementation 'androidx.media3:media3-exoplayer-dash:1.9.0'
implementation 'androidx.media3:media3-ui:1.9.0'
implementation 'androidx.media3:media3-exoplayer-hls:1.9.0'
implementation 'androidx.media3:media3-exoplayer-rtsp:1.9.0'
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.9.0'
implementation 'androidx.media3:media3-transformer:1.9.0'
implementation 'androidx.navigation:navigation-fragment-ktx:2.9.6'
implementation 'androidx.navigation:navigation-ui-ktx:2.9.6'
implementation 'androidx.media:media:1.7.1'
implementation 'androidx.media3:media3-exoplayer:1.2.1'
implementation 'androidx.media3:media3-exoplayer-dash:1.2.1'
implementation 'androidx.media3:media3-ui:1.2.1'
implementation 'androidx.media3:media3-exoplayer-hls:1.2.1'
implementation 'androidx.media3:media3-exoplayer-rtsp:1.2.1'
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.2.1'
implementation 'androidx.media3:media3-transformer:1.2.1'
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.6'
implementation 'androidx.navigation:navigation-ui-ktx:2.7.6'
implementation 'androidx.media:media:1.7.0'
//Other
implementation 'org.jsoup:jsoup:1.21.2'
implementation 'org.jsoup:jsoup:1.15.3'
implementation 'com.google.android.flexbox:flexbox:3.0.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation fileTree(dir: 'aar', include: ['*.aar'])
implementation 'com.arthenica:smart-exception-java:0.2.1'
implementation 'org.jetbrains.kotlin:kotlin-reflect:2.2.0'
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
implementation 'com.github.dhaval2404:imagepicker:2.1'
implementation 'com.google.zxing:core:3.5.3'
implementation 'com.google.zxing:core:3.4.1'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'androidx.webkit:webkit:1.15.0'
//Protobuf
implementation 'com.google.protobuf:protobuf-javalite:4.33.0'
implementation 'com.google.protobuf:protobuf-javalite:3.25.1'
implementation 'com.polycentric.core:app:1.0'
implementation 'com.futo.futopay:app:1.0'
implementation 'androidx.work:work-runtime-ktx:2.11.0'
implementation 'androidx.concurrent:concurrent-futures-ktx:1.3.0'
implementation 'androidx.work:work-runtime-ktx:2.9.0'
implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0'
//Database
implementation("androidx.room:room-runtime:2.8.3")
ksp("androidx.room:room-compiler:2.8.3")
implementation("androidx.room:room-ktx:2.8.3")
implementation("androidx.room:room-runtime:2.6.1")
annotationProcessor("androidx.room:room-compiler:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
//Payment
implementation 'com.stripe:stripe-android:22.0.0'
implementation 'com.stripe:stripe-android:20.35.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2'
testImplementation "org.jetbrains.kotlin:kotlin-test:2.0.21"
testImplementation "org.xmlunit:xmlunit-core:2.11.0"
testImplementation "org.mockito:mockito-core:5.20.0"
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
testImplementation "org.jetbrains.kotlin:kotlin-test:1.8.22"
testImplementation "org.xmlunit:xmlunit-core:2.9.1"
testImplementation "org.mockito:mockito-core:5.4.0"
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}
@@ -0,0 +1,111 @@
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)
}
}
+20 -50
View File
@@ -16,9 +16,6 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -29,8 +26,6 @@
android:supportsRtl="true"
android:theme="@style/Theme.FutoVideo"
android:usesCleartextTraffic="true"
tools:replace="android:enableOnBackInvokedCallback"
android:enableOnBackInvokedCallback="false"
tools:targetApi="31"
android:largeHeap="true">
<provider
@@ -60,10 +55,9 @@
<activity
android:name=".activities.MainActivity"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar"
android:windowSoftInputMode="adjustPan"
android:launchMode="singleInstance"
android:resizeableActivity="true"
android:supportsPictureInPicture="true">
@@ -159,30 +153,30 @@
</activity>
<activity
android:name=".activities.TestActivity"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.SettingsActivity"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.DeveloperActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.ExceptionActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.CaptchaActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.LoginActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.AddSourceActivity"
android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem">
android:theme="@style/Theme.FutoVideo.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@@ -195,78 +189,54 @@
<activity
android:name=".activities.AddSourceOptionsActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricHomeActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricBackupActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricCreateProfileActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricProfileActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricWhyActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricImportProfileActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.ManageTabsActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.QRCaptureActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.FCastGuideActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.SyncHomeActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.SyncPairActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.SyncShowPairingCodeActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity
android:name=".activities.PolycentricModerationActivity"
android:exported="false"
android:screenOrientation="portrait" />
<activity
android:name=".activities.QRCodeFullscreenActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<service
android:name=".UpdateDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".UpdateActionReceiver"
android:exported="false" />
<activity
android:name=".activities.InstallUpdateActivity"
android:exported="false"
android:theme="@style/Theme.App.TransparentNoUi"
android:excludeFromRecents="true"
android:finishOnTaskLaunch="true" />
</application>
</manifest>
+2 -25
View File
@@ -1022,38 +1022,15 @@
return x.value
});
let settingsToUse = __DEV_SETTINGS ?? {};
if (true) {
const settings = this.Plugin?.currentPlugin?.settings;
if (settings) {
for (let setting of settings) {
if (typeof settingsToUse[setting.variable] == "undefined") {
switch (setting?.type?.toLowerCase()) {
case "boolean":
settingsToUse[setting.variable] = setting.default === 'true';
break;
case "dropdown":
let dropDownIndex = parseInt(setting.default);
if (dropDownIndex) {
settingsToUse[setting.variable] = setting.options[dropDownIndex];
}
break;
}
}
}
}
}
if(name == "enable") {
if(parameterVals.length > 0)
parameterVals[0] = this.Plugin.currentPlugin;
else
parameterVals.push(this.Plugin.currentPlugin);
if(parameterVals.length > 1)
parameterVals[1] = settingsToUse;
parameterVals[1] = __DEV_SETTINGS;
else
parameterVals.push(settingsToUse);
parameterVals.push(__DEV_SETTINGS);
}
const func = source[name];
-8
View File
@@ -67,7 +67,6 @@ class ScriptException extends Error {
super(arguments[0]);
this.plugin_type = "ScriptException";
this.message = arguments[0];
this.msg = arguments[0];
}
else {
super(msg);
@@ -415,8 +414,6 @@ class VideoUrlSource {
this.url = obj.url;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
}
}
class VideoUrlWidevineSource extends VideoUrlSource {
@@ -514,8 +511,6 @@ class HLSSource {
this.language = obj.language;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
}
}
class DashSource {
@@ -529,8 +524,6 @@ class DashSource {
this.language = obj.language;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
}
}
class DashWidevineSource extends DashSource {
@@ -556,7 +549,6 @@ class DashManifestRawSource {
this.language = obj.language ?? Language.UNKNOWN;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
this.original = obj?.original;
}
}
@@ -1,43 +0,0 @@
package com.futo.platformplayer
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
object AppCaUpdater {
private const val CA_URL = "https://curl.se/ca/cacert.pem"
private const val CACHE_FILENAME = "curl-ca-bundle.pem"
private const val MAX_AGE_DAYS = 30
suspend fun ensureCaBundle(context: Context): File = withContext(Dispatchers.IO) {
val file = File(context.noBackupFilesDir, CACHE_FILENAME)
val needsUpdate = !file.exists() || isOlderThanDays(file, MAX_AGE_DAYS)
if (needsUpdate) {
downloadToFile(CA_URL, file)
}
return@withContext file
}
private fun isOlderThanDays(file: File, days: Int): Boolean {
val ageMs = System.currentTimeMillis() - file.lastModified()
return ageMs > days * 24L * 60L * 60L * 1000L
}
private fun downloadToFile(urlStr: String, dest: File) {
val conn = (URL(urlStr).openConnection() as HttpURLConnection).apply {
connectTimeout = 15000
readTimeout = 15000
instanceFollowRedirects = true
}
conn.inputStream.use { input ->
dest.parentFile?.mkdirs()
dest.outputStream().use { output ->
input.copyTo(output)
}
}
conn.disconnect()
}
}
@@ -216,9 +216,10 @@ private fun ByteArray.toInetAddress(): InetAddress {
return InetAddress.getByAddress(this);
}
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int, timeoutMs: Int = 10_000): Socket? {
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
ensureNotMainThread()
val timeout = 10000
val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses;
if(addresses.isEmpty())
throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})");
@@ -231,7 +232,7 @@ fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int, timeoutMs
val socket = Socket()
try {
return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeoutMs) }
return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeout) }
} catch (e: Throwable) {
Log.i("getConnectedSocket", "Failed to connect to: ${addresses[0]}", e)
socket.close()
@@ -262,7 +263,7 @@ fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int, timeoutMs
}
}
socket.connect(InetSocketAddress(address, port), timeoutMs);
socket.connect(InetSocketAddress(address, port), timeout);
synchronized(syncObject) {
if (connectedSocket == null) {
@@ -7,14 +7,11 @@ import com.caoccao.javet.values.reference.V8ValueArray
import com.caoccao.javet.values.reference.V8ValueError
import com.caoccao.javet.values.reference.V8ValueObject
import com.caoccao.javet.values.reference.V8ValuePromise
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
@@ -24,6 +21,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.selects.SelectClause0
import kotlinx.coroutines.selects.SelectClause1
import java.util.concurrent.CancellationException
import java.util.concurrent.CountDownLatch
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
@@ -118,13 +116,14 @@ inline fun <reified T> V8ValueArray.expectV8Variants(config: IV8PluginConfig, co
inline fun V8Plugin.ensureIsBusy() {
this.let {
if (!it.isThreadAlreadyBusy()) {
//throw IllegalStateException("Tried to access V8Plugin without busy");
val stacktrace = Thread.currentThread().stackTrace;
val message = "V8 USE OUTSIDE BUSY: " + stacktrace.drop(3)?.firstOrNull().toString() +
", " + stacktrace.drop(4)?.firstOrNull().toString() +
", " + stacktrace.drop(5)?.firstOrNull()?.toString() +
", " + stacktrace.drop(6)?.firstOrNull()?.toString();
Logger.w("Extensions_V8", message);
throw IllegalStateException(message);
Logger.w("Extensions_V8",
"V8 USE OUTSIDE BUSY: " + stacktrace.drop(3)?.firstOrNull().toString() +
", " + stacktrace.drop(4)?.firstOrNull().toString() +
", " + stacktrace.drop(5)?.firstOrNull()?.toString() +
", " + stacktrace.drop(6)?.firstOrNull()?.toString()
);
}
}
}
@@ -135,7 +134,8 @@ inline fun V8Value.ensureIsBusy() {
}
inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextName: String): T {
ensureIsBusy();
if(false)
ensureIsBusy();
return when(T::class) {
String::class -> this.expectOrThrow<V8ValueString>(config, contextName).value as T;
Int::class -> {
@@ -184,20 +184,17 @@ inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextN
else -> throw NotImplementedError("Type ${T::class.simpleName} not implemented conversion");
}
}
fun V8ArrayToStringList(obj: V8ValueArray): List<String> {
obj.ensureIsBusy();
return obj.keys.map { obj.getString(it) };
}
fun V8ArrayToStringList(obj: V8ValueArray): List<String> = obj.keys.map { obj.getString(it) };
fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap<String, String> {
if(obj == null)
return hashMapOf();
obj.ensureIsBusy();
val map = hashMapOf<String, String>();
for(prop in obj.ownPropertyNames.keys.map { obj.ownPropertyNames.get<V8Value>(it).toString() })
map.put(prop, obj.getString(prop));
return map;
}
fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
val latch = CountDownLatch(1);
var promiseResult: T? = null;
@@ -205,27 +202,18 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
plugin.busy {
this.register(object: IV8ValuePromise.IListener {
override fun onFulfilled(p0: V8Value?) {
plugin.busy {
if(p0 is V8ValueError)
promiseException = ScriptExecutionException(plugin.config, p0.message);
else {
if(p0 is V8ValueObject)
p0.setWeak();
promiseResult = p0 as T;
}
}
if(p0 is V8ValueError)
promiseException = ScriptExecutionException(plugin.config, p0.message);
else
promiseResult = p0 as T;
latch.countDown();
}
override fun onRejected(p0: V8Value?) {
plugin.busy {
promiseException = p0?.toException(plugin.config);
}
promiseException = (NotImplementedError("onRejected promise not implemented.."));
latch.countDown();
}
override fun onCatch(p0: V8Value?) {
plugin.busy {
promiseException = p0?.toException(plugin.config);
}
promiseException = (NotImplementedError("onCatch promise not implemented.."));
latch.countDown();
}
});
@@ -235,28 +223,8 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
promiseException = CancellationException("Cancelled by system");
latch.countDown();
}
//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 {
promise.isPending
};
if(!isPending) {
plugin.busy {
try {
Logger.i("V8", "V8Promise resolved synchronously");
if(promise.isFulfilled)
promiseResult = promise.getResult<T>();
else
promiseException = promise.getResult<V8Value>().toException(plugin.config);
}
catch(ex: Throwable) {
promiseException = ex;
}
}
} else {
plugin.unbusy {
latch.await();
}
plugin.unbusy {
latch.await();
}
if(promiseException != null)
throw promiseException!!;
@@ -277,35 +245,16 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
plugin.busy {
this.register(object: IV8ValuePromise.IListener {
override fun onFulfilled(p0: V8Value?) {
plugin.busy {
plugin.resolvePromise(promise);
underlyingDef.complete(p0 as T);
}
plugin.resolvePromise(promise);
underlyingDef.complete(p0 as T);
}
override fun onRejected(p0: V8Value?) {
try {
plugin.busy {
plugin.resolvePromise(promise);
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented..");
Logger.i("V8", "Promise rejected, setting exception");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
}
}
catch(ex: Throwable) {
Logger.e("V8", "Rejection handling failed?" , ex);
}
plugin.resolvePromise(promise);
underlyingDef.completeExceptionally(NotImplementedError("onRejected promise not implemented.."));
}
override fun onCatch(p0: V8Value?) {
try {
plugin.busy {
plugin.resolvePromise(promise);
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented..");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
}
}
catch(ex: Throwable) {
Logger.e("V8", "Catching handling failed?" , ex);
}
plugin.resolvePromise(promise);
underlyingDef.completeExceptionally(NotImplementedError("onCatch promise not implemented.."));
}
});
}
@@ -316,24 +265,6 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
return def;
}
fun V8Value.toException(config: IV8PluginConfig): Throwable {
ensureIsBusy();
val p0 = this;
if(p0 is V8ValueObject) {
return V8Plugin.getExceptionFromPlugin(config, p0, null, null, null, "P:");
/*
val pluginType = p0.getOrDefault(config, "plugin_type", "Promise Exception", "")?.let { if(!it.isNullOrBlank()) it + "" else "" }
val msg = p0.getOrDefault<String?>(config, "msg", "Promise Exception", null)
?: p0.getOrDefault(config, "message", "Promise Exception", "");
return Throwable("Promise Failed: " + pluginType + msg);
*/
}
else if(p0 is V8ValueString)
return Throwable("Promise Failed:" + p0.value);
else
return NotImplementedError("onCatch promise not implemented..");
}
class V8Deferred<T>(val deferred: Deferred<T>, val estDuration: Int = -1): Deferred<T> by deferred {
fun <R> convert(conversion: (result: T)->R): V8Deferred<R>{
@@ -367,7 +298,6 @@ class V8Deferred<T>(val deferred: Deferred<T>, val estDuration: Int = -1): Defer
fun <T: V8Value> V8ValueObject.invokeV8(method: String, vararg obj: Any?): T {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) {
return result.toV8ValueBlocking(this.getSourcePlugin()!!);
@@ -375,7 +305,6 @@ fun <T: V8Value> V8ValueObject.invokeV8(method: String, vararg obj: Any?): T {
return result as T;
}
fun <T: V8Value> V8ValueObject.invokeV8Async(method: String, vararg obj: Any?): V8Deferred<T> {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) {
return result.toV8ValueAsync(this.getSourcePlugin()!!);
@@ -383,7 +312,6 @@ fun <T: V8Value> V8ValueObject.invokeV8Async(method: String, vararg obj: Any?):
return V8Deferred(CompletableDeferred(result as T));
}
fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) {
return result.toV8ValueBlocking(this.getSourcePlugin()!!);
@@ -391,34 +319,10 @@ fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value {
return result;
}
fun V8ValueObject.invokeV8VoidAsync(method: String, vararg obj: Any?): V8Deferred<V8Value> {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) {
val result = result.toV8ValueAsync<V8Value>(this.getSourcePlugin()!!);
return result;
}
return V8Deferred(CompletableDeferred(result));
}
suspend fun <T> Deferred<T>.awaitCancelConverted(): T {
try {
return this.await();
}
catch(ex: CancellationException) {
if(ex.cause != null) {
throw ex.cause!!;
}
throw ex;
}
}
fun <T> IPager<T>.toList(): List<T> {
val list = this.getResults().toMutableList();
while(this.hasMorePages()) {
this.nextPage();
list.addAll(this.getResults());
}
return list.toList();
}
}
@@ -1,118 +0,0 @@
package com.futo.platformplayer
import android.app.Activity
import android.graphics.Color
import android.os.Build
import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.doOnAttach
import androidx.core.view.updatePadding
import kotlin.math.max
class RootInsetsController private constructor(
private val activity: Activity,
private val window: Window,
private val root: ViewGroup
) {
private val controller by lazy { WindowInsetsControllerCompat(window, root) }
private val basePaddingLeft = root.paddingLeft
private val basePaddingTop = root.paddingTop
private val basePaddingRight = root.paddingRight
private val basePaddingBottom = root.paddingBottom
private var currentInsets: WindowInsetsCompat = WindowInsetsCompat.CONSUMED
private var fullscreen = false
init {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets ->
currentInsets = insets
applyPadding()
insets
}
root.doOnAttach { ViewCompat.requestApplyInsets(root) }
}
private fun effectiveInsets(): Insets {
if (fullscreen) return Insets.NONE
val sys = currentInsets.getInsets(Type.systemBars())
val cut = currentInsets.getInsetsIgnoringVisibility(Type.displayCutout())
val portrait = activity.resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_PORTRAIT
val top = if (portrait) max(sys.top, cut.top) else sys.top
return Insets.of(sys.left, top, sys.right, sys.bottom)
}
private fun applyPadding() {
val e = effectiveInsets()
root.updatePadding(
left = basePaddingLeft + e.left,
top = basePaddingTop + e.top,
right = basePaddingRight + e.right,
bottom = basePaddingBottom + e.bottom
)
}
private fun forceRelayoutAndInsets() {
root.post {
ViewCompat.requestApplyInsets(root)
applyPadding()
root.post {
ViewCompat.requestApplyInsets(root)
applyPadding()
}
}
}
fun enterFullscreen(allowCutoutShortEdges: Boolean = true) {
fullscreen = true
if (allowCutoutShortEdges) {
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
controller.hide(Type.systemBars())
forceRelayoutAndInsets()
}
fun exitFullscreen() {
fullscreen = false
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
}
controller.show(Type.systemBars())
forceRelayoutAndInsets()
}
fun onConfigurationChanged() {
forceRelayoutAndInsets()
}
fun setLightSystemBarAppearance(lightStatus: Boolean, lightNav: Boolean) {
controller.isAppearanceLightStatusBars = lightStatus
controller.isAppearanceLightNavigationBars = lightNav
}
companion object {
fun attach(activity: Activity, root: ViewGroup): RootInsetsController {
return RootInsetsController(activity, activity.window, root)
}
}
}
@@ -6,16 +6,15 @@ import android.content.Intent
import android.net.Uri
import android.webkit.CookieManager
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.ManageTabsActivity
import com.futo.platformplayer.activities.PolycentricHomeActivity
import com.futo.platformplayer.activities.PolycentricProfileActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.activities.SyncHomeActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
@@ -26,7 +25,6 @@ import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePayment
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.states.StateUpdate
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson
@@ -36,13 +34,13 @@ import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormField
import com.futo.platformplayer.views.fields.FormFieldButton
import com.futo.platformplayer.views.fields.FormFieldWarning
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.time.OffsetDateTime
@@ -64,7 +62,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.sync_grayjay, FieldForm.BUTTON, R.string.sync_grayjay_description, -8)
@FormFieldButton(R.drawable.ic_update)
fun syncGrayjay() {
StateApp?.instance?.activity?.let {
SettingsActivity.getActivity()?.let {
it.startActivity(Intent(it, SyncHomeActivity::class.java))
}
}
@@ -73,7 +71,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -7)
@FormFieldButton(R.drawable.ic_person)
fun managePolycentricIdentity() {
StateApp?.instance?.activity?.let {
SettingsActivity.getActivity()?.let {
if (StatePolycentric.instance.enabled) {
if (StatePolycentric.instance.processHandle != null) {
it.startActivity(Intent(it, PolycentricProfileActivity::class.java));
@@ -91,7 +89,7 @@ class Settings : FragmentedStorageFileJson() {
fun openFAQ() {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(Settings.URL_FAQ))
StateApp?.instance?.activity?.startActivity(browserIntent);
SettingsActivity.getActivity()?.startActivity(browserIntent);
} catch (e: Throwable) {
//Ignored
}
@@ -101,7 +99,7 @@ class Settings : FragmentedStorageFileJson() {
fun openIssues() {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/futo-org/grayjay-android/issues"))
StateApp?.instance?.activity?.startActivity(browserIntent);
SettingsActivity.getActivity()?.startActivity(browserIntent);
} catch (e: Throwable) {
//Ignored
}
@@ -132,7 +130,7 @@ class Settings : FragmentedStorageFileJson() {
@FormFieldButton(R.drawable.ic_tabs)
fun manageTabs() {
try {
StateApp?.instance?.activity?.let {
SettingsActivity.getActivity()?.let {
it.startActivity(Intent(it, ManageTabsActivity::class.java));
}
} catch (e: Throwable) {
@@ -145,7 +143,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, -3)
@FormFieldButton(R.drawable.ic_move_up)
fun import() {
val act = StateApp.instance.activity ?: return;
val act = SettingsActivity.getActivity() ?: return;
val intent = MainActivity.getImportOptionsIntent(act);
act.startActivity(intent);
}
@@ -154,7 +152,7 @@ class Settings : FragmentedStorageFileJson() {
@FormFieldButton(R.drawable.ic_link)
fun manageLinks() {
try {
StateApp.instance.activity?.let { UIDialogs.showUrlHandlingPrompt(it) }
SettingsActivity.getActivity()?.let { UIDialogs.showUrlHandlingPrompt(it) }
} catch (e: Throwable) {
Logger.e(TAG, "Failed to show url handling prompt", e)
}
@@ -163,7 +161,7 @@ class Settings : FragmentedStorageFileJson() {
/*@FormField(R.string.disable_battery_optimization, FieldForm.BUTTON, R.string.click_to_go_to_battery_optimization_settings_disabling_battery_optimization_will_prevent_the_os_from_killing_media_sessions, -1)
@FormFieldButton(R.drawable.battery_full_24px)
fun ignoreBatteryOptimization() {
StateApp.instance.activity?.let {
SettingsActivity.getActivity()?.let {
val intent = Intent()
val packageName = it.packageName
val pm = it.getSystemService(POWER_SERVICE) as PowerManager;
@@ -203,8 +201,6 @@ class Settings : FragmentedStorageFileJson() {
8 -> "zh";
9 -> "ru";
10 -> "ar";
11 -> "it";
12 -> "tr";
else -> null
}
}
@@ -244,7 +240,7 @@ class Settings : FragmentedStorageFileJson() {
fun clearHidden() {
StateMeta.instance.removeAllHiddenCreators();
StateMeta.instance.removeAllHiddenVideos();
StateApp.instance.activity?.let {
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, "Creators and videos should show up again");
}
}
@@ -313,7 +309,7 @@ class Settings : FragmentedStorageFileJson() {
var showSubscriptionGroups: Boolean = true;
@FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6)
var useSubscriptionExchange: Boolean = true;
var useSubscriptionExchange: Boolean = false;
@AdvancedField
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
@@ -374,9 +370,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 16)
fun clearChannelCache() {
UIDialogs.toast(StateApp.instance.activity!!, "Started clearing..");
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
StateCache.instance.clear();
UIDialogs.toast(StateApp.instance.activity!!, "Finished clearing");
UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing");
}
}
@@ -388,7 +384,7 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.audio_languages)
var primaryLanguage: Int = 0;
fun getPrimaryLanguage(context: Context? = null): String? {
fun getPrimaryLanguage(context: Context): String? {
return when(primaryLanguage) {
0 -> "en";
1 -> "es";
@@ -401,18 +397,13 @@ class Settings : FragmentedStorageFileJson() {
8 -> "id";
9 -> "hi";
10 -> "ar";
11 -> "tr";
11 -> "tu";
12 -> "ru";
13 -> "pt";
14 -> "zh";
15 -> "it";
else -> null
}
}
@FormField(R.string.sticky_subtitles, FieldForm.TOGGLE, R.string.sticky_subtitles_description, -1)
var stickySubtitles: Boolean = true;
@FormField(R.string.prefer_original_audio, FieldForm.TOGGLE, R.string.prefer_original_audio_description, -1)
var preferOriginalAudio: Boolean = true;
@@ -431,9 +422,6 @@ class Settings : FragmentedStorageFileJson() {
6 -> 1.75f;
7 -> 2.0f;
8 -> 2.25f;
9 -> 2.5f;
10 -> 2.75f;
11 -> 3.0f;
else -> 1.0f;
};
@@ -615,16 +603,6 @@ class Settings : FragmentedStorageFileJson() {
else -> 2.0
}
}
@AdvancedField
@FormField(R.string.shorts_pregenerate, FieldForm.TOGGLE, R.string.shorts_pregenerate_description, 28)
var shortsPregenerate: Boolean = false;
@AdvancedField
@FormField(R.string.shorts_fit_video, FieldForm.TOGGLE, R.string.shorts_fit_video_description, 29)
@FormFieldWarning(R.string.shorts_fit_video_warning)
var shortsFitVideo: Boolean = false;
}
@FormField(R.string.comments, "group", R.string.comments_description, 6)
@@ -759,7 +737,7 @@ class Settings : FragmentedStorageFileJson() {
try {
if (!Logger.submitLogs()) {
withContext(Dispatchers.Main) {
StateApp.instance.activity?.let { UIDialogs.toast(it, it.getString(R.string.please_enable_logging_to_submit_logs)) }
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.please_enable_logging_to_submit_logs)) }
}
}
} catch (e: Throwable) {
@@ -776,7 +754,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1)
fun resetAnnouncements() {
StateAnnouncement.instance.resetAnnouncements();
StateApp.instance.activity?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
}
}
@@ -789,6 +767,7 @@ class Settings : FragmentedStorageFileJson() {
}
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
@Transient
var plugins = Plugins();
@Serializable
class Plugins {
@@ -797,9 +776,6 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
var checkDisabledPluginsForUpdates: Boolean = false;
@AdvancedField
@FormField(R.string.clear_cookies_after_login, FieldForm.TOGGLE, R.string.clear_cookies_after_login_desc, 0)
var clearCookiesAfterLogin: Boolean = false;
@AdvancedField
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
var clearCookiesOnLogout: Boolean = true;
@@ -809,12 +785,6 @@ class Settings : FragmentedStorageFileJson() {
val cookieManager: CookieManager = CookieManager.getInstance();
cookieManager.removeAllCookies(null);
}
fun shouldClearWebviewCookies(): Boolean {
return clearCookiesAfterLogin;
}
/*@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
fun reinstallEmbedded() {
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
@@ -852,13 +822,13 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.change_external_general_directory, FieldForm.BUTTON, R.string.change_the_external_directory_for_general_files, 3)
fun changeStorageGeneral() {
StateApp.instance.activity?.let {
SettingsActivity.getActivity()?.let {
StateApp.instance.changeExternalGeneralDirectory(it);
}
}
@FormField(R.string.change_external_downloads_directory, FieldForm.BUTTON, R.string.change_the_external_storage_for_download_files, 4)
fun changeStorageDownload() {
StateApp.instance.activity?.let {
SettingsActivity.getActivity()?.let {
StateApp.instance.changeExternalDownloadDirectory(it);
}
}
@@ -867,7 +837,7 @@ class Settings : FragmentedStorageFileJson() {
fun clearStorageDownload() {
Settings.instance.storage.storage_download = null;
Settings.instance.save();
StateApp.instance.activity?.let { UIDialogs.toast(it, "Cleared download storage directory") };
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Cleared download storage directory") };
}
}
@@ -880,9 +850,9 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.auto_update_when_array)
var check: Int = 0;
@FormField(R.string.background_download, FieldForm.TOGGLE, R.string.configure_if_background_download_should_be_used, 1)
//@DropdownFieldOptionsId(R.array.background_download)
var shouldBackgroundDownload: Boolean = true;
@FormField(R.string.background_download, FieldForm.DROPDOWN, R.string.configure_if_background_download_should_be_used, 1)
@DropdownFieldOptionsId(R.array.background_download)
var backgroundDownload: Int = 0;
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
@DropdownFieldOptionsId(R.array.when_download)
@@ -904,13 +874,13 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3)
fun manualCheck() {
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
StateApp.instance.activity?.let {
SettingsActivity.getActivity()?.let {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.checkForUpdates(it, true)
}
}
} else {
StateApp.instance.activity?.let {
SettingsActivity.getActivity()?.let {
try {
it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}")))
} catch (e: ActivityNotFoundException) {
@@ -922,7 +892,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4)
fun viewChangelog() {
StateApp.instance.activity?.let {
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it.getString(R.string.retrieving_changelog));
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
@@ -963,34 +933,21 @@ class Settings : FragmentedStorageFileJson() {
@Serializable(with = OffsetDateTimeSerializer::class)
var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN;
var didAskAutoBackup: Boolean = false;
var autoBackupEnabled: Boolean = false
var autoBackupPassword: String? = null;
fun shouldAutomaticBackup() = autoBackupEnabled
fun shouldAutomaticBackup() = autoBackupPassword != null;
@FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0)
val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day";
@FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
fun configureAutomaticBackup() {
StateApp.instance.activity?.let { activity ->
if(!Settings.instance.storage.isStorageMainValid(activity)) {
UIDialogs.toast("Missing general directory")
StateApp.instance.changeExternalGeneralDirectory(activity) {
UIDialogs.showAutomaticBackupDialog(activity) {
SettingsFragment.currentView?.reloadSettings()
}
};
}
else {
UIDialogs.showAutomaticBackupDialog(activity) {
SettingsFragment.currentView?.reloadSettings()
}
}
}
UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!, autoBackupPassword != null) {
SettingsActivity.getActivity()?.reloadSettings();
};
}
@FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
fun restoreAutomaticBackup() {
val activity = StateApp.instance.activity!!
val activity = SettingsActivity.getActivity()!!
if(!StateBackup.hasAutomaticBackup())
UIDialogs.toast(activity, activity.getString(R.string.you_don_t_have_any_automatic_backups), false);
@@ -1001,9 +958,8 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.export_data, FieldForm.BUTTON, R.string.creates_a_zip_file_with_your_data_which_can_be_imported_by_opening_it_with_grayjay, 3)
fun export() {
val activity = StateApp.instance.activity ?: return;
val fragView = SettingsFragment.currentView ?: return;
UISlideOverlays.showOverlay(fragView.overlay, "Select export type", null, {},
val activity = SettingsActivity.getActivity() ?: return;
UISlideOverlays.showOverlay(activity.overlay, "Select export type", null, {},
SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", tag = null, call = {
StateBackup.shareExternalBackup();
}),
@@ -1019,11 +975,11 @@ class Settings : FragmentedStorageFileJson() {
@Serializable
class Payment {
@FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 1)
val paymentStatus: String get() = StateApp.instance.activity?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
val paymentStatus: String get() = SettingsActivity.getActivity()?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
@FormField(R.string.license_status, FieldForm.BUTTON, R.string.view_license_status, 2)
fun viewLicenseStatus() {
StateApp.instance.activity?.let {
SettingsActivity.getActivity()?.let {
try {
if (StatePayment.instance.hasPaid) {
val paymentKey = StatePayment.instance.getPaymentKey()
@@ -1039,12 +995,12 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 3)
fun clearPayment() {
StateApp.instance.activity?.let { context ->
SettingsActivity.getActivity()?.let { context ->
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", {
StatePayment.instance.clearLicenses();
StateApp.instance.activity?.let {
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
SettingsFragment.currentView?.reloadSettings();
it.reloadSettings();
}
})
}
@@ -1070,7 +1026,6 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
var polycentricLocalCache: Boolean = true;
var showPrivacyModeDialog: Boolean = true;
}
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
@@ -1132,39 +1087,6 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3)
var localConnections: Boolean = true;
var syncServerUrl: String? = null;
@FormField(R.string.relay_server, FieldForm.READONLYTEXT, -1, 6)
val syncServer: String get() = if(syncServerUrl?.isBlank() == true) StateSync.RELAY_SERVER else syncServerUrl ?: StateSync.RELAY_SERVER;
@AdvancedField
@FormField(R.string.configure_sync_server, FieldForm.BUTTON, R.string.configure_sync_server_description, 7)
fun configureSyncServer() {
StateApp.instance.activity?.let { context ->
UIDialogs.showDialog(context, R.drawable.device_sync, false,
"Enter the url to your relay server",
"Using your own relay server requires a proper setup with portforwarding.\nUse at your own risk.",
null,
syncServerUrl ?: "",
"YourRelayServerDomain.com", 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Reset", {
syncServerUrl = null;
instance.save();
SettingsFragment.currentView?.reloadSettings();
UIDialogs.toast("Sync server changes require a restart");
}, UIDialogs.ActionStyle.ACCENT),
UIDialogs.Action.withInput("Configure", {
syncServerUrl = it?.text
instance.save();
SettingsFragment.currentView?.reloadSettings();
UIDialogs.toast("Sync server changes require a restart");
}, UIDialogs.ActionStyle.PRIMARY),
)
}
}
}
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
@@ -8,7 +8,9 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.caoccao.javet.values.primitive.V8ValueInteger
import com.caoccao.javet.values.primitive.V8ValueString
import com.futo.platformplayer.activities.DeveloperActivity
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@@ -18,8 +20,6 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.background.BackgroundWorker
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.fragment.mainactivity.main.DeveloperFragment
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
import com.futo.platformplayer.states.StateAnnouncement
@@ -97,10 +97,10 @@ class SettingsDev : FragmentedStorageFileJson() {
fun subscriptionsCache5000() {
Logger.i("SettingsDev", "Started caching 5000 sub items");
UIDialogs.toast(
StateApp.instance.activity!!,
SettingsActivity.getActivity()!!,
"Started caching 5000 sub items"
);
val button = DeveloperFragment.currentView?.getField("subscription_cache_button");
val button = DeveloperActivity.getActivity()?.getField("subscription_cache_button");
if(button is ButtonField)
button.setButtonEnabled(false);
StateApp.instance.scope.launch(Dispatchers.IO) {
@@ -121,7 +121,7 @@ class SettingsDev : FragmentedStorageFileJson() {
val diff = System.currentTimeMillis() - lastToast;
lastToast = System.currentTimeMillis();
UIDialogs.toast(
StateApp.instance.activity!!,
SettingsActivity.getActivity()!!,
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
);
}
@@ -130,7 +130,7 @@ class SettingsDev : FragmentedStorageFileJson() {
withContext(Dispatchers.Main) {
UIDialogs.toast(
StateApp.instance.activity!!,
SettingsActivity.getActivity()!!,
"FINISHED Page: ${page}, Total: ${total}"
);
}
@@ -152,10 +152,10 @@ class SettingsDev : FragmentedStorageFileJson() {
fun historyCache100() {
Logger.i("SettingsDev", "Started caching 100 history items (from home)");
UIDialogs.toast(
StateApp.instance.activity!!,
SettingsActivity.getActivity()!!,
"Started caching 100 history items (from home)"
);
val button = DeveloperFragment.currentView?.getField("history_cache_button");
val button = DeveloperActivity.getActivity()?.getField("history_cache_button");
if(button is ButtonField)
button.setButtonEnabled(false);
StateApp.instance.scope.launch(Dispatchers.IO) {
@@ -186,7 +186,7 @@ class SettingsDev : FragmentedStorageFileJson() {
val diff = System.currentTimeMillis() - lastToast;
lastToast = System.currentTimeMillis();
UIDialogs.toast(
StateApp.instance.activity!!,
SettingsActivity.getActivity()!!,
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
);
}
@@ -195,7 +195,7 @@ class SettingsDev : FragmentedStorageFileJson() {
withContext(Dispatchers.Main) {
UIDialogs.toast(
StateApp.instance.activity!!,
SettingsActivity.getActivity()!!,
"FINISHED Page: ${page}, Total: ${total}"
);
}
@@ -235,9 +235,9 @@ class SettingsDev : FragmentedStorageFileJson() {
@FormField(R.string.test_background_worker, FieldForm.BUTTON,
R.string.test_background_worker_description, 4)
fun triggerBackgroundUpdate() {
val act = StateApp.instance.activity!!;
val act = SettingsActivity.getActivity()!!;
try {
UIDialogs.toast(StateApp.instance.activity!!, "Starting test background worker");
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
val wm = WorkManager.getInstance(act);
val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
@@ -251,9 +251,9 @@ class SettingsDev : FragmentedStorageFileJson() {
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
R.string.test_background_worker_description, 4)
fun clearChannelContentCache() {
UIDialogs.toast(StateApp.instance.activity!!, "Clearing cache");
UIDialogs.toast(SettingsActivity.getActivity()!!, "Clearing cache");
StateCache.instance.clearToday();
UIDialogs.toast(StateApp.instance.activity!!, "Cleared");
UIDialogs.toast(SettingsActivity.getActivity()!!, "Cleared");
}
@@ -19,7 +19,6 @@ import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -114,8 +113,8 @@ class UIDialogs {
currentDialog.code,
currentDialog.defaultCloseAction,
*currentDialog.actions.map {
return@map Action.withInput(it.text, { str ->
it.invokeAction(str);
return@map Action(it.text, {
it.action();
multiShowDialog(context, dialogDescriptor.drop(1), finally);
}, it.style);
}.toTypedArray());
@@ -166,42 +165,27 @@ class UIDialogs {
dialog.show()
}
fun showAutomaticBackupDialog(context: Context, onClosed: (() -> Unit)? = null) {
val dialogAction: () -> Unit = {
val dialog = AutomaticBackupDialog(context)
registerDialogOpened(dialog)
dialog.setOnDismissListener {
registerDialogClosed(dialog)
onClosed?.invoke()
}
dialog.show()
}
if (!Settings.instance.backup.autoBackupEnabled && StateBackup.hasAutomaticBackup()) {
UIDialogs.showDialog(
context,
R.drawable.ic_move_up,
context.getString(R.string.an_old_backup_is_available),
context.getString(R.string.would_you_like_to_restore_this_backup),
null,
0,
UIDialogs.Action(context.getString(R.string.cancel), {}),
UIDialogs.Action(context.getString(R.string.continue_anyway), {
dialogAction()
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
val dialogAction: ()->Unit = {
val dialog = AutomaticBackupDialog(context);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog); onClosed?.invoke() };
dialog.show();
};
if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck)
UIDialogs.showDialog(context, R.drawable.ic_move_up, context.getString(R.string.an_old_backup_is_available), context.getString(R.string.would_you_like_to_restore_this_backup), null, 0,
UIDialogs.Action(context.getString(R.string.cancel), {}), //To nothing
UIDialogs.Action(context.getString(R.string.override), {
dialogAction();
}, UIDialogs.ActionStyle.DANGEROUS),
UIDialogs.Action(context.getString(R.string.restore), {
val scope = (context as? androidx.lifecycle.LifecycleOwner)?.lifecycleScope
?: StateApp.instance.scopeOrNull
?: StateApp.instance.scope
UIDialogs.showAutomaticRestoreDialog(context, scope)
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
}, UIDialogs.ActionStyle.PRIMARY)
)
} else {
dialogAction()
);
else {
dialogAction();
}
}
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
val dialog = AutomaticRestoreDialog(context, scope);
registerDialogOpened(dialog);
@@ -219,9 +203,7 @@ class UIDialogs {
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions);
}
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog
= showDialog(context, icon, animated, text, textDetails, code, null, null, defaultCloseAction, *actions);
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, input: String?, placeholder: String?, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
val builder = AlertDialog.Builder(context);
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
builder.setView(view);
@@ -244,16 +226,6 @@ class UIDialogs {
this.text = textDetails;
}
};
var inputView = view.findViewById<TextView>(R.id.dialog_text_input);
inputView.apply {
if (input == null && placeholder == null) this.visibility = View.GONE;
else {
this.text = input ?: "";
this.hint = placeholder ?: "";
this.visibility = View.VISIBLE;
this.textAlignment = View.TEXT_ALIGNMENT_VIEW_START
}
};
view.findViewById<TextView>(R.id.dialog_text_code).apply {
if (code == null) this.visibility = View.GONE;
else {
@@ -278,7 +250,7 @@ class UIDialogs {
buttonView.textSize = 14f;
buttonView.typeface = resources.getFont(R.font.inter_regular);
buttonView.text = act.text;
buttonView.setOnClickListener { act.invokeAction(DialogResult(inputView?.text?.toString())); dialog.dismiss(); };
buttonView.setOnClickListener { act.action(); dialog.dismiss(); };
when(act.style) {
ActionStyle.PRIMARY -> buttonView.setBackgroundResource(R.drawable.background_button_primary);
ActionStyle.ACCENT -> buttonView.setBackgroundResource(R.drawable.background_button_accent);
@@ -303,7 +275,7 @@ class UIDialogs {
};
dialog.setOnCancelListener {
if(defaultCloseAction >= 0 && defaultCloseAction < actions.size)
actions[defaultCloseAction].invokeAction(DialogResult(inputView?.text?.toString()));
actions[defaultCloseAction].action();
}
dialog.setOnDismissListener {
registerDialogClosed(dialog);
@@ -386,19 +358,17 @@ class UIDialogs {
}
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null): AlertDialog {
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) {
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
return showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction).apply {
setOnDismissListener { dismissAction?.invoke() }
}
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
}
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null): AlertDialog {
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null) {
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE)
return showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
}
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
@@ -421,6 +391,13 @@ class UIDialogs {
dialog.setMaxVersion(lastVersion);
}
fun showInstallDownloadedUpdateDialog(context: Context, apkFile: File) {
val dialog = AutoUpdateDialog(context);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.showPredownloaded(apkFile);
}
fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) {
if(!store.hasMissingReconstructions())
onConcluded();
@@ -558,36 +535,17 @@ class UIDialogs {
}
class Action {
val text: String;
val action: ((DialogResult?)->Unit);
val action: ()->Unit;
val style: ActionStyle;
var center: Boolean;
constructor(text: String, action: ()->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
this.text = text;
this.action = { action() };
this.style = style;
this.center = center;
}
protected constructor(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
this.text = text;
this.action = action;
this.style = style;
this.center = center;
}
fun invokeAction(input: DialogResult? = null) {
this.action(input);
}
companion object {
fun withInput(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false): Action {
return Action(text, action, style, center);
}
}
}
class DialogResult(
val text: String?
);
enum class ActionStyle {
NONE,
PRIMARY,
@@ -5,7 +5,6 @@ import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.view.View
import android.view.ViewGroup
import androidx.annotation.OptIn
@@ -15,6 +14,7 @@ import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
@@ -74,9 +74,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
import androidx.core.net.toUri
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
import kotlin.collections.toList
class UISlideOverlays {
companion object {
@@ -334,9 +331,15 @@ class UISlideOverlays {
0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Configure", {
StateApp.instance.activity?.let {
it.navigate(it.getFragment<SettingsFragment>(), mainContext.getString(R.string.background_update))
}
val intent = Intent(
mainContext,
SettingsActivity::class.java
);
intent.putExtra(
"query",
mainContext.getString(R.string.background_update)
);
mainContext.startActivity(intent);
}, UIDialogs.ActionStyle.PRIMARY)
);
}
@@ -576,51 +579,6 @@ class UISlideOverlays {
return null;
}
val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
lang -> videoSources
.filter { v -> v.language == lang }
.map { it.height * it.width }
.distinct()
.map { res -> Pair(res, lang) }
} else listOf();
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
var selectedLanguage: String? = null;
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
SlideUpMenuButtonList(container.context, null, "language_filter", true).apply {
var languageFilterLabels = allLanguages.filterNotNull().toList();
val english = languageFilterLabels.find { it?.lowercase() == "en" };
val originalLanguage = videoSources?.find { it.original == true }?.language;
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
if(english != null)
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
if(originalLanguage != null)
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
setButtons(languageFilterLabels, selectedLanguage);
onClick.subscribe { selected ->
setSelected(selected);
videoSourceItems.forEach {
val item = it.itemTag;
if(item is IVideoSource) {
if(item.language == selected)
it.visibility = View.VISIBLE;
else
it.visibility = View.GONE;
}
}
}
}
else null;
if(languageFilters != null) items.add(languageFilters)
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
listOf((if (audioSources != null) listOf(SlideUpMenuItem(
container.context,
@@ -657,13 +615,7 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
)
}
is JSDashManifestRawSource -> {
@@ -683,13 +635,7 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
)
}
is IHLSManifestSource -> {
@@ -703,13 +649,7 @@ class UISlideOverlays {
showHlsPicker(video, it, it.url, container)
},
invokeParent = false
).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
)
}
else -> {
@@ -1,63 +0,0 @@
package com.futo.platformplayer
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationManagerCompat
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() {
override fun onReceive(context: Context, intent: Intent) {
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)
}
}
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) {
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
val cancelIntent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_CANCEL, true)
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
}
ContextCompat.startForegroundService(context, cancelIntent)
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_DOWNLOADING)
}
}
@@ -1,64 +0,0 @@
package com.futo.platformplayer
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class UpdateCheckWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
if (!Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
Logger.i(TAG, "Auto-update disabled, skipping worker run")
return Result.success()
}
return withContext(Dispatchers.IO) {
try {
val client = ManagedHttpClient()
val latestVersion = StateUpdate.Companion.instance.downloadVersionCode(client)
if (latestVersion == null) {
Logger.w(TAG, "Failed to fetch latest version in worker")
return@withContext Result.retry()
}
val currentVersion = BuildConfig.VERSION_CODE
Logger.i(TAG, "Worker check: current=$currentVersion, latest=$latestVersion")
if (latestVersion <= currentVersion) {
return@withContext Result.success()
}
UpdateNotificationManager.showUpdateAvailableNotification(applicationContext, latestVersion)
if (StateApp.instance.isMainActive) {
withContext(Dispatchers.Main) {
StateApp.withContext { ctx ->
try {
UIDialogs.showUpdateAvailableDialog(ctx, latestVersion, false)
} catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update dialog from worker", t)
}
}
}
}
Result.success()
} catch (t: Throwable) {
Logger.w(TAG, "Exception in UpdateCheckWorker", t)
Result.retry()
}
}
}
companion object {
private const val TAG = "UpdateCheckWorker"
const val UNIQUE_WORK_NAME = "updateCheck"
}
}
@@ -1,302 +0,0 @@
package com.futo.platformplayer
import android.app.Dialog
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.os.SystemClock
import com.futo.platformplayer.UIDialogs.ActionStyle
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.SessionAnnouncement
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.*
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.time.OffsetDateTime
class UpdateDownloadService : Service() {
companion object {
private const val TAG = "UpdateDownloadService"
const val EXTRA_VERSION = "version"
const val EXTRA_CANCEL = "cancel"
private const val MAX_RETRIES = 5
private const val INITIAL_BACKOFF_MS = 5_000L
private const val BUFFER_SIZE = 8 * 1024
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
var updateDownloadedDialog: Dialog? = null
}
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + job)
@Volatile
private var isDownloading: Boolean = false
@Volatile
private var cancelRequested: Boolean = false
private var lastProgressUpdateElapsedMs: Long = 0L
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) {
stopSelf()
return START_NOT_STICKY
}
if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
cancelRequested = true
Logger.i(TAG, "Download cancel requested")
stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
val version = intent.getIntExtra(EXTRA_VERSION, 0)
if (version == 0) {
stopSelf()
return START_NOT_STICKY
}
if (isDownloading) {
Logger.i(TAG, "Download already in progress, ignoring new start")
return START_STICKY
}
isDownloading = true
cancelRequested = false
val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true)
startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
scope.launch {
downloadApk(version)
}
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
job.cancel()
}
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean, onProgress: ((Int) -> Unit)? = null) {
val now = SystemClock.elapsedRealtime()
val force = progress == 100 && !indeterminate
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
lastProgressUpdateElapsedMs = now
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate);
if(onProgress != null)
onProgress.invoke(progress);
}
}
private suspend fun downloadApk(version: Int) {
val apkFile = StateUpdate.getApkFile(this, version)
val partialFile = StateUpdate.getPartialApkFile(this, version)
var announcement: SessionAnnouncement? = null;
try {
if (apkFile.exists() && apkFile.length() > 0L) {
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
onDownloadComplete(version, apkFile)
return
}
try {
announcement = StateAnnouncement.instance.registerLoading("Downloading new version [${version}]", "New version is being downloaded..",
ImageVariable.fromResource(R.drawable.foreground));
}
catch(ex: Exception){
Logger.e(TAG, "Failed to set progress announcement", ex);
}
var backoffMs = INITIAL_BACKOFF_MS
for (attempt in 0 until MAX_RETRIES) {
if (cancelRequested) {
Logger.i(TAG, "Download cancelled before attempt ${attempt + 1}")
break
}
try {
performDownload(StateUpdate.APK_URL, partialFile, version, {
try {
if (announcement != null)
announcement?.setProgress(it);
}
catch(ex: Throwable) {}
})
if (!cancelRequested) {
if (apkFile.exists()) {
apkFile.delete()
}
if (!partialFile.renameTo(apkFile)) {
throw IllegalStateException("Failed to rename partial APK file")
}
onDownloadComplete(version, apkFile)
}
break
} catch (t: Throwable) {
if (cancelRequested) {
Logger.i(TAG, "Download cancelled by user", t)
break
}
if (attempt == MAX_RETRIES - 1) {
Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
break
} else {
Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t)
delay(backoffMs)
backoffMs *= 2
}
}
}
} finally {
try {
if (announcement != null) {
StateAnnouncement.instance.closeAnnouncement(announcement.id);
}
}
catch(ex: Throwable){}
isDownloading = false
cancelRequested = false
stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private fun performDownload(url: String, partialFile: File, version: Int, onProgress: ((Int)->Unit)? = null) {
var startOffset = if (partialFile.exists()) partialFile.length() else 0L
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
var connection: HttpURLConnection? = null
try {
connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 30_000
if (startOffset > 0L) {
setRequestProperty("Range", "bytes=$startOffset-")
}
}
connection.connect()
val responseCode = connection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK && startOffset > 0L) {
Logger.w(TAG, "Server ignored Range header, restarting download from scratch")
partialFile.delete()
startOffset = 0L
} else if (responseCode != HttpURLConnection.HTTP_OK &&
responseCode != HttpURLConnection.HTTP_PARTIAL) {
throw IllegalStateException("Unexpected HTTP response code $responseCode")
}
val contentLength = connection.contentLengthLong
val totalBytes = if (contentLength > 0L) startOffset + contentLength else -1L
val buffer = ByteArray(BUFFER_SIZE)
var downloaded = 0L
var lastProgress = -1
connection.inputStream.use { input ->
FileOutputStream(partialFile, startOffset > 0L).use { output ->
while (!cancelRequested) {
val read = input.read(buffer)
if (read == -1) {
break
}
output.write(buffer, 0, read)
downloaded += read
if (totalBytes > 0L) {
val progress = (((startOffset + downloaded) * 100L) / totalBytes).toInt()
if (progress != lastProgress) {
lastProgress = progress
val safeProgress = when {
progress < 0 -> 0
progress > 100 -> 100
else -> progress
}
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false, onProgress)
}
} else {
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
}
}
if (!cancelRequested && totalBytes > 0L) {
val finalProgress = 100
throttledUpdateDownloadProgress(version, finalProgress, indeterminate = false)
}
output.flush()
}
}
if (cancelRequested) {
throw CancellationException("Download cancelled")
}
if (totalBytes > 0L && startOffset + downloaded < totalBytes) {
throw IllegalStateException("Download incomplete: expected=$totalBytes, got=${startOffset + downloaded}")
}
} finally {
connection?.disconnect()
}
}
private fun onDownloadComplete(version: Int, apkFile: File) {
Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}")
UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile)
if (StateApp.instance.isMainActive) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
StateApp.withContext { ctx ->
try {
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
"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
}
}
}
}
}
}
@@ -1,122 +0,0 @@
package com.futo.platformplayer
import android.annotation.SuppressLint
import android.app.PendingIntent.FLAG_MUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.PendingIntent.getBroadcast
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.graphics.drawable.Animatable
import android.provider.Settings
import android.view.View
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.receivers.InstallReceiver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
import androidx.core.net.toUri
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.states.StateApp
object UpdateInstaller {
private const val TAG = "UpdateInstaller"
@SuppressLint("RequestInstallPackagesPolicy")
fun startInstall(context: Context, version: Int, apkFile: File) {
if (!apkFile.exists()) {
Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}")
UIDialogs.toast(context, "Update file missing")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "APK file does not exist.")
return
}
if (BuildConfig.IS_PLAYSTORE_BUILD) {
UIDialogs.toast(context, "Updates are managed by the Play Store")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Updates are managed by the Play Store.")
return
}
try {
val pm = context.packageManager
if (!pm.canRequestPackageInstalls()) {
UIDialogs.toast(context, "Allow this app to install updates, then try again")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Install update permission was missing.")
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${context.packageName}".toUri()
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
return
}
} catch (t: Throwable) {
Logger.e(TAG, "Failed to check unknown sources permission", t)
}
GlobalScope.launch(Dispatchers.IO) {
var inputStream: InputStream? = null
var session: PackageInstaller.Session? = null
try {
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
val sessionId = packageInstaller.createSession(params)
session = packageInstaller.openSession(sessionId)
inputStream = apkFile.inputStream()
val dataLength = apkFile.length()
session.openWrite("package", 0, dataLength).use { sessionStream ->
inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
session.fsync(sessionStream)
}
val intent = Intent(context, InstallReceiver::class.java).apply {
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkFile.absolutePath)
}
val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val statusReceiver = pendingIntent.intentSender
InstallReceiver.onReceiveResult.subscribe(this) { message ->
InstallReceiver.onReceiveResult.clear();
onReceiveResult(context, version, apkFile, message);
};
Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}")
session.commit(statusReceiver)
} catch (e: Throwable) {
Logger.w(TAG, "Exception while installing update", e)
session?.abandon()
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to install update: ${e.message}")
}
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message)
} finally {
session?.close()
inputStream?.close()
}
}
}
private fun onReceiveResult(context: Context, version: Int, apkFile: File, result: String?) {
try {
InstallReceiver.onReceiveResult.remove(this)
if (result.isNullOrEmpty()) {
Logger.i(TAG, "Update install finished successfully")
UpdateNotificationManager.showInstallSucceededNotification(context, version)
} else {
Logger.w(TAG, "Update install failed: $result")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle install result", e)
}
}
}
@@ -1,233 +0,0 @@
package com.futo.platformplayer
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_MUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.PendingIntent.getBroadcast
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.futo.platformplayer.activities.InstallUpdateActivity
import java.io.File
object UpdateNotificationManager {
private const val CHANNEL_ID = "app_updates"
private const val CHANNEL_NAME = "App updates"
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_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
private const val REQUEST_CODE_INSTALL = 1001
const val EXTRA_VERSION = "version"
const val EXTRA_APK_PATH = "apk_path"
const val NOTIF_ID_AVAILABLE = 2001
const val NOTIF_ID_DOWNLOADING = 2002
const val NOTIF_ID_READY = 2003
const val NOTIF_ID_INSTALL_FAILED = 2004
const val NOTIF_ID_INSTALL_SUCCEEDED = 2005
fun ensureChannel(context: Context) {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
description = CHANNEL_DESCRIPTION
enableVibration(false)
enableLights(false)
setSound(null, null)
}
manager.createNotificationChannel(channel)
}
}
fun showInstallSucceededNotification(context: Context, version: Int) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val launchIntent = context.packageManager
.getLaunchIntentForPackage(context.packageName)
?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
}
val launchPendingIntent = launchIntent?.let {
PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Update installed")
.setContentText("Version $version installed. Tap to open.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setSilent(true)
if (launchPendingIntent != null) {
builder.setContentIntent(launchPendingIntent)
builder.addAction(0, "Open app", launchPendingIntent)
}
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 {
ensureChannel(context)
val cancelIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_DOWNLOAD_CANCEL
putExtra(EXTRA_VERSION, version)
}
val cancelPendingIntent = getBroadcast(
context,
3,
cancelIntent,
FLAG_MUTABLE or FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Downloading update")
.setContentText("Downloading version $version")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setOngoing(true)
.setSilent(true)
.addAction(0, "Cancel", cancelPendingIntent)
if (indeterminate) {
builder.setProgress(0, 0, true)
} else {
builder.setProgress(100, progress, false)
}
return builder.build()
}
fun updateDownloadProgress(context: Context, version: Int, progress: Int, indeterminate: Boolean) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
val notification = buildDownloadProgressNotification(context, version, progress, indeterminate)
NotificationManagerCompat.from(context).notify(NOTIF_ID_DOWNLOADING, notification)
}
fun showDownloadCompleteNotification(context: Context, version: Int, apkFile: File) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Update downloaded")
.setContentText("Tap to install version $version.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(installPendingIntent)
.setAutoCancel(true)
.setSilent(true)
.addAction(0, "Install", installPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
}
fun showDownloadFailedNotification(context: Context, version: Int, error: Throwable?) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Failed to download update")
.setContentText(error?.message ?: "Unknown error")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setSilent(true)
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
}
fun showInstallFailedNotification(context: Context, version: Int, apkFile: File, error: String?) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
return
ensureChannel(context)
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Failed to install update")
.setContentText(if (error != null && error.isNotBlank()) "$error Tap to try again." else "Tap to try again.")
.setAutoCancel(true)
.setSilent(true)
.setContentIntent(installPendingIntent)
.addAction(0, "Install again", installPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_FAILED, builder.build())
}
fun cancelAll(context: Context) {
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
}
}
@@ -5,6 +5,8 @@ import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.icu.util.Output
import android.os.Build
import android.os.Looper
import android.os.OperationCanceledException
@@ -42,9 +44,6 @@ import java.util.*
import java.util.concurrent.ThreadLocalRandom
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
import androidx.core.graphics.scale
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
fun getRandomString(sizeOfRandomString: Int): String {
@@ -102,7 +101,7 @@ fun String.isHexColor(): Boolean {
fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPooled(this);
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
fun DocumentFile.getInputStream(context: Context) = context.contentResolver.openInputStream(this.uri);
fun DocumentFile.getOutputStream(context: Context) = context.contentResolver.openOutputStream(this.uri);
@@ -115,6 +114,23 @@ fun DocumentFile.writeBytes(context: Context, byteArray: ByteArray) = context.co
it.flush();
};
fun loadBitmap(url: String): Bitmap {
try {
val client = ManagedHttpClient();
val response = client.get(url);
if (response.isOk && response.body != null) {
val bitmapStream = response.body.byteStream();
val bitmap = BitmapFactory.decodeStream(bitmapStream);
return bitmap;
} else {
throw Exception("Failed to find data at URL.");
}
} catch (e: Throwable) {
Logger.w("Utility", "Exception thrown while downloading bitmap.", e);
throw e;
}
}
fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) {
this.movementMethod = PlatformLinkMovementMethod(context);
}
@@ -442,11 +458,4 @@ fun addressScore(addr: InetAddress): Int {
}
}
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
fun <T> RequestBuilder<T>.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder<T> {
return this;
//.downsample(DownsampleStrategy.AT_MOST)
//.override(maxSizePx, maxSizePx)
//.centerInside()
}
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
@@ -107,9 +107,10 @@ class AddSourceActivity : AppCompatActivity() {
onNewIntent(intent);
}
override fun onNewIntent(intent: Intent) {
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
var url = intent.dataString;
var url = intent?.dataString;
if(url == null)
UIDialogs.showDialog(this, R.drawable.ic_error, getString(R.string.no_valid_url_provided), null, null,
0, UIDialogs.Action(getString(R.string.ok), { finish() }, UIDialogs.ActionStyle.PRIMARY));
@@ -68,15 +68,11 @@ class CaptchaActivity : AppCompatActivity() {
intent.getStringExtra("body");
else null;
// 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.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";
_webView.settings.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = true;
val webViewClient = if(config != null) CaptchaWebViewClient(config, capturedUserAgent) else CaptchaWebViewClient(captchaConfig, capturedUserAgent);
val webViewClient = if(config != null) CaptchaWebViewClient(config) else CaptchaWebViewClient(captchaConfig);
webViewClient.onCaptchaFinished.subscribe { captcha ->
_callback?.let {
_callback = null;
@@ -0,0 +1,58 @@
package com.futo.platformplayer.activities
import android.annotation.SuppressLint
import android.os.Bundle
import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.*
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.IField
class DeveloperActivity : AppCompatActivity() {
private lateinit var _form: FieldForm;
private lateinit var _buttonBack: ImageButton;
fun getField(id: String): IField? {
return _form.findField(id);
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
DeveloperActivity._lastActivity = this;
setContentView(R.layout.activity_dev);
setNavigationBarColorAndIcons();
_buttonBack = findViewById(R.id.button_back);
_form = findViewById(R.id.settings_form);
_form.fromObject(SettingsDev.instance);
_form.onChanged.subscribe { _, _ ->
_form.setObjectValues();
SettingsDev.instance.save();
};
_buttonBack.setOnClickListener {
finish();
}
}
override fun finish() {
super.finish()
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
}
companion object {
//TODO: Temporary for solving Settings issues
@SuppressLint("StaticFieldLeak")
private var _lastActivity: DeveloperActivity? = null;
fun getActivity(): DeveloperActivity? {
val act = _lastActivity;
if(act != null)
return act;
return null;
}
}
}
@@ -1,49 +0,0 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UpdateInstaller
import com.futo.platformplayer.UpdateNotificationManager
import com.futo.platformplayer.logging.Logger
import java.io.File
class InstallUpdateActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
UpdateNotificationManager.cancelAll(this)
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
if (version == 0 || apkPath.isNullOrEmpty()) {
Logger.w("InstallUpdateActivity", "Missing version or apkPath")
finish()
return
}
val apkFile = File(apkPath)
if (!apkFile.exists()) {
Logger.w("InstallUpdateActivity", "APK file does not exist: $apkPath")
UIDialogs.Companion.toast(this, "Update file missing")
finish()
return
}
UpdateInstaller.startInstall(this, version, apkFile)
finish()
}
companion object {
fun createIntent(context: Context, version: Int, apkPath: String): Intent =
Intent(context, InstallUpdateActivity::class.java).apply {
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkPath)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
}
@@ -15,7 +15,6 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.matchesDomain
import com.futo.platformplayer.others.LoginWebViewClient
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
@@ -61,15 +60,11 @@ class LoginActivity : AppCompatActivity() {
else throw IllegalStateException("No valid configuration?");
//TODO: Backwards compat removal?
// 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.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";
_webView.settings.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = true;
val webViewClient = if(config != null) LoginWebViewClient(config, capturedUserAgent) else LoginWebViewClient(authConfig, capturedUserAgent);
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig);
webViewClient.onLogin.subscribe { auth ->
_callback?.let {
@@ -79,26 +74,9 @@ class LoginActivity : AppCompatActivity() {
finish();
};
var isFirstLoad = true;
val loginWarnings = authConfig.loginWarnings?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.Warning>();
val uiMods = authConfig.uiMods?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.UIMod>();
var currentScale = 100;
var currentDesktop = false;
webViewClient.onPageLoaded.subscribe { view, url ->
_textUrl.setText(url ?: "");
if(loginWarnings.size > 0 && url != null) {
synchronized(loginWarnings) {
val warning = loginWarnings.find { url.matches(it.getRegex()) };
if(warning != null) {
if(warning.once == true)
loginWarnings.remove(warning);
UIDialogs.showDialog(this@LoginActivity, R.drawable.ic_warning_yellow, warning.text ?: "", warning.details ?: "", null, 0,
UIDialogs.Action("Understood", {
}, UIDialogs.ActionStyle.PRIMARY));
}
}
}
if(!isFirstLoad)
return@subscribe;
isFirstLoad = false;
@@ -108,35 +86,6 @@ class LoginActivity : AppCompatActivity() {
//TODO: Find most reliable way to wait for page js to finish
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
}
/*
var specifiedScale = false;
var specifiedDesktop = false;
if(uiMods.size > 0 && url != null) {
synchronized(uiMods) {
val uimod = uiMods.find { url.matches(it.getRegex()) };
if(uimod != null) {
if(uimod.scale != null) {
currentScale =(uimod.scale * 100).toInt();
_webView.setInitialScale(currentScale);
specifiedScale = true;
}
if(uimod.desktop != null && uimod.desktop) {
_webView.settings.useWideViewPort = true;
specifiedDesktop = true;
}
}
}
}
if(!specifiedScale && currentScale != 100) {
currentScale = (100).toInt();
_webView.setInitialScale(currentScale);
}
if(!specifiedDesktop && currentDesktop) {
_webView.settings.useWideViewPort = false;
currentDesktop = false;
}
*/
}
_webView.settings.domStorageEnabled = true;
@@ -8,7 +8,6 @@ import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
@@ -17,6 +16,7 @@ import android.os.StrictMode.VmPolicy
import android.util.Log
import android.util.TypedValue
import android.view.View
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.activity.result.ActivityResult
@@ -36,11 +36,9 @@ import androidx.lifecycle.withStateAtLeast
import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.RootInsetsController
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.video.LocalVideoDetails
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
@@ -53,28 +51,17 @@ import com.futo.platformplayer.fragment.mainactivity.main.CommentsFragment
import com.futo.platformplayer.fragment.mainactivity.main.ContentSearchResultsFragment
import com.futo.platformplayer.fragment.mainactivity.main.CreatorSearchResultsFragment
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
import com.futo.platformplayer.fragment.mainactivity.main.DeveloperFragment
import com.futo.platformplayer.fragment.mainactivity.main.DownloadsFragment
import com.futo.platformplayer.fragment.mainactivity.main.HistoryFragment
import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment
import com.futo.platformplayer.fragment.mainactivity.main.ImportPlaylistsFragment
import com.futo.platformplayer.fragment.mainactivity.main.ImportSubscriptionsFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryAlbumFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryAlbumsFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryArtistFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryArtistsFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFilesFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibrarySearchFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryVideosFragment
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistFragment
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsFragment
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.fragment.mainactivity.main.ShortsFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment
@@ -88,7 +75,6 @@ import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.St
import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment
import com.futo.platformplayer.fragment.mainactivity.main.WebDetailFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.FilesTopBarFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
@@ -110,7 +96,6 @@ import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView
import com.futo.platformplayer.views.notification.NotificationOverlayView
import com.futo.polycentric.core.ApiMethods
import com.google.gson.JsonParser
import com.google.zxing.integration.android.IntentIntegrator
@@ -161,7 +146,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragTopBarNavigation: NavigationTopBarFragment;
lateinit var _fragTopBarImport: ImportTopBarFragment;
lateinit var _fragTopBarAdd: AddTopBarFragment;
lateinit var _fragTopBarFiles: FilesTopBarFragment;
//Frags BotBar
lateinit var _fragBotBarMenu: MenuBottomBarFragment;
@@ -194,18 +178,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragBuy: BuyFragment;
lateinit var _fragSubGroup: SubscriptionGroupFragment;
lateinit var _fragSubGroupList: SubscriptionGroupListFragment;
lateinit var _fragLibrary: LibraryFragment;
lateinit var _fragLibraryAlbums: LibraryAlbumsFragment;
lateinit var _fragLibraryAlbum: LibraryAlbumFragment;
lateinit var _fragLibraryArtists: LibraryArtistsFragment;
lateinit var _fragLibraryArtist: LibraryArtistFragment;
lateinit var _fragLibraryVideos: LibraryVideosFragment;
lateinit var _fragLibrarySearch: LibrarySearchFragment;
lateinit var _fragLibraryFiles: LibraryFilesFragment;
lateinit var _fragNotifications: NotificationOverlayView.Frag;
lateinit var _fragSettings: SettingsFragment;
lateinit var _fragDeveloper: DeveloperFragment;
lateinit var _fragLogin: LoginFragment;
lateinit var _fragBrowser: BrowserFragment;
@@ -214,7 +186,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//State
private val _queue: LinkedList<Pair<MainFragment, Any?>> = LinkedList();
var fragCurrent: MainFragment? = null; private set;
lateinit var fragCurrent: MainFragment private set;
private var _parameterCurrent: Any? = null;
var fragBeforeOverlay: MainFragment? = null; private set;
@@ -226,7 +198,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private var _privateModeEnabled = false
private var _pictureInPictureEnabled = false
private var _isFullscreen = false
private lateinit var _rootInsetsController: RootInsetsController
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
@@ -302,7 +273,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
@UnstableApi
override fun onCreate(savedInstanceState: Bundle?) {
Logger.w(TAG, "MainActivity Starting [$mainId]");
StateApp.instance.setGlobalContext(this, lifecycleScope, mainId);
StateApp.instance.mainAppStarting(this);
@@ -313,6 +283,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
setContentView(R.layout.activity_main);
setNavigationBarColorAndIcons();
if (Settings.instance.playback.allowVideoToGoUnderCutout)
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
runBlocking {
try {
@@ -322,18 +295,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
//Preload common files to memory
FragmentedStorage.get<SubscriptionStorage>();
FragmentedStorage.get<Settings>();
rootView = findViewById(R.id.rootView);
_rootInsetsController = RootInsetsController.attach(this, rootView)
_rootInsetsController.setLightSystemBarAppearance(lightStatus = false, lightNav = false)
_fragContainerTopBar = findViewById(R.id.fragment_top_bar);
_fragContainerMain = findViewById(R.id.fragment_main);
_fragContainerBotBar = findViewById(R.id.fragment_bottom_bar);
@@ -350,7 +316,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragTopBarNavigation = NavigationTopBarFragment.newInstance();
_fragTopBarImport = ImportTopBarFragment.newInstance();
_fragTopBarAdd = AddTopBarFragment.newInstance();
_fragTopBarFiles = FilesTopBarFragment.newInstance();
//BotBars
_fragBotBarMenu = MenuBottomBarFragment.newInstance();
@@ -383,18 +348,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragBuy = BuyFragment.newInstance();
_fragSubGroup = SubscriptionGroupFragment.newInstance();
_fragSubGroupList = SubscriptionGroupListFragment.newInstance();
_fragLibrary = LibraryFragment.newInstance();
_fragLibraryAlbums = LibraryAlbumsFragment.newInstance();
_fragLibraryAlbum = LibraryAlbumFragment.newInstance();
_fragLibraryArtists = LibraryArtistsFragment.newInstance();
_fragLibraryArtist = LibraryArtistFragment.newInstance();
_fragLibraryVideos = LibraryVideosFragment.newInstance();
_fragLibraryFiles = LibraryFilesFragment.newInstance();
_fragLibrarySearch = LibrarySearchFragment.newInstance();
_fragNotifications = NotificationOverlayView.Frag();
_fragSettings = SettingsFragment.newInstance();
_fragDeveloper = DeveloperFragment.newInstance();
_fragLogin = LoginFragment.newInstance();
_fragBrowser = BrowserFragment.newInstance();
@@ -413,17 +366,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
updateSegmentPaddings();
};
_fragVideoDetail.onTransitioning.subscribe {
if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED) {
Logger.i(TAG, "onTransition Setting elevation higher");
if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED)
_fragContainerOverlay.elevation =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics);
}
else {
Logger.i(TAG, "onTransition Setting elevation lower");
else
_fragContainerOverlay.elevation =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
}
}
_fragVideoDetail.onCloseEvent.subscribe {
@@ -462,11 +410,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "onFullscreenChanged ${it}");
_isFullscreen = it
updatePrivateModeVisibility()
if (it) {
_rootInsetsController.enterFullscreen(allowCutoutShortEdges = Settings.instance.playback.allowVideoToGoUnderCutout)
} else {
_rootInsetsController.exitFullscreen()
}
}
_fragVideoDetail.onMinimize.subscribe {
@@ -531,17 +474,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragImportSubscriptions.topBar = _fragTopBarImport;
_fragImportPlaylists.topBar = _fragTopBarImport;
_fragSubGroupList.topBar = _fragTopBarAdd;
_fragLibrary.topBar = _fragTopBarGeneral;
_fragLibraryAlbums.topBar = _fragTopBarNavigation;
_fragLibraryAlbum.topBar = _fragTopBarNavigation;
_fragLibraryArtists.topBar = _fragTopBarNavigation;
_fragLibraryArtist.topBar = _fragTopBarNavigation;
_fragLibraryVideos.topBar = _fragTopBarNavigation;
_fragLibraryFiles.topBar = _fragTopBarFiles;
_fragLibrarySearch.topBar = _fragTopBarSearch;
_fragSettings.topBar = _fragTopBarNavigation;
_fragDeveloper.topBar = _fragTopBarNavigation;
_fragNotifications.topBar = _fragTopBarGeneral;
_fragBrowser.topBar = _fragTopBarNavigation;
@@ -567,7 +499,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
defaultTab.action(_fragBotBarMenu);
StateSubscriptions.instance;
fragCurrent?.onShown(null, false);
fragCurrent.onShown(null, false);
//Other stuff
rootView.progress = 0f;
@@ -622,10 +554,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled() && Settings.instance.autoUpdate.shouldBackgroundDownload) {
requestNotificationPermissions("You have enabled background updating.\n\nGrayjay uses notifications to inform you when a new app update is available.");
}
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
@@ -710,11 +638,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private var _qrCodeLoadingDialog: AlertDialog? = null
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
_rootInsetsController.onConfigurationChanged()
}
fun showUrlQrCodeScanner() {
try {
_qrCodeLoadingDialog = UIDialogs.showDialog(this, R.drawable.ic_loader_animated, true,
@@ -773,13 +696,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_wasStopped = true;
}
override fun onNewIntent(intent: Intent) {
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent);
handleIntent(intent);
}
private fun handleIntent(intent: Intent) {
private fun handleIntent(intent: Intent?) {
if (intent == null)
return;
Logger.i(TAG, "handleIntent started by " + intent.action);
var targetData: String? = null;
when (intent.action) {
@@ -841,7 +768,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (targetData != null) {
lifecycleScope.launch(Dispatchers.Main) {
try {
handleUrlAll(targetData, intent)
handleUrlAll(targetData)
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
}
@@ -852,9 +779,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
suspend fun handleUrlAll(url: String, openIntent: Intent? = null) {
suspend fun handleUrlAll(url: String) {
val uri = Uri.parse(url)
val intent = openIntent ?: this.intent;
when (uri.scheme) {
"grayjay" -> {
if (url.startsWith("grayjay://license/")) {
@@ -881,11 +807,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
"content" -> {
if (!handleContent(url, intent?.type)) {
if (!handleContent(url, intent.type)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
getString(R.string.unknown_content_format) + " [${url}]\n[${intent?.type}]",
getString(R.string.unknown_content_format) + " [${url}]\n[${intent.type}]",
"Ok",
{ });
}
@@ -1006,12 +932,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} else if (file.lowercase().endsWith(".txt") || mime == "text/plain") {
return handleUnknownText(String(data));
}
else if (mime?.let { it.startsWith("video/") || it.startsWith("audio/") } ?: false) {
val mediaItem = LocalVideoDetails.fromContent(file, mime);
navigateWhenReady(_fragVideoDetail, mediaItem);
return true;
}
return false;
}
@@ -1126,7 +1046,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "handleFCast");
try {
StateCasting.instance.handleUrl(url)
StateCasting.instance.handleUrl(this, url)
return true;
} catch (e: Throwable) {
Log.e(TAG, "Failed to parse FCast URL '${url}'.", e)
@@ -1158,7 +1078,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
return;
if (!(fragCurrent?.onBackPressed() ?: true))
if (!fragCurrent.onBackPressed())
closeSegment();
}
@@ -1209,11 +1129,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
inline fun <reified T : Fragment> navigate(parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
val segment = getFragment<T>();
navigate(segment as MainFragment, parameter, withHistory, isBack);
}
/**
* Navigate takes a MainFragment, and makes them the current main visible view
* A parameter can be provided which becomes available in the onShow of said fragment
@@ -1236,27 +1151,27 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return;
}
fragCurrent?.onHide();
fragCurrent.onHide();
if (segment.isMainView) {
var transaction = supportFragmentManager.beginTransaction();
if (segment.topBar != null) {
if (segment.topBar != fragCurrent?.topBar) {
if (segment.topBar != fragCurrent.topBar) {
transaction = transaction
.show(segment.topBar as Fragment)
.replace(R.id.fragment_top_bar, segment.topBar as Fragment);
fragCurrent?.topBar?.onHide();
fragCurrent.topBar?.onHide();
}
} else if (fragCurrent?.topBar != null)
transaction.hide(fragCurrent?.topBar as Fragment);
} else if (fragCurrent.topBar != null)
transaction.hide(fragCurrent.topBar as Fragment);
transaction = transaction.replace(R.id.fragment_main, segment);
if (segment.hasBottomBar) {
if (!(fragCurrent?.hasBottomBar ?: false))
if (!fragCurrent.hasBottomBar)
transaction = transaction.show(_fragBotBarMenu);
} else {
if (fragCurrent?.hasBottomBar ?: false)
if (fragCurrent.hasBottomBar)
transaction = transaction.hide(_fragBotBarMenu);
}
transaction.commitNow();
@@ -1269,10 +1184,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
if (fragCurrent?.isHistory ?: false && withHistory && _queue.lastOrNull() != fragCurrent)
_queue.add(Pair(fragCurrent!!, _parameterCurrent));
if (fragCurrent.isHistory && withHistory && _queue.lastOrNull() != fragCurrent)
_queue.add(Pair(fragCurrent, _parameterCurrent));
if (segment.isOverlay && !(fragCurrent?.isOverlay ?: false) && withHistory)// && fragCurrent.isHistory)
if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
fragBeforeOverlay = fragCurrent;
fragCurrent = segment;
@@ -1303,24 +1218,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
navigate(last.first, last.second, false, true);
} else {
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
Logger.i(TAG, "Closing activity because _fragVideoDetail.state == closed");
finish();
} else {
//UIDialogs.toast("Grayjay continues in background because of an open video.")
if(Settings.instance.playback.isBackgroundPictureInPicture()) {
try {
_fragVideoDetail._viewDetail?.startPictureInPicture();
_fragVideoDetail?.forcePictureInPicture();
} catch (ex: Throwable) {
} //Fail silently
}
else
moveTaskToBack(false);
/*
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
finish();
})
*/
}
}
}
@@ -1339,7 +1241,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
VideoDetailFragment::class -> _fragVideoDetail as T;
MenuBottomBarFragment::class -> _fragBotBarMenu as T;
GeneralTopBarFragment::class -> _fragTopBarGeneral as T;
FilesTopBarFragment::class -> _fragTopBarFiles as T;
SearchTopBarFragment::class -> _fragTopBarSearch as T;
CreatorsFragment::class -> _fragMainSubscriptions as T;
CommentsFragment::class -> _fragMainComments as T;
@@ -1364,18 +1265,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
BuyFragment::class -> _fragBuy as T;
SubscriptionGroupFragment::class -> _fragSubGroup as T;
SubscriptionGroupListFragment::class -> _fragSubGroupList as T;
LibraryFragment::class -> _fragLibrary as T;
LibraryAlbumsFragment::class -> _fragLibraryAlbums as T;
LibraryAlbumFragment::class -> _fragLibraryAlbum as T;
LibraryArtistsFragment::class -> _fragLibraryArtists as T;
LibraryArtistFragment::class -> _fragLibraryArtist as T;
LibraryVideosFragment::class -> _fragLibraryVideos as T;
LibraryFilesFragment::class -> _fragLibraryFiles as T;
LibrarySearchFragment::class -> _fragLibrarySearch as T;
NotificationOverlayView.Frag::class -> _fragNotifications as T;
SettingsFragment:: class -> _fragSettings as T;
DeveloperFragment::class -> _fragDeveloper as T;
LoginFragment::class -> _fragLogin as T;
else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity");
}
}
@@ -1383,7 +1272,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private fun updateSegmentPaddings() {
var paddingBottom = 0f;
if (fragCurrent?.hasBottomBar ?: false)
if (fragCurrent.hasBottomBar)
paddingBottom += HEIGHT_MENU_DP;
_fragContainerOverlay.setPadding(
@@ -1400,23 +1289,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
);
}
var _callbackPermissionAudio: ((Boolean)->Unit)? = null;
var _callbackPermissionVideo: ((Boolean)->Unit)? = null;
val permissionReqAudio = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
_callbackPermissionAudio?.invoke(isGranted);
});
val permissionReqVideo = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
_callbackPermissionVideo?.invoke(isGranted);
});
fun requestPermissionAudio(cb: ((Boolean)->Unit)? = null) {
_callbackPermissionAudio = cb;
permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO);
}
fun requestPermissionVideo(cb: ((Boolean)->Unit)? = null) {
_callbackPermissionVideo = cb;
permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO);
}
val notifPermission = "android.permission.POST_NOTIFICATIONS";
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
@@ -1543,4 +1415,4 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return sourcesIntent;
}
}
}
}
@@ -13,18 +13,15 @@ import android.view.View
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateApp.Companion.withContext
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.activities.QRCodeFullscreenActivity
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.SignedEvent
import com.futo.polycentric.core.StorageTypeCRDTItem
@@ -32,10 +29,8 @@ import com.futo.polycentric.core.StorageTypeCRDTSetItem
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.toBase64Url
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -46,27 +41,11 @@ import userpackage.Protocol.URLInfo
class PolycentricBackupActivity : AppCompatActivity() {
private lateinit var _buttonShare: BigButton;
private lateinit var _buttonCopy: BigButton;
private lateinit var _buttonExportFile: BigButton;
private lateinit var _imageQR: ImageView;
private lateinit var _exportBundle: String;
private lateinit var _textQR: TextView;
private lateinit var _textQRHint: TextView;
private lateinit var _loader: View
private val _createDocumentLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
uri?.let { fileUri ->
try {
contentResolver.openOutputStream(fileUri)?.use { outputStream ->
outputStream.write(_exportBundle.toByteArray())
}
UIDialogs.toast(this, getString(R.string.profile_saved_successfully))
} catch (e: Exception) {
Logger.e(TAG, "Failed to write to document", e)
UIDialogs.toast(this, "Failed to save profile: ${e.message}")
}
}
}
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
@@ -78,10 +57,8 @@ class PolycentricBackupActivity : AppCompatActivity() {
_buttonShare = findViewById(R.id.button_share)
_buttonCopy = findViewById(R.id.button_copy)
_buttonExportFile = findViewById(R.id.button_export_file)
_imageQR = findViewById(R.id.image_qr)
_textQR = findViewById(R.id.text_qr)
_textQRHint = findViewById(R.id.text_qr_hint)
_loader = findViewById(R.id.progress_loader)
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish();
@@ -89,23 +66,14 @@ class PolycentricBackupActivity : AppCompatActivity() {
_imageQR.visibility = View.INVISIBLE
_textQR.visibility = View.INVISIBLE
_textQRHint.visibility = View.INVISIBLE
_loader.visibility = View.VISIBLE
_buttonShare.visibility = View.INVISIBLE
_buttonCopy.visibility = View.INVISIBLE
_buttonExportFile.visibility = View.INVISIBLE
lifecycleScope.launch {
val bundle = withContext(Dispatchers.IO) { createExportBundle() }
_exportBundle = bundle
Logger.i(TAG, "Export bundle created, length: ${bundle.length}")
try {
val pair = withContext(Dispatchers.IO) {
if (!isContentSuitableForQRCode(bundle)) {
throw Exception("Data too big for QR code generation")
}
val bundle = createExportBundle()
val dimension = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics
).toInt()
@@ -113,35 +81,18 @@ class PolycentricBackupActivity : AppCompatActivity() {
Pair(bundle, qr)
}
_exportBundle = pair.first
_imageQR.setImageBitmap(pair.second)
_imageQR.visibility = View.VISIBLE
_textQR.visibility = View.VISIBLE
_textQRHint.visibility = View.VISIBLE
_buttonShare.visibility = View.VISIBLE
_buttonCopy.visibility = View.VISIBLE
_imageQR.setOnClickListener {
val intent = QRCodeFullscreenActivity.createIntent(this@PolycentricBackupActivity, _exportBundle)
startActivity(intent)
}
} catch (e: Exception) {
val byteSize = bundle.toByteArray(Charsets.UTF_8).size
Logger.e(TAG, "QR code generation failed. Bundle length: ${bundle.length} chars, ${byteSize} bytes, Error: ${e.message}", e)
if (e.message?.contains("Data too big") == true) {
_textQR.text = getString(R.string.qr_code_too_large_use_file_export)
_buttonExportFile.visibility = View.VISIBLE
} else {
_textQR.text = getString(R.string.failed_to_generate_qr_code)
}
_textQR.visibility = View.VISIBLE
_textQRHint.visibility = View.INVISIBLE
_buttonShare.visibility = View.VISIBLE
_buttonCopy.visibility = View.VISIBLE
// Hide QR image since generation failed
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e)
_imageQR.visibility = View.INVISIBLE
_textQR.visibility = View.INVISIBLE
_buttonShare.visibility = View.INVISIBLE
_buttonCopy.visibility = View.INVISIBLE
} finally {
_loader.visibility = View.GONE
}
@@ -157,29 +108,11 @@ class PolycentricBackupActivity : AppCompatActivity() {
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
clipboard.setPrimaryClip(clip);
};
_buttonExportFile.onClick.subscribe {
val fileName = "polycentric_profile_${System.currentTimeMillis()}.txt"
_createDocumentLauncher.launch(fileName)
};
}
private fun isContentSuitableForQRCode(content: String): Boolean {
val bytes = content.toByteArray(Charsets.UTF_8)
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
}
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
if (!isContentSuitableForQRCode(content)) {
throw Exception("Data too big for QR code generation")
}
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
hints[EncodeHintType.MARGIN] = 1
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
return bitMatrixToBitmap(bitMatrix)
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height);
return bitMatrixToBitmap(bitMatrix);
}
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
@@ -270,8 +203,7 @@ class PolycentricBackupActivity : AppCompatActivity() {
.setBody(exportBundle.toByteString())
.build();
val data = urlInfo.toByteArray()
return "polycentric://" + data.toBase64Url()
return "polycentric://" + urlInfo.toByteArray().toBase64Url()
}
companion object {
@@ -32,166 +32,100 @@ import userpackage.Protocol
import userpackage.Protocol.ExportBundle
class PolycentricImportProfileActivity : AppCompatActivity() {
private lateinit var _buttonHelp: ImageButton
private lateinit var _buttonScanProfile: LinearLayout
private lateinit var _buttonImportFile: LinearLayout
private lateinit var _buttonImportProfile: LinearLayout
private lateinit var _editProfile: EditText
private lateinit var _loaderOverlay: LoaderOverlay
private lateinit var _buttonHelp: ImageButton;
private lateinit var _buttonScanProfile: LinearLayout;
private lateinit var _buttonImportProfile: LinearLayout;
private lateinit var _editProfile: EditText;
private lateinit var _loaderOverlay: LoaderOverlay;
private val _qrCodeResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult =
IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let {
if (it.contents != null) {
val scannedUrl = it.contents
import(scannedUrl)
}
}
}
private val _filePickerLauncher =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { fileUri ->
try {
// Check file size before reading
val fileSize =
contentResolver.openFileDescriptor(fileUri, "r")?.statSize ?: 0
val maxFileSize = 10 * 1024 * 1024 // 10MB limit
if (fileSize > maxFileSize) {
UIDialogs.toast(this, "File too large. Maximum size is 10MB.")
return@let
}
if (fileSize == 0L) {
UIDialogs.toast(this, "Selected file is empty.")
return@let
}
val content =
contentResolver
.openInputStream(fileUri)
?.bufferedReader()
?.readText()
content?.let { fileContent ->
val trimmedContent = fileContent.trim()
// Check if content is empty after trimming
if (trimmedContent.isEmpty()) {
UIDialogs.toast(this, "Selected file contains no data.")
return@let
}
// Check if content looks like a valid polycentric URL
if (!trimmedContent.startsWith("polycentric://")) {
UIDialogs.toast(
this,
"Selected file does not contain a valid polycentric profile URL."
)
return@let
}
import(trimmedContent)
}
?: run { UIDialogs.toast(this, "Could not read file content.") }
} catch (e: SecurityException) {
Logger.e(TAG, "Security exception reading file", e)
UIDialogs.toast(this, "Permission denied to read file.")
} catch (e: OutOfMemoryError) {
Logger.e(TAG, "Out of memory reading file", e)
UIDialogs.toast(this, "File too large to process.")
} catch (e: Exception) {
Logger.e(TAG, "Failed to read file", e)
UIDialogs.toast(this, "Failed to read file: ${e.message}")
}
}
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let {
if (it.contents != null) {
val scannedUrl = it.contents
import(scannedUrl)
}
}
}
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_polycentric_import_profile)
setNavigationBarColorAndIcons()
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_import_profile);
setNavigationBarColorAndIcons();
_buttonHelp = findViewById(R.id.button_help)
_buttonScanProfile = findViewById(R.id.button_scan_profile)
_buttonImportFile = findViewById(R.id.button_import_file)
_buttonImportProfile = findViewById(R.id.button_import_profile)
_loaderOverlay = findViewById(R.id.loader_overlay)
_editProfile = findViewById(R.id.edit_profile)
findViewById<ImageButton>(R.id.button_back).setOnClickListener { finish() }
_buttonHelp = findViewById(R.id.button_help);
_buttonScanProfile = findViewById(R.id.button_scan_profile);
_buttonImportProfile = findViewById(R.id.button_import_profile);
_loaderOverlay = findViewById(R.id.loader_overlay);
_editProfile = findViewById(R.id.edit_profile);
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish();
};
_buttonHelp.setOnClickListener {
startActivity(Intent(this, PolycentricWhyActivity::class.java))
}
startActivity(Intent(this, PolycentricWhyActivity::class.java));
};
_buttonScanProfile.setOnClickListener {
val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt(getString(R.string.scan_a_qr_code))
integrator.setOrientationLocked(true)
integrator.setOrientationLocked(true);
integrator.setCameraId(0)
integrator.setBeepEnabled(false)
integrator.setBarcodeImageEnabled(true)
integrator.setCaptureActivity(QRCaptureActivity::class.java)
integrator.setCaptureActivity(QRCaptureActivity::class.java);
_qrCodeResultLauncher.launch(integrator.createScanIntent())
}
_buttonImportFile.setOnClickListener { _filePickerLauncher.launch("text/plain") }
};
_buttonImportProfile.setOnClickListener {
if (_editProfile.text.isEmpty()) {
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data))
return@setOnClickListener
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data));
return@setOnClickListener;
}
import(_editProfile.text.toString())
}
import(_editProfile.text.toString());
};
val url = intent.getStringExtra("url")
val url = intent.getStringExtra("url");
if (url != null) {
import(url)
import(url);
}
}
private fun import(url: String) {
if (!url.startsWith("polycentric://")) {
UIDialogs.toast(this, getString(R.string.not_a_valid_url))
return
UIDialogs.toast(this, getString(R.string.not_a_valid_url));
return;
}
_loaderOverlay.show()
lifecycleScope.launch(Dispatchers.IO) {
try {
val data = url.substring("polycentric://".length).base64UrlToByteArray()
val urlInfo = Protocol.URLInfo.parseFrom(data)
val data = url.substring("polycentric://".length).base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(data);
if (urlInfo.urlType != 3L) {
throw Exception("Expected urlInfo struct of type ExportBundle")
}
val exportBundle = ExportBundle.parseFrom(urlInfo.body)
val keyPair = KeyPair.fromProto(exportBundle.keyPair)
val exportBundle = ExportBundle.parseFrom(urlInfo.body);
val keyPair = KeyPair.fromProto(exportBundle.keyPair);
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey)
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
if (existingProcessSecret != null) {
withContext(Dispatchers.Main) {
UIDialogs.toast(
this@PolycentricImportProfileActivity,
getString(R.string.this_profile_is_already_imported)
)
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.this_profile_is_already_imported));
}
return@launch
return@launch;
}
val processSecret = ProcessSecret(keyPair, Process.random())
Store.instance.addProcessSecret(processSecret)
val processSecret = ProcessSecret(keyPair, Process.random());
Store.instance.addProcessSecret(processSecret);
try {
PolycentricStorage.instance.addProcessSecret(processSecret)
@@ -199,43 +133,37 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
}
val processHandle = processSecret.toProcessHandle()
val processHandle = processSecret.toProcessHandle();
for (e in exportBundle.events.eventsList) {
try {
val se = SignedEvent.fromProto(e)
Store.instance.putSignedEvent(se)
val se = SignedEvent.fromProto(e);
Store.instance.putSignedEvent(se);
} catch (e: Throwable) {
Logger.w(TAG, "Ignored invalid event", e)
Logger.w(TAG, "Ignored invalid event", e);
}
}
StatePolycentric.instance.setProcessHandle(processHandle)
processHandle.fullyBackfillClient(ApiMethods.SERVER)
StatePolycentric.instance.setProcessHandle(processHandle);
processHandle.fullyBackfillClient(ApiMethods.SERVER);
withContext(Dispatchers.Main) {
startActivity(
Intent(
this@PolycentricImportProfileActivity,
PolycentricProfileActivity::class.java
)
)
finish()
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
finish();
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to import profile", e)
Logger.w(TAG, "Failed to import profile", e);
withContext(Dispatchers.Main) {
UIDialogs.toast(
this@PolycentricImportProfileActivity,
getString(R.string.failed_to_import_profile) + " '${e.message}'"
)
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.failed_to_import_profile) + " '${e.message}'");
}
} finally {
withContext(Dispatchers.Main) { _loaderOverlay.hide() }
withContext(Dispatchers.Main) {
_loaderOverlay.hide();
}
}
}
}
companion object {
private const val TAG = "PolycentricImportProfileActivity"
private const val TAG = "PolycentricImportProfileActivity";
}
}
}
@@ -1,147 +0,0 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.os.Bundle
import android.widget.ImageButton
import android.widget.SeekBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.polycentric.ModerationsManager
import com.futo.platformplayer.R
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.setNavigationBarColorAndIcons
class PolycentricModerationActivity : AppCompatActivity() {
private lateinit var _seekbarOffensive: SeekBar
private lateinit var _seekbarExplicit: SeekBar
private lateinit var _seekbarViolence: SeekBar
private lateinit var _textOffensiveDesc: TextView
private lateinit var _textExplicitDesc: TextView
private lateinit var _textViolenceDesc: TextView
private lateinit var _textOffensiveValue: TextView
private lateinit var _textExplicitValue: TextView
private lateinit var _textViolenceValue: TextView
private lateinit var _moderationsManager: ModerationsManager
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_polycentric_moderation)
setNavigationBarColorAndIcons()
_moderationsManager = ModerationsManager.getInstance()
try {
_moderationsManager = ModerationsManager.getInstance()
} catch (e: IllegalStateException) {
finish()
return
}
_seekbarOffensive = findViewById(R.id.seekbar_offensive)
_seekbarExplicit = findViewById(R.id.seekbar_explicit)
_seekbarViolence = findViewById(R.id.seekbar_violence)
_textOffensiveDesc = findViewById(R.id.text_offensive_desc)
_textExplicitDesc = findViewById(R.id.text_explicit_desc)
_textViolenceDesc = findViewById(R.id.text_violence_desc)
_textOffensiveValue = findViewById(R.id.text_offensive_value)
_textExplicitValue = findViewById(R.id.text_explicit_value)
_textViolenceValue = findViewById(R.id.text_violence_value)
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish()
}
loadSettings()
setupListeners()
}
private fun loadSettings() {
val levels = _moderationsManager.moderationLevels.value ?: mapOf()
val offensiveLevel = levels["hate"] ?: 2
val explicitLevel = levels["sexual"] ?: 1
val violenceLevel = levels["violence"] ?: 1
_seekbarOffensive.progress = offensiveLevel
_seekbarExplicit.progress = explicitLevel
_seekbarViolence.progress = violenceLevel
updateDescriptionText(_seekbarOffensive, _textOffensiveDesc, _textOffensiveValue, getOffensiveDescriptions())
updateDescriptionText(_seekbarExplicit, _textExplicitDesc, _textExplicitValue, getExplicitDescriptions())
updateDescriptionText(_seekbarViolence, _textViolenceDesc, _textViolenceValue, getViolenceDescriptions())
}
private fun setupListeners() {
_seekbarOffensive.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
updateDescriptionText(seekBar, _textOffensiveDesc, _textOffensiveValue, getOffensiveDescriptions())
if (fromUser) {
_moderationsManager.setModerationLevel("hate", progress)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
_seekbarExplicit.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
updateDescriptionText(seekBar, _textExplicitDesc, _textExplicitValue, getExplicitDescriptions())
if (fromUser) {
_moderationsManager.setModerationLevel("sexual", progress)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
_seekbarViolence.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
updateDescriptionText(seekBar, _textViolenceDesc, _textViolenceValue, getViolenceDescriptions())
if (fromUser) {
_moderationsManager.setModerationLevel("violence", progress)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
}
private fun updateDescriptionText(seekBar: SeekBar?, textDesc: TextView, textValue: TextView, descriptions: Array<String>) {
val progress = seekBar?.progress ?: 0
textDesc.text = descriptions[progress]
textValue.text = progress.toString()
}
private fun getOffensiveDescriptions(): Array<String> {
return arrayOf(
"Neutral, general terms, no bias or hate.",
"Mildly sensitive, factual.",
"Potentially offensive content",
"Offensive content"
)
}
private fun getExplicitDescriptions(): Array<String> {
return arrayOf(
"No explicit content",
"Mildly suggestive, factual or educational",
"Moderate sexual content, non-graphic",
"Explicit sexual content"
)
}
private fun getViolenceDescriptions(): Array<String> {
return arrayOf(
"Non-violent",
"Mild violence, factual or contextual",
"Moderate violence, some graphic content.",
"Graphic violence"
)
}
}
@@ -49,7 +49,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
private lateinit var _buttonHelp: ImageButton;
private lateinit var _editName: EditText;
private lateinit var _buttonExport: BigButton;
private lateinit var _buttonModeration: BigButton;
private lateinit var _buttonOpenHarborProfile: BigButton;
private lateinit var _buttonLogout: BigButton;
private lateinit var _buttonDelete: BigButton;
private lateinit var _username: String;
@@ -71,7 +71,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
_imagePolycentric = findViewById(R.id.image_polycentric);
_editName = findViewById(R.id.edit_profile_name);
_buttonExport = findViewById(R.id.button_export);
_buttonModeration = findViewById(R.id.button_moderation);
_buttonOpenHarborProfile = findViewById(R.id.button_open_harbor_profile);
_buttonLogout = findViewById(R.id.button_logout);
_buttonDelete = findViewById(R.id.button_delete);
_loaderOverlay = findViewById(R.id.loader_overlay);
@@ -99,9 +99,15 @@ class PolycentricProfileActivity : AppCompatActivity() {
startActivity(Intent(this, PolycentricBackupActivity::class.java));
};
_buttonModeration.onClick.subscribe {
startActivity(Intent(this, PolycentricModerationActivity::class.java));
};
_buttonOpenHarborProfile.onClick.subscribe {
val processHandle = StatePolycentric.instance.processHandle!!;
processHandle?.let {
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(it.system));
val url = it.system.systemToURLInfoSystemLinkUrl(systemState.servers.asIterable());
val navUrl = "https://harbor.social/" + url.substring("polycentric://".length)
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl)))
}
}
_buttonLogout.onClick.subscribe {
StatePolycentric.instance.setProcessHandle(null);
@@ -1,109 +0,0 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.os.Bundle
import android.util.TypedValue
import android.view.View
import android.widget.ImageButton
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.R
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
class QRCodeFullscreenActivity : AppCompatActivity() {
companion object {
private const val EXTRA_QR_TEXT = "qr_text"
fun createIntent(context: Context, qrText: String): android.content.Intent {
return android.content.Intent(context, QRCodeFullscreenActivity::class.java).apply {
putExtra(EXTRA_QR_TEXT, qrText)
}
}
}
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_qr_code_fullscreen)
setNavigationBarColorAndIcons()
val qrText = intent.getStringExtra(EXTRA_QR_TEXT)
val imageQR = findViewById<ImageView>(R.id.image_qr_fullscreen)
val buttonBack = findViewById<ImageButton>(R.id.button_back_fullscreen)
val buttonClose = findViewById<ImageButton>(R.id.button_close_fullscreen)
// Generate QR code bitmap from text
qrText?.let { text ->
try {
if (!isContentSuitableForQRCode(text)) {
throw Exception("Data too big for QR code generation")
}
val dimension = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 300f, resources.displayMetrics
).toInt()
val qrBitmap = generateQRCode(text, dimension, dimension)
imageQR.setImageBitmap(qrBitmap)
} catch (e: Exception) {
// If QR generation fails, show error or fallback
imageQR.setImageResource(R.drawable.ic_qr)
}
}
buttonBack.setOnClickListener {
finish()
}
buttonClose.setOnClickListener {
finish()
}
imageQR.setOnClickListener {
finish()
}
}
private fun isContentSuitableForQRCode(content: String): Boolean {
val bytes = content.toByteArray(Charsets.UTF_8)
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
}
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
if (!isContentSuitableForQRCode(content)) {
throw Exception("Data too big for QR code generation")
}
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
hints[EncodeHintType.MARGIN] = 1
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
return bitMatrixToBitmap(bitMatrix)
}
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
val width = matrix.width
val height = matrix.height
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
for (x in 0 until width) {
for (y in 0 until height) {
bmp.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE)
}
}
return bmp
}
}
@@ -0,0 +1,208 @@
package com.futo.platformplayer.activities
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.ReadOnlyTextField
import com.google.android.material.button.MaterialButton
class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
private lateinit var _form: FieldForm;
private lateinit var _buttonBack: ImageButton;
private lateinit var _loaderView: LoaderView;
private lateinit var _devSets: LinearLayout;
private lateinit var _buttonDev: MaterialButton;
private var _isFinished = false;
lateinit var overlay: FrameLayout;
val notifPermission = "android.permission.POST_NOTIFICATIONS";
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted)
UIDialogs.toast(this, "Notification permission granted");
else
UIDialogs.toast(this, "Notification permission denied");
}
override fun attachBaseContext(newBase: Context?) {
Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext")
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_settings);
setNavigationBarColorAndIcons();
_form = findViewById(R.id.settings_form);
_buttonBack = findViewById(R.id.button_back);
_buttonDev = findViewById(R.id.button_dev);
_devSets = findViewById(R.id.dev_settings);
_loaderView = findViewById(R.id.loader);
overlay = findViewById(R.id.overlay_container);
_form.onChanged.subscribe { field, _ ->
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
_form.setObjectValues();
Settings.instance.save();
if(field.descriptor?.id == "app_language") {
Logger.i("SettingsActivity", "App language change detected, propogating to shared preferences");
StateApp.instance.setLocaleSetting(this, Settings.instance.language.getAppLanguageLocaleString());
}
if(field.descriptor?.id == "background_update") {
Logger.i("SettingsActivity", "Detected change in background work ${field.value}");
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval > 0) {
val notifManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
if(!notifManager.areNotificationsEnabled()) {
UIDialogs.toast(this, "Notifications aren't enabled");
when {
ContextCompat.checkSelfPermission(this, notifPermission) == PackageManager.PERMISSION_GRANTED -> {
}
ActivityCompat.shouldShowRequestPermissionRationale(this, notifPermission) -> {
UIDialogs.showDialog(this, R.drawable.ic_notifications, "Notifications Required",
"Notifications need to be enabled for background updating to function", null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Enable", {
requestPermissionLauncher.launch(notifPermission);
}, UIDialogs.ActionStyle.PRIMARY));
}
else -> {
requestPermissionLauncher.launch(notifPermission);
}
}
}
}
}
};
_buttonBack.setOnClickListener {
finish();
}
_buttonDev.setOnClickListener {
startActivity(Intent(this, DeveloperActivity::class.java));
}
_lastActivity = this;
reloadSettings();
}
var isFirstLoad = true;
fun reloadSettings() {
val firstLoad = isFirstLoad;
isFirstLoad = false;
_form.setSearchVisible(false);
_loaderView.start();
_form.fromObject(lifecycleScope, Settings.instance) {
_loaderView.stop();
_form.setSearchVisible(true);
var devCounter = 0;
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
devCounter++;
if(devCounter > 5) {
devCounter = 0;
SettingsDev.instance.developerMode = true;
SettingsDev.instance.save();
updateDevMode();
UIDialogs.toast(this, getString(R.string.you_are_now_in_developer_mode));
}
};
if(firstLoad) {
val query = intent.getStringExtra("query");
if(!query.isNullOrEmpty()) {
_form.setSearchQuery(query);
}
}
};
}
override fun onResume() {
super.onResume()
updateDevMode();
}
fun updateDevMode() {
if(SettingsDev.instance.developerMode)
_devSets.visibility = View.VISIBLE;
else
_devSets.visibility = View.GONE;
}
override fun finish() {
super.finish()
_isFinished = true;
if(_lastActivity == this)
_lastActivity = null;
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
}
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
private var requestCode: Int? = -1;
private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult ->
val handler = synchronized(resultLauncherMap) {
resultLauncherMap.remove(requestCode);
}
if(handler != null)
handler(result);
};
override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit) {
synchronized(resultLauncherMap) {
resultLauncherMap[code] = handler;
}
requestCode = code;
resultLauncher.launch(intent);
}
override fun onDestroy() {
super.onDestroy()
settingsActivityClosed.emit()
}
companion object {
//TODO: Temporary for solving Settings issues
@SuppressLint("StaticFieldLeak")
private var _lastActivity: SettingsActivity? = null;
val settingsActivityClosed = Event0()
fun getActivity(): SettingsActivity? {
val act = _lastActivity;
if(act != null && !act._isFinished)
return act;
return null;
}
}
}
@@ -110,19 +110,7 @@ class SyncPairActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.IO) {
try {
var wasCompleted = false
StateSync.instance.syncService?.connect(deviceInfo, true) { complete, message ->
if (wasCompleted) {
Logger.i(TAG, "onStatusUpdate(complete = ${complete}, message = '${message} ignored because wasCompleted')")
return@connect
}
if (complete == true) {
wasCompleted = true
}
Logger.i(TAG, "onStatusUpdate(complete = ${complete}, message = '${message}')")
StateSync.instance.syncService?.connect(deviceInfo) { complete, message ->
lifecycleScope.launch(Dispatchers.Main) {
if (complete != null) {
if (complete) {
@@ -1,318 +0,0 @@
package com.futo.platformplayer.api.http.server.handlers
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import android.provider.OpenableColumns
import com.futo.platformplayer.api.http.server.HttpContext
import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.logging.Logger
import java.io.FileNotFoundException
import java.io.InputStream
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.*
class HttpContentUriHandler(
method: String,
path: String,
private val contentResolver: ContentResolver,
private val uri: Uri,
private val explicitContentType: String? = null
) : HttpHandler(method, path) {
override fun handle(httpContext: HttpContext) {
val resolver = contentResolver
val requestHeaders = httpContext.headers
val responseHeaders = this.headers.clone()
val meta = try {
queryMetadata(resolver, uri)
} catch (e: Exception) {
Logger.e(TAG, "Failed to query metadata for $uri", e)
httpContext.respondCode(404, responseHeaders)
return
}
val contentType = explicitContentType
?: resolver.getType(uri)
?: "application/octet-stream"
responseHeaders["Content-Type"] = contentType
meta.lastModifiedMillis?.let { lastModified ->
responseHeaders["Last-Modified"] = httpDateFormat.format(Date(lastModified))
val ifModifiedSinceHeader = requestHeaders["If-Modified-Since"]
if (ifModifiedSinceHeader != null) {
val ifModifiedSince = try {
httpDateFormat.parse(ifModifiedSinceHeader)
} catch (_: Exception) {
null
}
if (ifModifiedSince != null && lastModified <= ifModifiedSince.time) {
httpContext.respondCode(304, responseHeaders)
return
}
}
}
val safeName = (meta.displayName ?: "content.bin").replace("\"", "\\\"")
responseHeaders["Content-Disposition"] = "attachment; filename=\"$safeName\""
val length = meta.size
if (length == null) {
Logger.i(TAG, "Streaming $uri with unknown length; Range not supported")
responseHeaders.remove("Content-Length")
responseHeaders.remove("Content-Range")
responseHeaders.remove("Accept-Ranges")
stream(
httpContext = httpContext,
resolver = resolver,
uri = uri,
statusCode = 200,
headers = responseHeaders,
start = null,
length = null
)
return
}
responseHeaders["Accept-Ranges"] = "bytes"
val rangeHeader = requestHeaders["Range"]
if (rangeHeader.isNullOrBlank()) {
responseHeaders["Content-Length"] = length.toString()
Logger.i(TAG, "Sending full content for $uri, length=$length")
stream(
httpContext = httpContext,
resolver = resolver,
uri = uri,
statusCode = 200,
headers = responseHeaders,
start = 0L,
length = length
)
return
}
val range = parseRange(rangeHeader, length)
if (range == null) {
Logger.w(TAG, "Invalid Range '$rangeHeader' for $uri (length=$length)")
responseHeaders["Content-Range"] = "bytes */$length"
httpContext.respondCode(416, responseHeaders)
return
}
val start = range.first
val endInclusive = range.last
val bytesToSend = endInclusive - start + 1
responseHeaders["Content-Range"] = "bytes $start-$endInclusive/$length"
responseHeaders["Content-Length"] = bytesToSend.toString()
Logger.i(TAG, "Sending range $start-$endInclusive (length=$bytesToSend) of $length for $uri")
stream(
httpContext = httpContext,
resolver = resolver,
uri = uri,
statusCode = 206,
headers = responseHeaders,
start = start,
length = bytesToSend
)
}
data class ContentMeta(
val displayName: String?,
val size: Long?,
val lastModifiedMillis: Long?
)
private fun queryMetadata(resolver: ContentResolver, uri: Uri): ContentMeta {
var displayName: String? = null
var size: Long? = null
var lastModifiedMillis: Long? = null
resolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1 && !cursor.isNull(nameIndex)) {
displayName = cursor.getString(nameIndex)
}
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) {
val s = cursor.getLong(sizeIndex)
if (s >= 0) size = s // -1 means unknown
}
val dateModifiedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED)
if (dateModifiedIndex != -1 && !cursor.isNull(dateModifiedIndex)) {
val seconds = cursor.getLong(dateModifiedIndex)
if (seconds > 0) {
lastModifiedMillis = seconds * 1000L
}
}
if (lastModifiedMillis == null) {
val dateAddedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED)
if (dateAddedIndex != -1 && !cursor.isNull(dateAddedIndex)) {
val seconds = cursor.getLong(dateAddedIndex)
if (seconds > 0) {
lastModifiedMillis = seconds * 1000L
}
}
}
}
}
if (displayName == null) {
displayName = uri.lastPathSegment
}
if (size == null) {
try {
resolver.openAssetFileDescriptor(uri, "r")?.use { afd ->
val assetLen = afd.length
if (assetLen >= 0) {
size = assetLen
}
}
} catch (_: Exception) { }
}
return ContentMeta(
displayName = displayName,
size = size,
lastModifiedMillis = lastModifiedMillis
)
}
private fun parseRange(header: String, totalLength: Long): LongRange? {
if (totalLength <= 0L) return null
val prefix = "bytes="
if (!header.startsWith(prefix, ignoreCase = true)) return null
val spec = header.substring(prefix.length).trim()
if (spec.isEmpty()) return null
if (spec.contains(",")) return null
val dashIndex = spec.indexOf('-')
if (dashIndex < 0) return null
val startPart = spec.substring(0, dashIndex).trim()
val endPart = spec.substring(dashIndex + 1).trim()
return when {
startPart.isNotEmpty() -> {
val start = startPart.toLongOrNull() ?: return null
if (start < 0 || start >= totalLength) return null
val end = if (endPart.isNotEmpty()) {
val rawEnd = endPart.toLongOrNull() ?: return null
if (rawEnd < start) return null
rawEnd.coerceAtMost(totalLength - 1)
} else {
totalLength - 1
}
start..end
}
endPart.isNotEmpty() -> {
val suffixLen = endPart.toLongOrNull() ?: return null
if (suffixLen <= 0L) return null
if (suffixLen >= totalLength) {
0L..(totalLength - 1)
} else {
val start = totalLength - suffixLen
val end = totalLength - 1
start..end
}
}
else -> null
}
}
private fun stream(httpContext: HttpContext, resolver: ContentResolver, uri: Uri, statusCode: Int, headers: HttpHeaders, start: Long?, length: Long?) {
try {
val input = resolver.openInputStream(uri)
if (input == null) {
Logger.w(TAG, "Content not found: $uri")
httpContext.respondCode(404, headers)
return
}
input.use { inputStream ->
httpContext.respond(statusCode, headers) { outputStream ->
try {
val offset = start ?: 0L
if (offset > 0L) {
skipFully(inputStream, offset)
}
copyStream(inputStream, outputStream, length)
outputStream.flush()
} catch (e: Exception) {
Logger.e(TAG, "Error while streaming $uri (start=$start, length=$length)", e)
}
}
}
} catch (e: FileNotFoundException) {
Logger.w(TAG, "Content not found: $uri", e)
httpContext.respondCode(404, headers)
} catch (e: Exception) {
Logger.e(TAG, "Failed to open stream for $uri", e)
httpContext.respondCode(500, headers)
}
}
private fun copyStream(input: InputStream, output: OutputStream, limit: Long?) {
val buffer = ByteArray(8192)
if (limit == null) {
while (true) {
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
}
} else {
var remaining = limit
while (remaining > 0L) {
val toRead = remaining.coerceAtMost(buffer.size.toLong()).toInt()
val read = input.read(buffer, 0, toRead)
if (read < 0) break
output.write(buffer, 0, read)
remaining -= read.toLong()
}
}
}
private fun skipFully(input: InputStream, bytesToSkip: Long) {
var remaining = bytesToSkip
while (remaining > 0L) {
val skipped = input.skip(remaining)
if (skipped <= 0L) {
val b = input.read()
if (b == -1) break
remaining -= 1L
} else {
remaining -= skipped
}
}
}
companion object {
private const val TAG = "HttpContentUriHandler"
private val httpDateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("GMT")
}
}
}
@@ -5,7 +5,6 @@ import android.util.Log
import com.futo.platformplayer.api.http.server.HttpContext
import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.modifier.IRequest
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.parsers.HttpResponseParser
import com.futo.platformplayer.readLine
@@ -28,7 +27,6 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
private var _injectReferer = false;
private val _client = ManagedHttpClient();
private var _requestModifier: ((String, Map<String, String>) -> IRequest)? = null;
override fun handle(context: HttpContext) {
if (useTcp) {
@@ -45,33 +43,21 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
for (injectHeader in _injectRequestHeader)
proxyHeaders[injectHeader.first] = injectHeader.second;
val req = _requestModifier?.invoke(targetUrl, proxyHeaders)
var url = targetUrl
if (req != null) {
req.url?.let {
url = it
}
req.headers.let {
proxyHeaders.clear()
proxyHeaders.putAll(it)
}
}
val parsed = Uri.parse(url);
val parsed = Uri.parse(targetUrl);
if(_injectHost)
proxyHeaders.put("Host", parsed.host!!);
if(_injectReferer)
proxyHeaders.put("Referer", url);
proxyHeaders.put("Referer", targetUrl);
val useMethod = if (method == "inherit") context.method else method;
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${url}");
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${targetUrl}");
Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
val resp = when (useMethod) {
"GET" -> _client.get(url, proxyHeaders);
"POST" -> _client.post(url, content ?: "", proxyHeaders);
"HEAD" -> _client.head(url, proxyHeaders)
else -> _client.requestMethod(useMethod, url, proxyHeaders);
"GET" -> _client.get(targetUrl, proxyHeaders);
"POST" -> _client.post(targetUrl, content ?: "", proxyHeaders);
"HEAD" -> _client.head(targetUrl, proxyHeaders)
else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders);
};
Logger.i(TAG, "Proxied Response [${resp.code}]");
@@ -105,23 +91,11 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
for (injectHeader in _injectRequestHeader)
proxyHeaders[injectHeader.first] = injectHeader.second;
val req = _requestModifier?.invoke(targetUrl, proxyHeaders)
var url = targetUrl
if (req != null) {
req.url?.let {
url = it
}
req.headers.let {
proxyHeaders.clear()
proxyHeaders.putAll(it)
}
}
val parsed = Uri.parse(url);
val parsed = Uri.parse(targetUrl);
if(_injectHost)
proxyHeaders.put("Host", parsed.host!!);
if(_injectReferer)
proxyHeaders.put("Referer", url);
proxyHeaders.put("Referer", targetUrl);
val useMethod = if (method == "inherit") context.method else method;
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}");
@@ -268,10 +242,6 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
_ignoreRequestHeaders.add("referer");
return this;
}
fun withRequestModifier(modifier: (String, Map<String, String>) -> IRequest) : HttpProxyHandler {
_requestModifier = modifier;
return this;
}
companion object {
private const val TAG = "HttpProxyHandler"
@@ -54,16 +54,14 @@ interface IPlatformChannelContent : IPlatformContent {
val subscribers: Long?
}
open class JSChannelContent(
config: SourcePluginConfig,
obj: V8ValueObject
) : JSContent(config, obj), IPlatformChannelContent {
open class JSChannelContent : JSContent, IPlatformChannelContent {
override val contentType: ContentType get() = ContentType.CHANNEL
override val thumbnail: String?
override val subscribers: Long?
final override val contentType: ContentType = ContentType.CHANNEL
override val thumbnail: String? =
_content.getOrDefault<String>(_pluginConfig, "thumbnail", "Channel", null)
override val subscribers: Long? =
_content.getOrDefault<Long>(_pluginConfig, "subscribers", "Channel", null)?.toLong()
}
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
val contextName = "Channel";
thumbnail = obj.getOrDefault<String>(config, "thumbnail", contextName, null)
subscribers = if(obj.has("subscribers")) obj.getOrThrow(config,"subscribers", contextName) else null
}
}
@@ -6,15 +6,25 @@ import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.structures.IPager
import java.time.OffsetDateTime
open class PlatformComment(
override val contextUrl: String,
override val author: PlatformAuthorLink,
override val message: String,
override val rating: IRating,
override val date: OffsetDateTime,
override val replyCount: Int? = null
) : IPlatformComment {
open class PlatformComment : IPlatformComment {
override val contextUrl: String;
override val author: PlatformAuthorLink;
override val message: String;
override val rating: IRating;
override val date: OffsetDateTime;
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> =
NoCommentsPager()
}
override val replyCount: Int?;
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, replyCount: Int? = null) {
this.contextUrl = contextUrl;
this.author = author;
this.message = msg;
this.rating = rating;
this.date = date;
this.replyCount = replyCount;
}
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> {
return NoCommentsPager();
}
}
@@ -2,24 +2,10 @@ package com.futo.platformplayer.api.media.models.streams
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
import com.futo.platformplayer.downloads.VideoLocal
class LocalVideoUnMuxedSourceDescriptor : VideoUnMuxedSourceDescriptor {
override val videoSources: Array<IVideoSource>;
override val audioSources: Array<IAudioSource>;
constructor(video: VideoLocal) {
videoSources = video.videoSource.toTypedArray();
audioSources = video.audioSource.toTypedArray();
}
constructor(audio: LocalAudioContentSource) {
videoSources = arrayOf()
audioSources = arrayOf(audio);
}
constructor(videoSources: Array<IVideoSource>, audioSources: Array<IAudioSource>) {
this.videoSources = videoSources;
this.audioSources = audioSources;
}
class LocalVideoUnMuxedSourceDescriptor(private val video: VideoLocal) : VideoUnMuxedSourceDescriptor() {
override val videoSources: Array<IVideoSource> get() = video.videoSource.toTypedArray();
override val audioSources: Array<IAudioSource> get() = video.audioSource.toTypedArray();
}
@@ -14,8 +14,7 @@ class AudioUrlSource(
override val language: String = Language.UNKNOWN,
override val duration: Long? = null,
override var priority: Boolean = false,
override var original: Boolean = false,
var isLocal: Boolean = false
override var original: Boolean = false
) : IAudioUrlSource, IStreamMetaDataSource{
override var streamMetaData: StreamMetaData? = null;
@@ -12,9 +12,6 @@ class DashManifestSource : IVideoSource, IDashManifestSource {
override var priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
constructor(url : String) {
this.url = url;
}
@@ -12,9 +12,6 @@ class HLSManifestSource : IVideoSource, IHLSManifestSource {
override var priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
constructor(url : String) {
this.url = url;
}
@@ -14,9 +14,6 @@ class HLSVariantVideoUrlSource(
override val priority: Boolean,
val url: String
) : IVideoUrlSource {
override val language: String? = null;
override val original: Boolean? = false;
override fun getVideoUrl(): String {
return url
}
@@ -44,7 +41,6 @@ class HLSVariantSubtitleUrlSource(
override val format: String,
) : ISubtitleSource {
override val hasFetch: Boolean = false
override val language: String? = null
override fun getSubtitles(): String? {
return null
@@ -9,6 +9,4 @@ interface IVideoSource {
val bitrate : Int?;
val duration: Long;
val priority: Boolean;
val language: String?;
val original: Boolean?;
}
@@ -9,15 +9,13 @@ class LocalSubtitleSource : ISubtitleSource {
override val name: String;
override val url: String?;
override val format: String?;
override val language: String?
override val hasFetch: Boolean get() = false;
val filePath: String;
constructor(name: String, language: String?, format: String?, filePath: String) {
constructor(name: String, format: String?, filePath: String) {
this.name = name;
this.format = format;
this.language = language
this.filePath = filePath;
this.url = Uri.fromFile(File(filePath)).toString();
}
@@ -34,7 +32,6 @@ class LocalSubtitleSource : ISubtitleSource {
fun fromSource(source: SubtitleRawSource, path: String): LocalSubtitleSource {
return LocalSubtitleSource(
source.name,
source.language,
source.format,
path
);
@@ -16,10 +16,6 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
override var priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
val filePath : String;
val fileSize : Long;
@@ -6,7 +6,6 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
@kotlinx.serialization.Serializable
class SubtitleRawSource(
override val name: String,
override val language: String?,
override val format: String?,
val _subtitles: String,
override val url: String? = null,
@@ -14,14 +14,10 @@ open class VideoUrlSource(
override val codec : String = "",
override val bitrate : Int? = 0,
override var priority: Boolean = false,
var isLocal: Boolean = false
override var priority: Boolean = false
) : IVideoUrlSource, IStreamMetaDataSource {
override var streamMetaData: StreamMetaData? = null;
override val language: String? = null;
override val original: Boolean? = false;
override fun getVideoUrl() : String {
return url;
}
@@ -7,7 +7,6 @@ interface ISubtitleSource {
val url: String?;
val format: String?;
val hasFetch: Boolean;
val language: String?
fun getSubtitles(): String?;
@@ -1,122 +0,0 @@
package com.futo.platformplayer.api.media.models.video
import android.annotation.SuppressLint
import android.net.Uri
import android.provider.MediaStore
import android.provider.OpenableColumns
import androidx.core.net.toUri
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
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.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.LocalVideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
import com.futo.platformplayer.api.media.platforms.local.models.LocalVideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.states.StateApp
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.OffsetDateTime
@kotlinx.serialization.Serializable
open class LocalVideoDetails(
override val id: PlatformID,
override val name: String,
override val thumbnails: Thumbnails,
override val author: PlatformAuthorLink,
override val url: String,
override val duration: Long,
val mimeType: String? = null,
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override val datetime: OffsetDateTime?
) : IPlatformVideo, IPlatformVideoDetails {
final override val contentType: ContentType get() = ContentType.MEDIA;
override var playbackTime: Long = -1;
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override var playbackDate: OffsetDateTime? = null;
override val isLive: Boolean get() = false;
override val dash: IDashManifestSource? get() = null;
override val hls: IHLSManifestSource? get() = null;
override val live: IVideoSource? get() = null;
override val shareUrl: String = ""
override val viewCount: Long = -1
override val rating: IRating = RatingLikes(0)
override val description: String = "";
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
(LocalVideoUnMuxedSourceDescriptor(
arrayOf(),
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name, duration))
))
else (LocalVideoMuxedSourceDescriptor(
LocalVideoContentSource(url, mimeType ?: "", name, duration)
))
);
override val preview: ISerializedVideoSourceDescriptor? = null;
override val subtitles: List<SubtitleRawSource> = listOf()
override val isShort: Boolean = false
fun toJson() : String {
return Json.encodeToString(this);
}
fun fromJson(str : String) : SerializedPlatformVideoDetails {
return Serializer.json.decodeFromString<SerializedPlatformVideoDetails>(str);
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? = null;
companion object {
fun fromFile(name: String, filePath: String, mimeType: String? = null) : LocalVideoDetails {
if(filePath.startsWith("content://"))
return fromContent(filePath, mimeType);
return LocalVideoDetails(PlatformID("FILE", filePath, null, 0, -1),
name, Thumbnails(), PlatformAuthorLink.UNKNOWN, filePath, -1, mimeType, null);
}
fun fromContent(contentUrl: String, mimeType: String? = null) : LocalVideoDetails {
var nameToUse = getFileNameFromContentUrl(contentUrl) ?: "File";
return LocalVideoDetails(PlatformID("FILE", contentUrl, null, 0, -1),
nameToUse, Thumbnails(), PlatformAuthorLink.UNKNOWN, contentUrl, -1, mimeType, null);
}
@SuppressLint("Range")
private fun getFileNameFromContentUrl(url: String): String? {
val cursor = StateApp.instance.context.contentResolver.query(url.toUri(), null, null, null, null);
cursor?.moveToFirst();
val fileName = cursor?.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
cursor?.close();
return fileName;
}
}
}
@@ -55,7 +55,6 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptValidationException
import com.futo.platformplayer.engine.packages.PackageBridge
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.states.AnnouncementType
@@ -104,7 +103,7 @@ open class JSClient : IPlatformClient {
override val id: String get() = config.id;
override val name: String get() = config.name;
override val icon: ImageVariable get() = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null)
override val icon: ImageVariable;
override var capabilities: PlatformClientCapabilities = PlatformClientCapabilities();
private var _busyAction = "";
@@ -148,16 +147,16 @@ open class JSClient : IPlatformClient {
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String? = null) {
this._context = context;
this.config = descriptor.config;
icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null);
this.descriptor = descriptor;
_injectedSaveState = saveState;
_auth = descriptor.getAuth();
_captcha = descriptor.getCaptchaData();
flags = descriptor.flags.toTypedArray();
_httpClient = JSHttpClient(this, null, _captcha, config);
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
_httpClient = JSHttpClient(this, null, _captcha);
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
_plugin.bridge.descriptor = descriptor;
_plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js");
@@ -179,6 +178,7 @@ open class JSClient : IPlatformClient {
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String, withoutCredentials: Boolean = false) {
this._context = context;
this.config = descriptor.config;
icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null);
this.descriptor = descriptor;
_injectedSaveState = saveState;
if(!withoutCredentials)
@@ -188,10 +188,9 @@ open class JSClient : IPlatformClient {
_captcha = descriptor.getCaptchaData();
flags = descriptor.flags.toTypedArray();
_httpClient = JSHttpClient(this, null, _captcha, config);
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
_httpClient = JSHttpClient(this, null, _captcha);
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
_plugin.bridge.descriptor = descriptor;
_plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js");
_plugin.withScript(script);
@@ -488,14 +487,13 @@ open class JSClient : IPlatformClient {
if (_peekChannelTypes != null) {
return _peekChannelTypes!!;
}
return busy {
val arr: V8ValueArray = plugin.executeTyped("source.getPeekChannelTypes()");
_peekChannelTypes = arr.keys.mapNotNull {
val str = arr.get<V8ValueString>(it);
return@mapNotNull str.value;
};
return@busy _peekChannelTypes ?: listOf();
}
val arr: V8ValueArray = plugin.executeTyped("source.getPeekChannelTypes()");
_peekChannelTypes = arr.keys.mapNotNull {
val str = arr.get<V8ValueString>(it);
return@mapNotNull str.value;
};
return _peekChannelTypes ?: listOf();
}
catch(ex: Throwable) {
announcePluginUnhandledException("getPeekChannelTypes", ex);
@@ -524,12 +522,10 @@ open class JSClient : IPlatformClient {
if(!capabilities.hasGetChannelUrlByClaim)
throw IllegalStateException("This plugin does not support channel url by claim");
return busy {
val value = plugin.executeTyped<V8Value>("source.getChannelUrlByClaim(${claimType}, ${Json.encodeToString(claimValues)})");
if(value !is V8ValueString)
return@busy null;
return@busy value.value;
}
val value = plugin.executeTyped<V8Value>("source.getChannelUrlByClaim(${claimType}, ${Json.encodeToString(claimValues)})");
if(value !is V8ValueString)
return null;
return value.value;
}
@JSOptional
@JSDocs(12, "source.getChannelTemplateByClaimMap()", "Get a map for every supported claimtype mapping field to urls")
@@ -539,30 +535,28 @@ open class JSClient : IPlatformClient {
if(!capabilities.hasGetChannelTemplateByClaimMap)
throw IllegalStateException("This plugin does not support channel template by claim map");
return busy {
val value = plugin.executeTyped<V8Value>("source.getChannelTemplateByClaimMap()");
if(value !is V8ValueObject)
return@busy mapOf();
val value = plugin.executeTyped<V8Value>("source.getChannelTemplateByClaimMap()");
if(value !is V8ValueObject)
return mapOf();
val claimTypes = mutableMapOf<Int, Map<Int, String>>();
val claimTypes = mutableMapOf<Int, Map<Int, String>>();
val keys = value.ownPropertyNames;
for(key in keys.toArray()) {
if(key is V8ValueInteger) {
val map = value.get<V8ValueObject>(key);
val mapKeys = map.ownPropertyNames;
val keys = value.ownPropertyNames;
for(key in keys.toArray()) {
if(key is V8ValueInteger) {
val map = value.get<V8ValueObject>(key);
val mapKeys = map.ownPropertyNames;
claimTypes[key.value] = mapKeys.toArray().filter {
it is V8ValueInteger
}.associate {
val mapKey = (it as V8ValueInteger).value;
return@associate Pair(mapKey, map.getString(mapKey));
};
}
claimTypes[key.value] = mapKeys.toArray().filter {
it is V8ValueInteger
}.associate {
val mapKey = (it as V8ValueInteger).value;
return@associate Pair(mapKey, map.getString(mapKey));
};
}
channelClaimTemplates = claimTypes.toMap();
return@busy claimTypes;
}
channelClaimTemplates = claimTypes.toMap();
return claimTypes;
}
@@ -703,33 +697,27 @@ open class JSClient : IPlatformClient {
@JSDocs(22, "source.getUserPlaylists()", "Gets the playlist of the current user")
override fun getUserPlaylists(): Array<String> {
ensureEnabled();
return busy {
return@busy plugin.executeTyped<V8ValueArray>("source.getUserPlaylists()")
.toArray()
.map { (it as V8ValueString).value }
.toTypedArray();
}
return plugin.executeTyped<V8ValueArray>("source.getUserPlaylists()")
.toArray()
.map { (it as V8ValueString).value }
.toTypedArray();
}
@JSOptional
@JSDocs(23, "source.getUserSubscriptions()", "Gets the subscriptions of the current user")
override fun getUserSubscriptions(): Array<String> {
ensureEnabled();
return busy {
return@busy plugin.executeTyped<V8ValueArray>("source.getUserSubscriptions()")
.toArray()
.map { (it as V8ValueString).value }
.toTypedArray();
}
return plugin.executeTyped<V8ValueArray>("source.getUserSubscriptions()")
.toArray()
.map { (it as V8ValueString).value }
.toTypedArray();
}
@JSOptional
@JSDocs(23, "source.getUserHistory()", "Gets the history of the current user")
override fun getUserHistory(): IPager<IPlatformContent> {
ensureEnabled();
return isBusyWith("getUserHistory") {
return@isBusyWith JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
}
return JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
}
fun validate() {
@@ -905,4 +893,4 @@ open class JSClient : IPlatformClient {
return name;
}
}
}
}
@@ -5,9 +5,9 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf(), val userAgent: String? = null) {
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
override fun toString(): String {
return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
return "(headers: '$headers', cookieString: '$cookieMap')";
}
fun toEncrypted(): String{
@@ -15,25 +15,23 @@ data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? =
}
private fun serialize(): String {
return Json.encodeToString(SerializedAuth(cookieMap, headers, userAgent));
return Json.encodeToString(SerializedAuth(cookieMap, headers));
}
companion object {
val TAG = "SourceAuth";
private val _json = Json { ignoreUnknownKeys = true };
fun fromEncrypted(encrypted: String?): SourceAuth? {
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
}
private fun deserialize(str: String): SourceAuth {
val data = _json.decodeFromString<SerializedAuth>(str);
return SourceAuth(data.cookieMap, data.headers, data.userAgent);
val data = Json.decodeFromString<SerializedAuth>(str);
return SourceAuth(data.cookieMap, data.headers);
}
}
@Serializable
data class SerializedAuth(val cookieMap: HashMap<String, HashMap<String, String>>?,
val headers: Map<String, Map<String, String>> = mapOf(),
val userAgent: String? = null)
val headers: Map<String, Map<String, String>> = mapOf())
}
@@ -5,9 +5,9 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf(), val userAgent: String? = null) {
data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
override fun toString(): String {
return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
return "(headers: '$headers', cookieString: '$cookieMap')";
}
fun toEncrypted(): String{
@@ -15,25 +15,23 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
}
private fun serialize(): String {
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers, userAgent));
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers));
}
companion object {
val TAG = "SourceCaptchaData";
private val _json = Json { ignoreUnknownKeys = true };
fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
}
fun deserialize(str: String): SourceCaptchaData {
val data = _json.decodeFromString<SerializedCaptchaData>(str);
return SourceCaptchaData(data.cookieMap, data.headers, data.userAgent);
val data = Json.decodeFromString<SerializedCaptchaData>(str);
return SourceCaptchaData(data.cookieMap, data.headers);
}
}
@Serializable
data class SerializedCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>?,
val headers: Map<String, Map<String, String>> = mapOf(),
val userAgent: String? = null)
val headers: Map<String, Map<String, String>> = mapOf())
}
@@ -1,11 +1,6 @@
package com.futo.platformplayer.api.media.platforms.js
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.Dictionary
@Serializable
@kotlinx.serialization.Serializable
class SourcePluginAuthConfig(
val loginUrl: String,
val completionUrl: String? = null,
@@ -16,44 +11,5 @@ class SourcePluginAuthConfig(
val userAgent: String? = null,
val loginButton: String? = null,
val domainHeadersToFind: Map<String, List<String>>? = null,
val loginWarning: String? = null,
val loginWarnings: List<Warning>? = null,
val uiMods: List<UIMod>? = null
) {
@Serializable
class Warning(
val url: String,
val text: String?,
val details: String? = null,
val once: Boolean? = true
) {
@Transient
private var _regex: Regex? = null;
fun getRegex(): Regex {
return _regex ?: url.let {
val reg = Regex(it);
_regex = reg;
return reg;
}
}
}
@Serializable
class UIMod(
val url: String,
val scale: Float?,
val desktop: Boolean?
) {
@Contextual
private var _regex: Regex? = null;
fun getRegex(): Regex {
return _regex ?: url.let {
val reg = Regex(it);
_regex = reg;
return reg;
}
}
}
}
val loginWarning: String? = null
) { }
@@ -23,7 +23,7 @@ class SourcePluginConfig(
//Script
val repositoryUrl: String? = null,
val scriptUrl: String = "",
var version: Int = -1,
val version: Int = -1,
val iconUrl: String? = null,
var id: String = UUID.randomUUID().toString(),
@@ -61,11 +61,6 @@ class SourcePluginConfig(
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
val absoluteScriptUrl: String get() = resolveAbsoluteUrl(scriptUrl, sourceUrl)!!;
fun isOfficialAuthor(): Boolean {
return scriptSignature != null &&
scriptPublicKey == "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsoFJU4AReDyUnSQI9A99UjLCwkY8OH+1o8cdtf2EjSb+fO2qmP8MGMTAvfvgmq5d2QBJE2XHRkRO3JKcTlcc1j0WlOlU8P9W272DYCeX6oYaavpKNqGKoGEuodp9wtiyNwyH46++JfpU/uIUacZbZKkHv9gIGchmNvpKYZQjFd/8pUqXGpcXZP54tGSC9PLcY+5TozZThK7Oy1+3YEf1bZ44UinRYYATbLk/wNuAfsupvlt6nxZOcJhABhdo9V+gY0FE6Ayg5+1cd1noWhnRtLF+sPdEr3z8Nt15JEK5a/524t25FMhwz8yKxlGW5qW3QLJHSUgLQncL6a1zlZ1s8QIDAQAB"
}
private fun resolveAbsoluteUrl(url: String?, sourceUrl: String?): String? {
if(url == null)
return null;
@@ -170,12 +165,6 @@ class SourcePluginConfig(
"Unrestricted Http Header access",
"Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests."
))
/*if(packagesOptional.contains("Browser") || packages.contains("Browser")) {
list.add(Pair(
"Browser Interop",
"This plugin requires webbrowser interop. May access urls outside of the restricted urls. This will only work for official plugins and during development builds."
))
}*/
return list;
}
@@ -235,8 +224,7 @@ class SourcePluginConfig(
val variable: String? = null,
val dependency: String? = null,
val warningDialog: String? = null,
val options: List<String>? = null,
val isAdvanced: Boolean? = null
val options: List<String>? = null
) {
val variableOrName: String get() = variable ?: name;
}
@@ -23,7 +23,6 @@ import java.util.UUID
class JSHttpClient : ManagedHttpClient {
private val _jsClient: JSClient?;
private val _jsConfig: SourcePluginConfig?;
val config get() = _jsConfig
private val _auth: SourceAuth?;
private val _captcha: SourceCaptchaData?;
@@ -255,76 +254,6 @@ class JSHttpClient : ManagedHttpClient {
return resp;
}
fun processRequest(method: String, responseCode: Int, url: Uri, headers: Map<String, List<String>>) {
if(doUpdateCookies) {
val domain = url.host?.lowercase() ?: return;
val domainParts = domain.split(".");
val defaultCookieDomain =
"." + domainParts.drop(domainParts.size - 2).joinToString(".");
for (header in headers) {
if(header.key.lowercase() == "set-cookie") {
var domainToUse = domain;
val cookie = cookieStringToPair(header.value.first());
var cookieValue = cookie.second;
if (cookie.first.isNotEmpty() && cookie.second.isNotEmpty()) {
val cookieParts = cookie.second.split(";");
if (cookieParts.size == 0)
continue;
cookieValue = cookieParts[0].trim();
val cookieVariables = cookieParts.drop(1).map {
val splitIndex = it.indexOf("=");
if (splitIndex < 0)
return@map Pair(it.trim().lowercase(), "");
return@map Pair<String, String>(
it.substring(0, splitIndex).lowercase().trim(),
it.substring(splitIndex + 1).trim()
);
}.toMap();
domainToUse = if (cookieVariables.containsKey("domain"))
cookieVariables["domain"]!!.lowercase();
else defaultCookieDomain;
//TODO: Make sure this has no negative effect besides apply cookies to root domain
if(!domainToUse.startsWith("."))
domainToUse = ".${domainToUse}";
}
if ((_auth != null || _currentCookieMap.isNotEmpty())) {
val cookieMap = if (_currentCookieMap.containsKey(domainToUse))
_currentCookieMap[domainToUse]!!;
else {
val newMap = hashMapOf<String, String>();
_currentCookieMap[domainToUse] = newMap
newMap;
}
if (cookieMap.containsKey(cookie.first) || doAllowNewCookies)
cookieMap[cookie.first] = cookieValue;
}
else {
val cookieMap = if (_otherCookieMap.containsKey(domainToUse))
_otherCookieMap[domainToUse]!!;
else {
val newMap = hashMapOf<String, String>();
_otherCookieMap[domainToUse] = newMap
newMap;
}
if (cookieMap.containsKey(cookie.first) || doAllowNewCookies)
cookieMap[cookie.first] = cookieValue;
}
}
}
}
if(_jsClient is DevJSClient) {
//val peekBody = resp.peekBody(1000 * 1000).string();
StateDeveloper.instance.addDevHttpExchange(
StateDeveloper.DevHttpExchange(
StateDeveloper.DevHttpRequest(method, url.toString(), mapOf(*headers.map { Pair(it.key, it.value.joinToString(";")) }.toTypedArray()), ""),
StateDeveloper.DevHttpRequest("RESP", url.toString(), mapOf(*headers.map { Pair(it.key, it.value.joinToString(";")) }.toTypedArray()), "", responseCode)
));
}
}
private fun cookieStringToPair(cookie: String): Pair<String, String> {
val cookieKey = cookie.substring(0, cookie.indexOf("="));
@@ -21,25 +21,19 @@ import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.states.StateDeveloper
open class JSArticle(
config: SourcePluginConfig,
obj: V8ValueObject
) : JSContent(config, obj), IPlatformArticle, IPluginSourced {
open class JSArticle : JSContent, IPlatformArticle, IPluginSourced {
final override val contentType: ContentType get() = ContentType.ARTICLE;
final override val contentType: ContentType = ContentType.ARTICLE
override val summary: String;
override val thumbnails: Thumbnails?;
override val summary: String =
obj.getOrDefault<String>(config, "summary", "PlatformArticle", "") ?: ""
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformArticle";
override val thumbnails: Thumbnails? =
if (obj.getSourcePlugin()?.busy { obj.has("thumbnails") } ?: obj.has("thumbnails"))
Thumbnails.fromV8(
config,
obj.getOrThrow<V8ValueObject>(config, "thumbnails", "PlatformArticle")
)
else
null
}
summary = _content.getOrDefault(config, "summary", contextName, "") ?: "";
thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName));
}
}
@@ -24,47 +24,39 @@ import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.states.StateDeveloper
open class JSArticleDetails(
private val client: JSClient,
obj: V8ValueObject
) : JSContent(client.config, obj), IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
final override val contentType: ContentType get() = ContentType.ARTICLE;
final override val contentType: ContentType = ContentType.ARTICLE
private val _hasGetComments: Boolean;
private val _hasGetContentRecommendations: Boolean;
private val _hasGetComments: Boolean = client.busy { _content.has("getComments") }
private val _hasGetContentRecommendations: Boolean = client.busy { _content.has("getContentRecommendations") }
override val rating: IRating;
override val rating: IRating = client.busy {
obj.getOrDefault<V8ValueObject>(client.config, "rating", "PlatformArticle", null)
?.let { IRating.fromV8(client.config, it, "PlatformArticle") }
?: RatingLikes(0)
}
override val summary: String;
override val thumbnails: Thumbnails?;
override val segments: List<IJSArticleSegment>;
override val summary: String = client.busy {
_content.getOrThrow(client.config, "summary", "PlatformArticle")
}
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
val contextName = "PlatformArticle";
override val thumbnails: Thumbnails? = client.busy {
if (_content.has("thumbnails"))
Thumbnails.fromV8(
client.config,
_content.getOrThrow(client.config, "thumbnails", "PlatformArticle")
)
rating = obj.getOrDefault<V8ValueObject>(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0);
summary = _content.getOrThrow(client.config, "summary", contextName);
if(_content.has("thumbnails"))
thumbnails = Thumbnails.fromV8(client.config, _content.getOrThrow(client.config, "thumbnails", contextName));
else
null
}
thumbnails = null;
override val segments: List<IJSArticleSegment> = client.busy {
obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", "PlatformArticle")
?.mapNotNull { fromV8Segment(client, it) }
?: emptyList()
segments = (obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", contextName)
?.map { fromV8Segment(client, it) }
?.filterNotNull() ?: listOf());
_hasGetComments = _content.has("getComments");
_hasGetContentRecommendations = _content.has("getContentRecommendations");
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
val canGetComments = this.client.busy {
_hasGetComments && !_content.isClosed
}
if(!canGetComments)
if(!_hasGetComments || _content.isClosed)
return null;
if(client is DevJSClient)
@@ -80,10 +72,7 @@ open class JSArticleDetails(
override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
val canGetContentRecommendations = this.client.busy {
_hasGetContentRecommendations && !_content.isClosed
}
if(!canGetContentRecommendations)
if(!_hasGetContentRecommendations || _content.isClosed)
return null;
if(client is DevJSClient)
@@ -97,31 +86,25 @@ open class JSArticleDetails(
}
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
return client.busy {
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return@busy JSContentPager(_pluginConfig, client, contentPager);
}
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return JSContentPager(_pluginConfig, client, contentPager);
}
private fun getCommentsJS(client: JSClient): JSCommentPager {
return client.busy {
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return@busy JSCommentPager(_pluginConfig, client, commentPager);
}
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return JSCommentPager(_pluginConfig, client, commentPager);
}
companion object {
fun fromV8Segment(client: JSClient, obj: V8ValueObject): IJSArticleSegment? {
return client.busy {
if(!obj.has("type"))
throw IllegalArgumentException("Object missing type field");
return@busy when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
SegmentType.TEXT -> JSTextSegment(client, obj);
SegmentType.IMAGES -> JSImagesSegment(client, obj);
SegmentType.HEADER -> JSHeaderSegment(client, obj);
SegmentType.NESTED -> JSNestedSegment(client, obj);
else -> null;
}
if(!obj.has("type"))
throw IllegalArgumentException("Object missing type field");
return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
SegmentType.TEXT -> JSTextSegment(client, obj);
SegmentType.IMAGES -> JSImagesSegment(client, obj);
SegmentType.HEADER -> JSHeaderSegment(client, obj);
SegmentType.NESTED -> JSNestedSegment(client, obj);
else -> null;
}
}
}
@@ -192,4 +175,4 @@ class JSNestedSegment: IJSArticleSegment {
val nestedObj = obj.getOrThrow<V8ValueObject>(client.config, "nested", contextName, false);
nested = IJSContent.fromV8(client, nestedObj);
}
}
}
@@ -46,45 +46,23 @@ class JSComment : IPlatformComment {
_comment = obj;
_plugin = plugin;
var parsedContextUrl: String? = null;
var parsedAuthor: PlatformAuthorLink? = null;
var parsedMessage: String? = null;
var parsedRating: IRating? = null;
var parsedDate: OffsetDateTime? = null;
var parsedReplyCount: Int? = null;
var parsedContext: Map<String, String>? = null;
var parsedHasGetReplies = false;
plugin.busy {
val contextName = "Comment";
parsedContextUrl = _comment!!.getOrThrow(config, "contextUrl", contextName);
parsedAuthor = PlatformAuthorLink.fromV8(_config!!, _comment!!.getOrThrow(config, "author", contextName));
parsedMessage = _comment!!.getOrThrow(config, "message", contextName);
parsedRating = IRating.fromV8(config, _comment!!.getOrThrow(config, "rating", contextName));
parsedDate = _comment!!.getOrThrowNullable<Int>(config, "date", contextName)?.let { OffsetDateTime.of(LocalDateTime.ofEpochSecond(it.toLong(), 0, ZoneOffset.UTC), ZoneOffset.UTC) };
parsedReplyCount = _comment!!.getOrThrowNullable(config, "replyCount", contextName);
parsedContext = _comment!!.getOrDefault(config, "context", contextName, hashMapOf()) ?: hashMapOf();
parsedHasGetReplies = _comment!!.has("getReplies");
}
contextUrl = parsedContextUrl ?: "";
author = parsedAuthor ?: PlatformAuthorLink.UNKNOWN;
message = parsedMessage ?: "";
rating = parsedRating ?: throw IllegalStateException("Missing comment rating");
date = parsedDate;
replyCount = parsedReplyCount;
context = parsedContext ?: hashMapOf();
_hasGetReplies = parsedHasGetReplies;
val contextName = "Comment";
contextUrl = _comment!!.getOrThrow(config, "contextUrl", contextName);
author = PlatformAuthorLink.fromV8(_config!!, _comment!!.getOrThrow(config, "author", contextName));
message = _comment!!.getOrThrow(config, "message", contextName);
rating = IRating.fromV8(config, _comment!!.getOrThrow(config, "rating", contextName));
date = _comment!!.getOrThrowNullable<Int>(config, "date", contextName)?.let { OffsetDateTime.of(LocalDateTime.ofEpochSecond(it.toLong(), 0, ZoneOffset.UTC), ZoneOffset.UTC) }
replyCount = _comment!!.getOrThrowNullable(config, "replyCount", contextName);
context = _comment!!.getOrDefault(config, "context", contextName, hashMapOf()) ?: hashMapOf();
_hasGetReplies = _comment!!.has("getReplies");
}
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment>? {
if(!_hasGetReplies)
return null;
val obj = _comment!!.invokeV8<V8ValueObject>("getReplies", arrayOf<Any>());
val plugin = if(client is JSClient) client else throw NotImplementedError("Only implemented for JSClient");
return plugin.busy {
val obj = _comment!!.invokeV8<V8ValueObject>("getReplies", arrayOf<Any>());
return@busy JSCommentPager(_config!!, plugin, obj);
}
return JSCommentPager(_config!!, plugin, obj);
}
}
}
@@ -11,56 +11,56 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.decodeUnicode
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.logging.Logger
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
open class JSContent(
protected val _pluginConfig: SourcePluginConfig,
protected val _content: V8ValueObject
) : IPlatformContent, IPluginSourced {
open class JSContent : IPlatformContent, IPluginSourced {
protected val _pluginConfig: SourcePluginConfig;
protected val _content : V8ValueObject;
override val contentType: ContentType = ContentType.UNKNOWN
protected val _hasGetDetails: Boolean;
protected val _hasGetDetails: Boolean =
_content.getSourcePlugin()?.busy { _content.has("getDetails") } ?: false
override val contentType: ContentType get() = ContentType.UNKNOWN;
override val id: PlatformID =
PlatformID.fromV8(_pluginConfig, _content.getOrThrow(_pluginConfig, "id", CTX))
override val id: PlatformID;
override val name: String;
override val author: PlatformAuthorLink;
override val datetime: OffsetDateTime?;
override val name: String =
HtmlCompat.fromHtml(
_content.getOrThrow<String>(_pluginConfig, "name", CTX).decodeUnicode(),
HtmlCompat.FROM_HTML_MODE_LEGACY
).toString()
override val url: String;
override val shareUrl: String;
override val author: PlatformAuthorLink =
_content.getOrDefault<V8ValueObject>(_pluginConfig, "author", CTX, null)
?.let { PlatformAuthorLink.fromV8(_pluginConfig, it) }
?: PlatformAuthorLink.UNKNOWN
override val sourceConfig: SourcePluginConfig get() = _pluginConfig;
private val _epoch: Long? =
_content.getOrDefault<Long>(_pluginConfig, "datetime", CTX, null)?.toLong()
constructor(config: SourcePluginConfig, obj: V8ValueObject) {
_pluginConfig = config;
_content = obj;
override val datetime: OffsetDateTime? =
_epoch?.takeIf { it != 0L }?.let {
OffsetDateTime.of(LocalDateTime.ofEpochSecond(it, 0, ZoneOffset.UTC), ZoneOffset.UTC)
}
val contextName = "PlatformContent";
override val url: String =
_content.getOrThrow<String>(_pluginConfig, "url", CTX)
id = PlatformID.fromV8(_pluginConfig, _content.getOrThrow(config, "id", contextName));
name = HtmlCompat.fromHtml(_content.getOrThrow<String>(config, "name", contextName).decodeUnicode(), HtmlCompat.FROM_HTML_MODE_LEGACY).toString();
override val shareUrl: String =
_content.getOrDefault<String>(_pluginConfig, "shareUrl", CTX, null) ?: url
val authorObj = _content.getOrDefault<V8ValueObject>(config, "author", contextName, null);
if(authorObj != null)
author = PlatformAuthorLink.fromV8(_pluginConfig, authorObj);
else
author = PlatformAuthorLink.UNKNOWN;
override val sourceConfig: SourcePluginConfig
get() = _pluginConfig
val datetimeInt = _content.getOrDefault<Int>(config, "datetime", contextName, null)?.toLong();
if(datetimeInt == null || datetimeInt == 0.toLong())
datetime = null;
else
datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
url = _content.getOrThrow(config, "url", contextName);
shareUrl = _content.getOrDefault<String>(config, "shareUrl", contextName, null) ?: url;
fun getUnderlyingObject(): V8ValueObject? = _content
companion object {
private const val CTX = "PlatformContent"
_hasGetDetails = _content.has("getDetails");
}
}
fun getUnderlyingObject(): V8ValueObject? {
return _content;
}
}
@@ -41,9 +41,7 @@ abstract class JSPager<T> : IPager<T> {
}
override fun hasMorePages(): Boolean {
return plugin.getUnderlyingPlugin().busy {
_hasMorePages && !pager.isClosed;
}
return _hasMorePages && !pager.isClosed;
}
override fun nextPage() {
@@ -93,4 +91,4 @@ abstract class JSPager<T> : IPager<T> {
}
abstract fun convertResult(obj: V8ValueObject): T;
}
}
@@ -6,16 +6,14 @@ import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
open class JSPlaylist(
config: SourcePluginConfig,
obj: V8ValueObject
) : JSContent(config, obj), IPlatformPlaylist {
open class JSPlaylist : JSContent, IPlatformPlaylist {
override val contentType: ContentType get() = ContentType.PLAYLIST;
override val thumbnail: String?;
override val videoCount: Int;
override val contentType: ContentType = ContentType.PLAYLIST
override val thumbnail: String? =
_content.getOrDefault<String>(_pluginConfig, "thumbnail", "Playlist", null)
override val videoCount: Int =
_content.getOrDefault<Int>(_pluginConfig, "videoCount", "Playlist", null)?.toInt() ?: -1
}
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
val contextName = "Playlist";
thumbnail = obj.getOrDefault(config, "thumbnail", contextName, null);
videoCount = obj.getOrDefault(config, "videoCount", contextName, -1)!!;
}
}
@@ -15,7 +15,6 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.states.StateDeveloper
@@ -31,79 +30,52 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
var parsedRating: IRating? = null;
var parsedTextType: TextType? = null;
var parsedContent: String? = null;
var parsedHasGetComments = false;
var parsedHasGetContentRecommendations = false;
val contextName = "PlatformPostDetails";
val parse = {
val contextName = "PlatformPostDetails";
rating = obj.getOrDefault<V8ValueObject>(config, "rating", contextName, null)?.let { IRating.fromV8(config, it, contextName) } ?: RatingLikes(0);
textType = TextType.fromInt((obj.getOrDefault<Int>(config, "textType", contextName, null) ?: 0));
content = obj.getOrDefault(config, "content", contextName, "") ?: "";
parsedRating = obj.getOrDefault<V8ValueObject>(config, "rating", contextName, null)?.let { IRating.fromV8(config, it, contextName) } ?: RatingLikes(0);
parsedTextType = TextType.fromInt((obj.getOrDefault<Int>(config, "textType", contextName, null) ?: 0));
parsedContent = obj.getOrDefault(config, "content", contextName, "") ?: "";
parsedHasGetComments = _content.has("getComments");
parsedHasGetContentRecommendations = _content.has("getContentRecommendations");
};
obj.getSourcePlugin()?.busy {
parse();
} ?: parse()
rating = parsedRating ?: RatingLikes(0);
textType = parsedTextType ?: TextType.RAW;
content = parsedContent ?: "";
_hasGetComments = parsedHasGetComments;
_hasGetContentRecommendations = parsedHasGetContentRecommendations;
_hasGetComments = _content.has("getComments");
_hasGetContentRecommendations = _content.has("getContentRecommendations");
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
val jsClient = client as? JSClient;
if(jsClient == null)
return null;
val canGetComments = jsClient.busy {
_hasGetComments && !_content.isClosed
}
if(!canGetComments)
if(!_hasGetComments || _content.isClosed)
return null;
if(client is DevJSClient)
return StateDeveloper.instance.handleDevCall(client.devID, "videoDetail.getComments()") {
return@handleDevCall getCommentsJS(client);
}
return getCommentsJS(jsClient);
else if(client is JSClient)
return getCommentsJS(client);
return null;
}
override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
val jsClient = client as? JSClient;
if(jsClient == null)
return null;
val canGetContentRecommendations = jsClient.busy {
_hasGetContentRecommendations && !_content.isClosed
}
if(!canGetContentRecommendations)
if(!_hasGetContentRecommendations || _content.isClosed)
return null;
if(client is DevJSClient)
return StateDeveloper.instance.handleDevCall(client.devID, "postDetail.getContentRecommendations()") {
return@handleDevCall getContentRecommendationsJS(client);
}
return getContentRecommendationsJS(jsClient);
else if(client is JSClient)
return getContentRecommendationsJS(client);
return null;
}
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
return client.busy {
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return@busy JSContentPager(_pluginConfig, client, contentPager);
}
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return JSContentPager(_pluginConfig, client, contentPager);
}
private fun getCommentsJS(client: JSClient): JSCommentPager {
return client.busy {
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return@busy JSCommentPager(_pluginConfig, client, commentPager);
}
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return JSCommentPager(_pluginConfig, client, commentPager);
}
}
}
@@ -17,14 +17,11 @@ import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.invokeV8Void
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDeveloper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import java.util.Base64
class JSRequestExecutor: AutoCloseable {
class JSRequestExecutor {
private val _plugin: JSClient;
private val _config: IV8PluginConfig;
private var _executor: V8ValueObject;
@@ -32,35 +29,26 @@ class JSRequestExecutor: AutoCloseable {
private val hasCleanup: Boolean;
private var _cleanLock = Any();
private var _cleaned: Boolean = false;
constructor(plugin: JSClient, executor: V8ValueObject) {
this._plugin = plugin;
this._executor = executor;
this._config = plugin.config;
val config = plugin.config;
var parsedUrlPrefix: String? = null;
var parsedHasCleanup = false;
plugin.busy {
parsedUrlPrefix = executor.getOrDefault(config, "urlPrefix", "RequestExecutor", null);
urlPrefix = executor.getOrDefault(config, "urlPrefix", "RequestExecutor", null);
if(!executor.has("executeRequest"))
throw ScriptImplementationException(config, "RequestExecutor is missing executeRequest", null);
parsedHasCleanup = executor.has("cleanup");
}
urlPrefix = parsedUrlPrefix;
hasCleanup = parsedHasCleanup;
if(!executor.has("executeRequest"))
throw ScriptImplementationException(config, "RequestExecutor is missing executeRequest", null);
hasCleanup = executor.has("cleanup");
}
//TODO: Executor properties?
@Throws(ScriptException::class)
open fun executeRequest(method: String, url: String, body: ByteArray?, headers: Map<String, String>): ByteArray {
if (_executor.isClosed)
throw IllegalStateException("Executor object is closed");
return _plugin.getUnderlyingPlugin().busy {
if (_executor.isClosed)
throw IllegalStateException("Executor object is closed");
val result = if(_plugin is DevJSClient)
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
@@ -114,14 +102,8 @@ class JSRequestExecutor: AutoCloseable {
open fun cleanup() {
_plugin.busy {
synchronized(_cleanLock) {
if (!hasCleanup || _executor.isClosed || _cleaned)
return@busy;
_cleaned = true;
}
}
Logger.i("JSRequestExecutor", "JSRequestExecutor cleanup requested");
if (!hasCleanup || _executor.isClosed)
return;
_plugin.busy {
if(_plugin is DevJSClient)
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
@@ -143,25 +125,9 @@ class JSRequestExecutor: AutoCloseable {
}
}
override fun close() {
cleanup();
}
fun closeAsync() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
try {
close();
}
catch(ex: Throwable) {
Logger.e("JSRequestExecutor", "Cleanup failed");
}
}
}
/*
protected fun finalize() {
cleanup();
}*/
}
}
//TODO: are these available..?
@@ -171,4 +137,4 @@ class ExecutorParameters {
var rangeEnd: Int = -1;
var segment: Int = -1;
}
}
@@ -36,11 +36,11 @@ class JSRequestModifier: IRequestModifier {
}
override fun modifyRequest(url: String, headers: Map<String, String>): IRequest {
return _plugin.busy {
if (_modifier.isClosed) {
return@busy Request(url, headers);
}
if (_modifier.isClosed) {
return Request(url, headers);
}
return _plugin.busy {
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") {
_modifier.invokeV8("modifyRequest", url, headers);
} as V8ValueObject;
@@ -53,4 +53,4 @@ class JSRequestModifier: IRequestModifier {
data class Request(override val url: String, override val headers: Map<String, String>) : IRequest;
}
}
@@ -5,7 +5,6 @@ import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.invokeV8
@@ -23,34 +22,16 @@ class JSSubtitleSource : ISubtitleSource {
override val name: String;
override val url: String?;
override val format: String?;
override val language: String?
override val hasFetch: Boolean;
constructor(config: SourcePluginConfig, v8Value: V8ValueObject) {
_obj = v8Value;
var parsedName: String? = null;
var parsedLanguage: String? = null;
var parsedUrl: String? = null;
var parsedFormat: String? = null;
var parsedHasFetch = false;
val parse = {
val context = "JSSubtitles";
parsedName = v8Value.getOrThrow(config, "name", context, false);
parsedLanguage = v8Value.getOrDefault(config, "language", context, null);
parsedUrl = v8Value.getOrThrow(config, "url", context, true);
parsedFormat = v8Value.getOrThrow(config, "format", context, true);
parsedHasFetch = v8Value.has("getSubtitles");
};
v8Value.getSourcePlugin()?.busy {
parse();
} ?: parse()
name = parsedName ?: "";
language = parsedLanguage;
url = parsedUrl;
format = parsedFormat;
hasFetch = parsedHasFetch;
val context = "JSSubtitles";
name = v8Value.getOrThrow(config, "name", context, false);
url = v8Value.getOrThrow(config, "url", context, true);
format = v8Value.getOrThrow(config, "format", context, true);
hasFetch = v8Value.has("getSubtitles");
}
override fun getSubtitles(): String {
@@ -85,4 +66,4 @@ class JSSubtitleSource : ISubtitleSource {
return JSSubtitleSource(config, value);
}
}
}
}
@@ -52,63 +52,34 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
override val subtitles: List<ISubtitleSource>;
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) {
val contextName = "VideoDetails";
_plugin = plugin;
var parsedDescription: String? = null;
var parsedVideo: IVideoSourceDescriptor? = null;
var parsedDash: IDashManifestSource? = null;
var parsedHls: IHLSManifestSource? = null;
var parsedLive: IVideoSource? = null;
var parsedRating: IRating? = null;
var parsedSubtitles: List<ISubtitleSource>? = null;
var parsedHasGetComments = false;
var parsedHasGetPlaybackTracker = false;
var parsedHasGetContentRecommendations = false;
var parsedHasGetVODEvents = false;
val config = plugin.config;
description = _content.getOrThrow(config, "description", contextName);
video = JSVideoSourceDescriptor.fromV8(plugin, _content.getOrThrow(config, "video", contextName));
dash = JSSource.fromV8DashNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "dash", contextName));
hls = JSSource.fromV8HLSNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "hls", contextName));
live = JSSource.fromV8VideoNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "live", contextName));
rating = IRating.fromV8OrDefault(config, _content.getOrDefault<V8ValueObject>(config, "rating", contextName, null), RatingLikes(0));
plugin.busy {
val contextName = "VideoDetails";
val config = plugin.config;
parsedDescription = _content.getOrThrow(config, "description", contextName);
parsedVideo = JSVideoSourceDescriptor.fromV8(plugin, _content.getOrThrow(config, "video", contextName));
parsedDash = JSSource.fromV8DashNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "dash", contextName));
parsedHls = JSSource.fromV8HLSNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "hls", contextName));
parsedLive = JSSource.fromV8VideoNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "live", contextName));
parsedRating = IRating.fromV8OrDefault(config, _content.getOrDefault<V8ValueObject>(config, "rating", contextName, null), RatingLikes(0));
if(!_content.has("subtitles"))
parsedSubtitles = listOf();
else {
val subArrs = _content.getOrThrowNullable<V8ValueArray>(config, "subtitles", contextName);
if(subArrs != null)
parsedSubtitles = subArrs.keys.map { JSSubtitleSource.fromV8(config, subArrs.get(it)) };
else
parsedSubtitles = listOf();
}
parsedHasGetComments = _content.has("getComments");
parsedHasGetPlaybackTracker = _content.has("getPlaybackTracker");
parsedHasGetContentRecommendations = _content.has("getContentRecommendations");
parsedHasGetVODEvents = _content.has("getVODEvents");
if(!_content.has("subtitles"))
subtitles = listOf();
else {
val subArrs = _content.getOrThrowNullable<V8ValueArray>(config, "subtitles", contextName);
if(subArrs != null)
subtitles = subArrs.keys.map { JSSubtitleSource.fromV8(config, subArrs.get(it)) };
else
subtitles = listOf();
}
description = parsedDescription ?: "";
video = parsedVideo ?: throw IllegalStateException("Missing video source descriptor");
dash = parsedDash;
hls = parsedHls;
live = parsedLive;
rating = parsedRating ?: RatingLikes(0);
subtitles = parsedSubtitles ?: listOf();
_hasGetComments = parsedHasGetComments;
_hasGetPlaybackTracker = parsedHasGetPlaybackTracker;
_hasGetContentRecommendations = parsedHasGetContentRecommendations;
_hasGetVODEvents = parsedHasGetVODEvents;
_hasGetComments = _content.has("getComments");
_hasGetPlaybackTracker = _content.has("getPlaybackTracker");
_hasGetContentRecommendations = _content.has("getContentRecommendations");
_hasGetVODEvents = _content.has("getVODEvents");
}
override fun getPlaybackTracker(): IPlaybackTracker? {
val canGetPlaybackTracker = _plugin.busy {
_hasGetPlaybackTracker && !_content.isClosed
}
if(!canGetPlaybackTracker)
if(!_hasGetPlaybackTracker || _content.isClosed)
return null;
if(_pluginConfig.id == StateDeveloper.DEV_ID)
return StateDeveloper.instance.handleDevCall(_pluginConfig.id, "videoDetail.getComments()") {
@@ -131,10 +102,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
}
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
val canGetContentRecommendations = _plugin.busy {
_hasGetContentRecommendations && !_content.isClosed
}
if(!canGetContentRecommendations)
if(!_hasGetContentRecommendations || _content.isClosed)
return null;
if(client is DevJSClient)
@@ -154,12 +122,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
if(client !is JSClient)
return null;
val canGetComments = _plugin.busy {
_hasGetComments && !_content.isClosed
}
if(!canGetComments)
if(client !is JSClient || !_hasGetComments || _content.isClosed)
return null;
if(client is DevJSClient)
@@ -190,4 +153,4 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
return@busy JSVODEventPager(_plugin.config, _plugin,
_content.invokeV8<V8ValueObject>("getVODEvents", arrayOf<Any>()));
}
}
}
@@ -8,44 +8,43 @@ import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
open class JSAudioUrlSource(
plugin: JSClient,
obj: V8ValueObject
) : JSSource(TYPE_AUDIOURL, plugin, obj), IAudioUrlSource {
open class JSAudioUrlSource : IAudioUrlSource, JSSource {
override val name: String;
override val bitrate : Int;
override val container : String;
override val codec: String;
private val url : String;
private val ctx = "AudioUrlSource"
private val cfg = plugin.config
override val language: String;
override val bitrate: Int =
_obj.getOrThrow<Int>(cfg, "bitrate", ctx)
override val duration: Long?;
override val container: String =
_obj.getOrThrow<String>(cfg, "container", ctx)
override var priority: Boolean = false;
override val codec: String =
_obj.getOrThrow<String>(cfg, "codec", ctx)
override var original: Boolean = false;
private val url: String =
_obj.getOrThrow<String>(cfg, "url", ctx)
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_AUDIOURL, plugin, obj) {
val contextName = "AudioUrlSource";
val config = plugin.config;
override val language: String =
_obj.getOrThrow<String>(cfg, "language", ctx)
bitrate = _obj.getOrThrow(config, "bitrate", contextName);
container = _obj.getOrThrow(config, "container", contextName);
codec = _obj.getOrThrow(config, "codec", contextName);
url = _obj.getOrThrow(config, "url", contextName);
language = _obj.getOrThrow(config, "language", contextName);
duration = _obj.getOrDefault(config, "duration", contextName, null);
override val duration: Long? =
_obj.getOrDefault<Long>(cfg, "duration", ctx, null)?.toLong()
name = _obj.getOrDefault(config, "name", contextName, "${container} ${bitrate}") ?: "${container} ${bitrate}";
override val name: String =
_obj.getOrDefault<String>(cfg, "name", ctx, null)
?: "$container $bitrate"
priority = if(_obj.has("priority")) obj.getOrThrow(config, "priority", contextName) else false;
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
}
override var priority: Boolean =
plugin.busy { if (_obj.has("priority")) _obj.getOrThrow<Boolean>(cfg, "priority", ctx) else false }
override fun getAudioUrl() : String {
return url;
}
override var original: Boolean =
plugin.busy { if (_obj.has("original")) _obj.getOrThrow<Boolean>(cfg, "original", ctx) else false }
override fun getAudioUrl(): String = url
override fun toString(): String =
"(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration)"
}
override fun toString(): String {
return "(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration)";
}
}
@@ -19,23 +19,21 @@ class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource {
val config = plugin.config
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = plugin.busy { obj.has("getLicenseRequestExecutor") }
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
return _plugin.busy {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return@busy null
if (!hasLicenseRequestExecutor || _obj.isClosed)
return null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return@busy null
return@busy JSRequestExecutor(_plugin, result)
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return null
return JSRequestExecutor(_plugin, result)
}
override fun toString(): String {
@@ -17,7 +17,6 @@ import com.futo.platformplayer.getOrNull
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.invokeV8Async
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.states.StateDeveloper
import kotlinx.coroutines.CompletableDeferred
@@ -54,28 +53,16 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
original = obj.getOrNull(config, "original", contextName) ?: false;
hasGenerate = plugin.busy { _obj.has("generate") };
}
private var _pregenerate: V8Deferred<String?>? = null;
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
_pregenerate = generateAsync(scope);
return _pregenerate;
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
hasGenerate = _obj.has("generate");
}
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
if(!hasGenerate)
return V8Deferred(CompletableDeferred(manifest));
if(_plugin.busy { _obj.isClosed })
if(_obj.isClosed)
throw IllegalStateException("Source object already closed");
val pregenerated = _pregenerate;
if(pregenerated != null) {
Logger.w("JSDashManifestRawAudioSource", "Returning pre-generated audio");
return pregenerated;
}
val plugin = _plugin.getUnderlyingPlugin();
var result: V8Deferred<V8ValueString>? = null;
@@ -111,7 +98,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
override fun generate(): String? {
if(!hasGenerate)
return manifest;
if(_plugin.busy { _obj.isClosed })
if(_obj.isClosed)
throw IllegalStateException("Source object already closed");
val plugin = _plugin.getUnderlyingPlugin();
@@ -145,4 +132,4 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
}
return result;
}
}
}
@@ -18,7 +18,6 @@ import com.futo.platformplayer.getOrNull
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.invokeV8Async
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateDeveloper
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
@@ -31,72 +30,46 @@ interface IJSDashManifestRawSource {
fun generateAsync(scope: CoroutineScope): Deferred<String?>;
fun generate(): String?;
}
open class JSDashManifestRawSource(
plugin: JSClient,
obj: V8ValueObject
) : JSSource(TYPE_DASH_RAW, plugin, obj), IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource {
open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource {
override val container : String;
override val name : String;
override val width: Int;
override val height: Int;
override val codec: String;
override val bitrate: Int?;
override val duration: Long;
override val priority: Boolean;
private val ctx = "DashRawSource"
private val cfg = plugin.config
val url: String?;
override var manifest: String?;
override val language: String? = _obj.getOrDefault(cfg, "language", ctx, null);
override val original: Boolean? = _obj.getOrDefault(cfg, "original", ctx, null);
override val hasGenerate: Boolean;
val canMerge: Boolean;
override var streamMetaData: StreamMetaData? = null;
override val container: String =
_obj.getOrDefault<String>(cfg, "container", ctx, null) ?: "application/dash+xml"
override val name: String =
_obj.getOrThrow<String>(cfg, "name", ctx)
override val width: Int =
_obj.getOrDefault<Int>(cfg, "width", ctx, null)?.toInt() ?: 0
override val height: Int =
_obj.getOrDefault<Int>(cfg, "height", ctx, null)?.toInt() ?: 0
override val codec: String =
_obj.getOrDefault<String>(cfg, "codec", ctx, "") ?: ""
override val bitrate: Int? =
_obj.getOrDefault<Int>(cfg, "bitrate", ctx, null)?.toInt()
override val duration: Long =
_obj.getOrDefault<Long>(cfg, "duration", ctx, 0)?.toLong() ?: 0L
override val priority: Boolean =
_obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false
val url: String? =
_obj.getOrDefault<String>(cfg, "url", ctx, null)
override var manifest: String? =
_obj.getOrDefault<String>(cfg, "manifest", ctx, null)
override val hasGenerate: Boolean = plugin.busy { _obj.has("generate") }
val canMerge: Boolean =
_obj.getOrDefault<Boolean>(cfg, "canMerge", ctx, false) ?: false
override var streamMetaData: StreamMetaData? = null
var audioStreamMetaData: StreamMetaData? = null
private var _pregenerate: V8Deferred<String?>? = null
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
_pregenerate = generateAsync(scope);
return _pregenerate;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) {
val contextName = "DashRawSource";
val config = plugin.config;
name = _obj.getOrThrow(config, "name", contextName);
url = _obj.getOrThrow(config, "url", contextName);
container = _obj.getOrDefault<String>(config, "container", contextName, null) ?: "application/dash+xml";
manifest = _obj.getOrDefault<String>(config, "manifest", contextName, null);
width = _obj.getOrDefault(config, "width", contextName, 0) ?: 0;
height = _obj.getOrDefault(config, "height", contextName, 0) ?: 0;
codec = _obj.getOrDefault(config, "codec", contextName, "") ?: "";
bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0;
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
canMerge = _obj.getOrDefault(config, "canMerge", contextName, false) ?: false;
hasGenerate = _obj.has("generate");
}
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
if(!hasGenerate)
return V8Deferred(CompletableDeferred(manifest));
if(_plugin.busy { _obj.isClosed })
if(_obj.isClosed)
throw IllegalStateException("Source object already closed");
val pregenerated = _pregenerate;
if(pregenerated != null) {
Logger.w("JSDashManifestRawSource", "Returning pre-generated video");
return pregenerated;
}
val plugin = _plugin.getUnderlyingPlugin();
@@ -126,14 +99,6 @@ open class JSDashManifestRawSource(
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
}
val audioInitStart = _obj.getOrDefault<Int>(_config, "audioInitStart", "JSDashManifestRawSource", null) ?: 0;
val audioInitEnd = _obj.getOrDefault<Int>(_config, "audioInitEnd", "JSDashManifestRawSource", null) ?: 0;
val audioIndexStart = _obj.getOrDefault<Int>(_config, "audioIndexStart", "JSDashManifestRawSource", null) ?: 0;
val audioIndexEnd = _obj.getOrDefault<Int>(_config, "audioIndexEnd", "JSDashManifestRawSource", null) ?: 0;
if(audioInitEnd > 0 && audioIndexStart > 0 && audioIndexEnd > 0) {
audioStreamMetaData = StreamMetaData(audioInitStart, audioInitEnd, audioIndexStart, audioIndexEnd);
}
return@busy result.convert {
it.value
};
@@ -142,7 +107,7 @@ open class JSDashManifestRawSource(
override open fun generate(): String? {
if(!hasGenerate)
return manifest;
if(_plugin.busy { _obj.isClosed })
if(_obj.isClosed)
throw IllegalStateException("Source object already closed");
var result: String? = null;
@@ -171,14 +136,6 @@ open class JSDashManifestRawSource(
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
}
val audioInitStart = _obj.getOrDefault<Int>(_config, "audioInitStart", "JSDashManifestRawSource", null) ?: 0;
val audioInitEnd = _obj.getOrDefault<Int>(_config, "audioInitEnd", "JSDashManifestRawSource", null) ?: 0;
val audioIndexStart = _obj.getOrDefault<Int>(_config, "audioIndexStart", "JSDashManifestRawSource", null) ?: 0;
val audioIndexEnd = _obj.getOrDefault<Int>(_config, "audioIndexEnd", "JSDashManifestRawSource", null) ?: 0;
if(audioInitEnd > 0 && audioIndexStart > 0 && audioIndexEnd > 0) {
audioStreamMetaData = StreamMetaData(audioInitStart, audioInitEnd, audioIndexStart, audioIndexEnd);
}
}
}
return result;
@@ -206,9 +163,6 @@ class JSDashManifestMergingRawSource(
override val priority: Boolean
get() = video.priority;
override val language: String? get() = audio.language
override val original: Boolean? get() = audio.original;
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
val videoDashDef = video.generateAsync(scope);
val audioDashDef = audio.generateAsync(scope);
@@ -258,4 +212,4 @@ class JSDashManifestMergingRawSource(
companion object {
private val adaptationSetRegex = Regex("<AdaptationSet.*?>.*?<\\/AdaptationSet>", RegexOption.DOT_MATCHES_ALL);
}
}
}
@@ -21,9 +21,6 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
override var priority: Boolean = false;
override val language: String?;
override val original: Boolean?;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
val contextName = "DashSource";
val config = plugin.config;
@@ -32,9 +29,6 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
duration = _obj.getOrThrow(config, "duration", contextName);
priority = obj.getOrNull(config, "priority", contextName) ?: false;
language = obj.getOrNull(config, "language", contextName);
original = obj.getOrNull(config, "original", contextName);
}
override fun getVideoUrl(): String {
@@ -28,9 +28,6 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
override val licenseUri: String
override val hasLicenseRequestExecutor: Boolean
override val language: String?;
override val original: Boolean?;
@Suppress("ConvertSecondaryConstructorToPrimary")
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
val contextName = "DashWidevineSource"
@@ -42,29 +39,24 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
priority = obj.getOrNull(config, "priority", contextName) ?: false
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = plugin.busy { obj.has("getLicenseRequestExecutor") }
language = _obj.getOrNull(config, "language", contextName);
original = _obj.getOrNull(config, "original", contextName);
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
return _plugin.busy {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return@busy null
if (!hasLicenseRequestExecutor || _obj.isClosed)
return null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return@busy null
return@busy JSRequestExecutor(_plugin, result)
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return null
return JSRequestExecutor(_plugin, result)
}
override fun getVideoUrl(): String {
return url
}
}
}
@@ -34,7 +34,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
language = _obj.getOrThrow(config, "language", contextName);
priority = obj.getOrNull(config, "priority", contextName) ?: false;
original = obj.getOrNull(config, "original", contextName) ?: false;
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
}
@@ -21,9 +21,6 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
override var priority: Boolean = false;
override val language: String?;
override val original: Boolean?;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
val contextName = "HLSSource";
val config = plugin.config;
@@ -33,8 +30,5 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
priority = obj.getOrNull(config, "priority", contextName) ?: false;
language = _obj.getOrNull(config, "language", contextName);
original = _obj.getOrNull(config, "original", contextName);
}
}
@@ -44,26 +44,15 @@ abstract class JSSource {
this._obj = obj;
this.type = type;
var parsedRequestModifier: JSRequest? = null;
var parsedHasRequestModifier = false;
var parsedRequestExecutor: JSRequest? = null;
var parsedHasRequestExecutor = false;
plugin.busy {
parsedRequestModifier = obj.getOrDefault<V8ValueObject>(_config, "requestModifier", "JSSource.requestModifier", null)?.let {
JSRequest(plugin, it, null, null, true);
};
parsedHasRequestModifier = parsedRequestModifier != null || obj.has("getRequestModifier");
parsedRequestExecutor = obj.getOrDefault<V8ValueObject>(_config, "requestExecutor", "JSSource.requestExecutor", null)?.let {
JSRequest(plugin, it, null, null, true);
};
parsedHasRequestExecutor = parsedRequestExecutor != null || obj.has("getRequestExecutor");
_requestModifier = obj.getOrDefault<V8ValueObject>(_config, "requestModifier", "JSSource.requestModifier", null)?.let {
JSRequest(plugin, it, null, null, true);
}
hasRequestModifier = _requestModifier != null || obj.has("getRequestModifier");
_requestModifier = parsedRequestModifier;
hasRequestModifier = parsedHasRequestModifier;
_requestExecutor = parsedRequestExecutor;
hasRequestExecutor = parsedHasRequestExecutor;
_requestExecutor = obj.getOrDefault<V8ValueObject>(_config, "requestExecutor", "JSSource.requestExecutor", null)?.let {
JSRequest(plugin, it, null, null, true);
}
hasRequestExecutor = _requestExecutor != null || obj.has("getRequestExecutor");
}
fun getRequestModifier(): IRequestModifier? = _plugin.isBusyWith("getRequestModifier") {
@@ -177,4 +166,4 @@ abstract class JSSource {
}
}
}
}
}
@@ -5,50 +5,42 @@ import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrNull
import com.futo.platformplayer.getOrThrow
open class JSVideoUrlSource(
plugin: JSClient,
obj: V8ValueObject
) : JSSource(TYPE_VIDEOURL, plugin, obj), IVideoUrlSource {
open class JSVideoUrlSource : IVideoUrlSource, JSSource {
override val width : Int;
override val height : Int;
override val container : String;
override val codec: String;
override val name : String;
override val bitrate : Int;
override val duration: Long;
private val url : String;
private val ctx = "JSVideoUrlSource"
private val cfg = plugin.config
override var priority: Boolean = false;
override val width: Int =
_obj.getOrThrow<Int>(cfg, "width", ctx)
constructor(plugin: JSClient, obj: V8ValueObject): super(TYPE_VIDEOURL, plugin, obj) {
val contextName = "JSVideoUrlSource";
val config = plugin.config;
override val height: Int =
_obj.getOrThrow<Int>(cfg, "height", ctx)
width = _obj.getOrThrow(config, "width", contextName);
height = _obj.getOrThrow(config, "height", contextName);
container = _obj.getOrThrow(config, "container", contextName);
codec = _obj.getOrThrow(config, "codec", contextName);
name = _obj.getOrThrow(config, "name", contextName);
bitrate = _obj.getOrThrow(config, "bitrate", contextName);
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
url = _obj.getOrThrow(config, "url", contextName);
override val container: String =
_obj.getOrThrow<String>(cfg, "container", ctx)
priority = obj.getOrNull(config, "priority", contextName) ?: false;
}
override val codec: String =
_obj.getOrThrow<String>(cfg, "codec", ctx)
override fun getVideoUrl() : String {
return url;
}
override val name: String =
_obj.getOrThrow<String>(cfg, "name", ctx)
override val bitrate: Int =
_obj.getOrThrow<Int>(cfg, "bitrate", ctx)
override val duration: Long =
_obj.getOrThrow<Long>(cfg, "duration", ctx)
private val url: String =
_obj.getOrThrow<String>(cfg, "url", ctx)
override var priority: Boolean =
_obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false
override val language: String? = _obj.getOrDefault(cfg, "language", ctx, null);
override val original: Boolean? = _obj.getOrDefault(cfg, "original", ctx, null);
override fun getVideoUrl(): String = url
override fun toString(): String =
"(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url)"
}
override fun toString(): String {
return "(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url)"
}
}
@@ -18,27 +18,25 @@ class JSVideoUrlWidevineSource : JSVideoUrlSource, IVideoUrlWidevineSource {
val config = plugin.config
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = plugin.busy { obj.has("getLicenseRequestExecutor") }
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
return _plugin.busy {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return@busy null
if (!hasLicenseRequestExecutor || _obj.isClosed)
return null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return@busy null
return@busy JSRequestExecutor(_plugin, result)
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return null
return JSRequestExecutor(_plugin, result)
}
override fun toString(): String {
val url = getVideoUrl()
return "(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url, hasLicenseRequestExecutor=$hasLicenseRequestExecutor, licenseUri=$licenseUri)"
}
}
}
@@ -1,160 +1,5 @@
package com.futo.platformplayer.api.media.platforms.local
import android.content.ContentResolver
import android.net.Uri
import android.provider.MediaStore
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformClientCapabilities
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.states.StateLibrary
import java.net.MalformedURLException
class LocalClient: IPlatformClient {
override val id: String = "LOCAL"
override val name: String = "Local"
override val icon: ImageVariable? = ImageVariable.fromResource(R.drawable.ic_library)
override val capabilities: PlatformClientCapabilities = PlatformClientCapabilities()
override fun initialize() {}
override fun disable() {
}
override fun getHome(): IPager<IPlatformContent>
= EmptyPager();
override fun isContentDetailsUrl(url: String): Boolean {
try {
val uri = Uri.parse(url);
return ContentResolver.SCHEME_CONTENT == uri.scheme
&& (
MediaStore.AUTHORITY == uri.authority ||
uri.authority == "com.android.externalstorage.documents"
)
}
catch(ex: MalformedURLException) {
return false;
}
}
val audioExtensions = listOf(".mp3", ".wav", ".flac", ".mp4a", ".m4a");
override fun getContentDetails(url: String): IPlatformContentDetails {
val uri = Uri.parse(url);
if("audio" in uri.pathSegments) {
return StateLibrary.getAudioTrack(url) ?: throw Exception("Failed to find ${url}");
}
else if("video" in uri.pathSegments) {
return StateLibrary.getVideoTrack(url) ?: throw Exception("Failed to find ${url}");
}
else if(uri.toString().contains("com.android.externalstorage.documents")) {
if(audioExtensions.any { uri.lastPathSegment?.lowercase()?.endsWith(it) ?: false })
return StateLibrary.getAudioTrack(url) ?: throw Exception("Failed to find ${url}");
else
return StateLibrary.getVideoTrack(url) ?: throw Exception("Failed to find ${url}");
}
else
throw Exception("Unknown content url [${url}]");
}
override fun getSearchCapabilities(): ResultCapabilities
= ResultCapabilities();
override fun search(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> {
return EmptyPager(); //TODO
}
override fun getSearchChannelContentsCapabilities(): ResultCapabilities
= ResultCapabilities();
override fun searchChannelContents(channelUrl: String, query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> {
return EmptyPager(); //TODO
}
override fun searchChannels(query: String): IPager<PlatformAuthorLink> {
return EmptyPager(); //TODO
}
override fun searchChannelsAsContent(query: String): IPager<IPlatformContent> {
return EmptyPager(); //TODO
}
override fun isChannelUrl(url: String): Boolean {
return false //TODO
}
override fun getChannel(channelUrl: String): IPlatformChannel {
throw NotImplementedError();
}
override fun getChannelCapabilities(): ResultCapabilities
= ResultCapabilities();
override fun getChannelContents(channelUrl: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> {
return EmptyPager();
}
override fun getChannelPlaylists(channelUrl: String): IPager<IPlatformPlaylist> {
return EmptyPager();
}
override fun getPeekChannelTypes(): List<String> = listOf();
override fun peekChannelContents(channelUrl: String, type: String?): List<IPlatformContent>
= listOf();
override fun getShorts(): IPager<IPlatformVideo> = EmptyPager();
override fun searchSuggestions(query: String): Array<String> = arrayOf();
override fun getChannelUrlByClaim(claimType: Int, claimValues: Map<Int, String>): String?
= null;
override fun getContentChapters(url: String): List<IChapter>
= listOf();
override fun getPlaybackTracker(url: String): IPlaybackTracker?
= null;
override fun getContentRecommendations(url: String): IPager<IPlatformContent>?
= null;
override fun getComments(url: String): IPager<IPlatformComment>
= EmptyPager();
override fun getSubComments(comment: IPlatformComment): IPager<IPlatformComment>
= EmptyPager();
override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor?
= null;
override fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>?
= null;
override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent>
= throw NotImplementedError();
override fun isPlaylistUrl(url: String): Boolean = false;
override fun getPlaylist(url: String): IPlatformPlaylistDetails
= throw NotImplementedError();
override fun getUserPlaylists(): Array<String> = throw NotImplementedError();
override fun getUserSubscriptions(): Array<String> = throw NotImplementedError();
override fun getUserHistory(): IPager<IPlatformContent> = throw NotImplementedError();
override fun isClaimTypeSupported(claimType: Int): Boolean = false;
class LocalClient {
//TODO
}
@@ -1,23 +1,13 @@
package com.futo.platformplayer.api.media.platforms.local.models
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
import com.futo.platformplayer.downloads.VideoLocal
class LocalVideoMuxedSourceDescriptor: VideoMuxedSourceDescriptor {
override val videoSources: Array<IVideoSource>;
constructor(video: LocalVideoFileSource) {
videoSources = arrayOf(video);
}
constructor(video: LocalVideoContentSource) {
videoSources = arrayOf(video);
}
constructor(videoSources: Array<IVideoSource>) {
this.videoSources = videoSources;
}
class LocalVideoMuxedSourceDescriptor(
private val video: LocalVideoFileSource
) : VideoMuxedSourceDescriptor() {
override val videoSources: Array<IVideoSource> get() = arrayOf(video);
}
@@ -1,33 +0,0 @@
package com.futo.platformplayer.api.media.platforms.local.models.sources
import android.content.Context
import android.provider.MediaStore
import android.provider.MediaStore.Video
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.others.Language
import java.io.File
class LocalAudioContentSource : IAudioSource {
override val name: String;
override val container: String;
override val codec: String = ""
override val bitrate: Int = 0
override val duration: Long;
override val priority: Boolean = false;
override val language: String = Language.UNKNOWN
override val original: Boolean = false;
var contentUrl: String;
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
this.name = name ?: "File";
container = mime;
this.duration = duration;
this.contentUrl = contentUrl;
}
}
@@ -1,34 +0,0 @@
package com.futo.platformplayer.api.media.platforms.local.models.sources
import android.content.Context
import android.provider.MediaStore
import android.provider.MediaStore.Video
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.others.Language
import java.io.File
class LocalAudioFileSource: IAudioSource {
override val name: String;
override val container: String;
override val codec: String = ""
override val bitrate: Int = 0
override val duration: Long;
override val priority: Boolean = false;
override val language: String = Language.UNKNOWN;
override val original: Boolean = false;
var file: File;
constructor(file: File) {
this.file = file;
name = file.name;
container = VideoHelper.videoExtensionToMimetype(file.extension) ?: "";
duration = 0;
}
}
@@ -1,36 +0,0 @@
package com.futo.platformplayer.api.media.platforms.local.models.sources
import android.content.Context
import android.provider.MediaStore
import android.provider.MediaStore.Video
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
import com.futo.platformplayer.helpers.VideoHelper
import java.io.File
class LocalVideoContentSource: IVideoSource {
override val name: String;
override val width: Int;
override val height: Int;
override val container: String;
override val codec: String = ""
override val bitrate: Int = 0
override val duration: Long;
override val priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
var contentUrl: String;
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
this.name = name ?: "File";
width = 0;
height = 0;
container = mime;
this.duration = duration;
this.contentUrl = contentUrl;
}
}
@@ -20,13 +20,7 @@ class LocalVideoFileSource: IVideoSource {
override val duration: Long;
override val priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = null;
var file: File;
constructor(file: File) {
this.file = file;
name = file.name;
width = 0;
height = 0;
@@ -12,7 +12,7 @@ class MultiDistributionContentPager<T : IPlatformContent> : MultiPager<T> {
private val dist : HashMap<IPager<T>, Float>;
private val distConsumed : HashMap<IPager<T>, Float>;
constructor(pagers : Map<IPager<T>, Float>, pageSize: Int = 9) : super(pagers.keys.toMutableList(), false, pageSize) {
constructor(pagers : Map<IPager<T>, Float>) : super(pagers.keys.toMutableList()) {
val distTotal = pagers.values.sum();
dist = HashMap();
@@ -0,0 +1,330 @@
package com.futo.platformplayer.casting
import android.os.Looper
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.net.InetAddress
import java.util.UUID
class AirPlayCastingDevice : CastingDevice {
//See for more info: https://nto.github.io/AirPlay
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = false;
override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
private var _scopeIO: CoroutineScope? = null;
private var _started: Boolean = false;
private var _sessionId: String? = null;
private val _client = ManagedHttpClient();
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
this.addresses = addresses;
this.port = port;
}
constructor(deviceInfo: CastingDeviceInfo) : super() {
this.name = deviceInfo.name;
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
this.port = deviceInfo.port;
}
override fun getAddresses(): List<InetAddress> {
return addresses?.toList() ?: listOf();
}
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
return;
}
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
setTime(resumePosition);
setDuration(duration);
if (resumePosition > 0.0) {
val pos = resumePosition / duration;
Logger.i(TAG, "resumePosition: $resumePosition, duration: ${duration}, pos: $pos")
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: $pos");
} else {
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0");
}
if (speed != null) {
changeSpeed(speed)
}
}
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
throw NotImplementedError();
}
override fun seekVideo(timeSeconds: Double) {
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
return;
}
post("scrub?position=${timeSeconds}");
}
override fun resumeVideo() {
if (invokeInIOScopeIfRequired(::resumeVideo)) {
return;
}
isPlaying = true;
post("rate?value=1.000000");
}
override fun pauseVideo() {
if (invokeInIOScopeIfRequired(::pauseVideo)) {
return;
}
isPlaying = false;
post("rate?value=0.000000");
}
override fun stopVideo() {
if (invokeInIOScopeIfRequired(::stopVideo)) {
return;
}
post("stop");
}
override fun stopCasting() {
if (invokeInIOScopeIfRequired(::stopCasting)) {
return;
}
post("stop");
stop();
}
override fun start() {
val adrs = addresses ?: return;
if (_started) {
return;
}
_started = true;
_scopeIO?.cancel();
_scopeIO = CoroutineScope(Dispatchers.IO);
Logger.i(TAG, "Starting...");
_scopeIO?.launch {
try {
connectionState = CastConnectionState.CONNECTING;
while (_scopeIO?.isActive == true) {
try {
val connectedSocket = getConnectedSocket(adrs.toList(), port);
if (connectedSocket == null) {
delay(1000);
continue;
}
usedRemoteAddress = connectedSocket.inetAddress;
localAddress = connectedSocket.localAddress;
connectedSocket.close();
_sessionId = UUID.randomUUID().toString();
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
delay(1000);
}
}
while (_scopeIO?.isActive == true) {
try {
val progressInfo = getProgress();
if (progressInfo == null) {
connectionState = CastConnectionState.CONNECTING;
Logger.i(TAG, "Failed to retrieve progress from AirPlay device.");
delay(1000);
continue;
}
connectionState = CastConnectionState.CONNECTED;
val progressIndex = progressInfo.lowercase().indexOf("position: ");
if (progressIndex == -1) {
delay(1000);
continue;
}
val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue;
setTime(progress);
val durationIndex = progressInfo.lowercase().indexOf("duration: ");
if (durationIndex == -1) {
delay(1000);
continue;
}
val duration = progressInfo.substring(durationIndex + "duration: ".length).toDoubleOrNull() ?: continue;
setDuration(duration);
delay(1000);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get server info from AirPlay device.", e)
}
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to setup AirPlay device connection.", e)
}
};
Logger.i(TAG, "Started.");
}
override fun stop() {
Logger.i(TAG, "Stopping...");
connectionState = CastConnectionState.DISCONNECTED;
usedRemoteAddress = null;
localAddress = null;
_started = false;
_scopeIO?.cancel();
_scopeIO = null;
}
override fun changeSpeed(speed: Double) {
setSpeed(speed)
post("rate?value=$speed")
}
override fun getDeviceInfo(): CastingDeviceInfo {
return CastingDeviceInfo(name!!, CastProtocolType.AIRPLAY, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
}
private fun getProgress(): String? {
val info = get("scrub");
Logger.i(TAG, "Progress: ${info ?: "null"}");
return info;
}
private fun getPlaybackInfo(): String? {
val playbackInfo = get("playback-info");
Logger.i(TAG, "Playback info: ${playbackInfo ?: "null"}");
return playbackInfo;
}
private fun getServerInfo(): String? {
val serverInfo = get("server-info");
Logger.i(TAG, "Server info: ${serverInfo ?: "null"}");
return serverInfo;
}
private fun post(path: String): Boolean {
try {
val sessionId = _sessionId ?: return false;
val headers = hashMapOf(
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
"User-Agent" to "MediaControl/1.0",
"Content-Length" to "0",
"X-Apple-Session-ID" to sessionId
);
val url = "http://${usedRemoteAddress}:${port}/${path}";
Logger.i(TAG, "POST $url");
val response = _client.post(url, headers);
if (!response.isOk) {
return false;
}
return true;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to POST $path");
return false;
}
}
private fun post(path: String, contentType: String, body: String): Boolean {
try {
val sessionId = _sessionId ?: return false;
val headers = hashMapOf(
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
"User-Agent" to "MediaControl/1.0",
"X-Apple-Session-ID" to sessionId,
"Content-Type" to contentType
);
val url = "http://${usedRemoteAddress}:${port}/${path}";
Logger.i(TAG, "POST $url:\n$body");
val response = _client.post(url, body, headers);
if (!response.isOk) {
return false;
}
return true;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to POST $path $body");
return false;
}
}
private fun get(path: String): String? {
val sessionId = _sessionId ?: return null;
try {
val headers = hashMapOf(
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
"Content-Length" to "0",
"User-Agent" to "MediaControl/1.0",
"X-Apple-Session-ID" to sessionId
);
val url = "http://${usedRemoteAddress}:${port}/${path}";
Logger.i(TAG, "GET $url");
val response = _client.get(url, headers);
if (!response.isOk) {
return null;
}
if (response.body == null) {
return null;
}
return response.body.string();
} catch (e: Throwable) {
Logger.w(TAG, "Failed to GET $path");
return null;
}
}
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
if(Looper.getMainLooper().thread == Thread.currentThread()) {
_scopeIO?.launch { action(); }
return true;
}
return false;
}
companion object {
val TAG = "AirPlayCastingDevice";
}
}
@@ -1,10 +1,6 @@
package com.futo.platformplayer.casting
import android.os.Build
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
@@ -13,25 +9,7 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.fcast.sender_sdk.ApplicationInfo
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice
import org.fcast.sender_sdk.KeyEvent
import org.fcast.sender_sdk.MediaEvent
import java.net.InetAddress
import org.fcast.sender_sdk.PlaybackState
import org.fcast.sender_sdk.Source
import org.fcast.sender_sdk.urlFormatIpAddr
import java.net.Inet4Address
import java.net.Inet6Address
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
import org.fcast.sender_sdk.DeviceConnectionState
import org.fcast.sender_sdk.DeviceFeature
import org.fcast.sender_sdk.EventSubscription
import org.fcast.sender_sdk.IpAddr
import org.fcast.sender_sdk.LoadRequest
import org.fcast.sender_sdk.MediaItemEventType
import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.ProtocolType
enum class CastConnectionState {
DISCONNECTED,
@@ -46,8 +24,7 @@ enum class CastProtocolType {
FCAST;
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: CastProtocolType) {
encoder.encodeString(value.name)
@@ -63,263 +40,109 @@ enum class CastProtocolType {
}
}
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
is IpAddr.V4 -> Inet4Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte()
)
)
abstract class CastingDevice {
abstract val protocol: CastProtocolType;
abstract val isReady: Boolean;
abstract var usedRemoteAddress: InetAddress?;
abstract var localAddress: InetAddress?;
abstract val canSetVolume: Boolean;
abstract val canSetSpeed: Boolean;
is IpAddr.V6 -> Inet6Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte(),
addr.o5.toByte(),
addr.o6.toByte(),
addr.o7.toByte(),
addr.o8.toByte(),
addr.o9.toByte(),
addr.o10.toByte(),
addr.o11.toByte(),
addr.o12.toByte(),
addr.o13.toByte(),
addr.o14.toByte(),
addr.o15.toByte(),
addr.o16.toByte()
)
)
}
var name: String? = null;
var isPlaying: Boolean = false
set(value) {
val changed = value != field;
field = value;
if (changed) {
onPlayChanged.emit(value);
}
};
class CastingDevice(val device: RsCastingDevice) {
class EventHandler : RsDeviceEventHandler {
var onConnectionStateChanged = Event1<DeviceConnectionState>();
var onPlayChanged = Event1<Boolean>()
var onTimeChanged = Event1<Double>()
var onDurationChanged = Event1<Double>()
var onVolumeChanged = Event1<Double>()
var onSpeedChanged = Event1<Double>()
var onMediaItemEnd = Event0()
override fun connectionStateChanged(state: DeviceConnectionState) {
onConnectionStateChanged.emit(state)
}
override fun volumeChanged(volume: Double) {
onVolumeChanged.emit(volume)
}
override fun timeChanged(time: Double) {
onTimeChanged.emit(time)
}
override fun playbackStateChanged(state: PlaybackState) {
onPlayChanged.emit(state == PlaybackState.PLAYING)
}
override fun durationChanged(duration: Double) {
onDurationChanged.emit(duration)
}
override fun speedChanged(speed: Double) {
onSpeedChanged.emit(speed)
}
override fun sourceChanged(source: Source) {
// TODO
}
override fun keyEvent(event: KeyEvent) {
// Unreachable
}
override fun mediaEvent(event: MediaEvent) {
if (event.type == MediaItemEventType.END) {
onMediaItemEnd.emit()
}
}
override fun playbackError(message: String) {
Logger.e(TAG, "Playback error: $message")
}
}
val eventHandler = EventHandler()
val isReady: Boolean
get() = device.isReady()
val name: String
get() = device.name()
var usedRemoteAddress: InetAddress? = null
var localAddress: InetAddress? = null
fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
val onConnectionStateChanged =
Event1<CastConnectionState>()
val onPlayChanged: Event1<Boolean>
get() = eventHandler.onPlayChanged
val onTimeChanged: Event1<Double>
get() = eventHandler.onTimeChanged
val onDurationChanged: Event1<Double>
get() = eventHandler.onDurationChanged
val onVolumeChanged: Event1<Double>
get() = eventHandler.onVolumeChanged
val onSpeedChanged: Event1<Double>
get() = eventHandler.onSpeedChanged
val onMediaItemEnd: Event0
get() = eventHandler.onMediaItemEnd
fun resumePlayback() = device.resumePlayback()
fun pausePlayback() = device.pausePlayback()
fun stopPlayback() = device.stopPlayback()
fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
fun changeVolume(newVolume: Double) {
device.changeVolume(newVolume)
volume = newVolume
}
fun changeSpeed(speed: Double) = device.changeSpeed(speed)
fun connect() = device.connect(
ApplicationInfo(
"Grayjay Android",
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
"${Build.MANUFACTURER} ${Build.MODEL}"
),
eventHandler,
1000.toULong()
)
fun disconnect() = device.disconnect()
fun getDeviceInfo(): CastingDeviceInfo {
val info = device.getDeviceInfo()
return CastingDeviceInfo(
info.name,
when (info.protocol) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
},
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
port = info.port.toInt(),
)
}
fun getAddresses(): List<InetAddress> = device.getAddresses().map {
ipAddrToInetAddress(it)
}
fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Video(
contentType = contentType,
url = contentId,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata,
requestHeaders = null,
)
)
fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Content(
contentType = contentType,
content = content,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata,
requestHeaders = null,
)
)
var connectionState = CastConnectionState.DISCONNECTED
val protocolType: CastProtocolType
get() = when (device.castingProtocol()) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
}
var volume: Double = 1.0
var duration: Double = 0.0
private var lastTimeChangeTime_ms: Long = 0
var time: Double = 0.0
var speed: Double = 0.0
var isPlaying: Boolean = false
private set
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
time = value
lastTimeChangeTime_ms = changeTime_ms
onTimeChanged.emit(value)
}
}
private var lastDurationChangeTime_ms: Long = 0
var duration: Double = 0.0
private set
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
duration = value
lastDurationChangeTime_ms = changeTime_ms
onDurationChanged.emit(value)
}
}
private var lastVolumeChangeTime_ms: Long = 0
var volume: Double = 1.0
private set
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
volume = value
lastVolumeChangeTime_ms = changeTime_ms
onVolumeChanged.emit(value)
}
}
private var lastSpeedChangeTime_ms: Long = 0
var speed: Double = 1.0
private set
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
speed = value
lastSpeedChangeTime_ms = changeTime_ms
onSpeedChanged.emit(value)
}
}
val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff
}
val diff = if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff;
};
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
set(value) {
val changed = value != field;
field = value;
init {
eventHandler.onConnectionStateChanged.subscribe { newState ->
when (newState) {
is DeviceConnectionState.Connected -> {
if (device.supportsFeature(DeviceFeature.MEDIA_EVENT_SUBSCRIPTION)) {
try {
device.subscribeEvent(EventSubscription.MediaItemEnd)
} catch (e: Exception) {
Logger.e(TAG, "Failed to subscribe to MediaItemEnd events: $e")
}
}
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
localAddress = ipAddrToInetAddress(newState.localAddr)
connectionState = CastConnectionState.CONNECTED
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
}
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
connectionState = CastConnectionState.CONNECTING
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
}
DeviceConnectionState.Disconnected -> {
connectionState = CastConnectionState.DISCONNECTED
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
}
if (changed) {
onConnectionStateChanged.emit(value);
}
};
if (newState == DeviceConnectionState.Disconnected) {
try {
Logger.i(TAG, "Stopping device")
device.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to stop device: $e")
}
}
}
eventHandler.onPlayChanged.subscribe { isPlaying = it }
eventHandler.onTimeChanged.subscribe {
lastTimeChangeTime_ms = System.currentTimeMillis()
time = it
}
eventHandler.onDurationChanged.subscribe { duration = it }
eventHandler.onVolumeChanged.subscribe { volume = it }
eventHandler.onSpeedChanged.subscribe { speed = it }
}
var onConnectionStateChanged = Event1<CastConnectionState>();
var onPlayChanged = Event1<Boolean>();
var onTimeChanged = Event1<Double>();
var onDurationChanged = Event1<Double>();
var onVolumeChanged = Event1<Double>();
var onSpeedChanged = Event1<Double>();
fun ensureThreadStarted() {}
abstract fun stopCasting();
companion object {
private val TAG = "CastingDeviceExp"
}
}
abstract fun seekVideo(timeSeconds: Double);
abstract fun stopVideo();
abstract fun pauseVideo();
abstract fun resumeVideo();
abstract fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?);
abstract fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?);
open fun changeVolume(volume: Double) { throw NotImplementedError() }
open fun changeSpeed(speed: Double) { throw NotImplementedError() }
abstract fun start();
abstract fun stop();
abstract fun getDeviceInfo(): CastingDeviceInfo;
abstract fun getAddresses(): List<InetAddress>;
}
@@ -0,0 +1,736 @@
package com.futo.platformplayer.casting
import android.os.Looper
import android.util.Log
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.protos.ChromeCast
import com.futo.platformplayer.toHexString
import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.io.DataInputStream
import java.io.DataOutputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
class ChromecastCastingDevice : CastingDevice {
//See for more info: https://developers.google.com/cast/docs/media/messages
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = true;
override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
private var _streamType: String? = null;
private var _contentType: String? = null;
private var _contentId: String? = null;
private var _socket: SSLSocket? = null;
private var _outputStream: DataOutputStream? = null;
private var _outputStreamLock = Object();
private var _inputStream: DataInputStream? = null;
private var _inputStreamLock = Object();
private var _scopeIO: CoroutineScope? = null;
private var _requestId = 1;
private var _started: Boolean = false;
private var _sessionId: String? = null;
private var _transportId: String? = null;
private var _launching = false;
private var _mediaSessionId: Int? = null;
private var _thread: Thread? = null;
private var _pingThread: Thread? = null;
private var _launchRetries = 0
private val MAX_LAUNCH_RETRIES = 3
private var _lastLaunchTime_ms = 0L
private var _retryJob: Job? = null
private var _autoLaunchEnabled = true
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
this.addresses = addresses;
this.port = port;
}
constructor(deviceInfo: CastingDeviceInfo) : super() {
this.name = deviceInfo.name;
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
this.port = deviceInfo.port;
}
override fun getAddresses(): List<InetAddress> {
return addresses?.toList() ?: listOf();
}
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
return;
}
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
setTime(resumePosition);
setDuration(duration);
_streamType = streamType;
_contentType = contentType;
_contentId = contentId;
playVideo();
}
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
//TODO: Can maybe be implemented by sending data:contentType,base64...
throw NotImplementedError();
}
private fun connectMediaChannel(transportId: String) {
val connectObject = JSONObject();
connectObject.put("type", "CONNECT");
connectObject.put("connType", 0);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
}
private fun requestMediaStatus() {
val transportId = _transportId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "GET_STATUS");
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
private fun playVideo() {
val transportId = _transportId ?: return;
val contentId = _contentId ?: return;
val streamType = _streamType ?: return;
val contentType = _contentType ?: return;
val loadObject = JSONObject();
loadObject.put("type", "LOAD");
val mediaObject = JSONObject();
mediaObject.put("contentId", contentId);
mediaObject.put("streamType", streamType);
mediaObject.put("contentType", contentType);
if (time > 0.0) {
val seekTime = time;
loadObject.put("currentTime", seekTime);
}
loadObject.put("media", mediaObject);
loadObject.put("requestId", _requestId++);
//TODO: This replace is necessary to get rid of backward slashes added by the JSON Object serializer
val json = loadObject.toString().replace("\\/","/");
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", json);
}
override fun changeSpeed(speed: Double) {
if (invokeInIOScopeIfRequired { changeSpeed(speed) }) return
val speedClamped = speed.coerceAtLeast(1.0).coerceAtLeast(1.0).coerceAtMost(2.0)
setSpeed(speedClamped)
val mediaSessionId = _mediaSessionId ?: return
val transportId = _transportId ?: return
val setSpeedObject = JSONObject().apply {
put("type", "SET_PLAYBACK_RATE")
put("mediaSessionId", mediaSessionId)
put("playbackRate", speedClamped)
put("requestId", _requestId++)
}
sendChannelMessage(sourceId = "sender-0", destinationId = transportId, namespace = "urn:x-cast:com.google.cast.media", json = setSpeedObject.toString())
}
override fun changeVolume(volume: Double) {
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
return;
}
setVolume(volume)
val setVolumeObject = JSONObject();
setVolumeObject.put("type", "SET_VOLUME");
val volumeObject = JSONObject();
volumeObject.put("level", volume)
setVolumeObject.put("volume", volumeObject);
setVolumeObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", setVolumeObject.toString());
}
override fun seekVideo(timeSeconds: Double) {
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "SEEK");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
loadObject.put("currentTime", timeSeconds);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
override fun resumeVideo() {
if (invokeInIOScopeIfRequired(::resumeVideo)) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "PLAY");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
override fun pauseVideo() {
if (invokeInIOScopeIfRequired(::pauseVideo)) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "PAUSE");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
override fun stopVideo() {
if (invokeInIOScopeIfRequired(::stopVideo)) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
_contentId = null;
_contentType = null;
_streamType = null;
val loadObject = JSONObject();
loadObject.put("type", "STOP");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
private fun launchPlayer() {
if (invokeInIOScopeIfRequired(::launchPlayer)) {
return;
}
val launchObject = JSONObject();
launchObject.put("type", "LAUNCH");
launchObject.put("appId", "CC1AD845");
launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
_lastLaunchTime_ms = System.currentTimeMillis()
}
private fun getStatus() {
if (invokeInIOScopeIfRequired(::getStatus)) {
return;
}
val launchObject = JSONObject();
launchObject.put("type", "GET_STATUS");
launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
}
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
if(Looper.getMainLooper().thread == Thread.currentThread()) {
_scopeIO?.launch { action(); }
return true;
}
return false;
}
override fun stopCasting() {
if (invokeInIOScopeIfRequired(::stopCasting)) {
return;
}
val sessionId = _sessionId;
if (sessionId != null) {
val launchObject = JSONObject();
launchObject.put("type", "STOP");
launchObject.put("sessionId", sessionId);
launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
_contentId = null;
_contentType = null;
_streamType = null;
_sessionId = null;
_launchRetries = 0
_transportId = null;
}
Logger.i(TAG, "Stopping active device because stopCasting was called.")
stop();
}
override fun start() {
if (_started) {
return;
}
_autoLaunchEnabled = true
_started = true;
_sessionId = null;
_launchRetries = 0
_mediaSessionId = null;
Logger.i(TAG, "Starting...");
_launching = true;
ensureThreadsStarted();
Logger.i(TAG, "Started.");
}
fun ensureThreadsStarted() {
val adrs = addresses ?: return;
val thread = _thread
val pingThread = _pingThread
if (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive) {
Log.i(TAG, "Restarting threads because one of the threads has died")
_scopeIO?.cancel();
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
_scopeIO = CoroutineScope(Dispatchers.IO);
_thread = Thread {
connectionState = CastConnectionState.CONNECTING;
var connectedSocket: Socket? = null
while (_scopeIO?.isActive == true) {
try {
val resultSocket = getConnectedSocket(adrs.toList(), port);
if (resultSocket == null) {
Thread.sleep(1000);
continue;
}
connectedSocket = resultSocket
usedRemoteAddress = connectedSocket.inetAddress;
localAddress = connectedSocket.localAddress;
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
Thread.sleep(1000);
}
}
val sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, null);
val factory = sslContext.socketFactory;
val address = InetSocketAddress(usedRemoteAddress, port)
//Connection loop
while (_scopeIO?.isActive == true) {
_sessionId = null;
_launchRetries = 0
_mediaSessionId = null;
Logger.i(TAG, "Connecting to Chromecast.");
connectionState = CastConnectionState.CONNECTING;
try {
_socket?.close()
if (connectedSocket != null) {
Logger.i(TAG, "Using connected socket.")
_socket = factory.createSocket(connectedSocket, connectedSocket.inetAddress.hostAddress, connectedSocket.port, true) as SSLSocket
connectedSocket = null
} else {
Logger.i(TAG, "Using new socket.")
val s = Socket().apply { this.connect(address, 2000) }
_socket = factory.createSocket(s, s.inetAddress.hostAddress, s.port, true) as SSLSocket
}
_socket?.startHandshake();
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
try {
_outputStream = DataOutputStream(_socket?.outputStream);
_inputStream = DataInputStream(_socket?.inputStream);
} catch (e: Throwable) {
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
}
} catch (e: Throwable) {
_socket?.close();
Logger.i(TAG, "Failed to connect to Chromecast.", e);
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
continue;
}
localAddress = _socket?.localAddress;
try {
val connectObject = JSONObject();
connectObject.put("type", "CONNECT");
connectObject.put("connType", 0);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
} catch (e: Throwable) {
Logger.i(TAG, "Failed to send connect message to Chromecast.", e);
_socket?.close();
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
continue;
}
getStatus();
val buffer = ByteArray(409600);
Logger.i(TAG, "Started receiving.");
while (_scopeIO?.isActive == true) {
try {
val inputStream = _inputStream ?: break;
val message = synchronized(_inputStreamLock)
{
Log.d(TAG, "Receiving next packet...");
val b1 = inputStream.readUnsignedByte();
val b2 = inputStream.readUnsignedByte();
val b3 = inputStream.readUnsignedByte();
val b4 = inputStream.readUnsignedByte();
val size =
((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt();
if (size > buffer.size) {
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
inputStream.skip(size.toLong());
return@synchronized null
}
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
inputStream.read(buffer, 0, size);
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val msg = ChromeCast.CastMessage.parseFrom(messageBytes);
if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $msg");
}
return@synchronized msg
}
if (message != null) {
try {
handleMessage(message);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to handle message.", e);
break
}
}
} catch (e: java.net.SocketException) {
Logger.e(TAG, "Socket exception while receiving.", e);
break;
} catch (e: Throwable) {
Logger.e(TAG, "Exception while receiving.", e);
break;
}
}
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
}
Logger.i(TAG, "Stopped connection loop.");
connectionState = CastConnectionState.DISCONNECTED;
}.apply { start() };
//Start ping loop
_pingThread = Thread {
Logger.i(TAG, "Started ping loop.")
val pingObject = JSONObject();
pingObject.put("type", "PING");
while (_scopeIO?.isActive == true) {
try {
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString());
} catch (e: Throwable) {
Log.w(TAG, "Failed to send ping.");
}
Thread.sleep(5000);
}
Logger.i(TAG, "Stopped ping loop.");
}.apply { start() };
} else {
Log.i(TAG, "Threads still alive, not restarted")
}
}
private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) {
try {
val castMessage = ChromeCast.CastMessage.newBuilder()
.setProtocolVersion(ChromeCast.CastMessage.ProtocolVersion.CASTV2_1_0)
.setSourceId(sourceId)
.setDestinationId(destinationId)
.setNamespace(namespace)
.setPayloadType(ChromeCast.CastMessage.PayloadType.STRING)
.setPayloadUtf8(json)
.build();
sendMessage(castMessage.toByteArray());
if (namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
//Log.d(TAG, "Sent channel message: $castMessage");
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e);
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
connectionState = CastConnectionState.CONNECTING;
}
}
private fun handleMessage(message: ChromeCast.CastMessage) {
if (message.payloadType == ChromeCast.CastMessage.PayloadType.STRING) {
val jsonObject = JSONObject(message.payloadUtf8);
val type = jsonObject.getString("type");
if (type == "RECEIVER_STATUS") {
val status = jsonObject.getJSONObject("status");
var sessionIsRunning = false;
if (status.has("applications")) {
val applications = status.getJSONArray("applications");
for (i in 0 until applications.length()) {
val applicationUpdate = applications.getJSONObject(i);
val appId = applicationUpdate.getString("appId");
Logger.i(TAG, "Status update received appId (appId: $appId)");
if (appId == "CC1AD845") {
sessionIsRunning = true;
_autoLaunchEnabled = false
if (_sessionId == null) {
connectionState = CastConnectionState.CONNECTED;
_sessionId = applicationUpdate.getString("sessionId");
_launchRetries = 0
val transportId = applicationUpdate.getString("transportId");
connectMediaChannel(transportId);
Logger.i(TAG, "Connected to media channel $transportId");
_transportId = transportId;
requestMediaStatus();
}
}
}
}
if (!sessionIsRunning) {
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
_sessionId = null
_mediaSessionId = null
_transportId = null
if (_autoLaunchEnabled) {
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
_launchRetries++
launchPlayer()
} else {
// Maybe the first GET_STATUS came back empty; still try launching
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
_launching = true
_launchRetries++
launchPlayer()
}
} else {
Logger.e(TAG, "Player not found ($_launchRetries, _autoLaunchEnabled = $_autoLaunchEnabled); giving up.")
Logger.i(TAG, "Unable to start media receiver on device")
stop()
}
} else {
if (_retryJob == null) {
Logger.i(TAG, "Scheduled retry job over 5 seconds")
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
delay(5000)
getStatus()
_retryJob = null
}
}
}
} else {
_launching = false
_launchRetries = 0
_autoLaunchEnabled = false
}
val volume = status.getJSONObject("volume");
//val volumeControlType = volume.getString("controlType");
val volumeLevel = volume.getString("level").toDouble();
val volumeMuted = volume.getBoolean("muted");
//val volumeStepInterval = volume.getString("stepInterval").toFloat();
setVolume(if (volumeMuted) 0.0 else volumeLevel);
Logger.i(TAG, "Status update received volume (level: $volumeLevel, muted: $volumeMuted)");
} else if (type == "MEDIA_STATUS") {
val statuses = jsonObject.getJSONArray("status");
for (i in 0 until statuses.length()) {
val status = statuses.getJSONObject(i);
_mediaSessionId = status.getInt("mediaSessionId");
val playerState = status.getString("playerState");
val currentTime = status.getDouble("currentTime");
if (status.has("media")) {
val media = status.getJSONObject("media")
if (media.has("duration")) {
setDuration(media.getDouble("duration"))
}
}
isPlaying = playerState == "PLAYING";
if (isPlaying || playerState == "PAUSED") {
setTime(currentTime);
}
val playbackRate = status.getInt("playbackRate");
Logger.i(TAG, "Media update received (mediaSessionId: $_mediaSessionId, playedState: $playerState, currentTime: $currentTime, playbackRate: $playbackRate)");
if (_contentType == null) {
stopVideo();
}
}
val needsLoad = statuses.length() == 0 || (statuses.getJSONObject(0).getString("playerState") == "IDLE")
if (needsLoad && _contentId != null && _mediaSessionId == null) {
Logger.i(TAG, "Receiver idle, sending initial LOAD")
playVideo()
}
} else if (type == "CLOSE") {
if (message.sourceId == "receiver-0") {
Logger.i(TAG, "Close received.");
stopCasting();
} else if (_transportId == message.sourceId) {
throw Exception("Transport id closed.")
}
}
} else {
throw Exception("Payload type ${message.payloadType} is not implemented.");
}
}
private fun sendMessage(data: ByteArray) {
val outputStream = _outputStream;
if (outputStream == null) {
Logger.w(TAG, "Failed to send ${data.size} bytes, output stream is null.");
return;
}
synchronized(_outputStreamLock)
{
val serializedSizeBE = ByteArray(4);
serializedSizeBE[0] = (data.size shr 24 and 0xff).toByte();
serializedSizeBE[1] = (data.size shr 16 and 0xff).toByte();
serializedSizeBE[2] = (data.size shr 8 and 0xff).toByte();
serializedSizeBE[3] = (data.size and 0xff).toByte();
outputStream.write(serializedSizeBE);
outputStream.write(data);
}
//Log.d(TAG, "Sent ${data.size} bytes.");
}
override fun stop() {
Logger.i(TAG, "Stopping...");
usedRemoteAddress = null;
localAddress = null;
_started = false;
_contentId = null
_contentType = null
_streamType = null
_retryJob?.cancel()
_retryJob = null
val socket = _socket;
val scopeIO = _scopeIO;
if (scopeIO != null && socket != null) {
Logger.i(TAG, "Cancelling scopeIO with open socket.")
scopeIO.launch {
socket.close();
connectionState = CastConnectionState.DISCONNECTED;
scopeIO.cancel();
Logger.i(TAG, "Cancelled scopeIO with open socket.")
}
} else {
scopeIO?.cancel();
Logger.i(TAG, "Cancelled scopeIO without open socket.")
}
_pingThread = null;
_thread = null;
_scopeIO = null;
_socket = null;
_outputStream = null;
_inputStream = null;
_mediaSessionId = null;
connectionState = CastConnectionState.DISCONNECTED;
}
override fun getDeviceInfo(): CastingDeviceInfo {
return CastingDeviceInfo(name!!, CastProtocolType.CHROMECAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
}
companion object {
val TAG = "ChromecastCastingDevice";
val trustAllCerts: Array<TrustManager> = arrayOf<TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
override fun getAcceptedIssuers(): Array<X509Certificate> { return emptyArray(); }
});
}
}
@@ -0,0 +1,639 @@
package com.futo.platformplayer.casting
import android.os.Looper
import android.util.Base64
import android.util.Log
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
import com.futo.platformplayer.casting.models.FCastKeyExchangeMessage
import com.futo.platformplayer.casting.models.FCastPlayMessage
import com.futo.platformplayer.casting.models.FCastPlaybackErrorMessage
import com.futo.platformplayer.casting.models.FCastPlaybackUpdateMessage
import com.futo.platformplayer.casting.models.FCastSeekMessage
import com.futo.platformplayer.casting.models.FCastSetSpeedMessage
import com.futo.platformplayer.casting.models.FCastSetVolumeMessage
import com.futo.platformplayer.casting.models.FCastVersionMessage
import com.futo.platformplayer.casting.models.FCastVolumeUpdateMessage
import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.toHexString
import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.math.BigInteger
import java.net.Inet4Address
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.PrivateKey
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.KeyAgreement
import javax.crypto.spec.DHParameterSpec
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
enum class Opcode(val value: Byte) {
None(0),
Play(1),
Pause(2),
Resume(3),
Stop(4),
Seek(5),
PlaybackUpdate(6),
VolumeUpdate(7),
SetVolume(8),
PlaybackError(9),
SetSpeed(10),
Version(11),
Ping(12),
Pong(13);
companion object {
private val _map = entries.associateBy { it.value }
fun find(value: Byte): Opcode = _map[value] ?: Opcode.None
}
}
class FCastCastingDevice : CastingDevice {
//See for more info: TODO
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = true;
override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
private var _socket: Socket? = null;
private var _outputStream: OutputStream? = null;
private var _inputStream: InputStream? = null;
private var _scopeIO: CoroutineScope? = null;
private var _started: Boolean = false;
private var _version: Long = 1;
private var _thread: Thread? = null
private var _pingThread: Thread? = null
@Volatile private var _lastPongTime = System.currentTimeMillis()
private var _outputStreamLock = Object()
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
this.addresses = addresses;
this.port = port;
}
constructor(deviceInfo: CastingDeviceInfo) : super() {
this.name = deviceInfo.name;
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
this.port = deviceInfo.port;
}
override fun getAddresses(): List<InetAddress> {
return addresses?.toList() ?: listOf();
}
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
return;
}
//TODO: Remove this later, temporary for the transition
if (_version <= 1L) {
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
}
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
setTime(resumePosition);
setDuration(duration);
send(Opcode.Play, FCastPlayMessage(
container = contentType,
url = contentId,
time = resumePosition,
speed = speed
));
setSpeed(speed ?: 1.0);
}
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadContent(contentType, content, resumePosition, duration, speed) })) {
return;
}
//TODO: Remove this later, temporary for the transition
if (_version <= 1L) {
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
}
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
setTime(resumePosition);
setDuration(duration);
send(Opcode.Play, FCastPlayMessage(
container = contentType,
content = content,
time = resumePosition,
speed = speed
));
setSpeed(speed ?: 1.0);
}
override fun changeVolume(volume: Double) {
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
return;
}
setVolume(volume);
send(Opcode.SetVolume, FCastSetVolumeMessage(volume))
}
override fun changeSpeed(speed: Double) {
if (invokeInIOScopeIfRequired({ changeSpeed(speed) })) {
return;
}
setSpeed(speed);
send(Opcode.SetSpeed, FCastSetSpeedMessage(speed))
}
override fun seekVideo(timeSeconds: Double) {
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
return;
}
send(Opcode.Seek, FCastSeekMessage(
time = timeSeconds
));
}
override fun resumeVideo() {
if (invokeInIOScopeIfRequired(::resumeVideo)) {
return;
}
send(Opcode.Resume);
}
override fun pauseVideo() {
if (invokeInIOScopeIfRequired(::pauseVideo)) {
return;
}
send(Opcode.Pause);
}
override fun stopVideo() {
if (invokeInIOScopeIfRequired(::stopVideo)) {
return;
}
send(Opcode.Stop);
}
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
if(Looper.getMainLooper().thread == Thread.currentThread()) {
_scopeIO?.launch {
try {
action();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to invoke in IO scope.", e)
}
}
return true;
}
return false;
}
override fun stopCasting() {
if (invokeInIOScopeIfRequired(::stopCasting)) {
return;
}
stopVideo();
Logger.i(TAG, "Stopping active device because stopCasting was called.")
stop();
}
override fun start() {
if (_started) {
return;
}
_started = true;
Logger.i(TAG, "Starting...");
ensureThreadStarted();
Logger.i(TAG, "Started.");
}
fun ensureThreadStarted() {
val adrs = addresses ?: return;
val thread = _thread
val pingThread = _pingThread
if (_started && (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive)) {
Log.i(TAG, "(Re)starting thread because the thread has died")
_scopeIO?.let {
it.cancel()
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
}
_scopeIO = CoroutineScope(Dispatchers.IO);
_thread = Thread {
connectionState = CastConnectionState.CONNECTING;
Log.i(TAG, "Connection thread started.")
var connectedSocket: Socket? = null
while (_scopeIO?.isActive == true) {
try {
Log.i(TAG, "getConnectedSocket (adrs = [ ${adrs.joinToString(", ")} ], port = ${port}).")
val resultSocket = getConnectedSocket(adrs.toList(), port);
if (resultSocket == null) {
Log.i(TAG, "Connection failed, waiting 1 seconds.")
Thread.sleep(1000);
continue;
}
Log.i(TAG, "Connection succeeded.")
connectedSocket = resultSocket
usedRemoteAddress = connectedSocket.inetAddress
localAddress = connectedSocket.localAddress
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
Thread.sleep(1000);
}
}
val address = InetSocketAddress(usedRemoteAddress, port)
//Connection loop
while (_scopeIO?.isActive == true) {
Logger.i(TAG, "Connecting to FastCast.");
connectionState = CastConnectionState.CONNECTING;
try {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
if (connectedSocket != null) {
Logger.i(TAG, "Using connected socket.");
_socket = connectedSocket
connectedSocket = null
} else {
Logger.i(TAG, "Using new socket.");
_socket = Socket().apply { this.connect(address, 2000) };
}
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
_outputStream = _socket?.outputStream;
_inputStream = _socket?.inputStream;
} catch (e: IOException) {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
Logger.i(TAG, "Failed to connect to FastCast.", e);
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
continue;
}
localAddress = _socket?.localAddress
_lastPongTime = System.currentTimeMillis()
connectionState = CastConnectionState.CONNECTED
val buffer = ByteArray(4096);
Logger.i(TAG, "Started receiving.");
while (_scopeIO?.isActive == true) {
try {
val inputStream = _inputStream ?: break;
Log.d(TAG, "Receiving next packet...");
var headerBytesRead = 0
while (headerBytesRead < 4) {
val read = inputStream.read(buffer, headerBytesRead, 4 - headerBytesRead)
if (read == -1)
throw Exception("Stream closed")
headerBytesRead += read
}
val size = ((buffer[3].toUByte().toLong() shl 24) or (buffer[2].toUByte().toLong() shl 16) or (buffer[1].toUByte().toLong() shl 8) or buffer[0].toUByte().toLong()).toInt();
if (size > buffer.size) {
Logger.w(TAG, "Packets larger than $size bytes are not supported.")
break
}
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
var bytesRead = 0
while (bytesRead < size) {
val read = inputStream.read(buffer, bytesRead, size - bytesRead)
if (read == -1)
throw Exception("Stream closed")
bytesRead += read
}
val messageBytes = buffer.sliceArray(IntRange(0, size));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val opcode = messageBytes[0];
var json: String? = null;
if (size > 1) {
json = messageBytes.sliceArray(IntRange(1, size - 1)).decodeToString();
}
try {
handleMessage(Opcode.find(opcode), json);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to handle message.", e)
break
}
} catch (e: java.net.SocketException) {
Logger.e(TAG, "Socket exception while receiving.", e);
break
} catch (e: Throwable) {
Logger.e(TAG, "Exception while receiving.", e);
break
}
}
try {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
Logger.i(TAG, "Socket disconnected.");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to close socket.", e)
}
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
}
Logger.i(TAG, "Stopped connection loop.");
connectionState = CastConnectionState.DISCONNECTED;
}.apply { start() }
_pingThread = Thread {
Logger.i(TAG, "Started ping loop.")
while (_scopeIO?.isActive == true) {
if (connectionState == CastConnectionState.CONNECTED) {
try {
send(Opcode.Ping)
if (System.currentTimeMillis() - _lastPongTime > 15000) {
Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.")
try {
_socket?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}
} catch (e: Throwable) {
Log.w(TAG, "Failed to send ping.")
try {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}
}
Thread.sleep(5000)
}
Logger.i(TAG, "Stopped ping loop.")
}.apply { start() }
} else {
Log.i(TAG, "Thread was still alive, not restarted")
}
}
private fun handleMessage(opcode: Opcode, json: String? = null) {
Log.i(TAG, "Processing packet (opcode: $opcode, size: ${json?.length ?: 0})")
when (opcode) {
Opcode.PlaybackUpdate -> {
if (json == null) {
Logger.w(TAG, "Got playback update without JSON, ignoring.");
return;
}
val playbackUpdate = FCastCastingDevice.json.decodeFromString<FCastPlaybackUpdateMessage>(json);
setTime(playbackUpdate.time, playbackUpdate.generationTime);
setDuration(playbackUpdate.duration, playbackUpdate.generationTime);
isPlaying = when (playbackUpdate.state) {
1 -> true
else -> false
}
}
Opcode.VolumeUpdate -> {
if (json == null) {
Logger.w(TAG, "Got volume update without JSON, ignoring.");
return;
}
val volumeUpdate = FCastCastingDevice.json.decodeFromString<FCastVolumeUpdateMessage>(json);
setVolume(volumeUpdate.volume, volumeUpdate.generationTime);
}
Opcode.PlaybackError -> {
if (json == null) {
Logger.w(TAG, "Got playback error without JSON, ignoring.");
return;
}
val playbackError = FCastCastingDevice.json.decodeFromString<FCastPlaybackErrorMessage>(json);
Logger.e(TAG, "Remote casting playback error received: $playbackError")
}
Opcode.Version -> {
if (json == null) {
Logger.w(TAG, "Got version without JSON, ignoring.");
return;
}
val version = FCastCastingDevice.json.decodeFromString<FCastVersionMessage>(json);
_version = version.version;
Logger.i(TAG, "Remote version received: $version")
}
Opcode.Ping -> send(Opcode.Pong)
Opcode.Pong -> _lastPongTime = System.currentTimeMillis()
else -> { }
}
}
private fun send(opcode: Opcode, message: String? = null) {
ensureNotMainThread()
synchronized (_outputStreamLock) {
try {
val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
val size = 1 + data.size
val outputStream = _outputStream
if (outputStream == null) {
Log.w(TAG, "Failed to send $size bytes, output stream is null.")
return
}
val serializedSizeLE = ByteArray(4)
serializedSizeLE[0] = (size and 0xff).toByte()
serializedSizeLE[1] = (size shr 8 and 0xff).toByte()
serializedSizeLE[2] = (size shr 16 and 0xff).toByte()
serializedSizeLE[3] = (size shr 24 and 0xff).toByte()
outputStream.write(serializedSizeLE)
val opcodeBytes = ByteArray(1)
opcodeBytes[0] = opcode.value
outputStream.write(opcodeBytes)
if (data.isNotEmpty()) {
outputStream.write(data)
}
Log.d(TAG, "Sent $size bytes: (opcode: $opcode, body: $message).")
} catch (e: Throwable) {
Log.i(TAG, "Failed to send message.", e)
throw e
}
}
}
private inline fun <reified T> send(opcode: Opcode, message: T) {
try {
send(opcode, message?.let { Json.encodeToString(it) })
} catch (e: Throwable) {
Log.i(TAG, "Failed to encode message to string.", e)
throw e
}
}
override fun stop() {
Logger.i(TAG, "Stopping...");
usedRemoteAddress = null;
localAddress = null;
_started = false;
//TODO: Kill and/or join thread?
_thread = null;
_pingThread = null;
val socket = _socket;
val scopeIO = _scopeIO;
if (scopeIO != null && socket != null) {
Logger.i(TAG, "Cancelling scopeIO with open socket.")
scopeIO.launch {
socket.close();
_inputStream?.close()
_outputStream?.close()
connectionState = CastConnectionState.DISCONNECTED;
scopeIO.cancel();
Logger.i(TAG, "Cancelled scopeIO with open socket.")
}
} else {
scopeIO?.cancel();
Logger.i(TAG, "Cancelled scopeIO without open socket.")
}
_scopeIO = null;
_socket = null;
_outputStream = null;
_inputStream = null;
connectionState = CastConnectionState.DISCONNECTED;
}
override fun getDeviceInfo(): CastingDeviceInfo {
return CastingDeviceInfo(name!!, CastProtocolType.FCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
}
companion object {
val TAG = "FCastCastingDevice";
private val json = Json { ignoreUnknownKeys = true }
fun getKeyExchangeMessage(keyPair: KeyPair): FCastKeyExchangeMessage {
return FCastKeyExchangeMessage(1, Base64.encodeToString(keyPair.public.encoded, Base64.NO_WRAP))
}
fun generateKeyPair(): KeyPair {
//modp14
val p = BigInteger("ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff", 16)
val g = BigInteger("2", 16)
val dhSpec = DHParameterSpec(p, g)
val keyGen = KeyPairGenerator.getInstance("DH")
keyGen.initialize(dhSpec)
return keyGen.generateKeyPair()
}
fun computeSharedSecret(privateKey: PrivateKey, keyExchangeMessage: FCastKeyExchangeMessage): SecretKeySpec {
val keyFactory = KeyFactory.getInstance("DH")
val receivedPublicKeyBytes = Base64.decode(keyExchangeMessage.publicKey, Base64.NO_WRAP)
val receivedPublicKeySpec = X509EncodedKeySpec(receivedPublicKeyBytes)
val receivedPublicKey = keyFactory.generatePublic(receivedPublicKeySpec)
val keyAgreement = KeyAgreement.getInstance("DH")
keyAgreement.init(privateKey)
keyAgreement.doPhase(receivedPublicKey, true)
val sharedSecret = keyAgreement.generateSecret()
Log.i(TAG, "sharedSecret ${Base64.encodeToString(sharedSecret, Base64.NO_WRAP)}")
val sha256 = MessageDigest.getInstance("SHA-256")
val hashedSecret = sha256.digest(sharedSecret)
Log.i(TAG, "hashedSecret ${Base64.encodeToString(hashedSecret, Base64.NO_WRAP)}")
return SecretKeySpec(hashedSecret, "AES")
}
fun encryptMessage(aesKey: SecretKeySpec, decryptedMessage: FCastDecryptedMessage): FCastEncryptedMessage {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, aesKey)
val iv = cipher.iv
val json = Json.encodeToString(decryptedMessage)
val encrypted = cipher.doFinal(json.toByteArray(Charsets.UTF_8))
return FCastEncryptedMessage(
version = 1,
iv = Base64.encodeToString(iv, Base64.NO_WRAP),
blob = Base64.encodeToString(encrypted, Base64.NO_WRAP)
)
}
fun decryptMessage(aesKey: SecretKeySpec, encryptedMessage: FCastEncryptedMessage): FCastDecryptedMessage {
val iv = Base64.decode(encryptedMessage.iv, Base64.NO_WRAP)
val encrypted = Base64.decode(encryptedMessage.blob, Base64.NO_WRAP)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
val decryptedJson = cipher.doFinal(encrypted)
return Json.decodeFromString(String(decryptedJson, Charsets.UTF_8))
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
package com.futo.platformplayer.casting.models
import kotlinx.serialization.Serializable
@Serializable
data class FCastPlayMessage(
val container: String,
val url: String? = null,
val content: String? = null,
val time: Double? = null,
val speed: Double? = null
) { }
@Serializable
data class FCastSeekMessage(
val time: Double
) { }
@Serializable
data class FCastPlaybackUpdateMessage(
val generationTime: Long,
val time: Double,
val duration: Double,
val state: Int,
val speed: Double
) { }
@Serializable
data class FCastVolumeUpdateMessage(
val generationTime: Long,
val volume: Double
)
@Serializable
data class FCastSetVolumeMessage(
val volume: Double
)
@Serializable
data class FCastSetSpeedMessage(
val speed: Double
)
@Serializable
data class FCastPlaybackErrorMessage(
val message: String
)
@Serializable
data class FCastVersionMessage(
val version: Long
)
@Serializable
data class FCastKeyExchangeMessage(
val version: Long,
val publicKey: String
)
@Serializable
data class FCastDecryptedMessage(
val opcode: Long,
val message: String?
)
@Serializable
data class FCastEncryptedMessage(
val version: Long,
val iv: String?,
val blob: String
)
@@ -18,7 +18,6 @@ import com.futo.platformplayer.engine.dev.V8RemoteObject
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.gsonStandard
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.serialize
import com.futo.platformplayer.engine.packages.PackageHttp
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateAssets
@@ -29,8 +28,6 @@ import com.google.gson.FieldAttributes
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.lang.reflect.Field
@@ -271,17 +268,11 @@ class DeveloperEndpoints(private val context: Context) {
context.respondCode(403, "This plugin doesn't support auth");
return;
}
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
LoginFragment.showLogin(config){
_testPluginVariables.clear();
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
};
}
/*
LoginActivity.showLogin(StateApp.instance.context, config) {
_testPluginVariables.clear();
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
}; */
};
context.respondCode(200, "Login started");
}
catch(ex: Throwable) {
@@ -16,12 +16,9 @@ import android.widget.Button
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UpdateDownloadService
import com.futo.platformplayer.UpdateNotificationManager
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.logging.Logger
@@ -37,8 +34,6 @@ import java.io.InputStream
class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
companion object {
private val TAG = "AutoUpdateDialog";
var currentDialog: AutoUpdateDialog? = null
}
private lateinit var _buttonNever: Button;
@@ -51,6 +46,7 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
private var _maxVersion: Int = 0;
private var _updating: Boolean = false;
private var _apkFile: File? = null;
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
@@ -65,14 +61,12 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
_buttonShowChangelog = findViewById(R.id.button_show_changelog);
_buttonNever.setOnClickListener {
UpdateNotificationManager.cancelAll(context)
Settings.instance.autoUpdate.check = 1;
Settings.instance.save();
dismiss();
};
_buttonClose.setOnClickListener {
UpdateNotificationManager.cancelAll(context)
dismiss();
};
@@ -82,32 +76,23 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
};
_buttonUpdate.setOnClickListener {
UpdateNotificationManager.cancelAll(context)
if (_updating) {
return@setOnClickListener;
}
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
val ctx = context.applicationContext;
val intent = Intent(ctx, UpdateDownloadService::class.java);
intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion);
ContextCompat.startForegroundService(ctx, intent);
UIDialogs.toast(context, "Downloading update in background");
dismiss();
} else {
_updating = true;
update();
}
_updating = true;
update();
};
}
currentDialog = this
fun showPredownloaded(apkFile: File) {
_apkFile = apkFile;
super.show()
}
override fun dismiss() {
super.dismiss()
InstallReceiver.onReceiveResult.clear();
currentDialog = null
Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
}
@@ -133,14 +118,21 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
GlobalScope.launch(Dispatchers.IO) {
var inputStream: InputStream? = null;
try {
val client = ManagedHttpClient();
val response = client.get(StateUpdate.APK_URL);
if (response.isOk && response.body != null) {
inputStream = response.body.byteStream();
val dataLength = response.body.contentLength();
val apkFile = _apkFile;
if (apkFile != null) {
inputStream = apkFile.inputStream();
val dataLength = apkFile.length();
install(inputStream, dataLength);
} else {
throw Exception("Failed to download latest version of app.");
val client = ManagedHttpClient();
val response = client.get(StateUpdate.APK_URL);
if (response.isOk && response.body != null) {
inputStream = response.body.byteStream();
val dataLength = response.body.contentLength();
install(inputStream, dataLength);
} else {
throw Exception("Failed to download latest version of app.");
}
}
} catch (e: Throwable) {
Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e);
@@ -1,13 +1,9 @@
package com.futo.platformplayer.dialogs
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.*
@@ -15,88 +11,88 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.google.android.material.button.MaterialButton
class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
private lateinit var _buttonStart: LinearLayout
private lateinit var _buttonStop: LinearLayout
private lateinit var _buttonCancel: ImageButton
private lateinit var _imm: InputMethodManager
private lateinit var _buttonStart: LinearLayout;
private lateinit var _buttonStop: LinearLayout;
private lateinit var _buttonCancel: ImageButton;
private lateinit var _editPassword: EditText;
private lateinit var _editPassword2: EditText;
private lateinit var _inputMethodManager: InputMethodManager;
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null))
super.onCreate(savedInstanceState);
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null));
_buttonCancel = findViewById(R.id.button_cancel)
_buttonStop = findViewById(R.id.button_stop)
_buttonStart = findViewById(R.id.button_start)
_buttonCancel = findViewById(R.id.button_cancel);
_buttonStop = findViewById(R.id.button_stop);
_buttonStart = findViewById(R.id.button_start);
_editPassword = findViewById(R.id.edit_password);
_editPassword2 = findViewById(R.id.edit_password2);
_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
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
_buttonCancel.setOnClickListener {
dismiss()
}
clearFocus();
dismiss();
};
_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))
clearFocus();
dismiss();
Settings.instance.backup.autoBackupPassword = null;
Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save();
UIDialogs.toast(context, "AutoBackup disabled");
}
_buttonStart.setOnClickListener {
dismiss()
Logger.i(TAG, "Enable AutoBackup (unencrypted)")
val activity = StateApp.instance.activity as? Activity
if (activity == null) {
UIDialogs.toast(context, "No activity available")
return@setOnClickListener
val p1 = _editPassword.text.toString();
val p2 = _editPassword2.text.toString();
if(!(p1?.equals(p2) ?: false)) {
UIDialogs.toast(context, "Password fields do not match, confirm that you typed it correctly.");
return@setOnClickListener;
}
dismiss()
val pbytes = _editPassword.text.toString().toByteArray();
if(pbytes.size < 4 || pbytes.size > 32) {
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and smaller than 32 bytes", false);
return@setOnClickListener;
}
clearFocus();
dismiss();
Logger.i(TAG, "Enable AutoBackup")
Settings.instance.backup.autoBackupPassword = null
Settings.instance.backup.didAskAutoBackup = true
Settings.instance.save()
Logger.i(TAG, "Set AutoBackupPassword");
Settings.instance.backup.autoBackupPassword = _editPassword.text.toString();
Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save();
UIDialogs.toast(context, "AutoBackup enabled")
UIDialogs.toast(context, "AutoBackup 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)
StateBackup.startAutomaticBackup(true);
}
Settings.instance.backup.autoBackupEnabled = true
Settings.instance.backup.autoBackupPassword = null
Settings.instance.backup.didAskAutoBackup = true
Settings.instance.save()
StateAnnouncement.instance.deleteAnnouncement("backup")
UIDialogs.toast(context, context.getString(R.string.automatic_backup_enabled))
try {
StateBackup.startAutomaticBackup(true)
} catch (ex: Throwable) {
Logger.e(TAG, "Forced automatic backup failed", ex)
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message)
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 {
private const val TAG = "AutomaticBackupDialog"
private val TAG = "AutomaticBackupDialog";
}
}
@@ -3,155 +3,87 @@ package com.futo.platformplayer.dialogs
import android.app.AlertDialog
import android.content.Context
import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.*
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.*
import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import userpackage.Protocol
import java.time.OffsetDateTime
class AutomaticRestoreDialog(context: Context, private val scope: CoroutineScope) : AlertDialog(context) {
private lateinit var _buttonStart: LinearLayout
private lateinit var _buttonCancel: MaterialButton
private lateinit var _textReason: TextView
private lateinit var _editPassword: EditText
private lateinit var _passwordContainer: LinearLayout
private lateinit var _icon: ImageView
private lateinit var _progress: ProgressBar
private lateinit var _textStart: TextView
private lateinit var _imm: InputMethodManager
class AutomaticRestoreDialog(context: Context, val scope: CoroutineScope) : AlertDialog(context) {
private lateinit var _buttonStart: LinearLayout;
private lateinit var _buttonCancel: MaterialButton;
private lateinit var _editPassword: EditText;
private lateinit var _inputMethodManager: InputMethodManager;
private var _needsPassword: Boolean = true
private var _detectJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup_restore, null))
super.onCreate(savedInstanceState);
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup_restore, null));
_buttonCancel = findViewById(R.id.button_cancel)
_buttonStart = findViewById(R.id.button_start)
_editPassword = findViewById(R.id.edit_password)
_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)
_buttonCancel = findViewById(R.id.button_cancel);
_buttonStart = findViewById(R.id.button_start);
_editPassword = findViewById(R.id.edit_password);
_imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
_needsPassword = true
applyMode(needsPassword = true)
setBusy(true, labelRes = R.string.checking_backup, lockCancel = false)
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
_buttonCancel.setOnClickListener {
clearFocus()
dismiss()
}
_buttonStart.setOnClickListener { onStartClicked() }
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
}
clearFocus();
dismiss();
};
override fun onStart() {
super.onStart()
_detectJob?.cancel()
_detectJob = scope.launch(Dispatchers.Main) {
val needs = try {
StateBackup.requiresPasswordForAutomaticBackup(context)
} catch (_: Throwable) {
true
_buttonStart.setOnClickListener {
val pbytes = _editPassword.text.toString().toByteArray();
if(pbytes.size < 4 || pbytes.size > 32) {
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and less than 32 bytes", false);
return@setOnClickListener;
}
clearFocus();
if (!isShowing) return@launch
_needsPassword = needs
applyMode(needsPassword = needs)
setBusy(false)
}
}
override fun onStop() {
_detectJob?.cancel()
_detectJob = null
super.onStop()
}
private fun applyMode(needsPassword: Boolean) {
_textStart.setText(R.string.restore)
if (needsPassword) {
_icon.setImageResource(R.drawable.ic_lock)
_passwordContainer.visibility = View.VISIBLE
_editPassword.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
_textReason.setText(R.string.it_appears_an_automatic_backup_exists_on_your_device_if_you_would_like_to_restore_enter_your_backup_password)
} else {
_icon.setImageResource(R.drawable.ic_move_up)
_passwordContainer.visibility = View.GONE
_editPassword.setText("")
_textReason.setText(R.string.automatic_backup_found_no_password)
}
}
private fun onStartClicked() {
val password = _editPassword.text?.toString() ?: ""
if (_needsPassword) {
val pbytes = password.toByteArray()
if (pbytes.size < 4 || pbytes.size > 32) {
_editPassword.error = context.getString(R.string.backup_password_length_error)
_editPassword.requestFocus()
return
}
}
clearFocus()
setBusy(true, labelRes = R.string.restoring, lockCancel = true)
scope.launch(Dispatchers.IO) {
try {
StateBackup.restoreAutomaticBackup(context, scope, if (_needsPassword) password else "", true)
withContext(Dispatchers.Main) {
if (isShowing) dismiss()
}
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to restore automatic backup", ex)
withContext(Dispatchers.Main) {
if (!isShowing) return@withContext
setBusy(false)
UIDialogs.showGeneralErrorDialog(context, "Restore failed", ex)
}
StateBackup.restoreAutomaticBackup(context, scope, _editPassword.text.toString(), true);
dismiss();
}
}
}
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);
}
};
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)
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
}
private fun clearFocus() {
_editPassword.clearFocus()
currentFocus?.let { _imm.hideSoftInputFromWindow(it.windowToken, 0) }
_editPassword.clearFocus();
currentFocus?.let { _inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) };
}
companion object {
private const val TAG = "AutomaticRestoreDialog"
private val TAG = "AutomaticRestoreDialog";
}
}
}
@@ -8,13 +8,11 @@ import android.view.View
import android.view.WindowManager
import android.widget.*
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.CastProtocolType
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.toInetAddress
import com.futo.platformplayer.logging.Logger
class CastingAddDialog(context: Context?) : AlertDialog(context) {
@@ -103,11 +101,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_textError.visibility = View.GONE;
val castingDeviceInfo = CastingDeviceInfo(name, castProtocolType, arrayOf(ip), port.toInt());
try {
StateCasting.instance.addRememberedDevice(castingDeviceInfo)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to add remembered device: $e")
}
StateCasting.instance.addRememberedDevice(castingDeviceInfo);
performDismiss();
};

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