mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-04-20 19:44:48 +00:00
Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay
This commit is contained in:
commit
99c028de28
8 changed files with 657 additions and 307 deletions
|
@ -0,0 +1,111 @@
|
|||
package com.futo.platformplayer
|
||||
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.futo.platformplayer.casting.FCastCastingDevice
|
||||
import com.futo.platformplayer.casting.Opcode
|
||||
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastKeyExchangeMessage
|
||||
import com.futo.platformplayer.casting.models.FCastPlayMessage
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.security.KeyFactory
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FCastEncryptionTests {
|
||||
@Test
|
||||
fun testDHEncryptionSelf() {
|
||||
val keyPair1 = FCastCastingDevice.generateKeyPair()
|
||||
val keyPair2 = FCastCastingDevice.generateKeyPair()
|
||||
Log.i("testDHEncryptionSelf", "privates (1: ${Base64.encodeToString(keyPair1.private.encoded, Base64.NO_WRAP)}, 2: ${Base64.encodeToString(keyPair2.private.encoded, Base64.NO_WRAP)})")
|
||||
|
||||
val keyExchangeMessage1 = FCastCastingDevice.getKeyExchangeMessage(keyPair1)
|
||||
val keyExchangeMessage2 = FCastCastingDevice.getKeyExchangeMessage(keyPair2)
|
||||
Log.i("testDHEncryptionSelf", "publics (1: ${keyExchangeMessage1.publicKey}, 2: ${keyExchangeMessage2.publicKey})")
|
||||
|
||||
val aesKey1 = FCastCastingDevice.computeSharedSecret(keyPair1.private, keyExchangeMessage2)
|
||||
val aesKey2 = FCastCastingDevice.computeSharedSecret(keyPair2.private, keyExchangeMessage1)
|
||||
|
||||
assertEquals(Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP), Base64.encodeToString(aesKey2.encoded, Base64.NO_WRAP))
|
||||
Log.i("testDHEncryptionSelf", "aesKey ${Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP)}")
|
||||
|
||||
val message = FCastPlayMessage("text/html")
|
||||
val serializedBody = Json.encodeToString(message)
|
||||
val encryptedMessage = FCastCastingDevice.encryptMessage(aesKey1, FCastDecryptedMessage(Opcode.Play.value.toLong(), serializedBody))
|
||||
Log.i("testDHEncryptionSelf", Json.encodeToString(encryptedMessage))
|
||||
|
||||
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey1, encryptedMessage)
|
||||
|
||||
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
|
||||
assertEquals(serializedBody, decryptedMessage.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAESKeyGeneration() {
|
||||
val cases = listOf(
|
||||
listOf(
|
||||
//Public other
|
||||
"MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgEnOS0oHteVA+3kND3u4yXe7GGRohy1LkR9Q5tL4c4ylC5n4iSwWSoIhcSIvUMWth6KAhPhu05sMcPY74rFMSS2AGTNCdT/5KilediipuUMdFVvjGqfNMNH1edzW5mquIw3iXKdfQmfY/qxLTI2wccyDj4hHFhLCZL3Y+shsm3KF",
|
||||
//Private self
|
||||
"MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAeo/ceIeH8Jt1ZRNKX5aTHkMi23GCV1LtcS2O6Tktn9k8DCv7gIoekysQUhMyWtR+MsZlq2mXjr1JFpAyxl89rqoEPU6QDsGe9q8R4O8eBZ2u+48mkUkGSh7xPGRQUBvmhH2yk4hIEA8aK4BcYi1OTsCZtmk7pQq+uaFkKovD/8M=",
|
||||
//AES
|
||||
"7dpl1/6KQTTooOrFf2VlUOSqgrFHi6IYxapX0IxFfwk="
|
||||
),
|
||||
listOf(
|
||||
//Public other
|
||||
"MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgGvIlCP/S+xpAuNEHSn4cEDOL1esUf+uMuY2Kp5J10a7HGbwzNd+7eYsgEc4+adddgB7hJgTvjsGg7lXUhHQ7WbfbCGgt7dbkx8qkic6Rgq4f5eRYd1Cgidw4MhZt7mEIOKrHweqnV6B9rypbXjbqauc6nGgtwx+Gvl6iLpVATRK",
|
||||
//Private self
|
||||
"MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAMXmiIgWyutbaO+f4UiMAb09iVVSCI6Lb6xzNyD2MpUZyk4/JOT04Daj4JeCKFkF1Fq79yKhrnFlXCrF4WFX00xUOXb8BpUUUH35XG5ApvolQQLL6N0om8/MYP4FK/3PUxuZAJz45TUsI/v3u6UqJelVTNL83ltcFbZDIfEVftRA=",
|
||||
//AES
|
||||
"a2tUSxnXifKohfNocAQHkAlPffDv6ReihJ7OojBGt0Q="
|
||||
)
|
||||
)
|
||||
|
||||
for (case in cases) {
|
||||
val decodedPrivateKey1 = Base64.decode(case[1], Base64.NO_WRAP)
|
||||
val keyExchangeMessage2 = FCastKeyExchangeMessage(1, case[0])
|
||||
|
||||
val keyFactory = KeyFactory.getInstance("DH")
|
||||
val privateKeySpec = PKCS8EncodedKeySpec(decodedPrivateKey1)
|
||||
val privateKey = keyFactory.generatePrivate(privateKeySpec)
|
||||
val aesKey1 = FCastCastingDevice.computeSharedSecret(privateKey, keyExchangeMessage2)
|
||||
assertEquals(case[2], Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDHEncryptionKnown() {
|
||||
val decodedPrivateKey1 = Base64.decode("MIIDJwIBADCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egSCAQQCggEAECNvEczf0y6IoX/IwhrPeWZ5IxrHcpwjcdVAuyZQLLlOq0iqnYMFcSD8QjMF8NKObfZZCDQUJlzGzRsG0oXsWiWtmoRvUZ9tQK0j28hDylpbyP00Bt9NlMgeHXkAy54P7Z2v/BPCd3o23kzjgXzYaSRuCFY7zQo1g1IQG8mfjYjdE4jjRVdVrlh8FS8x4OLPeglc+cp2/kuyxaVEfXAG84z/M8019mRSfdczi4z1iidPX6HgDEEWsN42Ud60mNKy5jsQpQYkRdOLmxR3+iQEtGFjdzbVhVCUr7S5EORU9B1MOl5gyPJpjfU3baOqrg6WXVyTvMDaA05YEnAHQNOOfA==", Base64.NO_WRAP)
|
||||
val keyExchangeMessage2 = FCastKeyExchangeMessage(1, "MIIDJTCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egOCAQUAAoIBAGlL9EYsrFz3I83NdlwhM241M+M7PA9P5WXgtdvS+pcalIaqN2IYdfzzCUfye7lchVkT9A2Y9eWQYX0OUhmjf8PPKkRkATLXrqO5HTsxV96aYNxMjz5ipQ6CaErTQaPLr3OPoauIMPVVI9zM+WT0KOGp49YMyx+B5rafT066vOVbF/0z1crq0ZXxyYBUv135rwFkIHxBMj5bhRLXKsZ2G5aLAZg0DsVam104mgN/v75f7Spg/n5hO7qxbNgbvSrvQ7Ag/rMk5T3sk7KoM23Qsjl08IZKs2jjx21MiOtyLqGuCW6GOTNK4yEEDF5gA0K13eXGwL5lPS0ilRw+Lrw7cJU=")
|
||||
|
||||
val keyFactory = KeyFactory.getInstance("DH")
|
||||
val privateKeySpec = PKCS8EncodedKeySpec(decodedPrivateKey1)
|
||||
val privateKey = keyFactory.generatePrivate(privateKeySpec)
|
||||
val aesKey1 = FCastCastingDevice.computeSharedSecret(privateKey, keyExchangeMessage2)
|
||||
assertEquals("vI5LGE625zGEG350ggkyBsIAXm2y4sNohiPcED1oAEE=", Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP))
|
||||
|
||||
val message = FCastPlayMessage("text/html")
|
||||
val serializedBody = Json.encodeToString(message)
|
||||
val encryptedMessage = FCastCastingDevice.encryptMessage(aesKey1, FCastDecryptedMessage(Opcode.Play.value.toLong(), serializedBody))
|
||||
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey1, encryptedMessage)
|
||||
|
||||
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
|
||||
assertEquals(serializedBody, decryptedMessage.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecryptMessageKnown() {
|
||||
val encryptedMessage = Json.decodeFromString<FCastEncryptedMessage>("{\"version\":1,\"iv\":\"C4H70VC5FWrNtkty9/cLIA==\",\"blob\":\"K6/N7JMyi1PFwKhU0mFj7ZJmd/tPp3NCOMldmQUtDaQ7hSmPoIMI5QNMOj+NFEiP4qTgtYp5QmBPoQum6O88pA==\"}")
|
||||
val aesKey = SecretKeySpec(Base64.decode("+hr9Jg8yre7S9WGUohv2AUSzHNQN514JPh6MoFAcFNU=", Base64.NO_WRAP), "AES")
|
||||
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey, encryptedMessage)
|
||||
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
|
||||
assertEquals("{\"container\":\"text/html\"}", decryptedMessage.message)
|
||||
}
|
||||
}
|
|
@ -50,6 +50,8 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
private var _transportId: String? = null;
|
||||
private var _launching = false;
|
||||
private var _mediaSessionId: Int? = null;
|
||||
private var _thread: Thread? = null;
|
||||
private var _pingThread: Thread? = null;
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
|
@ -270,7 +272,6 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
}
|
||||
|
||||
override fun start() {
|
||||
val adrs = addresses ?: return;
|
||||
if (_started) {
|
||||
return;
|
||||
}
|
||||
|
@ -283,152 +284,167 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
|
||||
_launching = true;
|
||||
|
||||
_scopeIO?.cancel();
|
||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
ensureThreadsStarted();
|
||||
Logger.i(TAG, "Started.");
|
||||
}
|
||||
|
||||
Thread {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
fun ensureThreadsStarted() {
|
||||
val adrs = addresses ?: return;
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val connectedSocket = getConnectedSocket(adrs.toList(), port);
|
||||
if (connectedSocket == null) {
|
||||
val thread = _thread
|
||||
val pingThread = _pingThread
|
||||
if (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive) {
|
||||
Log.i(TAG, "Restarting threads because one of the threads has died")
|
||||
|
||||
_scopeIO?.cancel();
|
||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
|
||||
_thread = Thread {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val connectedSocket = getConnectedSocket(adrs.toList(), port);
|
||||
if (connectedSocket == null) {
|
||||
Thread.sleep(3000);
|
||||
continue;
|
||||
}
|
||||
|
||||
usedRemoteAddress = connectedSocket.inetAddress;
|
||||
localAddress = connectedSocket.localAddress;
|
||||
connectedSocket.close();
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
|
||||
}
|
||||
}
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, trustAllCerts, null);
|
||||
|
||||
val factory = sslContext.socketFactory;
|
||||
|
||||
//Connection loop
|
||||
while (_scopeIO?.isActive == true) {
|
||||
Logger.i(TAG, "Connecting to Chromecast.");
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
_socket = factory.createSocket(usedRemoteAddress, port) as SSLSocket;
|
||||
_socket?.startHandshake();
|
||||
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
|
||||
|
||||
try {
|
||||
_outputStream = DataOutputStream(_socket?.outputStream);
|
||||
_inputStream = DataInputStream(_socket?.inputStream);
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Failed to connect to Chromecast.", e);
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
continue;
|
||||
}
|
||||
|
||||
usedRemoteAddress = connectedSocket.inetAddress;
|
||||
localAddress = connectedSocket.localAddress;
|
||||
connectedSocket.close();
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
|
||||
}
|
||||
}
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, trustAllCerts, null);
|
||||
|
||||
val factory = sslContext.socketFactory;
|
||||
|
||||
//Connection loop
|
||||
while (_scopeIO?.isActive == true) {
|
||||
Logger.i(TAG, "Connecting to Chromecast.");
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
_socket = factory.createSocket(usedRemoteAddress, port) as SSLSocket;
|
||||
_socket?.startHandshake();
|
||||
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
|
||||
localAddress = _socket?.localAddress;
|
||||
|
||||
try {
|
||||
_outputStream = DataOutputStream(_socket?.outputStream);
|
||||
_inputStream = DataInputStream(_socket?.inputStream);
|
||||
val connectObject = JSONObject();
|
||||
connectObject.put("type", "CONNECT");
|
||||
connectObject.put("connType", 0);
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
|
||||
Logger.i(TAG, "Failed to send connect message to Chromecast.", e);
|
||||
_socket?.close();
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
continue;
|
||||
}
|
||||
|
||||
getStatus();
|
||||
|
||||
val buffer = ByteArray(4096);
|
||||
|
||||
Logger.i(TAG, "Started receiving.");
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
val b1 = inputStream.readUnsignedByte();
|
||||
val b2 = inputStream.readUnsignedByte();
|
||||
val b3 = inputStream.readUnsignedByte();
|
||||
val b4 = inputStream.readUnsignedByte();
|
||||
val size = ((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||
inputStream.skip(size.toLong());
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
inputStream.read(buffer, 0, size);
|
||||
|
||||
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
Logger.i(TAG, "Received message: $message");
|
||||
}
|
||||
|
||||
try {
|
||||
handleMessage(message);
|
||||
} catch (e:Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e);
|
||||
}
|
||||
} catch (e: java.net.SocketException) {
|
||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Exception while receiving.", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Failed to connect to Chromecast.", e);
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
continue;
|
||||
}
|
||||
|
||||
localAddress = _socket?.localAddress;
|
||||
Logger.i(TAG, "Stopped connection loop.");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
_thread = null;
|
||||
}.apply { start() };
|
||||
|
||||
try {
|
||||
val connectObject = JSONObject();
|
||||
connectObject.put("type", "CONNECT");
|
||||
connectObject.put("connType", 0);
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to send connect message to Chromecast.", e);
|
||||
_socket?.close();
|
||||
//Start ping loop
|
||||
_pingThread = Thread {
|
||||
Logger.i(TAG, "Started ping loop.")
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
continue;
|
||||
}
|
||||
val pingObject = JSONObject();
|
||||
pingObject.put("type", "PING");
|
||||
|
||||
getStatus();
|
||||
|
||||
val buffer = ByteArray(4096);
|
||||
|
||||
Logger.i(TAG, "Started receiving.");
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
val b1 = inputStream.readUnsignedByte();
|
||||
val b2 = inputStream.readUnsignedByte();
|
||||
val b3 = inputStream.readUnsignedByte();
|
||||
val b4 = inputStream.readUnsignedByte();
|
||||
val size = ((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||
inputStream.skip(size.toLong());
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
inputStream.read(buffer, 0, size);
|
||||
|
||||
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
Logger.i(TAG, "Received message: $message");
|
||||
}
|
||||
|
||||
try {
|
||||
handleMessage(message);
|
||||
} catch (e:Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e);
|
||||
}
|
||||
} catch (e: java.net.SocketException) {
|
||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
||||
break;
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString());
|
||||
Thread.sleep(5000);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Exception while receiving.", e);
|
||||
break;
|
||||
Log.w(TAG, "Failed to send ping.");
|
||||
}
|
||||
}
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped connection loop.");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
}.start();
|
||||
|
||||
//Start ping loop
|
||||
Thread {
|
||||
Logger.i(TAG, "Started ping loop.")
|
||||
|
||||
val pingObject = JSONObject();
|
||||
pingObject.put("type", "PING");
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString());
|
||||
Thread.sleep(5000);
|
||||
} catch (e: Throwable) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped ping loop.");
|
||||
}.start();
|
||||
|
||||
Logger.i(TAG, "Started.");
|
||||
Logger.i(TAG, "Stopped ping loop.");
|
||||
_pingThread = null;
|
||||
}.apply { start() };
|
||||
} else {
|
||||
Log.i(TAG, "Threads still alive, not restarted")
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) {
|
||||
|
@ -593,6 +609,8 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
Logger.i(TAG, "Cancelled scopeIO without open socket.")
|
||||
}
|
||||
|
||||
_pingThread = null;
|
||||
_thread = null;
|
||||
_scopeIO = null;
|
||||
_socket = null;
|
||||
_outputStream = null;
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastKeyExchangeMessage
|
||||
import com.futo.platformplayer.casting.models.FCastPlayMessage
|
||||
import com.futo.platformplayer.casting.models.FCastPlaybackErrorMessage
|
||||
import com.futo.platformplayer.casting.models.FCastPlaybackUpdateMessage
|
||||
|
@ -26,22 +30,44 @@ import kotlinx.serialization.json.Json
|
|||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.IOException
|
||||
import java.math.BigInteger
|
||||
import java.net.InetAddress
|
||||
import java.net.Socket
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyPair
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.MessageDigest
|
||||
import java.security.PrivateKey
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyAgreement
|
||||
import javax.crypto.spec.DHParameterSpec
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
enum class Opcode(val value: Byte) {
|
||||
NONE(0),
|
||||
PLAY(1),
|
||||
PAUSE(2),
|
||||
RESUME(3),
|
||||
STOP(4),
|
||||
SEEK(5),
|
||||
PLAYBACK_UPDATE(6),
|
||||
VOLUME_UPDATE(7),
|
||||
SET_VOLUME(8),
|
||||
PLAYBACK_ERROR(9),
|
||||
SET_SPEED(10),
|
||||
VERSION(11)
|
||||
None(0),
|
||||
Play(1),
|
||||
Pause(2),
|
||||
Resume(3),
|
||||
Stop(4),
|
||||
Seek(5),
|
||||
PlaybackUpdate(6),
|
||||
VolumeUpdate(7),
|
||||
SetVolume(8),
|
||||
PlaybackError(9),
|
||||
SetSpeed(10),
|
||||
Version(11),
|
||||
KeyExchange(12),
|
||||
Encrypted(13),
|
||||
Ping(14),
|
||||
Pong(15),
|
||||
StartEncryption(16);
|
||||
|
||||
companion object {
|
||||
private val _map = entries.associateBy { it.value }
|
||||
fun find(value: Byte): Opcode = _map[value] ?: Opcode.None
|
||||
}
|
||||
}
|
||||
|
||||
class FCastCastingDevice : CastingDevice {
|
||||
|
@ -63,17 +89,26 @@ class FCastCastingDevice : CastingDevice {
|
|||
private var _scopeIO: CoroutineScope? = null;
|
||||
private var _started: Boolean = false;
|
||||
private var _version: Long = 1;
|
||||
private val _keyPair: KeyPair
|
||||
private var _aesKey: SecretKeySpec? = null
|
||||
private val _queuedEncryptedMessages = arrayListOf<FCastEncryptedMessage>()
|
||||
private var _encryptionStarted = false
|
||||
private var _thread: Thread? = null
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
this.addresses = addresses;
|
||||
this.port = port;
|
||||
|
||||
_keyPair = generateKeyPair()
|
||||
}
|
||||
|
||||
constructor(deviceInfo: CastingDeviceInfo) : super() {
|
||||
this.name = deviceInfo.name;
|
||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
|
||||
this.port = deviceInfo.port;
|
||||
|
||||
_keyPair = generateKeyPair()
|
||||
}
|
||||
|
||||
override fun getAddresses(): List<InetAddress> {
|
||||
|
@ -94,7 +129,7 @@ class FCastCastingDevice : CastingDevice {
|
|||
|
||||
setTime(resumePosition);
|
||||
setDuration(duration);
|
||||
sendMessage(Opcode.PLAY, FCastPlayMessage(
|
||||
send(Opcode.Play, FCastPlayMessage(
|
||||
container = contentType,
|
||||
url = contentId,
|
||||
time = resumePosition,
|
||||
|
@ -118,7 +153,7 @@ class FCastCastingDevice : CastingDevice {
|
|||
|
||||
setTime(resumePosition);
|
||||
setDuration(duration);
|
||||
sendMessage(Opcode.PLAY, FCastPlayMessage(
|
||||
send(Opcode.Play, FCastPlayMessage(
|
||||
container = contentType,
|
||||
content = content,
|
||||
time = resumePosition,
|
||||
|
@ -134,7 +169,7 @@ class FCastCastingDevice : CastingDevice {
|
|||
}
|
||||
|
||||
setVolume(volume);
|
||||
sendMessage(Opcode.SET_VOLUME, FCastSetVolumeMessage(volume))
|
||||
send(Opcode.SetVolume, FCastSetVolumeMessage(volume))
|
||||
}
|
||||
|
||||
override fun changeSpeed(speed: Double) {
|
||||
|
@ -143,7 +178,7 @@ class FCastCastingDevice : CastingDevice {
|
|||
}
|
||||
|
||||
setSpeed(speed);
|
||||
sendMessage(Opcode.SET_SPEED, FCastSetSpeedMessage(speed))
|
||||
send(Opcode.SetSpeed, FCastSetSpeedMessage(speed))
|
||||
}
|
||||
|
||||
override fun seekVideo(timeSeconds: Double) {
|
||||
|
@ -151,7 +186,7 @@ class FCastCastingDevice : CastingDevice {
|
|||
return;
|
||||
}
|
||||
|
||||
sendMessage(Opcode.SEEK, FCastSeekMessage(
|
||||
send(Opcode.Seek, FCastSeekMessage(
|
||||
time = timeSeconds
|
||||
));
|
||||
}
|
||||
|
@ -161,7 +196,7 @@ class FCastCastingDevice : CastingDevice {
|
|||
return;
|
||||
}
|
||||
|
||||
sendMessage(Opcode.RESUME);
|
||||
send(Opcode.Resume);
|
||||
}
|
||||
|
||||
override fun pauseVideo() {
|
||||
|
@ -169,7 +204,7 @@ class FCastCastingDevice : CastingDevice {
|
|||
return;
|
||||
}
|
||||
|
||||
sendMessage(Opcode.PAUSE);
|
||||
send(Opcode.Pause);
|
||||
}
|
||||
|
||||
override fun stopVideo() {
|
||||
|
@ -177,7 +212,7 @@ class FCastCastingDevice : CastingDevice {
|
|||
return;
|
||||
}
|
||||
|
||||
sendMessage(Opcode.STOP);
|
||||
send(Opcode.Stop);
|
||||
}
|
||||
|
||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
||||
|
@ -201,7 +236,6 @@ class FCastCastingDevice : CastingDevice {
|
|||
}
|
||||
|
||||
override fun start() {
|
||||
val adrs = addresses ?: return;
|
||||
if (_started) {
|
||||
return;
|
||||
}
|
||||
|
@ -209,123 +243,137 @@ class FCastCastingDevice : CastingDevice {
|
|||
_started = true;
|
||||
Logger.i(TAG, "Starting...");
|
||||
|
||||
_scopeIO?.cancel();
|
||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
ensureThreadStarted();
|
||||
Logger.i(TAG, "Started.");
|
||||
}
|
||||
|
||||
Thread {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
fun ensureThreadStarted() {
|
||||
val adrs = addresses ?: return;
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val connectedSocket = getConnectedSocket(adrs.toList(), port);
|
||||
if (connectedSocket == null) {
|
||||
val thread = _thread
|
||||
if (thread == null || !thread.isAlive) {
|
||||
Log.i(TAG, "Restarting thread because the thread has died")
|
||||
|
||||
_scopeIO?.cancel();
|
||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
|
||||
_thread = Thread {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val connectedSocket = getConnectedSocket(adrs.toList(), port);
|
||||
if (connectedSocket == null) {
|
||||
Thread.sleep(3000);
|
||||
continue;
|
||||
}
|
||||
|
||||
usedRemoteAddress = connectedSocket.inetAddress;
|
||||
localAddress = connectedSocket.localAddress;
|
||||
connectedSocket.close();
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(ChromecastCastingDevice.TAG, "Failed to get setup initial connection to FastCast device.", e)
|
||||
}
|
||||
}
|
||||
|
||||
//Connection loop
|
||||
while (_scopeIO?.isActive == true) {
|
||||
Logger.i(TAG, "Connecting to FastCast.");
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
try {
|
||||
_socket = Socket(usedRemoteAddress, port);
|
||||
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
|
||||
|
||||
_outputStream = DataOutputStream(_socket?.outputStream);
|
||||
_inputStream = DataInputStream(_socket?.inputStream);
|
||||
} catch (e: IOException) {
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Failed to connect to FastCast.", e);
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
continue;
|
||||
}
|
||||
|
||||
usedRemoteAddress = connectedSocket.inetAddress;
|
||||
localAddress = connectedSocket.localAddress;
|
||||
connectedSocket.close();
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(ChromecastCastingDevice.TAG, "Failed to get setup initial connection to FastCast device.", e)
|
||||
}
|
||||
}
|
||||
localAddress = _socket?.localAddress;
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
|
||||
//Connection loop
|
||||
while (_scopeIO?.isActive == true) {
|
||||
Logger.i(TAG, "Connecting to FastCast.");
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Logger.i(TAG, "Sending KeyExchange.")
|
||||
send(Opcode.KeyExchange, getKeyExchangeMessage(_keyPair))
|
||||
|
||||
try {
|
||||
_socket = Socket(usedRemoteAddress, port);
|
||||
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
|
||||
val buffer = ByteArray(4096);
|
||||
|
||||
Logger.i(TAG, "Started receiving.");
|
||||
var exceptionOccurred = false;
|
||||
while (_scopeIO?.isActive == true && !exceptionOccurred) {
|
||||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
val b1 = inputStream.readUnsignedByte();
|
||||
val b2 = inputStream.readUnsignedByte();
|
||||
val b3 = inputStream.readUnsignedByte();
|
||||
val b4 = inputStream.readUnsignedByte();
|
||||
val size = ((b4.toLong() shl 24) or (b3.toLong() shl 16) or (b2.toLong() shl 8) or b1.toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||
inputStream.skip(size.toLong());
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
inputStream.read(buffer, 0, size);
|
||||
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
|
||||
val opcode = messageBytes[0];
|
||||
var json: String? = null;
|
||||
if (size > 1) {
|
||||
json = messageBytes.sliceArray(IntRange(1, size - 1)).decodeToString();
|
||||
}
|
||||
|
||||
try {
|
||||
handleMessage(Opcode.find(opcode), json);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e);
|
||||
}
|
||||
} catch (e: java.net.SocketException) {
|
||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
||||
exceptionOccurred = true;
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Exception while receiving.", e);
|
||||
exceptionOccurred = true;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
_outputStream = DataOutputStream(_socket?.outputStream);
|
||||
_inputStream = DataInputStream(_socket?.inputStream);
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to authenticate to FastCast.", e);
|
||||
Logger.e(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Failed to connect to FastCast.", e);
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
continue;
|
||||
}
|
||||
|
||||
localAddress = _socket?.localAddress;
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
|
||||
val buffer = ByteArray(4096);
|
||||
|
||||
Logger.i(TAG, "Started receiving.");
|
||||
var exceptionOccurred = false;
|
||||
while (_scopeIO?.isActive == true && !exceptionOccurred) {
|
||||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
val b1 = inputStream.readUnsignedByte();
|
||||
val b2 = inputStream.readUnsignedByte();
|
||||
val b3 = inputStream.readUnsignedByte();
|
||||
val b4 = inputStream.readUnsignedByte();
|
||||
val size = ((b4.toLong() shl 24) or (b3.toLong() shl 16) or (b2.toLong() shl 8) or b1.toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||
inputStream.skip(size.toLong());
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
inputStream.read(buffer, 0, size);
|
||||
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
|
||||
val opcode = messageBytes[0];
|
||||
var json: String? = null;
|
||||
if (size > 1) {
|
||||
json = messageBytes.sliceArray(IntRange(1, size - 1)).decodeToString();
|
||||
}
|
||||
|
||||
try {
|
||||
handleMessage(Opcode.entries.first { it.value == opcode }, json);
|
||||
} catch (e:Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e);
|
||||
}
|
||||
} catch (e: java.net.SocketException) {
|
||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
||||
exceptionOccurred = true;
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Exception while receiving.", e);
|
||||
exceptionOccurred = true;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped connection loop.");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
}.start();
|
||||
|
||||
Logger.i(TAG, "Started.");
|
||||
Logger.i(TAG, "Stopped connection loop.");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
_thread = null;
|
||||
}.apply { start() };
|
||||
} else {
|
||||
Log.i(TAG, "Thread was still alive, not restarted")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessage(opcode: Opcode, json: String? = null) {
|
||||
Log.i(TAG, "Processing packet (opcode: $opcode, size: ${json?.length ?: 0})")
|
||||
|
||||
when (opcode) {
|
||||
Opcode.PLAYBACK_UPDATE -> {
|
||||
Opcode.PlaybackUpdate -> {
|
||||
if (json == null) {
|
||||
Logger.w(TAG, "Got playback update without JSON, ignoring.");
|
||||
return;
|
||||
|
@ -339,7 +387,7 @@ class FCastCastingDevice : CastingDevice {
|
|||
else -> false
|
||||
}
|
||||
}
|
||||
Opcode.VOLUME_UPDATE -> {
|
||||
Opcode.VolumeUpdate -> {
|
||||
if (json == null) {
|
||||
Logger.w(TAG, "Got volume update without JSON, ignoring.");
|
||||
return;
|
||||
|
@ -348,7 +396,7 @@ class FCastCastingDevice : CastingDevice {
|
|||
val volumeUpdate = FCastCastingDevice.json.decodeFromString<FCastVolumeUpdateMessage>(json);
|
||||
setVolume(volumeUpdate.volume, volumeUpdate.generationTime);
|
||||
}
|
||||
Opcode.PLAYBACK_ERROR -> {
|
||||
Opcode.PlaybackError -> {
|
||||
if (json == null) {
|
||||
Logger.w(TAG, "Got playback error without JSON, ignoring.");
|
||||
return;
|
||||
|
@ -357,7 +405,7 @@ class FCastCastingDevice : CastingDevice {
|
|||
val playbackError = FCastCastingDevice.json.decodeFromString<FCastPlaybackErrorMessage>(json);
|
||||
Logger.e(TAG, "Remote casting playback error received: $playbackError")
|
||||
}
|
||||
Opcode.VERSION -> {
|
||||
Opcode.Version -> {
|
||||
if (json == null) {
|
||||
Logger.w(TAG, "Got version without JSON, ignoring.");
|
||||
return;
|
||||
|
@ -367,72 +415,100 @@ class FCastCastingDevice : CastingDevice {
|
|||
_version = version.version;
|
||||
Logger.i(TAG, "Remote version received: $version")
|
||||
}
|
||||
Opcode.KeyExchange -> {
|
||||
if (json == null) {
|
||||
Logger.w(TAG, "Got KeyExchange without JSON, ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
val keyExchangeMessage: FCastKeyExchangeMessage = FCastCastingDevice.json.decodeFromString(json)
|
||||
Logger.i(TAG, "Received public key: ${keyExchangeMessage.publicKey}")
|
||||
_aesKey = computeSharedSecret(_keyPair.private, keyExchangeMessage)
|
||||
|
||||
synchronized(_queuedEncryptedMessages) {
|
||||
for (queuedEncryptedMessages in _queuedEncryptedMessages) {
|
||||
val decryptedMessage = decryptMessage(_aesKey!!, queuedEncryptedMessages)
|
||||
val o = Opcode.find(decryptedMessage.opcode.toByte())
|
||||
handleMessage(o, decryptedMessage.message)
|
||||
}
|
||||
|
||||
_queuedEncryptedMessages.clear()
|
||||
}
|
||||
}
|
||||
Opcode.Ping -> send(Opcode.Pong)
|
||||
Opcode.Encrypted -> {
|
||||
if (json == null) {
|
||||
Logger.w(TAG, "Got Encrypted without JSON, ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
val encryptedMessage: FCastEncryptedMessage = FCastCastingDevice.json.decodeFromString(json)
|
||||
if (_aesKey != null) {
|
||||
val decryptedMessage = decryptMessage(_aesKey!!, encryptedMessage)
|
||||
val o = Opcode.find(decryptedMessage.opcode.toByte())
|
||||
handleMessage(o, decryptedMessage.message)
|
||||
} else {
|
||||
synchronized(_queuedEncryptedMessages) {
|
||||
if (_queuedEncryptedMessages.size == 15) {
|
||||
_queuedEncryptedMessages.removeAt(0)
|
||||
}
|
||||
|
||||
_queuedEncryptedMessages.add(encryptedMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
Opcode.StartEncryption -> {
|
||||
_encryptionStarted = true
|
||||
//TODO: Send decrypted messages waiting for encryption to be established
|
||||
}
|
||||
else -> { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendMessage(opcode: Opcode) {
|
||||
private fun send(opcode: Opcode, message: String? = null) {
|
||||
val aesKey = _aesKey
|
||||
if (_encryptionStarted && aesKey != null && opcode != Opcode.Encrypted && opcode != Opcode.KeyExchange && opcode != Opcode.StartEncryption) {
|
||||
send(Opcode.Encrypted, encryptMessage(aesKey, FCastDecryptedMessage(opcode.value.toLong(), message)))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val size = 1;
|
||||
val outputStream = _outputStream;
|
||||
val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
|
||||
val size = 1 + data.size
|
||||
val outputStream = _outputStream
|
||||
if (outputStream == null) {
|
||||
Logger.w(TAG, "Failed to send $size bytes, output stream is null.");
|
||||
return;
|
||||
Log.w(TAG, "Failed to send $size bytes, output stream is null.")
|
||||
return
|
||||
}
|
||||
|
||||
val serializedSizeLE = ByteArray(4);
|
||||
serializedSizeLE[0] = (size and 0xff).toByte();
|
||||
serializedSizeLE[1] = (size shr 8 and 0xff).toByte();
|
||||
serializedSizeLE[2] = (size shr 16 and 0xff).toByte();
|
||||
serializedSizeLE[3] = (size shr 24 and 0xff).toByte();
|
||||
outputStream.write(serializedSizeLE);
|
||||
val serializedSizeLE = ByteArray(4)
|
||||
serializedSizeLE[0] = (size and 0xff).toByte()
|
||||
serializedSizeLE[1] = (size shr 8 and 0xff).toByte()
|
||||
serializedSizeLE[2] = (size shr 16 and 0xff).toByte()
|
||||
serializedSizeLE[3] = (size shr 24 and 0xff).toByte()
|
||||
outputStream.write(serializedSizeLE)
|
||||
|
||||
val opcodeBytes = ByteArray(1);
|
||||
opcodeBytes[0] = opcode.value;
|
||||
outputStream.write(opcodeBytes);
|
||||
val opcodeBytes = ByteArray(1)
|
||||
opcodeBytes[0] = opcode.value
|
||||
outputStream.write(opcodeBytes)
|
||||
|
||||
Log.d(TAG, "Sent $size bytes.");
|
||||
if (data.isNotEmpty()) {
|
||||
outputStream.write(data)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Sent $size bytes: (opcode: $opcode, body: $message).")
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to send message.", e);
|
||||
Log.i(TAG, "Failed to send message.", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T> sendMessage(opcode: Opcode, message: T) {
|
||||
private inline fun <reified T> send(opcode: Opcode, message: T) {
|
||||
try {
|
||||
val data: ByteArray;
|
||||
var jsonString: String? = null;
|
||||
if (message != null) {
|
||||
jsonString = json.encodeToString(message);
|
||||
data = jsonString.encodeToByteArray();
|
||||
} else {
|
||||
data = ByteArray(0);
|
||||
}
|
||||
|
||||
val size = 1 + data.size;
|
||||
val outputStream = _outputStream;
|
||||
if (outputStream == null) {
|
||||
Logger.w(TAG, "Failed to send $size bytes, output stream is null.");
|
||||
return;
|
||||
}
|
||||
|
||||
val serializedSizeLE = ByteArray(4);
|
||||
serializedSizeLE[0] = (size and 0xff).toByte();
|
||||
serializedSizeLE[1] = (size shr 8 and 0xff).toByte();
|
||||
serializedSizeLE[2] = (size shr 16 and 0xff).toByte();
|
||||
serializedSizeLE[3] = (size shr 24 and 0xff).toByte();
|
||||
outputStream.write(serializedSizeLE);
|
||||
|
||||
val opcodeBytes = ByteArray(1);
|
||||
opcodeBytes[0] = opcode.value;
|
||||
outputStream.write(opcodeBytes);
|
||||
|
||||
if (data.isNotEmpty()) {
|
||||
outputStream.write(data);
|
||||
}
|
||||
|
||||
Log.d(TAG, "Sent $size bytes: '$jsonString'.");
|
||||
send(opcode, message?.let { Json.encodeToString(it) })
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to send message.", e);
|
||||
Log.i(TAG, "Failed to encode message to string.", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -441,6 +517,8 @@ class FCastCastingDevice : CastingDevice {
|
|||
usedRemoteAddress = null;
|
||||
localAddress = null;
|
||||
_started = false;
|
||||
//TODO: Kill and/or join thread?
|
||||
_thread = null;
|
||||
|
||||
val socket = _socket;
|
||||
val scopeIO = _scopeIO;
|
||||
|
@ -471,7 +549,65 @@ class FCastCastingDevice : CastingDevice {
|
|||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "FastCastCastingDevice";
|
||||
val TAG = "FCastCastingDevice";
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun getKeyExchangeMessage(keyPair: KeyPair): FCastKeyExchangeMessage {
|
||||
return FCastKeyExchangeMessage(1, Base64.encodeToString(keyPair.public.encoded, Base64.NO_WRAP))
|
||||
}
|
||||
|
||||
fun generateKeyPair(): KeyPair {
|
||||
//modp14
|
||||
val p = BigInteger("ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff", 16)
|
||||
val g = BigInteger("2", 16)
|
||||
val dhSpec = DHParameterSpec(p, g)
|
||||
|
||||
val keyGen = KeyPairGenerator.getInstance("DH")
|
||||
keyGen.initialize(dhSpec)
|
||||
|
||||
return keyGen.generateKeyPair()
|
||||
}
|
||||
|
||||
fun computeSharedSecret(privateKey: PrivateKey, keyExchangeMessage: FCastKeyExchangeMessage): SecretKeySpec {
|
||||
val keyFactory = KeyFactory.getInstance("DH")
|
||||
val receivedPublicKeyBytes = Base64.decode(keyExchangeMessage.publicKey, Base64.NO_WRAP)
|
||||
val receivedPublicKeySpec = X509EncodedKeySpec(receivedPublicKeyBytes)
|
||||
val receivedPublicKey = keyFactory.generatePublic(receivedPublicKeySpec)
|
||||
|
||||
val keyAgreement = KeyAgreement.getInstance("DH")
|
||||
keyAgreement.init(privateKey)
|
||||
keyAgreement.doPhase(receivedPublicKey, true)
|
||||
|
||||
val sharedSecret = keyAgreement.generateSecret()
|
||||
Log.i(TAG, "sharedSecret ${Base64.encodeToString(sharedSecret, Base64.NO_WRAP)}")
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
val hashedSecret = sha256.digest(sharedSecret)
|
||||
Log.i(TAG, "hashedSecret ${Base64.encodeToString(hashedSecret, Base64.NO_WRAP)}")
|
||||
|
||||
return SecretKeySpec(hashedSecret, "AES")
|
||||
}
|
||||
|
||||
fun encryptMessage(aesKey: SecretKeySpec, decryptedMessage: FCastDecryptedMessage): FCastEncryptedMessage {
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, aesKey)
|
||||
val iv = cipher.iv
|
||||
val json = Json.encodeToString(decryptedMessage)
|
||||
val encrypted = cipher.doFinal(json.toByteArray(Charsets.UTF_8))
|
||||
return FCastEncryptedMessage(
|
||||
version = 1,
|
||||
iv = Base64.encodeToString(iv, Base64.NO_WRAP),
|
||||
blob = Base64.encodeToString(encrypted, Base64.NO_WRAP)
|
||||
)
|
||||
}
|
||||
|
||||
fun decryptMessage(aesKey: SecretKeySpec, encryptedMessage: FCastEncryptedMessage): FCastDecryptedMessage {
|
||||
val iv = Base64.decode(encryptedMessage.iv, Base64.NO_WRAP)
|
||||
val encrypted = Base64.decode(encryptedMessage.blob, Base64.NO_WRAP)
|
||||
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
|
||||
val decryptedJson = cipher.doFinal(encrypted)
|
||||
return Json.decodeFromString(String(decryptedJson, Charsets.UTF_8))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -205,11 +205,20 @@ class StateCasting {
|
|||
}
|
||||
|
||||
fun onResume() {
|
||||
val resumeCastingDevice = _resumeCastingDevice
|
||||
if (resumeCastingDevice != null) {
|
||||
connectDevice(deviceFromCastingDeviceInfo(resumeCastingDevice))
|
||||
_resumeCastingDevice = null
|
||||
Log.i(TAG, "_resumeCastingDevice set to null onResume")
|
||||
val ad = activeDevice
|
||||
if (ad != null) {
|
||||
if (ad is FCastCastingDevice) {
|
||||
ad.ensureThreadStarted()
|
||||
} else if (ad is ChromecastCastingDevice) {
|
||||
ad.ensureThreadsStarted()
|
||||
}
|
||||
} else {
|
||||
val resumeCastingDevice = _resumeCastingDevice
|
||||
if (resumeCastingDevice != null) {
|
||||
connectDevice(deviceFromCastingDeviceInfo(resumeCastingDevice))
|
||||
_resumeCastingDevice = null
|
||||
Log.i(TAG, "_resumeCastingDevice set to null onResume")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -50,4 +50,23 @@ data class FCastPlaybackErrorMessage(
|
|||
@Serializable
|
||||
data class FCastVersionMessage(
|
||||
val version: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FCastKeyExchangeMessage(
|
||||
val version: Long,
|
||||
val publicKey: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FCastDecryptedMessage(
|
||||
val opcode: Long,
|
||||
val message: String?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FCastEncryptedMessage(
|
||||
val version: Long,
|
||||
val iv: String?,
|
||||
val blob: String
|
||||
)
|
|
@ -50,6 +50,7 @@ import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetExcept
|
|||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
|
||||
import com.futo.platformplayer.api.media.models.chapters.ChapterType
|
||||
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
|
@ -459,20 +460,29 @@ class VideoDetailView : ConstraintLayout {
|
|||
_cast.onSettingsClick.subscribe { showVideoSettings() };
|
||||
_player.onVideoSettings.subscribe { showVideoSettings() };
|
||||
_player.onToggleFullScreen.subscribe(::handleFullScreen);
|
||||
_player.onChapterChanged.subscribe { chapter, isScrub ->
|
||||
|
||||
val onChapterChanged = { chapter: IChapter?, isScrub: Boolean ->
|
||||
if(_layoutSkip.visibility == VISIBLE && chapter?.type != ChapterType.SKIPPABLE)
|
||||
_layoutSkip.visibility = GONE;
|
||||
|
||||
if(!isScrub) {
|
||||
if(chapter?.type == ChapterType.SKIPPABLE) {
|
||||
_layoutSkip.visibility = VISIBLE;
|
||||
}
|
||||
else if(chapter?.type == ChapterType.SKIP) {
|
||||
_player.seekTo((chapter.timeEnd * 1000).toLong());
|
||||
} else if(chapter?.type == ChapterType.SKIP) {
|
||||
val ad = StateCasting.instance.activeDevice
|
||||
if (ad != null) {
|
||||
ad.seekVideo(chapter.timeEnd)
|
||||
} else {
|
||||
_player.seekTo((chapter.timeEnd * 1000).toLong());
|
||||
}
|
||||
|
||||
UIDialogs.toast(context, "Skipped chapter [${chapter.name}]", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_player.onChapterChanged.subscribe(onChapterChanged);
|
||||
_cast.onChapterChanged.subscribe(onChapterChanged);
|
||||
|
||||
_cast.onMinimizeClick.subscribe {
|
||||
_player.setFullScreen(false);
|
||||
|
@ -667,9 +677,17 @@ class VideoDetailView : ConstraintLayout {
|
|||
};
|
||||
|
||||
_layoutSkip.setOnClickListener {
|
||||
val currentChapter = _player.getCurrentChapter(_player.position);
|
||||
if(currentChapter?.type == ChapterType.SKIPPABLE) {
|
||||
_player.seekTo((currentChapter.timeEnd * 1000).toLong());
|
||||
val ad = StateCasting.instance.activeDevice;
|
||||
if (ad != null) {
|
||||
val currentChapter = _cast.getCurrentChapter((ad.time * 1000).toLong());
|
||||
if(currentChapter?.type == ChapterType.SKIPPABLE) {
|
||||
ad.seekVideo(currentChapter.timeEnd);
|
||||
}
|
||||
} else {
|
||||
val currentChapter = _player.getCurrentChapter(_player.position);
|
||||
if(currentChapter?.type == ChapterType.SKIPPABLE) {
|
||||
_player.seekTo((currentChapter.timeEnd * 1000).toLong());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1145,10 +1163,12 @@ class VideoDetailView : ConstraintLayout {
|
|||
//TODO: Implement video.getContentChapters()
|
||||
val chapters = null ?: StatePlatform.instance.getContentChapters(video.url);
|
||||
_player.setChapters(chapters);
|
||||
_cast.setChapters(chapters);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to get chapters", ex);
|
||||
_player.setChapters(null);
|
||||
_cast.setChapters(null);
|
||||
|
||||
/*withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(context, "Failed to get chapters\n" + ex.message);
|
||||
|
|
|
@ -18,10 +18,12 @@ import androidx.media3.ui.DefaultTimeBar
|
|||
import androidx.media3.ui.TimeBar
|
||||
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.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.toHumanTime
|
||||
import com.futo.platformplayer.views.behavior.GestureControlView
|
||||
|
@ -51,7 +53,10 @@ class CastView : ConstraintLayout {
|
|||
private var _scope: CoroutineScope = CoroutineScope(Dispatchers.Main);
|
||||
private var _updateTimeJob: Job? = null;
|
||||
private var _inPictureInPicture: Boolean = false;
|
||||
private var _chapters: List<IChapter>? = null;
|
||||
private var _currentChapter: IChapter? = null;
|
||||
|
||||
val onChapterChanged = Event2<IChapter?, Boolean>();
|
||||
val onMinimizeClick = Event0();
|
||||
val onSettingsClick = Event0();
|
||||
val onPrevious = Event0();
|
||||
|
@ -129,6 +134,36 @@ class CastView : ConstraintLayout {
|
|||
_buttonNext.setOnClickListener { onNext.emit() };
|
||||
}
|
||||
|
||||
private fun updateCurrentChapter(chaptPos: Long, isScrub: Boolean = false): Boolean {
|
||||
val currentChapter = getCurrentChapter(chaptPos);
|
||||
if(_currentChapter != currentChapter) {
|
||||
_currentChapter = currentChapter;
|
||||
/*runBlocking(Dispatchers.Main) {
|
||||
if (currentChapter != null) {
|
||||
//TODO: Add chapter controls
|
||||
//_control_chapter.text = " • " + currentChapter.name;
|
||||
//_control_chapter_fullscreen.text = " • " + currentChapter.name;
|
||||
} else {
|
||||
//TODO: Add chapter controls
|
||||
//_control_chapter.text = "";
|
||||
//_control_chapter_fullscreen.text = "";
|
||||
}
|
||||
}*/
|
||||
|
||||
onChapterChanged.emit(currentChapter, isScrub);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fun setChapters(chapters: List<IChapter>?) {
|
||||
_chapters = chapters;
|
||||
}
|
||||
|
||||
fun getCurrentChapter(pos: Long): IChapter? {
|
||||
return _chapters?.let { chaps -> chaps.find { pos.toDouble() / 1000 > it.timeStart && pos.toDouble() / 1000 < it.timeEnd } };
|
||||
}
|
||||
|
||||
private fun updateNextPrevious() {
|
||||
val vidPrev = StatePlayer.instance.getPrevQueueItem(true);
|
||||
val vidNext = StatePlayer.instance.getNextQueueItem(true);
|
||||
|
@ -225,6 +260,7 @@ class CastView : ConstraintLayout {
|
|||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun setTime(ms: Long) {
|
||||
updateCurrentChapter(ms);
|
||||
_textPosition.text = ms.toHumanTime(true);
|
||||
_timeBar.setPosition(ms / 1000);
|
||||
StatePlayer.instance.updateMediaSessionPlaybackState(getPlaybackStateCompat(), ms);
|
||||
|
|
|
@ -458,7 +458,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||
val currentChapter = getCurrentChapter(chaptPos);
|
||||
if(_currentChapter != currentChapter) {
|
||||
_currentChapter = currentChapter;
|
||||
runBlocking(Dispatchers.Main) {
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
if (currentChapter != null) {
|
||||
_control_chapter.text = " • " + currentChapter.name;
|
||||
_control_chapter_fullscreen.text = " • " + currentChapter.name;
|
||||
|
|
Loading…
Add table
Reference in a new issue