Initial implementation of AirPlay2 pairing.

This commit is contained in:
Koen J
2025-06-24 13:05:17 +02:00
parent 43887586b5
commit 4c0eceaa8e
21 changed files with 2312 additions and 578 deletions
+5 -1
View File
@@ -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'
@@ -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 lastfragment 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 isnt 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)
}
}
@@ -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
}
}*/
@@ -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
}
}*/
@@ -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” bigendian representation.
*/
fun ByteArray.stripLeadingZero(): ByteArray {
return if (this.size > 1 && this[0] == 0.toByte()) {
this.copyOfRange(1, this.size)
} else {
this
}
}
@@ -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();
}
@@ -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<InetAddress>? = null;
var port: Int = 0;
var addresses: Array<InetAddress>? = null
var port: Int = 0
private var _scopeIO: CoroutineScope? = null;
private var _started: Boolean = false;
private var _sessionId: String? = null;
private val _client = ManagedHttpClient();
private var _scopeIO: CoroutineScope? = null
private var _started: Boolean = false
private var _sessionId: String? = null
private val _client = ManagedHttpClient()
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
this.addresses = addresses;
this.port = port;
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<InetAddress> {
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"
}
}
@@ -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<InetAddress>? = 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<InetAddress>, 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<InetAddress> = 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<TLV8Tag, ByteArray>) {
_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<TLV8Tag, ByteArray>) {
_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<TLV8Tag, ByteArray>) {
_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<TLV8Tag, ByteArray>) {
_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<String, String>?): 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<ByteArray, ByteArray> {
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)
}
@@ -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<CastProtocolType> {
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<CastConnectionState>();
var onPlayChanged = Event1<Boolean>();
var onTimeChanged = Event1<Double>();
var onDurationChanged = Event1<Double>();
var onVolumeChanged = Event1<Double>();
var onSpeedChanged = Event1<Double>();
var onPairingPinRequired = Event0()
open fun providePairingPin(pin: String?) { throw NotImplementedError() }
abstract fun stopCasting();
var onConnectionStateChanged = Event1<CastConnectionState>()
var onPlayChanged = Event1<Boolean>()
var onTimeChanged = Event1<Double>()
var onDurationChanged = Event1<Double>()
var onVolumeChanged = Event1<Double>()
var onSpeedChanged = Event1<Double>()
abstract fun 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<InetAddress>;
abstract fun getAddresses(): List<InetAddress>
}
@@ -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<BigInteger, BigInteger, ByteArray> {
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)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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<TLV8Tag, ByteArray> {
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<TLV8Item>): String = items.joinToString(separator = "\n") { it.toString() }
fun encodeWithLogging(items: List<TLV8Item>, 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<TLV8Item>): List<TLV8Item> {
val frags = mutableListOf<TLV8Item>()
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<TLV8Item>): List<TLV8Item> {
val frags = mutableListOf<TLV8Item>()
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<TLV8Item> {
val items = mutableListOf<TLV8Item>()
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
}
}
}
@@ -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
}
})
@@ -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";
@@ -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"
}
}
@@ -4,15 +4,15 @@ import com.futo.platformplayer.casting.CastProtocolType
@kotlinx.serialization.Serializable
class CastingDeviceInfo {
var name: String;
var type: CastProtocolType;
var addresses: Array<String>;
var port: Int;
var name: String
var type: CastProtocolType
var addresses: Array<String>
var port: Int
constructor(name: String, type: CastProtocolType, addresses: Array<String>, 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
}
}
@@ -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<SerializedPlatformVideo>("lastQueue").load();
fun setCurrentlyPlaying(video: IPlatformVideoDetails?) {
currentVideo = video;
}
@@ -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";
@@ -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;
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:id="@+id/text_instructions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Please enter the pairing code displayed on your device. If no PIN is required, tap cancel."
android:textSize="16sp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular" />
<EditText
android:id="@+id/edit_pairing_code"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Pairing Code"
android:inputType="text"
android:singleLine="true"
android:layout_marginTop="12dp" />
<TextView
android:id="@+id/text_error"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="@color/pastel_red"
android:fontFamily="@font/inter_regular"
android:visibility="gone"
android:layout_marginTop="5dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="end"
android:layout_marginTop="12dp">
<TextView
android:id="@+id/button_cancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Cancel"
android:textSize="14sp"
android:textColor="@color/colorPrimary"
android:fontFamily="@font/inter_regular"
android:padding="10dp" />
<LinearLayout
android:id="@+id/button_submit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_button_primary"
android:layout_marginStart="28dp"
android:clickable="true">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Submit"
android:textSize="14sp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
android:paddingStart="28dp"
android:paddingEnd="28dp"
android:paddingTop="10dp"
android:paddingBottom="10dp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>