From 4c0eceaa8e17369b9cfefc03319cef7d76eefadc Mon Sep 17 00:00:00 2001 From: Koen J Date: Tue, 24 Jun 2025 13:05:17 +0200 Subject: [PATCH] Initial implementation of AirPlay2 pairing. --- app/build.gradle | 6 +- .../com/futo/platformplayer/AirPlay2Test.kt | 202 ++++ .../futo/platformplayer/SyncServerTests.kt | 4 +- .../java/com/futo/platformplayer/SyncTests.kt | 4 +- .../futo/platformplayer/Extensions_Syntax.kt | 12 + .../java/com/futo/platformplayer/UIDialogs.kt | 9 + ...tingDevice.kt => AirPlay1CastingDevice.kt} | 304 +++--- .../casting/AirPlay2CastingDevice.kt | 871 ++++++++++++++++++ .../platformplayer/casting/CastingDevice.kt | 85 +- .../futo/platformplayer/casting/SRPClient.kt | 166 ++++ .../platformplayer/casting/StateCasting.kt | 834 +++++++++-------- .../com/futo/platformplayer/casting/TLV8.kt | 187 ++++ .../dialogs/ConnectCastingDialog.kt | 16 +- .../dialogs/ConnectedCastingDialog.kt | 8 +- .../dialogs/PairingCodeDialog.kt | 70 ++ .../models/CastingDeviceInfo.kt | 16 +- .../futo/platformplayer/states/StatePlayer.kt | 6 + .../views/adapters/DeviceViewHolder.kt | 10 +- .../platformplayer/views/casting/CastView.kt | 4 +- .../main/res/layout/dialog_pairing_code.xml | 74 ++ dep/futopay | 2 +- 21 files changed, 2312 insertions(+), 578 deletions(-) create mode 100644 app/src/androidTest/java/com/futo/platformplayer/AirPlay2Test.kt rename app/src/main/java/com/futo/platformplayer/casting/{AirPlayCastingDevice.kt => AirPlay1CastingDevice.kt} (50%) create mode 100644 app/src/main/java/com/futo/platformplayer/casting/AirPlay2CastingDevice.kt create mode 100644 app/src/main/java/com/futo/platformplayer/casting/SRPClient.kt create mode 100644 app/src/main/java/com/futo/platformplayer/casting/TLV8.kt create mode 100644 app/src/main/java/com/futo/platformplayer/dialogs/PairingCodeDialog.kt create mode 100644 app/src/main/res/layout/dialog_pairing_code.xml diff --git a/app/build.gradle b/app/build.gradle index fcbd422c..0bb70a24 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -173,6 +173,7 @@ dependencies { //HTTP implementation "com.squareup.okhttp3:okhttp:4.11.0" + implementation "com.squareup.okhttp3:okhttp-urlconnection:4.11.0" //JSON implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2" //Used for structured json @@ -204,6 +205,7 @@ dependencies { 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 'org.bouncycastle:bcprov-jdk15on:1.70' //Protobuf implementation 'com.google.protobuf:protobuf-javalite:3.25.1' @@ -220,7 +222,9 @@ dependencies { implementation("androidx.room:room-ktx:2.6.1") //Payment - implementation 'com.stripe:stripe-android:20.35.1' + implementation('com.stripe:stripe-android:20.35.1') { + exclude group: 'org.bouncycastle', module: 'bcprov-jdk15to18' + } testImplementation 'junit:junit:4.13.2' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' diff --git a/app/src/androidTest/java/com/futo/platformplayer/AirPlay2Test.kt b/app/src/androidTest/java/com/futo/platformplayer/AirPlay2Test.kt new file mode 100644 index 00000000..11d17640 --- /dev/null +++ b/app/src/androidTest/java/com/futo/platformplayer/AirPlay2Test.kt @@ -0,0 +1,202 @@ +package com.futo.platformplayer + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.futo.platformplayer.casting.SRPClient +import com.futo.platformplayer.casting.TLV8Item +import com.futo.platformplayer.casting.TLV8Tag +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.runner.RunWith +import java.math.BigInteger +import org.junit.Test + +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalStdlibApi::class, ExperimentalUnsignedTypes::class) +class AirPlay2Test { + @Test + fun testSRP() { + val N = BigInteger(1, ("FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 8A67CC74" + + "020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B 302B0A6D F25F1437" + + "4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED" + + "EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D C2007CB8 A163BF05" + + "98DA4836 1C55D39A 69163FA8 FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB" + + "9ED52907 7096966D 670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B" + + "E39E772C 180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718" + + "3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D 04507A33" + + "A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D B3970F85 A6E1E4C7" + + "ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226 1AD2EE6B F12FFA06 D98A0864" + + "D8760273 3EC86A64 521F2B18 177B200C BBE11757 7A615D6C 770988C0 BAD946E2" + + "08E24FA0 74E5AB31 43DB5BFC E0FD108E 4B82D120 A93AD2CA FFFFFFFF FFFFFFFF").replace(" ", "").hexToByteArray()) + + val g = BigInteger(1, "05".hexToByteArray()) + val I = "alice" + val p = "password123" + val a = BigInteger(1, "60975527 035CF2AD 1989806F 0407210B C81EDC04 E2762A56 AFD529DD DA2D4393".replace(" ", "").hexToByteArray()) + val A = BigInteger(1, ("FAB6F5D2 615D1E32 3512E799 1CC37443 F487DA60 4CA8C923 0FCB04E5 41DCE628" + + "0B27CA46 80B0374F 179DC3BD C7553FE6 2459798C 701AD864 A91390A2 8C93B644" + + "ADBF9C00 745B942B 79F9012A 21B9B787 82319D83 A1F83628 66FBD6F4 6BFC0DDB" + + "2E1AB6E4 B45A9906 B82E37F0 5D6F97F6 A3EB6E18 2079759C 4F684783 7B62321A" + + "C1B4FA68 641FCB4B B98DD697 A0C73641 385F4BAB 25B79358 4CC39FC8 D48D4BD8" + + "67A9A3C1 0F8EA121 70268E34 FE3BBE6F F89998D6 0DA2F3E4 283CBEC1 393D52AF" + + "724A5723 0C604E9F BCE583D7 613E6BFF D67596AD 121A8707 EEC46944 95703368" + + "6A155F64 4D5C5863 B48F61BD BF19A53E AB6DAD0A 186B8C15 2E5F5D8C AD4B0EF8" + + "AA4EA500 8834C3CD 342E5E0F 167AD045 92CD8BD2 79639398 EF9E114D FAAAB919" + + "E14E8509 89224DDD 98576D79 385D2210 902E9F9B 1F2D86CF A47EE244 635465F7" + + "1058421A 0184BE51 DD10CC9D 079E6F16 04E7AA9B 7CF7883C 7D4CE12B 06EBE160" + + "81E23F27 A231D184 32D7D1BB 55C28AE2 1FFCF005 F57528D1 5A88881B B3BBB7FE").replace(" ", "").hexToByteArray()) + val b = BigInteger(1, "E487CB59 D31AC550 471E81F0 0F6928E0 1DDA08E9 74A004F4 9E61F5D1 05284D20".replace(" ", "").hexToByteArray()) + val B = ("40F57088 A482D4C7 733384FE 0D301FDD CA9080AD 7D4F6FDF 09A01006 C3CB6D56" + + "2E41639A E8FA21DE 3B5DBA75 85B27558 9BDB2798 63C56280 7B2B9908 3CD1429C" + + "DBE89E25 BFBD7E3C AD3173B2 E3C5A0B1 74DA6D53 91E6A06E 465F037A 40062548" + + "39A56BF7 6DA84B1C 94E0AE20 8576156F E5C140A4 BA4FFC9E 38C3B07B 88845FC6" + + "F7DDDA93 381FE0CA 6084C4CD 2D336E54 51C464CC B6EC65E7 D16E548A 273E8262" + + "84AF2559 B6264274 215960FF F47BDD63 D3AFF064 D6137AF7 69661C9D 4FEE4738" + + "2603C88E AA098058 1D077584 61B777E4 356DDA58 35198B51 FEEA308D 70F75450" + + "B71675C0 8C7D8302 FD7539DD 1FF2A11C B4258AA7 0D234436 AA42B6A0 615F3F91" + + "5D55CC3B 966B2716 B36E4D1A 06CE5E5D 2EA3BEE5 A1270E87 51DA45B6 0B997B0F" + + "FDB0F996 2FEE4F03 BEE780BA 0A845B1D 92714217 83AE6601 A61EA2E3 42E4F2E8" + + "BC935A40 9EAD19F2 21BD1B74 E2964DD1 9FC845F6 0EFC0933 8B60B6B2 56D8CAC8" + + "89CCA306 CC370A0B 18C8B886 E95DA0AF 5235FEF4 393020D2 B7F30569 04759042").replace(" ", "").hexToByteArray() + val s = "BEB25379 D1A8581E B5A72767 3A2441EE".replace(" ", "").hexToByteArray() + val v = BigInteger(1, ("9B5E0617 01EA7AEB 39CF6E35 19655A85 3CF94C75 CAF2555E F1FAF759 BB79CB47" + + "7014E04A 88D68FFC 05323891 D4C205B8 DE81C2F2 03D8FAD1 B24D2C10 9737F1BE" + + "BBD71F91 2447C4A0 3C26B9FA D8EDB3E7 80778E30 2529ED1E E138CCFC 36D4BA31" + + "3CC48B14 EA8C22A0 186B222E 655F2DF5 603FD75D F76B3B08 FF895006 9ADD03A7" + + "54EE4AE8 8587CCE1 BFDE3679 4DBAE459 2B7B904F 442B041C B17AEBAD 1E3AEBE3" + + "CBE99DE6 5F4BB1FA 00B0E7AF 06863DB5 3B02254E C66E781E 3B62A821 2C86BEB0" + + "D50B5BA6 D0B478D8 C4E9BBCE C2176532 6FBD1405 8D2BBDE2 C33045F0 3873E539" + + "48D78B79 4F0790E4 8C36AED6 E880F557 427B2FC0 6DB5E1E2 E1D7E661 AC482D18" + + "E528D729 5EF74372 95FF1A72 D4027717 13F16876 DD050AE5 B7AD53CC B90855C9" + + "39566483 58ADFD96 6422F524 98732D68 D1D7FBEF 10D78034 AB8DCB6F 0FCF885C" + + "C2B2EA2C 3E6AC866 09EA058A 9DA8CC63 531DC915 414DF568 B09482DD AC1954DE" + + "C7EB714F 6FF7D44C D5B86F6B D1158109 30637C01 D0F6013B C9740FA2 C633BA89").replace(" ", "").hexToByteArray()) + val u = BigInteger(1, ("03AE5F3C 3FA9EFF1 A50D7DBB 8D2F60A1 EA66EA71 2D50AE97 6EE34641 A1CD0E51" + + "C4683DA3 83E8595D 6CB56A15 D5FBC754 3E07FBDD D316217E 01A391A1 8EF06DFF").replace(" ", "").hexToByteArray()) + val S = ("F1036FEC D017C823 9C0D5AF7 E0FCF0D4 08B009E3 6411618A 60B23AAB BFC38339" + + "72682312 14BAACDC 94CA1C53 F442FB51 C1B027C3 18AE238E 16414D60 D1881B66" + + "486ADE10 ED02BA33 D098F6CE 9BCF1BB0 C46CA2C4 7F2F174C 59A9C61E 2560899B" + + "83EF6113 1E6FB30B 714F4E43 B735C9FE 6080477C 1B83E409 3E4D456B 9BCA492C" + + "F9339D45 BC42E67C E6C02C24 3E49F5DA 42A869EC 855780E8 4207B8A1 EA6501C4" + + "78AAC0DF D3D22614 F531A00D 826B7954 AE8B14A9 85A42931 5E6DD366 4CF47181" + + "496A9432 9CDE8005 CAE63C2F 9CA4969B FE840019 24037C44 6559BDBB 9DB9D4DD" + + "142FBCD7 5EEF2E16 2C843065 D99E8F05 762C4DB7 ABD9DB20 3D41AC85 A58C05BD" + + "4E2DBF82 2A934523 D54E0653 D376CE8B 56DCB452 7DDDC1B9 94DC7509 463A7468" + + "D7F02B1B EB168571 4CE1DD1E 71808A13 7F788847 B7C6B7BF A1364474 B3B7E894" + + "78954F6A 8E68D45B 85A88E4E BFEC1336 8EC0891C 3BC86CF5 00978801 78D86135" + + "E7287234 58538858 D715B7B2 47406222 C1019F53 603F0169 52D49710 0858824C").replace(" ", "").hexToByteArray() + val K = ("5CBC219D B052138E E1148C71 CD449896 3D682549 CE91CA24 F098468F 06015BEB" + + "6AF245C2 093F98C3 651BCA83 AB8CAB2B 580BBF02 184FEFDF 26142F73 DF95AC50").replace(" ", "").hexToByteArray() + + + val srp = SRPClient(N, g, I, p) + val A_computed = srp.srp_user_start_authentication(a) + assert(A_computed == A) { "Mismatch in A value" } + + val triple = srp.srp_user_process_challenge_internal(s, B) + val u_computed = triple.first + val v_computed = triple.second + val M_computed = triple.third + val S_computed = srp.getS()!! + assert(u_computed == u) { "Mismatch in u" } + assert(v_computed == v) { "Mismatch in v" } + //assert(M_computed.contentEquals(M)) { "Mismatch in M" } + assert(S_computed.contentEquals(S)) { "Mismatch in session key S" } + + val K_computed = srp.getSessionKey()!! + assert(K_computed.contentEquals(K)) { "Mismatch in derived key K" } + } + + @Test + fun testEncodeAndDecodeSimpleSmallValue() { + val value = byteArrayOf(0x01, 0x02, 0x03, 0x04).toUByteArray() + val item = TLV8Item(TLV8Tag.METHOD, value) + + val encoded = TLV8Item.encode(listOf(item)) + val decoded = TLV8Item.decode(encoded.toUByteArray()) + + assertEquals(1, decoded.size) + assertEquals(item.tag, decoded[0].tag) + assertTrue(decoded[0].value.contentEquals(value)) + } + + @Test + fun testEncodeAndDecodeExactly255BytesNoFragmentation() { + val data255 = UByteArray(255) { it.toUByte() } + val item255 = TLV8Item(TLV8Tag.IDENTIFIER, data255) + + val encoded = TLV8Item.encode(listOf(item255)) + // Expect: 1 byte tag + 1 byte length + 255 bytes data + assertEquals(257, encoded.size) + assertEquals(TLV8Tag.IDENTIFIER.value.toByte(), encoded[0]) + assertEquals(0xFF, encoded[1].toInt() and 0xFF) + + val decoded = TLV8Item.decode(encoded.toUByteArray()) + assertEquals(1, decoded.size) + assertEquals(TLV8Tag.IDENTIFIER, decoded[0].tag) + assertTrue(decoded[0].value.contentEquals(data255)) + } + + @Test + fun testEncodeAndDecode256BytesWithFragmentation() { + val data256 = UByteArray(256) { it.toUByte() } + val item256 = TLV8Item(TLV8Tag.SALT, data256) + + val encoded = TLV8Item.encode(listOf(item256)) + // First fragment header: SALT tag + 0xFF length + assertEquals(TLV8Tag.SALT.value.toByte(), encoded[0]) + assertEquals(0xFF, encoded[1].toInt() and 0xFF) + + // Locate last‐fragment header: two bytes before the final data byte + val lastFragmentIndex = encoded.size - (1 /*remaining*/ + 2) + assertEquals(TLV8Tag.FRAGMENT_LAST.value.toByte(), encoded[lastFragmentIndex]) + assertEquals(1.toByte(), encoded[lastFragmentIndex + 1]) + + val decoded = TLV8Item.decode(encoded.toUByteArray()) + assertEquals(1, decoded.size) + assertTrue(decoded[0].value.contentEquals(data256)) + } + + @Test + fun testEncodeAndDecodeMultipleItems() { + val v1 = byteArrayOf(0x0A, 0x0B).toUByteArray() + val v2 = byteArrayOf(0xFF.toByte(), 0xEE.toByte(), 0xDD.toByte()).toUByteArray() + val items = listOf( + TLV8Item(TLV8Tag.PROOF, v1), + TLV8Item(TLV8Tag.ERROR, v2) + ) + + val encoded = TLV8Item.encode(items) + val decoded = TLV8Item.decode(encoded.toUByteArray()) + + assertEquals(2, decoded.size) + assertEquals(TLV8Tag.PROOF, decoded[0].tag) + assertTrue(decoded[0].value.contentEquals(v1)) + assertEquals(TLV8Tag.ERROR, decoded[1].tag) + assertTrue(decoded[1].value.contentEquals(v2)) + } + + @Test(expected = IllegalArgumentException::class) + fun testDecodeUnknownTagThrowsIllegalArgumentException() { + // Tag 0x10 isn’t defined in TLV8Tag + val bogus = byteArrayOf(0x10, 0x00).toUByteArray() + TLV8Item.decode(bogus) + } + + @Test(expected = IllegalArgumentException::class) + fun testDecodeTruncatedLengthByteThrowsIllegalArgumentException() { + // Only a tag byte, missing length byte + val onlyTag = byteArrayOf(TLV8Tag.STATE.value.toByte()).toUByteArray() + TLV8Item.decode(onlyTag) + } + + @Test(expected = IllegalArgumentException::class) + fun testDecodeTruncatedDataThrowsIllegalArgumentException() { + // Declared length = 2, but only 1 data byte follows + val arr = buildList { + add(TLV8Tag.FLAGS.value.toByte()) + add(2) // length + add(0x5A) // only one byte of data + }.toByteArray().toUByteArray() + TLV8Item.decode(arr) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt b/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt index 7607a2c9..f3e12645 100644 --- a/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt +++ b/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt @@ -11,7 +11,7 @@ import java.nio.ByteBuffer import kotlin.random.Random import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds - +/* class SyncServerTests { //private val relayHost = "relay.grayjay.app" @@ -335,4 +335,4 @@ class SyncServerTests { class AlwaysAuthorized : IAuthorizable { override val isAuthorized: Boolean get() = true -} \ No newline at end of file +}*/ \ No newline at end of file diff --git a/app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt b/app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt index 1b9f19cd..d34bfad4 100644 --- a/app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt +++ b/app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt @@ -13,7 +13,7 @@ import kotlin.random.Random import java.io.InputStream import java.io.OutputStream import kotlin.time.Duration.Companion.seconds - +/* data class PipeStreams( val initiatorInput: LittleEndianDataInputStream, val initiatorOutput: LittleEndianDataOutputStream, @@ -509,4 +509,4 @@ class Authorized : IAuthorizable { class Unauthorized : IAuthorizable { override val isAuthorized: Boolean = false -} \ No newline at end of file +}*/ \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt index f1e63366..08e04bb2 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt @@ -69,4 +69,16 @@ fun Long?.msToOffsetDateTimeUTC(): OffsetDateTime { if(this > 4070912400) return OffsetDateTime.MAX; return OffsetDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneOffset.UTC) +} + +/** + * Strips a leading zero byte if BigInteger.toByteArray() included it just to indicate a positive sign. + * Mirrors C's expectation that BN_bn2bin yields exactly the “minimal” big‐endian representation. + */ +fun ByteArray.stripLeadingZero(): ByteArray { + return if (this.size > 1 && this[0] == 0.toByte()) { + this.copyOfRange(1, this.size) + } else { + this + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index 8034854d..36d906bc 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -35,6 +35,7 @@ import com.futo.platformplayer.dialogs.ConnectedCastingDialog import com.futo.platformplayer.dialogs.ImportDialog import com.futo.platformplayer.dialogs.ImportOptionsDialog import com.futo.platformplayer.dialogs.MigrateDialog +import com.futo.platformplayer.dialogs.PairingCodeDialog import com.futo.platformplayer.dialogs.PluginUpdateDialog import com.futo.platformplayer.dialogs.ProgressDialog import com.futo.platformplayer.engine.exceptions.PluginException @@ -455,6 +456,14 @@ class UIDialogs { dialog.show(); } + fun showPairingCodeDialog(context: Context, onSubmit: (code: String) -> Unit, onCancel: () -> Unit) { + val dialog = PairingCodeDialog(context, onSubmit); + registerDialogOpened(dialog); + dialog.setOnDismissListener { registerDialogClosed(dialog) } + dialog.setOnCancelListener { onCancel() } + dialog.show(); + } + fun toast(context : Context, text : String, long : Boolean = false) { Toast.makeText(context, text, if(long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show(); } diff --git a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/AirPlay1CastingDevice.kt similarity index 50% rename from app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt rename to app/src/main/java/com/futo/platformplayer/casting/AirPlay1CastingDevice.kt index 0cc1bebc..5cfb8b49 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/AirPlay1CastingDevice.kt @@ -15,55 +15,60 @@ import kotlinx.coroutines.launch import java.net.InetAddress import java.util.UUID -class AirPlayCastingDevice : CastingDevice { +class AirPlay1CastingDevice : 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; + 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? = null; - var port: Int = 0; + var addresses: Array? = null + var port: Int = 0 - private var _scopeIO: CoroutineScope? = null; - private var _started: Boolean = false; - private var _sessionId: String? = null; - private val _client = ManagedHttpClient(); + private var _scopeIO: CoroutineScope? = null + private var _started: Boolean = false + private var _sessionId: String? = null + private val _client = ManagedHttpClient() constructor(name: String, addresses: Array, port: Int) : super() { - this.name = name; - this.addresses = addresses; - this.port = port; + 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; + this.name = deviceInfo.name + this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray() + this.port = deviceInfo.port } override fun getAddresses(): List { - return addresses?.toList() ?: listOf(); + 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; + return } - Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)"); + Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)") - setTime(resumePosition); - setDuration(duration); + if (_sessionId == null) { + Logger.w(TAG, "loadContent called before session established. Ignoring.") + return + } + + setTime(resumePosition) + setDuration(duration) if (resumePosition > 0.0) { - val pos = resumePosition / duration; + val pos = resumePosition / duration Logger.i(TAG, "resumePosition: $resumePosition, duration: ${duration}, pos: $pos") - post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: $pos"); + post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: $pos") } else { - post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0"); + post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0") } if (speed != null) { @@ -72,117 +77,157 @@ class AirPlayCastingDevice : CastingDevice { } override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) { - throw NotImplementedError(); + throw NotImplementedError() } override fun seekVideo(timeSeconds: Double) { if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) { - return; + return } - post("scrub?position=${timeSeconds}"); + Logger.i(TAG, "seekVideo()-> $timeSeconds") + if (_sessionId == null) { + Logger.w(TAG, "seekVideo called before session established. Ignoring.") + return + } + + post("scrub?position=${timeSeconds}") } override fun resumeVideo() { if (invokeInIOScopeIfRequired(::resumeVideo)) { - return; + return } - isPlaying = true; - post("rate?value=1.000000"); + Logger.i(TAG, "resumeVideo()") + if (_sessionId == null) { + Logger.w(TAG, "resumeVideo called before session established. Ignoring.") + return + } + + isPlaying = true + post("rate?value=1.000000") } override fun pauseVideo() { if (invokeInIOScopeIfRequired(::pauseVideo)) { - return; + return } - isPlaying = false; - post("rate?value=0.000000"); + Logger.i(TAG, "pauseVideo()") + if (_sessionId == null) { + Logger.w(TAG, "pauseVideo called before session established. Ignoring.") + return + } + + isPlaying = false + post("rate?value=0.000000") } override fun stopVideo() { if (invokeInIOScopeIfRequired(::stopVideo)) { - return; + return } - post("stop"); + Logger.i(TAG, "stopVideo()") + if (_sessionId == null) { + Logger.w(TAG, "stopVideo called before session established. Ignoring.") + return + } + + post("stop") } override fun stopCasting() { if (invokeInIOScopeIfRequired(::stopCasting)) { - return; + return } - post("stop"); - stop(); + Logger.i(TAG, "stopCasting()") + if (_sessionId != null) { + post("stop") + } + stop() } override fun start() { - val adrs = addresses ?: return; + val adrs = addresses ?: return if (_started) { - return; + return } - _started = true; - _scopeIO?.cancel(); - _scopeIO = CoroutineScope(Dispatchers.IO); + _started = true + _scopeIO?.cancel() + _scopeIO = CoroutineScope(Dispatchers.IO) - Logger.i(TAG, "Starting..."); + Logger.i(TAG, "Starting...") _scopeIO?.launch { try { - connectionState = CastConnectionState.CONNECTING; + connectionState = CastConnectionState.CONNECTING while (_scopeIO?.isActive == true) { try { - val connectedSocket = getConnectedSocket(adrs.toList(), port); + val connectedSocket = getConnectedSocket(adrs.toList(), port) if (connectedSocket == null) { - delay(1000); - continue; + Logger.i(TAG, "Unable to connect yet; retrying in 1s.") + delay(1000) + continue } - usedRemoteAddress = connectedSocket.inetAddress; - localAddress = connectedSocket.localAddress; - connectedSocket.close(); - _sessionId = UUID.randomUUID().toString(); - break; + usedRemoteAddress = connectedSocket.inetAddress + localAddress = connectedSocket.localAddress + _sessionId = UUID.randomUUID().toString() + + val probeSuccess = get("server-info") != null + connectedSocket.close() + + if (!probeSuccess) { + Logger.w(TAG, "Handshake (GET /server-info) failed; retrying") + _sessionId = null + delay(1000) + continue + } + + Logger.i(TAG, "Handshake successful. SessionId=$_sessionId") + break + } catch (e: Throwable) { Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e) - delay(1000); + delay(1000) } } while (_scopeIO?.isActive == true) { try { - val progressInfo = getProgress(); + val progressInfo = getProgress() if (progressInfo == null) { - connectionState = CastConnectionState.CONNECTING; - Logger.i(TAG, "Failed to retrieve progress from AirPlay device."); - delay(1000); - continue; + connectionState = CastConnectionState.CONNECTING + Logger.i(TAG, "Failed to retrieve progress from AirPlay device.") + delay(1000) + continue } - connectionState = CastConnectionState.CONNECTED; + connectionState = CastConnectionState.CONNECTED - val progressIndex = progressInfo.lowercase().indexOf("position: "); + val progressIndex = progressInfo.lowercase().indexOf("position: ") if (progressIndex == -1) { - delay(1000); - continue; + delay(1000) + continue } - val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue; - setTime(progress); + val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue + setTime(progress) - val durationIndex = progressInfo.lowercase().indexOf("duration: "); + val durationIndex = progressInfo.lowercase().indexOf("duration: ") if (durationIndex == -1) { - delay(1000); - continue; + delay(1000) + continue } - val duration = progressInfo.substring(durationIndex + "duration: ".length).toDoubleOrNull() ?: continue; - setDuration(duration); - delay(1000); + 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) } @@ -190,103 +235,111 @@ class AirPlayCastingDevice : CastingDevice { } catch (e: Throwable) { Logger.w(TAG, "Failed to setup AirPlay device connection.", e) } - }; + } - Logger.i(TAG, "Started."); + Logger.i(TAG, "Started.") } override fun stop() { - Logger.i(TAG, "Stopping..."); - connectionState = CastConnectionState.DISCONNECTED; + Logger.i(TAG, "Stopping...") + connectionState = CastConnectionState.DISCONNECTED - usedRemoteAddress = null; - localAddress = null; - _started = false; - _scopeIO?.cancel(); - _scopeIO = null; + _sessionId = null + usedRemoteAddress = null + localAddress = null + _started = false + _scopeIO?.cancel() + _scopeIO = null } override fun changeSpeed(speed: Double) { + if (_sessionId == null) { + Logger.w(TAG, "changeSpeed called before session established. Ignoring.") + return + } + 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); + 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; + 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; + 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; + 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 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}"; + val url = "http://${usedRemoteAddress}:${port}/${path}" - Logger.i(TAG, "POST $url"); - val response = _client.post(url, headers); + Logger.i(TAG, "POST $url") + val response = _client.post(url, headers) if (!response.isOk) { - return false; + Logger.w(TAG, "POST /$path failed (HTTP ${response.code})") + return false } - return true; + return true } catch (e: Throwable) { - Logger.w(TAG, "Failed to POST $path"); - return false; + 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 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}"; + val url = "http://${usedRemoteAddress}:${port}/${path}" - Logger.i(TAG, "POST $url:\n$body"); - val response = _client.post(url, body, headers); + Logger.i(TAG, "POST $url:\n$body") + val response = _client.post(url, body, headers) if (!response.isOk) { - return false; + Logger.w(TAG, "POST /$path failed (HTTP ${response.code})") + return false } - return true; + return true } catch (e: Throwable) { - Logger.w(TAG, "Failed to POST $path $body"); - return false; + Logger.w(TAG, "Failed to POST $path $body") + return false } } private fun get(path: String): String? { - val sessionId = _sessionId ?: return null; + val sessionId = _sessionId ?: return null try { val headers = hashMapOf( @@ -294,37 +347,38 @@ class AirPlayCastingDevice : CastingDevice { "Content-Length" to "0", "User-Agent" to "MediaControl/1.0", "X-Apple-Session-ID" to sessionId - ); + ) - val url = "http://${usedRemoteAddress}:${port}/${path}"; + val url = "http://${usedRemoteAddress}:${port}/${path}" - Logger.i(TAG, "GET $url"); - val response = _client.get(url, headers); + Logger.i(TAG, "GET $url") + val response = _client.get(url, headers) if (!response.isOk) { - return null; + Logger.w(TAG, "GET /$path failed (HTTP ${response.code})") + return null } if (response.body == null) { - return null; + return null } - return response.body.string(); + return response.body.string() } catch (e: Throwable) { - Logger.w(TAG, "Failed to GET $path"); - return null; + 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; + _scopeIO?.launch { action() } + return true } - return false; + return false } companion object { - val TAG = "AirPlayCastingDevice"; + val TAG = "AirPlay1CastingDevice" } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/AirPlay2CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/AirPlay2CastingDevice.kt new file mode 100644 index 00000000..783c7b55 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/AirPlay2CastingDevice.kt @@ -0,0 +1,871 @@ +package com.futo.platformplayer.casting + +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.CastingDeviceInfo +import com.futo.platformplayer.stripLeadingZero +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.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.bouncycastle.crypto.digests.SHA512Digest +import org.bouncycastle.crypto.generators.HKDFBytesGenerator +import org.bouncycastle.crypto.params.* +import org.bouncycastle.crypto.signers.Ed25519Signer +import java.io.ByteArrayOutputStream +import java.math.BigInteger +import java.net.InetAddress +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.SecureRandom +import java.util.* +import okhttp3.JavaNetCookieJar +import org.bouncycastle.crypto.modes.ChaCha20Poly1305 +import java.net.CookieManager +import java.net.CookiePolicy + +@OptIn(ExperimentalStdlibApi::class) +class AirPlay2CastingDevice : CastingDevice { + override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY2 + override val isReady: Boolean get() = name != 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? = null + var port: Int = 0 + + private val _pairingDataHandler: IPairingDataHandler + private var _scopeIO: CoroutineScope? = null + private var _started: Boolean = false + @Volatile private var _paired: Boolean = false + private var _state: AirPlaySenderState = AirPlaySenderState.NOT_CONNECTED + private var _srpClient: SRPClient? = null + private var _pin: String? = null + private var _sessionKey: ByteArray? = null + private var _devicePrivateKey: ByteArray? = null + private var _devicePublicKey: ByteArray? = null + private var _verifierPrivateKey: ByteArray? = null + private var _verifierPublicKey: ByteArray? = null + private var _accessoryLtpk: ByteArray? = null + private var _accessoryCurvePublic: ByteArray? = null + private var _accessorySharedKey: ByteArray? = null + private var _isEncrypted: Boolean = false + private var _outgoingKey: ByteArray? = null + private var _incomingKey: ByteArray? = null + private var _outCount: Int = 0 + private var _inCount: Int = 0 + private var _cseq = 0 + private val _httpClient: OkHttpClient = OkHttpClient.Builder().cookieJar(JavaNetCookieJar(CookieManager().apply { setCookiePolicy(CookiePolicy.ACCEPT_ALL) })).build() + + companion object { + private const val TAG = "AirPlay2CastingDevice" + private const val DEVICE_ID = "C9635ED0964902E0" + private const val CONTENT_TYPE = "application/octet-stream" + private const val TAG_LENGTH = 16 + private const val MAX_BLOCK_LENGTH = 0x400 + val N = BigInteger(1, ("FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 8A67CC74" + + "020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B 302B0A6D F25F1437" + + "4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED" + + "EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D C2007CB8 A163BF05" + + "98DA4836 1C55D39A 69163FA8 FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB" + + "9ED52907 7096966D 670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B" + + "E39E772C 180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718" + + "3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D 04507A33" + + "A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D B3970F85 A6E1E4C7" + + "ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226 1AD2EE6B F12FFA06 D98A0864" + + "D8760273 3EC86A64 521F2B18 177B200C BBE11757 7A615D6C 770988C0 BAD946E2" + + "08E24FA0 74E5AB31 43DB5BFC E0FD108E 4B82D120 A93AD2CA FFFFFFFF FFFFFFFF").replace(" ", "").hexToByteArray()) + val g = BigInteger(1, "05".hexToByteArray()) + } + + constructor(name: String, addresses: Array, port: Int, pairingDataHandler: IPairingDataHandler) { + this.name = name + this.addresses = addresses + this.port = port + _pairingDataHandler = pairingDataHandler + } + + constructor(deviceInfo: CastingDeviceInfo, pairingDataHandler: IPairingDataHandler) { + this.name = deviceInfo.name + this.addresses = deviceInfo.addresses.mapNotNull { it.toInetAddress() }.toTypedArray() + this.port = deviceInfo.port + _pairingDataHandler = pairingDataHandler + } + + override fun getAddresses(): List = addresses?.toList() ?: emptyList() + + override fun providePairingPin(pin: String?) { + Logger.i(TAG, "Pairing PIN provided $pin") + _pin = pin + _scopeIO?.launch(Dispatchers.IO) { + performPair(pin) + } + } + + override fun start() { + if (_started) return + val adrs = addresses ?: return + + _started = true + _paired = false + _scopeIO = CoroutineScope(Dispatchers.IO) + + Logger.i(TAG, "Starting AirPlay2 device...") + + _scopeIO?.launch(Dispatchers.IO) { + usedRemoteAddress = adrs.firstOrNull { addr -> + try { + val socket = java.net.Socket(addr, port) + localAddress = socket.localAddress + socket.close() + true + } catch (e: Exception) { + Logger.w(TAG, "Failed connecting to $addr:$port", e) + false + } + } + + if (usedRemoteAddress == null) { + Logger.w(TAG, "Could not connect to any address.") + return@launch + } + + Logger.i(TAG, "Connected to ${usedRemoteAddress}:${port}") + if (!_paired) { + performPairSetup() + } + } + } + + override fun stop() { + Logger.i(TAG, "Stopping AirPlay2 device...") + connectionState = CastConnectionState.DISCONNECTED + _paired = false + _started = false + _scopeIO?.cancel() + _scopeIO = null + Logger.i(TAG, "AirPlay2 device stopped.") + } + + override fun loadVideo( + streamType: String, + contentType: String, + contentId: String, + resumePosition: Double, + duration: Double, + speed: Double? + ) { + Logger.i(TAG, "loadVideo: contentId=$contentId, resumePosition=$resumePosition") + if (!isReady || !_paired) return + + val payload = mapOf( + "Content-Location" to contentId, + "Start-Position" to resumePosition / duration + ) + val body = Json.encodeToString(payload) + val encryptedBody = if (_isEncrypted) encryptData(body.toByteArray()) else body.toByteArray() + postHttp("/play", encryptedBody, "application/x-apple-binary-plist")?.let { success -> + if (success) { + setTime(resumePosition) + setDuration(duration) + isPlaying = true + } + } + } + + override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) { + loadVideo(contentType, contentType, content, resumePosition, duration, speed) + } + + override fun seekVideo(timeSeconds: Double) { + Logger.i(TAG, "seekVideo: $timeSeconds") + if (!isReady || !_paired) return + + val payload = mapOf("position" to timeSeconds) + val body = Json.encodeToString(payload) + val encryptedBody = if (_isEncrypted) encryptData(body.toByteArray()) else body.toByteArray() + postHttp("/scrub", encryptedBody, "application/json")?.let { success -> + if (success) setTime(timeSeconds) + } + } + + override fun resumeVideo() { + Logger.i(TAG, "resumeVideo") + if (!isReady || !_paired) return + changeRate(1.0) + isPlaying = true + } + + override fun pauseVideo() { + Logger.i(TAG, "pauseVideo") + if (!isReady || !_paired) return + changeRate(0.0) + isPlaying = false + } + + override fun stopVideo() { + Logger.i(TAG, "stopVideo") + if (!isReady || !_paired) return + val body = ByteArray(0) + val encryptedBody = if (_isEncrypted) encryptData(body) else body + postHttp("/stop", encryptedBody, null)?.let { success -> + if (success) { + isPlaying = false + setTime(0.0) + } + } + } + + override fun stopCasting() { + stopVideo() + stop() + } + + override fun changeVolume(volume: Double) { + Logger.i(TAG, "changeVolume: $volume") + if (!isReady || !_paired) return + + val payload = mapOf("volume" to volume.coerceIn(0.0, 1.0)) + val body = Json.encodeToString(payload) + val encryptedBody = if (_isEncrypted) encryptData(body.toByteArray()) else body.toByteArray() + postHttp("/volume", encryptedBody, "application/json")?.let { success -> + if (success) setVolume(volume) + } + } + + override fun changeSpeed(speed: Double) { + Logger.i(TAG, "changeSpeed: $speed") + if (!isReady || !_paired) return + changeRate(speed) + } + + private fun changeRate(value: Double) { + val payload = mapOf("rate" to value) + val body = Json.encodeToString(payload) + val encryptedBody = if (_isEncrypted) encryptData(body.toByteArray()) else body.toByteArray() + postHttp("/rate", encryptedBody, "application/json")?.let { success -> + if (success) setSpeed(value) + } + } + + override fun getDeviceInfo(): CastingDeviceInfo { + return CastingDeviceInfo( + name!!, + CastProtocolType.AIRPLAY2, + addresses!!.mapNotNull { it.hostAddress }.toTypedArray(), + port + ) + } + + private fun getUrl(endpoint: String): String { + return "http://${usedRemoteAddress?.hostAddress}:$port$endpoint" + } + + private fun performPairSetup() { + /*Logger.i(TAG, "Starting pair-setup...") + _state = AirPlaySenderState.WAITING_ON_PAIR_PIN_START + val pinResult = postHttp("/pair-pin-start", ByteArray(0), null) + if (pinResult == true) { + Logger.i(TAG, "Waiting for PIN...") + onPairingPinRequired.emit() + } else { + Logger.w(TAG, "Failed to show PIN, attempting pair without PIN") + _scopeIO?.launch(Dispatchers.IO) { performPair(null) } + }*/ + + _scopeIO?.launch(Dispatchers.IO) { performPair(null) } + } + + private fun performPair(pin: String?) { + Logger.i(TAG, "Performing pair with PIN $pin") + + _state = AirPlaySenderState.WAITING_ON_PAIR_SETUP1 + val username = "Pair-Setup" + val password = pin ?: "3939" + _srpClient = SRPClient(N, g, username, password) + + val stateItem = TLV8Item(TLV8Tag.STATE, ubyteArrayOf(PairingState.M1.value)) + val methodItem = TLV8Item(TLV8Tag.METHOD, ubyteArrayOf(PairingMethod.PAIR_SETUP.value)) + val tlvItems = listOf(stateItem, methodItem) + val encodedTlv = TLV8Item.encodeWithLogging(tlvItems) + + val headers = mapOf( + "Content-Type" to CONTENT_TYPE, + "Content-Length" to encodedTlv.size.toString() + ) + val response = postHttpWithResponse("/pair-setup", encodedTlv, headers) + if (response?.isSuccessful == true) { + response.body?.bytes()?.let { continuePairSetup(it) } + } else { + pairingDidFail("Failed to initiate pair-setup") + } + } + + private fun continuePairSetup(responseData: ByteArray) { + if (responseData.isEmpty()) { + pairingDidFail("Server response data is empty") + return + } + + Logger.i(TAG, "Response: " + TLV8Item.decodeAsString(responseData.asUByteArray())) + + val fields = TLV8Item.decodeAndReassembleWithLogging(responseData.asUByteArray()) + val errorBytes = fields[TLV8Tag.ERROR] + if (errorBytes?.isNotEmpty() == true) { + val errorCode = errorBytes[0].toUByte().toInt() + if (errorCode == 0x03) { + val backoffBytes = fields[TLV8Tag.RETRY_DELAY] + val backoffSeconds = ByteBuffer.wrap(backoffBytes).order(ByteOrder.LITTLE_ENDIAN).short + pairingDidFail("Pairing backoff requested, should retry in ${backoffSeconds}s") + } else { + pairingDidFail("Pairing failed with error code $errorCode") + } + return + } + val stateBytes = fields[TLV8Tag.STATE] + if (stateBytes == null || stateBytes.isEmpty()) { + pairingDidFail("State item is missing") + return + } + val remoteState = stateBytes[0].toUByte() + Logger.i(TAG, "Transitioned to state ${remoteState}") + + when { + // ───── SETUP PHASE ───── + _state == AirPlaySenderState.WAITING_ON_PAIR_SETUP1 && remoteState == PairingState.M2.value -> pairSetupM2M3(fields) + _state == AirPlaySenderState.WAITING_ON_PAIR_SETUP2 && remoteState == PairingState.M4.value -> pairSetupM4M5(fields) + _state == AirPlaySenderState.WAITING_ON_PAIR_SETUP3 && remoteState == PairingState.M6.value -> pairVerifyM1(fields) + + // ───── VERIFY PHASE ───── + _state == AirPlaySenderState.WAITING_ON_PAIR_VERIFY1 && remoteState == PairingState.M2.value -> pairVerifyM2(fields) + _state == AirPlaySenderState.WAITING_ON_PAIR_VERIFY2 && remoteState == PairingState.M4.value -> { + _isEncrypted = true + setCiphers() + _state = AirPlaySenderState.READY_TO_PLAY + _paired = true + pairingDidFinish() + } + + else -> pairingDidFail("Unexpected STATE=$remoteState when in $_state") + } + } + + + private fun pairSetupM2M3(fields: Map) { + _state = AirPlaySenderState.WAITING_ON_PAIR_SETUP2 + + val saltBytes = fields[TLV8Tag.SALT] + val BBytes = fields[TLV8Tag.PUBLIC_KEY] + if (saltBytes == null || BBytes == null) { + pairingDidFail("Salt or public key is missing") + return + } + + try { + val client = _srpClient ?: throw IllegalStateException("SRPClient not initialized") + val ABytes = client.srp_user_start_authentication() + + val M1Bytes = client.srp_user_process_challenge(saltBytes, BBytes) + + val stateItem = TLV8Item(TLV8Tag.STATE, ubyteArrayOf(PairingState.M3.value)) + val aBytes = ABytes.toByteArray().stripLeadingZero().asUByteArray() + val pkItem = TLV8Item(TLV8Tag.PUBLIC_KEY, aBytes) + val m1Bytes = M1Bytes.asUByteArray() + val proofItem = TLV8Item(TLV8Tag.PROOF, m1Bytes) + val tlvItems = listOf(stateItem, pkItem, proofItem) + val encodedTlv = TLV8Item.encodeWithLogging(tlvItems) + + val headers = mapOf( + "Content-Type" to CONTENT_TYPE, + "Content-Length" to encodedTlv.size.toString() + ) + val response = postHttpWithResponse("/pair-setup", encodedTlv, headers) + if (response == null) { + pairingDidFail("M2→M3: no HTTP response (connection error)") + return + } + + val code = response.code + val bodyBytes = response.body?.bytes() + if (response.isSuccessful && bodyBytes != null) { + continuePairSetup(bodyBytes) + } else { + pairingDidFail("M2→M3 failed: HTTP $code, body=${bodyBytes?.toHexString()}") + } + } catch (e: Exception) { + pairingDidFail("SRP calculation failed.", e) + } + } + + private fun pairSetupM4M5(fields: Map) { + _state = AirPlaySenderState.WAITING_ON_PAIR_SETUP3 + + val proofBytes = fields[TLV8Tag.PROOF] + if (proofBytes == null) { + pairingDidFail("Proof is missing") + return + } + + try { + val client = _srpClient ?: throw IllegalStateException("SRPClient not initialized") + val verified = client.srp_user_verify_session(proofBytes) + if (!verified) { + pairingDidFail("Server authentication failed") + return + } + + val K = client.getSessionKey() ?: throw IllegalStateException("Session key not computed") + _sessionKey = K + + val seed = ByteArray(32).also { SecureRandom().nextBytes(it) } + val edPriv = Ed25519PrivateKeyParameters(seed, 0) + val edPub = edPriv.generatePublicKey() + + _devicePrivateKey = seed + _devicePublicKey = edPub.encoded + + val deviceX = hkdfExtractExpand( + K, + "Pair-Setup-Controller-Sign-Salt".toByteArray(Charsets.US_ASCII), + "Pair-Setup-Controller-Sign-Info".toByteArray(Charsets.US_ASCII), + 32 + ) + + val deviceIDBytes = DEVICE_ID.toByteArray(Charsets.US_ASCII) + val deviceInfo = concat(deviceX, deviceIDBytes, edPub.encoded) + + val signer = Ed25519Signer() + signer.init(true, edPriv) + signer.update(deviceInfo, 0, deviceInfo.size) + val signature = signer.generateSignature() + + val identifierItem = TLV8Item(TLV8Tag.IDENTIFIER, deviceIDBytes.asUByteArray()) + val publicKeyItem = TLV8Item(TLV8Tag.PUBLIC_KEY, edPub.encoded.asUByteArray()) + val sigItem = TLV8Item(TLV8Tag.SIGNATURE, signature.asUByteArray()) + val tlvItems = listOf(identifierItem, publicKeyItem, sigItem) + val encodedTlv = TLV8Item.encodeWithLogging(tlvItems) + + val sessionKey2 = hkdfExtractExpand( + K, + "Pair-Setup-Encrypt-Salt".toByteArray(Charsets.US_ASCII), + "Pair-Setup-Encrypt-Info".toByteArray(Charsets.US_ASCII), + 32 + ) + + val bcNonce = ByteArray(4) { 0x00 } + "PS-Msg05".toByteArray(Charsets.UTF_8) + val (ciphertext, mac) = chacha20Poly1305Encrypt( + sessionKey2, + bcNonce, + ByteArray(0), + encodedTlv + ) + val encryptedData = ciphertext + mac + + val stateItem = TLV8Item(TLV8Tag.STATE, ubyteArrayOf(PairingState.M5.value)) + val encryptedDataItem = TLV8Item(TLV8Tag.ENCRYPTED_DATA, encryptedData.asUByteArray()) + val responseItems = listOf(stateItem, encryptedDataItem) + val responseTlv = TLV8Item.encodeWithLogging(responseItems) + + val headers = mapOf( + "Content-Type" to CONTENT_TYPE, + "Content-Length" to responseTlv.size.toString() + ) + val response = postHttpWithResponse("/pair-setup", responseTlv, headers) + if (response?.isSuccessful == true) { + response.body?.bytes()?.let { continuePairSetup(it) } + } else { + pairingDidFail("Failed to process M4→M5") + } + } catch (e: Exception) { + pairingDidFail("Error in M4→M5.", e) + } + } + + private fun pairVerifyM1(fields: Map) { + _state = AirPlaySenderState.WAITING_ON_PAIR_VERIFY1 + + val encryptedField = fields[TLV8Tag.ENCRYPTED_DATA] + if (encryptedField == null) { + pairingDidFail("Encrypted data missing") + return + } + val encryptedTlvData = encryptedField.copyOfRange(0, encryptedField.size - TAG_LENGTH) + val tagData = encryptedField.copyOfRange(encryptedField.size - TAG_LENGTH, encryptedField.size) + + try { + val K = _sessionKey ?: throw IllegalStateException("No valid session key") + val sessionKey2 = hkdfExtractExpand( + K, + "Pair-Setup-Encrypt-Salt".toByteArray(Charsets.UTF_8), + "Pair-Setup-Encrypt-Info".toByteArray(Charsets.UTF_8), + 32 + ) + + val nonce = ByteArray(4) { 0x00 } + "PS-Msg06".toByteArray(Charsets.UTF_8) + val decryptedTlv = chacha20Poly1305Decrypt( + sessionKey2, + nonce, + ByteArray(0), + encryptedTlvData, + tagData + ) ?: throw IllegalStateException("Decryption failed") + + val accessoryItems = TLV8Item.decode(decryptedTlv.asUByteArray()) + val accessoryIdBytes = accessoryItems.find { it.tag == TLV8Tag.IDENTIFIER }?.value?.asByteArray() + val accessoryLtpkBytes = accessoryItems.find { it.tag == TLV8Tag.PUBLIC_KEY }?.value?.asByteArray() + val accessorySigBytes = accessoryItems.find { it.tag == TLV8Tag.SIGNATURE }?.value?.asByteArray() + + if (accessoryIdBytes == null || accessoryLtpkBytes == null || accessorySigBytes == null) { + pairingDidFail("Accessory data incomplete") + return + } + _accessoryLtpk = accessoryLtpkBytes + val accessoryX = hkdfExtractExpand( + K, + "Pair-Setup-Accessory-Sign-Salt".toByteArray(Charsets.UTF_8), + "Pair-Setup-Accessory-Sign-Info".toByteArray(Charsets.UTF_8), + 32 + ) + + val accessoryInfo = concat(accessoryX, accessoryIdBytes, accessoryLtpkBytes) + val verifier = Ed25519Signer() + val pubParam = Ed25519PublicKeyParameters(accessoryLtpkBytes, 0) + verifier.init(false, pubParam) + verifier.update(accessoryInfo, 0, accessoryInfo.size) + if (!verifier.verifySignature(accessorySigBytes)) { + pairingDidFail("Accessory signature not verified") + return + } + Logger.i(TAG, "Accessory signature is valid!") + + val curvePriv = ByteArray(32).also { SecureRandom().nextBytes(it) } + val curvePub = X25519PrivateKeyParameters(curvePriv, 0) + .generatePublicKey() + .encoded + _verifierPrivateKey = curvePriv + _verifierPublicKey = curvePub + + val stateItem = TLV8Item(TLV8Tag.STATE, ubyteArrayOf(PairingState.M1.value)) + val pkItem = TLV8Item(TLV8Tag.PUBLIC_KEY, curvePub.asUByteArray()) + val responseItems = listOf(stateItem, pkItem) + val encodedTlv = TLV8Item.encodeWithLogging(responseItems) + + val headers = mapOf( + "Content-Type" to CONTENT_TYPE, + "Content-Length" to encodedTlv.size.toString() + ) + val response = postHttpWithResponse("/pair-verify", encodedTlv, headers) + if (response?.isSuccessful == true) { + response.body?.bytes()?.let { continuePairSetup(it) } + } else { + pairingDidFail("Failed to process pair-verify M1") + } + } catch (e: Exception) { + pairingDidFail("Pair-verify M1 failed: ${e.message}") + } + } + + + private fun pairVerifyM2(fields: Map) { + _state = AirPlaySenderState.WAITING_ON_PAIR_VERIFY2 + + val accessoryCurvePubBytes = fields[TLV8Tag.PUBLIC_KEY] + val accessoryEncryptedField = fields[TLV8Tag.ENCRYPTED_DATA] + if (accessoryCurvePubBytes == null || accessoryEncryptedField == null) { + pairingDidFail("Public key or encrypted data missing") + return + } + _accessoryCurvePublic = accessoryCurvePubBytes + + val encryptedTlvData = accessoryEncryptedField.copyOfRange(0, accessoryEncryptedField.size - TAG_LENGTH) + val tagData = accessoryEncryptedField.copyOfRange(accessoryEncryptedField.size - TAG_LENGTH, accessoryEncryptedField.size) + + try { + val privParam = X25519PrivateKeyParameters(_verifierPrivateKey!!, 0) + val pubParam = X25519PublicKeyParameters(accessoryCurvePubBytes, 0) + val sharedSecret = ByteArray(32) + privParam.generateSecret(pubParam, sharedSecret, 0) + _accessorySharedKey = sharedSecret + + val sessionKey = hkdfExtractExpand( + sharedSecret, + "Pair-Verify-Encrypt-Salt".toByteArray(Charsets.UTF_8), + "Pair-Verify-Encrypt-Info".toByteArray(Charsets.UTF_8), + 32 + ) + + val nonce = ByteArray(4) { 0x00 } + "PV-Msg02".toByteArray(Charsets.UTF_8) + val decryptedTlv = chacha20Poly1305Decrypt( + sessionKey, + nonce, + ByteArray(0), + encryptedTlvData, + tagData + ) ?: throw IllegalStateException("Decryption failed") + + val accessoryItems = TLV8Item.decode(decryptedTlv.asUByteArray()) + val accessoryIdBytes = accessoryItems.find { it.tag == TLV8Tag.IDENTIFIER }?.value?.asByteArray() + val accessorySigBytes = accessoryItems.find { it.tag == TLV8Tag.SIGNATURE }?.value?.asByteArray() + if (accessoryIdBytes == null || accessorySigBytes == null) { + pairingDidFail("Accessory data incomplete") + return + } + + val accessoryInfo = concat( + accessoryCurvePubBytes, + accessoryIdBytes, + _verifierPublicKey!! + ) + val verifier = Ed25519Signer() + verifier.init(false, Ed25519PublicKeyParameters(_accessoryLtpk!!, 0)) + verifier.update(accessoryInfo, 0, accessoryInfo.size) + if (!verifier.verifySignature(accessorySigBytes)) { + pairingDidFail("Accessory signature not verified") + return + } + Logger.i(TAG, "Accessory signature is valid!") + + val deviceIDBytes = DEVICE_ID.toByteArray(Charsets.UTF_8) + val deviceInfo = concat( + _verifierPublicKey!!, + deviceIDBytes, + accessoryCurvePubBytes + ) + val signer = Ed25519Signer() + val edPriv = Ed25519PrivateKeyParameters(_devicePrivateKey!!, 0) + signer.init(true, edPriv) + signer.update(deviceInfo, 0, deviceInfo.size) + val signature = signer.generateSignature() + + val identifierItem = TLV8Item(TLV8Tag.IDENTIFIER, deviceIDBytes.asUByteArray()) + val signatureItem = TLV8Item(TLV8Tag.SIGNATURE, signature.asUByteArray()) + val tlvItems = listOf(identifierItem, signatureItem) + val encodedTlv = TLV8Item.encodeWithLogging(tlvItems) + + val nonce2 = ByteArray(4) { 0x00 } + "PV-Msg03".toByteArray(Charsets.UTF_8) + val (ciphertext, mac) = chacha20Poly1305Encrypt( + sessionKey, + nonce2, + ByteArray(0), + encodedTlv + ) + val encryptedData = ciphertext + mac + + val stateItem = TLV8Item(TLV8Tag.STATE, ubyteArrayOf(PairingState.M3.value)) + val encryptedDataItem = TLV8Item(TLV8Tag.ENCRYPTED_DATA, encryptedData.asUByteArray()) + val responseItems = listOf(stateItem, encryptedDataItem) + val encodedResponse = TLV8Item.encodeWithLogging(responseItems) + + val headers = mapOf( + "Content-Type" to CONTENT_TYPE, + "Content-Length" to encodedResponse.size.toString() + ) + val response = postHttpWithResponse("/pair-verify", encodedResponse, headers) + if (response?.isSuccessful == true) { + response.body?.bytes()?.let { continuePairSetup(it) } + } else { + pairingDidFail("Failed to process pair-verify M2") + } + } catch (e: Exception) { + pairingDidFail("Pair-verify M2 failed: ${e.message}") + } + } + + private fun setCiphers() { + val sharedKey = _accessorySharedKey ?: return + val prk = hkdfExtractExpand(sharedKey, "Control-Salt".encodeToByteArray(), null, 64) + _outgoingKey = hkdfExtractExpand(prk, "Control-Write-Encryption-Key".encodeToByteArray(), null, 32) + _incomingKey = hkdfExtractExpand(prk, "Control-Read-Encryption-Key".encodeToByteArray(), null, 32) + } + + private fun encryptData(data: ByteArray): ByteArray { + if (!_isEncrypted || _outgoingKey == null) return data + val result = ByteArrayOutputStream() + var offset = 0 + while (offset < data.size) { + val length = minOf(data.size - offset, MAX_BLOCK_LENGTH) + val blockData = data.copyOfRange(offset, offset + length) + val lengthData = ByteBuffer.allocate(2).putShort(length.toShort()).array() + val nonce = ByteBuffer.allocate(12).putInt(0).putLong(_outCount.toLong()).array() + val (ciphertext, mac) = chacha20Poly1305Encrypt(_outgoingKey!!, nonce, lengthData, blockData) + result.write(lengthData) + result.write(ciphertext) + result.write(mac) + offset += length + _outCount++ + } + return result.toByteArray() + } + + private fun decryptData(data: ByteArray): ByteArray? { + if (!_isEncrypted || _incomingKey == null || data.size < 2 + TAG_LENGTH) return null + val length = ByteBuffer.wrap(data, 0, 2).short.toInt() and 0xFFFF + if (data.size < 2 + length + TAG_LENGTH) return null + val blockData = data.copyOfRange(2, 2 + length) + val mac = data.copyOfRange(2 + length, 2 + length + TAG_LENGTH) + val nonce = ByteBuffer.allocate(12).putInt(0).putLong(_inCount.toLong()).array() + val plaintext = chacha20Poly1305Decrypt(_incomingKey!!, nonce, byteArrayOf(), blockData, mac) + if (plaintext != null) _inCount++ + return plaintext + } + + private fun pairingDidFail(message: String, e: Throwable? = null) { + _state = AirPlaySenderState.PAIRING_FAILED + if (e != null) + Logger.e(TAG, "Pairing failed with message '${message}'.", e) + else + Logger.e(TAG, "Pairing failed with message '${message}'.") + } + + private fun pairingDidFinish() { + Logger.i(TAG, "Pairing succeeded. Device is ready.") + val payload = mapOf( + "sessionUUID" to UUID.randomUUID().toString(), + "timingProtocol" to "None" + ) + val body = Json.encodeToString(payload) + val setupRequest = """ + SETUP /2182745467221657149 RTSP/1.0 + Content-Length: ${body.length} + Content-Type: application/x-apple-binary-plist + User-Agent: AirPlay/381.13 + X-Apple-HKP: 3 + X-Apple-StreamID: 1 + + $body + """.trimIndent() + val encryptedData = encryptData(setupRequest.encodeToByteArray()) + postHttp("/2182745467221657149", encryptedData, null) + } + + private fun postHttp(path: String, bodyBytes: ByteArray, contentType: String?): Boolean? { + val url = getUrl(path) + val request = Request.Builder() + .url(url) + .post(bodyBytes.toRequestBody(contentType?.toMediaType())) + .header("User-Agent", "AirPlay/381.13") + .header("X-Apple-HKP", "3") + .header("CSeq", (_cseq++).toString()) + .apply { if (contentType != null) header("Content-Type", contentType) } + .build() + + return try { + _httpClient.newCall(request).execute().use { response -> + response.isSuccessful + } + } catch (e: Exception) { + Logger.w(TAG, "HTTP POST failed: $url", e) + false + } + } + + private fun postHttpWithResponse(path: String, bodyBytes: ByteArray, headers: Map?): Response? { + val url = getUrl(path) + val request = Request.Builder() + .url(url) + .post(bodyBytes.toRequestBody(headers?.get("Content-Type")?.toMediaType())) + .header("User-Agent", "AirPlay/381.13") + .header("X-Apple-HKP", "3") + .header("X-Apple-Client-Name", "Grayjay") + .apply { + headers?.forEach { (k, v) -> header(k, v) } + } + .build() + + return try { + _httpClient.newCall(request).execute() + } catch (e: Exception) { + Logger.w(TAG, "HTTP POST failed: $url", e) + null + } + } + + private fun hkdfExtractExpand(ikm: ByteArray, salt: ByteArray?, info: ByteArray?, length: Int): ByteArray { + val hkdf = HKDFBytesGenerator(SHA512Digest()) + val params = HKDFParameters(ikm, salt, info) + hkdf.init(params) + val output = ByteArray(length) + hkdf.generateBytes(output, 0, length) + return output + } + + private fun chacha20Poly1305Encrypt(key: ByteArray, nonce: ByteArray, aad: ByteArray, plaintext: ByteArray): Pair { + val aead = ChaCha20Poly1305() + aead.init(true, AEADParameters(KeyParameter(key), 128, nonce, aad)) + + val output = ByteArray(plaintext.size + 16) + var offset = aead.processBytes(plaintext, 0, plaintext.size, output, 0) + aead.doFinal(output, offset) + + val ciphertext = output.copyOf(plaintext.size) + val tag = output.copyOfRange(plaintext.size, output.size) + return Pair(ciphertext, tag) + } + + private fun chacha20Poly1305Decrypt(key: ByteArray, nonce: ByteArray, aad: ByteArray, ciphertext: ByteArray, mac: ByteArray): ByteArray? { + val aead = ChaCha20Poly1305() + aead.init(false, AEADParameters(KeyParameter(key), 128, nonce, aad)) + + val input = ciphertext + mac + val output = ByteArray(ciphertext.size) + var len = aead.processBytes(input, 0, input.size, output, 0) + return try { + aead.doFinal(output, len) + output + } catch (_: Exception) { + null + } + } + + private fun concat(vararg arrays: ByteArray): ByteArray { + val totalLength = arrays.sumOf { it.size } + val result = ByteArray(totalLength) + var offset = 0 + for (array in arrays) { + System.arraycopy(array, 0, result, offset, array.size) + offset += array.size + } + return result + } +} + +enum class AirPlaySenderState { + NOT_CONNECTED, + WAITING_ON_PAIR_PIN_START, + WAITING_ON_PAIR_SETUP1, + WAITING_ON_PAIR_SETUP2, + WAITING_ON_PAIR_SETUP3, + WAITING_ON_PAIR_VERIFY1, + WAITING_ON_PAIR_VERIFY2, + READY_TO_PLAY, + CANCELLED, + PAIRING_FAILED +} + +enum class PairingState(val value: UByte) { + M1(1u), + M2(2u), + M3(3u), + M4(4u), + M5(5u), + M6(6u) +} + +enum class PairingMethod(val value: UByte) { + PAIR_SETUP(0u), + PAIR_SETUP_WITH_AUTH(1u), + PAIR_VERIFY(2u), + ADD_PAIRING(3u), + REMOVE_PAIRING(4u), + LIST_PAIRINGS(5u) +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt index c6e046ef..f30567d7 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.casting +import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.models.CastingDeviceInfo import kotlinx.serialization.KSerializer @@ -21,7 +22,8 @@ enum class CastConnectionState { enum class CastProtocolType { CHROMECAST, AIRPLAY, - FCAST; + FCAST, + AIRPLAY2; object CastProtocolTypeSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING) @@ -40,23 +42,29 @@ enum class CastProtocolType { } } -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; +interface IPairingDataHandler { + fun savePairingData(deviceId: String, pairingData: ByteArray) + fun loadPairingData(deviceId: String): ByteArray? + fun clearPairingData(deviceId: String) +} - var name: String? = null; +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 + + var name: String? = null var isPlaying: Boolean = false set(value) { - val changed = value != field; - field = value; + val changed = value != field + field = value if (changed) { - onPlayChanged.emit(value); + onPlayChanged.emit(value) } - }; + } private var lastTimeChangeTime_ms: Long = 0 var time: Double = 0.0 @@ -108,41 +116,44 @@ abstract class CastingDevice { val expectedCurrentTime: Double get() { - val diff = (System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0; - return time + diff; - }; + val diff = (System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0 + return time + diff + } var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED set(value) { - val changed = value != field; - field = value; + val changed = value != field + field = value if (changed) { - onConnectionStateChanged.emit(value); + onConnectionStateChanged.emit(value) } - }; + } - var onConnectionStateChanged = Event1(); - var onPlayChanged = Event1(); - var onTimeChanged = Event1(); - var onDurationChanged = Event1(); - var onVolumeChanged = Event1(); - var onSpeedChanged = Event1(); + var onPairingPinRequired = Event0() + open fun providePairingPin(pin: String?) { throw NotImplementedError() } - abstract fun stopCasting(); + var onConnectionStateChanged = Event1() + var onPlayChanged = Event1() + var onTimeChanged = Event1() + var onDurationChanged = Event1() + var onVolumeChanged = Event1() + var onSpeedChanged = Event1() - 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?); + abstract fun stopCasting() + + abstract fun seekVideo(timeSeconds: Double) + abstract fun stopVideo() + abstract fun pauseVideo() + abstract fun resumeVideo() + abstract fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) + abstract fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) open fun changeVolume(volume: Double) { throw NotImplementedError() } open fun changeSpeed(speed: Double) { throw NotImplementedError() } - abstract fun start(); - abstract fun stop(); + abstract fun start() + abstract fun stop() - abstract fun getDeviceInfo(): CastingDeviceInfo; + abstract fun getDeviceInfo(): CastingDeviceInfo - abstract fun getAddresses(): List; + abstract fun getAddresses(): List } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/SRPClient.kt b/app/src/main/java/com/futo/platformplayer/casting/SRPClient.kt new file mode 100644 index 00000000..07a2a829 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/SRPClient.kt @@ -0,0 +1,166 @@ +package com.futo.platformplayer.casting + +import com.futo.platformplayer.stripLeadingZero +import org.bouncycastle.crypto.digests.SHA512Digest +import java.math.BigInteger +import java.security.SecureRandom + +class SRPClient(private val N: BigInteger, private val g: BigInteger, private val username: String, private val password: String) { + private val digest = SHA512Digest() + private val hashLen = digest.digestSize + private val PAD_L: Int = (N.bitLength() + 7) / 8 + + private var a: BigInteger? = null + private var A: BigInteger? = null + private var S: BigInteger? = null + private var sessionKey: ByteArray? = null + private var M: ByteArray? = null + private var HAMK: ByteArray? = null + private var authenticated: Boolean = false + + private val random = SecureRandom() + + fun isAuthenticated(): Boolean = authenticated + fun getSessionKey(): ByteArray? = sessionKey + + fun srp_user_start_authentication(aOverride: BigInteger? = null): BigInteger { + a = aOverride ?: BigInteger(256, random) + A = g.modPow(a, N) + + if (A!!.mod(N).signum() == 0) { + throw IllegalStateException("Invalid client parameter: A mod N = 0") + } + + return A!! + } + + fun getS(): ByteArray? = S?.toByteArray()?.stripLeadingZero() + fun getA(): ByteArray? = A?.toByteArray()?.stripLeadingZero() + + fun srp_user_process_challenge(saltBytes: ByteArray, BBytes: ByteArray): ByteArray { + return srp_user_process_challenge_internal(saltBytes, BBytes).third + } + + fun srp_user_process_challenge_internal(saltBytes: ByteArray, BBytes: ByteArray): Triple { + if (A == null || a == null) { + throw IllegalStateException("Must call srp_user_start_authentication() first.") + } + + val B = BigInteger(1, BBytes) + val u = H_nn(A!!, B) + if (u.signum() == 0) { + throw IllegalStateException("Invalid server parameter: u = 0") + } + + val x = calculate_x(BigInteger(1, saltBytes)) + val k = H_nn(N, g) + val v = g.modPow(x, N) + if (B.mod(N).signum() == 0) { + throw IllegalStateException("Invalid server parameter: B mod N = 0") + } + + val kv = k.multiply(v).mod(N) + val base = B.subtract(kv).mod(N) + val exponent = a!!.add(u.multiply(x)) + S = base.modPow(exponent, N) + + sessionKey = hashBigInteger(S!!) + M = calculate_M(saltBytes, A!!, B, sessionKey!!) + return Triple(u, v, M!!.clone()) + } + + fun srp_user_verify_session(serverHAMK: ByteArray): Boolean { + if (M == null || sessionKey == null || A == null) { + throw IllegalStateException("Must call srp_user_process_challenge() first.") + } + + val hamk = calculate_H_AMK(A!!, M!!, sessionKey!!) + HAMK = hamk + + authenticated = HAMK!!.contentEquals(serverHAMK) + return authenticated + } + + private fun H_padded(vararg inputs: BigInteger): BigInteger { + val allBytes = inputs.fold(ByteArray(0)) { acc, big -> acc + padTo(big, PAD_L) } + val d = SHA512Digest() + d.update(allBytes, 0, allBytes.size) + val out = ByteArray(hashLen) + d.doFinal(out, 0) + return BigInteger(1, out) + } + + private fun H_nn(bn1: BigInteger, bn2: BigInteger): BigInteger { + return H_padded(bn1, bn2) + } + + private fun H_ns(n: BigInteger, saltBytes: ByteArray): BigInteger { + val nMinimal = n.toByteArray().stripLeadingZero() + val concatenated = nMinimal + saltBytes + val digest = SHA512Digest() + digest.update(concatenated, 0, concatenated.size) + val out = ByteArray(hashLen) + digest.doFinal(out, 0) + return BigInteger(1, out) + } + + private fun calculate_x(salt: BigInteger): BigInteger { + val userColonPass = username.toByteArray(Charsets.US_ASCII) + byteArrayOf(0x3A /* : */) + password.toByteArray(Charsets.US_ASCII) + val ucpHash = hash(userColonPass) + return H_ns(salt, ucpHash) + } + + private fun hashBigInteger(value: BigInteger): ByteArray { + val raw = value.toByteArray().stripLeadingZero() + return hash(raw) + } + + private fun hash(data: ByteArray): ByteArray { + val d = SHA512Digest() + d.update(data, 0, data.size) + val out = ByteArray(hashLen) + d.doFinal(out, 0) + return out + } + + private fun calculate_M(saltBytes: ByteArray, Aint: BigInteger, Bint: BigInteger, K: ByteArray): ByteArray { + val H_N = hashBigInteger(N) + val H_g = hashBigInteger(g) + val H_xor = ByteArray(hashLen) { i -> (H_N[i].toInt() xor H_g[i].toInt()).toByte() } + val H_I = hash(username.toByteArray(Charsets.UTF_8)) + val Abytes = Aint.toByteArray().stripLeadingZero() + val Bbytes = Bint.toByteArray().stripLeadingZero() + val mDigest = SHA512Digest() + mDigest.update(H_xor, 0, hashLen) + mDigest.update(H_I, 0, hashLen) + mDigest.update(saltBytes, 0, saltBytes.size) + mDigest.update(Abytes, 0, Abytes.size) + mDigest.update(Bbytes, 0, Bbytes.size) + mDigest.update(K, 0, hashLen) + val mOut = ByteArray(hashLen) + mDigest.doFinal(mOut, 0) + return mOut + } + + private fun calculate_H_AMK(Aint: BigInteger, M: ByteArray, K: ByteArray): ByteArray { + val Abytes = Aint.toByteArray().stripLeadingZero() + val hamkDigest = SHA512Digest() + hamkDigest.update(Abytes, 0, Abytes.size) + hamkDigest.update(M, 0, hashLen) + hamkDigest.update(K, 0, hashLen) + val out = ByteArray(hashLen) + hamkDigest.doFinal(out, 0) + return out + } + + private fun padTo(value: BigInteger, length: Int): ByteArray { + val minimal = value.toByteArray().stripLeadingZero() + return if (minimal.size == length) { + minimal + } else if (minimal.size < length) { + ByteArray(length - minimal.size) { 0 } + minimal + } else { + minimal.copyOfRange(minimal.size - length, minimal.size) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 58bd772c..3bb9bae2 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -10,8 +10,6 @@ import android.os.Build import android.os.Looper import android.util.Base64 import android.util.Log -import java.net.NetworkInterface -import java.net.Inet4Address import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import com.futo.platformplayer.R @@ -58,37 +56,52 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import java.net.Inet6Address import java.net.InetAddress import java.net.URLDecoder import java.net.URLEncoder -import java.util.Collections import java.util.UUID class StateCasting { - private val _scopeIO = CoroutineScope(Dispatchers.IO); - private val _scopeMain = CoroutineScope(Dispatchers.Main); - private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get(); + private val _scopeIO = CoroutineScope(Dispatchers.IO) + private val _scopeMain = CoroutineScope(Dispatchers.Main) + private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get() - private val _castServer = ManagedHttpServer(); - private var _started = false; + private val _castServer = ManagedHttpServer() + private var _started = false - var devices: HashMap = hashMapOf(); - val onDeviceAdded = Event1(); - val onDeviceChanged = Event1(); - val onDeviceRemoved = Event1(); - val onActiveDeviceConnectionStateChanged = Event2(); - val onActiveDevicePlayChanged = Event1(); - val onActiveDeviceTimeChanged = Event1(); - val onActiveDeviceDurationChanged = Event1(); - val onActiveDeviceVolumeChanged = Event1(); - var activeDevice: CastingDevice? = null; + var devices: HashMap = hashMapOf() + val onDeviceAdded = Event1() + val onDeviceChanged = Event1() + val onDeviceRemoved = Event1() + val onActiveDeviceConnectionStateChanged = Event2() + val onActiveDevicePlayChanged = Event1() + val onActiveDeviceTimeChanged = Event1() + val onActiveDeviceDurationChanged = Event1() + val onActiveDeviceVolumeChanged = Event1() + var activeDevice: CastingDevice? = null + var onPairingPinRequired = Event1() private var _videoExecutor: JSRequestExecutor? = null private var _audioExecutor: JSRequestExecutor? = null - private val _client = ManagedHttpClient(); - var _resumeCastingDevice: CastingDeviceInfo? = null; + private val _client = ManagedHttpClient() + var _resumeCastingDevice: CastingDeviceInfo? = null private var _nsdManager: NsdManager? = null - val isCasting: Boolean get() = activeDevice != null; + val isCasting: Boolean get() = activeDevice != null + + private val inMemoryStorage: MutableMap = mutableMapOf() + val pairingDataHandler: IPairingDataHandler = object : IPairingDataHandler { + override fun savePairingData(deviceId: String, pairingData: ByteArray) { + inMemoryStorage[deviceId] = pairingData + } + + override fun loadPairingData(deviceId: String): ByteArray? { + return inMemoryStorage[deviceId] + } + + override fun clearPairingData(deviceId: String) { + inMemoryStorage.remove(deviceId) + } + + } private val _discoveryListeners = mapOf( "_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice), @@ -124,11 +137,11 @@ class StateCasting { } fun onStop() { - val ad = activeDevice ?: return; + val ad = activeDevice ?: return _resumeCastingDevice = ad.getDeviceInfo() Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'") - Logger.i(TAG, "Stopping active device because of onStop."); - ad.stop(); + Logger.i(TAG, "Stopping active device because of onStop.") + ad.stop() } fun onResume() { @@ -152,20 +165,30 @@ class StateCasting { @Synchronized fun start(context: Context) { if (_started) - return; - _started = true; + return + _started = true Log.i(TAG, "_resumeCastingDevice set null start") - _resumeCastingDevice = null; + _resumeCastingDevice = null - Logger.i(TAG, "CastingService starting..."); + Logger.i(TAG, "CastingService starting...") - _castServer.start(); - enableDeveloper(true); + _castServer.start() + enableDeveloper(true) - Logger.i(TAG, "CastingService started."); + Logger.i(TAG, "CastingService started.") _nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager + + onPairingPinRequired.subscribe { device -> + _scopeMain.launch(Dispatchers.Main) { + UIDialogs.showPairingCodeDialog(context, onSubmit = { + device.providePairingPin(it) + }, onCancel = { + device.stop() + }) + } + } } @Synchronized @@ -193,30 +216,30 @@ class StateCasting { @Synchronized fun stop() { if (!_started) - return; + return - _started = false; + _started = false Logger.i(TAG, "CastingService stopping.") stopDiscovering() - _scopeIO.cancel(); - _scopeMain.cancel(); + _scopeIO.cancel() + _scopeMain.cancel() Logger.i(TAG, "Stopping active device because StateCasting is being stopped.") - val d = activeDevice; - activeDevice = null; - d?.stop(); + val d = activeDevice + activeDevice = null + d?.stop() - _castServer.stop(); - _castServer.removeAllHandlers(); + _castServer.stop() + _castServer.removeAllHandlers() Logger.i(TAG, "CastingService stopped.") _nsdManager = null } - private fun createDiscoveryListener(addOrUpdate: (String, Array, Int) -> Unit): NsdManager.DiscoveryListener { + private fun createDiscoveryListener(addOrUpdate: (String, Array, Int, Map) -> Unit): NsdManager.DiscoveryListener { return object : NsdManager.DiscoveryListener { override fun onDiscoveryStarted(regType: String) { Log.d(TAG, "Service discovery started for $regType") @@ -256,12 +279,12 @@ class StateCasting { } else { arrayOf(service.host) } - addOrUpdate(service.serviceName, addresses, service.port) + addOrUpdate(service.serviceName, addresses, service.port, service.attributes) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { _nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback { override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { Log.v(TAG, "onServiceUpdated: $serviceInfo") - addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port) + addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port, serviceInfo.attributes) } override fun onServiceLost() { @@ -285,7 +308,7 @@ class StateCasting { override fun onServiceResolved(serviceInfo: NsdServiceInfo) { Log.v(TAG, "Resolve Succeeded: $serviceInfo") - addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port) + addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port, serviceInfo.attributes) } }) } @@ -293,56 +316,56 @@ class StateCasting { } } - private val _castingDialogLock = Any(); - private var _currentDialog: AlertDialog? = null; + private val _castingDialogLock = Any() + private var _currentDialog: AlertDialog? = null @Synchronized fun connectDevice(device: CastingDevice) { if (activeDevice == device) - return; + return - val ad = activeDevice; + val ad = activeDevice if (ad != null) { Logger.i(TAG, "Stopping previous device because a new one is being connected.") - device.onConnectionStateChanged.clear(); - device.onPlayChanged.clear(); - device.onTimeChanged.clear(); - device.onVolumeChanged.clear(); - device.onDurationChanged.clear(); - ad.stop(); + device.onConnectionStateChanged.clear() + device.onPlayChanged.clear() + device.onTimeChanged.clear() + device.onVolumeChanged.clear() + device.onDurationChanged.clear() + ad.stop() } device.onConnectionStateChanged.subscribe { castConnectionState -> - Logger.i(TAG, "Active device connection state changed: $castConnectionState"); + Logger.i(TAG, "Active device connection state changed: $castConnectionState") if (castConnectionState == CastConnectionState.DISCONNECTED) { - Logger.i(TAG, "Clearing events: $castConnectionState"); + Logger.i(TAG, "Clearing events: $castConnectionState") - device.onConnectionStateChanged.clear(); - device.onPlayChanged.clear(); - device.onTimeChanged.clear(); - device.onVolumeChanged.clear(); - device.onDurationChanged.clear(); - activeDevice = null; + device.onConnectionStateChanged.clear() + device.onPlayChanged.clear() + device.onTimeChanged.clear() + device.onVolumeChanged.clear() + device.onDurationChanged.clear() + activeDevice = null } invokeInMainScopeIfRequired { StateApp.withContext(false) { context -> context.let { - Logger.i(TAG, "Casting state changed to ${castConnectionState}"); + Logger.i(TAG, "Casting state changed to ${castConnectionState}") when (castConnectionState) { CastConnectionState.CONNECTED -> { - Logger.i(TAG, "Casting connected to [${device.name}]"); + Logger.i(TAG, "Casting connected to [${device.name}]") UIDialogs.appToast("Connected to device") synchronized(_castingDialogLock) { if(_currentDialog != null) { - _currentDialog?.hide(); - _currentDialog = null; + _currentDialog?.hide() + _currentDialog = null } } } CastConnectionState.CONNECTING -> { - Logger.i(TAG, "Casting connecting to [${device.name}]"); + Logger.i(TAG, "Casting connecting to [${device.name}]") UIDialogs.toast(it, "Connecting to device...") synchronized(_castingDialogLock) { if(_currentDialog == null) { @@ -350,8 +373,8 @@ class StateCasting { "Connecting to [${device.name}]", "Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2, UIDialogs.Action("Disconnect", { - device.stop(); - })); + device.stop() + })) } } } @@ -359,49 +382,49 @@ class StateCasting { UIDialogs.toast(it, "Disconnected from device") synchronized(_castingDialogLock) { if(_currentDialog != null) { - _currentDialog?.hide(); - _currentDialog = null; + _currentDialog?.hide() + _currentDialog = null } } } } } - }; - onActiveDeviceConnectionStateChanged.emit(device, castConnectionState); - }; - }; + } + onActiveDeviceConnectionStateChanged.emit(device, castConnectionState) + } + } device.onPlayChanged.subscribe { - invokeInMainScopeIfRequired { onActiveDevicePlayChanged.emit(it) }; + invokeInMainScopeIfRequired { onActiveDevicePlayChanged.emit(it) } } device.onDurationChanged.subscribe { - invokeInMainScopeIfRequired { onActiveDeviceDurationChanged.emit(it) }; - }; + invokeInMainScopeIfRequired { onActiveDeviceDurationChanged.emit(it) } + } device.onVolumeChanged.subscribe { - invokeInMainScopeIfRequired { onActiveDeviceVolumeChanged.emit(it) }; - }; + invokeInMainScopeIfRequired { onActiveDeviceVolumeChanged.emit(it) } + } device.onTimeChanged.subscribe { - invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) }; - }; - - try { - device.start(); - } catch (e: Throwable) { - Logger.w(TAG, "Failed to connect to device."); - device.onConnectionStateChanged.clear(); - device.onPlayChanged.clear(); - device.onTimeChanged.clear(); - device.onVolumeChanged.clear(); - device.onDurationChanged.clear(); - return; + invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) } } - activeDevice = device; - Logger.i(TAG, "Connect to device ${device.name}"); + try { + device.start() + } catch (e: Throwable) { + Logger.w(TAG, "Failed to connect to device.") + device.onConnectionStateChanged.clear() + device.onPlayChanged.clear() + device.onTimeChanged.clear() + device.onVolumeChanged.clear() + device.onDurationChanged.clear() + return + } + + activeDevice = device + Logger.i(TAG, "Connect to device ${device.name}") } fun addRememberedDevice(deviceInfo: CastingDeviceInfo): CastingDeviceInfo { - val device = deviceFromCastingDeviceInfo(deviceInfo); - return addRememberedDevice(device); + val device = deviceFromCastingDeviceInfo(deviceInfo) + return addRememberedDevice(device) } fun getRememberedCastingDevices(): List { @@ -424,123 +447,123 @@ class StateCasting { private fun invokeInMainScopeIfRequired(action: () -> Unit){ if(Looper.getMainLooper().thread != Thread.currentThread()) { - _scopeMain.launch { action(); } - return; + _scopeMain.launch { action() } + return } - action(); + action() } fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?): Boolean { - val ad = activeDevice ?: return false; + val ad = activeDevice ?: return false if (ad.connectionState != CastConnectionState.CONNECTED) { - return false; + return false } - val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0; + val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0 - var sourceCount = 0; - if (videoSource != null) sourceCount++; - if (audioSource != null) sourceCount++; - if (subtitleSource != null) sourceCount++; + var sourceCount = 0 + if (videoSource != null) sourceCount++ + if (audioSource != null) sourceCount++ + if (subtitleSource != null) sourceCount++ if (sourceCount < 1) { - throw Exception("At least one source should be specified."); + throw Exception("At least one source should be specified.") } if (sourceCount > 1) { if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) { - if (ad is AirPlayCastingDevice) { - Logger.i(TAG, "Casting as local HLS"); - castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); + if (ad is AirPlay1CastingDevice) { + Logger.i(TAG, "Casting as local HLS") + castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed) } else { - Logger.i(TAG, "Casting as local DASH"); - castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); + Logger.i(TAG, "Casting as local DASH") + castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed) } } else { StateApp.instance.scope.launch(Dispatchers.IO) { try { val isRawDash = videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource if (isRawDash) { - Logger.i(TAG, "Casting as raw DASH"); + Logger.i(TAG, "Casting as raw DASH") try { - castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed); + castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed) } catch (e: Throwable) { - Logger.e(TAG, "Failed to start casting DASH raw videoSource=${videoSource} audioSource=${audioSource}.", e); + Logger.e(TAG, "Failed to start casting DASH raw videoSource=${videoSource} audioSource=${audioSource}.", e) } } else { if (ad is FCastCastingDevice) { - Logger.i(TAG, "Casting as DASH direct"); - castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); - } else if (ad is AirPlayCastingDevice) { - Logger.i(TAG, "Casting as HLS indirect"); - castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); + Logger.i(TAG, "Casting as DASH direct") + castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed) + } else if (ad is AirPlay1CastingDevice) { + Logger.i(TAG, "Casting as HLS indirect") + castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed) } else { - Logger.i(TAG, "Casting as DASH indirect"); - castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); + Logger.i(TAG, "Casting as DASH indirect") + castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed) } } } catch (e: Throwable) { - Logger.e(TAG, "Failed to start casting DASH videoSource=${videoSource} audioSource=${audioSource}.", e); + Logger.e(TAG, "Failed to start casting DASH videoSource=${videoSource} audioSource=${audioSource}.", e) } } } } else { - val proxyStreams = Settings.instance.casting.alwaysProxyRequests; - val url = getLocalUrl(ad); - val id = UUID.randomUUID(); + val proxyStreams = Settings.instance.casting.alwaysProxyRequests + val url = getLocalUrl(ad) + val id = UUID.randomUUID() if (videoSource is IVideoUrlSource) { val videoPath = "/video-${id}" - val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl(); - Logger.i(TAG, "Casting as singular video"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed); + val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl() + Logger.i(TAG, "Casting as singular video") + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed) } else if (audioSource is IAudioUrlSource) { val audioPath = "/audio-${id}" - val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl(); - Logger.i(TAG, "Casting as singular audio"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed); + val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl() + Logger.i(TAG, "Casting as singular audio") + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed) } else if(videoSource is IHLSManifestSource) { if (proxyStreams || ad is ChromecastCastingDevice) { - Logger.i(TAG, "Casting as proxied HLS"); - castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed); + Logger.i(TAG, "Casting as proxied HLS") + castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed) } else { - Logger.i(TAG, "Casting as non-proxied HLS"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed); + Logger.i(TAG, "Casting as non-proxied HLS") + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed) } } else if(audioSource is IHLSManifestAudioSource) { if (proxyStreams || ad is ChromecastCastingDevice) { - Logger.i(TAG, "Casting as proxied audio HLS"); - castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed); + Logger.i(TAG, "Casting as proxied audio HLS") + castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed) } else { - Logger.i(TAG, "Casting as non-proxied audio HLS"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed); + Logger.i(TAG, "Casting as non-proxied audio HLS") + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed) } } else if (videoSource is LocalVideoSource) { - Logger.i(TAG, "Casting as local video"); - castLocalVideo(video, videoSource, resumePosition, speed); + Logger.i(TAG, "Casting as local video") + castLocalVideo(video, videoSource, resumePosition, speed) } else if (audioSource is LocalAudioSource) { - Logger.i(TAG, "Casting as local audio"); - castLocalAudio(video, audioSource, resumePosition, speed); + Logger.i(TAG, "Casting as local audio") + castLocalAudio(video, audioSource, resumePosition, speed) } else if (videoSource is JSDashManifestRawSource) { - Logger.i(TAG, "Casting as JSDashManifestRawSource video"); + Logger.i(TAG, "Casting as JSDashManifestRawSource video") StateApp.instance.scope.launch(Dispatchers.IO) { try { - castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed); + castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed) } catch (e: Throwable) { - Logger.e(TAG, "Failed to start casting DASH raw videoSource=${videoSource}.", e); + Logger.e(TAG, "Failed to start casting DASH raw videoSource=${videoSource}.", e) } } } else if (audioSource is JSDashManifestRawAudioSource) { - Logger.i(TAG, "Casting as JSDashManifestRawSource audio"); + Logger.i(TAG, "Casting as JSDashManifestRawSource audio") StateApp.instance.scope.launch(Dispatchers.IO) { try { - castDashRaw(contentResolver, video, null, audioSource as JSDashManifestRawAudioSource?, null, resumePosition, speed); + castDashRaw(contentResolver, video, null, audioSource as JSDashManifestRawAudioSource?, null, resumePosition, speed) } catch (e: Throwable) { - Logger.e(TAG, "Failed to start casting DASH raw audioSource=${audioSource}.", e); + Logger.e(TAG, "Failed to start casting DASH raw audioSource=${audioSource}.", e) } } } else { @@ -548,74 +571,74 @@ class StateCasting { if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null, if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null, if(subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null - ).filterNotNull().joinToString(", "); - throw UnsupportedCastException(str); + ).filterNotNull().joinToString(", ") + throw UnsupportedCastException(str) } } - return true; + return true } fun resumeVideo(): Boolean { - val ad = activeDevice ?: return false; - ad.resumeVideo(); - return true; + val ad = activeDevice ?: return false + ad.resumeVideo() + return true } fun pauseVideo(): Boolean { - val ad = activeDevice ?: return false; - ad.pauseVideo(); - return true; + val ad = activeDevice ?: return false + ad.pauseVideo() + return true } fun stopVideo(): Boolean { - val ad = activeDevice ?: return false; - ad.stopVideo(); - return true; + val ad = activeDevice ?: return false + ad.stopVideo() + return true } fun videoSeekTo(timeSeconds: Double): Boolean { - val ad = activeDevice ?: return false; - ad.seekVideo(timeSeconds); - return true; + val ad = activeDevice ?: return false + ad.seekVideo(timeSeconds) + return true } private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List { - val ad = activeDevice ?: return listOf(); + val ad = activeDevice ?: return listOf() - val url = getLocalUrl(ad); - val id = UUID.randomUUID(); + val url = getLocalUrl(ad) + val id = UUID.randomUUID() val videoPath = "/video-${id}" - val videoUrl = url + videoPath; + val videoUrl = url + videoPath _castServer.addHandlerWithAllowAllOptions( HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + ).withTag("cast") - Logger.i(TAG, "Casting local video (videoUrl: $videoUrl)."); - ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed); + Logger.i(TAG, "Casting local video (videoUrl: $videoUrl).") + ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed) - return listOf(videoUrl); + return listOf(videoUrl) } private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List { - val ad = activeDevice ?: return listOf(); + val ad = activeDevice ?: return listOf() - val url = getLocalUrl(ad); - val id = UUID.randomUUID(); + val url = getLocalUrl(ad) + val id = UUID.randomUUID() val audioPath = "/audio-${id}" - val audioUrl = url + audioPath; + val audioUrl = url + audioPath _castServer.addHandlerWithAllowAllOptions( HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + ).withTag("cast") - Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl)."); - ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed); + Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl).") + ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed) - return listOf(audioUrl); + return listOf(audioUrl) } private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List { @@ -715,92 +738,92 @@ class StateCasting { } private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List { - val ad = activeDevice ?: return listOf(); + val ad = activeDevice ?: return listOf() - val url = getLocalUrl(ad); - val id = UUID.randomUUID(); + val url = getLocalUrl(ad) + val id = UUID.randomUUID() val dashPath = "/dash-${id}" val videoPath = "/video-${id}" val audioPath = "/audio-${id}" val subtitlePath = "/subtitle-${id}" - val dashUrl = url + dashPath; - val videoUrl = url + videoPath; - val audioUrl = url + audioPath; - val subtitleUrl = url + subtitlePath; + val dashUrl = url + dashPath + val videoUrl = url + videoPath + val audioUrl = url + audioPath + val subtitleUrl = url + subtitlePath - val dashContent = DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitleUrl); - Logger.v(TAG) { "Dash manifest: $dashContent" }; + val dashContent = DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitleUrl) + Logger.v(TAG) { "Dash manifest: $dashContent" } _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", dashPath, dashContent, "application/dash+xml") .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + ).withTag("cast") if (videoSource != null) { _castServer.addHandlerWithAllowAllOptions( HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + ).withTag("cast") } if (audioSource != null) { _castServer.addHandlerWithAllowAllOptions( HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + ).withTag("cast") } if (subtitleSource != null) { _castServer.addHandlerWithAllowAllOptions( HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath) .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + ).withTag("cast") } - Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)."); - ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed); + Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).") + ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed) - return listOf(dashUrl, videoUrl, audioUrl, subtitleUrl); + return listOf(dashUrl, videoUrl, audioUrl, subtitleUrl) } private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { - val ad = activeDevice ?: return listOf(); - val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; + val ad = activeDevice ?: return listOf() + val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice - val url = getLocalUrl(ad); - val id = UUID.randomUUID(); + val url = getLocalUrl(ad) + val id = UUID.randomUUID() val videoPath = "/video-${id}" val audioPath = "/audio-${id}" val subtitlePath = "/subtitle-${id}" - val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl(); - val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl(); + val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl() + val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl() val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { - return@withContext subtitleSource.getSubtitlesURI(); - } else null; + return@withContext subtitleSource.getSubtitlesURI() + } else null - var subtitlesUrl: String? = null; + var subtitlesUrl: String? = null if (subtitlesUri != null) { if(subtitlesUri.scheme == "file") { - var content: String? = null; - val inputStream = contentResolver.openInputStream(subtitlesUri); + var content: String? = null + val inputStream = contentResolver.openInputStream(subtitlesUri) inputStream?.use { stream -> - val reader = stream.bufferedReader(); - content = reader.use { it.readText() }; + val reader = stream.bufferedReader() + content = reader.use { it.readText() } } if (content != null) { _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + ).withTag("cast") } - subtitlesUrl = url + subtitlePath; + subtitlesUrl = url + subtitlePath } else { - subtitlesUrl = subtitlesUri.toString(); + subtitlesUrl = subtitlesUri.toString() } } @@ -809,41 +832,41 @@ class StateCasting { HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) .withInjectedHost() .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + ).withTag("cast") } if (audioSource != null) { _castServer.addHandlerWithAllowAllOptions( HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) .withInjectedHost() .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + ).withTag("cast") } - val content = DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl); + val content = DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl) - Logger.i(TAG, "Direct dash cast to casting device (videoUrl: $videoUrl, audioUrl: $audioUrl)."); - Logger.v(TAG) { "Dash manifest: $content" }; - ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble(), speed); + Logger.i(TAG, "Direct dash cast to casting device (videoUrl: $videoUrl, audioUrl: $audioUrl).") + Logger.v(TAG) { "Dash manifest: $content" } + ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble(), speed) - return listOf(videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); + return listOf(videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()) } private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, codec: String?, resumePosition: Double, speed: Double?): List { _castServer.removeAllHandlers("castProxiedHlsMaster") - val ad = activeDevice ?: return listOf(); - val url = getLocalUrl(ad); + val ad = activeDevice ?: return listOf() + val url = getLocalUrl(ad) - val id = UUID.randomUUID(); + val id = UUID.randomUUID() val hlsPath = "/hls-${id}" val hlsUrl = url + hlsPath - Logger.i(TAG, "HLS url: $hlsUrl"); + Logger.i(TAG, "HLS url: $hlsUrl") _castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", hlsPath) { masterContext -> _castServer.removeAllHandlers("castProxiedHlsVariant") val headers = masterContext.headers.clone() - headers["Content-Type"] = "application/vnd.apple.mpegurl"; + headers["Content-Type"] = "application/vnd.apple.mpegurl" val masterPlaylistResponse = _client.get(sourceUrl) check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } @@ -857,35 +880,35 @@ class StateCasting { } catch (e: Throwable) { if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { //This is a variant playlist, not a master playlist - Logger.i(TAG, "HLS casting as variant playlist (codec: $codec): $hlsUrl"); + Logger.i(TAG, "HLS casting as variant playlist (codec: $codec): $hlsUrl") val vpHeaders = masterContext.headers.clone() - vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl" val variantPlaylist = HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl) val proxiedVariantPlaylist = proxyVariantPlaylist(url, id, variantPlaylist, video.isLive) val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() - masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8) return@HttpFunctionHandler } else { throw e } } - Logger.i(TAG, "HLS casting as master playlist: $hlsUrl"); + Logger.i(TAG, "HLS casting as master playlist: $hlsUrl") val newVariantPlaylistRefs = arrayListOf() val newMediaRenditions = arrayListOf() val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.sessionDataList, masterPlaylist.independentSegments) for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) { - val playlistId = UUID.randomUUID(); + val playlistId = UUID.randomUUID() val newPlaylistPath = "/hls-playlist-${playlistId}" - val newPlaylistUrl = url + newPlaylistPath; + val newPlaylistUrl = url + newPlaylistPath _castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", newPlaylistPath) { vpContext -> val vpHeaders = vpContext.headers.clone() - vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl" val response = _client.get(variantPlaylistRef.url) check(response.isOk) { "Failed to get variant playlist: ${response.code}" } @@ -896,7 +919,7 @@ class StateCasting { val variantPlaylist = HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url) val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() - vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8) }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant") newVariantPlaylistRefs.add(HLS.VariantPlaylistReference( @@ -915,7 +938,7 @@ class StateCasting { _castServer.addHandlerWithAllowAllOptions(HttpFunctionHandler("GET", newPlaylistPath) { vpContext -> val vpHeaders = vpContext.headers.clone() - vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl" val response = _client.get(mediaRendition.uri) check(response.isOk) { "Failed to get variant playlist: ${response.code}" } @@ -926,7 +949,7 @@ class StateCasting { val variantPlaylist = HLS.parseVariantPlaylist(vpContent, mediaRendition.uri) val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() - vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8) }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant") } @@ -942,16 +965,16 @@ class StateCasting { )) } - masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8()); + masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8()) }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsMaster") - Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath)."); + Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath).") //ChromeCast is sometimes funky with resume position 0 - val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition; - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble(), speed); + val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble(), speed) - return listOf(hlsUrl); + return listOf(hlsUrl) } private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, proxySegments: Boolean = true): HLS.VariantPlaylist { @@ -981,7 +1004,7 @@ class StateCasting { private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment { if (segment is HLS.MediaSegment) { val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}" - val newSegmentUrl = url + newSegmentPath; + val newSegmentUrl = url + newSegmentPath if (_castServer.getHandler("GET", newSegmentPath) == null) { _castServer.addHandlerWithAllowAllOptions( @@ -1001,14 +1024,14 @@ class StateCasting { } private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { - val ad = activeDevice ?: return listOf(); - val url = getLocalUrl(ad); - val id = UUID.randomUUID(); + val ad = activeDevice ?: return listOf() + val url = getLocalUrl(ad) + val id = UUID.randomUUID() val hlsPath = "/hls-${id}" - val hlsUrl = url + hlsPath; - Logger.i(TAG, "HLS url: $hlsUrl"); + val hlsUrl = url + hlsPath + Logger.i(TAG, "HLS url: $hlsUrl") val mediaRenditions = arrayListOf() val variantPlaylistReferences = arrayListOf() @@ -1027,7 +1050,7 @@ class StateCasting { HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(), "application/vnd.apple.mpegurl") .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castHlsIndirectVariant"); + ).withTag("castHlsIndirectVariant") mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "df", "default", true, true, true)) @@ -1035,34 +1058,34 @@ class StateCasting { HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) .withInjectedHost() .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castHlsIndirectVariant"); + ).withTag("castHlsIndirectVariant") } val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { - return@withContext subtitleSource.getSubtitlesURI(); - } else null; + return@withContext subtitleSource.getSubtitlesURI() + } else null - var subtitlesUrl: String? = null; + var subtitlesUrl: String? = null if (subtitlesUri != null) { val subtitlePath = "/subtitles-${id}" if(subtitlesUri.scheme == "file") { - var content: String? = null; - val inputStream = contentResolver.openInputStream(subtitlesUri); + var content: String? = null + val inputStream = contentResolver.openInputStream(subtitlesUri) inputStream?.use { stream -> - val reader = stream.bufferedReader(); - content = reader.use { it.readText() }; + val reader = stream.bufferedReader() + content = reader.use { it.readText() } } if (content != null) { _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castHlsIndirectVariant"); + ).withTag("castHlsIndirectVariant") } - subtitlesUrl = url + subtitlePath; + subtitlesUrl = url + subtitlePath } else { - subtitlesUrl = subtitlesUri.toString(); + subtitlesUrl = subtitlesUri.toString() } } @@ -1077,7 +1100,7 @@ class StateCasting { HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(), "application/vnd.apple.mpegurl") .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castHlsIndirectVariant"); + ).withTag("castHlsIndirectVariant") mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "df", "default", true, true, true)) } @@ -1096,7 +1119,7 @@ class StateCasting { HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(), "application/vnd.apple.mpegurl") .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castHlsIndirectVariant"); + ).withTag("castHlsIndirectVariant") variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo( videoSource.bitrate ?: 0, @@ -1112,7 +1135,7 @@ class StateCasting { HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) .withInjectedHost() .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castHlsIndirectVariant"); + ).withTag("castHlsIndirectVariant") } val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) @@ -1122,88 +1145,88 @@ class StateCasting { .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectMaster") - Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath)."); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed); + Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath).") + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed) - return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); + return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()) } private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { - val ad = activeDevice ?: return listOf(); - val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; + val ad = activeDevice ?: return listOf() + val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice - val url = getLocalUrl(ad); - val id = UUID.randomUUID(); + val url = getLocalUrl(ad) + val id = UUID.randomUUID() val dashPath = "/dash-${id}" val videoPath = "/video-${id}" val audioPath = "/audio-${id}" val subtitlePath = "/subtitle-${id}" - val dashUrl = url + dashPath; - Logger.i(TAG, "DASH url: $dashUrl"); + val dashUrl = url + dashPath + Logger.i(TAG, "DASH url: $dashUrl") - val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl(); - val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl(); + val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl() + val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl() val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { - return@withContext subtitleSource.getSubtitlesURI(); - } else null; + return@withContext subtitleSource.getSubtitlesURI() + } else null - //_castServer.removeAllHandlers("cast"); - //Logger.i(TAG, "removed all old castDash handlers."); + //_castServer.removeAllHandlers("cast") + //Logger.i(TAG, "removed all old castDash handlers.") - var subtitlesUrl: String? = null; + var subtitlesUrl: String? = null if (subtitlesUri != null) { if(subtitlesUri.scheme == "file") { - var content: String? = null; - val inputStream = contentResolver.openInputStream(subtitlesUri); + var content: String? = null + val inputStream = contentResolver.openInputStream(subtitlesUri) inputStream?.use { stream -> - val reader = stream.bufferedReader(); - content = reader.use { it.readText() }; + val reader = stream.bufferedReader() + content = reader.use { it.readText() } } if (content != null) { _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + ).withTag("cast") } - subtitlesUrl = url + subtitlePath; + subtitlesUrl = url + subtitlePath } else { - subtitlesUrl = subtitlesUri.toString(); + subtitlesUrl = subtitlesUri.toString() } } - val dashContent = DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl); - Logger.v(TAG) { "Dash manifest: $dashContent" }; + val dashContent = DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl) + Logger.v(TAG) { "Dash manifest: $dashContent" } _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", dashPath, dashContent, "application/dash+xml") .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + ).withTag("cast") if (videoSource != null) { _castServer.addHandlerWithAllowAllOptions( HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) .withInjectedHost() .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + ).withTag("cast") } if (audioSource != null) { _castServer.addHandlerWithAllowAllOptions( HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) .withInjectedHost() .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + ).withTag("cast") } - Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)."); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed); + Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath).") + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed) - return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); + return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()) } private fun cleanExecutors() { @@ -1224,54 +1247,54 @@ class StateCasting { address = findPreferredAddress() ?: address Logger.i(TAG, "Selected casting address: $address") } - return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}"; + return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}" } @OptIn(UnstableApi::class) private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { - val ad = activeDevice ?: return listOf(); + val ad = activeDevice ?: return listOf() cleanExecutors() _castServer.removeAllHandlers("castDashRaw") - val url = getLocalUrl(ad); - val id = UUID.randomUUID(); + val url = getLocalUrl(ad) + val id = UUID.randomUUID() val dashPath = "/dash-${id}" val videoPath = "/video-${id}" val audioPath = "/audio-${id}" val subtitlePath = "/subtitle-${id}" - val dashUrl = url + dashPath; - Logger.i(TAG, "DASH url: $dashUrl"); + val dashUrl = url + dashPath + Logger.i(TAG, "DASH url: $dashUrl") val videoUrl = url + videoPath val audioUrl = url + audioPath val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { - return@withContext subtitleSource.getSubtitlesURI(); - } else null; + return@withContext subtitleSource.getSubtitlesURI() + } else null - var subtitlesUrl: String? = null; + var subtitlesUrl: String? = null if (subtitlesUri != null) { if(subtitlesUri.scheme == "file") { - var content: String? = null; - val inputStream = contentResolver.openInputStream(subtitlesUri); + var content: String? = null + val inputStream = contentResolver.openInputStream(subtitlesUri) inputStream?.use { stream -> - val reader = stream.bufferedReader(); - content = reader.use { it.readText() }; + val reader = stream.bufferedReader() + content = reader.use { it.readText() } } if (content != null) { _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + ).withTag("cast") } - subtitlesUrl = url + subtitlePath; + subtitlesUrl = url + subtitlePath } else { - subtitlesUrl = subtitlesUri.toString(); + subtitlesUrl = subtitlesUri.toString() } } @@ -1297,9 +1320,9 @@ class StateCasting { } if (mediaType.startsWith("video/")) { - return@replace "${it.groups[1]!!.value}=\"${videoUrl}?url=${URLEncoder.encode(it.groups[2]!!.value, "UTF-8").replace("%24Number%24", "\$Number\$")}&mediaType=${URLEncoder.encode(mediaType, "UTF-8")}\"" + return@replace "${it.groups[1]!!.value}=\"${videoUrl}?url=${URLEncoder.encode(it.groups[2]!!.value, "UTF-8").replace("%24Number%24", "\$Number\$")}&mediaType=${URLEncoder.encode(mediaType, "UTF-8")}\"" } else if (mediaType.startsWith("audio/")) { - return@replace "${it.groups[1]!!.value}=\"${audioUrl}?url=${URLEncoder.encode(it.groups[2]!!.value, "UTF-8").replace("%24Number%24", "\$Number\$")}&mediaType=${URLEncoder.encode(mediaType, "UTF-8")}\"" + return@replace "${it.groups[1]!!.value}=\"${audioUrl}?url=${URLEncoder.encode(it.groups[2]!!.value, "UTF-8").replace("%24Number%24", "\$Number\$")}&mediaType=${URLEncoder.encode(mediaType, "UTF-8")}\"" } else { throw Exception("Expected audio or video") } @@ -1324,13 +1347,13 @@ class StateCasting { //TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also - Logger.v(TAG) { "Dash manifest: $dashContent" }; + Logger.v(TAG) { "Dash manifest: $dashContent" } _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", dashPath, dashContent, "application/dash+xml") .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castDashRaw"); + ).withTag("castDashRaw") if (videoSource != null) { _castServer.addHandlerWithAllowAllOptions( @@ -1338,17 +1361,17 @@ class StateCasting { val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler val mediaType = httpContext.query["mediaType"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler - val videoExecutor = _videoExecutor; + val videoExecutor = _videoExecutor if (videoExecutor != null) { val data = videoExecutor.executeRequest("GET", originalUrl, null, httpContext.headers) httpContext.respondBytes(200, HttpHeaders().apply { put("Content-Type", mediaType) - }, data); + }, data) } else { throw NotImplementedError() } }.withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castDashRaw"); + ).withTag("castDashRaw") } if (audioSource != null) { _castServer.addHandlerWithAllowAllOptions( @@ -1356,21 +1379,21 @@ class StateCasting { val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler val mediaType = httpContext.query["mediaType"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler - val audioExecutor = _audioExecutor; + val audioExecutor = _audioExecutor if (audioExecutor != null) { val data = audioExecutor.executeRequest("GET", originalUrl, null, httpContext.headers) httpContext.respondBytes(200, HttpHeaders().apply { put("Content-Type", mediaType) - }, data); + }, data) } else { throw NotImplementedError() } }.withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castDashRaw"); + ).withTag("castDashRaw") } - Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)."); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed); + Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath).") + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed) return listOf() } @@ -1378,114 +1401,159 @@ class StateCasting { private fun deviceFromCastingDeviceInfo(deviceInfo: CastingDeviceInfo): CastingDevice { return when (deviceInfo.type) { CastProtocolType.CHROMECAST -> { - ChromecastCastingDevice(deviceInfo); + ChromecastCastingDevice(deviceInfo) } CastProtocolType.AIRPLAY -> { - AirPlayCastingDevice(deviceInfo); + AirPlay1CastingDevice(deviceInfo) + } + CastProtocolType.AIRPLAY2 -> { + val device = AirPlay2CastingDevice(deviceInfo, pairingDataHandler) + device.onPairingPinRequired.subscribe { this@StateCasting.onPairingPinRequired.emit(device) } + return device } CastProtocolType.FCAST -> { - FCastCastingDevice(deviceInfo); + FCastCastingDevice(deviceInfo) } } } - private fun addOrUpdateChromeCastDevice(name: String, addresses: Array, port: Int) { + private fun addOrUpdateChromeCastDevice(name: String, addresses: Array, port: Int, attrs: Map) { return addOrUpdateCastDevice(name, deviceFactory = { ChromecastCastingDevice(name, addresses, port) }, deviceUpdater = { d -> if (d.isReady) { - return@addOrUpdateCastDevice false; + return@addOrUpdateCastDevice false } - val changed = addresses.contentEquals(d.addresses) || d.name != name || d.port != port; + val changed = addresses.contentEquals(d.addresses) || d.name != name || d.port != port if (changed) { - d.name = name; - d.addresses = addresses; - d.port = port; + d.name = name + d.addresses = addresses + d.port = port } - return@addOrUpdateCastDevice changed; + return@addOrUpdateCastDevice changed } - ); + ) } - private fun addOrUpdateAirPlayDevice(name: String, addresses: Array, port: Int) { - return addOrUpdateCastDevice(name, - deviceFactory = { AirPlayCastingDevice(name, addresses, port) }, - deviceUpdater = { d -> - if (d.isReady) { - return@addOrUpdateCastDevice false; - } + private fun addOrUpdateAirPlayDevice(name: String, addresses: Array, port: Int, attrs: Map) { + val txtMap: Map = attrs.mapValues { (_, v) -> + String(v, Charsets.UTF_8) + } - val changed = addresses.contentEquals(addresses) || d.name != name || d.port != port; - if (changed) { - d.name = name; - d.port = port; - d.addresses = addresses; - } - - return@addOrUpdateCastDevice changed; + val srcversString: String? = txtMap["srcvers"] + val isAirPlay2: Boolean = srcversString?.let { + try { + val major = it.split('.', limit = 2)[0].toIntOrNull() ?: 0 + major >= 200 + } catch (e: Exception) { + false } - ); + } ?: false + + if (isAirPlay2) { + addOrUpdateCastDevice(name, + deviceFactory = { + val device = AirPlay2CastingDevice(name, addresses, port, pairingDataHandler) + device.onPairingPinRequired.subscribe { this@StateCasting.onPairingPinRequired.emit(device) } + return@addOrUpdateCastDevice device + }, + deviceUpdater = { existing -> + if (existing.isReady) return@addOrUpdateCastDevice false + + val changed = !addresses.contentEquals(existing.addresses) || + existing.name != name || + existing.port != port + if (changed) { + existing.name = name + existing.port = port + existing.addresses = addresses + } + changed + } + ) + } else { + addOrUpdateCastDevice(name, + deviceFactory = { AirPlay1CastingDevice(name, addresses, port) }, + deviceUpdater = { existing -> + if (existing.isReady) return@addOrUpdateCastDevice false + + val changed = !addresses.contentEquals(existing.addresses) || + existing.name != name || + existing.port != port + if (changed) { + existing.name = name + existing.port = port + existing.addresses = addresses + } + changed + } + ) + } } - private fun addOrUpdateFastCastDevice(name: String, addresses: Array, port: Int) { + private fun addOrUpdateFastCastDevice(name: String, addresses: Array, port: Int, attrs: Map) { return addOrUpdateCastDevice(name, deviceFactory = { FCastCastingDevice(name, addresses, port) }, deviceUpdater = { d -> if (d.isReady) { - return@addOrUpdateCastDevice false; + return@addOrUpdateCastDevice false } - val changed = addresses.contentEquals(addresses) || d.name != name || d.port != port; + val changed = addresses.contentEquals(addresses) || d.name != name || d.port != port if (changed) { - d.name = name; - d.port = port; - d.addresses = addresses; + d.name = name + d.port = port + d.addresses = addresses } - return@addOrUpdateCastDevice changed; + return@addOrUpdateCastDevice changed } - ); + ) } private inline fun addOrUpdateCastDevice(name: String, deviceFactory: () -> TCastDevice, deviceUpdater: (device: TCastDevice) -> Boolean) where TCastDevice : CastingDevice { - var invokeEvents: (() -> Unit)? = null; + var invokeEvents: (() -> Unit)? = null synchronized(devices) { - val device = devices[name]; - if (device != null) { - if (device !is TCastDevice) { - Logger.w(TAG, "Device name conflict between device types. Ignoring device."); + val existingDevice = devices[name] + if (existingDevice != null) { + if (existingDevice::class != TCastDevice::class) { + Logger.w(TAG, "Device name conflict detected. Replacing device.") + val newDevice = deviceFactory() + devices[name] = newDevice + invokeEvents = { + onDeviceRemoved.emit(existingDevice) + onDeviceAdded.emit(newDevice) + } } else { - val changed = deviceUpdater(device as TCastDevice); + val changed = deviceUpdater(existingDevice as TCastDevice) if (changed) { invokeEvents = { - onDeviceChanged.emit(device); + onDeviceChanged.emit(existingDevice) } - } else { - } } } else { - val newDevice = deviceFactory(); - this.devices[name] = newDevice; + val newDevice = deviceFactory() + this.devices[name] = newDevice invokeEvents = { - onDeviceAdded.emit(newDevice); - }; + onDeviceAdded.emit(newDevice) + } } } - invokeEvents?.let { _scopeMain.launch { it(); }; }; + invokeEvents?.let { _scopeMain.launch { it() } } } fun enableDeveloper(enableDev: Boolean){ - _castServer.removeAllHandlers("dev"); + _castServer.removeAllHandlers("dev") if(enableDev) { _castServer.addHandler(HttpFunctionHandler("GET", "/dashPlayer") { context -> if (context.query.containsKey("dashUrl")) { - val dashUrl = context.query["dashUrl"]; + val dashUrl = context.query["dashUrl"] val html = "
\n" + " \n" + @@ -1495,15 +1563,15 @@ class StateCasting { " \n" + - "
"; - context.respondCode(200, html, "text/html"); + "" + context.respondCode(200, html, "text/html") } - }).withTag("dev"); + }).withTag("dev") } } @@ -1521,12 +1589,12 @@ class StateCasting { ) companion object { - val instance: StateCasting = StateCasting(); + val instance: StateCasting = StateCasting() private val representationRegex = Regex("(.*?)<\\/Representation>", RegexOption.DOT_MATCHES_ALL) - private val mediaInitializationRegex = Regex("(media|initiali[sz]ation)=\"([^\"]+)\"", RegexOption.DOT_MATCHES_ALL); + private val mediaInitializationRegex = Regex("(media|initiali[sz]ation)=\"([^\"]+)\"", RegexOption.DOT_MATCHES_ALL) - private val TAG = "StateCasting"; + private val TAG = "StateCasting" } } diff --git a/app/src/main/java/com/futo/platformplayer/casting/TLV8.kt b/app/src/main/java/com/futo/platformplayer/casting/TLV8.kt new file mode 100644 index 00000000..5617d8b5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/casting/TLV8.kt @@ -0,0 +1,187 @@ +package com.futo.platformplayer.casting + +import com.futo.platformplayer.logging.Logger +import java.io.ByteArrayOutputStream + +enum class TLV8Tag(val value: UByte) { + METHOD(0u), + IDENTIFIER(1u), + SALT(2u), + PUBLIC_KEY(3u), + PROOF(4u), + ENCRYPTED_DATA(5u), + STATE(6u), + ERROR(7u), + RETRY_DELAY(8u), + CERTIFICATE(9u), + SIGNATURE(0x0Au), + PERMISSIONS(0x0Bu), + FRAGMENT_DATA(0x0Cu), + FRAGMENT_LAST(0x0Du), + FLAGS(0x13u), + SEPARATOR(0xFFu) +} + +data class TLV8Item(val tag: TLV8Tag, val value: UByteArray) { + override fun toString(): String { + val tagHex = "%02X".format(tag.value.toInt()) + val dataHex = value.joinToString(" ") { "%02X".format(it.toInt()) } + return "${tag.name}(0x$tagHex): $dataHex" + } + + companion object { + private const val TAG = "AirPlayTLV8" + private const val FRAGMENT_THRESHOLD = 0xFF + + fun decodeAndReassembleWithLogging(data: UByteArray): Map { + val items = decode(data) + Logger.i(TAG, "Raw TLV8 items:\n" + items.joinToString("\n") { it.toString() }) + + val fields = items + .groupBy { it.tag } + .mapValues { (_, chunk) -> + chunk.fold(UByteArray(0)) { acc, item -> acc + item.value }.toByteArray() + } + + Logger.i(TAG, "Reassembled TLV8 fields:\n" + + fields.entries.joinToString("\n") { (tag, bytes) -> + "%-12s: %s".format(tag.name, + bytes.joinToString(" ") { "%02X".format(it) }) + } + ) + + return fields + } + + fun decodeAsString(data: UByteArray): String { + return decode(data).joinToString("\n") { it.toString() } + } + + fun itemsToString(items: List): String = items.joinToString(separator = "\n") { it.toString() } + + fun encodeWithLogging(items: List, useFragmentData: Boolean = false): ByteArray { + Logger.i(TAG, "Assembled TLV8 items:\n" + itemsToString(items)) + + val fragments = if (useFragmentData) fragmentStandard(items) else fragmentRepeat(items) + Logger.i(TAG, "Split TLV8 items:\n" + itemsToString(fragments)) + + val out = ByteArrayOutputStream() + fragments.forEach { frag -> + val data = frag.value.asByteArray() + out.write(frag.tag.value.toInt()) + out.write(data.size) + out.write(data) + } + val encoded = out.toByteArray() + val hexStream = encoded.joinToString(" ") { "%02X".format(it) } + Logger.i(TAG, "Final TLV8 byte stream (${encoded.size} bytes):\n$hexStream") + + return encoded + } + + private fun fragmentStandard(items: List): List { + val frags = mutableListOf() + items.forEach { item -> + val bytes = item.value.asByteArray() + if (bytes.size <= FRAGMENT_THRESHOLD) { + frags += item + } else { + var offset = 0 + // first fragment with original tag + frags += TLV8Item(item.tag, bytes.copyOfRange(0, FRAGMENT_THRESHOLD).toUByteArray()) + offset += FRAGMENT_THRESHOLD + + // middle fragments + while (bytes.size - offset > FRAGMENT_THRESHOLD) { + frags += TLV8Item( + TLV8Tag.FRAGMENT_DATA, + bytes.copyOfRange(offset, offset + FRAGMENT_THRESHOLD).toUByteArray() + ) + offset += FRAGMENT_THRESHOLD + } + + // last fragment + val rem = bytes.size - offset + frags += TLV8Item( + TLV8Tag.FRAGMENT_LAST, + bytes.copyOfRange(offset, offset + rem).toUByteArray() + ) + } + } + return frags + } + + private fun fragmentRepeat(items: List): List { + val frags = mutableListOf() + items.forEach { item -> + val bytes = item.value.asByteArray() + var offset = 0 + while (offset < bytes.size) { + val chunk = minOf(FRAGMENT_THRESHOLD, bytes.size - offset) + frags += TLV8Item( + item.tag, + bytes.copyOfRange(offset, offset + chunk).toUByteArray() + ) + offset += chunk + } + } + return frags + } + + fun decode(data: UByteArray): List { + val items = mutableListOf() + var i = 0 + + while (i < data.size) { + val tagByte = data[i] + val tag = TLV8Tag.values().find { it.value == tagByte } + ?: throw IllegalArgumentException("Unknown tag 0x${tagByte.toString(16)} at offset $i") + if (i + 1 >= data.size) { + throw IllegalArgumentException("Truncated TLV: no length byte for tag $tag at offset $i") + } + + val length = data[i + 1].toInt() and 0xFF + i += 2 + if (i + length > data.size) { + throw IllegalArgumentException("Truncated TLV: declared length $length exceeds available bytes (${data.size - i})") + } + + var value = data.copyOfRange(i, i + length) + i += length + + if (length == FRAGMENT_THRESHOLD && i < data.size) { + val nextTag = data[i] + if (nextTag == TLV8Tag.FRAGMENT_DATA.value || + nextTag == TLV8Tag.FRAGMENT_LAST.value + ) { + while (true) { + if (i + 2 > data.size) { + throw IllegalArgumentException("Truncated fragment header at offset $i") + } + val fragTagByte = data[i] + val fragTag = TLV8Tag.values().find { it.value == fragTagByte } + ?: throw IllegalArgumentException("Unknown fragment tag 0x${fragTagByte.toString(16)} at offset $i") + val fragLen = data[i + 1].toInt() and 0xFF + i += 2 + if (i + fragLen > data.size) { + throw IllegalArgumentException("Truncated fragment: declared length $fragLen exceeds available bytes (${data.size - i})") + } + val fragData = data.copyOfRange(i, i + fragLen) + value += fragData + i += fragLen + + if (fragTag == TLV8Tag.FRAGMENT_LAST) break + if (fragTag != TLV8Tag.FRAGMENT_DATA) { + throw IllegalArgumentException("Unexpected tag $fragTag in fragment sequence") + } + } + } + } + + items += TLV8Item(tag, value) + } + + return items + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt index f00bd191..b6e2ed61 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -121,7 +121,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { } StateCasting.instance.onDeviceChanged.subscribe(this) { d -> - val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name } + val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name && it.castingDevice.protocol == d.protocol } if (index != -1) { _unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice) _adapter.notifyItemChanged(index) @@ -163,20 +163,14 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { override fun getOldListSize() = oldList.size override fun getNewListSize() = newList.size override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val oldItem = oldList[oldItemPosition] - val newItem = newList[newItemPosition] - return oldItem.castingDevice.name == newItem.castingDevice.name - && oldItem.castingDevice.isReady == newItem.castingDevice.isReady - && oldItem.isOnlineDevice == newItem.isOnlineDevice - && oldItem.isPinnedDevice == newItem.isPinnedDevice + return oldList[oldItemPosition].castingDevice.name == newList[newItemPosition].castingDevice.name && oldList[oldItemPosition].castingDevice.protocol == newList[newItemPosition].castingDevice.protocol } + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { val oldItem = oldList[oldItemPosition] val newItem = newList[newItemPosition] - return oldItem.castingDevice.name == newItem.castingDevice.name - && oldItem.castingDevice.isReady == newItem.castingDevice.isReady - && oldItem.isOnlineDevice == newItem.isOnlineDevice - && oldItem.isPinnedDevice == newItem.isPinnedDevice + + return oldItem == newItem } }) diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt index 862f8333..b8278f71 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt @@ -13,7 +13,8 @@ import android.widget.LinearLayout import android.widget.TextView import com.futo.platformplayer.R import com.futo.platformplayer.activities.MainActivity -import com.futo.platformplayer.casting.AirPlayCastingDevice +import com.futo.platformplayer.casting.AirPlay1CastingDevice +import com.futo.platformplayer.casting.AirPlay2CastingDevice import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.ChromecastCastingDevice @@ -175,9 +176,12 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { if (d is ChromecastCastingDevice) { _imageDevice.setImageResource(R.drawable.ic_chromecast); _textType.text = "Chromecast"; - } else if (d is AirPlayCastingDevice) { + } else if (d is AirPlay1CastingDevice) { _imageDevice.setImageResource(R.drawable.ic_airplay); _textType.text = "AirPlay"; + } else if (d is AirPlay2CastingDevice) { + _imageDevice.setImageResource(R.drawable.airplay_audio_logo); + _textType.text = "AirPlay 2"; } else if (d is FCastCastingDevice) { _imageDevice.setImageResource(R.drawable.ic_fc); _textType.text = "FastCast"; diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/PairingCodeDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/PairingCodeDialog.kt new file mode 100644 index 00000000..e50f6473 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/dialogs/PairingCodeDialog.kt @@ -0,0 +1,70 @@ +package com.futo.platformplayer.dialogs + +import android.app.AlertDialog +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs + +class PairingCodeDialog(context: Context?, private val onSubmit: (code: String) -> Unit) : AlertDialog(context) { + private lateinit var _editPairingCode: EditText + private lateinit var _textError: TextView + private lateinit var _buttonSubmit: LinearLayout + private lateinit var _buttonCancel: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_pairing_code, null)) + + _editPairingCode = findViewById(R.id.edit_pairing_code) + _textError = findViewById(R.id.text_error) + _buttonSubmit = findViewById(R.id.button_submit) + _buttonCancel = findViewById(R.id.button_cancel) + + setTitle("Enter Pairing Code") + + _buttonCancel.setOnClickListener { + performDismiss() + } + + _buttonSubmit.setOnClickListener { + val code = _editPairingCode.text.toString().trim() + if (code.isBlank()) { + _textError.text = "Pairing code cannot be empty." + _textError.visibility = View.VISIBLE + return@setOnClickListener + } + + _textError.visibility = View.GONE + onSubmit(code) + performDismiss() + } + } + + override fun show() { + super.show() + + _editPairingCode.text.clear() + _textError.visibility = View.GONE + + window?.apply { + clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) + setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + } + } + + private fun performDismiss() { + dismiss() + } + + companion object { + private val TAG = "PairingCodeDialog" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt b/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt index a530e415..651efa4f 100644 --- a/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt +++ b/app/src/main/java/com/futo/platformplayer/models/CastingDeviceInfo.kt @@ -4,15 +4,15 @@ import com.futo.platformplayer.casting.CastProtocolType @kotlinx.serialization.Serializable class CastingDeviceInfo { - var name: String; - var type: CastProtocolType; - var addresses: Array; - var port: Int; + var name: String + var type: CastProtocolType + var addresses: Array + var port: Int constructor(name: String, type: CastProtocolType, addresses: Array, port: Int) { - this.name = name; - this.type = type; - this.addresses = addresses; - this.port = port; + this.name = name + this.type = type + this.addresses = addresses + this.port = port } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt index 15c91025..8584eda4 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt @@ -17,8 +17,12 @@ import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.HistoryVideo +import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.services.MediaPlaybackService +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.v2.ReconstructStore import com.futo.platformplayer.video.PlayerManager import kotlin.random.Random @@ -113,6 +117,8 @@ class StatePlayer { var currentVideo: IPlatformVideoDetails? = null private set; + private val _lastQueue = FragmentedStorage.storeJson("lastQueue").load(); + fun setCurrentlyPlaying(video: IPlatformVideoDetails?) { currentVideo = video; } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt index 133dd26b..ba57caab 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt @@ -4,21 +4,20 @@ import android.graphics.drawable.Animatable import android.view.View import android.widget.FrameLayout import android.widget.ImageView -import android.widget.LinearLayout import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.RecyclerView.ViewHolder import com.futo.platformplayer.R -import com.futo.platformplayer.casting.AirPlayCastingDevice +import com.futo.platformplayer.casting.AirPlay1CastingDevice import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.ChromecastCastingDevice import com.futo.platformplayer.casting.FCastCastingDevice import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.constructs.Event2 import androidx.core.view.isVisible import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.casting.AirPlay2CastingDevice class DeviceViewHolder : ViewHolder { private val _layoutDevice: FrameLayout; @@ -84,9 +83,12 @@ class DeviceViewHolder : ViewHolder { if (d is ChromecastCastingDevice) { _imageDevice.setImageResource(R.drawable.ic_chromecast); _textType.text = "Chromecast"; - } else if (d is AirPlayCastingDevice) { + } else if (d is AirPlay1CastingDevice) { _imageDevice.setImageResource(R.drawable.ic_airplay); _textType.text = "AirPlay"; + } else if (d is AirPlay2CastingDevice) { + _imageDevice.setImageResource(R.drawable.airplay_audio_logo); + _textType.text = "AirPlay 2"; } else if (d is FCastCastingDevice) { _imageDevice.setImageResource(R.drawable.ic_fc); _textType.text = "FCast"; diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt index 91631f1e..aa0ee54c 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt @@ -20,7 +20,7 @@ import com.bumptech.glide.Glide import com.futo.platformplayer.R import com.futo.platformplayer.api.media.models.chapters.IChapter import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails -import com.futo.platformplayer.casting.AirPlayCastingDevice +import com.futo.platformplayer.casting.AirPlay1CastingDevice import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event2 @@ -189,7 +189,7 @@ class CastView : ConstraintLayout { if(isPlaying) { val d = StateCasting.instance.activeDevice; - if (d is AirPlayCastingDevice) { + if (d is AirPlay1CastingDevice) { _updateTimeJob = _scope.launch { while (true) { val device = StateCasting.instance.activeDevice; diff --git a/app/src/main/res/layout/dialog_pairing_code.xml b/app/src/main/res/layout/dialog_pairing_code.xml new file mode 100644 index 00000000..4a57d95b --- /dev/null +++ b/app/src/main/res/layout/dialog_pairing_code.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dep/futopay b/dep/futopay index 3e99ed52..224d6976 160000 --- a/dep/futopay +++ b/dep/futopay @@ -1 +1 @@ -Subproject commit 3e99ed522a16300874e931bbcb86899aaebf1013 +Subproject commit 224d69764c238c80cc21280be03b283eebbf6757