diff --git a/app/src/androidTest/java/com/futo/platformplayer/FCastEncryptionTests.kt b/app/src/androidTest/java/com/futo/platformplayer/FCastEncryptionTests.kt new file mode 100644 index 00000000..5e977917 --- /dev/null +++ b/app/src/androidTest/java/com/futo/platformplayer/FCastEncryptionTests.kt @@ -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("{\"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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt index 89e13933..7885da41 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -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, 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; diff --git a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt index 110a91d6..0003cf4e 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt @@ -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() + private var _encryptionStarted = false + private var _thread: Thread? = null constructor(name: String, addresses: Array, 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 { @@ -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(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(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 sendMessage(opcode: Opcode, message: T) { + private inline fun 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)) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 8598acac..885a629f 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -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") + } } } diff --git a/app/src/main/java/com/futo/platformplayer/casting/models/FCast.kt b/app/src/main/java/com/futo/platformplayer/casting/models/FCast.kt index f7078d18..79cc9579 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/models/FCast.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/models/FCast.kt @@ -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 ) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 9cb27040..30c827ec 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -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); diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt index 087586d3..b33c62da 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt @@ -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? = null; + private var _currentChapter: IChapter? = null; + val onChapterChanged = Event2(); 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?) { + _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); diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt index 5b782037..154db811 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt @@ -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;