diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..173a6f10 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +aar/* filter=lfs diff=lfs merge=lfs -text +app/aar/* filter=lfs diff=lfs merge=lfs -text diff --git a/.gitmodules b/.gitmodules index c906834c..00037939 100644 --- a/.gitmodules +++ b/.gitmodules @@ -94,3 +94,15 @@ [submodule "app/src/unstable/assets/sources/tedtalks"] path = app/src/unstable/assets/sources/tedtalks url = ../plugins/tedtalks.git +[submodule "app/src/stable/assets/sources/curiositystream"] + path = app/src/stable/assets/sources/curiositystream + url = ../plugins/curiositystream.git +[submodule "app/src/unstable/assets/sources/curiositystream"] + path = app/src/unstable/assets/sources/curiositystream + url = ../plugins/curiositystream.git +[submodule "app/src/unstable/assets/sources/crunchyroll"] + path = app/src/unstable/assets/sources/crunchyroll + url = ../plugins/crunchyroll.git +[submodule "app/src/stable/assets/sources/crunchyroll"] + path = app/src/stable/assets/sources/crunchyroll + url = ../plugins/crunchyroll.git diff --git a/app/aar/ffmpeg-kit-full-6.0-2.LTS.aar b/app/aar/ffmpeg-kit-full-6.0-2.LTS.aar new file mode 100644 index 00000000..27b62b35 --- /dev/null +++ b/app/aar/ffmpeg-kit-full-6.0-2.LTS.aar @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea10d3c5562c9f449a4e89e9c3dfcf881ed79a952f3409bc005bcc62c2cf4b81 +size 65512557 diff --git a/app/build.gradle b/app/build.gradle index 23d17cda..c20ba29f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -198,7 +198,8 @@ dependencies { implementation 'org.jsoup:jsoup:1.15.3' implementation 'com.google.android.flexbox:flexbox:3.0.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' - implementation 'com.arthenica:ffmpeg-kit-full:6.0-2.LTS' + implementation fileTree(dir: 'aar', include: ['*.aar']) + implementation 'com.arthenica:smart-exception-java:0.2.1' implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0' implementation 'com.github.dhaval2404:imagepicker:2.1' implementation 'com.google.zxing:core:3.4.1' diff --git a/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt b/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt new file mode 100644 index 00000000..7607a2c9 --- /dev/null +++ b/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt @@ -0,0 +1,338 @@ +package com.futo.platformplayer + +import com.futo.platformplayer.noise.protocol.Noise +import com.futo.platformplayer.sync.internal.* +import kotlinx.coroutines.* +import kotlinx.coroutines.selects.select +import org.junit.Assert.* +import org.junit.Test +import java.net.Socket +import java.nio.ByteBuffer +import kotlin.random.Random +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class SyncServerTests { + + //private val relayHost = "relay.grayjay.app" + //private val relayKey = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw=" + private val relayKey = "XlUaSpIlRaCg0TGzZ7JYmPupgUHDqTZXUUBco2K7ejw=" + private val relayHost = "192.168.1.138" + private val relayPort = 9000 + + /** Creates a client connected to the live relay server. */ + private suspend fun createClient( + onHandshakeComplete: ((SyncSocketSession) -> Unit)? = null, + onData: ((SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit)? = null, + onNewChannel: ((SyncSocketSession, ChannelRelayed) -> Unit)? = null, + isHandshakeAllowed: ((LinkType, SyncSocketSession, String, String?, UInt) -> Boolean)? = null, + onException: ((Throwable) -> Unit)? = null + ): SyncSocketSession = withContext(Dispatchers.IO) { + val p = Noise.createDH("25519") + p.generateKeyPair() + val socket = Socket(relayHost, relayPort) + val inputStream = LittleEndianDataInputStream(socket.getInputStream()) + val outputStream = LittleEndianDataOutputStream(socket.getOutputStream()) + val tcs = CompletableDeferred() + val socketSession = SyncSocketSession( + relayHost, + p, + inputStream, + outputStream, + onClose = { socket.close() }, + onHandshakeComplete = { s -> + onHandshakeComplete?.invoke(s) + tcs.complete(true) + }, + onData = onData ?: { _, _, _, _ -> }, + onNewChannel = onNewChannel ?: { _, _ -> }, + isHandshakeAllowed = isHandshakeAllowed ?: { _, _, _, _, _ -> true } + ) + socketSession.authorizable = AlwaysAuthorized() + try { + socketSession.startAsInitiator(relayKey) + } catch (e: Throwable) { + onException?.invoke(e) + } + withTimeout(5000.milliseconds) { tcs.await() } + return@withContext socketSession + } + + @Test + fun multipleClientsHandshake_Success() = runBlocking { + val client1 = createClient() + val client2 = createClient() + assertNotNull(client1.remotePublicKey, "Client 1 handshake failed") + assertNotNull(client2.remotePublicKey, "Client 2 handshake failed") + client1.stop() + client2.stop() + } + + @Test + fun publishAndRequestConnectionInfo_Authorized_Success() = runBlocking { + val clientA = createClient() + val clientB = createClient() + val clientC = createClient() + clientA.publishConnectionInformation(arrayOf(clientB.localPublicKey), 12345, true, true, true, true) + delay(100.milliseconds) + val infoB = clientB.requestConnectionInfo(clientA.localPublicKey) + val infoC = clientC.requestConnectionInfo(clientA.localPublicKey) + assertNotNull("Client B should receive connection info", infoB) + assertEquals(12345.toUShort(), infoB!!.port) + assertNull("Client C should not receive connection info (unauthorized)", infoC) + clientA.stop() + clientB.stop() + clientC.stop() + } + + @Test + fun relayedTransport_Bidirectional_Success() = runBlocking { + val tcsA = CompletableDeferred() + val tcsB = CompletableDeferred() + val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) }) + val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) }) + val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) } + val channelA = withTimeout(5000.milliseconds) { tcsA.await() } + channelA.authorizable = AlwaysAuthorized() + val channelB = withTimeout(5000.milliseconds) { tcsB.await() } + channelB.authorizable = AlwaysAuthorized() + channelTask.await() + + val tcsDataB = CompletableDeferred() + channelB.setDataHandler { _, _, o, so, d -> + val b = ByteArray(d.remaining()) + d.get(b) + if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b) + } + channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(1, 2, 3))) + + val tcsDataA = CompletableDeferred() + channelA.setDataHandler { _, _, o, so, d -> + val b = ByteArray(d.remaining()) + d.get(b) + if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataA.complete(b) + } + channelB.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(4, 5, 6))) + + val receivedB = withTimeout(5000.milliseconds) { tcsDataB.await() } + val receivedA = withTimeout(5000.milliseconds) { tcsDataA.await() } + assertArrayEquals(byteArrayOf(1, 2, 3), receivedB) + assertArrayEquals(byteArrayOf(4, 5, 6), receivedA) + clientA.stop() + clientB.stop() + } + + @Test + fun relayedTransport_MaximumMessageSize_Success() = runBlocking { + val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE - 8 - 16 - 16 + val maxSizeData = ByteArray(MAX_DATA_PER_PACKET).apply { Random.nextBytes(this) } + val tcsA = CompletableDeferred() + val tcsB = CompletableDeferred() + val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) }) + val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) }) + val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) } + val channelA = withTimeout(5000.milliseconds) { tcsA.await() } + channelA.authorizable = AlwaysAuthorized() + val channelB = withTimeout(5000.milliseconds) { tcsB.await() } + channelB.authorizable = AlwaysAuthorized() + channelTask.await() + + val tcsDataB = CompletableDeferred() + channelB.setDataHandler { _, _, o, so, d -> + val b = ByteArray(d.remaining()) + d.get(b) + if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b) + } + channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxSizeData)) + val receivedData = withTimeout(5000.milliseconds) { tcsDataB.await() } + assertArrayEquals(maxSizeData, receivedData) + clientA.stop() + clientB.stop() + } + + @Test + fun publishAndGetRecord_Success() = runBlocking { + val clientA = createClient() + val clientB = createClient() + val clientC = createClient() + val data = byteArrayOf(1, 2, 3) + val success = clientA.publishRecords(listOf(clientB.localPublicKey), "testKey", data) + val recordB = clientB.getRecord(clientA.localPublicKey, "testKey") + val recordC = clientC.getRecord(clientA.localPublicKey, "testKey") + assertTrue(success) + assertNotNull(recordB) + assertArrayEquals(data, recordB!!.first) + assertNull("Unauthorized client should not access record", recordC) + clientA.stop() + clientB.stop() + clientC.stop() + } + + @Test + fun getNonExistentRecord_ReturnsNull() = runBlocking { + val clientA = createClient() + val clientB = createClient() + val record = clientB.getRecord(clientA.localPublicKey, "nonExistentKey") + assertNull("Getting non-existent record should return null", record) + clientA.stop() + clientB.stop() + } + + @Test + fun updateRecord_TimestampUpdated() = runBlocking { + val clientA = createClient() + val clientB = createClient() + val key = "updateKey" + val data1 = byteArrayOf(1) + val data2 = byteArrayOf(2) + clientA.publishRecords(listOf(clientB.localPublicKey), key, data1) + val record1 = clientB.getRecord(clientA.localPublicKey, key) + delay(1000.milliseconds) + clientA.publishRecords(listOf(clientB.localPublicKey), key, data2) + val record2 = clientB.getRecord(clientA.localPublicKey, key) + assertNotNull(record1) + assertNotNull(record2) + assertTrue(record2!!.second > record1!!.second) + assertArrayEquals(data2, record2.first) + clientA.stop() + clientB.stop() + } + + @Test + fun deleteRecord_Success() = runBlocking { + val clientA = createClient() + val clientB = createClient() + val data = byteArrayOf(1, 2, 3) + clientA.publishRecords(listOf(clientB.localPublicKey), "toDelete", data) + val success = clientB.deleteRecords(clientA.localPublicKey, clientB.localPublicKey, listOf("toDelete")) + val record = clientB.getRecord(clientA.localPublicKey, "toDelete") + assertTrue(success) + assertNull(record) + clientA.stop() + clientB.stop() + } + + @Test + fun listRecordKeys_Success() = runBlocking { + val clientA = createClient() + val clientB = createClient() + val keys = arrayOf("key1", "key2", "key3") + keys.forEach { key -> + clientA.publishRecords(listOf(clientB.localPublicKey), key, byteArrayOf(1)) + } + val listedKeys = clientB.listRecordKeys(clientA.localPublicKey, clientB.localPublicKey) + assertArrayEquals(keys, listedKeys.map { it.first }.toTypedArray()) + clientA.stop() + clientB.stop() + } + + @Test + fun singleLargeMessageViaRelayedChannel_Success() = runBlocking { + val largeData = ByteArray(100000).apply { Random.nextBytes(this) } + val tcsA = CompletableDeferred() + val tcsB = CompletableDeferred() + val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) }) + val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) }) + val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) } + val channelA = withTimeout(5000.milliseconds) { tcsA.await() } + channelA.authorizable = AlwaysAuthorized() + val channelB = withTimeout(5000.milliseconds) { tcsB.await() } + channelB.authorizable = AlwaysAuthorized() + channelTask.await() + + val tcsDataB = CompletableDeferred() + channelB.setDataHandler { _, _, o, so, d -> + val b = ByteArray(d.remaining()) + d.get(b) + if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b) + } + channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData)) + val receivedData = withTimeout(10000.milliseconds) { tcsDataB.await() } + assertArrayEquals(largeData, receivedData) + clientA.stop() + clientB.stop() + } + + @Test + fun publishAndGetLargeRecord_Success() = runBlocking { + val largeData = ByteArray(1000000).apply { Random.nextBytes(this) } + val clientA = createClient() + val clientB = createClient() + val success = clientA.publishRecords(listOf(clientB.localPublicKey), "largeRecord", largeData) + val record = clientB.getRecord(clientA.localPublicKey, "largeRecord") + assertTrue(success) + assertNotNull(record) + assertArrayEquals(largeData, record!!.first) + clientA.stop() + clientB.stop() + } + + @Test + fun relayedTransport_WithValidAppId_Success() = runBlocking { + // Arrange: Set up clients + val allowedAppId = 1234u + val tcsB = CompletableDeferred() + + // Client B requires appId 1234 + val clientB = createClient( + onNewChannel = { _, c -> tcsB.complete(c) }, + isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId } + ) + + val clientA = createClient() + + // Act: Start relayed channel with valid appId + val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey, appId = allowedAppId) } + val channelB = withTimeout(5.seconds) { tcsB.await() } + withTimeout(5.seconds) { channelTask.await() } + + // Assert: Channel is established + assertNotNull("Channel should be created on target with valid appId", channelB) + + // Clean up + clientA.stop() + clientB.stop() + } + + @Test + fun relayedTransport_WithInvalidAppId_Fails() = runBlocking { + // Arrange: Set up clients + val allowedAppId = 1234u + val invalidAppId = 5678u + val tcsB = CompletableDeferred() + + // Client B requires appId 1234 + val clientB = createClient( + onNewChannel = { _, c -> tcsB.complete(c) }, + isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId }, + onException = { } + ) + + val clientA = createClient() + + // Act & Assert: Attempt with invalid appId should fail + try { + withTimeout(5.seconds) { + clientA.startRelayedChannel(clientB.localPublicKey, appId = invalidAppId) + } + fail("Starting relayed channel with invalid appId should fail") + } catch (e: Throwable) { + // Expected: The channel creation should time out or fail + } + + // Ensure no channel was created on client B + val completedTask = select { + tcsB.onAwait { "channel" } + async { delay(1.seconds); "timeout" }.onAwait { "timeout" } + } + assertEquals("No channel should be created with invalid appId", "timeout", completedTask) + + // Clean up + clientA.stop() + clientB.stop() + } +} + +class AlwaysAuthorized : IAuthorizable { + override val isAuthorized: Boolean get() = true +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt b/app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt new file mode 100644 index 00000000..1b9f19cd --- /dev/null +++ b/app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt @@ -0,0 +1,512 @@ +package com.futo.platformplayer + +import com.futo.platformplayer.noise.protocol.DHState +import com.futo.platformplayer.noise.protocol.Noise +import com.futo.platformplayer.sync.internal.* +import kotlinx.coroutines.* +import org.junit.Assert.* +import org.junit.Test +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.nio.ByteBuffer +import kotlin.random.Random +import java.io.InputStream +import java.io.OutputStream +import kotlin.time.Duration.Companion.seconds + +data class PipeStreams( + val initiatorInput: LittleEndianDataInputStream, + val initiatorOutput: LittleEndianDataOutputStream, + val responderInput: LittleEndianDataInputStream, + val responderOutput: LittleEndianDataOutputStream +) + +typealias OnHandshakeComplete = (SyncSocketSession) -> Unit +typealias IsHandshakeAllowed = (LinkType, SyncSocketSession, String, String?, UInt) -> Boolean +typealias OnClose = (SyncSocketSession) -> Unit +typealias OnData = (SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit + +class SyncSocketTests { + private fun createPipeStreams(): PipeStreams { + val initiatorOutput = PipedOutputStream() + val responderOutput = PipedOutputStream() + val responderInput = PipedInputStream(initiatorOutput) + val initiatorInput = PipedInputStream(responderOutput) + return PipeStreams( + LittleEndianDataInputStream(initiatorInput), LittleEndianDataOutputStream(initiatorOutput), + LittleEndianDataInputStream(responderInput), LittleEndianDataOutputStream(responderOutput) + ) + } + + fun generateKeyPair(): DHState { + val p = Noise.createDH("25519") + p.generateKeyPair() + return p + } + + private fun createSessions( + initiatorInput: LittleEndianDataInputStream, + initiatorOutput: LittleEndianDataOutputStream, + responderInput: LittleEndianDataInputStream, + responderOutput: LittleEndianDataOutputStream, + initiatorKeyPair: DHState, + responderKeyPair: DHState, + onInitiatorHandshakeComplete: OnHandshakeComplete, + onResponderHandshakeComplete: OnHandshakeComplete, + onInitiatorClose: OnClose? = null, + onResponderClose: OnClose? = null, + onClose: OnClose? = null, + isHandshakeAllowed: IsHandshakeAllowed? = null, + onDataA: OnData? = null, + onDataB: OnData? = null + ): Pair { + val initiatorSession = SyncSocketSession( + "", initiatorKeyPair, initiatorInput, initiatorOutput, + onClose = { + onClose?.invoke(it) + onInitiatorClose?.invoke(it) + }, + onHandshakeComplete = onInitiatorHandshakeComplete, + onData = onDataA, + isHandshakeAllowed = isHandshakeAllowed + ) + + val responderSession = SyncSocketSession( + "", responderKeyPair, responderInput, responderOutput, + onClose = { + onClose?.invoke(it) + onResponderClose?.invoke(it) + }, + onHandshakeComplete = onResponderHandshakeComplete, + onData = onDataB, + isHandshakeAllowed = isHandshakeAllowed + ) + + return Pair(initiatorSession, responderSession) + } + + @Test + fun handshake_WithValidPairingCode_Succeeds(): Unit = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + val validPairingCode = "secret" + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode } + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = validPairingCode) + responderSession.startAsResponder() + + withTimeout(5.seconds) { + handshakeInitiatorCompleted.await() + handshakeResponderCompleted.await() + } + } + + @Test + fun handshake_WithInvalidPairingCode_Fails() = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + val validPairingCode = "secret" + val invalidPairingCode = "wrong" + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + val initiatorClosed = CompletableDeferred() + val responderClosed = CompletableDeferred() + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + onInitiatorClose = { + initiatorClosed.complete(true) + }, + onResponderClose = { + responderClosed.complete(true) + }, + isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode } + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = invalidPairingCode) + responderSession.startAsResponder() + + withTimeout(100.seconds) { + initiatorClosed.await() + responderClosed.await() + } + + assertFalse(handshakeInitiatorCompleted.isCompleted) + assertFalse(handshakeResponderCompleted.isCompleted) + } + + @Test + fun handshake_WithoutPairingCodeWhenRequired_Fails() = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + val validPairingCode = "secret" + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + val initiatorClosed = CompletableDeferred() + val responderClosed = CompletableDeferred() + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + onInitiatorClose = { + initiatorClosed.complete(true) + }, + onResponderClose = { + responderClosed.complete(true) + }, + isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode } + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey) // No pairing code + responderSession.startAsResponder() + + withTimeout(5.seconds) { + initiatorClosed.await() + responderClosed.await() + } + + assertFalse(handshakeInitiatorCompleted.isCompleted) + assertFalse(handshakeResponderCompleted.isCompleted) + } + + @Test + fun handshake_WithPairingCodeWhenNotRequired_Succeeds(): Unit = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + val pairingCode = "unnecessary" + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + isHandshakeAllowed = { _, _, _, _, _ -> true } // Always allow + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = pairingCode) + responderSession.startAsResponder() + + withTimeout(10.seconds) { + handshakeInitiatorCompleted.await() + handshakeResponderCompleted.await() + } + } + + @Test + fun sendAndReceive_SmallDataPacket_Succeeds() = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + val tcsDataReceived = CompletableDeferred() + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + onDataB = { _, opcode, subOpcode, data -> + if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) { + val b = ByteArray(data.remaining()) + data.get(b) + tcsDataReceived.complete(b) + } + } + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey) + responderSession.startAsResponder() + + withTimeout(10.seconds) { + handshakeInitiatorCompleted.await() + handshakeResponderCompleted.await() + } + + // Ensure both sessions are authorized + initiatorSession.authorizable = Authorized() + responderSession.authorizable = Authorized() + + val smallData = byteArrayOf(1, 2, 3) + initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(smallData)) + + val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() } + assertArrayEquals(smallData, receivedData) + } + + @Test + fun sendAndReceive_ExactlyMaximumPacketSize_Succeeds() = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + val tcsDataReceived = CompletableDeferred() + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + onDataB = { _, opcode, subOpcode, data -> + if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) { + val b = ByteArray(data.remaining()) + data.get(b) + tcsDataReceived.complete(b) + } + } + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey) + responderSession.startAsResponder() + + withTimeout(10.seconds) { + handshakeInitiatorCompleted.await() + handshakeResponderCompleted.await() + } + + // Ensure both sessions are authorized + initiatorSession.authorizable = Authorized() + responderSession.authorizable = Authorized() + + val maxData = ByteArray(SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE).apply { Random.nextBytes(this) } + initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxData)) + + val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() } + assertArrayEquals(maxData, receivedData) + } + + @Test + fun stream_LargeData_Succeeds() = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + val tcsDataReceived = CompletableDeferred() + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + onDataB = { _, opcode, subOpcode, data -> + if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) { + val b = ByteArray(data.remaining()) + data.get(b) + tcsDataReceived.complete(b) + } + } + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey) + responderSession.startAsResponder() + + withTimeout(10.seconds) { + handshakeInitiatorCompleted.await() + handshakeResponderCompleted.await() + } + + // Ensure both sessions are authorized + initiatorSession.authorizable = Authorized() + responderSession.authorizable = Authorized() + + val largeData = ByteArray(2 * (SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE)).apply { Random.nextBytes(this) } + initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData)) + + val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() } + assertArrayEquals(largeData, receivedData) + } + + @Test + fun authorizedSession_CanSendData() = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + val tcsDataReceived = CompletableDeferred() + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + onDataB = { _, opcode, subOpcode, data -> + if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) { + val b = ByteArray(data.remaining()) + data.get(b) + tcsDataReceived.complete(b) + } + } + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey) + responderSession.startAsResponder() + + withTimeout(10.seconds) { + handshakeInitiatorCompleted.await() + handshakeResponderCompleted.await() + } + + // Authorize both sessions + initiatorSession.authorizable = Authorized() + responderSession.authorizable = Authorized() + + val data = byteArrayOf(1, 2, 3) + initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data)) + + val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() } + assertArrayEquals(data, receivedData) + } + + @Test + fun unauthorizedSession_CannotSendData() = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + val tcsDataReceived = CompletableDeferred() + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + onDataB = { _, _, _, _ -> } + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey) + responderSession.startAsResponder() + + withTimeout(10.seconds) { + handshakeInitiatorCompleted.await() + handshakeResponderCompleted.await() + } + + // Authorize initiator but not responder + initiatorSession.authorizable = Authorized() + responderSession.authorizable = Unauthorized() + + val data = byteArrayOf(1, 2, 3) + initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data)) + + delay(1.seconds) + assertFalse(tcsDataReceived.isCompleted) + } + + @Test + fun directHandshake_WithValidAppId_Succeeds() = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + val allowedAppId = 1234u + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + + val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt -> + linkType == LinkType.Direct && appId == allowedAppId + } + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + isHandshakeAllowed = responderIsHandshakeAllowed + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = allowedAppId) + responderSession.startAsResponder() + + withTimeout(5.seconds) { + handshakeInitiatorCompleted.await() + handshakeResponderCompleted.await() + } + + assertNotNull(initiatorSession.remotePublicKey) + assertNotNull(responderSession.remotePublicKey) + } + + @Test + fun directHandshake_WithInvalidAppId_Fails() = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + val allowedAppId = 1234u + val invalidAppId = 5678u + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + val initiatorClosed = CompletableDeferred() + val responderClosed = CompletableDeferred() + + val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt -> + linkType == LinkType.Direct && appId == allowedAppId + } + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + onInitiatorClose = { + initiatorClosed.complete(true) + }, + onResponderClose = { + responderClosed.complete(true) + }, + isHandshakeAllowed = responderIsHandshakeAllowed + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = invalidAppId) + responderSession.startAsResponder() + + withTimeout(5.seconds) { + initiatorClosed.await() + responderClosed.await() + } + + assertFalse(handshakeInitiatorCompleted.isCompleted) + assertFalse(handshakeResponderCompleted.isCompleted) + } +} + +class Authorized : IAuthorizable { + override val isAuthorized: Boolean = true +} + +class Unauthorized : IAuthorizable { + override val isAuthorized: Boolean = false +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c9917a2d..176dc044 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ + - \ No newline at end of file + diff --git a/app/src/main/assets/scripts/source.js b/app/src/main/assets/scripts/source.js index 1a30c006..86a23cbc 100644 --- a/app/src/main/assets/scripts/source.js +++ b/app/src/main/assets/scripts/source.js @@ -32,7 +32,8 @@ let Type = { Text: { RAW: 0, HTML: 1, - MARKUP: 2 + MARKUP: 2, + CODE: 3 }, Chapter: { NORMAL: 0, @@ -291,15 +292,39 @@ class PlatformPostDetails extends PlatformPost { } } -class PlatformArticleDetails extends PlatformContent { +class PlatformWeb extends PlatformContent { + constructor(obj) { + super(obj, 7); + obj = obj ?? {}; + this.plugin_type = "PlatformWeb"; + } +} +class PlatformWebDetails extends PlatformWeb { + constructor(obj) { + super(obj, 7); + obj = obj ?? {}; + this.plugin_type = "PlatformWebDetails"; + this.html = obj.html; + } +} + +class PlatformArticle extends PlatformContent { + constructor(obj) { + super(obj, 3); + obj = obj ?? {}; + this.plugin_type = "PlatformArticle"; + this.rating = obj.rating ?? new RatingLikes(-1); + this.summary = obj.summary ?? ""; + this.thumbnails = obj.thumbnails ?? new Thumbnails([]); + } +} +class PlatformArticleDetails extends PlatformArticle { constructor(obj) { super(obj, 3); obj = obj ?? {}; this.plugin_type = "PlatformArticleDetails"; this.rating = obj.rating ?? new RatingLikes(-1); - this.summary = obj.summary ?? ""; this.segments = obj.segments ?? []; - this.thumbnails = obj.thumbnails ?? new Thumbnails([]); } } class ArticleSegment { @@ -315,9 +340,17 @@ class ArticleTextSegment extends ArticleSegment { } } class ArticleImagesSegment extends ArticleSegment { - constructor(images) { + constructor(images, caption) { super(2); this.images = images; + this.caption = caption; + } +} +class ArticleHeaderSegment extends ArticleSegment { + constructor(content, level) { + super(3); + this.level = level; + this.content = content; } } class ArticleNestedSegment extends ArticleSegment { @@ -595,6 +628,8 @@ class PlatformComment { this.date = obj.date ?? 0; this.replyCount = obj.replyCount ?? 0; this.context = obj.context ?? {}; + if(obj.getReplies) + this.getReplies = obj.getReplies; } } diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt index 4ddf37ad..42210c60 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt @@ -14,7 +14,6 @@ import java.text.DecimalFormat import java.time.OffsetDateTime import java.time.temporal.ChronoUnit import kotlin.math.abs -import kotlin.math.roundToInt import kotlin.math.roundToLong @@ -376,14 +375,19 @@ private val slds = hashSetOf(".com.ac", ".net.ac", ".gov.ac", ".org.ac", ".mil.a fun String.matchesDomain(queryDomain: String): Boolean { if(queryDomain.startsWith(".")) { - - val parts = queryDomain.lowercase().split("."); - if(parts.size < 3) + val parts = this.lowercase().split("."); + val queryParts = queryDomain.lowercase().trimStart("."[0]).split("."); + if(queryParts.size < 2) throw IllegalStateException("Illegal use of wildcards on First-Level-Domain (" + queryDomain + ")"); - if(parts.size >= 3){ - val isSLD = slds.contains("." + parts[parts.size - 2] + "." + parts[parts.size - 1]); - if(isSLD && parts.size <= 3) + else { + val possibleDomain = "." + queryParts.joinToString("."); + if(slds.contains(possibleDomain)) throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")"); + /* + val isSLD = slds.contains("." + queryParts[queryParts.size - 2] + "." + queryParts[queryParts.size - 1]); + if(isSLD && queryParts.size <= 3) + throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")"); + */ } //TODO: Should be safe, but double verify if can't be exploited @@ -395,9 +399,11 @@ fun String.matchesDomain(queryDomain: String): Boolean { fun String.getSubdomainWildcardQuery(): String { val domainParts = this.split("."); - val sldParts = "." + domainParts[domainParts.size - 2].lowercase() + "." + domainParts[domainParts.size - 1].lowercase(); - if(slds.contains(sldParts)) - return "." + domainParts.drop(domainParts.size - 3).joinToString("."); + var wildcardDomain = if(domainParts.size > 2) + "." + domainParts.drop(1).joinToString(".") else - return "." + domainParts.drop(domainParts.size - 2).joinToString("."); + "." + domainParts.joinToString("."); + if(slds.contains(wildcardDomain.lowercase())) + "." + domainParts.joinToString("."); + return wildcardDomain; } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt index 00f47885..9fe21ec8 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt @@ -217,6 +217,8 @@ private fun ByteArray.toInetAddress(): InetAddress { } fun getConnectedSocket(attemptAddresses: List, port: Int): Socket? { + ensureNotMainThread() + val timeout = 2000 diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt index 442304d4..f1e63366 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt @@ -7,6 +7,9 @@ import java.net.InetAddress import java.net.URI import java.net.URISyntaxException import java.net.URLEncoder +import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneOffset //Syntax sugaring inline fun Any.assume(): T?{ @@ -33,13 +36,37 @@ fun Boolean?.toYesNo(): String { fun InetAddress?.toUrlAddress(): String { return when (this) { is Inet6Address -> { - "[${hostAddress}]" + val hostAddr = this.hostAddress ?: throw Exception("Invalid address: hostAddress is null") + val index = hostAddr.indexOf('%') + if (index != -1) { + val addrPart = hostAddr.substring(0, index) + val scopeId = hostAddr.substring(index + 1) + "[${addrPart}%25${scopeId}]" // %25 is URL-encoded '%' + } else { + "[$hostAddr]" + } } is Inet4Address -> { - hostAddress + this.hostAddress ?: throw Exception("Invalid address: hostAddress is null") } else -> { throw Exception("Invalid address type") } } +} + +fun Long?.sToOffsetDateTimeUTC(): OffsetDateTime { + if (this == null || this < 0) + return OffsetDateTime.MIN + if(this > 4070912400) + return OffsetDateTime.MAX; + return OffsetDateTime.ofInstant(Instant.ofEpochSecond(this), ZoneOffset.UTC) +} + +fun Long?.msToOffsetDateTimeUTC(): OffsetDateTime { + if (this == null || this < 0) + return OffsetDateTime.MIN + if(this > 4070912400) + return OffsetDateTime.MAX; + return OffsetDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneOffset.UTC) } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 2bd95905..7f827f66 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -499,6 +499,22 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22) var deleteFromWatchLaterAuto: Boolean = true; + + @FormField(R.string.seek_offset, FieldForm.DROPDOWN, R.string.seek_offset_description, 23) + @DropdownFieldOptionsId(R.array.seek_offset_duration) + var seekOffset: Int = 2; + + fun getSeekOffset(): Long { + return when(seekOffset) { + 0 -> 3_000L; + 1 -> 5_000L; + 2 -> 10_000L; + 3 -> 20_000L; + 4 -> 30_000L; + 5 -> 60_000L; + else -> 10_000L; + } + } } @FormField(R.string.comments, "group", R.string.comments_description, 6) @@ -590,7 +606,7 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4) @Serializable(with = FlexibleBooleanSerializer::class) - var allowIpv6: Boolean = false; + var allowIpv6: Boolean = true; /*TODO: Should we have a different casting quality? @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @@ -926,7 +942,7 @@ class Settings : FragmentedStorageFileJson() { @Serializable class Synchronization { @FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1) - var enabled: Boolean = true; + var enabled: Boolean = false; @FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1) var broadcast: Boolean = false; @@ -936,6 +952,21 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.connect_last, FieldForm.TOGGLE, R.string.connect_last_description, 3) var connectLast: Boolean = true; + + @FormField(R.string.discover_through_relay, FieldForm.TOGGLE, R.string.discover_through_relay_description, 3) + var discoverThroughRelay: Boolean = true; + + @FormField(R.string.pair_through_relay, FieldForm.TOGGLE, R.string.pair_through_relay_description, 3) + var pairThroughRelay: Boolean = true; + + @FormField(R.string.connect_through_relay, FieldForm.TOGGLE, R.string.connect_through_relay_description, 3) + var connectThroughRelay: Boolean = true; + + @FormField(R.string.connect_local_direct_through_relay, FieldForm.TOGGLE, R.string.connect_local_direct_through_relay_description, 3) + var connectLocalDirectThroughRelay: Boolean = true; + + @FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3) + var localConnections: Boolean = true; } @FormField(R.string.info, FieldForm.GROUP, -1, 21) @@ -1004,4 +1035,4 @@ class Settings : FragmentedStorageFileJson() { } } //endregion -} \ No newline at end of file +} diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index 8034854d..1d7e3ef7 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -319,7 +319,11 @@ class UIDialogs { closeAction?.invoke() }, UIDialogs.ActionStyle.NONE), UIDialogs.Action(context.getString(R.string.retry), { - retryAction?.invoke(); + try { + retryAction?.invoke(); + } catch (e: Throwable) { + Logger.e(TAG, "Unhandled exception retrying", e) + } }, UIDialogs.ActionStyle.PRIMARY) ); else @@ -333,7 +337,11 @@ class UIDialogs { closeAction?.invoke() }, UIDialogs.ActionStyle.NONE), UIDialogs.Action(context.getString(R.string.retry), { - retryAction?.invoke(); + try { + retryAction?.invoke(); + } catch (e: Throwable) { + Logger.e(TAG, "Unhandled exception retrying", e) + } }, UIDialogs.ActionStyle.PRIMARY) ); } diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 874ffd4f..3db07410 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -4,8 +4,14 @@ import android.app.NotificationManager import android.content.ContentResolver import android.content.Context import android.content.Intent +import android.net.Uri import android.view.View import android.view.ViewGroup +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory +import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist +import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.SettingsActivity @@ -37,6 +43,9 @@ import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.parsers.HLS +import com.futo.platformplayer.parsers.HLS.MediaRendition +import com.futo.platformplayer.parsers.HLS.StreamInfo +import com.futo.platformplayer.parsers.HLS.VariantPlaylistReference import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateHistory @@ -63,6 +72,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.ByteArrayInputStream +import androidx.core.net.toUri class UISlideOverlays { companion object { @@ -299,6 +310,7 @@ class UISlideOverlays { } + @OptIn(UnstableApi::class) fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay { val items = arrayListOf(LoaderView(container.context)) val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items) @@ -310,6 +322,8 @@ class UISlideOverlays { val masterPlaylistContent = masterPlaylistResponse.body?.string() ?: throw Exception("Master playlist content is empty") + val resolvedPlaylistUrl = masterPlaylistResponse.url + val videoButtons = arrayListOf() val audioButtons = arrayListOf() //TODO: Implement subtitles @@ -322,55 +336,103 @@ class UISlideOverlays { val masterPlaylist: HLS.MasterPlaylist try { - masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) + val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray()) + val playlist = DefaultHlsPlaylistParserFactory().createPlaylistParser() + .parse(sourceUrl.toUri(), inputStream) - masterPlaylist.getAudioSources().forEach { it -> + if (playlist is HlsMediaPlaylist) { + if (source is IHLSManifestAudioSource) { + val variant = HLS.mediaRenditionToVariant(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null))!! - val estSize = VideoHelper.estimateSourceSize(it); - val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; - audioButtons.add(SlideUpMenuItem( - container.context, - R.drawable.ic_music, - it.name, - listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), - (prefix + it.codec).trim(), - tag = it, - call = { - selectedAudioVariant = it - slideUpMenuOverlay.selectOption(audioButtons, it) - slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) - }, - invokeParent = false - )) - } - - /*masterPlaylist.getSubtitleSources().forEach { it -> - subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, { - selectedSubtitleVariant = it - slideUpMenuOverlay.selectOption(subtitleButtons, it) - slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) - }, false)) - }*/ - - masterPlaylist.getVideoSources().forEach { - val estSize = VideoHelper.estimateSourceSize(it); - val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; - videoButtons.add(SlideUpMenuItem( - container.context, - R.drawable.ic_movie, - it.name, - "${it.width}x${it.height}", - (prefix + it.codec).trim(), - tag = it, - call = { - selectedVideoVariant = it - slideUpMenuOverlay.selectOption(videoButtons, it) - if (audioButtons.isEmpty()){ + val estSize = VideoHelper.estimateSourceSize(variant); + val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + audioButtons.add(SlideUpMenuItem( + container.context, + R.drawable.ic_music, + variant.name, + listOf(variant.language, variant.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), + (prefix + variant.codec).trim(), + tag = variant, + call = { + selectedAudioVariant = variant + slideUpMenuOverlay.selectOption(audioButtons, variant) slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) - } - }, - invokeParent = false - )) + }, + invokeParent = false + )) + } else { + val variant = HLS.variantReferenceToVariant(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null))) + + val estSize = VideoHelper.estimateSourceSize(variant); + val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + videoButtons.add(SlideUpMenuItem( + container.context, + R.drawable.ic_movie, + variant.name, + "${variant.width}x${variant.height}", + (prefix + variant.codec).trim(), + tag = variant, + call = { + selectedVideoVariant = variant + slideUpMenuOverlay.selectOption(videoButtons, variant) + if (audioButtons.isEmpty()){ + slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) + } + }, + invokeParent = false + )) + } + } else if (playlist is HlsMultivariantPlaylist) { + masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, resolvedPlaylistUrl) + + masterPlaylist.getAudioSources().forEach { it -> + + val estSize = VideoHelper.estimateSourceSize(it); + val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + audioButtons.add(SlideUpMenuItem( + container.context, + R.drawable.ic_music, + it.name, + listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), + (prefix + it.codec).trim(), + tag = it, + call = { + selectedAudioVariant = it + slideUpMenuOverlay.selectOption(audioButtons, it) + slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) + }, + invokeParent = false + )) + } + + /*masterPlaylist.getSubtitleSources().forEach { it -> + subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, { + selectedSubtitleVariant = it + slideUpMenuOverlay.selectOption(subtitleButtons, it) + slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) + }, false)) + }*/ + + masterPlaylist.getVideoSources().forEach { + val estSize = VideoHelper.estimateSourceSize(it); + val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + videoButtons.add(SlideUpMenuItem( + container.context, + R.drawable.ic_movie, + it.name, + "${it.width}x${it.height}", + (prefix + it.codec).trim(), + tag = it, + call = { + selectedVideoVariant = it + slideUpMenuOverlay.selectOption(videoButtons, it) + if (audioButtons.isEmpty()){ + slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) + } + }, + invokeParent = false + )) + } } val newItems = arrayListOf() @@ -398,11 +460,11 @@ class UISlideOverlays { if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { withContext(Dispatchers.Main) { if (source is IHLSManifestSource) { - StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, sourceUrl), null, null) + StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null) UIDialogs.toast(container.context, "Variant video HLS playlist download started") slideUpMenuOverlay.hide() } else if (source is IHLSManifestAudioSource) { - StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, sourceUrl), null) + StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null) UIDialogs.toast(container.context, "Variant audio HLS playlist download started") slideUpMenuOverlay.hide() } else { @@ -684,6 +746,10 @@ class UISlideOverlays { } } } + if(!Settings.instance.downloads.shouldDownload()) { + UIDialogs.appToast("Download will start when you're back on wifi.\n" + + "(You can change this in settings)", true); + } } }; return menu.apply { show() }; @@ -980,26 +1046,30 @@ class UISlideOverlays { + actions).filterNotNull() )); items.add( - SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto", - SlideUpMenuItem(container.context, + SlideUpMenuGroup( + container.context, container.context.getString(R.string.add_to), "addto", + SlideUpMenuItem( + container.context, R.drawable.ic_queue_add, container.context.getString(R.string.add_to_queue), "${queue.size} " + container.context.getString(R.string.videos), tag = "queue", call = { StatePlayer.instance.addToQueue(video); }), - SlideUpMenuItem(container.context, + SlideUpMenuItem( + container.context, R.drawable.ic_watchlist_add, "${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "", "${watchLater.size} " + container.context.getString(R.string.videos), tag = "watch later", call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }), - SlideUpMenuItem(container.context, + SlideUpMenuItem( + container.context, R.drawable.ic_history, container.context.getString(R.string.add_to_history), "Mark as watched", tag = "history", call = { StateHistory.instance.markAsWatched(video); }), - )); + )); val playlistItems = arrayListOf(); playlistItems.add(SlideUpMenuItem( @@ -1063,14 +1133,17 @@ class UISlideOverlays { val queue = StatePlayer.instance.getQueue(); val watchLater = StatePlaylists.instance.getWatchLater(); items.add( - SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other", - SlideUpMenuItem(container.context, + SlideUpMenuGroup( + container.context, container.context.getString(R.string.other), "other", + SlideUpMenuItem( + container.context, R.drawable.ic_queue_add, container.context.getString(R.string.queue), "${queue.size} " + container.context.getString(R.string.videos), tag = "queue", call = { StatePlayer.instance.addToQueue(video); }), - SlideUpMenuItem(container.context, + SlideUpMenuItem( + container.context, R.drawable.ic_watchlist_add, StatePlayer.TYPE_WATCHLATER, "${watchLater.size} " + container.context.getString(R.string.videos), @@ -1079,7 +1152,7 @@ class UISlideOverlays { if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true)) UIDialogs.appToast("Added to watch later", false); }), - ) + ) ); val playlistItems = arrayListOf(); @@ -1117,8 +1190,8 @@ class UISlideOverlays { return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() }; } - fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List, filterValues: HashMap>, isChannelSearch: Boolean = false): SlideUpMenuFilters { - val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues, isChannelSearch); + fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List, filterValues: HashMap>): SlideUpMenuFilters { + val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues); overlay.show(); return overlay; } diff --git a/app/src/main/java/com/futo/platformplayer/Utility.kt b/app/src/main/java/com/futo/platformplayer/Utility.kt index 5cd5d26f..bfa7925b 100644 --- a/app/src/main/java/com/futo/platformplayer/Utility.kt +++ b/app/src/main/java/com/futo/platformplayer/Utility.kt @@ -28,12 +28,17 @@ import com.futo.platformplayer.models.PlatformVideoWithTime import com.futo.platformplayer.others.PlatformLinkMovementMethod import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream -import java.io.File import java.io.IOException import java.io.InputStream import java.io.OutputStream +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.net.InterfaceAddress +import java.net.NetworkInterface +import java.net.SocketException import java.nio.ByteBuffer -import java.nio.ByteOrder +import java.security.SecureRandom import java.time.OffsetDateTime import java.util.* import java.util.concurrent.ThreadLocalRandom @@ -70,7 +75,14 @@ fun warnIfMainThread(context: String) { } fun ensureNotMainThread() { - if (Looper.myLooper() == Looper.getMainLooper()) { + val isMainLooper = try { + Looper.myLooper() == Looper.getMainLooper() + } catch (e: Throwable) { + //Ignore, for unit tests where its not mocked + false + } + + if (isMainLooper) { Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread") throw IllegalStateException("Cannot run on main thread") } @@ -273,7 +285,7 @@ fun findNewIndex(originalArr: List, newArr: List, item: T): Int{ } } if(newIndex < 0) - return originalArr.size; + return newArr.size; else return newIndex; } @@ -284,6 +296,18 @@ fun ByteBuffer.toUtf8String(): String { return String(remainingBytes, Charsets.UTF_8) } +fun generateReadablePassword(length: Int): String { + val validChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789" + val secureRandom = SecureRandom() + val randomBytes = ByteArray(length) + secureRandom.nextBytes(randomBytes) + val sb = StringBuilder(length) + for (byte in randomBytes) { + val index = (byte.toInt() and 0xFF) % validChars.length + sb.append(validChars[index]) + } + return sb.toString() +} fun ByteArray.toGzip(): ByteArray { if (this == null || this.isEmpty()) return ByteArray(0) @@ -313,4 +337,98 @@ fun ByteArray.fromGzip(): ByteArray { } } return outputStream.toByteArray() -} \ No newline at end of file +} + +fun findPreferredAddress(): InetAddress? { + val candidates = NetworkInterface.getNetworkInterfaces() + .toList() + .asSequence() + .filter(::isUsableInterface) + .flatMap { nif -> + nif.interfaceAddresses + .asSequence() + .mapNotNull { ia -> + ia.address.takeIf(::isUsableAddress)?.let { addr -> + nif to ia + } + } + } + .toList() + + return candidates + .minWithOrNull( + compareBy>( + { addressScore(it.second.address) }, + { interfaceScore(it.first) }, + { -it.second.networkPrefixLength.toInt() }, + { -it.first.mtu } + ) + )?.second?.address +} + +private fun isUsableInterface(nif: NetworkInterface): Boolean { + val name = nif.name.lowercase() + return try { + // must be up, not loopback/virtual/PtP, have a MAC, not Docker/tun/etc. + nif.isUp + && !nif.isLoopback + && !nif.isPointToPoint + && !nif.isVirtual + && !name.startsWith("docker") + && !name.startsWith("veth") + && !name.startsWith("br-") + && !name.startsWith("virbr") + && !name.startsWith("vmnet") + && !name.startsWith("tun") + && !name.startsWith("tap") + } catch (e: SocketException) { + false + } +} + +private fun isUsableAddress(addr: InetAddress): Boolean { + return when { + addr.isAnyLocalAddress -> false // 0.0.0.0 / :: + addr.isLoopbackAddress -> false + addr.isLinkLocalAddress -> false // 169.254.x.x or fe80::/10 + addr.isMulticastAddress -> false + else -> true + } +} + +private fun interfaceScore(nif: NetworkInterface): Int { + val name = nif.name.lowercase() + return when { + name.matches(Regex("^(eth|enp|eno|ens|em)\\d+")) -> 0 + name.startsWith("eth") || name.contains("ethernet") -> 0 + name.matches(Regex("^(wlan|wlp)\\d+")) -> 1 + name.contains("wi-fi") || name.contains("wifi") -> 1 + else -> 2 + } +} + +private fun addressScore(addr: InetAddress): Int { + return when (addr) { + is Inet4Address -> { + val octets = addr.address.map { it.toInt() and 0xFF } + when { + octets[0] == 10 -> 0 // 10/8 + octets[0] == 192 && octets[1] == 168 -> 0 // 192.168/16 + octets[0] == 172 && octets[1] in 16..31 -> 0 // 172.16–31/12 + else -> 1 // public IPv4 + } + } + is Inet6Address -> { + // ULA (fc00::/7) vs global vs others + val b0 = addr.address[0].toInt() and 0xFF + when { + (b0 and 0xFE) == 0xFC -> 2 // ULA + (b0 and 0xE0) == 0x20 -> 3 // global + else -> 4 + } + } + else -> Int.MAX_VALUE + } +} + +fun Enumeration.toList(): List = Collections.list(this) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 73c40a2f..199598bd 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -1,12 +1,15 @@ package com.futo.platformplayer.activities import android.annotation.SuppressLint +import android.app.AlertDialog +import android.app.UiModeManager import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.pm.PackageManager import android.content.res.Configuration import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.StrictMode import android.os.StrictMode.VmPolicy @@ -19,6 +22,7 @@ import android.widget.ImageView import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.motion.widget.MotionLayout import androidx.core.app.ActivityCompat @@ -28,6 +32,8 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentContainerView import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.whenStateAtLeast +import androidx.lifecycle.withStateAtLeast import androidx.media3.common.util.UnstableApi import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.R @@ -36,7 +42,9 @@ import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.dp import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment +import com.futo.platformplayer.fragment.mainactivity.main.ArticleDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment import com.futo.platformplayer.fragment.mainactivity.main.BuyFragment import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment @@ -64,7 +72,9 @@ import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragm import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment import com.futo.platformplayer.fragment.mainactivity.main.TutorialFragment import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment +import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.State import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment +import com.futo.platformplayer.fragment.mainactivity.main.WebDetailFragment import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment @@ -144,6 +154,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { //Frags Main lateinit var _fragMainHome: HomeFragment; lateinit var _fragPostDetail: PostDetailFragment; + lateinit var _fragArticleDetail: ArticleDetailFragment; + lateinit var _fragWebDetail: WebDetailFragment; lateinit var _fragMainVideoSearchResults: ContentSearchResultsFragment; lateinit var _fragMainCreatorSearchResults: CreatorSearchResultsFragment; lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment; @@ -184,6 +196,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { private var _isVisible = true; private var _wasStopped = false; + private var _privateModeEnabled = false + private var _pictureInPictureEnabled = false + private var _isFullscreen = false private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data) @@ -195,7 +210,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { } try { - runBlocking { + lifecycleScope.launch { handleUrlAll(content) } } catch (e: Throwable) { @@ -261,6 +276,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { StateApp.instance.mainAppStarting(this); super.onCreate(savedInstanceState); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val uiMode = getSystemService(UiModeManager::class.java) + uiMode.setApplicationNightMode(UiModeManager.MODE_NIGHT_YES) + } setContentView(R.layout.activity_main); setNavigationBarColorAndIcons(); if (Settings.instance.playback.allowVideoToGoUnderCutout) @@ -268,7 +287,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES runBlocking { - StatePlatform.instance.updateAvailableClients(this@MainActivity); + try { + StatePlatform.instance.updateAvailableClients(this@MainActivity); + } catch (e: Throwable) { + Logger.e(TAG, "Unhandled exception in updateAvailableClients", e) + } } //Preload common files to memory @@ -312,6 +335,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragMainPlaylist = PlaylistFragment.newInstance(); _fragMainRemotePlaylist = RemotePlaylistFragment.newInstance(); _fragPostDetail = PostDetailFragment.newInstance(); + _fragArticleDetail = ArticleDetailFragment.newInstance(); + _fragWebDetail = WebDetailFragment.newInstance(); _fragWatchlist = WatchLaterFragment.newInstance(); _fragHistory = HistoryFragment.newInstance(); _fragShorts = ShortsFragment.newInstance(); @@ -358,22 +383,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragMainSubscriptionsFeed.setPreviewsEnabled(true); _fragContainerVideoDetail.visibility = View.INVISIBLE; updateSegmentPaddings(); + updatePrivateModeVisibility() }; _buttonIncognito = findViewById(R.id.incognito_button); - _buttonIncognito.elevation = -99f; - _buttonIncognito.alpha = 0f; + updatePrivateModeVisibility() StateApp.instance.privateModeChanged.subscribe { //Messing with visibility causes some issues with layout ordering? - if (it) { - _buttonIncognito.elevation = 99f; - _buttonIncognito.alpha = 1f; - } else { - _buttonIncognito.elevation = -99f; - _buttonIncognito.alpha = 0f; - } + _privateModeEnabled = it + updatePrivateModeVisibility() } + _buttonIncognito.setOnClickListener { if (!StateApp.instance.privateMode) return@setOnClickListener; @@ -390,19 +411,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { }; _fragVideoDetail.onFullscreenChanged.subscribe { Logger.i(TAG, "onFullscreenChanged ${it}"); + _isFullscreen = it + updatePrivateModeVisibility() + } - if (it) { - _buttonIncognito.elevation = -99f; - _buttonIncognito.alpha = 0f; - } else { - if (StateApp.instance.privateMode) { - _buttonIncognito.elevation = 99f; - _buttonIncognito.alpha = 1f; - } else { - _buttonIncognito.elevation = -99f; - _buttonIncognito.alpha = 0f; - } - } + _fragVideoDetail.onMinimize.subscribe { + updatePrivateModeVisibility() + } + + _fragVideoDetail.onMaximized.subscribe { + updatePrivateModeVisibility() } StatePlayer.instance.also { @@ -450,6 +468,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragMainPlaylist.topBar = _fragTopBarNavigation; _fragMainRemotePlaylist.topBar = _fragTopBarNavigation; _fragPostDetail.topBar = _fragTopBarNavigation; + _fragArticleDetail.topBar = _fragTopBarNavigation; + _fragWebDetail.topBar = _fragTopBarNavigation; _fragWatchlist.topBar = _fragTopBarNavigation; _fragHistory.topBar = _fragTopBarNavigation; _fragSourceDetail.topBar = _fragTopBarNavigation; @@ -617,8 +637,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { UIDialogs.toast(this, "No external file permissions\nExporting and auto backups will not work"); }*/ + private var _qrCodeLoadingDialog: AlertDialog? = null + fun showUrlQrCodeScanner() { try { + _qrCodeLoadingDialog = UIDialogs.showDialog(this, R.drawable.ic_loader_animated, true, + "Launching QR scanner", + "Make sure your camera is enabled", null, -2, + UIDialogs.Action("Close", { + _qrCodeLoadingDialog?.dismiss() + _qrCodeLoadingDialog = null + })); + val integrator = IntentIntegrator(this) integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) integrator.setPrompt(getString(R.string.scan_a_qr_code)) @@ -634,6 +664,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { } } + @OptIn(UnstableApi::class) + private fun updatePrivateModeVisibility() { + if (_privateModeEnabled && (_fragVideoDetail.state == State.CLOSED || !_pictureInPictureEnabled && !_isFullscreen)) { + _buttonIncognito.elevation = 99f; + _buttonIncognito.alpha = 1f; + _buttonIncognito.translationY = if (_fragVideoDetail.state == State.MINIMIZED) -60.dp(resources).toFloat() else 0f + } else { + _buttonIncognito.elevation = -99f; + _buttonIncognito.alpha = 0f; + } + } + override fun onResume() { super.onResume(); Logger.v(TAG, "onResume") @@ -644,6 +686,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { super.onPause(); Logger.v(TAG, "onPause") _isVisible = false; + + _qrCodeLoadingDialog?.dismiss() + _qrCodeLoadingDialog = null } override fun onStop() { @@ -682,7 +727,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { "VIDEO" -> { val url = intent.getStringExtra("VIDEO"); - navigate(_fragVideoDetail, url); + navigateWhenReady(_fragVideoDetail, url); } "IMPORT_OPTIONS" -> { @@ -700,11 +745,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { "Sources" -> { runBlocking { StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed.. - navigate(_fragMainSources); + navigateWhenReady(_fragMainSources); } }; "BROWSE_PLUGINS" -> { - navigate(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf( + navigateWhenReady(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf( Pair("grayjay") { req -> StateApp.instance.contextOrNull?.let { if (it is MainActivity) { @@ -722,8 +767,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { try { if (targetData != null) { - runBlocking { - handleUrlAll(targetData) + lifecycleScope.launch(Dispatchers.Main) { + try { + handleUrlAll(targetData) + } catch (e: Throwable) { + Logger.e(TAG, "Unhandled exception in handleUrlAll", e) + } } } } catch (ex: Throwable) { @@ -751,10 +800,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { startActivity(intent); } else if (url.startsWith("grayjay://video/")) { val videoUrl = url.substring("grayjay://video/".length); - navigate(_fragVideoDetail, videoUrl); + navigateWhenReady(_fragVideoDetail, videoUrl); } else if (url.startsWith("grayjay://channel/")) { val channelUrl = url.substring("grayjay://channel/".length); - navigate(_fragMainChannel, channelUrl); + navigateWhenReady(_fragMainChannel, channelUrl); } } @@ -820,29 +869,29 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { return withContext(Dispatchers.IO) { Logger.i(TAG, "handleUrl(url=$url) on IO"); - if (StatePlatform.instance.hasEnabledVideoClient(url)) { + if (StatePlatform.instance.hasEnabledContentClient(url)) { Logger.i(TAG, "handleUrl(url=$url) found video client"); - lifecycleScope.launch(Dispatchers.Main) { + withContext(Dispatchers.Main) { if (position > 0) - navigate(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true)); + navigateWhenReady(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true)); else - navigate(_fragVideoDetail, url); + navigateWhenReady(_fragVideoDetail, url); _fragVideoDetail.maximizeVideoDetail(true); } return@withContext true; } else if (StatePlatform.instance.hasEnabledChannelClient(url)) { Logger.i(TAG, "handleUrl(url=$url) found channel client"); - lifecycleScope.launch(Dispatchers.Main) { - navigate(_fragMainChannel, url); + withContext(Dispatchers.Main) { + navigateWhenReady(_fragMainChannel, url); delay(100); _fragVideoDetail.minimizeVideoDetail(); }; return@withContext true; } else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) { Logger.i(TAG, "handleUrl(url=$url) found playlist client"); - lifecycleScope.launch(Dispatchers.Main) { - navigate(_fragMainRemotePlaylist, url); + withContext(Dispatchers.Main) { + navigateWhenReady(_fragMainRemotePlaylist, url); delay(100); _fragVideoDetail.minimizeVideoDetail(); }; @@ -1054,6 +1103,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { Logger.v(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop") _fragVideoDetail.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig); Logger.v(TAG, "onPictureInPictureModeChanged Ready"); + + _pictureInPictureEnabled = isInPictureInPictureMode + updatePrivateModeVisibility() } override fun onDestroy() { @@ -1066,6 +1118,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { return fragCurrent is T; } + fun navigateWhenReady(segment: MainFragment, parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) { + if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + navigate(segment, parameter, withHistory, isBack) + } else { + lifecycleScope.launch { + lifecycle.withStateAtLeast(Lifecycle.State.RESUMED) { + navigate(segment, parameter, withHistory, isBack) + } + } + } + } + /** * Navigate takes a MainFragment, and makes them the current main visible view * A parameter can be provided which becomes available in the onShow of said fragment @@ -1191,6 +1255,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { PlaylistFragment::class -> _fragMainPlaylist as T; RemotePlaylistFragment::class -> _fragMainRemotePlaylist as T; PostDetailFragment::class -> _fragPostDetail as T; + ArticleDetailFragment::class -> _fragArticleDetail as T; + WebDetailFragment::class -> _fragWebDetail as T; WatchLaterFragment::class -> _fragWatchlist as T; HistoryFragment::class -> _fragHistory as T; ShortsFragment::class -> _fragShorts as T; diff --git a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt index d1cd7706..0fff7b06 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt @@ -9,6 +9,8 @@ import android.widget.LinearLayout import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateSync @@ -29,6 +31,16 @@ class SyncHomeActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + if (StateApp.instance.contextOrNull == null) { + Logger.w(TAG, "No main activity, restarting main.") + val intent = Intent(this, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + startActivity(intent) + finish() + return + } + setContentView(R.layout.activity_sync_home) setNavigationBarColorAndIcons() @@ -54,7 +66,6 @@ class SyncHomeActivity : AppCompatActivity() { val view = _viewMap[publicKey] if (!session.isAuthorized) { if (view != null) { - _layoutDevices.removeView(view) _viewMap.remove(publicKey) } return@launch @@ -89,6 +100,20 @@ class SyncHomeActivity : AppCompatActivity() { updateEmptyVisibility() } } + + StateSync.instance.confirmStarted(this, onStarted = { + if (StateSync.instance.syncService?.serverSocketFailedToStart == true) { + UIDialogs.toast(this, "Server socket failed to start, is the port in use?", true) + } + if (StateSync.instance.syncService?.relayConnected == false) { + UIDialogs.toast(this, "Not connected to relay, remote connections will work.", false) + } + if (StateSync.instance.syncService?.serverSocketStarted == false) { + UIDialogs.toast(this, "Listener not started, local connections will not work.", false) + } + }, onNotStarted = { + finish() + }) } override fun onDestroy() { @@ -100,10 +125,12 @@ class SyncHomeActivity : AppCompatActivity() { private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView { val connected = session?.connected ?: false - syncDeviceView.setLinkType(if (connected) LinkType.Local else LinkType.None) + val authorized = session?.isAuthorized ?: false + + syncDeviceView.setLinkType(session?.linkType ?: LinkType.None) .setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey) //TODO: also display public key? - .setStatus(if (connected) "Connected" else "Disconnected") + .setStatus(if (connected && authorized) "Connected" else "Disconnected or unauthorized") return syncDeviceView } diff --git a/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt index a7030b97..b0ca616a 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt @@ -83,6 +83,7 @@ class SyncPairActivity : AppCompatActivity() { _layoutPairingSuccess.setOnClickListener { _layoutPairingSuccess.visibility = View.GONE + finish() } _layoutPairingError.setOnClickListener { _layoutPairingError.visibility = View.GONE @@ -109,11 +110,17 @@ class SyncPairActivity : AppCompatActivity() { lifecycleScope.launch(Dispatchers.IO) { try { - StateSync.instance.connect(deviceInfo) { session, complete, message -> + StateSync.instance.syncService?.connect(deviceInfo) { complete, message -> lifecycleScope.launch(Dispatchers.Main) { - if (complete) { - _layoutPairingSuccess.visibility = View.VISIBLE - _layoutPairing.visibility = View.GONE + if (complete != null) { + if (complete) { + _layoutPairingSuccess.visibility = View.VISIBLE + _layoutPairing.visibility = View.GONE + } else { + _textError.text = message + _layoutPairingError.visibility = View.VISIBLE + _layoutPairing.visibility = View.GONE + } } else { _textPairingStatus.text = message } @@ -137,8 +144,6 @@ class SyncPairActivity : AppCompatActivity() { _textError.text = e.message _layoutPairing.visibility = View.GONE Logger.e(TAG, "Failed to pair", e) - } finally { - _layoutPairing.visibility = View.GONE } } diff --git a/app/src/main/java/com/futo/platformplayer/activities/SyncShowPairingCodeActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SyncShowPairingCodeActivity.kt index 2fbb4b97..848c320c 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncShowPairingCodeActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncShowPairingCodeActivity.kt @@ -67,11 +67,18 @@ class SyncShowPairingCodeActivity : AppCompatActivity() { } val ips = getIPs() - val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT) - val json = Json.encodeToString(selfDeviceInfo) - val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) - val url = "grayjay://sync/${base64}" - setCode(url) + val publicKey = StateSync.instance.syncService?.publicKey + val pairingCode = StateSync.instance.syncService?.pairingCode + if (publicKey == null || pairingCode == null) { + setCode("Public key or pairing code was not known, is sync enabled?") + } else { + val selfDeviceInfo = SyncDeviceInfo(publicKey, ips.toTypedArray(), StateSync.PORT, pairingCode) + val json = Json.encodeToString(selfDeviceInfo) + val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + val url = "grayjay://sync/${base64}" + setCode(url) + } + } fun setCode(code: String?) { diff --git a/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt b/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt index 641dbed2..089c8106 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt @@ -90,6 +90,7 @@ open class ManagedHttpClient { } fun tryHead(url: String): Map? { + ensureNotMainThread() try { val result = head(url); if(result.isOk) @@ -104,7 +105,7 @@ open class ManagedHttpClient { } fun socket(url: String, headers: MutableMap = HashMap(), listener: SocketListener): Socket { - + ensureNotMainThread() val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder() .url(url); if(user_agent.isNotEmpty() && !headers.any { it.key.lowercase() == "user-agent" }) @@ -300,6 +301,7 @@ open class ManagedHttpClient { } fun send(msg: String) { + ensureNotMainThread() socket.send(msg); } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt index 156bd209..56d8fbd2 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.api.media +import com.futo.platformplayer.api.media.models.IPlatformChannelContent import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.channels.IPlatformChannel @@ -72,6 +73,11 @@ interface IPlatformClient { */ fun searchChannels(query: String): IPager; + /** + * Searches for channels and returns a content pager + */ + fun searchChannelsAsContent(query: String): IPager; + //Video Pages /** diff --git a/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientPool.kt b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientPool.kt index 0119bf38..211f83a6 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientPool.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientPool.kt @@ -14,14 +14,16 @@ class PlatformClientPool { private var _poolCounter = 0; private val _poolName: String?; private val _privatePool: Boolean; + private val _isolatedInitialization: Boolean var isDead: Boolean = false private set; val onDead = Event2(); - constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false) { + constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false, isolatedInitialization: Boolean = false) { _poolName = name; _privatePool = privatePool; + _isolatedInitialization = isolatedInitialization if(parentClient !is JSClient) throw IllegalArgumentException("Pooling only supported for JSClients right now"); Logger.i(TAG, "Pool for ${parentClient.name} was started"); @@ -53,7 +55,7 @@ class PlatformClientPool { reserved = _pool.keys.find { !it.isBusy }; if(reserved == null && _pool.size < capacity) { Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})"); - reserved = _parent.getCopy(_privatePool); + reserved = _parent.getCopy(_privatePool, _isolatedInitialization); reserved?.onCaptchaException?.subscribe { client, ex -> StateApp.instance.handleCaptchaException(client, ex); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/PlatformMultiClientPool.kt b/app/src/main/java/com/futo/platformplayer/api/media/PlatformMultiClientPool.kt index a9fc3819..fcc85371 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/PlatformMultiClientPool.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/PlatformMultiClientPool.kt @@ -7,13 +7,15 @@ class PlatformMultiClientPool { private var _isFake = false; private var _privatePool = false; + private val _isolatedInitialization: Boolean - constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false) { + constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false, isolatedInitialization: Boolean = false) { _name = name; _maxCap = if(maxCap > 0) maxCap else 99; _privatePool = isPrivatePool; + _isolatedInitialization = isolatedInitialization } fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient { @@ -21,7 +23,7 @@ class PlatformMultiClientPool { return parentClient; val pool = synchronized(_clientPools) { if(!_clientPools.containsKey(parentClient)) - _clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool).apply { + _clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool, _isolatedInitialization).apply { this.onDead.subscribe { _, pool -> synchronized(_clientPools) { if(_clientPools[parentClient] == pool) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt index e0acb91e..330597a4 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt @@ -2,7 +2,10 @@ package com.futo.platformplayer.api.media.models import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.platforms.js.models.JSContent import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow @@ -42,4 +45,21 @@ open class PlatformAuthorLink { ); } } +} + +interface IPlatformChannelContent : IPlatformContent { + val thumbnail: String? + val subscribers: Long? +} + +open class JSChannelContent : JSContent, IPlatformChannelContent { + override val contentType: ContentType get() = ContentType.CHANNEL + override val thumbnail: String? + override val subscribers: Long? + + constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) { + val contextName = "Channel"; + thumbnail = obj.getOrDefault(config, "thumbnail", contextName, null) + subscribers = if(obj.has("subscribers")) obj.getOrThrow(config,"subscribers", contextName) else null + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticle.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticle.kt new file mode 100644 index 00000000..818f8a3b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticle.kt @@ -0,0 +1,9 @@ +package com.futo.platformplayer.api.media.models.article + +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.IPlatformContent + +interface IPlatformArticle: IPlatformContent { + val summary: String?; + val thumbnails: Thumbnails?; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticleDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticleDetails.kt new file mode 100644 index 00000000..be7f816d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticleDetails.kt @@ -0,0 +1,12 @@ +package com.futo.platformplayer.api.media.models.article + +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.platforms.js.models.IJSArticleSegment + +interface IPlatformArticleDetails: IPlatformContent, IPlatformArticle, IPlatformContentDetails { + val segments: List; + val rating : IRating; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt index a310e089..736e9090 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt @@ -8,10 +8,12 @@ enum class ContentType(val value: Int) { POST(2), ARTICLE(3), PLAYLIST(4), + WEB(7), URL(9), NESTED_VIDEO(11), + CHANNEL(60), LOCKED(70), diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt index c1de57d1..c59b7e1a 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt @@ -5,7 +5,8 @@ import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException enum class TextType(val value: Int) { RAW(0), HTML(1), - MARKUP(2); + MARKUP(2), + CODE(3); companion object { fun fromInt(value: Int): TextType diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt index 1d726dd5..b26abe45 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt @@ -54,8 +54,11 @@ class DevJSClient : JSClient { return DevJSClient(context, config, _devScript, _auth, _captcha, devID, descriptor.settings); } - override fun getCopy(privateCopy: Boolean): JSClient { - return DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, saveState(), devID); + override fun getCopy(privateCopy: Boolean, noSaveState: Boolean): JSClient { + val client = DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, if (noSaveState) null else saveState(), devID); + if (noSaveState) + client.initialize() + return client } override fun initialize() { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt index 1c08bc83..1ac4c13e 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt @@ -10,6 +10,7 @@ import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.PlatformClientCapabilities +import com.futo.platformplayer.api.media.models.IPlatformChannelContent import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.channels.IPlatformChannel @@ -32,6 +33,7 @@ import com.futo.platformplayer.api.media.platforms.js.internal.JSParameterDocs import com.futo.platformplayer.api.media.platforms.js.models.IJSContent import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails import com.futo.platformplayer.api.media.platforms.js.models.JSChannel +import com.futo.platformplayer.api.media.platforms.js.models.JSChannelContentPager import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager import com.futo.platformplayer.api.media.platforms.js.models.JSChapter import com.futo.platformplayer.api.media.platforms.js.models.JSComment @@ -196,8 +198,11 @@ open class JSClient : IPlatformClient { _plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit); } - open fun getCopy(withoutCredentials: Boolean = false): JSClient { - return JSClient(_context, descriptor, saveState(), _script, withoutCredentials); + open fun getCopy(withoutCredentials: Boolean = false, noSaveState: Boolean = false): JSClient { + val client = JSClient(_context, descriptor, if (noSaveState) null else saveState(), _script, withoutCredentials); + if (noSaveState) + client.initialize() + return client } fun getUnderlyingPlugin(): V8Plugin { @@ -212,6 +217,8 @@ open class JSClient : IPlatformClient { } override fun initialize() { + if (_initialized) return + Logger.i(TAG, "Plugin [${config.name}] initializing"); plugin.start(); plugin.execute("plugin.config = ${Json.encodeToString(config)}"); @@ -371,6 +378,10 @@ open class JSClient : IPlatformClient { return@isBusyWith JSChannelPager(config, this, plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})")); } + override fun searchChannelsAsContent(query: String): IPager = isBusyWith("searchChannels") { + ensureEnabled(); + return@isBusyWith JSChannelContentPager(config, this, plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"), ); + } @JSDocs(6, "source.isChannelUrl(url)", "Validates if an channel url is for this platform") @JSDocsParameter("url", "A channel url (May not be your platform)") diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt index da10e2ec..6f835304 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt @@ -127,7 +127,7 @@ class JSHttpClient : ManagedHttpClient { } if(doApplyCookies) { - if (_currentCookieMap.isNotEmpty()) { + if (_currentCookieMap.isNotEmpty() || _otherCookieMap.isNotEmpty()) { val cookiesToApply = hashMapOf(); synchronized(_currentCookieMap) { for(cookie in _currentCookieMap @@ -135,6 +135,12 @@ class JSHttpClient : ManagedHttpClient { .flatMap { it.value.toList() }) cookiesToApply[cookie.first] = cookie.second; }; + synchronized(_otherCookieMap) { + for(cookie in _otherCookieMap + .filter { domain.matchesDomain(it.key) } + .flatMap { it.value.toList() }) + cookiesToApply[cookie.first] = cookie.second; + } if(cookiesToApply.size > 0) { val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; "); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt index a6a15fb6..777981bf 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt @@ -1,6 +1,7 @@ package com.futo.platformplayer.api.media.platforms.js.models import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.JSChannelContent import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.platforms.js.JSClient @@ -26,6 +27,9 @@ interface IJSContent: IPlatformContent { ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj); ContentType.PLAYLIST -> JSPlaylist(config, obj); ContentType.LOCKED -> JSLockedContent(config, obj); + ContentType.CHANNEL -> JSChannelContent(config, obj); + ContentType.ARTICLE -> JSArticle(config, obj); + ContentType.WEB -> JSWeb(config, obj); else -> throw NotImplementedError("Unknown content type ${type}"); } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContentDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContentDetails.kt index 04382057..21b475ff 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContentDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContentDetails.kt @@ -17,6 +17,7 @@ interface IJSContentDetails: IPlatformContent { ContentType.MEDIA -> JSVideoDetails(plugin, obj); ContentType.POST -> JSPostDetails(plugin.config, obj); ContentType.ARTICLE -> JSArticleDetails(plugin, obj); + ContentType.WEB -> JSWebDetails(plugin, obj); else -> throw NotImplementedError("Unknown content type ${type}"); } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticle.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticle.kt new file mode 100644 index 00000000..d1fb658e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticle.kt @@ -0,0 +1,39 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.IPluginSourced +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.article.IPlatformArticle +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.models.post.TextType +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.platforms.js.DevJSClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.getOrThrowNullableList +import com.futo.platformplayer.states.StateDeveloper + +open class JSArticle : JSContent, IPlatformArticle, IPluginSourced { + final override val contentType: ContentType get() = ContentType.ARTICLE; + + override val summary: String; + override val thumbnails: Thumbnails?; + + constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { + val contextName = "PlatformArticle"; + + summary = _content.getOrDefault(config, "summary", contextName, "") ?: ""; + thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName)); + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt index 453c59d8..0e2c0e32 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt @@ -4,6 +4,8 @@ import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.article.IPlatformArticle +import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent @@ -21,20 +23,20 @@ import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrowNullableList import com.futo.platformplayer.states.StateDeveloper -open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails { +open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails { final override val contentType: ContentType get() = ContentType.ARTICLE; private val _hasGetComments: Boolean; private val _hasGetContentRecommendations: Boolean; - val rating: IRating; + override val rating: IRating; - val summary: String; - val thumbnails: Thumbnails?; - val segments: List; + override val summary: String; + override val thumbnails: Thumbnails?; + override val segments: List; constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) { - val contextName = "PlatformPost"; + val contextName = "PlatformArticle"; rating = obj.getOrDefault(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0); summary = _content.getOrThrow(client.config, "summary", contextName); @@ -99,6 +101,7 @@ open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) { SegmentType.TEXT -> JSTextSegment(client, obj); SegmentType.IMAGES -> JSImagesSegment(client, obj); + SegmentType.HEADER -> JSHeaderSegment(client, obj); SegmentType.NESTED -> JSNestedSegment(client, obj); else -> null; } @@ -110,6 +113,7 @@ enum class SegmentType(val value: Int) { UNKNOWN(0), TEXT(1), IMAGES(2), + HEADER(3), NESTED(9); @@ -150,6 +154,17 @@ class JSImagesSegment: IJSArticleSegment { caption = obj.getOrDefault(client.config, "caption", contextName, "") ?: ""; } } +class JSHeaderSegment: IJSArticleSegment { + override val type = SegmentType.HEADER; + val content: String; + val level: Int; + + constructor(client: JSClient, obj: V8ValueObject) { + val contextName = "JSHeaderSegment"; + content = obj.getOrDefault(client.config, "content", contextName, "") ?: ""; + level = obj.getOrDefault(client.config, "level", contextName, 1) ?: 1; + } +} class JSNestedSegment: IJSArticleSegment { override val type = SegmentType.NESTED; val nested: IPlatformContent; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSChannelPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSChannelPager.kt index 683c64af..3a0331d4 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSChannelPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSChannelPager.kt @@ -5,7 +5,6 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.structures.IPager -import com.futo.platformplayer.engine.V8Plugin class JSChannelPager : JSPager, IPager { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt index ab3b6f10..1e74bd0d 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt @@ -49,8 +49,8 @@ open class JSContent : IPlatformContent, IPluginSourced { else author = PlatformAuthorLink.UNKNOWN; - val datetimeInt = _content.getOrThrow(config, "datetime", contextName).toLong(); - if(datetimeInt == 0.toLong()) + val datetimeInt = _content.getOrDefault(config, "datetime", contextName, null)?.toLong(); + if(datetimeInt == null || datetimeInt == 0.toLong()) datetime = null; else datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContentPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContentPager.kt index 490fa7c4..256e8a5a 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContentPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContentPager.kt @@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js.models import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.IPluginSourced +import com.futo.platformplayer.api.media.models.JSChannelContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig @@ -15,4 +16,14 @@ class JSContentPager : JSPager, IPluginSourced { override fun convertResult(obj: V8ValueObject): IPlatformContent { return IJSContent.fromV8(plugin, obj); } +} + +class JSChannelContentPager : JSPager, IPluginSourced { + override val sourceConfig: SourcePluginConfig get() = config; + + constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {} + + override fun convertResult(obj: V8ValueObject): IPlatformContent { + return JSChannelContent(config, obj); + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWeb.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWeb.kt new file mode 100644 index 00000000..49f6fbeb --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWeb.kt @@ -0,0 +1,31 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.IPluginSourced +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.models.post.TextType +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.platforms.js.DevJSClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.getOrThrowNullableList +import com.futo.platformplayer.states.StateDeveloper + +open class JSWeb : JSContent, IPluginSourced { + final override val contentType: ContentType get() = ContentType.WEB; + + constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { + val contextName = "PlatformWeb"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWebDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWebDetails.kt new file mode 100644 index 00000000..02a274f4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWebDetails.kt @@ -0,0 +1,41 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.IPluginSourced +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.post.TextType +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.platforms.js.DevJSClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.getOrThrowNullableList +import com.futo.platformplayer.states.StateDeveloper + +open class JSWebDetails : JSContent, IPluginSourced, IPlatformContentDetails { + final override val contentType: ContentType get() = ContentType.WEB; + + val html: String?; + //TODO: Options? + + + constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) { + val contextName = "PlatformWeb"; + + html = obj.getOrDefault(client.config, "html", contextName, null); + } + + override fun getComments(client: IPlatformClient): IPager? = null; + override fun getPlaybackTracker(): IPlaybackTracker? = null; + override fun getContentRecommendations(client: IPlatformClient): IPager? = null; + +} diff --git a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt index 2bf6c1ce..0cc1bebc 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt @@ -149,6 +149,7 @@ class AirPlayCastingDevice : CastingDevice { break; } catch (e: Throwable) { Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e) + delay(1000); } } diff --git a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt index c6e046ef..69f74747 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt @@ -108,7 +108,7 @@ abstract class CastingDevice { val expectedCurrentTime: Double get() { - val diff = (System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0; + val diff = if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0; return time + diff; }; var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED 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 0b763675..3d362efd 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -10,7 +10,9 @@ import com.futo.platformplayer.toHexString import com.futo.platformplayer.toInetAddress import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.json.JSONObject @@ -56,6 +58,10 @@ class ChromecastCastingDevice : CastingDevice { private var _mediaSessionId: Int? = null; private var _thread: Thread? = null; private var _pingThread: Thread? = null; + private var _launchRetries = 0 + private val MAX_LAUNCH_RETRIES = 3 + private var _lastLaunchTime_ms = 0L + private var _retryJob: Job? = null constructor(name: String, addresses: Array, port: Int) : super() { this.name = name; @@ -229,6 +235,7 @@ class ChromecastCastingDevice : CastingDevice { launchObject.put("appId", "CC1AD845"); launchObject.put("requestId", _requestId++); sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString()); + _lastLaunchTime_ms = System.currentTimeMillis() } private fun getStatus() { @@ -268,6 +275,7 @@ class ChromecastCastingDevice : CastingDevice { _contentType = null; _streamType = null; _sessionId = null; + _launchRetries = 0 _transportId = null; } @@ -282,6 +290,7 @@ class ChromecastCastingDevice : CastingDevice { _started = true; _sessionId = null; + _launchRetries = 0 _mediaSessionId = null; Logger.i(TAG, "Starting..."); @@ -322,6 +331,7 @@ class ChromecastCastingDevice : CastingDevice { break; } catch (e: Throwable) { Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e) + Thread.sleep(1000); } } @@ -334,6 +344,10 @@ class ChromecastCastingDevice : CastingDevice { //Connection loop while (_scopeIO?.isActive == true) { + _sessionId = null; + _launchRetries = 0 + _mediaSessionId = null; + Logger.i(TAG, "Connecting to Chromecast."); connectionState = CastConnectionState.CONNECTING; @@ -392,7 +406,7 @@ class ChromecastCastingDevice : CastingDevice { try { val inputStream = _inputStream ?: break; - synchronized(_inputStreamLock) + val message = synchronized(_inputStreamLock) { Log.d(TAG, "Receiving next packet..."); val b1 = inputStream.readUnsignedByte(); @@ -404,7 +418,7 @@ class ChromecastCastingDevice : CastingDevice { if (size > buffer.size) { Logger.w(TAG, "Skipping packet that is too large $size bytes.") inputStream.skip(size.toLong()); - return@synchronized + return@synchronized null } Log.d(TAG, "Received header indicating $size bytes. Waiting for message."); @@ -413,15 +427,19 @@ class ChromecastCastingDevice : CastingDevice { //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"); + val msg = ChromeCast.CastMessage.parseFrom(messageBytes); + if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") { + Logger.i(TAG, "Received message: $msg"); } + return@synchronized msg + } + if (message != null) { try { handleMessage(message); } catch (e: Throwable) { Logger.w(TAG, "Failed to handle message.", e); + break } } } catch (e: java.net.SocketException) { @@ -485,6 +503,10 @@ class ChromecastCastingDevice : CastingDevice { } } catch (e: Throwable) { Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e); + _socket?.close(); + Logger.i(TAG, "Socket disconnected."); + + connectionState = CastConnectionState.CONNECTING; } } @@ -511,6 +533,7 @@ class ChromecastCastingDevice : CastingDevice { if (_sessionId == null) { connectionState = CastConnectionState.CONNECTED; _sessionId = applicationUpdate.getString("sessionId"); + _launchRetries = 0 val transportId = applicationUpdate.getString("transportId"); connectMediaChannel(transportId); @@ -525,21 +548,40 @@ class ChromecastCastingDevice : CastingDevice { } if (!sessionIsRunning) { - _sessionId = null; - _mediaSessionId = null; - setTime(0.0); - _transportId = null; - Logger.w(TAG, "Session not found."); + if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) { + _sessionId = null + _mediaSessionId = null + setTime(0.0) + _transportId = null - if (_launching) { - Logger.i(TAG, "Player not found, launching."); - launchPlayer(); + if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) { + Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}") + _launchRetries++ + launchPlayer() + } else if (!_launching && _launchRetries < MAX_LAUNCH_RETRIES) { + // Maybe the first GET_STATUS came back empty; still try launching + Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}") + _launching = true + _launchRetries++ + launchPlayer() + } else { + Logger.e(TAG, "Player not found after $_launchRetries attempts; giving up.") + Logger.i(TAG, "Unable to start media receiver on device") + stop() + } } else { - Logger.i(TAG, "Player not found, disconnecting."); - stop(); + if (_retryJob == null) { + Logger.i(TAG, "Scheduled retry job over 5 seconds") + _retryJob = _scopeIO?.launch(Dispatchers.IO) { + delay(5000) + getStatus() + _retryJob = null + } + } } } else { - _launching = false; + _launching = false + _launchRetries = 0 } val volume = status.getJSONObject("volume"); @@ -566,7 +608,7 @@ class ChromecastCastingDevice : CastingDevice { } isPlaying = playerState == "PLAYING"; - if (isPlaying) { + if (isPlaying || playerState == "PAUSED") { setTime(currentTime); } @@ -581,6 +623,8 @@ class ChromecastCastingDevice : CastingDevice { if (message.sourceId == "receiver-0") { Logger.i(TAG, "Close received."); stop(); + } else if (_transportId == message.sourceId) { + throw Exception("Transport id closed.") } } } else { @@ -615,6 +659,9 @@ class ChromecastCastingDevice : CastingDevice { localAddress = null; _started = false; + _retryJob?.cancel() + _retryJob = null + val socket = _socket; val scopeIO = _scopeIO; 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 85b928c2..dcfaf63d 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt @@ -25,6 +25,7 @@ import com.futo.platformplayer.toInetAddress import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString @@ -92,7 +93,7 @@ class FCastCastingDevice : CastingDevice { private var _version: Long = 1; private var _thread: Thread? = null private var _pingThread: Thread? = null - private var _lastPongTime = -1L + @Volatile private var _lastPongTime = System.currentTimeMillis() private var _outputStreamLock = Object() constructor(name: String, addresses: Array, port: Int) : super() { @@ -289,6 +290,7 @@ class FCastCastingDevice : CastingDevice { break; } catch (e: Throwable) { Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e) + Thread.sleep(1000); } } @@ -326,9 +328,9 @@ class FCastCastingDevice : CastingDevice { continue; } - localAddress = _socket?.localAddress; - connectionState = CastConnectionState.CONNECTED; - _lastPongTime = -1L + localAddress = _socket?.localAddress + _lastPongTime = System.currentTimeMillis() + connectionState = CastConnectionState.CONNECTED val buffer = ByteArray(4096); @@ -404,36 +406,32 @@ class FCastCastingDevice : CastingDevice { _pingThread = Thread { Logger.i(TAG, "Started ping loop.") - while (_scopeIO?.isActive == true) { - try { - send(Opcode.Ping) - } catch (e: Throwable) { - Log.w(TAG, "Failed to send ping.") - + if (connectionState == CastConnectionState.CONNECTED) { try { - _socket?.close() - _inputStream?.close() - _outputStream?.close() + send(Opcode.Ping) + if (System.currentTimeMillis() - _lastPongTime > 15000) { + Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.") + try { + _socket?.close() + } catch (e: Throwable) { + Log.w(TAG, "Failed to close socket.", e) + } + } } catch (e: Throwable) { - Log.w(TAG, "Failed to close socket.", e) + Log.w(TAG, "Failed to send ping.") + try { + _socket?.close() + _inputStream?.close() + _outputStream?.close() + } catch (e: Throwable) { + Log.w(TAG, "Failed to close socket.", e) + } } } - - /*if (_lastPongTime != -1L && System.currentTimeMillis() - _lastPongTime > 6000) { - Logger.w(TAG, "Closing socket due to last pong time being larger than 6 seconds.") - - try { - _socket?.close() - } catch (e: Throwable) { - Log.w(TAG, "Failed to close socket.", e) - } - }*/ - - Thread.sleep(2000) + Thread.sleep(5000) } - - Logger.i(TAG, "Stopped ping loop."); + Logger.i(TAG, "Stopped ping loop.") }.apply { start() } } else { Log.i(TAG, "Thread was still alive, not restarted") 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 90177050..58bd772c 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -4,10 +4,14 @@ import android.app.AlertDialog import android.content.ContentResolver import android.content.Context import android.net.Uri +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.os.Build import android.os.Looper import android.util.Base64 import android.util.Log -import android.util.Xml +import java.net.NetworkInterface +import java.net.Inet4Address import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import com.futo.platformplayer.R @@ -39,9 +43,8 @@ import com.futo.platformplayer.builders.DashBuilder import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.exceptions.UnsupportedCastException +import com.futo.platformplayer.findPreferredAddress import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.mdns.DnsService -import com.futo.platformplayer.mdns.ServiceDiscoverer import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.states.StateApp @@ -55,10 +58,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import java.io.ByteArrayInputStream +import java.net.Inet6Address import java.net.InetAddress import java.net.URLDecoder import java.net.URLEncoder +import java.util.Collections import java.util.UUID class StateCasting { @@ -70,7 +74,6 @@ class StateCasting { private var _started = false; var devices: HashMap = hashMapOf(); - var rememberedDevices: ArrayList = arrayListOf(); val onDeviceAdded = Event1(); val onDeviceChanged = Event1(); val onDeviceRemoved = Event1(); @@ -84,48 +87,15 @@ class StateCasting { private var _audioExecutor: JSRequestExecutor? = null private val _client = ManagedHttpClient(); var _resumeCastingDevice: CastingDeviceInfo? = null; - val _serviceDiscoverer = ServiceDiscoverer(arrayOf( - "_googlecast._tcp.local", - "_airplay._tcp.local", - "_fastcast._tcp.local", - "_fcast._tcp.local" - )) { handleServiceUpdated(it) } - + private var _nsdManager: NsdManager? = null val isCasting: Boolean get() = activeDevice != null; - private fun handleServiceUpdated(services: List) { - for (s in services) { - //TODO: Addresses IPv4 only? - val addresses = s.addresses.toTypedArray() - val port = s.port.toInt() - var name = s.texts.firstOrNull { it.startsWith("md=") }?.substring("md=".length) - if (s.name.endsWith("._googlecast._tcp.local")) { - if (name == null) { - name = s.name.substring(0, s.name.length - "._googlecast._tcp.local".length) - } - - addOrUpdateChromeCastDevice(name, addresses, port) - } else if (s.name.endsWith("._airplay._tcp.local")) { - if (name == null) { - name = s.name.substring(0, s.name.length - "._airplay._tcp.local".length) - } - - addOrUpdateAirPlayDevice(name, addresses, port) - } else if (s.name.endsWith("._fastcast._tcp.local")) { - if (name == null) { - name = s.name.substring(0, s.name.length - "._fastcast._tcp.local".length) - } - - addOrUpdateFastCastDevice(name, addresses, port) - } else if (s.name.endsWith("._fcast._tcp.local")) { - if (name == null) { - name = s.name.substring(0, s.name.length - "._fcast._tcp.local".length) - } - - addOrUpdateFastCastDevice(name, addresses, port) - } - } - } + private val _discoveryListeners = mapOf( + "_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice), + "_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice), + "_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice), + "_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice) + ) fun handleUrl(context: Context, url: String) { val uri = Uri.parse(url) @@ -190,30 +160,33 @@ class StateCasting { Logger.i(TAG, "CastingService starting..."); - rememberedDevices.clear(); - rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) }); - _castServer.start(); enableDeveloper(true); Logger.i(TAG, "CastingService started."); + + _nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager } @Synchronized fun startDiscovering() { - try { - _serviceDiscoverer.start() - } catch (e: Throwable) { - Logger.i(TAG, "Failed to start ServiceDiscoverer", e) + _nsdManager?.apply { + _discoveryListeners.forEach { + discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value) + } } } @Synchronized fun stopDiscovering() { - try { - _serviceDiscoverer.stop() - } catch (e: Throwable) { - Logger.i(TAG, "Failed to stop ServiceDiscoverer", e) + _nsdManager?.apply { + _discoveryListeners.forEach { + try { + stopServiceDiscovery(it.value) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } + } } } @@ -239,6 +212,85 @@ class StateCasting { _castServer.removeAllHandlers(); Logger.i(TAG, "CastingService stopped.") + + _nsdManager = null + } + + private fun createDiscoveryListener(addOrUpdate: (String, Array, Int) -> Unit): NsdManager.DiscoveryListener { + return object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(regType: String) { + Log.d(TAG, "Service discovery started for $regType") + } + + override fun onDiscoveryStopped(serviceType: String) { + Log.i(TAG, "Discovery stopped: $serviceType") + } + + override fun onServiceLost(service: NsdServiceInfo) { + Log.e(TAG, "service lost: $service") + // TODO: Handle service lost, e.g., remove device + } + + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode") + try { + _nsdManager?.stopServiceDiscovery(this) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode") + try { + _nsdManager?.stopServiceDiscovery(this) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } + } + + override fun onServiceFound(service: NsdServiceInfo) { + Log.v(TAG, "Service discovery success for ${service.serviceType}: $service") + val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + service.hostAddresses.toTypedArray() + } else { + arrayOf(service.host) + } + addOrUpdate(service.serviceName, addresses, service.port) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + _nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback { + override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "onServiceUpdated: $serviceInfo") + addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port) + } + + override fun onServiceLost() { + Log.v(TAG, "onServiceLost: $service") + // TODO: Handle service lost + } + + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode") + } + + override fun onServiceInfoCallbackUnregistered() { + Log.v(TAG, "onServiceInfoCallbackUnregistered") + } + }) + } else { + _nsdManager?.resolveService(service, object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.v(TAG, "Resolve failed: $errorCode") + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "Resolve Succeeded: $serviceInfo") + addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port) + } + }) + } + } + } } private val _castingDialogLock = Any(); @@ -294,7 +346,9 @@ class StateCasting { UIDialogs.toast(it, "Connecting to device...") synchronized(_castingDialogLock) { if(_currentDialog == null) { - _currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, "Connecting to [${device.name}]", "Make sure you are on the same network", null, -2, + _currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, + "Connecting to [${device.name}]", + "Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2, UIDialogs.Action("Disconnect", { device.stop(); })); @@ -329,9 +383,6 @@ class StateCasting { invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) }; }; - addRememberedDevice(device); - Logger.i(TAG, "Device added to active discovery. Active discovery now contains ${_storage.getDevicesCount()} devices.") - try { device.start(); } catch (e: Throwable) { @@ -353,21 +404,22 @@ class StateCasting { return addRememberedDevice(device); } + fun getRememberedCastingDevices(): List { + return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) } + } + + fun getRememberedCastingDeviceNames(): List { + return _storage.getDeviceNames() + } + fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo { val deviceInfo = device.getDeviceInfo() - val foundInfo = _storage.addDevice(deviceInfo) - if (foundInfo == deviceInfo) { - rememberedDevices.add(device); - return foundInfo; - } - - return foundInfo; + return _storage.addDevice(deviceInfo) } fun removeRememberedDevice(device: CastingDevice) { - val name = device.name ?: return; - _storage.removeDevice(name); - rememberedDevices.remove(device); + val name = device.name ?: return + _storage.removeDevice(name) } private fun invokeInMainScopeIfRequired(action: () -> Unit){ @@ -436,7 +488,7 @@ class StateCasting { } } else { val proxyStreams = Settings.instance.casting.alwaysProxyRequests; - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); if (videoSource is IVideoUrlSource) { @@ -531,7 +583,7 @@ class StateCasting { private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val videoPath = "/video-${id}" val videoUrl = url + videoPath; @@ -550,7 +602,7 @@ class StateCasting { private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val audioPath = "/audio-${id}" val audioUrl = url + audioPath; @@ -569,7 +621,7 @@ class StateCasting { private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List { val ad = activeDevice ?: return listOf() - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}" + val url = getLocalUrl(ad) val id = UUID.randomUUID() val hlsPath = "/hls-${id}" @@ -665,7 +717,7 @@ class StateCasting { private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val dashPath = "/dash-${id}" @@ -715,7 +767,7 @@ class StateCasting { val ad = activeDevice ?: return listOf(); val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val videoPath = "/video-${id}" @@ -780,7 +832,7 @@ class StateCasting { _castServer.removeAllHandlers("castProxiedHlsMaster") val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val hlsPath = "/hls-${id}" @@ -950,7 +1002,7 @@ class StateCasting { private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val hlsPath = "/hls-${id}" @@ -1080,7 +1132,7 @@ class StateCasting { val ad = activeDevice ?: return listOf(); val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val dashPath = "/dash-${id}" @@ -1166,6 +1218,15 @@ class StateCasting { } } + private fun getLocalUrl(ad: CastingDevice): String { + var address = ad.localAddress!! + if (address.isLinkLocalAddress) { + address = findPreferredAddress() ?: address + Logger.i(TAG, "Selected casting address: $address") + } + return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}"; + } + @OptIn(UnstableApi::class) private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); @@ -1173,7 +1234,7 @@ class StateCasting { cleanExecutors() _castServer.removeAllHandlers("castDashRaw") - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val dashPath = "/dash-${id}" diff --git a/app/src/main/java/com/futo/platformplayer/constructs/TaskHandler.kt b/app/src/main/java/com/futo/platformplayer/constructs/TaskHandler.kt index b590fe6e..d3457077 100644 --- a/app/src/main/java/com/futo/platformplayer/constructs/TaskHandler.kt +++ b/app/src/main/java/com/futo/platformplayer/constructs/TaskHandler.kt @@ -82,7 +82,11 @@ class TaskHandler { handled = true; } catch (e: Throwable) { Logger.w(TAG, "Handled exception in TaskHandler onSuccess.", e); - onError.emit(e, parameter); + try { + onError.emit(e, parameter); + } catch (e: Throwable) { + Logger.e(TAG, "Unhandled exception in .exception handler 1", e) + } handled = true; } } @@ -99,10 +103,14 @@ class TaskHandler { if (id != _idGenerator) return@withContext; - if (!onError.emit(e, parameter)) { - Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e); - } else { - //Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); (Prevents duplicate logs) + try { + if (!onError.emit(e, parameter)) { + Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e); + } else { + //Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); (Prevents duplicate logs) + } + } catch (e: Throwable) { + Logger.e(TAG, "Unhandled exception in .exception handler 2", e) } } } diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt index 5e2b72de..1691b9df 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt @@ -7,7 +7,9 @@ import android.app.PendingIntent.getBroadcast import android.content.Context import android.content.Intent import android.content.pm.PackageInstaller +import android.content.pm.PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED import android.graphics.drawable.Animatable +import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -155,6 +157,9 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) { val packageInstaller: PackageInstaller = context.packageManager.packageInstaller; val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + params.setRequireUserAction(USER_ACTION_NOT_REQUIRED) + } val sessionId = packageInstaller.createSession(params); session = packageInstaller.openSession(sessionId) diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt index 8f3b836c..f00bd191 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -9,7 +9,9 @@ import android.view.View import android.widget.Button import android.widget.ImageButton import android.widget.ImageView +import android.widget.LinearLayout import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.R @@ -21,22 +23,21 @@ import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.views.adapters.DeviceAdapter +import com.futo.platformplayer.views.adapters.DeviceAdapterEntry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class ConnectCastingDialog(context: Context?) : AlertDialog(context) { private lateinit var _imageLoader: ImageView; private lateinit var _buttonClose: Button; - private lateinit var _buttonAdd: ImageButton; - private lateinit var _buttonScanQR: ImageButton; + private lateinit var _buttonAdd: LinearLayout; + private lateinit var _buttonScanQR: LinearLayout; private lateinit var _textNoDevicesFound: TextView; - private lateinit var _textNoDevicesRemembered: TextView; private lateinit var _recyclerDevices: RecyclerView; - private lateinit var _recyclerRememberedDevices: RecyclerView; private lateinit var _adapter: DeviceAdapter; - private lateinit var _rememberedAdapter: DeviceAdapter; - private val _devices: ArrayList = arrayListOf(); - private val _rememberedDevices: ArrayList = arrayListOf(); + private val _devices: MutableSet = mutableSetOf() + private val _rememberedDevices: MutableSet = mutableSetOf() + private val _unifiedDevices: MutableList = mutableListOf() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState); @@ -45,42 +46,40 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { _imageLoader = findViewById(R.id.image_loader); _buttonClose = findViewById(R.id.button_close); _buttonAdd = findViewById(R.id.button_add); - _buttonScanQR = findViewById(R.id.button_scan_qr); + _buttonScanQR = findViewById(R.id.button_qr); _recyclerDevices = findViewById(R.id.recycler_devices); - _recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices); _textNoDevicesFound = findViewById(R.id.text_no_devices_found); - _textNoDevicesRemembered = findViewById(R.id.text_no_devices_remembered); - _adapter = DeviceAdapter(_devices, false); + _adapter = DeviceAdapter(_unifiedDevices) _recyclerDevices.adapter = _adapter; _recyclerDevices.layoutManager = LinearLayoutManager(context); - _rememberedAdapter = DeviceAdapter(_rememberedDevices, true); - _rememberedAdapter.onRemove.subscribe { d -> - if (StateCasting.instance.activeDevice == d) { - d.stopCasting(); + _adapter.onPin.subscribe { d -> + val isRemembered = _rememberedDevices.contains(d.name) + val newIsRemembered = !isRemembered + if (newIsRemembered) { + StateCasting.instance.addRememberedDevice(d) + val name = d.name + if (name != null) { + _rememberedDevices.add(name) + } + } else { + StateCasting.instance.removeRememberedDevice(d) + _rememberedDevices.remove(d.name) } - - StateCasting.instance.removeRememberedDevice(d); - val index = _rememberedDevices.indexOf(d); - if (index != -1) { - _rememberedDevices.removeAt(index); - _rememberedAdapter.notifyItemRemoved(index); - } - - _textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE; - _recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE; - }; - _rememberedAdapter.onConnect.subscribe { _ -> - dismiss() - //UIDialogs.showCastingDialog(context) + updateUnifiedList() } + + //TODO: Integrate remembered into the main list + //TODO: Add green indicator to indicate a device is oneline + //TODO: Add pinning + //TODO: Implement QR code as an option in add manually + //TODO: Remove start button + _adapter.onConnect.subscribe { _ -> dismiss() //UIDialogs.showCastingDialog(context) } - _recyclerRememberedDevices.adapter = _rememberedAdapter; - _recyclerRememberedDevices.layoutManager = LinearLayoutManager(context); _buttonClose.setOnClickListener { dismiss(); }; _buttonAdd.setOnClickListener { @@ -105,77 +104,112 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { Logger.i(TAG, "Dialog shown."); StateCasting.instance.startDiscovering() - (_imageLoader.drawable as Animatable?)?.start(); - _devices.clear(); - synchronized (StateCasting.instance.devices) { - _devices.addAll(StateCasting.instance.devices.values); + synchronized(StateCasting.instance.devices) { + _devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name }) } - _rememberedDevices.clear(); - synchronized (StateCasting.instance.rememberedDevices) { - _rememberedDevices.addAll(StateCasting.instance.rememberedDevices); + _rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames()) + updateUnifiedList() + + StateCasting.instance.onDeviceAdded.subscribe(this) { d -> + val name = d.name + if (name != null) + _devices.add(name) + updateUnifiedList() + } + + StateCasting.instance.onDeviceChanged.subscribe(this) { d -> + val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name } + if (index != -1) { + _unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice) + _adapter.notifyItemChanged(index) + } + } + + StateCasting.instance.onDeviceRemoved.subscribe(this) { d -> + _devices.remove(d.name) + updateUnifiedList() + } + + StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> + if (connectionState == CastConnectionState.CONNECTED) { + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + dismiss() + } + } } _textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE; _recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE; - _textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE; - _recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE; - - StateCasting.instance.onDeviceAdded.subscribe(this) { d -> - _devices.add(d); - _adapter.notifyItemInserted(_devices.size - 1); - _textNoDevicesFound.visibility = View.GONE; - _recyclerDevices.visibility = View.VISIBLE; - }; - - StateCasting.instance.onDeviceChanged.subscribe(this) { d -> - val index = _devices.indexOf(d); - if (index == -1) { - return@subscribe; - } - - _devices[index] = d; - _adapter.notifyItemChanged(index); - }; - - StateCasting.instance.onDeviceRemoved.subscribe(this) { d -> - val index = _devices.indexOf(d); - if (index == -1) { - return@subscribe; - } - - _devices.removeAt(index); - _adapter.notifyItemRemoved(index); - _textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE; - _recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE; - }; - - StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> - if (connectionState != CastConnectionState.CONNECTED) { - return@subscribe; - } - - StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { - dismiss(); - }; - }; - - _adapter.notifyDataSetChanged(); - _rememberedAdapter.notifyDataSetChanged(); } override fun dismiss() { - super.dismiss(); - - (_imageLoader.drawable as Animatable?)?.stop(); - + super.dismiss() + (_imageLoader.drawable as Animatable?)?.stop() StateCasting.instance.stopDiscovering() - StateCasting.instance.onDeviceAdded.remove(this); - StateCasting.instance.onDeviceChanged.remove(this); - StateCasting.instance.onDeviceRemoved.remove(this); - StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); + StateCasting.instance.onDeviceAdded.remove(this) + StateCasting.instance.onDeviceChanged.remove(this) + StateCasting.instance.onDeviceRemoved.remove(this) + StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this) + } + + private fun updateUnifiedList() { + val oldList = ArrayList(_unifiedDevices) + val newList = buildUnifiedList() + + val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize() = oldList.size + override fun getNewListSize() = newList.size + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + return oldItem.castingDevice.name == newItem.castingDevice.name + && oldItem.castingDevice.isReady == newItem.castingDevice.isReady + && oldItem.isOnlineDevice == newItem.isOnlineDevice + && oldItem.isPinnedDevice == newItem.isPinnedDevice + } + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + return oldItem.castingDevice.name == newItem.castingDevice.name + && oldItem.castingDevice.isReady == newItem.castingDevice.isReady + && oldItem.isOnlineDevice == newItem.isOnlineDevice + && oldItem.isPinnedDevice == newItem.isPinnedDevice + } + }) + + _unifiedDevices.clear() + _unifiedDevices.addAll(newList) + diffResult.dispatchUpdatesTo(_adapter) + + _textNoDevicesFound.visibility = if (_unifiedDevices.isEmpty()) View.VISIBLE else View.GONE + _recyclerDevices.visibility = if (_unifiedDevices.isNotEmpty()) View.VISIBLE else View.GONE + } + + private fun buildUnifiedList(): List { + val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name } + val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name } + + val unifiedList = mutableListOf() + + val intersectionNames = _devices.intersect(_rememberedDevices) + for (name in intersectionNames) { + onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, true)) } + } + + val onlineOnlyNames = _devices - _rememberedDevices + for (name in onlineOnlyNames) { + onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, false, true)) } + } + + val rememberedOnlyNames = _rememberedDevices - _devices + for (name in rememberedOnlyNames) { + rememberedDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, false)) } + } + + return unifiedList } companion object { diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt index ede24707..f5cf534a 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -47,6 +47,7 @@ import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBytesSpeed +import com.futo.polycentric.core.hexStringToByteArray import hasAnySource import isDownloadable import kotlinx.coroutines.CancellationException @@ -59,16 +60,21 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.serialization.Contextual import kotlinx.serialization.Transient +import java.io.ByteArrayOutputStream import java.io.File import java.io.FileOutputStream import java.io.IOException import java.lang.Thread.sleep +import java.nio.ByteBuffer import java.time.OffsetDateTime import java.util.UUID import java.util.concurrent.Executors import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinTask import java.util.concurrent.ThreadLocalRandom +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec import kotlin.coroutines.resumeWithException import kotlin.time.times @@ -564,6 +570,14 @@ class VideoDownload { } } + private fun decryptSegment(encryptedSegment: ByteArray, key: ByteArray, iv: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val secretKey = SecretKeySpec(key, "AES") + val ivSpec = IvParameterSpec(iv) + cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec) + return cipher.doFinal(encryptedSegment) + } + private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { if(targetFile.exists()) targetFile.delete(); @@ -579,6 +593,14 @@ class VideoDownload { ?: throw Exception("Variant playlist content is empty") val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl) + val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) { + val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl) + check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" } + DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv?.hexStringToByteArray()) + } else { + null + } + variantPlaylist.segments.forEachIndexed { index, segment -> if (segment !is HLS.MediaSegment) { return@forEachIndexed @@ -590,7 +612,7 @@ class VideoDownload { try { segmentFiles.add(segmentFile) - val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri) { segmentLength, totalRead, lastSpeed -> + val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo, index) { segmentLength, totalRead, lastSpeed -> val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed) @@ -630,10 +652,8 @@ class VideoDownload { private suspend fun combineSegments(context: Context, segmentFiles: List, targetFile: File) = withContext(Dispatchers.IO) { suspendCancellableCoroutine { continuation -> - val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt") - fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" }) - - val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\"" + val cmd = + "-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\"" val statisticsCallback = StatisticsCallback { _ -> //TODO: Show progress? @@ -643,7 +663,6 @@ class VideoDownload { val session = FFmpegKit.executeAsync(cmd, { session -> if (ReturnCode.isSuccess(session.returnCode)) { - fileList.delete() continuation.resumeWith(Result.success(Unit)) } else { val errorMessage = if (ReturnCode.isCancel(session.returnCode)) { @@ -651,7 +670,6 @@ class VideoDownload { } else { "Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}" } - fileList.delete() continuation.resumeWithException(RuntimeException(errorMessage)) } }, @@ -771,7 +789,7 @@ class VideoDownload { else { Logger.i(TAG, "Download $name Sequential"); try { - sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress); + sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress); } catch (e: Throwable) { Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)") throw e @@ -798,7 +816,31 @@ class VideoDownload { } return sourceLength!!; } - private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, onProgress: (Long, Long, Long) -> Unit): Long { + + data class DecryptionInfo( + val key: ByteArray, + val iv: ByteArray? + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DecryptionInfo + + if (!key.contentEquals(other.key)) return false + if (!iv.contentEquals(other.iv)) return false + + return true + } + + override fun hashCode(): Int { + var result = key.contentHashCode() + result = 31 * result + iv.contentHashCode() + return result + } + } + + private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long { val progressRate: Int = 4096 * 5; var lastProgressCount: Int = 0; val speedRate: Int = 4096 * 5; @@ -818,6 +860,8 @@ class VideoDownload { val sourceLength = result.body.contentLength(); val sourceStream = result.body.byteStream(); + val segmentBuffer = ByteArrayOutputStream() + var totalRead: Long = 0; try { var read: Int; @@ -828,7 +872,7 @@ class VideoDownload { if (read < 0) break; - fileStream.write(buffer, 0, read); + segmentBuffer.write(buffer, 0, read); totalRead += read; @@ -854,6 +898,21 @@ class VideoDownload { result.body.close() } + if (decryptionInfo != null) { + var iv = decryptionInfo.iv + if (iv == null) { + iv = ByteBuffer.allocate(16) + .putLong(0L) + .putLong(index.toLong()) + .array() + } + + val decryptedData = decryptSegment(segmentBuffer.toByteArray(), decryptionInfo.key, iv!!) + fileStream.write(decryptedData) + } else { + fileStream.write(segmentBuffer.toByteArray()) + } + onProgress(sourceLength, totalRead, 0); return sourceLength; } @@ -1160,6 +1219,8 @@ class VideoDownload { fun audioContainerToExtension(container: String): String { if (container.contains("audio/mp4")) return "mp4a"; + else if (container.contains("video/mp4")) + return "mp4"; else if (container.contains("audio/mpeg")) return "mpga"; else if (container.contains("audio/mp3")) @@ -1167,7 +1228,7 @@ class VideoDownload { else if (container.contains("audio/webm")) return "webm"; else if (container == "application/vnd.apple.mpegurl") - return "mp4a"; + return "m4a"; else return "audio";// throw IllegalStateException("Unknown container: " + container) } diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt index a4615822..90be3150 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt @@ -69,7 +69,7 @@ class VideoExport { outputFile = f; } else if (v != null) { val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container); - val f = downloadRoot.createFile(v.container, outputFileName) + val f = downloadRoot.createFile(if (v.container == "application/vnd.apple.mpegurl") "video/mp4" else v.container, outputFileName) ?: throw Exception("Failed to create file in external directory."); Logger.i(TAG, "Copying video."); @@ -81,8 +81,8 @@ class VideoExport { outputFile = f; } else if (a != null) { val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container); - val f = downloadRoot.createFile(a.container, outputFileName) - ?: throw Exception("Failed to create file in external directory."); + val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "video/mp4" else a.container, outputFileName) + ?: throw Exception("Failed to create file in external directory."); Logger.i(TAG, "Copying audio."); diff --git a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt index 1a9ba3f0..15412fd9 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt @@ -188,6 +188,14 @@ class V8Plugin { whenNotBusy { synchronized(_runtimeLock) { isStopped = true; + + //Cleanup http + for(pack in _depsPackages) { + if(pack is PackageHttp) { + pack.cleanup(); + } + } + _runtime?.let { _runtime = null; if(!it.isClosed && !it.isDead) { diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt index 1a77d82d..d2d7cf04 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt @@ -12,6 +12,7 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClientConstants import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig @@ -72,6 +73,26 @@ class PackageBridge : V8Package { fun buildSpecVersion(): Int { return JSClientConstants.PLUGIN_SPEC_VERSION; } + @V8Property + fun buildPlatform(): String { + return "android"; + } + + @V8Property + fun supportedContent(): Array { + return arrayOf( + ContentType.MEDIA.value, + ContentType.POST.value, + ContentType.PLAYLIST.value, + ContentType.WEB.value, + ContentType.URL.value, + ContentType.NESTED_VIDEO.value, + ContentType.CHANNEL.value, + ContentType.LOCKED.value, + ContentType.PLACEHOLDER.value, + ContentType.DEFERRED.value + ) + } @V8Function fun dispose(value: V8Value) { diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt index 686318a1..900eb6f0 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt @@ -8,9 +8,7 @@ import com.caoccao.javet.enums.V8ProxyMode import com.caoccao.javet.interop.V8Runtime import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.primitive.V8ValueString -import com.caoccao.javet.values.reference.V8ValueArrayBuffer import com.caoccao.javet.values.reference.V8ValueObject -import com.caoccao.javet.values.reference.V8ValueSharedArrayBuffer import com.caoccao.javet.values.reference.V8ValueTypedArray import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig @@ -20,15 +18,9 @@ import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.internal.IV8Convertable import com.futo.platformplayer.engine.internal.V8BindObject import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.states.StateApp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import java.net.SocketTimeoutException import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinTask -import kotlin.concurrent.thread -import kotlin.streams.asSequence class PackageHttp: V8Package { @Transient @@ -49,6 +41,9 @@ class PackageHttp: V8Package { private var _batchPoolLock: Any = Any(); private var _batchPool: ForkJoinPool? = null; + private val aliveSockets = mutableListOf(); + private var _cleanedUp = false; + constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) { _config = config; @@ -58,6 +53,27 @@ class PackageHttp: V8Package { _packageClientAuth = PackageHttpClient(this, _clientAuth); } + fun cleanup(){ + Logger.w(TAG, "PackageHttp Cleaning up") + val sockets = synchronized(aliveSockets) { aliveSockets.toList() } + _cleanedUp = true; + for(socket in sockets){ + try { + Logger.w(TAG, "PackageHttp Socket Cleaned Up"); + socket.close(1001, "Cleanup"); + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed to close socket", ex); + } + } + if(sockets.size > 0) { + //Thread.sleep(100); //Give sockets a bit + } + synchronized(aliveSockets) { + aliveSockets.clear(); + } + } + /* Automatically adjusting threadpool dedicated per PackageHttp for batch requests. @@ -111,24 +127,24 @@ class PackageHttp: V8Package { @V8Function fun request(method: String, url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse { return if(useAuth) - _packageClientAuth.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING) + _packageClientAuth.requestInternal(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING) else - _packageClient.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING); + _packageClient.requestInternal(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING); } @V8Function fun requestWithBody(method: String, url: String, body:String, headers: MutableMap = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse { return if(useAuth) - _packageClientAuth.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING) + _packageClientAuth.requestWithBodyInternal(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING) else - _packageClient.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING); + _packageClient.requestWithBodyInternal(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING); } @V8Function fun GET(url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { return if(useAuth) - _packageClientAuth.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING) + _packageClientAuth.GETInternal(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING) else - _packageClient.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + _packageClient.GETInternal(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); } @V8Function fun POST(url: String, body: Any, headers: MutableMap = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { @@ -136,15 +152,15 @@ class PackageHttp: V8Package { val client = if(useAuth) _packageClientAuth else _packageClient; if(body is V8ValueString) - return client.POST(url, body.value, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + return client.POSTInternal(url, body.value, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); else if(body is String) - return client.POST(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + return client.POSTInternal(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); else if(body is V8ValueTypedArray) - return client.POST(url, body.toBytes(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + return client.POSTInternal(url, body.toBytes(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); else if(body is ByteArray) - return client.POST(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + return client.POSTInternal(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); else if(body is ArrayList<*>) //Avoid this case, used purely for testing - return client.POST(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + return client.POSTInternal(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); else throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST"); } @@ -276,9 +292,9 @@ class PackageHttp: V8Package { if(it.second.method == "DUMMY") return@autoParallelPool null; if(it.second.body != null) - return@autoParallelPool it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType); + return@autoParallelPool it.first.requestWithBodyInternal(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType); else - return@autoParallelPool it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType); + return@autoParallelPool it.first.requestInternal(it.second.method, it.second.url, it.second.headers, it.second.respType); }.map { if(it.second != null) throw it.second!!; @@ -345,7 +361,9 @@ class PackageHttp: V8Package { } @V8Function - fun request(method: String, url: String, headers: MutableMap = HashMap(), returnType: ReturnType) : IBridgeHttpResponse { + fun request(method: String, url: String, headers: MutableMap = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse + = requestInternal(method, url, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING); + fun requestInternal(method: String, url: String, headers: MutableMap = HashMap(), returnType: ReturnType) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { return@logExceptions catchHttp { @@ -364,7 +382,9 @@ class PackageHttp: V8Package { }; } @V8Function - fun requestWithBody(method: String, url: String, body:String, headers: MutableMap = HashMap(), returnType: ReturnType) : IBridgeHttpResponse { + fun requestWithBody(method: String, url: String, body:String, headers: MutableMap = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse + = requestWithBodyInternal(method, url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING) + fun requestWithBodyInternal(method: String, url: String, body:String, headers: MutableMap = HashMap(), returnType: ReturnType) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { catchHttp { @@ -385,7 +405,9 @@ class PackageHttp: V8Package { } @V8Function - fun GET(url: String, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { + fun GET(url: String, headers: MutableMap = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse + = GETInternal(url, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING) + fun GETInternal(url: String, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { catchHttp { @@ -407,7 +429,9 @@ class PackageHttp: V8Package { }; } @V8Function - fun POST(url: String, body: String, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { + fun POST(url: String, body: String, headers: MutableMap = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse + = POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING) + fun POSTInternal(url: String, body: String, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { catchHttp { @@ -429,7 +453,9 @@ class PackageHttp: V8Package { }; } @V8Function - fun POST(url: String, body: ByteArray, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { + fun POST(url: String, body: ByteArray, headers: MutableMap = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse + = POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING) + fun POSTInternal(url: String, body: ByteArray, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { catchHttp { @@ -453,9 +479,16 @@ class PackageHttp: V8Package { @V8Function fun socket(url: String, headers: Map? = null): SocketResult { + if(_package._cleanedUp) + throw IllegalStateException("Plugin shutdown"); val socketHeaders = headers?.toMutableMap() ?: HashMap(); applyDefaultHeaders(socketHeaders); - return SocketResult(this, _client, url, socketHeaders); + val socket = SocketResult(_package, this, _client, url, socketHeaders); + Logger.w(TAG, "PackageHttp Socket opened"); + synchronized(_package.aliveSockets) { + _package.aliveSockets.add(socket); + } + return socket; } private fun applyDefaultHeaders(headerMap: MutableMap) { @@ -561,13 +594,15 @@ class PackageHttp: V8Package { private var _listeners: V8ValueObject? = null; + private val _package: PackageHttp; private val _packageClient: PackageHttpClient; private val _client: ManagedHttpClient; private val _url: String; private val _headers: Map; - constructor(pack: PackageHttpClient, client: ManagedHttpClient, url: String, headers: Map) { + constructor(parent: PackageHttp, pack: PackageHttpClient, client: ManagedHttpClient, url: String, headers: Map) { _packageClient = pack; + _package = parent; _client = client; _url = url; _headers = headers; @@ -593,7 +628,7 @@ class PackageHttp: V8Package { override fun open() { Logger.i(TAG, "Websocket opened: " + _url); _isOpen = true; - if(hasOpen) { + if(hasOpen && _listeners?.isClosed != true) { try { _listeners?.invokeVoid("open", arrayOf()); } @@ -603,7 +638,7 @@ class PackageHttp: V8Package { } } override fun message(msg: String) { - if(hasMessage) { + if(hasMessage && _listeners?.isClosed != true) { try { _listeners?.invokeVoid("message", msg); } @@ -611,7 +646,7 @@ class PackageHttp: V8Package { } } override fun closing(code: Int, reason: String) { - if(hasClosing) + if(hasClosing && _listeners?.isClosed != true) { try { _listeners?.invokeVoid("closing", code, reason); @@ -623,7 +658,7 @@ class PackageHttp: V8Package { } override fun closed(code: Int, reason: String) { _isOpen = false; - if(hasClosed) { + if(hasClosed && _listeners?.isClosed != true) { try { _listeners?.invokeVoid("closed", code, reason); } @@ -631,11 +666,15 @@ class PackageHttp: V8Package { Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex); } } + Logger.w(TAG, "PackageHttp Socket removed"); + synchronized(_package.aliveSockets) { + _package.aliveSockets.remove(this@SocketResult); + } } override fun failure(exception: Throwable) { _isOpen = false; Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception); - if(hasFailure) { + if(hasFailure && _listeners?.isClosed != true) { try { _listeners?.invokeVoid("failure", exception.message); } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt index b26c9b35..0939fbde 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt @@ -5,6 +5,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager @@ -25,6 +26,7 @@ import com.futo.platformplayer.api.media.structures.MultiPager import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.dp import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.exceptions.ChannelException @@ -32,9 +34,11 @@ import com.futo.platformplayer.fragment.mainactivity.main.FeedView import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.SearchView import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter @@ -54,6 +58,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), private var _results: ArrayList = arrayListOf(); private var _adapterResults: InsertedViewAdapterWithLoader? = null; private var _lastPolycentricProfile: PolycentricProfile? = null; + private var _query: String? = null + private var _searchView: SearchView? = null val onContentClicked = Event2(); val onContentUrlClicked = Event2(); @@ -68,16 +74,32 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), private fun getContentPager(channel: IPlatformChannel): IPager { Logger.i(TAG, "getContentPager"); - val lastPolycentricProfile = _lastPolycentricProfile; - var pager: IPager? = null; - if (lastPolycentricProfile != null) - pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile); + var pager: IPager? = null + val query = _query + if (!query.isNullOrBlank()) { + if(subType != null) { + Logger.i(TAG, "StatePlatform.instance.searchChannel(channel.url = ${channel.url}, query = ${query}, subType = ${subType})") + pager = StatePlatform.instance.searchChannel(channel.url, query, subType); + } else { + Logger.i(TAG, "StatePlatform.instance.searchChannel(channel.url = ${channel.url}, query = ${query})") + pager = StatePlatform.instance.searchChannel(channel.url, query); + } + } else { + val lastPolycentricProfile = _lastPolycentricProfile; + if (lastPolycentricProfile != null && StatePolycentric.instance.enabled) { + pager = StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType); + Logger.i(TAG, "StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = ${subType})") + } - if(pager == null) { - if(subType != null) - pager = StatePlatform.instance.getChannelContent(channel.url, subType); - else - pager = StatePlatform.instance.getChannelContent(channel.url); + if(pager == null) { + if(subType != null) { + pager = StatePlatform.instance.getChannelContent(channel.url, subType); + Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url}, subType = ${subType})") + } else { + pager = StatePlatform.instance.getChannelContent(channel.url); + Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url})") + } + } } return pager; } @@ -144,19 +166,49 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), _taskLoadVideos.cancel(); + _query = null _channel = channel; + updateSearchViewVisibility() _results.clear(); _adapterResults?.notifyDataSetChanged(); loadInitial(); } + private fun updateSearchViewVisibility() { + if (subType != null) { + _searchView?.visibility = View.GONE + return + } + + val client = _channel?.id?.pluginId?.let { StatePlatform.instance.getClientOrNull(it) } + Logger.i(TAG, "_searchView.visible = ${client?.capabilities?.hasSearchChannelContents == true}") + _searchView?.visibility = if (client?.capabilities?.hasSearchChannelContents == true) View.VISIBLE else View.GONE + } + + fun setQuery(query: String) { + _query = query + _taskLoadVideos.cancel() + _results.clear() + _adapterResults?.notifyDataSetChanged() + loadInitial() + } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val view = inflater.inflate(R.layout.fragment_channel_videos, container, false); + _query = null _recyclerResults = view.findViewById(R.id.recycler_videos); - _adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar).apply { + val searchView = SearchView(requireContext()).apply { layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT) }.apply { + onEnter.subscribe { + setQuery(it) + } + } + _searchView = searchView + updateSearchViewVisibility() + + _adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply { this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit); this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit); this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit); @@ -173,6 +225,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), _recyclerResults?.layoutManager = _glmVideo; _recyclerResults?.addOnScrollListener(_scrollListener); + return view; } @@ -181,6 +234,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), _recyclerResults?.removeOnScrollListener(_scrollListener); _recyclerResults = null; _pager = null; + _query = null + _searchView = null _taskLoadVideos.cancel(); _nextPageHandler.cancel(); @@ -303,6 +358,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), } private fun loadInitial() { + Logger.i(TAG, "loadInitial") val channel: IPlatformChannel = _channel ?: return; setLoading(true); _taskLoadVideos.run(channel); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt index 2d13088c..c52bcebb 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt @@ -331,7 +331,7 @@ class MenuBottomBarFragment : MainActivityFragment() { } if (!StatePayment.instance.hasPaid) { - newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid_filled, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate() })) + newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid_filled, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate(withHistory = false) })) } //Add conditional buttons here, when you add a conditional button, be sure to add the register and unregister events for when the button needs to be updated @@ -385,19 +385,19 @@ class MenuBottomBarFragment : MainActivityFragment() { currentMain.scrollToTop(false) currentMain.reloadFeed() } else { - it.navigate() + it.navigate(withHistory = false) } }), - ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate() }), - ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate() }), - ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate() }), - ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate() }), - ButtonDefinition(5, R.drawable.ic_smart_display, R.drawable.ic_smart_display_filled, R.string.shorts, canToggle = true, { it.currentMain is ShortsFragment }, { it.navigate() }), - ButtonDefinition(6, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate() }), - ButtonDefinition(7, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate() }), - ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate() }), - ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate() }), - ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate() }), + ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate(withHistory = false) }), + ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate(withHistory = false) }), + ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate(withHistory = false) }), + ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate(withHistory = false) }), + ButtonDefinition(5, R.drawable.ic_smart_display, R.drawable.ic_smart_display_filled, R.string.shorts, canToggle = true, { it.currentMain is ShortsFragment }, { it.navigate(withHistory = false) }), + ButtonDefinition(6, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate(withHistory = false) }), + ButtonDefinition(7, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate(withHistory = false) }), + ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate(withHistory = false) }), + ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate(withHistory = false) }), + ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(11, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, { val c = it.context ?: return@ButtonDefinition; Logger.i(TAG, "settings preventPictureInPicture()"); @@ -419,7 +419,7 @@ class MenuBottomBarFragment : MainActivityFragment() { }, UIDialogs.ActionStyle.PRIMARY)); }), ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, { - it.navigate(Settings.URL_FAQ); + it.navigate(Settings.URL_FAQ, withHistory = false); }) //96 is reserved for privacy button //98 is reserved for buy button diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ArticleDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ArticleDetailFragment.kt new file mode 100644 index 00000000..989a19e1 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ArticleDetailFragment.kt @@ -0,0 +1,814 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.text.Html +import android.text.method.ScrollingMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewPropertyAnimator +import android.widget.Button +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.children +import androidx.core.view.isVisible +import androidx.core.view.setPadding +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.UISlideOverlays +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.article.IPlatformArticle +import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent +import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails +import com.futo.platformplayer.api.media.models.post.TextType +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.api.media.platforms.js.models.JSArticleDetails +import com.futo.platformplayer.api.media.platforms.js.models.JSHeaderSegment +import com.futo.platformplayer.api.media.platforms.js.models.JSImagesSegment +import com.futo.platformplayer.api.media.platforms.js.models.JSNestedSegment +import com.futo.platformplayer.api.media.platforms.js.models.JSTextSegment +import com.futo.platformplayer.api.media.platforms.js.models.SegmentType +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.dp +import com.futo.platformplayer.fixHtmlWhitespace +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod +import com.futo.platformplayer.sp +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.adapters.feedtypes.PreviewLockedView +import com.futo.platformplayer.views.adapters.feedtypes.PreviewNestedVideoView +import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView +import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoView +import com.futo.platformplayer.views.comments.AddCommentView +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.overlays.RepliesOverlay +import com.futo.platformplayer.views.pills.PillRatingLikesDislikes +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.segments.CommentsList +import com.futo.platformplayer.views.subscriptions.SubscribeButton +import com.futo.polycentric.core.ApiMethods +import com.futo.polycentric.core.ContentType +import com.futo.polycentric.core.Models +import com.futo.polycentric.core.Opinion +import com.futo.polycentric.core.PolycentricProfile +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions +import com.google.android.flexbox.FlexboxLayout +import com.google.android.material.imageview.ShapeableImageView +import com.google.android.material.shape.CornerFamily +import com.google.android.material.shape.ShapeAppearanceModel +import com.google.protobuf.ByteString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import userpackage.Protocol +import java.lang.Integer.min + +class ArticleDetailFragment : MainFragment { + override val isMainView: Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _viewDetail: ArticleDetailView? = null; + + constructor() : super() { } + + override fun onBackPressed(): Boolean { + return false; + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = ArticleDetailView(inflater.context).applyFragment(this); + _viewDetail = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _viewDetail?.onDestroy(); + _viewDetail = null; + } + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + + if (parameter is IPlatformArticleDetails) { + _viewDetail?.clear(); + _viewDetail?.setArticleDetails(parameter); + } else if (parameter is IPlatformArticle) { + _viewDetail?.setArticleOverview(parameter); + } else if(parameter is String) { + _viewDetail?.setPostUrl(parameter); + } + } + + private class ArticleDetailView : ConstraintLayout { + private lateinit var _fragment: ArticleDetailFragment; + private var _url: String? = null; + private var _isLoading = false; + private var _article: IPlatformArticleDetails? = null; + private var _articleOverview: IPlatformArticle? = null; + private var _polycentricProfile: PolycentricProfile? = null; + private var _version = 0; + private var _isRepliesVisible: Boolean = false; + private var _repliesAnimator: ViewPropertyAnimator? = null; + + private val _creatorThumbnail: CreatorThumbnail; + private val _buttonSubscribe: SubscribeButton; + private val _channelName: TextView; + private val _channelMeta: TextView; + private val _textTitle: TextView; + private val _textMeta: TextView; + private val _textSummary: TextView; + private val _containerSegments: LinearLayout; + private val _platformIndicator: PlatformIndicator; + private val _buttonShare: ImageButton; + + private val _layoutRating: LinearLayout; + private val _imageLikeIcon: ImageView; + private val _textLikes: TextView; + private val _imageDislikeIcon: ImageView; + private val _textDislikes: TextView; + + private val _addCommentView: AddCommentView; + + private val _rating: PillRatingLikesDislikes; + + private val _layoutLoadingOverlay: FrameLayout; + private val _imageLoader: ImageView; + + private var _overlayContainer: FrameLayout + private val _repliesOverlay: RepliesOverlay; + + private val _commentsList: CommentsList; + + private var _commentType: Boolean? = null; + private val _buttonPolycentric: Button + private val _buttonPlatform: Button + + private val _taskLoadPost = if(!isInEditMode) TaskHandler( + StateApp.instance.scopeGetter, + { + val result = StatePlatform.instance.getContentDetails(it).await(); + if(result !is IPlatformArticleDetails) + throw IllegalStateException(context.getString(R.string.expected_media_content_found) + " ${result.contentType}"); + return@TaskHandler result; + }) + .success { setArticleDetails(it) } + .exception { + Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it); + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment); + } else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope }; + + private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { + if (!StatePolycentric.instance.enabled) + return@TaskHandler null + + ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) + }) + .success { it -> setPolycentricProfile(it, animate = true) } + .exception { + Logger.w(TAG, "Failed to load claims.", it); + }; + + constructor(context: Context) : super(context) { + inflate(context, R.layout.fragview_article_detail, this); + + val root = findViewById(R.id.root); + + _creatorThumbnail = findViewById(R.id.creator_thumbnail); + _buttonSubscribe = findViewById(R.id.button_subscribe); + _channelName = findViewById(R.id.text_channel_name); + _channelMeta = findViewById(R.id.text_channel_meta); + _textTitle = findViewById(R.id.text_title); + _textMeta = findViewById(R.id.text_meta); + _textSummary = findViewById(R.id.text_summary); + _containerSegments = findViewById(R.id.container_segments); + _platformIndicator = findViewById(R.id.platform_indicator); + _buttonShare = findViewById(R.id.button_share); + + _overlayContainer = findViewById(R.id.overlay_container); + + _layoutRating = findViewById(R.id.layout_rating); + _imageLikeIcon = findViewById(R.id.image_like_icon); + _textLikes = findViewById(R.id.text_likes); + _imageDislikeIcon = findViewById(R.id.image_dislike_icon); + _textDislikes = findViewById(R.id.text_dislikes); + + _commentsList = findViewById(R.id.comments_list); + _addCommentView = findViewById(R.id.add_comment_view); + + _rating = findViewById(R.id.rating); + + _layoutLoadingOverlay = findViewById(R.id.layout_loading_overlay); + _imageLoader = findViewById(R.id.image_loader); + + + _repliesOverlay = findViewById(R.id.replies_overlay); + + _buttonPolycentric = findViewById(R.id.button_polycentric) + _buttonPlatform = findViewById(R.id.button_platform) + + _buttonSubscribe.onSubscribed.subscribe { + //TODO: add overlay to layout + //UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer); + }; + + val layoutTop: LinearLayout = findViewById(R.id.layout_top); + root.removeView(layoutTop); + _commentsList.setPrependedView(layoutTop); + + /*TODO: Why is this here? + _commentsList.onCommentsLoaded.subscribe { + updateCommentType(false); + };*/ + + _commentsList.onRepliesClick.subscribe { c -> + val replyCount = c.replyCount ?: 0; + var metadata = ""; + if (replyCount > 0) { + metadata += "$replyCount " + context.getString(R.string.replies); + } + + if (c is PolycentricPlatformComment) { + var parentComment: PolycentricPlatformComment = c; + _repliesOverlay.load(_commentType!!, metadata, c.contextUrl, c.reference, c, + { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, + { + val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1); + _commentsList.replaceComment(parentComment, newComment); + parentComment = newComment; + }); + } else { + _repliesOverlay.load(_commentType!!, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) }); + } + + setRepliesOverlayVisible(isVisible = true, animate = true); + }; + + if (StatePolycentric.instance.enabled) { + _buttonPolycentric.setOnClickListener { + updateCommentType(false) + } + } else { + _buttonPolycentric.visibility = View.GONE + } + + _buttonPlatform.setOnClickListener { + updateCommentType(true) + } + + _addCommentView.onCommentAdded.subscribe { + _commentsList.addComment(it); + }; + + _repliesOverlay.onClose.subscribe { setRepliesOverlayVisible(isVisible = false, animate = true); }; + + _buttonShare.setOnClickListener { share() }; + + _creatorThumbnail.onClick.subscribe { openChannel() }; + _channelName.setOnClickListener { openChannel() }; + _channelMeta.setOnClickListener { openChannel() }; + } + + private fun openChannel() { + val author = _article?.author ?: _articleOverview?.author ?: return; + _fragment.navigate(author); + } + + private fun share() { + try { + Logger.i(PreviewPostView.TAG, "sharePost") + + val url = _article?.shareUrl ?: _articleOverview?.shareUrl ?: _url; + _fragment.startActivity(Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND; + putExtra(Intent.EXTRA_TEXT, url); + type = "text/plain"; //TODO: Determine alt types? + }, null)); + } catch (e: Throwable) { + //Ignored + Logger.e(PreviewPostView.TAG, "Failed to share.", e); + } + } + + private fun updatePolycentricRating() { + _rating.visibility = View.GONE; + + val ref = Models.referenceFromBuffer((_article?.url ?: _articleOverview?.url)?.toByteArray() ?: return) + val extraBytesRef = (_article?.id?.value ?: _articleOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null } + val version = _version; + + _rating.onLikeDislikeUpdated.remove(this); + + if (!StatePolycentric.instance.enabled) + return + + _fragment.lifecycleScope.launch(Dispatchers.IO) { + if (version != _version) { + return@launch; + } + + try { + val queryReferencesResponse = ApiMethods.getQueryReferences(ApiMethods.SERVER, ref, null,null, + arrayListOf( + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType( + ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.like.data)).build(), + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType( + ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.dislike.data)).build() + ), + extraByteReferences = listOfNotNull(extraBytesRef) + ); + + if (version != _version) { + return@launch; + } + + val likes = queryReferencesResponse.countsList[0]; + val dislikes = queryReferencesResponse.countsList[1]; + val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/; + val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/; + + withContext(Dispatchers.Main) { + if (version != _version) { + return@withContext; + } + + _rating.visibility = VISIBLE; + _rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked); + _rating.onLikeDislikeUpdated.subscribe(this) { args -> + if (args.hasLiked) { + args.processHandle.opinion(ref, Opinion.like); + } else if (args.hasDisliked) { + args.processHandle.opinion(ref, Opinion.dislike); + } else { + args.processHandle.opinion(ref, Opinion.neutral); + } + + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + Logger.i(TAG, "Started backfill"); + args.processHandle.fullyBackfillServersAnnounceExceptions(); + Logger.i(TAG, "Finished backfill"); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to backfill servers", e) + } + } + + StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked) + }; + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e); + _rating.visibility = View.GONE; + } + } + } + + private fun setPlatformRating(rating: IRating?) { + if (rating == null) { + _layoutRating.visibility = View.GONE; + return; + } + + _layoutRating.visibility = View.VISIBLE; + + when (rating) { + is RatingLikeDislikes -> { + _textLikes.visibility = View.VISIBLE; + _imageLikeIcon.visibility = View.VISIBLE; + _textLikes.text = rating.likes.toHumanNumber(); + + _imageDislikeIcon.visibility = View.VISIBLE; + _textDislikes.visibility = View.VISIBLE; + _textDislikes.text = rating.dislikes.toHumanNumber(); + } + is RatingLikes -> { + _textLikes.visibility = View.VISIBLE; + _imageLikeIcon.visibility = View.VISIBLE; + _textLikes.text = rating.likes.toHumanNumber(); + + _imageDislikeIcon.visibility = View.GONE; + _textDislikes.visibility = View.GONE; + } + else -> { + _textLikes.visibility = View.GONE; + _imageLikeIcon.visibility = View.GONE; + _imageDislikeIcon.visibility = View.GONE; + _textDislikes.visibility = View.GONE; + } + } + } + + fun applyFragment(frag: ArticleDetailFragment): ArticleDetailView { + _fragment = frag; + return this; + } + + fun clear() { + _commentsList.cancel(); + _taskLoadPost.cancel(); + _taskLoadPolycentricProfile.cancel(); + _version++; + + updateCommentType(null) + _url = null; + _article = null; + _articleOverview = null; + _creatorThumbnail.clear(); + //_buttonSubscribe.setSubscribeChannel(null); TODO: clear button + _channelName.text = ""; + setChannelMeta(null); + _textTitle.text = ""; + _textMeta.text = ""; + setPlatformRating(null); + _polycentricProfile = null; + _rating.visibility = View.GONE; + updatePolycentricRating(); + setRepliesOverlayVisible(isVisible = false, animate = false); + + _containerSegments.removeAllViews(); + + _addCommentView.setContext(null, null); + _platformIndicator.clearPlatform(); + } + + fun setArticleDetails(value: IPlatformArticleDetails) { + _url = value.url; + _article = value; + + _creatorThumbnail.setThumbnail(value.author.thumbnail, false); + _buttonSubscribe.setSubscribeChannel(value.author.url); + _channelName.text = value.author.name; + setChannelMeta(value); + _textTitle.text = value.name; + _textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count? + + _textSummary.text = value.summary + _textSummary.isVisible = !value.summary.isNullOrEmpty() + + _platformIndicator.setPlatformFromClientID(value.id.pluginId); + setPlatformRating(value.rating); + + for(seg in value.segments) { + when(seg.type) { + SegmentType.HEADER -> { + if(seg is JSHeaderSegment) { + _containerSegments.addView(ArticleHeaderBlock(context, seg.content, seg.level)) + } + } + SegmentType.TEXT -> { + if(seg is JSTextSegment) { + _containerSegments.addView(ArticleTextBlock(context, seg.content, seg.textType)) + } + } + SegmentType.IMAGES -> { + if(seg is JSImagesSegment) { + if(seg.images.size > 0) + _containerSegments.addView(ArticleImageBlock(context, seg.images[0], seg.caption)) + } + } + SegmentType.NESTED -> { + if(seg is JSNestedSegment) { + _containerSegments.addView(ArticleContentBlock(context, seg.nested, _fragment, _overlayContainer)); + } + } + else ->{} + } + } + + //Fetch only when not already called in setPostOverview + if (_articleOverview == null) { + fetchPolycentricProfile(); + updatePolycentricRating(); + _addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray())); + } + + val commentType = !Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1 + updateCommentType(commentType, true); + setLoading(false); + } + + fun setArticleOverview(value: IPlatformArticle) { + clear(); + _url = value.url; + _articleOverview = value; + + _creatorThumbnail.setThumbnail(value.author.thumbnail, false); + _buttonSubscribe.setSubscribeChannel(value.author.url); + _channelName.text = value.author.name; + setChannelMeta(value); + _textTitle.text = value.name; + _textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count? + + _platformIndicator.setPlatformFromClientID(value.id.pluginId); + _addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray())); + + updatePolycentricRating(); + fetchPolycentricProfile(); + fetchPost(); + } + + + private fun setRepliesOverlayVisible(isVisible: Boolean, animate: Boolean) { + if (_isRepliesVisible == isVisible) { + return; + } + + _isRepliesVisible = isVisible; + _repliesAnimator?.cancel(); + + if (isVisible) { + _repliesOverlay.visibility = View.VISIBLE; + + if (animate) { + _repliesOverlay.translationY = _repliesOverlay.height.toFloat(); + + _repliesAnimator = _repliesOverlay.animate() + .setDuration(300) + .translationY(0f) + .withEndAction { + _repliesAnimator = null; + }.apply { start() }; + } + } else { + if (animate) { + _repliesOverlay.translationY = 0f; + + _repliesAnimator = _repliesOverlay.animate() + .setDuration(300) + .translationY(_repliesOverlay.height.toFloat()) + .withEndAction { + _repliesOverlay.visibility = GONE; + _repliesAnimator = null; + }.apply { start(); } + } else { + _repliesOverlay.visibility = View.GONE; + _repliesOverlay.translationY = _repliesOverlay.height.toFloat(); + } + } + } + + private fun fetchPolycentricProfile() { + val author = _article?.author ?: _articleOverview?.author ?: return; + setPolycentricProfile(null, animate = false); + _taskLoadPolycentricProfile.run(author.id); + } + + private fun setChannelMeta(value: IPlatformArticle?) { + val subscribers = value?.author?.subscribers; + if(subscribers != null && subscribers > 0) { + _channelMeta.visibility = View.VISIBLE; + _channelMeta.text = if((value.author.subscribers ?: 0) > 0) value.author.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else ""; + } else { + _channelMeta.visibility = View.GONE; + _channelMeta.text = ""; + } + } + + fun setPostUrl(url: String) { + clear(); + _url = url; + fetchPost(); + } + + fun onDestroy() { + _commentsList.cancel(); + _taskLoadPost.cancel(); + _repliesOverlay.cleanup(); + } + + private fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) { + _polycentricProfile = polycentricProfile; + + val pp = _polycentricProfile; + if (pp == null) { + _creatorThumbnail.setHarborAvailable(false, animate, null); + return; + } + + _creatorThumbnail.setHarborAvailable(true, animate, pp.system.toProto()); + } + + private fun fetchPost() { + Logger.i(TAG, "fetchVideo") + _article = null; + + val url = _url; + if (!url.isNullOrBlank()) { + setLoading(true); + _taskLoadPost.run(url); + } + } + + private fun fetchComments() { + Logger.i(TAG, "fetchComments") + _article?.let { + _commentsList.load(true) { StatePlatform.instance.getComments(it); }; + } + } + + private fun fetchPolycentricComments() { + Logger.i(TAG, "fetchPolycentricComments") + val post = _article; + val ref = (_article?.url ?: _articleOverview?.url)?.toByteArray()?.let { Models.referenceFromBuffer(it) } + val extraBytesRef = (_article?.id?.value ?: _articleOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null } + + if (ref == null) { + Logger.w(TAG, "Failed to fetch polycentric comments because url was not set null") + _commentsList.clear(); + return + } + + _commentsList.load(false) { StatePolycentric.instance.getCommentPager(post!!.url, ref, listOfNotNull(extraBytesRef)); }; + } + + private fun updateCommentType(commentType: Boolean?, forceReload: Boolean = false) { + val changed = commentType != _commentType + _commentType = commentType + + if (commentType == null) { + _buttonPlatform.setTextColor(resources.getColor(R.color.gray_ac)) + _buttonPolycentric.setTextColor(resources.getColor(R.color.gray_ac)) + } else { + _buttonPlatform.setTextColor(resources.getColor(if (commentType) R.color.white else R.color.gray_ac)) + _buttonPolycentric.setTextColor(resources.getColor(if (!commentType) R.color.white else R.color.gray_ac)) + + if (commentType) { + _addCommentView.visibility = View.GONE; + + if (forceReload || changed) { + fetchComments(); + } + } else { + _addCommentView.visibility = View.VISIBLE; + + if (forceReload || changed) { + fetchPolycentricComments() + } + } + } + } + + private fun setLoading(isLoading : Boolean) { + if (_isLoading == isLoading) { + return; + } + + _isLoading = isLoading; + + if(isLoading) { + (_imageLoader.drawable as Animatable?)?.start() + _layoutLoadingOverlay.visibility = View.VISIBLE; + } + else { + _layoutLoadingOverlay.visibility = View.GONE; + (_imageLoader.drawable as Animatable?)?.stop() + } + } + + class ArticleHeaderBlock : LinearLayout { + constructor(context: Context?, content: String, level: Int) : super(context){ + inflate(context, R.layout.view_segment_text, this); + + findViewById(R.id.text_content)?.let { + it.text = content; + + val sp = when(level) { + 1 -> 6.sp(resources); + 2 -> 8.sp(resources); + 3 -> 10.sp(resources); + 4 -> 12.sp(resources); + 5 -> 14.sp(resources); + else -> 6.sp(resources); + } + it.setTextColor(Color.WHITE); + it.setTypeface(Typeface.create(null, 600, false)); + it.textSize = sp.toFloat(); + } + } + } + class ArticleTextBlock : LinearLayout { + constructor(context: Context?, content: String, textType: TextType) : super(context){ + inflate(context, R.layout.view_segment_text, this); + + findViewById(R.id.text_content)?.let { + if(textType == TextType.HTML) + it.text = Html.fromHtml(content, Html.FROM_HTML_MODE_COMPACT); + else if(textType == TextType.CODE) { + it.text = content; + it.setPadding(15.dp(resources)); + it.setHorizontallyScrolling(true); + it.movementMethod = ScrollingMovementMethod(); + it.setTypeface(Typeface.MONOSPACE); + it.setBackgroundResource(R.drawable.background_videodetail_description) + } + else + it.text = content; + } + } + } + class ArticleImageBlock: LinearLayout { + constructor(context: Context?, image: String, caption: String? = null) : super(context){ + inflate(context, R.layout.view_segment_image, this); + + findViewById(R.id.image_content)?.let { + Glide.with(it) + .load(image) + .crossfade() + .into(it); + } + findViewById(R.id.text_content)?.let { + if(caption?.isNullOrEmpty() == true) + it.isVisible = false; + else + it.text = caption; + } + } + } + class ArticleContentBlock: LinearLayout { + constructor(context: Context, content: IPlatformContent?, fragment: ArticleDetailFragment? = null, overlayContainer: FrameLayout? = null): super(context) { + if(content != null) { + var view: View? = null; + if(content is IPlatformNestedContent) { + view = PreviewNestedVideoView(context, FeedStyle.THUMBNAIL, null); + view.bind(content); + view.onContentUrlClicked.subscribe { a,b -> } + } + else if(content is IPlatformVideo) { + view = PreviewVideoView(context, FeedStyle.THUMBNAIL, null, true); + view.bind(content); + view.onVideoClicked.subscribe { a,b -> fragment?.navigate(a) } + view.onChannelClicked.subscribe { a -> fragment?.navigate(a) } + if(overlayContainer != null) { + view.onAddToClicked.subscribe { a -> UISlideOverlays.showVideoOptionsOverlay(a, overlayContainer) }; + } + view.onAddToQueueClicked.subscribe { a -> StatePlayer.instance.addToQueue(a) } + view.onAddToWatchLaterClicked.subscribe { a -> + if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true)) + UIDialogs.toast("Added to watch later\n[${content.name}]") + } + } + else if(content is IPlatformPost) { + view = PreviewPostView(context, FeedStyle.THUMBNAIL); + view.bind(content); + view.onContentClicked.subscribe { a -> fragment?.navigate(a) } + view.onChannelClicked.subscribe { a -> fragment?.navigate(a) } + } + else if(content is IPlatformArticle) { + view = PreviewPostView(context, FeedStyle.THUMBNAIL); + view.bind(content); + view.onContentClicked.subscribe { a -> fragment?.navigate(a) } + view.onChannelClicked.subscribe { a -> fragment?.navigate(a) } + } + else if(content is IPlatformLockedContent) { + view = PreviewLockedView(context, FeedStyle.THUMBNAIL); + view.bind(content); + } + if(view != null) + addView(view); + } + } + } + + + companion object { + const val TAG = "PostDetailFragment" + } + } + + companion object { + fun newInstance() = ArticleDetailFragment().apply {} + } +} diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt index 6be65482..63b60c1f 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt @@ -47,6 +47,7 @@ import com.futo.platformplayer.selectHighestResolutionImage import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.views.adapters.ChannelTab @@ -135,6 +136,8 @@ class ChannelFragment : MainFragment() { inflater.inflate(R.layout.fragment_channel, this) _taskLoadPolycentricProfile = TaskHandler({ fragment.lifecycleScope }, { id -> + if (!StatePolycentric.instance.enabled) + return@TaskHandler null return@TaskHandler ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, id.claimFieldType.toLong(), id.claimType.toLong(), id.value!!) }).success { setPolycentricProfile(it, animate = true) }.exception { Logger.w(TAG, "Failed to load polycentric profile.", it) @@ -422,17 +425,15 @@ class ChannelFragment : MainFragment() { _fragment.lifecycleScope.launch(Dispatchers.IO) { val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url) withContext(Dispatchers.Main) { - if (plugin != null && plugin.capabilities.hasSearchChannelContents) { - buttons.add(Pair(R.drawable.ic_search) { - _fragment.navigate( - SuggestionsFragmentData( - "", SearchType.VIDEO, channel.url - ) + buttons.add(Pair(R.drawable.ic_search) { + _fragment.navigate( + SuggestionsFragmentData( + "", SearchType.VIDEO ) - }) + ) + }) + _fragment.topBar?.assume()?.setMenuItems(buttons) - _fragment.topBar?.assume()?.setMenuItems(buttons) - } if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) { if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt index 4390a80c..cc528a2b 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt @@ -10,12 +10,14 @@ import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays +import com.futo.platformplayer.api.media.models.article.IPlatformArticle import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.post.IPlatformPost import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.api.media.platforms.js.models.JSWeb import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateMeta @@ -196,16 +198,24 @@ abstract class ContentFeedView : FeedView(content); } else if (content is IPlatformPost) { fragment.navigate(content); + } else if(content is IPlatformArticle) { + fragment.navigate(content); } + else if(content is JSWeb) { + fragment.navigate(content); + } + else + UIDialogs.appToast("Unknown content type [" + content.contentType.name + "]"); } protected open fun onContentUrlClicked(url: String, contentType: ContentType) { when(contentType) { ContentType.MEDIA -> { - StatePlayer.instance.clearQueue(); - fragment.navigate(url).maximizeVideoDetail(); - }; - ContentType.PLAYLIST -> fragment.navigate(url); - ContentType.URL -> fragment.navigate(url); + StatePlayer.instance.clearQueue() + fragment.navigate(url).maximizeVideoDetail() + } + ContentType.PLAYLIST -> fragment.navigate(url) + ContentType.URL -> fragment.navigate(url) + ContentType.CHANNEL -> fragment.navigate(url) else -> {}; } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt index b8b0b567..72094903 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt @@ -2,15 +2,18 @@ package com.futo.platformplayer.fragment.mainactivity.main import android.annotation.SuppressLint import android.os.Bundle +import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.allViews import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.models.ResultCapabilities +import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.TaskHandler @@ -18,9 +21,12 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.isHttpUrl import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.SearchType import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.ToggleBar +import com.futo.platformplayer.views.others.RadioGroupView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -83,7 +89,7 @@ class ContentSearchResultsFragment : MainFragment() { private var _sortBy: String? = null; private var _filterValues: HashMap> = hashMapOf(); private var _enabledClientIds: List? = null; - private var _channelUrl: String? = null; + private var _searchType: SearchType? = null; private val _taskSearch: TaskHandler>; override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar @@ -91,11 +97,12 @@ class ContentSearchResultsFragment : MainFragment() { constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) { _taskSearch = TaskHandler>({fragment.lifecycleScope}, { query -> Logger.i(TAG, "Searching for: $query") - val channelUrl = _channelUrl; - if (channelUrl != null) { - StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds) - } else { - StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds) + when (_searchType) + { + SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds) + SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query) + SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query) + else -> throw Exception("Search type must be specified") } }) .success { loadedResult(it); }.exception { } @@ -105,6 +112,25 @@ class ContentSearchResultsFragment : MainFragment() { } setPreviewsEnabled(Settings.instance.search.previewFeedItems); + + initializeToolbar(); + } + + fun initializeToolbar(){ + if(_toolbarContentView.allViews.any { it is RadioGroupView }) + _toolbarContentView.removeView(_toolbarContentView.allViews.find { it is RadioGroupView }); + + val radioGroup = RadioGroupView(context); + radioGroup.onSelectedChange.subscribe { + + if (it.size != 1) + setSearchType(SearchType.VIDEO); + else + setSearchType((it[0] ?: SearchType.VIDEO) as SearchType); + } + radioGroup?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true) + + _toolbarContentView.addView(radioGroup); } override fun cleanup() { @@ -115,7 +141,7 @@ class ContentSearchResultsFragment : MainFragment() { fun onShown(parameter: Any?) { if(parameter is SuggestionsFragmentData) { setQuery(parameter.query, false); - setChannelUrl(parameter.channelUrl, false); + setSearchType(parameter.searchType, false) fragment.topBar?.apply { if (this is SearchTopBarFragment) { @@ -131,7 +157,7 @@ class ContentSearchResultsFragment : MainFragment() { onFilterClick.subscribe(this) { _overlayContainer.let { val filterValuesCopy = HashMap(_filterValues); - val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy, _channelUrl != null); + val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy); filtersOverlay.onOK.subscribe { enabledClientIds, changed -> if (changed) { setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy); @@ -178,11 +204,7 @@ class ContentSearchResultsFragment : MainFragment() { fragment.lifecycleScope.launch(Dispatchers.IO) { try { - val commonCapabilities = - if(_channelUrl == null) - StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id }); - else - StatePlatform.instance.getCommonSearchChannelContentsCapabilities(StatePlatform.instance.getEnabledClients().map { it.id }); + val commonCapabilities = StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id }); val sorts = commonCapabilities?.sorts ?: listOf(); if (sorts.size > 1) { withContext(Dispatchers.Main) { @@ -249,8 +271,8 @@ class ContentSearchResultsFragment : MainFragment() { } } - private fun setChannelUrl(channelUrl: String?, updateResults: Boolean = true) { - _channelUrl = channelUrl; + private fun setSearchType(searchType: SearchType, updateResults: Boolean = true) { + _searchType = searchType if (updateResults) { clearResults(); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt index 217165ae..536700d8 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt @@ -14,10 +14,14 @@ import androidx.core.widget.addTextChangedListener import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.downloads.VideoDownload import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.services.DownloadService +import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists @@ -54,6 +58,15 @@ class DownloadsFragment : MainFragment() { super.onResume() _view?.reloadUI(); + if(StateDownloads.instance.getDownloading().any { it.state == VideoDownload.State.QUEUED } && + !StateDownloads.instance.getDownloading().any { it.state == VideoDownload.State.DOWNLOADING } && + Settings.instance.downloads.shouldDownload()) { + Logger.w(TAG, "Detected queued download, while not downloading, attempt recreating service"); + StateApp.withContext { + DownloadService.getOrCreateService(it); + } + } + StateDownloads.instance.onDownloadsChanged.subscribe(this) { lifecycleScope.launch(Dispatchers.Main) { try { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt index 3c915ebe..a9ea33b2 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt @@ -136,7 +136,6 @@ abstract class FeedView : L _toolbarContentView = findViewById(R.id.container_toolbar_content); _nextPageHandler = TaskHandler>({fragment.lifecycleScope}, { - if (it is IAsyncPager<*>) it.nextPageAsync(); else diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HistoryFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HistoryFragment.kt index 2da46620..80bd08cf 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HistoryFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HistoryFragment.kt @@ -15,6 +15,8 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.structures.IAsyncPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.TaskHandler @@ -22,10 +24,14 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFrag import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.states.StateHistory +import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.states.StatePlugins +import com.futo.platformplayer.views.ToggleBar import com.futo.platformplayer.views.adapters.HistoryListViewHolder import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.others.TagsView +import com.futo.platformplayer.views.others.Toggle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -68,6 +74,8 @@ class HistoryFragment : MainFragment() { private var _pager: IPager? = null; private val _results = arrayListOf(); private var _loading = false; + private val _toggleBar: ToggleBar + private var _togglePluginsDisabled = hashSetOf() private var _automaticNextPageCounter = 0; @@ -79,6 +87,7 @@ class HistoryFragment : MainFragment() { _clearSearch = findViewById(R.id.button_clear_search); _editSearch = findViewById(R.id.edit_search); _tagsView = findViewById(R.id.tags_text); + _toggleBar = findViewById(R.id.toggle_bar) _tagsView.setPairs(listOf( Pair(context.getString(R.string.last_hour), 60L), Pair(context.getString(R.string.last_24_hours), 24L * 60L), @@ -88,6 +97,22 @@ class HistoryFragment : MainFragment() { Pair(context.getString(R.string.all_time), -1L) )); + val toggles = StatePlatform.instance.getEnabledClients() + .filter { it is JSClient } + .map { plugin -> + val pluginName = plugin.name.lowercase() + ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) pluginName else "", plugin.icon, !_togglePluginsDisabled.contains(plugin.id), { view, active -> + if (active) { + _togglePluginsDisabled.remove(plugin.id) + } else { + _togglePluginsDisabled.add(plugin.id) + } + + filtersChanged() + }).withTag("plugins") + }.toTypedArray() + _toggleBar.setToggles(*toggles) + _adapter = InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(), { _results.size }, { view, _ -> @@ -162,14 +187,15 @@ class HistoryFragment : MainFragment() { else it.nextPage(); - return@TaskHandler it.getResults(); + return@TaskHandler filterResults(it.getResults()); }).success { setLoading(false); val posBefore = _results.size; - _results.addAll(it); - _adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), it.size); - ensureEnoughContentVisible(it) + val res = filterResults(it) + _results.addAll(res); + _adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), res.size); + ensureEnoughContentVisible(res) }.exception { Logger.w(TAG, "Failed to load next page.", it); UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, { @@ -178,6 +204,10 @@ class HistoryFragment : MainFragment() { }; } + private fun filtersChanged() { + updatePager() + } + private fun updatePager() { val query = _editSearch.text.toString(); if (_editSearch.text.isNotEmpty()) { @@ -246,11 +276,22 @@ class HistoryFragment : MainFragment() { _adapter.setLoading(loading); } + private fun filterResults(a: List): List { + val enabledPluginIds = StatePlatform.instance.getEnabledClients().map { it.id }.toHashSet() + val disabledPluginIds = _togglePluginsDisabled.toHashSet() + return a.filter { + val pluginId = it.video.id.pluginId ?: StatePlatform.instance.getContentClientOrNull(it.video.url)?.id ?: return@filter false + if (!enabledPluginIds.contains(pluginId)) + return@filter false + return@filter !disabledPluginIds.contains(pluginId) + }; + } + private fun loadPagerInternal(pager: IPager) { Logger.i(TAG, "Setting new internal pager on feed"); _results.clear(); - val toAdd = pager.getResults(); + val toAdd = filterResults(pager.getResults()) _results.addAll(toAdd); _adapter.notifyDataSetChanged(); ensureEnoughContentVisible(toAdd) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt index 58caabe1..5510ccbf 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt @@ -217,7 +217,7 @@ class PlaylistsFragment : MainFragment() { var playlistsToReturn = pls; if(!_listPlaylistsSearch.text.isNullOrEmpty()) playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) }; - if(!_ordering.value.isNullOrEmpty()){ + if(!_ordering.value.isNullOrEmpty()) { playlistsToReturn = when(_ordering.value){ "nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() } "nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() }; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt index d4dc1672..896e975e 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt @@ -168,7 +168,12 @@ class PostDetailFragment : MainFragment { UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment); } else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope }; - private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }) + private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { + if (!StatePolycentric.instance.enabled) + return@TaskHandler null + + ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) + }) .success { it -> setPolycentricProfile(it, animate = true) } .exception { Logger.w(TAG, "Failed to load claims.", it); @@ -327,6 +332,10 @@ class PostDetailFragment : MainFragment { val version = _version; _rating.onLikeDislikeUpdated.remove(this); + + if (!StatePolycentric.instance.enabled) + return + _fragment.lifecycleScope.launch(Dispatchers.IO) { if (version != _version) { return@launch; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourcesFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourcesFragment.kt index 39c87028..08169bed 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourcesFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourcesFragment.kt @@ -18,6 +18,7 @@ import com.futo.platformplayer.activities.AddSourceOptionsActivity import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment +import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.stores.FragmentedStorage @@ -44,6 +45,7 @@ class SourcesFragment : MainFragment() { if(topBar is AddTopBarFragment) { (topBar as AddTopBarFragment).onAdd.clear(); (topBar as AddTopBarFragment).onAdd.subscribe { + StateApp.instance.preventPictureInPicture.emit(); startActivity(Intent(requireContext(), AddSourceOptionsActivity::class.java)); }; } @@ -93,6 +95,7 @@ class SourcesFragment : MainFragment() { findViewById(R.id.plugin_disclaimer).isVisible = false; } findViewById(R.id.button_add_sources).onClick.subscribe { + StateApp.instance.preventPictureInPicture.emit(); fragment.startActivity(Intent(context, AddSourceOptionsActivity::class.java)); }; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt index a2875647..2953c84f 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt @@ -290,8 +290,8 @@ class SubscriptionGroupFragment : MainFragment() { image.setImageView(_imageGroup); } else { - _imageGroupBackground.setImageResource(0); - _imageGroup.setImageResource(0); + _imageGroupBackground.setImageDrawable(null); + _imageGroup.setImageDrawable(null); } updateMeta(); reloadCreators(group); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt index a07de94e..e0a68cc5 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt @@ -18,8 +18,10 @@ import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.SearchHistoryStorage import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter +import com.futo.platformplayer.views.others.RadioGroupView +import com.futo.platformplayer.views.others.TagsView -data class SuggestionsFragmentData(val query: String, val searchType: SearchType, val channelUrl: String? = null); +data class SuggestionsFragmentData(val query: String, val searchType: SearchType); class SuggestionsFragment : MainFragment { override val isMainView : Boolean = true; @@ -28,10 +30,10 @@ class SuggestionsFragment : MainFragment { private var _recyclerSuggestions: RecyclerView? = null; private var _llmSuggestions: LinearLayoutManager? = null; + private var _radioGroupView: RadioGroupView? = null; private val _suggestions: ArrayList = ArrayList(); private var _query: String? = null; private var _searchType: SearchType = SearchType.VIDEO; - private var _channelUrl: String? = null; private val _adapterSuggestions = SearchSuggestionAdapter(_suggestions); @@ -49,14 +51,7 @@ class SuggestionsFragment : MainFragment { _adapterSuggestions.onClicked.subscribe { suggestion -> val storage = FragmentedStorage.get(); storage.add(suggestion); - - if (_searchType == SearchType.CREATOR) { - navigate(suggestion); - } else if (_searchType == SearchType.PLAYLIST) { - navigate(suggestion); - } else { - navigate(SuggestionsFragmentData(suggestion, SearchType.VIDEO, _channelUrl)); - } + navigate(SuggestionsFragmentData(suggestion, _searchType)); } _adapterSuggestions.onRemove.subscribe { suggestion -> val index = _suggestions.indexOf(suggestion); @@ -80,6 +75,15 @@ class SuggestionsFragment : MainFragment { recyclerSuggestions.adapter = _adapterSuggestions; _recyclerSuggestions = recyclerSuggestions; + _radioGroupView = view.findViewById(R.id.radio_group).apply { + onSelectedChange.subscribe { + if (it.size != 1) + _searchType = SearchType.VIDEO + else + _searchType = (it[0] ?: SearchType.VIDEO) as SearchType + } + } + loadSuggestions(); return view; } @@ -104,37 +108,31 @@ class SuggestionsFragment : MainFragment { if (parameter is SuggestionsFragmentData) { _searchType = parameter.searchType; - _channelUrl = parameter.channelUrl; } else if (parameter is SearchType) { _searchType = parameter; - _channelUrl = null; } + _radioGroupView?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true) + topBar?.apply { if (this is SearchTopBarFragment) { onSearch.subscribe(this) { - if (_searchType == SearchType.CREATOR) { - navigate(it); - } else if (_searchType == SearchType.PLAYLIST) { - navigate(it); - } else { - if(it.isHttpUrl()) { - if(StatePlatform.instance.hasEnabledPlaylistClient(it)) - navigate(it); - else if(StatePlatform.instance.hasEnabledChannelClient(it)) - navigate(it); - else { - val url = it; - activity?.let { - close() - if(it is MainActivity) - it.navigate(it.getFragment(), url); - } + if(it.isHttpUrl()) { + if(StatePlatform.instance.hasEnabledPlaylistClient(it)) + navigate(it); + else if(StatePlatform.instance.hasEnabledChannelClient(it)) + navigate(it); + else { + val url = it; + activity?.let { + close() + if(it is MainActivity) + it.navigate(it.getFragment(), url); } } - else - navigate(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl)); } + else + navigate(SuggestionsFragmentData(it, _searchType)); }; onTextChange.subscribe(this) { @@ -196,6 +194,7 @@ class SuggestionsFragment : MainFragment { super.onDestroyMainView(); _getSuggestions.onError.clear(); _recyclerSuggestions = null; + _radioGroupView = null } override fun onDestroy() { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index 6477fa1f..fd3319f0 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt @@ -455,6 +455,10 @@ class VideoDetailFragment() : MainFragment() { activity?.enterPictureInPictureMode(params); } } + + if (isFullscreen) { + viewDetail?.restoreBrightness() + } } fun forcePictureInPicture() { @@ -487,6 +491,10 @@ class VideoDetailFragment() : MainFragment() { _isActive = true; _leavingPiP = false; + if (isFullscreen) { + _viewDetail?.saveBrightness() + } + _viewDetail?.let { Logger.v(TAG, "onResume preventPictureInPicture=false"); it.preventPictureInPicture = false; 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 dd10e70a..c5d647ff 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 @@ -46,6 +46,7 @@ import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays +import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.LiveChatManager import com.futo.platformplayer.api.media.PlatformID @@ -148,7 +149,6 @@ import com.futo.platformplayer.views.pills.PillRatingLikesDislikes import com.futo.platformplayer.views.pills.RoundButton import com.futo.platformplayer.views.pills.RoundButtonGroup import com.futo.platformplayer.views.platform.PlatformIndicator -import com.futo.platformplayer.views.segments.ChaptersList import com.futo.platformplayer.views.segments.CommentsList import com.futo.platformplayer.views.subscriptions.SubscribeButton import com.futo.platformplayer.views.video.FutoVideoPlayer @@ -571,7 +571,7 @@ class VideoDetailView : ConstraintLayout { _player.setIsReplay(true); val searchVideo = StatePlayer.instance.getCurrentQueueItem(); - if (searchVideo is SerializedPlatformVideo?) { + if (searchVideo is SerializedPlatformVideo? && Settings.instance.playback.deleteFromWatchLaterAuto) { searchVideo?.let { StatePlaylists.instance.removeFromWatchLater(it) }; } @@ -619,6 +619,7 @@ class VideoDetailView : ConstraintLayout { loadCurrentVideo(lastPositionMilliseconds); updatePillButtonVisibilities(); setCastEnabled(false); + } else -> {} } @@ -647,6 +648,15 @@ class VideoDetailView : ConstraintLayout { _timeBar.setDuration(video?.duration ?: 0); } }; + + _cast.onTimeJobTimeChanged_s.subscribe { + if (_isCasting) { + setLastPositionMilliseconds((it * 1000.0).toLong(), true); + _timeBar.setPosition(it); + _timeBar.setBufferedPosition(0); + _timeBar.setDuration(video?.duration ?: 0); + } + } } _playerProgress.player = _player.exoPlayer?.player; @@ -688,6 +698,20 @@ class VideoDetailView : ConstraintLayout { Logger.i(TAG, "MediaControlReceiver.onCloseReceived") onClose.emit() }; + MediaControlReceiver.onBackgroundReceived.subscribe(this) { + Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived") + _player.switchToAudioMode(); + allowBackground = true; + StateApp.instance.contextOrNull?.let { + try { + if (it is MainActivity) { + it.moveTaskToBack(true) + } + } catch (e: Throwable) { + Logger.i(TAG, "Failed to move task to back", e) + } + } + }; MediaControlReceiver.onSeekToReceived.subscribe(this) { handleSeek(it); }; _container_content_description.onClose.subscribe { switchContentView(_container_content_main); }; @@ -767,6 +791,7 @@ class VideoDetailView : ConstraintLayout { _lastAudioSource = null; _lastSubtitleSource = null; video = null; + _container_content_liveChat?.close(); _player.clear(); cleanupPlaybackTracker(); Logger.i(TAG, "Keep screen on unset onClose") @@ -1141,6 +1166,7 @@ class VideoDetailView : ConstraintLayout { MediaControlReceiver.onNextReceived.remove(this); MediaControlReceiver.onPreviousReceived.remove(this); MediaControlReceiver.onCloseReceived.remove(this); + MediaControlReceiver.onBackgroundReceived.remove(this); MediaControlReceiver.onSeekToReceived.remove(this); val job = _jobHideResume; @@ -1510,60 +1536,68 @@ class VideoDetailView : ConstraintLayout { _rating.visibility = View.GONE; - fragment.lifecycleScope.launch(Dispatchers.IO) { - try { - val queryReferencesResponse = ApiMethods.getQueryReferences( - ApiMethods.SERVER, ref, null, null, - arrayListOf( - Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() - .setFromType(ContentType.OPINION.value).setValue( - ByteString.copyFrom(Opinion.like.data) - ).build(), - Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() - .setFromType(ContentType.OPINION.value).setValue( - ByteString.copyFrom(Opinion.dislike.data) - ).build() - ), - extraByteReferences = listOfNotNull(extraBytesRef) - ); + if (StatePolycentric.instance.enabled) { + fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + val queryReferencesResponse = ApiMethods.getQueryReferences( + ApiMethods.SERVER, ref, null, null, + arrayListOf( + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() + .setFromType(ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.like.data) + ).build(), + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() + .setFromType(ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.dislike.data) + ).build() + ), + extraByteReferences = listOfNotNull(extraBytesRef) + ); - val likes = queryReferencesResponse.countsList[0]; - val dislikes = queryReferencesResponse.countsList[1]; - val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/; - val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/; + val likes = queryReferencesResponse.countsList[0]; + val dislikes = queryReferencesResponse.countsList[1]; + val hasLiked = + StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/; + val hasDisliked = + StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/; - withContext(Dispatchers.Main) { - _rating.visibility = View.VISIBLE; - _rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked); - _rating.onLikeDislikeUpdated.subscribe(this) { args -> - if (args.hasLiked) { - args.processHandle.opinion(ref, Opinion.like); - } else if (args.hasDisliked) { - args.processHandle.opinion(ref, Opinion.dislike); - } else { - args.processHandle.opinion(ref, Opinion.neutral); - } - - fragment.lifecycleScope.launch(Dispatchers.IO) { - try { - Logger.i(TAG, "Started backfill"); - args.processHandle.fullyBackfillServersAnnounceExceptions(); - Logger.i(TAG, "Finished backfill"); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to backfill servers", e) + withContext(Dispatchers.Main) { + _rating.visibility = View.VISIBLE; + _rating.setRating( + RatingLikeDislikes(likes, dislikes), + hasLiked, + hasDisliked + ); + _rating.onLikeDislikeUpdated.subscribe(this) { args -> + if (args.hasLiked) { + args.processHandle.opinion(ref, Opinion.like); + } else if (args.hasDisliked) { + args.processHandle.opinion(ref, Opinion.dislike); + } else { + args.processHandle.opinion(ref, Opinion.neutral); } - } - StatePolycentric.instance.updateLikeMap( - ref, - args.hasLiked, - args.hasDisliked - ) - }; + fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + Logger.i(TAG, "Started backfill"); + args.processHandle.fullyBackfillServersAnnounceExceptions(); + Logger.i(TAG, "Finished backfill"); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to backfill servers", e) + } + } + + StatePolycentric.instance.updateLikeMap( + ref, + args.hasLiked, + args.hasDisliked + ) + }; + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e); + _rating.visibility = View.GONE; } - } catch (e: Throwable) { - Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e); - _rating.visibility = View.GONE; } } @@ -2413,6 +2447,7 @@ class VideoDetailView : ConstraintLayout { Logger.i(TAG, "handleFullScreen(fullscreen=$fullscreen)") if(fullscreen) { + _container_content.visibility = GONE _layoutPlayerContainer.setPadding(0, 0, 0, 0); val lp = _container_content.layoutParams as LayoutParams; @@ -2426,6 +2461,7 @@ class VideoDetailView : ConstraintLayout { setProgressBarOverlayed(null); } else { + _container_content.visibility = VISIBLE _layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt()); val lp = _container_content.layoutParams as LayoutParams; @@ -2485,6 +2521,13 @@ class VideoDetailView : ConstraintLayout { } } + fun saveBrightness() { + _player.gestureControl.saveBrightness() + } + fun restoreBrightness() { + _player.gestureControl.restoreBrightness() + } + fun setFullscreen(fullscreen : Boolean) { Logger.i(TAG, "setFullscreen(fullscreen=$fullscreen)") _player.setFullScreen(fullscreen) @@ -2648,9 +2691,10 @@ class VideoDetailView : ConstraintLayout { } onChannelClicked.subscribe { - if(it.url.isNotBlank()) + if(it.url.isNotBlank()) { + fragment.minimizeVideoDetail() fragment.navigate(it) - else + } else UIDialogs.appToast("No author url present"); } @@ -2725,10 +2769,11 @@ class VideoDetailView : ConstraintLayout { else RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 6)); + val toBackgroundAction = RemoteAction(Icon.createWithResource(context, R.drawable.ic_screen_share), context.getString(R.string.background), context.getString(R.string.background_switch_audio), MediaControlReceiver.getToBackgroundIntent(context, 7)); return PictureInPictureParams.Builder() .setAspectRatio(Rational(videoSourceWidth, videoSourceHeight)) .setSourceRectHint(r) - .setActions(listOf(playpauseAction)) + .setActions(listOf(toBackgroundAction, playpauseAction)) .build(); } @@ -3041,7 +3086,12 @@ class VideoDetailView : ConstraintLayout { Logger.w(TAG, "Failed to load recommendations.", it); }; - private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }) + private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { + if (!StatePolycentric.instance.enabled) + return@TaskHandler null + + ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) + }) .success { it -> setPolycentricProfile(it, animate = true) } .exception { Logger.w(TAG, "Failed to load claims.", it); @@ -3113,10 +3163,6 @@ class VideoDetailView : ConstraintLayout { fun applyFragment(frag: VideoDetailFragment) { fragment = frag; - fragment.onMinimize.subscribe { - _liveChat?.stop(); - _container_content_liveChat.close(); - } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt index c0383b89..10deee40 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt @@ -1,9 +1,11 @@ package com.futo.platformplayer.fragment.mainactivity.main +import android.content.Context import android.graphics.drawable.Animatable import android.util.TypedValue import android.view.LayoutInflater import android.view.View +import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import android.widget.ImageButton import android.widget.ImageView @@ -48,6 +50,11 @@ abstract class VideoListEditorView : LinearLayout { private var _loadedVideos: List? = null; private var _loadedVideosCanEdit: Boolean = false; + fun hideSearchKeyboard() { + (context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager)?.hideSoftInputFromWindow(_search.textSearch.windowToken, 0) + _search.textSearch.clearFocus(); + } + constructor(inflater: LayoutInflater) : super(inflater.context) { inflater.inflate(R.layout.fragment_video_list_editor, this); @@ -79,6 +86,7 @@ abstract class VideoListEditorView : LinearLayout { _search.textSearch.text = ""; updateVideoFilters(); _buttonSearch.setImageResource(R.drawable.ic_search); + hideSearchKeyboard(); } else { _search.visibility = View.VISIBLE; @@ -89,23 +97,23 @@ abstract class VideoListEditorView : LinearLayout { _buttonShare = findViewById(R.id.button_share); val onShare = _onShare; if(onShare != null) { - _buttonShare.setOnClickListener { onShare.invoke() }; + _buttonShare.setOnClickListener { hideSearchKeyboard(); onShare.invoke() }; _buttonShare.visibility = View.VISIBLE; } else _buttonShare.visibility = View.GONE; - buttonPlayAll.setOnClickListener { onPlayAllClick(); }; - buttonShuffle.setOnClickListener { onShuffleClick(); }; + buttonPlayAll.setOnClickListener { hideSearchKeyboard();onPlayAllClick(); hideSearchKeyboard(); }; + buttonShuffle.setOnClickListener { hideSearchKeyboard();onShuffleClick(); hideSearchKeyboard(); }; - _buttonEdit.setOnClickListener { onEditClick(); }; + _buttonEdit.setOnClickListener { hideSearchKeyboard(); onEditClick(); }; setButtonExportVisible(false); setButtonDownloadVisible(canEdit()); videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged); videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved); videoListEditorView.onVideoOptions.subscribe(::onVideoOptions); - videoListEditorView.onVideoClicked.subscribe(::onVideoClicked); + videoListEditorView.onVideoClicked.subscribe { hideSearchKeyboard(); onVideoClicked(it)}; _videoListEditorView = videoListEditorView; } @@ -113,6 +121,7 @@ abstract class VideoListEditorView : LinearLayout { fun setOnShare(onShare: (()-> Unit)? = null) { _onShare = onShare; _buttonShare.setOnClickListener { + hideSearchKeyboard(); onShare?.invoke(); }; _buttonShare.visibility = View.VISIBLE; @@ -145,7 +154,7 @@ abstract class VideoListEditorView : LinearLayout { setButtonExportVisible(false); _buttonDownload.setImageResource(R.drawable.ic_loader_animated); _buttonDownload.drawable.assume { it.start() }; - _buttonDownload.setOnClickListener { + _buttonDownload.setOnClickListener { hideSearchKeyboard(); UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), { StateDownloads.instance.deleteCachedPlaylist(playlistId); }); @@ -154,7 +163,7 @@ abstract class VideoListEditorView : LinearLayout { else if(isDownloaded) { setButtonExportVisible(true) _buttonDownload.setImageResource(R.drawable.ic_download_off); - _buttonDownload.setOnClickListener { + _buttonDownload.setOnClickListener { hideSearchKeyboard(); UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), { StateDownloads.instance.deleteCachedPlaylist(playlistId); }); @@ -163,7 +172,7 @@ abstract class VideoListEditorView : LinearLayout { else { setButtonExportVisible(false); _buttonDownload.setImageResource(R.drawable.ic_download); - _buttonDownload.setOnClickListener { + _buttonDownload.setOnClickListener { hideSearchKeyboard(); onDownload(); //UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer); } @@ -215,7 +224,8 @@ abstract class VideoListEditorView : LinearLayout { fun updateVideoFilters() { val videos = _loadedVideos ?: return; - _videoListEditorView.setVideos(filterVideos(videos), _loadedVideosCanEdit); + val filteredVideos = filterVideos(videos) + _videoListEditorView.setVideos(filteredVideos, _loadedVideosCanEdit && filteredVideos.size == videos.size); } protected fun setButtonDownloadVisible(isVisible: Boolean) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WebDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WebDetailFragment.kt new file mode 100644 index 00000000..8aa1eec2 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WebDetailFragment.kt @@ -0,0 +1,223 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Animatable +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewPropertyAnimator +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Button +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.children +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.platforms.js.models.JSWeb +import com.futo.platformplayer.api.media.platforms.js.models.JSWebDetails +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.dp +import com.futo.platformplayer.fixHtmlWhitespace +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.adapters.ChannelTab +import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView +import com.futo.platformplayer.views.comments.AddCommentView +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.overlays.RepliesOverlay +import com.futo.platformplayer.views.pills.PillRatingLikesDislikes +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.segments.CommentsList +import com.futo.platformplayer.views.subscriptions.SubscribeButton +import com.futo.polycentric.core.ApiMethods +import com.futo.polycentric.core.ContentType +import com.futo.polycentric.core.Models +import com.futo.polycentric.core.Opinion +import com.futo.polycentric.core.PolycentricProfile +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions +import com.google.android.flexbox.FlexboxLayout +import com.google.android.material.imageview.ShapeableImageView +import com.google.android.material.shape.CornerFamily +import com.google.android.material.shape.ShapeAppearanceModel +import com.google.protobuf.ByteString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import userpackage.Protocol +import java.lang.Integer.min + +class WebDetailFragment : MainFragment { + override val isMainView: Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _viewDetail: WebDetailView? = null; + + constructor() : super() { } + + override fun onBackPressed(): Boolean { + return false; + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = WebDetailView(inflater.context).applyFragment(this); + _viewDetail = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _viewDetail?.onDestroy(); + _viewDetail = null; + } + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + + if (parameter is JSWeb) { + _viewDetail?.clear(); + _viewDetail?.setWeb(parameter); + } + if (parameter is JSWebDetails) { + _viewDetail?.clear(); + _viewDetail?.setWebDetails(parameter); + } + } + + private class WebDetailView : ConstraintLayout { + private lateinit var _fragment: WebDetailFragment; + private var _url: String? = null; + private var _isLoading = false; + private var _web: JSWebDetails? = null; + + private val _layoutLoadingOverlay: FrameLayout; + private val _imageLoader: ImageView; + + private val _webview: WebView; + + private val _taskLoadPost = if(!isInEditMode) TaskHandler( + StateApp.instance.scopeGetter, + { + val result = StatePlatform.instance.getContentDetails(it).await(); + if(result !is JSWebDetails) + throw IllegalStateException(context.getString(R.string.expected_media_content_found) + " ${result.contentType}"); + return@TaskHandler result; + }) + .success { setWebDetails(it) } + .exception { + Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it); + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment); + } else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope }; + + + constructor(context: Context) : super(context) { + inflate(context, R.layout.fragview_web_detail, this); + + val root = findViewById(R.id.root); + + _layoutLoadingOverlay = findViewById(R.id.layout_loading_overlay); + _imageLoader = findViewById(R.id.image_loader); + + _webview = findViewById(R.id.webview); + _webview.webViewClient = object: WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url); + if(url != "about:blank") + setLoading(false); + } + } + } + + + fun applyFragment(frag: WebDetailFragment): WebDetailView { + _fragment = frag; + return this; + } + + fun clear() { + _webview.loadUrl("about:blank"); + } + + fun setWeb(value: JSWeb) { + _url = value.url; + setLoading(true); + clear(); + fetchPost(); + } + fun setWebDetails(value: JSWebDetails) { + _web = value; + setLoading(true); + _webview.loadUrl("about:blank"); + if(!value.html.isNullOrEmpty()) + _webview.loadData(value.html, "text/html", null); + else + _webview.loadUrl(value.url ?: "about:blank"); + } + + private fun fetchPost() { + Logger.i(WebDetailView.TAG, "fetchWeb") + _web = null; + + val url = _url; + if (!url.isNullOrBlank()) { + setLoading(true); + _taskLoadPost.run(url); + } + } + + fun onDestroy() { + _webview.loadUrl("about:blank"); + } + + private fun setLoading(isLoading : Boolean) { + if (_isLoading == isLoading) { + return; + } + + _isLoading = isLoading; + + if(isLoading) { + (_imageLoader.drawable as Animatable?)?.start() + _layoutLoadingOverlay.visibility = View.VISIBLE; + } + else { + _layoutLoadingOverlay.visibility = View.GONE; + (_imageLoader.drawable as Animatable?)?.stop() + } + } + + companion object { + const val TAG = "WebDetailFragment" + } + } + + companion object { + fun newInstance() = WebDetailFragment().apply {} + } +} diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt index 44d8a9ad..15952d0a 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt @@ -88,7 +88,6 @@ class SearchTopBarFragment : TopFragment() { } else if (parameter is SuggestionsFragmentData) { this.setText(parameter.query); _searchType = parameter.searchType; - _channelUrl = parameter.channelUrl; } if(currentMain is SuggestionsFragment) @@ -114,7 +113,7 @@ class SearchTopBarFragment : TopFragment() { fun clear() { _editSearch?.text?.clear(); if (currentMain !is SuggestionsFragment) { - navigate(SuggestionsFragmentData("", _searchType, _channelUrl), false); + navigate(SuggestionsFragmentData("", _searchType), false); } else { onSearch.emit(""); } diff --git a/app/src/main/java/com/futo/platformplayer/mdns/BroadcastService.kt b/app/src/main/java/com/futo/platformplayer/mdns/BroadcastService.kt deleted file mode 100644 index ac3c61e0..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/BroadcastService.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.futo.platformplayer.mdns - -data class BroadcastService( - val deviceName: String, - val serviceName: String, - val port: UShort, - val ttl: UInt, - val weight: UShort, - val priority: UShort, - val texts: List? = null -) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsPacket.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsPacket.kt deleted file mode 100644 index 2c27edf8..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/DnsPacket.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.futo.platformplayer.mdns - -import java.nio.ByteBuffer -import java.nio.ByteOrder - -enum class QueryResponse(val value: Byte) { - Query(0), - Response(1) -} - -enum class DnsOpcode(val value: Byte) { - StandardQuery(0), - InverseQuery(1), - ServerStatusRequest(2) -} - -enum class DnsResponseCode(val value: Byte) { - NoError(0), - FormatError(1), - ServerFailure(2), - NameError(3), - NotImplemented(4), - Refused(5) -} - -data class DnsPacketHeader( - val identifier: UShort, - val queryResponse: Int, - val opcode: Int, - val authoritativeAnswer: Boolean, - val truncated: Boolean, - val recursionDesired: Boolean, - val recursionAvailable: Boolean, - val answerAuthenticated: Boolean, - val nonAuthenticatedData: Boolean, - val responseCode: DnsResponseCode -) - -data class DnsPacket( - val header: DnsPacketHeader, - val questions: List, - val answers: List, - val authorities: List, - val additionals: List -) { - companion object { - fun parse(data: ByteArray): DnsPacket { - val span = data.asUByteArray() - val flags = (span[2].toInt() shl 8 or span[3].toInt()).toUShort() - val questionCount = (span[4].toInt() shl 8 or span[5].toInt()).toUShort() - val answerCount = (span[6].toInt() shl 8 or span[7].toInt()).toUShort() - val authorityCount = (span[8].toInt() shl 8 or span[9].toInt()).toUShort() - val additionalCount = (span[10].toInt() shl 8 or span[11].toInt()).toUShort() - - var position = 12 - - val questions = List(questionCount.toInt()) { - DnsQuestion.parse(data, position).also { position = it.second } - }.map { it.first } - - val answers = List(answerCount.toInt()) { - DnsResourceRecord.parse(data, position).also { position = it.second } - }.map { it.first } - - val authorities = List(authorityCount.toInt()) { - DnsResourceRecord.parse(data, position).also { position = it.second } - }.map { it.first } - - val additionals = List(additionalCount.toInt()) { - DnsResourceRecord.parse(data, position).also { position = it.second } - }.map { it.first } - - return DnsPacket( - header = DnsPacketHeader( - identifier = (span[0].toInt() shl 8 or span[1].toInt()).toUShort(), - queryResponse = ((flags.toUInt() shr 15) and 0b1u).toInt(), - opcode = ((flags.toUInt() shr 11) and 0b1111u).toInt(), - authoritativeAnswer = (flags.toInt() shr 10) and 0b1 != 0, - truncated = (flags.toInt() shr 9) and 0b1 != 0, - recursionDesired = (flags.toInt() shr 8) and 0b1 != 0, - recursionAvailable = (flags.toInt() shr 7) and 0b1 != 0, - answerAuthenticated = (flags.toInt() shr 5) and 0b1 != 0, - nonAuthenticatedData = (flags.toInt() shr 4) and 0b1 != 0, - responseCode = DnsResponseCode.entries[flags.toInt() and 0b1111] - ), - questions = questions, - answers = answers, - authorities = authorities, - additionals = additionals - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsQuestion.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsQuestion.kt deleted file mode 100644 index 01a7bd77..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/DnsQuestion.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.futo.platformplayer.mdns - -import com.futo.platformplayer.mdns.Extensions.readDomainName -import java.nio.ByteBuffer -import java.nio.ByteOrder - - -enum class QuestionType(val value: UShort) { - A(1u), - NS(2u), - MD(3u), - MF(4u), - CNAME(5u), - SOA(6u), - MB(7u), - MG(8u), - MR(9u), - NULL(10u), - WKS(11u), - PTR(12u), - HINFO(13u), - MINFO(14u), - MX(15u), - TXT(16u), - RP(17u), - AFSDB(18u), - SIG(24u), - KEY(25u), - AAAA(28u), - LOC(29u), - SRV(33u), - NAPTR(35u), - KX(36u), - CERT(37u), - DNAME(39u), - APL(42u), - DS(43u), - SSHFP(44u), - IPSECKEY(45u), - RRSIG(46u), - NSEC(47u), - DNSKEY(48u), - DHCID(49u), - NSEC3(50u), - NSEC3PARAM(51u), - TSLA(52u), - SMIMEA(53u), - HIP(55u), - CDS(59u), - CDNSKEY(60u), - OPENPGPKEY(61u), - CSYNC(62u), - ZONEMD(63u), - SVCB(64u), - HTTPS(65u), - EUI48(108u), - EUI64(109u), - TKEY(249u), - TSIG(250u), - URI(256u), - CAA(257u), - TA(32768u), - DLV(32769u), - AXFR(252u), - IXFR(251u), - OPT(41u), - MAILB(253u), - MALA(254u), - All(252u) -} - -enum class QuestionClass(val value: UShort) { - IN(1u), - CS(2u), - CH(3u), - HS(4u), - All(255u) -} - -data class DnsQuestion( - override val name: String, - override val type: Int, - override val clazz: Int, - val queryUnicast: Boolean -) : DnsResourceRecordBase(name, type, clazz) { - companion object { - fun parse(data: ByteArray, startPosition: Int): Pair { - val span = data.asUByteArray() - var position = startPosition - val qname = span.readDomainName(position).also { position = it.second } - val qtype = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() - position += 2 - val qclass = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() - position += 2 - - return DnsQuestion( - name = qname.first, - type = qtype.toInt(), - queryUnicast = ((qclass.toInt() shr 15) and 0b1) != 0, - clazz = qclass.toInt() and 0b111111111111111 - ) to position - } - } -} - -open class DnsResourceRecordBase( - open val name: String, - open val type: Int, - open val clazz: Int -) diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsReader.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsReader.kt deleted file mode 100644 index 83c329ff..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/DnsReader.kt +++ /dev/null @@ -1,514 +0,0 @@ -package com.futo.platformplayer.mdns - -import com.futo.platformplayer.mdns.Extensions.readDomainName -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.nio.charset.StandardCharsets -import kotlin.math.pow -import java.net.InetAddress - -data class PTRRecord(val domainName: String) - -data class ARecord(val address: InetAddress) - -data class AAAARecord(val address: InetAddress) - -data class MXRecord(val preference: UShort, val exchange: String) - -data class CNAMERecord(val cname: String) - -data class TXTRecord(val texts: List) - -data class SOARecord( - val primaryNameServer: String, - val responsibleAuthorityMailbox: String, - val serialNumber: Int, - val refreshInterval: Int, - val retryInterval: Int, - val expiryLimit: Int, - val minimumTTL: Int -) - -data class SRVRecord(val priority: UShort, val weight: UShort, val port: UShort, val target: String) - -data class NSRecord(val nameServer: String) - -data class CAARecord(val flags: Byte, val tag: String, val value: String) - -data class HINFORecord(val cpu: String, val os: String) - -data class RPRecord(val mailbox: String, val txtDomainName: String) - - -data class AFSDBRecord(val subtype: UShort, val hostname: String) -data class LOCRecord( - val version: Byte, - val size: Double, - val horizontalPrecision: Double, - val verticalPrecision: Double, - val latitude: Double, - val longitude: Double, - val altitude: Double -) { - companion object { - fun decodeSizeOrPrecision(coded: Byte): Double { - val baseValue = (coded.toInt() shr 4) and 0x0F - val exponent = coded.toInt() and 0x0F - return baseValue * 10.0.pow(exponent.toDouble()) - } - - fun decodeLatitudeOrLongitude(coded: Int): Double { - val arcSeconds = coded / 1E3 - return arcSeconds / 3600.0 - } - - fun decodeAltitude(coded: Int): Double { - return (coded / 100.0) - 100000.0 - } - } -} - -data class NAPTRRecord( - val order: UShort, - val preference: UShort, - val flags: String, - val services: String, - val regexp: String, - val replacement: String -) - -data class RRSIGRecord( - val typeCovered: UShort, - val algorithm: Byte, - val labels: Byte, - val originalTTL: UInt, - val signatureExpiration: UInt, - val signatureInception: UInt, - val keyTag: UShort, - val signersName: String, - val signature: ByteArray -) - -data class KXRecord(val preference: UShort, val exchanger: String) - -data class CERTRecord(val type: UShort, val keyTag: UShort, val algorithm: Byte, val certificate: ByteArray) - - - -data class DNAMERecord(val target: String) - -data class DSRecord(val keyTag: UShort, val algorithm: Byte, val digestType: Byte, val digest: ByteArray) - -data class SSHFPRecord(val algorithm: Byte, val fingerprintType: Byte, val fingerprint: ByteArray) - -data class TLSARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray) - -data class SMIMEARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray) - -data class URIRecord(val priority: UShort, val weight: UShort, val target: String) - -data class NSECRecord(val ownerName: String, val typeBitMaps: List>) -data class NSEC3Record( - val hashAlgorithm: Byte, - val flags: Byte, - val iterations: UShort, - val salt: ByteArray, - val nextHashedOwnerName: ByteArray, - val typeBitMaps: List -) - -data class NSEC3PARAMRecord(val hashAlgorithm: Byte, val flags: Byte, val iterations: UShort, val salt: ByteArray) -data class SPFRecord(val texts: List) -data class TKEYRecord( - val algorithm: String, - val inception: UInt, - val expiration: UInt, - val mode: UShort, - val error: UShort, - val keyData: ByteArray, - val otherData: ByteArray -) - -data class TSIGRecord( - val algorithmName: String, - val timeSigned: UInt, - val fudge: UShort, - val mac: ByteArray, - val originalID: UShort, - val error: UShort, - val otherData: ByteArray -) - -data class OPTRecordOption(val code: UShort, val data: ByteArray) -data class OPTRecord(val options: List) - -class DnsReader(private val data: ByteArray, private var position: Int = 0, private val length: Int = data.size) { - - private val endPosition: Int = position + length - - fun readDomainName(): String { - return data.asUByteArray().readDomainName(position).also { position = it.second }.first - } - - fun readDouble(): Double { - checkRemainingBytes(Double.SIZE_BYTES) - val result = ByteBuffer.wrap(data, position, Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).double - position += Double.SIZE_BYTES - return result - } - - fun readInt16(): Short { - checkRemainingBytes(Short.SIZE_BYTES) - val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short - position += Short.SIZE_BYTES - return result - } - - fun readInt32(): Int { - checkRemainingBytes(Int.SIZE_BYTES) - val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int - position += Int.SIZE_BYTES - return result - } - - fun readInt64(): Long { - checkRemainingBytes(Long.SIZE_BYTES) - val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long - position += Long.SIZE_BYTES - return result - } - - fun readSingle(): Float { - checkRemainingBytes(Float.SIZE_BYTES) - val result = ByteBuffer.wrap(data, position, Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).float - position += Float.SIZE_BYTES - return result - } - - fun readByte(): Byte { - checkRemainingBytes(Byte.SIZE_BYTES) - return data[position++] - } - - fun readBytes(length: Int): ByteArray { - checkRemainingBytes(length) - return ByteArray(length).also { data.copyInto(it, startIndex = position, endIndex = position + length) } - .also { position += length } - } - - fun readUInt16(): UShort { - checkRemainingBytes(Short.SIZE_BYTES) - val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short.toUShort() - position += Short.SIZE_BYTES - return result - } - - fun readUInt32(): UInt { - checkRemainingBytes(Int.SIZE_BYTES) - val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int.toUInt() - position += Int.SIZE_BYTES - return result - } - - fun readUInt64(): ULong { - checkRemainingBytes(Long.SIZE_BYTES) - val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long.toULong() - position += Long.SIZE_BYTES - return result - } - - fun readString(): String { - val length = data[position++].toInt() - checkRemainingBytes(length) - return String(data, position, length, StandardCharsets.UTF_8).also { position += length } - } - - private fun checkRemainingBytes(requiredBytes: Int) { - if (position + requiredBytes > endPosition) throw IndexOutOfBoundsException() - } - - fun readRPRecord(): RPRecord { - return RPRecord(readDomainName(), readDomainName()) - } - - fun readKXRecord(): KXRecord { - val preference = readUInt16() - val exchanger = readDomainName() - return KXRecord(preference, exchanger) - } - - fun readCERTRecord(): CERTRecord { - val type = readUInt16() - val keyTag = readUInt16() - val algorithm = readByte() - val certificateLength = readUInt16().toInt() - 5 - val certificate = readBytes(certificateLength) - return CERTRecord(type, keyTag, algorithm, certificate) - } - - fun readPTRRecord(): PTRRecord { - return PTRRecord(readDomainName()) - } - - fun readARecord(): ARecord { - val address = readBytes(4) - return ARecord(InetAddress.getByAddress(address)) - } - - fun readAAAARecord(): AAAARecord { - val address = readBytes(16) - return AAAARecord(InetAddress.getByAddress(address)) - } - - fun readMXRecord(): MXRecord { - val preference = readUInt16() - val exchange = readDomainName() - return MXRecord(preference, exchange) - } - - fun readCNAMERecord(): CNAMERecord { - return CNAMERecord(readDomainName()) - } - - fun readTXTRecord(): TXTRecord { - val texts = mutableListOf() - while (position < endPosition) { - val textLength = data[position++].toInt() - checkRemainingBytes(textLength) - val text = String(data, position, textLength, StandardCharsets.UTF_8) - texts.add(text) - position += textLength - } - return TXTRecord(texts) - } - - fun readSOARecord(): SOARecord { - val primaryNameServer = readDomainName() - val responsibleAuthorityMailbox = readDomainName() - val serialNumber = readInt32() - val refreshInterval = readInt32() - val retryInterval = readInt32() - val expiryLimit = readInt32() - val minimumTTL = readInt32() - return SOARecord(primaryNameServer, responsibleAuthorityMailbox, serialNumber, refreshInterval, retryInterval, expiryLimit, minimumTTL) - } - - fun readSRVRecord(): SRVRecord { - val priority = readUInt16() - val weight = readUInt16() - val port = readUInt16() - val target = readDomainName() - return SRVRecord(priority, weight, port, target) - } - - fun readNSRecord(): NSRecord { - return NSRecord(readDomainName()) - } - - fun readCAARecord(): CAARecord { - val length = readUInt16().toInt() - val flags = readByte() - val tagLength = readByte().toInt() - val tag = String(data, position, tagLength, StandardCharsets.US_ASCII).also { position += tagLength } - val valueLength = length - 1 - 1 - tagLength - val value = String(data, position, valueLength, StandardCharsets.US_ASCII).also { position += valueLength } - return CAARecord(flags, tag, value) - } - - fun readHINFORecord(): HINFORecord { - val cpuLength = readByte().toInt() - val cpu = String(data, position, cpuLength, StandardCharsets.US_ASCII).also { position += cpuLength } - val osLength = readByte().toInt() - val os = String(data, position, osLength, StandardCharsets.US_ASCII).also { position += osLength } - return HINFORecord(cpu, os) - } - - fun readAFSDBRecord(): AFSDBRecord { - return AFSDBRecord(readUInt16(), readDomainName()) - } - - fun readLOCRecord(): LOCRecord { - val version = readByte() - val size = LOCRecord.decodeSizeOrPrecision(readByte()) - val horizontalPrecision = LOCRecord.decodeSizeOrPrecision(readByte()) - val verticalPrecision = LOCRecord.decodeSizeOrPrecision(readByte()) - val latitudeCoded = readInt32() - val longitudeCoded = readInt32() - val altitudeCoded = readInt32() - val latitude = LOCRecord.decodeLatitudeOrLongitude(latitudeCoded) - val longitude = LOCRecord.decodeLatitudeOrLongitude(longitudeCoded) - val altitude = LOCRecord.decodeAltitude(altitudeCoded) - return LOCRecord(version, size, horizontalPrecision, verticalPrecision, latitude, longitude, altitude) - } - - fun readNAPTRRecord(): NAPTRRecord { - val order = readUInt16() - val preference = readUInt16() - val flags = readString() - val services = readString() - val regexp = readString() - val replacement = readDomainName() - return NAPTRRecord(order, preference, flags, services, regexp, replacement) - } - - fun readDNAMERecord(): DNAMERecord { - return DNAMERecord(readDomainName()) - } - - fun readDSRecord(): DSRecord { - val keyTag = readUInt16() - val algorithm = readByte() - val digestType = readByte() - val digestLength = readUInt16().toInt() - 4 - val digest = readBytes(digestLength) - return DSRecord(keyTag, algorithm, digestType, digest) - } - - fun readSSHFPRecord(): SSHFPRecord { - val algorithm = readByte() - val fingerprintType = readByte() - val fingerprintLength = readUInt16().toInt() - 2 - val fingerprint = readBytes(fingerprintLength) - return SSHFPRecord(algorithm, fingerprintType, fingerprint) - } - - fun readTLSARecord(): TLSARecord { - val usage = readByte() - val selector = readByte() - val matchingType = readByte() - val dataLength = readUInt16().toInt() - 3 - val certificateAssociationData = readBytes(dataLength) - return TLSARecord(usage, selector, matchingType, certificateAssociationData) - } - - fun readSMIMEARecord(): SMIMEARecord { - val usage = readByte() - val selector = readByte() - val matchingType = readByte() - val dataLength = readUInt16().toInt() - 3 - val certificateAssociationData = readBytes(dataLength) - return SMIMEARecord(usage, selector, matchingType, certificateAssociationData) - } - - fun readURIRecord(): URIRecord { - val priority = readUInt16() - val weight = readUInt16() - val length = readUInt16().toInt() - val target = String(data, position, length, StandardCharsets.US_ASCII).also { position += length } - return URIRecord(priority, weight, target) - } - - fun readRRSIGRecord(): RRSIGRecord { - val typeCovered = readUInt16() - val algorithm = readByte() - val labels = readByte() - val originalTTL = readUInt32() - val signatureExpiration = readUInt32() - val signatureInception = readUInt32() - val keyTag = readUInt16() - val signersName = readDomainName() - val signatureLength = readUInt16().toInt() - val signature = readBytes(signatureLength) - return RRSIGRecord( - typeCovered, - algorithm, - labels, - originalTTL, - signatureExpiration, - signatureInception, - keyTag, - signersName, - signature - ) - } - - fun readNSECRecord(): NSECRecord { - val ownerName = readDomainName() - val typeBitMaps = mutableListOf>() - while (position < endPosition) { - val windowBlock = readByte() - val bitmapLength = readByte().toInt() - val bitmap = readBytes(bitmapLength) - typeBitMaps.add(windowBlock to bitmap) - } - return NSECRecord(ownerName, typeBitMaps) - } - - fun readNSEC3Record(): NSEC3Record { - val hashAlgorithm = readByte() - val flags = readByte() - val iterations = readUInt16() - val saltLength = readByte().toInt() - val salt = readBytes(saltLength) - val hashLength = readByte().toInt() - val nextHashedOwnerName = readBytes(hashLength) - val bitMapLength = readUInt16().toInt() - val typeBitMaps = mutableListOf() - val endPos = position + bitMapLength - while (position < endPos) { - typeBitMaps.add(readUInt16()) - } - return NSEC3Record(hashAlgorithm, flags, iterations, salt, nextHashedOwnerName, typeBitMaps) - } - - fun readNSEC3PARAMRecord(): NSEC3PARAMRecord { - val hashAlgorithm = readByte() - val flags = readByte() - val iterations = readUInt16() - val saltLength = readByte().toInt() - val salt = readBytes(saltLength) - return NSEC3PARAMRecord(hashAlgorithm, flags, iterations, salt) - } - - - fun readSPFRecord(): SPFRecord { - val length = readUInt16().toInt() - val texts = mutableListOf() - val endPos = position + length - while (position < endPos) { - val textLength = readByte().toInt() - val text = String(data, position, textLength, StandardCharsets.US_ASCII).also { position += textLength } - texts.add(text) - } - return SPFRecord(texts) - } - - fun readTKEYRecord(): TKEYRecord { - val algorithm = readDomainName() - val inception = readUInt32() - val expiration = readUInt32() - val mode = readUInt16() - val error = readUInt16() - val keySize = readUInt16().toInt() - val keyData = readBytes(keySize) - val otherSize = readUInt16().toInt() - val otherData = readBytes(otherSize) - return TKEYRecord(algorithm, inception, expiration, mode, error, keyData, otherData) - } - - fun readTSIGRecord(): TSIGRecord { - val algorithmName = readDomainName() - val timeSigned = readUInt32() - val fudge = readUInt16() - val macSize = readUInt16().toInt() - val mac = readBytes(macSize) - val originalID = readUInt16() - val error = readUInt16() - val otherSize = readUInt16().toInt() - val otherData = readBytes(otherSize) - return TSIGRecord(algorithmName, timeSigned, fudge, mac, originalID, error, otherData) - } - - - - fun readOPTRecord(): OPTRecord { - val options = mutableListOf() - while (position < endPosition) { - val optionCode = readUInt16() - val optionLength = readUInt16().toInt() - val optionData = readBytes(optionLength) - options.add(OPTRecordOption(optionCode, optionData)) - } - return OPTRecord(options) - } -} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsResourceRecord.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsResourceRecord.kt deleted file mode 100644 index 87ec0e5f..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/DnsResourceRecord.kt +++ /dev/null @@ -1,117 +0,0 @@ -package com.futo.platformplayer.mdns - -import com.futo.platformplayer.mdns.Extensions.readDomainName - -enum class ResourceRecordType(val value: UShort) { - None(0u), - A(1u), - NS(2u), - MD(3u), - MF(4u), - CNAME(5u), - SOA(6u), - MB(7u), - MG(8u), - MR(9u), - NULL(10u), - WKS(11u), - PTR(12u), - HINFO(13u), - MINFO(14u), - MX(15u), - TXT(16u), - RP(17u), - AFSDB(18u), - SIG(24u), - KEY(25u), - AAAA(28u), - LOC(29u), - SRV(33u), - NAPTR(35u), - KX(36u), - CERT(37u), - DNAME(39u), - APL(42u), - DS(43u), - SSHFP(44u), - IPSECKEY(45u), - RRSIG(46u), - NSEC(47u), - DNSKEY(48u), - DHCID(49u), - NSEC3(50u), - NSEC3PARAM(51u), - TSLA(52u), - SMIMEA(53u), - HIP(55u), - CDS(59u), - CDNSKEY(60u), - OPENPGPKEY(61u), - CSYNC(62u), - ZONEMD(63u), - SVCB(64u), - HTTPS(65u), - EUI48(108u), - EUI64(109u), - TKEY(249u), - TSIG(250u), - URI(256u), - CAA(257u), - TA(32768u), - DLV(32769u), - AXFR(252u), - IXFR(251u), - OPT(41u) -} - -enum class ResourceRecordClass(val value: UShort) { - IN(1u), - CS(2u), - CH(3u), - HS(4u) -} - -data class DnsResourceRecord( - override val name: String, - override val type: Int, - override val clazz: Int, - val timeToLive: UInt, - val cacheFlush: Boolean, - val dataPosition: Int = -1, - val dataLength: Int = -1, - private val data: ByteArray? = null -) : DnsResourceRecordBase(name, type, clazz) { - - companion object { - fun parse(data: ByteArray, startPosition: Int): Pair { - val span = data.asUByteArray() - var position = startPosition - val name = span.readDomainName(position).also { position = it.second } - val type = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() - position += 2 - val clazz = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() - position += 2 - val ttl = (span[position].toInt() shl 24 or (span[position + 1].toInt() shl 16) or - (span[position + 2].toInt() shl 8) or span[position + 3].toInt()).toUInt() - position += 4 - val rdlength = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() - val rdposition = position + 2 - position += 2 + rdlength.toInt() - - return DnsResourceRecord( - name = name.first, - type = type.toInt(), - clazz = clazz.toInt() and 0b1111111_11111111, - timeToLive = ttl, - cacheFlush = ((clazz.toInt() shr 15) and 0b1) != 0, - dataPosition = rdposition, - dataLength = rdlength.toInt(), - data = data - ) to position - } - } - - fun getDataReader(): DnsReader { - return DnsReader(data!!, dataPosition, dataLength) - } -} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsWriter.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsWriter.kt deleted file mode 100644 index 48a04580..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/DnsWriter.kt +++ /dev/null @@ -1,208 +0,0 @@ -package com.futo.platformplayer.mdns - -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.nio.charset.StandardCharsets - -class DnsWriter { - private val data = mutableListOf() - private val namePositions = mutableMapOf() - - fun toByteArray(): ByteArray = data.toByteArray() - - fun writePacket( - header: DnsPacketHeader, - questionCount: Int? = null, questionWriter: ((DnsWriter, Int) -> Unit)? = null, - answerCount: Int? = null, answerWriter: ((DnsWriter, Int) -> Unit)? = null, - authorityCount: Int? = null, authorityWriter: ((DnsWriter, Int) -> Unit)? = null, - additionalsCount: Int? = null, additionalWriter: ((DnsWriter, Int) -> Unit)? = null - ) { - if (questionCount != null && questionWriter == null || questionCount == null && questionWriter != null) - throw Exception("When question count is given, question writer should also be given.") - if (answerCount != null && answerWriter == null || answerCount == null && answerWriter != null) - throw Exception("When answer count is given, answer writer should also be given.") - if (authorityCount != null && authorityWriter == null || authorityCount == null && authorityWriter != null) - throw Exception("When authority count is given, authority writer should also be given.") - if (additionalsCount != null && additionalWriter == null || additionalsCount == null && additionalWriter != null) - throw Exception("When additionals count is given, additional writer should also be given.") - - writeHeader(header, questionCount ?: 0, answerCount ?: 0, authorityCount ?: 0, additionalsCount ?: 0) - - repeat(questionCount ?: 0) { questionWriter?.invoke(this, it) } - repeat(answerCount ?: 0) { answerWriter?.invoke(this, it) } - repeat(authorityCount ?: 0) { authorityWriter?.invoke(this, it) } - repeat(additionalsCount ?: 0) { additionalWriter?.invoke(this, it) } - } - - fun writeHeader(header: DnsPacketHeader, questionCount: Int, answerCount: Int, authorityCount: Int, additionalsCount: Int) { - write(header.identifier) - - var flags: UShort = 0u - flags = flags or ((header.queryResponse.toUInt() and 0xFFFFu) shl 15).toUShort() - flags = flags or ((header.opcode.toUInt() and 0xFFFFu) shl 11).toUShort() - flags = flags or ((if (header.authoritativeAnswer) 1u else 0u) shl 10).toUShort() - flags = flags or ((if (header.truncated) 1u else 0u) shl 9).toUShort() - flags = flags or ((if (header.recursionDesired) 1u else 0u) shl 8).toUShort() - flags = flags or ((if (header.recursionAvailable) 1u else 0u) shl 7).toUShort() - flags = flags or ((if (header.answerAuthenticated) 1u else 0u) shl 5).toUShort() - flags = flags or ((if (header.nonAuthenticatedData) 1u else 0u) shl 4).toUShort() - flags = flags or header.responseCode.value.toUShort() - write(flags) - - write(questionCount.toUShort()) - write(answerCount.toUShort()) - write(authorityCount.toUShort()) - write(additionalsCount.toUShort()) - } - - fun writeDomainName(name: String) { - synchronized(namePositions) { - val labels = name.split('.') - for (label in labels) { - val nameAtOffset = name.substring(name.indexOf(label)) - if (namePositions.containsKey(nameAtOffset)) { - val position = namePositions[nameAtOffset]!! - val pointer = (0b11000000_00000000 or position).toUShort() - write(pointer) - return - } - if (label.isNotEmpty()) { - val labelBytes = label.toByteArray(StandardCharsets.UTF_8) - val nameStartPos = data.size - write(labelBytes.size.toByte()) - write(labelBytes) - namePositions[nameAtOffset] = nameStartPos - } - } - write(0.toByte()) - } - } - - fun write(value: DnsResourceRecord, dataWriter: (DnsWriter) -> Unit) { - writeDomainName(value.name) - write(value.type.toUShort()) - val cls = ((if (value.cacheFlush) 1u else 0u) shl 15).toUShort() or value.clazz.toUShort() - write(cls) - write(value.timeToLive) - - val lengthOffset = data.size - write(0.toUShort()) - dataWriter(this) - val rdLength = data.size - lengthOffset - 2 - val rdLengthBytes = ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN).putShort(rdLength.toShort()).array() - data[lengthOffset] = rdLengthBytes[0] - data[lengthOffset + 1] = rdLengthBytes[1] - } - - fun write(value: DnsQuestion) { - writeDomainName(value.name) - write(value.type.toUShort()) - write(((if (value.queryUnicast) 1u else 0u shl 15).toUShort() or value.clazz.toUShort())) - } - - fun write(value: Double) { - val bytes = ByteBuffer.allocate(Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putDouble(value).array() - write(bytes) - } - - fun write(value: Short) { - val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value).array() - write(bytes) - } - - fun write(value: Int) { - val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value).array() - write(bytes) - } - - fun write(value: Long) { - val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value).array() - write(bytes) - } - - fun write(value: Float) { - val bytes = ByteBuffer.allocate(Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putFloat(value).array() - write(bytes) - } - - fun write(value: Byte) { - data.add(value) - } - - fun write(value: ByteArray) { - data.addAll(value.asIterable()) - } - - fun write(value: ByteArray, offset: Int, length: Int) { - data.addAll(value.slice(offset until offset + length)) - } - - fun write(value: UShort) { - val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value.toShort()).array() - write(bytes) - } - - fun write(value: UInt) { - val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value.toInt()).array() - write(bytes) - } - - fun write(value: ULong) { - val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value.toLong()).array() - write(bytes) - } - - fun write(value: String) { - val bytes = value.toByteArray(StandardCharsets.UTF_8) - write(bytes.size.toByte()) - write(bytes) - } - - fun write(value: PTRRecord) { - writeDomainName(value.domainName) - } - - fun write(value: ARecord) { - val bytes = value.address.address - if (bytes.size != 4) throw Exception("Unexpected amount of address bytes.") - write(bytes) - } - - fun write(value: AAAARecord) { - val bytes = value.address.address - if (bytes.size != 16) throw Exception("Unexpected amount of address bytes.") - write(bytes) - } - - fun write(value: TXTRecord) { - value.texts.forEach { - val bytes = it.toByteArray(StandardCharsets.UTF_8) - write(bytes.size.toByte()) - write(bytes) - } - } - - fun write(value: SRVRecord) { - write(value.priority) - write(value.weight) - write(value.port) - writeDomainName(value.target) - } - - fun write(value: NSECRecord) { - writeDomainName(value.ownerName) - value.typeBitMaps.forEach { (windowBlock, bitmap) -> - write(windowBlock) - write(bitmap.size.toByte()) - write(bitmap) - } - } - - fun write(value: OPTRecord) { - value.options.forEach { option -> - write(option.code) - write(option.data.size.toUShort()) - write(option.data) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/mdns/Extensions.kt b/app/src/main/java/com/futo/platformplayer/mdns/Extensions.kt deleted file mode 100644 index 48bb4c6a..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/Extensions.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.futo.platformplayer.mdns - -import android.util.Log - -object Extensions { - fun ByteArray.toByteDump(): String { - val result = StringBuilder() - for (i in indices) { - result.append(String.format("%02X ", this[i])) - - if ((i + 1) % 16 == 0 || i == size - 1) { - val padding = 3 * (16 - (i % 16 + 1)) - if (i == size - 1 && (i + 1) % 16 != 0) result.append(" ".repeat(padding)) - - result.append("; ") - val start = i - (i % 16) - val end = minOf(i, size - 1) - for (j in start..end) { - val ch = if (this[j] in 32..127) this[j].toChar() else '.' - result.append(ch) - } - if (i != size - 1) result.appendLine() - } - } - return result.toString() - } - - fun UByteArray.readDomainName(startPosition: Int): Pair { - var position = startPosition - return readDomainName(position, 0) - } - - private fun UByteArray.readDomainName(position: Int, depth: Int = 0): Pair { - if (depth > 16) throw Exception("Exceeded maximum recursion depth in DNS packet. Possible circular reference.") - - val domainParts = mutableListOf() - var newPosition = position - - while (true) { - if (newPosition < 0) - println() - - val length = this[newPosition].toUByte() - if ((length and 0b11000000u).toUInt() == 0b11000000u) { - val offset = (((length and 0b00111111u).toUInt()) shl 8) or this[newPosition + 1].toUInt() - val (part, _) = this.readDomainName(offset.toInt(), depth + 1) - domainParts.add(part) - newPosition += 2 - break - } else if (length.toUInt() == 0u) { - newPosition++ - break - } else { - newPosition++ - val part = String(this.asByteArray(), newPosition, length.toInt(), Charsets.UTF_8) - domainParts.add(part) - newPosition += length.toInt() - } - } - - return domainParts.joinToString(".") to newPosition - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt b/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt deleted file mode 100644 index 2b972d87..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt +++ /dev/null @@ -1,495 +0,0 @@ -package com.futo.platformplayer.mdns - -import com.futo.platformplayer.logging.Logger -import kotlinx.coroutines.* -import java.net.* -import java.util.* -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock - -class MDNSListener { - companion object { - private val TAG = "MDNSListener" - const val MulticastPort = 5353 - val MulticastAddressIPv4: InetAddress = InetAddress.getByName("224.0.0.251") - val MulticastAddressIPv6: InetAddress = InetAddress.getByName("FF02::FB") - val MdnsEndpointIPv6: InetSocketAddress = InetSocketAddress(MulticastAddressIPv6, MulticastPort) - val MdnsEndpointIPv4: InetSocketAddress = InetSocketAddress(MulticastAddressIPv4, MulticastPort) - } - - private val _lockObject = ReentrantLock() - private var _receiver4: MulticastSocket? = null - private var _receiver6: MulticastSocket? = null - private val _senders = mutableListOf() - private val _nicMonitor = NICMonitor() - private val _serviceRecordAggregator = ServiceRecordAggregator() - private var _started = false - private var _threadReceiver4: Thread? = null - private var _threadReceiver6: Thread? = null - private var _scope: CoroutineScope? = null - - var onPacket: ((DnsPacket) -> Unit)? = null - var onServicesUpdated: ((List) -> Unit)? = null - - private val _recordLockObject = ReentrantLock() - private val _recordsA = mutableListOf>() - private val _recordsAAAA = mutableListOf>() - private val _recordsPTR = mutableListOf>() - private val _recordsTXT = mutableListOf>() - private val _recordsSRV = mutableListOf>() - private val _services = mutableListOf() - - init { - _nicMonitor.added = { onNicsAdded(it) } - _nicMonitor.removed = { onNicsRemoved(it) } - _serviceRecordAggregator.onServicesUpdated = { onServicesUpdated?.invoke(it) } - } - - fun start() { - if (_started) { - Logger.i(TAG, "Already started.") - return - } - _started = true - - _scope = CoroutineScope(Dispatchers.IO); - - Logger.i(TAG, "Starting") - _lockObject.withLock { - val receiver4 = MulticastSocket(null).apply { - reuseAddress = true - bind(InetSocketAddress(InetAddress.getByName("0.0.0.0"), MulticastPort)) - } - _receiver4 = receiver4 - - val receiver6 = MulticastSocket(null).apply { - reuseAddress = true - bind(InetSocketAddress(InetAddress.getByName("::"), MulticastPort)) - } - _receiver6 = receiver6 - - _nicMonitor.start() - _serviceRecordAggregator.start() - onNicsAdded(_nicMonitor.current) - - _threadReceiver4 = Thread { - receiveLoop(receiver4) - }.apply { start() } - - _threadReceiver6 = Thread { - receiveLoop(receiver6) - }.apply { start() } - } - } - - fun queryServices(names: Array) { - if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.") - - val writer = DnsWriter() - writer.writePacket( - DnsPacketHeader( - identifier = 0u, - queryResponse = QueryResponse.Query.value.toInt(), - opcode = DnsOpcode.StandardQuery.value.toInt(), - truncated = false, - nonAuthenticatedData = false, - recursionDesired = false, - answerAuthenticated = false, - authoritativeAnswer = false, - recursionAvailable = false, - responseCode = DnsResponseCode.NoError - ), - questionCount = names.size, - questionWriter = { w, i -> - w.write( - DnsQuestion( - name = names[i], - type = QuestionType.PTR.value.toInt(), - clazz = QuestionClass.IN.value.toInt(), - queryUnicast = false - ) - ) - } - ) - - send(writer.toByteArray()) - } - - private fun send(data: ByteArray) { - _lockObject.withLock { - for (sender in _senders) { - try { - val endPoint = if (sender.localAddress is Inet4Address) MdnsEndpointIPv4 else MdnsEndpointIPv6 - sender.send(DatagramPacket(data, data.size, endPoint)) - } catch (e: Exception) { - Logger.i(TAG, "Failed to send on ${sender.localSocketAddress}: ${e.message}.") - } - } - } - } - - fun queryAllQuestions(names: Array) { - if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.") - - val questions = names.flatMap { _serviceRecordAggregator.getAllQuestions(it) } - questions.groupBy { it.name }.forEach { (_, questionsForHost) -> - val writer = DnsWriter() - writer.writePacket( - DnsPacketHeader( - identifier = 0u, - queryResponse = QueryResponse.Query.value.toInt(), - opcode = DnsOpcode.StandardQuery.value.toInt(), - truncated = false, - nonAuthenticatedData = false, - recursionDesired = false, - answerAuthenticated = false, - authoritativeAnswer = false, - recursionAvailable = false, - responseCode = DnsResponseCode.NoError - ), - questionCount = questionsForHost.size, - questionWriter = { w, i -> w.write(questionsForHost[i]) } - ) - send(writer.toByteArray()) - } - } - - private fun onNicsAdded(nics: List) { - _lockObject.withLock { - if (!_started) return - - val addresses = nics.flatMap { nic -> - nic.interfaceAddresses.map { it.address } - .filter { it is Inet4Address || (it is Inet6Address && it.isLinkLocalAddress) } - } - - addresses.forEach { address -> - Logger.i(TAG, "New address discovered $address") - - try { - when (address) { - is Inet4Address -> { - _receiver4?.let { receiver4 -> - //receiver4.setOption(StandardSocketOptions.IP_MULTICAST_IF, NetworkInterface.getByInetAddress(address)) - receiver4.joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address)) - } - - val sender = MulticastSocket(null).apply { - reuseAddress = true - bind(InetSocketAddress(address, MulticastPort)) - joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address)) - } - _senders.add(sender) - } - - is Inet6Address -> { - _receiver6?.let { receiver6 -> - //receiver6.setOption(StandardSocketOptions.IP_MULTICAST_IF, NetworkInterface.getByInetAddress(address)) - receiver6.joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), NetworkInterface.getByInetAddress(address)) - } - - val sender = MulticastSocket(null).apply { - reuseAddress = true - bind(InetSocketAddress(address, MulticastPort)) - joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), NetworkInterface.getByInetAddress(address)) - } - _senders.add(sender) - } - - else -> throw UnsupportedOperationException("Address type ${address.javaClass.name} is not supported.") - } - } catch (e: Exception) { - Logger.i(TAG, "Exception occurred when processing added address $address: ${e.message}.") - // Close the socket if there was an error - (_senders.lastOrNull() as? MulticastSocket)?.close() - } - } - } - - if (nics.isNotEmpty()) { - try { - updateBroadcastRecords() - broadcastRecords() - } catch (e: Exception) { - Logger.i(TAG, "Exception occurred when broadcasting records: ${e.message}.") - } - } - } - - private fun onNicsRemoved(nics: List) { - _lockObject.withLock { - if (!_started) return - //TODO: Cleanup? - } - - if (nics.isNotEmpty()) { - try { - updateBroadcastRecords() - broadcastRecords() - } catch (e: Exception) { - Logger.e(TAG, "Exception occurred when broadcasting records", e) - } - } - } - - private fun receiveLoop(client: DatagramSocket) { - Logger.i(TAG, "Started receive loop") - - val buffer = ByteArray(8972) - val packet = DatagramPacket(buffer, buffer.size) - while (_started) { - try { - client.receive(packet) - handleResult(packet) - } catch (e: Exception) { - Logger.e(TAG, "An exception occurred while handling UDP result:", e) - } - } - - Logger.i(TAG, "Stopped receive loop") - } - - fun broadcastService( - deviceName: String, - serviceName: String, - port: UShort, - ttl: UInt = 120u, - weight: UShort = 0u, - priority: UShort = 0u, - texts: List? = null - ) { - _recordLockObject.withLock { - _services.add( - BroadcastService( - deviceName = deviceName, - port = port, - priority = priority, - serviceName = serviceName, - texts = texts, - ttl = ttl, - weight = weight - ) - ) - } - - updateBroadcastRecords() - broadcastRecords() - } - - private fun updateBroadcastRecords() { - _recordLockObject.withLock { - _recordsSRV.clear() - _recordsPTR.clear() - _recordsA.clear() - _recordsAAAA.clear() - _recordsTXT.clear() - - _services.forEach { service -> - val id = UUID.randomUUID().toString() - val deviceDomainName = "${service.deviceName}.${service.serviceName}" - val addressName = "$id.local" - - _recordsSRV.add( - DnsResourceRecord( - clazz = ResourceRecordClass.IN.value.toInt(), - type = ResourceRecordType.SRV.value.toInt(), - timeToLive = service.ttl, - name = deviceDomainName, - cacheFlush = false - ) to SRVRecord( - target = addressName, - port = service.port, - priority = service.priority, - weight = service.weight - ) - ) - - _recordsPTR.add( - DnsResourceRecord( - clazz = ResourceRecordClass.IN.value.toInt(), - type = ResourceRecordType.PTR.value.toInt(), - timeToLive = service.ttl, - name = service.serviceName, - cacheFlush = false - ) to PTRRecord( - domainName = deviceDomainName - ) - ) - - val addresses = _nicMonitor.current.flatMap { nic -> - nic.interfaceAddresses.map { it.address } - } - - addresses.forEach { address -> - when (address) { - is Inet4Address -> _recordsA.add( - DnsResourceRecord( - clazz = ResourceRecordClass.IN.value.toInt(), - type = ResourceRecordType.A.value.toInt(), - timeToLive = service.ttl, - name = addressName, - cacheFlush = false - ) to ARecord( - address = address - ) - ) - - is Inet6Address -> _recordsAAAA.add( - DnsResourceRecord( - clazz = ResourceRecordClass.IN.value.toInt(), - type = ResourceRecordType.AAAA.value.toInt(), - timeToLive = service.ttl, - name = addressName, - cacheFlush = false - ) to AAAARecord( - address = address - ) - ) - - else -> Logger.i(TAG, "Invalid address type: $address.") - } - } - - if (service.texts != null) { - _recordsTXT.add( - DnsResourceRecord( - clazz = ResourceRecordClass.IN.value.toInt(), - type = ResourceRecordType.TXT.value.toInt(), - timeToLive = service.ttl, - name = deviceDomainName, - cacheFlush = false - ) to TXTRecord( - texts = service.texts - ) - ) - } - } - } - } - - private fun broadcastRecords(questions: List? = null) { - val writer = DnsWriter() - _recordLockObject.withLock { - val recordsA: List> - val recordsAAAA: List> - val recordsPTR: List> - val recordsTXT: List> - val recordsSRV: List> - - if (questions != null) { - recordsA = _recordsA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } - recordsAAAA = _recordsAAAA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } - recordsPTR = _recordsPTR.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } - recordsSRV = _recordsSRV.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } - recordsTXT = _recordsTXT.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } - } else { - recordsA = _recordsA - recordsAAAA = _recordsAAAA - recordsPTR = _recordsPTR - recordsSRV = _recordsSRV - recordsTXT = _recordsTXT - } - - val answerCount = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size + recordsTXT.size - if (answerCount < 1) return - - val txtOffset = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size - val srvOffset = recordsA.size + recordsAAAA.size + recordsPTR.size - val ptrOffset = recordsA.size + recordsAAAA.size - val aaaaOffset = recordsA.size - - writer.writePacket( - DnsPacketHeader( - identifier = 0u, - queryResponse = QueryResponse.Response.value.toInt(), - opcode = DnsOpcode.StandardQuery.value.toInt(), - truncated = false, - nonAuthenticatedData = false, - recursionDesired = false, - answerAuthenticated = false, - authoritativeAnswer = true, - recursionAvailable = false, - responseCode = DnsResponseCode.NoError - ), - answerCount = answerCount, - answerWriter = { w, i -> - when { - i >= txtOffset -> { - val record = recordsTXT[i - txtOffset] - w.write(record.first) { it.write(record.second) } - } - - i >= srvOffset -> { - val record = recordsSRV[i - srvOffset] - w.write(record.first) { it.write(record.second) } - } - - i >= ptrOffset -> { - val record = recordsPTR[i - ptrOffset] - w.write(record.first) { it.write(record.second) } - } - - i >= aaaaOffset -> { - val record = recordsAAAA[i - aaaaOffset] - w.write(record.first) { it.write(record.second) } - } - - else -> { - val record = recordsA[i] - w.write(record.first) { it.write(record.second) } - } - } - } - ) - } - - send(writer.toByteArray()) - } - - private fun handleResult(result: DatagramPacket) { - try { - val packet = DnsPacket.parse(result.data) - if (packet.questions.isNotEmpty()) { - _scope?.launch(Dispatchers.IO) { - try { - broadcastRecords(packet.questions) - } catch (e: Throwable) { - Logger.i(TAG, "Broadcasting records failed", e) - } - } - - } - _serviceRecordAggregator.add(packet) - onPacket?.invoke(packet) - } catch (e: Exception) { - Logger.v(TAG, "Failed to handle packet: ${Base64.getEncoder().encodeToString(result.data.slice(IntRange(0, result.length - 1)).toByteArray())}", e) - } - } - - fun stop() { - _lockObject.withLock { - _started = false - - _scope?.cancel() - _scope = null - - _nicMonitor.stop() - _serviceRecordAggregator.stop() - - _receiver4?.close() - _receiver4 = null - - _receiver6?.close() - _receiver6 = null - - _senders.forEach { it.close() } - _senders.clear() - } - - _threadReceiver4?.join() - _threadReceiver4 = null - - _threadReceiver6?.join() - _threadReceiver6 = null - } -} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/NICMonitor.kt b/app/src/main/java/com/futo/platformplayer/mdns/NICMonitor.kt deleted file mode 100644 index 884e1514..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/NICMonitor.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.futo.platformplayer.mdns - -import kotlinx.coroutines.* -import java.net.NetworkInterface - -class NICMonitor { - private val lockObject = Any() - private val nics = mutableListOf() - private var cts: Job? = null - - val current: List - get() = synchronized(nics) { nics.toList() } - - var added: ((List) -> Unit)? = null - var removed: ((List) -> Unit)? = null - - fun start() { - synchronized(lockObject) { - if (cts != null) throw Exception("Already started.") - - cts = CoroutineScope(Dispatchers.Default).launch { - loopAsync() - } - } - - nics.clear() - nics.addAll(getCurrentInterfaces().toList()) - } - - fun stop() { - synchronized(lockObject) { - cts?.cancel() - cts = null - } - - synchronized(nics) { - nics.clear() - } - } - - private suspend fun loopAsync() { - while (cts?.isActive == true) { - try { - val currentNics = getCurrentInterfaces().toList() - removed?.invoke(nics.filter { k -> currentNics.none { n -> k.name == n.name } }) - added?.invoke(currentNics.filter { nic -> nics.none { k -> k.name == nic.name } }) - - synchronized(nics) { - nics.clear() - nics.addAll(currentNics) - } - } catch (ex: Exception) { - // Ignored - } - delay(5000) - } - } - - private fun getCurrentInterfaces(): List { - val nics = NetworkInterface.getNetworkInterfaces().toList() - .filter { it.isUp && !it.isLoopback } - - return if (nics.isNotEmpty()) nics else NetworkInterface.getNetworkInterfaces().toList() - .filter { it.isUp } - } -} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt b/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt deleted file mode 100644 index f4a3e5e9..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.futo.platformplayer.mdns - -import com.futo.platformplayer.logging.Logger -import java.lang.Thread.sleep - -class ServiceDiscoverer(names: Array, private val _onServicesUpdated: (List) -> Unit) { - private val _names: Array - private var _listener: MDNSListener? = null - private var _started = false - private var _thread: Thread? = null - - init { - if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.") - _names = names - } - - fun broadcastService( - deviceName: String, - serviceName: String, - port: UShort, - ttl: UInt = 120u, - weight: UShort = 0u, - priority: UShort = 0u, - texts: List? = null - ) { - _listener?.let { - it.broadcastService(deviceName, serviceName, port, ttl, weight, priority, texts) - } - } - - fun stop() { - _started = false - _listener?.stop() - _listener = null - _thread?.join() - _thread = null - } - - fun start() { - if (_started) { - Logger.i(TAG, "Already started.") - return - } - _started = true - - val listener = MDNSListener() - _listener = listener - listener.onServicesUpdated = { _onServicesUpdated?.invoke(it) } - listener.start() - - _thread = Thread { - try { - sleep(2000) - - while (_started) { - listener.queryServices(_names) - sleep(2000) - listener.queryAllQuestions(_names) - sleep(2000) - } - } catch (e: Throwable) { - Logger.i(TAG, "Exception in loop thread", e) - stop() - } - }.apply { start() } - } - - companion object { - private val TAG = "ServiceDiscoverer" - } -} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt b/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt deleted file mode 100644 index 5292d375..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt +++ /dev/null @@ -1,226 +0,0 @@ -package com.futo.platformplayer.mdns - -import com.futo.platformplayer.logging.Logger -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import java.net.InetAddress -import java.util.Date - -data class DnsService( - var name: String, - var target: String, - var port: UShort, - val addresses: MutableList = mutableListOf(), - val pointers: MutableList = mutableListOf(), - val texts: MutableList = mutableListOf() -) - -data class CachedDnsAddressRecord( - val expirationTime: Date, - val address: InetAddress -) - -data class CachedDnsTxtRecord( - val expirationTime: Date, - val texts: List -) - -data class CachedDnsPtrRecord( - val expirationTime: Date, - val target: String -) - -data class CachedDnsSrvRecord( - val expirationTime: Date, - val service: SRVRecord -) - -class ServiceRecordAggregator { - private val _lockObject = Any() - private val _cachedAddressRecords = mutableMapOf>() - private val _cachedTxtRecords = mutableMapOf() - private val _cachedPtrRecords = mutableMapOf>() - private val _cachedSrvRecords = mutableMapOf() - private val _currentServices = mutableListOf() - private var _cts: Job? = null - - var onServicesUpdated: ((List) -> Unit)? = null - - fun start() { - synchronized(_lockObject) { - if (_cts != null) throw Exception("Already started.") - - _cts = CoroutineScope(Dispatchers.Default).launch { - try { - while (isActive) { - val now = Date() - synchronized(_currentServices) { - _cachedAddressRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } } - _cachedTxtRecords.entries.removeIf { now.after(it.value.expirationTime) } - _cachedSrvRecords.entries.removeIf { now.after(it.value.expirationTime) } - _cachedPtrRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } } - - val newServices = getCurrentServices() - _currentServices.clear() - _currentServices.addAll(newServices) - } - - onServicesUpdated?.invoke(_currentServices.toList()) - delay(5000) - } - } catch (e: Throwable) { - Logger.e(TAG, "Unexpected failure in MDNS loop", e) - } - } - } - } - - fun stop() { - synchronized(_lockObject) { - _cts?.cancel() - _cts = null - } - } - - fun add(packet: DnsPacket) { - val currentServices: List - val dnsResourceRecords = packet.answers + packet.additionals + packet.authorities - val txtRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.TXT.value.toInt() }.map { it to it.getDataReader().readTXTRecord() } - val aRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.A.value.toInt() }.map { it to it.getDataReader().readARecord() } - val aaaaRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.AAAA.value.toInt() }.map { it to it.getDataReader().readAAAARecord() } - val srvRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.SRV.value.toInt() }.map { it to it.getDataReader().readSRVRecord() } - val ptrRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.PTR.value.toInt() }.map { it to it.getDataReader().readPTRRecord() } - - /*val builder = StringBuilder() - builder.appendLine("Received records:") - srvRecords.forEach { builder.appendLine("SRV ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: (Port: ${it.second.port}, Target: ${it.second.target}, Priority: ${it.second.priority}, Weight: ${it.second.weight})") } - ptrRecords.forEach { builder.appendLine("PTR ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.domainName}") } - txtRecords.forEach { builder.appendLine("TXT ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.texts.joinToString(", ")}") } - aRecords.forEach { builder.appendLine("A ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") } - aaaaRecords.forEach { builder.appendLine("AAAA ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") } - Logger.i(TAG, "$builder")*/ - - synchronized(this._currentServices) { - ptrRecords.forEach { record -> - val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() } - val newPtrRecord = CachedDnsPtrRecord(Date(System.currentTimeMillis() + record.first.timeToLive.toLong() * 1000L), record.second.domainName) - cachedPtrRecord.replaceOrAdd(newPtrRecord) { it.target == record.second.domainName } - } - - aRecords.forEach { aRecord -> - val cachedARecord = _cachedAddressRecords.getOrPut(aRecord.first.name) { mutableListOf() } - val newARecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aRecord.first.timeToLive.toLong() * 1000L), aRecord.second.address) - cachedARecord.replaceOrAdd(newARecord) { it.address == newARecord.address } - } - - aaaaRecords.forEach { aaaaRecord -> - val cachedAaaaRecord = _cachedAddressRecords.getOrPut(aaaaRecord.first.name) { mutableListOf() } - val newAaaaRecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aaaaRecord.first.timeToLive.toLong() * 1000L), aaaaRecord.second.address) - cachedAaaaRecord.replaceOrAdd(newAaaaRecord) { it.address == newAaaaRecord.address } - } - - txtRecords.forEach { txtRecord -> - _cachedTxtRecords[txtRecord.first.name] = CachedDnsTxtRecord(Date(System.currentTimeMillis() + txtRecord.first.timeToLive.toLong() * 1000L), txtRecord.second.texts) - } - - srvRecords.forEach { srvRecord -> - _cachedSrvRecords[srvRecord.first.name] = CachedDnsSrvRecord(Date(System.currentTimeMillis() + srvRecord.first.timeToLive.toLong() * 1000L), srvRecord.second) - } - - currentServices = getCurrentServices() - this._currentServices.clear() - this._currentServices.addAll(currentServices) - } - - onServicesUpdated?.invoke(currentServices) - } - - fun getAllQuestions(serviceName: String): List { - val questions = mutableListOf() - synchronized(_currentServices) { - val servicePtrRecords = _cachedPtrRecords[serviceName] ?: return emptyList() - - val ptrWithoutSrvRecord = servicePtrRecords.filterNot { _cachedSrvRecords.containsKey(it.target) }.map { it.target } - questions.addAll(ptrWithoutSrvRecord.flatMap { s -> - listOf( - DnsQuestion( - name = s, - type = QuestionType.SRV.value.toInt(), - clazz = QuestionClass.IN.value.toInt(), - queryUnicast = false - ) - ) - }) - - val incompleteCurrentServices = _currentServices.filter { it.addresses.isEmpty() && it.name.endsWith(serviceName) } - questions.addAll(incompleteCurrentServices.flatMap { s -> - listOf( - DnsQuestion( - name = s.name, - type = QuestionType.TXT.value.toInt(), - clazz = QuestionClass.IN.value.toInt(), - queryUnicast = false - ), - DnsQuestion( - name = s.target, - type = QuestionType.A.value.toInt(), - clazz = QuestionClass.IN.value.toInt(), - queryUnicast = false - ), - DnsQuestion( - name = s.target, - type = QuestionType.AAAA.value.toInt(), - clazz = QuestionClass.IN.value.toInt(), - queryUnicast = false - ) - ) - }) - } - return questions - } - - private fun getCurrentServices(): MutableList { - val currentServices = _cachedSrvRecords.map { (key, value) -> - DnsService( - name = key, - target = value.service.target, - port = value.service.port - ) - }.toMutableList() - - currentServices.forEach { service -> - _cachedAddressRecords[service.target]?.let { - service.addresses.addAll(it.map { record -> record.address }) - } - } - - currentServices.forEach { service -> - service.pointers.addAll(_cachedPtrRecords.filter { it.value.any { ptr -> ptr.target == service.name } }.map { it.key }) - } - - currentServices.forEach { service -> - _cachedTxtRecords[service.name]?.let { - service.texts.addAll(it.texts) - } - } - - return currentServices - } - - private inline fun MutableList.replaceOrAdd(newElement: T, predicate: (T) -> Boolean) { - val index = indexOfFirst(predicate) - if (index >= 0) { - this[index] = newElement - } else { - add(newElement) - } - } - - private companion object { - private const val TAG = "ServiceRecordAggregator" - } -} diff --git a/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt b/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt index 97fe6408..83e2b45f 100644 --- a/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt +++ b/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt @@ -29,7 +29,7 @@ data class ImageVariable( Glide.with(imageView) .load(bitmap) .into(imageView) - } else if(resId != null) { + } else if(resId != null && resId > 0) { Glide.with(imageView) .load(resId) .into(imageView) diff --git a/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt b/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt index 29229d6d..1b31a5fc 100644 --- a/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt +++ b/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt @@ -113,7 +113,7 @@ class LoginWebViewClient : WebViewClient { //val domainParts = domain!!.split("."); //val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString("."); val cookieDomain = domain!!.getSubdomainWildcardQuery(); - if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) }) + if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || domain.matchesDomain(it) }) _authConfig.cookiesToFind?.let { cookiesToFind -> val cookies = cookieString.split(";"); for(cookieStr in cookies) { diff --git a/app/src/main/java/com/futo/platformplayer/others/WebViewRequirementExtractor.kt b/app/src/main/java/com/futo/platformplayer/others/WebViewRequirementExtractor.kt index 6b22d1a7..f9edf9e7 100644 --- a/app/src/main/java/com/futo/platformplayer/others/WebViewRequirementExtractor.kt +++ b/app/src/main/java/com/futo/platformplayer/others/WebViewRequirementExtractor.kt @@ -67,7 +67,7 @@ class WebViewRequirementExtractor { if(cookieString != null) { //val domainParts = domain!!.split("."); val cookieDomain = domain!!.getSubdomainWildcardQuery()//"." + domainParts.drop(domainParts.size - 2).joinToString("."); - if(allowedUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) }) + if(allowedUrls.any { it == "everywhere" || domain.matchesDomain(it) }) cookiesToFind?.let { cookiesToFind -> val cookies = cookieString.split(";"); for(cookieStr in cookies) { diff --git a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt index 7d57a151..ba6cdaf4 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -1,5 +1,11 @@ package com.futo.platformplayer.parsers +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory +import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist +import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource @@ -7,12 +13,15 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudi import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource import com.futo.platformplayer.toYesNo import com.futo.platformplayer.yesNoToBoolean +import java.io.ByteArrayInputStream import java.net.URI import java.time.ZonedDateTime import java.time.format.DateTimeFormatter +import kotlin.text.ifEmpty class HLS { companion object { + @OptIn(UnstableApi::class) fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist { val baseUrl = URI(sourceUrl).resolve("./").toString() @@ -49,6 +58,31 @@ class HLS { return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments) } + fun mediaRenditionToVariant(rendition: MediaRendition): HLSVariantAudioUrlSource? { + if (rendition.uri == null) { + return null + } + + val suffix = listOf(rendition.language, rendition.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") + return when (rendition.type) { + "AUDIO" -> HLSVariantAudioUrlSource(rendition.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", rendition.language ?: "", null, false, false, rendition.uri) + else -> null + } + } + + fun variantReferenceToVariant(reference: VariantPlaylistReference): HLSVariantVideoUrlSource { + var width: Int? = null + var height: Int? = null + val resolutionTokens = reference.streamInfo.resolution?.split('x') + if (resolutionTokens?.isNotEmpty() == true) { + width = resolutionTokens[0].toIntOrNull() + height = resolutionTokens[1].toIntOrNull() + } + + val suffix = listOf(reference.streamInfo.video, reference.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") + return HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", reference.streamInfo.codecs ?: "", reference.streamInfo.bandwidth, 0, false, reference.url) + } + fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist { val lines = content.lines() val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull() @@ -61,7 +95,25 @@ class HLS { val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":") val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) } + val keyInfo = + lines.find { it.startsWith("#EXT-X-KEY:") }?.substringAfter(":")?.split(",") + + val key = keyInfo?.find { it.startsWith("URI=") }?.substringAfter("=")?.trim('"') + val iv = + keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x") + + val decryptionInfo: DecryptionInfo? = key?.let { k -> + DecryptionInfo(k, iv) + } + + val initSegment = + lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0) + ?.substringAfter("=")?.trim('"') val segments = mutableListOf() + if (initSegment != null) { + segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment))) + } + var currentSegment: MediaSegment? = null lines.forEach { line -> when { @@ -86,7 +138,7 @@ class HLS { } } - return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments) + return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments, decryptionInfo) } fun parseAndGetVideoSources(source: Any, content: String, url: String): List { @@ -270,7 +322,7 @@ class HLS { val name: String?, val isDefault: Boolean?, val isAutoSelect: Boolean?, - val isForced: Boolean? + val isForced: Boolean?, ) { fun toM3U8Line(): String = buildString { append("#EXT-X-MEDIA:") @@ -319,30 +371,13 @@ class HLS { fun getVideoSources(): List { return variantPlaylistsRefs.map { - var width: Int? = null - var height: Int? = null - val resolutionTokens = it.streamInfo.resolution?.split('x') - if (resolutionTokens?.isNotEmpty() == true) { - width = resolutionTokens[0].toIntOrNull() - height = resolutionTokens[1].toIntOrNull() - } - - val suffix = listOf(it.streamInfo.video, it.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") - HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url) + variantReferenceToVariant(it) } } fun getAudioSources(): List { return mediaRenditions.mapNotNull { - if (it.uri == null) { - return@mapNotNull null - } - - val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") - return@mapNotNull when (it.type) { - "AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, false, it.uri) - else -> null - } + return@mapNotNull mediaRenditionToVariant(it) } } @@ -368,6 +403,11 @@ class HLS { } } + data class DecryptionInfo( + val keyUrl: String, + val iv: String? + ) + data class VariantPlaylist( val version: Int?, val targetDuration: Int?, @@ -376,7 +416,8 @@ class HLS { val programDateTime: ZonedDateTime?, val playlistType: String?, val streamInfo: StreamInfo?, - val segments: List + val segments: List, + val decryptionInfo: DecryptionInfo? = null ) { fun buildM3U8(): String = buildString { append("#EXTM3U\n") diff --git a/app/src/main/java/com/futo/platformplayer/receivers/MediaControlReceiver.kt b/app/src/main/java/com/futo/platformplayer/receivers/MediaControlReceiver.kt index 5540406a..5251fa75 100644 --- a/app/src/main/java/com/futo/platformplayer/receivers/MediaControlReceiver.kt +++ b/app/src/main/java/com/futo/platformplayer/receivers/MediaControlReceiver.kt @@ -21,6 +21,7 @@ class MediaControlReceiver : BroadcastReceiver() { EVENT_NEXT -> onNextReceived.emit(); EVENT_PREV -> onPreviousReceived.emit(); EVENT_CLOSE -> onCloseReceived.emit(); + EVENT_BACKGROUND -> onBackgroundReceived.emit(); } } catch(ex: Throwable) { @@ -38,6 +39,7 @@ class MediaControlReceiver : BroadcastReceiver() { const val EVENT_NEXT = "Next"; const val EVENT_PREV = "Prev"; const val EVENT_CLOSE = "Close"; + const val EVENT_BACKGROUND = "Background"; val onPlayReceived = Event0(); val onPauseReceived = Event0(); @@ -48,6 +50,7 @@ class MediaControlReceiver : BroadcastReceiver() { val onLowerVolumeReceived = Event0(); val onCloseReceived = Event0() + val onBackgroundReceived = Event0() fun getPlayIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply { this.putExtra(EXTRA_MEDIA_ACTION, EVENT_PLAY); @@ -64,5 +67,8 @@ class MediaControlReceiver : BroadcastReceiver() { fun getCloseIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply { this.putExtra(EXTRA_MEDIA_ACTION, EVENT_CLOSE); },PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT); + fun getToBackgroundIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply { + this.putExtra(EXTRA_MEDIA_ACTION, EVENT_BACKGROUND); + },PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT); } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt b/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt index faee4e3b..9e9d112b 100644 --- a/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt +++ b/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.serializers +import com.futo.platformplayer.sToOffsetDateTimeUTC import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor @@ -37,7 +38,7 @@ class OffsetDateTimeSerializer : KSerializer { return OffsetDateTime.MAX; else if(epochSecond < -9999999999) return OffsetDateTime.MIN; - return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC); + return epochSecond.sToOffsetDateTimeUTC() } } class OffsetDateTimeStringSerializer : KSerializer { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 3adbe9c8..e2155e8b 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -29,6 +29,7 @@ import com.futo.platformplayer.activities.CaptchaActivity import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.SettingsActivity +import com.futo.platformplayer.activities.SettingsActivity.Companion.settingsActivityClosed import com.futo.platformplayer.api.media.platforms.js.DevJSClient import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.background.BackgroundWorker @@ -411,7 +412,15 @@ class StateApp { } if (Settings.instance.synchronization.enabled) { - StateSync.instance.start() + StateSync.instance.start(context) + } + + settingsActivityClosed.subscribe { + if (Settings.instance.synchronization.enabled) { + StateSync.instance.start(context) + } else { + StateSync.instance.stop() + } } Logger.onLogSubmitted.subscribe { @@ -509,22 +518,33 @@ class StateApp { //Migration Logger.i(TAG, "MainApp Started: Check [Migrations]"); - migrateStores(context, listOf( - StateSubscriptions.instance.toMigrateCheck(), - StatePlaylists.instance.toMigrateCheck() - ).flatten(), 0); + + scopeOrNull?.launch(Dispatchers.IO) { + try { + migrateStores(context, listOf( + StateSubscriptions.instance.toMigrateCheck(), + StatePlaylists.instance.toMigrateCheck() + ).flatten(), 0) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to migrate stores") + } + } if(Settings.instance.subscriptions.fetchOnAppBoot) { scope.launch(Dispatchers.IO) { Logger.i(TAG, "MainApp Started: Fetch [Subscriptions]"); val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount(); val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n"); - val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true }; - if (isRateLimitReached) { + val isBelowRateLimit = !subRequestCounts.any { clientCount -> + clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true + }; + if (isBelowRateLimit) { Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}"); delay(5000); - if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5) - StateSubscriptions.instance.updateSubscriptionFeed(scope, false); + scopeOrNull?.let { + if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5) + StateSubscriptions.instance.updateSubscriptionFeed(it, false); + } } else Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}"); @@ -675,15 +695,27 @@ class StateApp { } - private fun migrateStores(context: Context, managedStores: List>, index: Int) { + private suspend fun migrateStores(context: Context, managedStores: List>, index: Int) { if(managedStores.size <= index) return; val store = managedStores[index]; - if(store.hasMissingReconstructions()) - UIDialogs.showMigrateDialog(context, store) { - migrateStores(context, managedStores, index + 1); - }; - else + if(store.hasMissingReconstructions()) { + withContext(Dispatchers.Main) { + try { + UIDialogs.showMigrateDialog(context, store) { + scopeOrNull?.launch(Dispatchers.IO) { + try { + migrateStores(context, managedStores, index + 1); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to migrate store", e) + } + } + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to migrate stores", e) + } + } + } else migrateStores(context, managedStores, index + 1); } @@ -703,6 +735,7 @@ class StateApp { StatePlayer.instance.closeMediaSession(); StateCasting.instance.stop(); + StateSync.instance.stop(); StatePlayer.dispose(); Companion.dispose(); _fileLogConsumer?.close(); diff --git a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt index e82cd0da..42ff55f4 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt @@ -383,7 +383,7 @@ class StateDownloads { } private fun validateDownload(videoState: VideoDownload) { if(_downloading.hasItem { it.videoEither.url == videoState.videoEither.url }) - throw IllegalStateException("Video [${videoState.name}] is already queued for dowload"); + throw IllegalStateException("Video [${videoState.name}] is already queued for download"); val existing = getCachedVideo(videoState.id); if(existing != null) { diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt index 017e7f37..a6e07a7b 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -5,7 +5,6 @@ import androidx.collection.LruCache import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs -import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.PlatformMultiClientPool @@ -46,7 +45,6 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.ImageVariable import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringArrayStorage -import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.views.ToastView import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -56,7 +54,6 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import okhttp3.internal.concat import java.lang.Thread.sleep import java.time.OffsetDateTime import kotlin.streams.asSequence @@ -94,9 +91,11 @@ class StatePlatform { private val _trackerClientPool = PlatformMultiClientPool("Trackers", 1); //Used exclusively for playback trackers private val _liveEventClientPool = PlatformMultiClientPool("LiveEvents", 1); //Used exclusively for live events private val _privateClientPool = PlatformMultiClientPool("Private", 2, true); //Used primarily for calls if in incognito mode + private val _instantClientPool = PlatformMultiClientPool("Instant", 1, false, true); //Used for all instant calls private val _icons : HashMap = HashMap(); + private val _iconsByName : HashMap = HashMap(); val hasClients: Boolean get() = _availableClients.size > 0; @@ -113,14 +112,14 @@ class StatePlatform { Logger.i(StatePlatform::class.java.name, "Fetching video details [${url}]"); if(!StateApp.instance.privateMode) { - _enabledClients.find { it.isContentDetailsUrl(url) }?.let { + _enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let { _mainClientPool.getClientPooled(it).getContentDetails(url) } ?: throw NoPlatformClientException("No client enabled that supports this url ($url)"); } else { Logger.i(TAG, "Fetching details with private client"); - _enabledClients.find { it.isContentDetailsUrl(url) }?.let { + _enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let { _privateClientPool.getClientPooled(it).getContentDetails(url) } ?: throw NoPlatformClientException("No client enabled that supports this url ($url)"); @@ -192,6 +191,7 @@ class StatePlatform { _availableClients.clear(); _icons.clear(); + _iconsByName.clear() _icons[StateDeveloper.DEV_ID] = ImageVariable(null, R.drawable.ic_security_red); StatePlugins.instance.updateEmbeddedPlugins(context); @@ -200,6 +200,8 @@ class StatePlatform { for (plugin in StatePlugins.instance.getPlugins()) { _icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?: ImageVariable(plugin.config.absoluteIconUrl, null); + _iconsByName[plugin.config.name.lowercase()] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?: + ImageVariable(plugin.config.absoluteIconUrl, null); val client = JSClient(context, plugin); client.onCaptchaException.subscribe { c, ex -> @@ -299,6 +301,15 @@ class StatePlatform { return null; } + fun getPlatformIconByName(name: String?) : ImageVariable? { + if(name == null) + return null; + val nameLower = name.lowercase() + if(_iconsByName.containsKey(nameLower)) + return _iconsByName[nameLower]; + return null; + } + fun setPlatformOrder(platformOrder: List) { _platformOrderPersistent.values.clear(); _platformOrderPersistent.values.addAll(platformOrder); @@ -673,12 +684,33 @@ class StatePlatform { return pager; } + fun searchChannelsAsContent(query: String): IPager { + Logger.i(TAG, "Platform - searchChannels"); + val pagers = mutableMapOf, Float>(); + getSortedEnabledClient().parallelStream().forEach { + try { + if (it.capabilities.hasChannelSearch) + pagers.put(it.searchChannelsAsContent(query), 1f); + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed search channels", ex) + UIDialogs.toast("Failed search channels on [${it.name}]\n(${ex.message})"); + } + }; + if(pagers.isEmpty()) + return EmptyPager(); + + val pager = MultiDistributionContentPager(pagers); + pager.initialize(); + return pager; + } + //Video - fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { it.isContentDetailsUrl(url) }; + fun hasEnabledContentClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }; fun getContentClient(url: String) : IPlatformClient = getContentClientOrNull(url) ?: throw NoPlatformClientException("No client enabled that supports this content url (${url})"); - fun getContentClientOrNull(url: String) : IPlatformClient? = getEnabledClients().find { it.isContentDetailsUrl(url) }; + fun getContentClientOrNull(url: String) : IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }; fun getContentDetails(url: String, forceRefetch: Boolean = false): Deferred { Logger.i(TAG, "Platform - getContentDetails (${url})"); if(forceRefetch) @@ -719,14 +751,14 @@ class StatePlatform { return client.getContentRecommendations(url); } - fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { it.isChannelUrl(url) }; + fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isChannelUrl(url) }; fun getChannelClient(url : String, exclude: List? = null) : IPlatformClient = getChannelClientOrNull(url, exclude) ?: throw NoPlatformClientException("No client enabled that supports this channel url (${url})"); fun getChannelClientOrNull(url : String, exclude: List? = null) : IPlatformClient? = if(exclude == null) - getEnabledClients().find { it.isChannelUrl(url) } + getEnabledClients().find { _instantClientPool.getClientPooled(it).isChannelUrl(url) } else - getEnabledClients().find { !exclude.contains(it.id) && it.isChannelUrl(url) }; + getEnabledClients().find { !exclude.contains(it.id) && _instantClientPool.getClientPooled(it).isChannelUrl(url) }; fun getChannel(url: String, updateSubscriptions: Boolean = true): Deferred { Logger.i(TAG, "Platform - getChannel"); @@ -738,7 +770,7 @@ class StatePlatform { } } - fun getChannelContent(baseClient: IPlatformClient, channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0): IPager { + fun getChannelContent(baseClient: IPlatformClient, channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, type: String? = null): IPager { val clientCapabilities = baseClient.getChannelCapabilities(); val client = if(usePooledClients > 1) _channelClientPool.getClientPooled(baseClient, usePooledClients); @@ -747,66 +779,75 @@ class StatePlatform { var lastStream: OffsetDateTime? = null; val pagerResult: IPager; - if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) && - ( clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) || - clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS) || - clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE) || - clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS) - )) { - val toQuery = mutableListOf(); - if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) - toQuery.add(ResultCapabilities.TYPE_VIDEOS); - if(clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS)) - toQuery.add(ResultCapabilities.TYPE_STREAMS); - if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE)) - toQuery.add(ResultCapabilities.TYPE_LIVE); - if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS)) - toQuery.add(ResultCapabilities.TYPE_POSTS); + if (type == null) { + if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) && + ( clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) || + clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS) || + clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE) || + clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS) + )) { + val toQuery = mutableListOf(); + if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) + toQuery.add(ResultCapabilities.TYPE_VIDEOS); + if(clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS)) + toQuery.add(ResultCapabilities.TYPE_STREAMS); + if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE)) + toQuery.add(ResultCapabilities.TYPE_LIVE); + if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS)) + toQuery.add(ResultCapabilities.TYPE_POSTS); - if(isSubscriptionOptimized) { - val sub = StateSubscriptions.instance.getSubscription(channelUrl); - if(sub != null) { - if(!sub.shouldFetchStreams()) { - Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 7 days, skipping live streams [${sub.lastLiveStream.getNowDiffDays()} days ago]"); - toQuery.remove(ResultCapabilities.TYPE_LIVE); - } - if(!sub.shouldFetchLiveStreams()) { - Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]"); - toQuery.remove(ResultCapabilities.TYPE_STREAMS); - } - if(!sub.shouldFetchPosts()) { - Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 5 days, skipping posts [${sub.lastPost.getNowDiffDays()} days ago]"); - toQuery.remove(ResultCapabilities.TYPE_POSTS); - } - } - } - - //Merged pager - val pagers = toQuery - .parallelStream() - .map { - val results = client.getChannelContents(channelUrl, it, ResultCapabilities.ORDER_CHONOLOGICAL) ; - - when(it) { - ResultCapabilities.TYPE_STREAMS -> { - val streamResults = results.getResults(); - if(streamResults.size == 0) - lastStream = OffsetDateTime.MIN; - else - lastStream = results.getResults().firstOrNull()?.datetime; + if(isSubscriptionOptimized) { + val sub = StateSubscriptions.instance.getSubscription(channelUrl); + if(sub != null) { + if(!sub.shouldFetchStreams()) { + Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 7 days, skipping live streams [${sub.lastLiveStream.getNowDiffDays()} days ago]"); + toQuery.remove(ResultCapabilities.TYPE_LIVE); + } + if(!sub.shouldFetchLiveStreams()) { + Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]"); + toQuery.remove(ResultCapabilities.TYPE_STREAMS); + } + if(!sub.shouldFetchPosts()) { + Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 5 days, skipping posts [${sub.lastPost.getNowDiffDays()} days ago]"); + toQuery.remove(ResultCapabilities.TYPE_POSTS); } } - return@map results; } - .asSequence() - .toList(); - val pager = MultiChronoContentPager(pagers.toTypedArray()); - pager.initialize(); - pagerResult = pager; + //Merged pager + val pagers = toQuery + .parallelStream() + .map { + val results = client.getChannelContents(channelUrl, it, ResultCapabilities.ORDER_CHONOLOGICAL) ; + + when(it) { + ResultCapabilities.TYPE_STREAMS -> { + val streamResults = results.getResults(); + if(streamResults.size == 0) + lastStream = OffsetDateTime.MIN; + else + lastStream = results.getResults().firstOrNull()?.datetime; + } + } + return@map results; + } + .asSequence() + .toList(); + + val pager = MultiChronoContentPager(pagers.toTypedArray()); + pager.initialize(); + pagerResult = pager; + } + else { + pagerResult = client.getChannelContents(channelUrl, ResultCapabilities.TYPE_MIXED, ResultCapabilities.ORDER_CHONOLOGICAL); + } + } else { + pagerResult = if (type == ResultCapabilities.TYPE_SHORTS && clientCapabilities.hasType(ResultCapabilities.TYPE_SHORTS)) { + client.getChannelContents(channelUrl, ResultCapabilities.TYPE_SHORTS, ResultCapabilities.ORDER_CHONOLOGICAL); + } else { + EmptyPager() + } } - else - pagerResult = client.getChannelContents(channelUrl, ResultCapabilities.TYPE_MIXED, ResultCapabilities.ORDER_CHONOLOGICAL); //Subscription optimization val sub = StateSubscriptions.instance.getSubscription(channelUrl); @@ -858,10 +899,10 @@ class StatePlatform { return pagerResult; } - fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List? = null): IPager { + fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List? = null, type: String? = null): IPager { Logger.i(TAG, "Platform - getChannelVideos"); val baseClient = getChannelClient(channelUrl, ignorePlugins); - return getChannelContent(baseClient, channelUrl, isSubscriptionOptimized, usePooledClients); + return getChannelContent(baseClient, channelUrl, isSubscriptionOptimized, usePooledClients, type); } fun getChannelContent(channelUrl: String, type: String?, ordering: String = ResultCapabilities.ORDER_CHONOLOGICAL): IPager { val client = getChannelClient(channelUrl); @@ -913,9 +954,9 @@ class StatePlatform { return urls; } - fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { it.isPlaylistUrl(url) }; - fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { it.isPlaylistUrl(url) } - fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { it.isPlaylistUrl(url) } + fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) }; + fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) } + fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) } ?: throw NoPlatformClientException("No client enabled that supports this playlist url (${url})"); fun getPlaylist(url: String): IPlatformPlaylistDetails { return getPlaylistClient(url).getPlaylist(url); diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt index b12889e8..eae8adf5 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt @@ -599,7 +599,7 @@ class StatePlayer { } if(_queuePosition < _queue.size) { - return _queue[_queuePosition]; + return getCurrentQueueItem(); } } return null; diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt index e2054c90..c20375f2 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt @@ -19,6 +19,7 @@ import com.futo.platformplayer.exceptions.ReconstructionException import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.sToOffsetDateTimeUTC import com.futo.platformplayer.smartMerge import com.futo.platformplayer.states.StateSubscriptionGroups.Companion import com.futo.platformplayer.stores.FragmentedStorage @@ -85,7 +86,7 @@ class StatePlaylists { if(value.isEmpty()) return OffsetDateTime.MIN; val tryParse = value.toLongOrNull() ?: 0; - return OffsetDateTime.ofInstant(Instant.ofEpochSecond(tryParse), ZoneOffset.UTC); + return tryParse.sToOffsetDateTimeUTC(); } private fun setWatchLaterReorderTime() { val now = OffsetDateTime.now(ZoneOffset.UTC); @@ -400,12 +401,15 @@ class StatePlaylists { companion object { val TAG = "StatePlaylists"; private var _instance : StatePlaylists? = null; + private var _lockObject = Object() val instance : StatePlaylists - get(){ - if(_instance == null) - _instance = StatePlaylists(); - return _instance!!; - }; + get() { + synchronized(_lockObject) { + if (_instance == null) + _instance = StatePlaylists(); + return _instance!!; + } + } fun finish() { _instance?.let { diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt b/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt index 9d6f7437..86ae541a 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt @@ -236,7 +236,7 @@ class StatePolycentric { return Pair(didUpdate, listOf(url)); } - fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager? { + fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1, type: String? = null): IPager? { ensureEnabled() //TODO: Currently abusing subscription concurrency for parallelism @@ -248,7 +248,11 @@ class StatePolycentric { return@mapNotNull Pair(client, scope.async(Dispatchers.IO) { try { - return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency); + if (type == null) { + return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency); + } else { + return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency, type = type); + } } catch (ex: Throwable) { Logger.e(TAG, "getChannelContent", ex); return@async null; diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 96c25f9d..fd08165c 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -1,232 +1,187 @@ package com.futo.platformplayer.states -import android.os.Build -import android.util.Log -import com.futo.platformplayer.LittleEndianDataInputStream -import com.futo.platformplayer.LittleEndianDataOutputStream +import android.content.Context +import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.SyncShowPairingCodeActivity +import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.encryption.GEncryptionProvider -import com.futo.platformplayer.getConnectedSocket import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.mdns.DnsService -import com.futo.platformplayer.mdns.ServiceDiscoverer -import com.futo.platformplayer.noise.protocol.DHState -import com.futo.platformplayer.noise.protocol.Noise +import com.futo.platformplayer.models.HistoryVideo +import com.futo.platformplayer.models.Subscription +import com.futo.platformplayer.sToOffsetDateTimeUTC +import com.futo.platformplayer.smartMerge import com.futo.platformplayer.stores.FragmentedStorage -import com.futo.platformplayer.stores.StringStringMapStorage import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.stores.StringStorage +import com.futo.platformplayer.stores.StringStringMapStorage import com.futo.platformplayer.stores.StringTMapStorage import com.futo.platformplayer.sync.SyncSessionData import com.futo.platformplayer.sync.internal.GJSyncOpcodes -import com.futo.platformplayer.sync.internal.SyncDeviceInfo +import com.futo.platformplayer.sync.internal.ISyncDatabaseProvider +import com.futo.platformplayer.sync.internal.Opcode import com.futo.platformplayer.sync.internal.SyncKeyPair +import com.futo.platformplayer.sync.internal.SyncService +import com.futo.platformplayer.sync.internal.SyncServiceSettings import com.futo.platformplayer.sync.internal.SyncSession -import com.futo.platformplayer.sync.internal.SyncSocketSession -import com.futo.polycentric.core.base64ToByteArray -import com.futo.polycentric.core.toBase64 +import com.futo.platformplayer.sync.models.SendToDevicePackage +import com.futo.platformplayer.sync.models.SyncPlaylistsPackage +import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage +import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage +import com.futo.platformplayer.sync.models.SyncWatchLaterPackage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import java.net.InetAddress -import java.net.InetSocketAddress -import java.net.ServerSocket -import java.net.Socket -import java.util.Base64 -import java.util.Locale +import java.io.ByteArrayInputStream +import java.nio.ByteBuffer +import java.time.OffsetDateTime import kotlin.system.measureTimeMillis class StateSync { - private val _authorizedDevices = FragmentedStorage.get("authorized_devices") - private val _nameStorage = FragmentedStorage.get("sync_remembered_name_storage") - private val _syncKeyPair = FragmentedStorage.get("sync_key_pair") - private val _lastAddressStorage = FragmentedStorage.get("sync_last_address_storage") private val _syncSessionData = FragmentedStorage.get>("syncSessionData") - private var _serverSocket: ServerSocket? = null - private var _thread: Thread? = null - private var _connectThread: Thread? = null - private var _started = false - private val _sessions: MutableMap = mutableMapOf() - private val _lastConnectTimesMdns: MutableMap = mutableMapOf() - private val _lastConnectTimesIp: MutableMap = mutableMapOf() - //TODO: Should sync mdns and casting mdns be merged? - //TODO: Decrease interval that devices are updated - //TODO: Send less data - val _serviceDiscoverer = ServiceDiscoverer(arrayOf("_gsync._tcp.local")) { handleServiceUpdated(it) } - - var keyPair: DHState? = null - var publicKey: String? = null + var syncService: SyncService? = null + private set val deviceRemoved: Event1 = Event1() val deviceUpdatedOrAdded: Event2 = Event2() - fun hasAuthorizedDevice(): Boolean { - synchronized(_sessions) { - return _sessions.any{ it.value.connected && it.value.isAuthorized }; - } - } - - fun start() { - if (_started) { + fun start(context: Context) { + if (syncService != null) { Logger.i(TAG, "Already started.") return } - _started = true - if (Settings.instance.synchronization.broadcast || Settings.instance.synchronization.connectDiscovered) { - _serviceDiscoverer.start() - } - - try { - val syncKeyPair = Json.decodeFromString(GEncryptionProvider.instance.decrypt(_syncKeyPair.value)) - val p = Noise.createDH(dh) - p.setPublicKey(syncKeyPair.publicKey.base64ToByteArray(), 0) - p.setPrivateKey(syncKeyPair.privateKey.base64ToByteArray(), 0) - keyPair = p - } catch (e: Throwable) { - //Sync key pair non-existing, invalid or lost - val p = Noise.createDH(dh) - p.generateKeyPair() - - val publicKey = ByteArray(p.publicKeyLength) - p.getPublicKey(publicKey, 0) - val privateKey = ByteArray(p.privateKeyLength) - p.getPrivateKey(privateKey, 0) - - val syncKeyPair = SyncKeyPair(1, publicKey.toBase64(), privateKey.toBase64()) - _syncKeyPair.setAndSave(GEncryptionProvider.instance.encrypt(Json.encodeToString(syncKeyPair))) - - Logger.e(TAG, "Failed to load existing key pair", e) - keyPair = p - } - - publicKey = keyPair?.let { - val pkey = ByteArray(it.publicKeyLength) - it.getPublicKey(pkey, 0) - return@let pkey.toBase64() - } - - if (Settings.instance.synchronization.broadcast) { - publicKey?.let { _serviceDiscoverer.broadcastService(getDeviceName(), "_gsync._tcp.local", PORT.toUShort(), texts = arrayListOf("pk=${it.replace('+', '-').replace('/', '_').replace("=", "")}")) } - } - - Logger.i(TAG, "Sync key pair initialized (public key = ${publicKey})") - - _thread = Thread { - try { - val serverSocket = ServerSocket(PORT) - _serverSocket = serverSocket - - Log.i(TAG, "Running on port ${PORT} (TCP)") - - while (_started) { - val socket = serverSocket.accept() - val session = createSocketSession(socket, true) { session, socketSession -> - - } - - session.startAsResponder() + syncService = SyncService( + SERVICE_NAME, + RELAY_SERVER, + RELAY_PUBLIC_KEY, + APP_ID, + StoreBasedSyncDatabaseProvider(), + SyncServiceSettings( + mdnsBroadcast = Settings.instance.synchronization.broadcast, + mdnsConnectDiscovered = Settings.instance.synchronization.connectDiscovered, + bindListener = Settings.instance.synchronization.localConnections, + connectLastKnown = Settings.instance.synchronization.connectLast, + relayHandshakeAllowed = Settings.instance.synchronization.connectThroughRelay, + relayPairAllowed = Settings.instance.synchronization.pairThroughRelay, + relayEnabled = Settings.instance.synchronization.discoverThroughRelay, + relayConnectDirect = Settings.instance.synchronization.connectLocalDirectThroughRelay, + relayConnectRelayed = Settings.instance.synchronization.connectThroughRelay + ) + ).apply { + onAuthorized = { sess, isNewlyAuthorized, isNewSession -> + if (isNewSession) { + deviceUpdatedOrAdded.emit(sess.remotePublicKey, sess) + StateApp.instance.scope.launch(Dispatchers.IO) { checkForSync(sess) } } - } catch (e: Throwable) { - Logger.e(TAG, "Failed to bind server socket to port ${PORT}", e) - UIDialogs.toast("Failed to start sync, port in use") } - }.apply { start() } - if (Settings.instance.synchronization.connectLast) { - _connectThread = Thread { - Log.i(TAG, "Running auto reconnector") - - while (_started) { - val authorizedDevices = synchronized(_authorizedDevices) { - return@synchronized _authorizedDevices.values - } - - val lastKnownMap = synchronized(_lastAddressStorage) { - return@synchronized _lastAddressStorage.map.toMap() - } - - val addressesToConnect = authorizedDevices.mapNotNull { - val connected = isConnected(it) - if (connected) { - return@mapNotNull null - } - - val lastKnownAddress = lastKnownMap[it] ?: return@mapNotNull null - return@mapNotNull Pair(it, lastKnownAddress) - } - - for (connectPair in addressesToConnect) { - try { - val syncDeviceInfo = SyncDeviceInfo(connectPair.first, arrayOf(connectPair.second), PORT) - - val now = System.currentTimeMillis() - val lastConnectTime = synchronized(_lastConnectTimesIp) { - _lastConnectTimesIp[connectPair.first] ?: 0 - } - - //Connect once every 30 seconds, max - if (now - lastConnectTime > 30000) { - synchronized(_lastConnectTimesIp) { - _lastConnectTimesIp[connectPair.first] = now - } - - Logger.i(TAG, "Attempting to connect to authorized device by last known IP '${connectPair.first}' with pkey=${connectPair.first}") - connect(syncDeviceInfo) - } - } catch (e: Throwable) { - Logger.i(TAG, "Failed to connect to " + connectPair.first, e) - } - } - Thread.sleep(5000) + onUnauthorized = { sess -> + StateApp.instance.scope.launch(Dispatchers.Main) { + UIDialogs.showConfirmationDialog( + context, + "Device Unauthorized: ${sess.displayName}", + action = { + Logger.i(TAG, "${sess.remotePublicKey} unauthorized received") + removeAuthorizedDevice(sess.remotePublicKey) + deviceRemoved.emit(sess.remotePublicKey) + }, + cancelAction = {} + ) } - }.apply { start() } + } + + onConnectedChanged = { sess, _ -> deviceUpdatedOrAdded.emit(sess.remotePublicKey, sess) } + onClose = { sess -> deviceRemoved.emit(sess.remotePublicKey) } + onData = { it, opcode, subOpcode, data -> + val dataCopy = ByteArray(data.remaining()) + data.get(dataCopy) + + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + handleData(it, opcode, subOpcode, ByteBuffer.wrap(dataCopy)) + } catch (e: Throwable) { + Logger.e(TAG, "Exception occurred while handling data, closing session", e) + it.close() + } + } + } + authorizePrompt = { remotePublicKey, callback -> + val scope = StateApp.instance.scopeOrNull + val activity = SyncShowPairingCodeActivity.activity + + if (scope != null && activity != null) { + scope.launch(Dispatchers.Main) { + UIDialogs.showConfirmationDialog(activity, "Allow connection from $remotePublicKey?", + action = { + scope.launch(Dispatchers.IO) { + try { + callback(true) + Logger.i(TAG, "Connection authorized for $remotePublicKey by confirmation") + + activity.finish() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to send authorize", e) + } + } + }, + cancelAction = { + scope.launch(Dispatchers.IO) { + try { + callback(false) + Logger.i(TAG, "$remotePublicKey unauthorized received") + } catch (e: Throwable) { + Logger.w(TAG, "Failed to send unauthorize", e) + } + } + } + ) + } + } else { + callback(false) + Logger.i(TAG, "Connection unauthorized for $remotePublicKey because not authorized and not on pairing activity to ask") + } + } } + + syncService?.start(context) } - private fun getDeviceName(): String { - val manufacturer = Build.MANUFACTURER.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - val model = Build.MODEL - - return if (model.startsWith(manufacturer, ignoreCase = true)) { - model.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + fun confirmStarted(context: Context, onStarted: () -> Unit, onNotStarted: () -> Unit) { + if (syncService == null) { + UIDialogs.showConfirmationDialog(context, "Sync has not been enabled yet, would you like to enable sync?", { + Settings.instance.synchronization.enabled = true + start(context) + Settings.instance.save() + onStarted.invoke() + }, { + onNotStarted.invoke() + }) } else { - "$manufacturer $model".replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + onStarted.invoke() } } - fun isConnected(publicKey: String): Boolean { - return synchronized(_sessions) { - _sessions[publicKey]?.connected ?: false - } + fun hasAuthorizedDevice(): Boolean { + return (syncService?.getAuthorizedDeviceCount() ?: 0) > 0 } fun isAuthorized(publicKey: String): Boolean { - return synchronized(_authorizedDevices) { - _authorizedDevices.values.contains(publicKey) - } + return syncService?.isAuthorized(publicKey) ?: false } fun getSession(publicKey: String): SyncSession? { - return synchronized(_sessions) { - _sessions[publicKey] - } - } - fun getSessions(): List { - return synchronized(_sessions) { - return _sessions.values.toList() - }; + return syncService?.getSession(publicKey) } + fun getAuthorizedSessions(): List { - return synchronized(_sessions) { - return _sessions.values.filter { it.isAuthorized }.toList() - }; + return syncService?.getSessions()?.filter { it.isAuthorized }?.toList() ?: listOf() } fun getSyncSessionData(key: String): SyncSessionData { @@ -239,197 +194,267 @@ class StateSync { _syncSessionData.setAndSave(data.publicKey, data); } - private fun handleServiceUpdated(services: List) { - if (!Settings.instance.synchronization.connectDiscovered) { - return + private fun handleSyncSubscriptionPackage(origin: SyncSession, pack: SyncSubscriptionsPackage) { + val added = mutableListOf() + for(sub in pack.subscriptions) { + if(!StateSubscriptions.instance.isSubscribed(sub.channel)) { + val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url); + if(sub.creationTime > removalTime) { + val newSub = StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime); + added.add(newSub); + } + } } + if(added.size > 3) + UIDialogs.appToast("${added.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}"); + else if(added.size > 0) + UIDialogs.appToast("Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" + + added.map { it.channel.name }.joinToString("\n")); - for (s in services) { - //TODO: Addresses IPv4 only? - val addresses = s.addresses.mapNotNull { it.hostAddress }.toTypedArray() - val port = s.port.toInt() - if (s.name.endsWith("._gsync._tcp.local")) { - val name = s.name.substring(0, s.name.length - "._gsync._tcp.local".length) - val urlSafePkey = s.texts.firstOrNull { it.startsWith("pk=") }?.substring("pk=".length) ?: continue - val pkey = Base64.getEncoder().encodeToString(Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/'))) - val syncDeviceInfo = SyncDeviceInfo(pkey, addresses, port) - val authorized = isAuthorized(pkey) + if(pack.subscriptionRemovals.isNotEmpty()) { + for (subRemoved in pack.subscriptionRemovals) { + val removed = StateSubscriptions.instance.applySubscriptionRemovals(pack.subscriptionRemovals); + if(removed.size > 3) { + UIDialogs.appToast("Removed ${removed.size} Subscriptions from ${origin.remotePublicKey.substring(0, 8.coerceAtMost(origin.remotePublicKey.length))}"); + } else if(removed.isNotEmpty()) { + UIDialogs.appToast("Subscriptions removed from ${origin.remotePublicKey.substring(0, 8.coerceAtMost(origin.remotePublicKey.length))}:\n" + removed.map { it.channel.name }.joinToString("\n")); + } + } + } + } - if (authorized && !isConnected(pkey)) { - val now = System.currentTimeMillis() - val lastConnectTime = synchronized(_lastConnectTimesMdns) { - _lastConnectTimesMdns[pkey] ?: 0 + private fun handleData(session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) { + val remotePublicKey = session.remotePublicKey + when (subOpcode) { + GJSyncOpcodes.sendToDevices -> { + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + val context = StateApp.instance.contextOrNull; + if (context != null && context is MainActivity) { + val dataBody = ByteArray(data.remaining()); + val remainder = data.remaining(); + data.get(dataBody, 0, remainder); + val json = String(dataBody, Charsets.UTF_8); + val obj = Json.decodeFromString(json); + UIDialogs.appToast("Received url from device [${session.remotePublicKey}]:\n{${obj.url}"); + context.handleUrl(obj.url, obj.position); } + }; + } - //Connect once every 30 seconds, max - if (now - lastConnectTime > 30000) { - synchronized(_lastConnectTimesMdns) { - _lastConnectTimesMdns[pkey] = now + GJSyncOpcodes.syncStateExchange -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val syncSessionData = Serializer.json.decodeFromString(json); + + Logger.i(TAG, "Received SyncSessionData from $remotePublicKey"); + + val subscriptionPackageString = StateSubscriptions.instance.getSyncSubscriptionsPackageString() + Logger.i(TAG, "syncStateExchange syncSubscriptions b (size: ${subscriptionPackageString.length})") + session.sendData(GJSyncOpcodes.syncSubscriptions, subscriptionPackageString); + Logger.i(TAG, "syncStateExchange syncSubscriptions (size: ${subscriptionPackageString.length})") + + val subscriptionGroupPackageString = StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString() + Logger.i(TAG, "syncStateExchange syncSubscriptionGroups b (size: ${subscriptionGroupPackageString.length})") + session.sendData(GJSyncOpcodes.syncSubscriptionGroups, subscriptionGroupPackageString); + Logger.i(TAG, "syncStateExchange syncSubscriptionGroups (size: ${subscriptionGroupPackageString.length})") + + val syncPlaylistPackageString = StatePlaylists.instance.getSyncPlaylistsPackageString() + Logger.i(TAG, "syncStateExchange syncPlaylists b (size: ${syncPlaylistPackageString.length})") + session.sendData(GJSyncOpcodes.syncPlaylists, syncPlaylistPackageString) + Logger.i(TAG, "syncStateExchange syncPlaylists (size: ${syncPlaylistPackageString.length})") + + val watchLaterPackageString = Json.encodeToString(StatePlaylists.instance.getWatchLaterSyncPacket(false)) + Logger.i(TAG, "syncStateExchange syncWatchLater b (size: ${watchLaterPackageString.length})") + session.sendData(GJSyncOpcodes.syncWatchLater, watchLaterPackageString); + Logger.i(TAG, "syncStateExchange syncWatchLater (size: ${watchLaterPackageString.length})") + + val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory); + + Logger.i(TAG, "syncStateExchange syncHistory b (size: ${recentHistory.size})") + if(recentHistory.isNotEmpty()) + session.sendJsonData(GJSyncOpcodes.syncHistory, recentHistory); + + Logger.i(TAG, "syncStateExchange syncHistory (size: ${recentHistory.size})") + } + + GJSyncOpcodes.syncExport -> { + val dataBody = ByteArray(data.remaining()); + val bytesStr = ByteArrayInputStream(data.array(), data.position(), data.remaining()); + bytesStr.use { bytesStrBytes -> + val exportStruct = StateBackup.ExportStructure.fromZipBytes(bytesStrBytes); + for (store in exportStruct.stores) { + if (store.key.equals("subscriptions", true)) { + val subStore = + StateSubscriptions.instance.getUnderlyingSubscriptionsStore(); + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + val pack = SyncSubscriptionsPackage( + store.value.map { + subStore.fromReconstruction(it, exportStruct.cache) + }, + StateSubscriptions.instance.getSubscriptionRemovals() + ); + handleSyncSubscriptionPackage(session, pack); + } } + } + } + } - Logger.i(TAG, "Found device authorized device '${name}' with pkey=$pkey, attempting to connect") + GJSyncOpcodes.syncSubscriptions -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val subPackage = Serializer.json.decodeFromString(json); + handleSyncSubscriptionPackage(session, subPackage); - try { - connect(syncDeviceInfo) - } catch (e: Throwable) { - Logger.i(TAG, "Failed to connect to $pkey", e) - } + if(subPackage.subscriptions.size > 0) { + val newestSub = subPackage.subscriptions.maxOf { it.creationTime }; + + val sesData = getSyncSessionData(remotePublicKey); + if (newestSub > sesData.lastSubscription) { + sesData.lastSubscription = newestSub; + saveSyncSessionData(sesData); + } + } + } + + GJSyncOpcodes.syncSubscriptionGroups -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val pack = Serializer.json.decodeFromString(json); + + var lastSubgroupChange = OffsetDateTime.MIN; + for(group in pack.groups){ + if(group.lastChange > lastSubgroupChange) + lastSubgroupChange = group.lastChange; + + val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id); + + if(existing == null) + StateSubscriptionGroups.instance.updateSubscriptionGroup(group, false, true); + else if(existing.lastChange < group.lastChange) { + existing.name = group.name; + existing.urls = group.urls; + existing.image = group.image; + existing.priority = group.priority; + existing.lastChange = group.lastChange; + StateSubscriptionGroups.instance.updateSubscriptionGroup(existing, false, true); + } + } + for(removal in pack.groupRemovals) { + val creation = StateSubscriptionGroups.instance.getSubscriptionGroup(removal.key); + val removalTime = removal.value.sToOffsetDateTimeUTC(); + if(creation != null && creation.creationTime < removalTime) + StateSubscriptionGroups.instance.deleteSubscriptionGroup(removal.key, false); + } + } + + GJSyncOpcodes.syncPlaylists -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val pack = Serializer.json.decodeFromString(json); + + for(playlist in pack.playlists) { + val existing = StatePlaylists.instance.getPlaylist(playlist.id); + + if(existing == null) + StatePlaylists.instance.createOrUpdatePlaylist(playlist, false); + else if(existing.dateUpdate < playlist.dateUpdate) { + existing.dateUpdate = playlist.dateUpdate; + existing.name = playlist.name; + existing.videos = playlist.videos; + existing.dateCreation = playlist.dateCreation; + existing.datePlayed = playlist.datePlayed; + StatePlaylists.instance.createOrUpdatePlaylist(existing, false); + } + } + for(removal in pack.playlistRemovals) { + val creation = StatePlaylists.instance.getPlaylist(removal.key); + val removalTime = removal.value.sToOffsetDateTimeUTC(); + if(creation != null && creation.dateCreation < removalTime) + StatePlaylists.instance.removePlaylist(creation, false); + + } + } + + GJSyncOpcodes.syncWatchLater -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val pack = Serializer.json.decodeFromString(json); + + Logger.i(TAG, "SyncWatchLater received ${pack.videos.size} (${pack.videoAdds?.size}, ${pack.videoRemovals?.size})"); + + val allExisting = StatePlaylists.instance.getWatchLater(); + for(video in pack.videos) { + val existing = allExisting.firstOrNull { it.url == video.url }; + val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) (pack.videoAdds[video.url] ?: 0).sToOffsetDateTimeUTC() else OffsetDateTime.MIN; + val removalTime = StatePlaylists.instance.getWatchLaterRemovalTime(video.url) ?: OffsetDateTime.MIN; + if(existing == null && time > removalTime) { + StatePlaylists.instance.addToWatchLater(video, false); + if(time > OffsetDateTime.MIN) + StatePlaylists.instance.setWatchLaterAddTime(video.url, time); + } + } + for(removal in pack.videoRemovals) { + val watchLater = allExisting.firstOrNull { it.url == removal.key } ?: continue; + val creation = StatePlaylists.instance.getWatchLaterRemovalTime(watchLater.url) ?: OffsetDateTime.MIN; + val removalTime = removal.value.sToOffsetDateTimeUTC() + if(creation < removalTime) + StatePlaylists.instance.removeFromWatchLater(watchLater, false, removalTime); + } + + val packReorderTime = pack.reorderTime.sToOffsetDateTimeUTC() + val localReorderTime = StatePlaylists.instance.getWatchLaterLastReorderTime(); + if(localReorderTime < packReorderTime && pack.ordering != null) { + StatePlaylists.instance.updateWatchLaterOrdering(smartMerge(pack.ordering!!, StatePlaylists.instance.getWatchLaterOrdering()), true); + } + } + + GJSyncOpcodes.syncHistory -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val history = Serializer.json.decodeFromString>(json); + Logger.i(TAG, "SyncHistory received ${history.size} videos from ${remotePublicKey}"); + if (history.size == 1) { + Logger.i(TAG, "SyncHistory received update video '${history[0].video.name}' (url: ${history[0].video.url}) at timestamp ${history[0].position}"); + } + + var lastHistory = OffsetDateTime.MIN; + for(video in history){ + val hist = StateHistory.instance.getHistoryByVideo(video.video, true, video.date); + if(hist != null) + StateHistory.instance.updateHistoryPosition(video.video, hist, true, video.position, video.date) + if(lastHistory < video.date) + lastHistory = video.date; + } + + if(lastHistory != OffsetDateTime.MIN && history.size > 1) { + val sesData = getSyncSessionData(remotePublicKey); + if (lastHistory > sesData.lastHistory) { + sesData.lastHistory = lastHistory; + saveSyncSessionData(sesData); } } } } } - private fun unauthorize(remotePublicKey: String) { - Logger.i(TAG, "${remotePublicKey} unauthorized received") - _authorizedDevices.remove(remotePublicKey) - _authorizedDevices.save() - deviceRemoved.emit(remotePublicKey) - } - - private fun createSocketSession(socket: Socket, isResponder: Boolean, onAuthorized: (session: SyncSession, socketSession: SyncSocketSession) -> Unit): SyncSocketSession { - var session: SyncSession? = null - return SyncSocketSession((socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, keyPair!!, LittleEndianDataInputStream(socket.getInputStream()), LittleEndianDataOutputStream(socket.getOutputStream()), - onClose = { s -> - session?.removeSocketSession(s) - }, - onHandshakeComplete = { s -> - val remotePublicKey = s.remotePublicKey - if (remotePublicKey == null) { - s.stop() - return@SyncSocketSession - } - - Logger.i(TAG, "Handshake complete with (LocalPublicKey = ${s.localPublicKey}, RemotePublicKey = ${s.remotePublicKey})") - - synchronized(_sessions) { - session = _sessions[s.remotePublicKey] - if (session == null) { - val remoteDeviceName = synchronized(_nameStorage) { - _nameStorage.get(remotePublicKey) - } - - session = SyncSession(remotePublicKey, onAuthorized = { it, isNewlyAuthorized, isNewSession -> - if (!isNewSession) { - return@SyncSession - } - - it.remoteDeviceName?.let { remoteDeviceName -> - synchronized(_nameStorage) { - _nameStorage.setAndSave(remotePublicKey, remoteDeviceName) - } - } - - Logger.i(TAG, "${s.remotePublicKey} authorized (name: ${it.displayName})") - synchronized(_lastAddressStorage) { - _lastAddressStorage.setAndSave(remotePublicKey, s.remoteAddress) - } - - onAuthorized(it, s) - _authorizedDevices.addDistinct(remotePublicKey) - _authorizedDevices.save() - deviceUpdatedOrAdded.emit(it.remotePublicKey, session!!) - - checkForSync(it); - }, onUnauthorized = { - unauthorize(remotePublicKey) - - synchronized(_sessions) { - session?.close() - _sessions.remove(remotePublicKey) - } - }, onConnectedChanged = { it, connected -> - Logger.i(TAG, "${s.remotePublicKey} connected: " + connected) - deviceUpdatedOrAdded.emit(it.remotePublicKey, session!!) - }, onClose = { - Logger.i(TAG, "${s.remotePublicKey} closed") - - synchronized(_sessions) - { - _sessions.remove(it.remotePublicKey) - } - - deviceRemoved.emit(it.remotePublicKey) - - }, remoteDeviceName) - _sessions[remotePublicKey] = session!! - } - session!!.addSocketSession(s) - } - - if (isResponder) { - val isAuthorized = synchronized(_authorizedDevices) { - _authorizedDevices.values.contains(remotePublicKey) - } - - if (!isAuthorized) { - val scope = StateApp.instance.scopeOrNull - val activity = SyncShowPairingCodeActivity.activity - - if (scope != null && activity != null) { - scope.launch(Dispatchers.Main) { - UIDialogs.showConfirmationDialog(activity, "Allow connection from ${remotePublicKey}?", action = { - scope.launch(Dispatchers.IO) { - try { - session!!.authorize(s) - Logger.i(TAG, "Connection authorized for $remotePublicKey by confirmation") - } catch (e: Throwable) { - Logger.e(TAG, "Failed to send authorize", e) - } - } - }, cancelAction = { - scope.launch(Dispatchers.IO) { - try { - unauthorize(remotePublicKey) - } catch (e: Throwable) { - Logger.w(TAG, "Failed to send unauthorize", e) - } - - synchronized(_sessions) { - session?.close() - _sessions.remove(remotePublicKey) - } - } - }) - } - } else { - val publicKey = session!!.remotePublicKey - session!!.unauthorize(s) - session!!.close() - - synchronized(_sessions) { - _sessions.remove(publicKey) - } - - Logger.i(TAG, "Connection unauthorized for ${remotePublicKey} because not authorized and not on pairing activity to ask") - } - } else { - //Responder does not need to check because already approved - session!!.authorize(s) - Logger.i(TAG, "Connection authorized for ${remotePublicKey} because already authorized") - } - } else { - //Initiator does not need to check because the manual action of scanning the QR counts as approval - session!!.authorize(s) - Logger.i(TAG, "Connection authorized for ${remotePublicKey} because initiator") - } - }, - onData = { s, opcode, subOpcode, data -> - session?.handlePacket(s, opcode, subOpcode, data) - }) - } - inline fun broadcastJsonData(subOpcode: UByte, data: T) { - broadcast(SyncSocketSession.Opcode.DATA.value, subOpcode, Json.encodeToString(data)); + broadcast(Opcode.DATA.value, subOpcode, Json.encodeToString(data)); } fun broadcastData(subOpcode: UByte, data: String) { - broadcast(SyncSocketSession.Opcode.DATA.value, subOpcode, data.toByteArray(Charsets.UTF_8)); + broadcast(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8))); } fun broadcast(opcode: UByte, subOpcode: UByte, data: String) { - broadcast(opcode, subOpcode, data.toByteArray(Charsets.UTF_8)); + broadcast(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8))); } - fun broadcast(opcode: UByte, subOpcode: UByte, data: ByteArray) { + fun broadcast(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { for(session in getAuthorizedSessions()) { try { session.send(opcode, subOpcode, data); @@ -450,40 +475,17 @@ class StateSync { } fun stop() { - _started = false - _serviceDiscoverer.stop() - - _serverSocket?.close() - _serverSocket = null - - //_thread?.join() - _thread = null - _connectThread = null + syncService?.stop() + syncService = null } - fun connect(deviceInfo: SyncDeviceInfo, onStatusUpdate: ((session: SyncSocketSession?, complete: Boolean, message: String) -> Unit)? = null): SyncSocketSession { - onStatusUpdate?.invoke(null, false, "Connecting...") - val socket = getConnectedSocket(deviceInfo.addresses.map { InetAddress.getByName(it) }, deviceInfo.port) ?: throw Exception("Failed to connect") - onStatusUpdate?.invoke(null, false, "Handshaking...") - - val session = createSocketSession(socket, false) { _, ss -> - onStatusUpdate?.invoke(ss, true, "Handshake complete") - } - - session.startAsInitiator(deviceInfo.publicKey) - return session - } fun getAll(): List { - synchronized(_authorizedDevices) { - return _authorizedDevices.values.toList() - } + return syncService?.getAllAuthorizedDevices()?.toList() ?: listOf() } fun getCachedName(publicKey: String): String? { - return synchronized(_nameStorage) { - _nameStorage.get(publicKey) - } + return syncService?.getCachedName(publicKey) } suspend fun delete(publicKey: String) { @@ -500,14 +502,8 @@ class StateSync { session.close() } - synchronized(_sessions) { - _sessions.remove(publicKey) - } - - synchronized(_authorizedDevices) { - _authorizedDevices.remove(publicKey) - } - _authorizedDevices.save() + syncService?.removeSession(publicKey) + syncService?.removeAuthorizedDevice(publicKey) withContext(Dispatchers.Main) { deviceRemoved.emit(publicKey) @@ -516,16 +512,47 @@ class StateSync { Logger.w(TAG, "Failed to delete", e) } } + } + class StoreBasedSyncDatabaseProvider : ISyncDatabaseProvider { + private val _authorizedDevices = FragmentedStorage.get("authorized_devices") + private val _nameStorage = FragmentedStorage.get("sync_remembered_name_storage") + private val _syncKeyPair = FragmentedStorage.get("sync_key_pair") + private val _lastAddressStorage = FragmentedStorage.get("sync_last_address_storage") + + override fun isAuthorized(publicKey: String): Boolean = synchronized(_authorizedDevices) { _authorizedDevices.values.contains(publicKey) } + override fun addAuthorizedDevice(publicKey: String) = synchronized(_authorizedDevices) { + _authorizedDevices.addDistinct(publicKey) + _authorizedDevices.save() + } + override fun removeAuthorizedDevice(publicKey: String) = synchronized(_authorizedDevices) { + _authorizedDevices.remove(publicKey) + _authorizedDevices.save() + } + override fun getAllAuthorizedDevices(): Array = synchronized(_authorizedDevices) { _authorizedDevices.values.toTypedArray() } + override fun getAuthorizedDeviceCount(): Int = synchronized(_authorizedDevices) { _authorizedDevices.values.size } + override fun getSyncKeyPair(): SyncKeyPair? = try { + Json.decodeFromString(GEncryptionProvider.instance.decrypt(_syncKeyPair.value)) + } catch (e: Throwable) { null } + override fun setSyncKeyPair(value: SyncKeyPair) { _syncKeyPair.setAndSave(GEncryptionProvider.instance.encrypt(Json.encodeToString(value))) } + override fun getLastAddress(publicKey: String): String? = synchronized(_lastAddressStorage) { _lastAddressStorage.map[publicKey] } + override fun setLastAddress(publicKey: String, address: String) = synchronized(_lastAddressStorage) { + _lastAddressStorage.map[publicKey] = address + _lastAddressStorage.save() + } + override fun getDeviceName(publicKey: String): String? = synchronized(_nameStorage) { _nameStorage.map[publicKey] } + override fun setDeviceName(publicKey: String, name: String) = synchronized(_nameStorage) { + _nameStorage.map[publicKey] = name + _nameStorage.save() + } } companion object { - val dh = "25519" - val pattern = "IK" - val cipher = "ChaChaPoly" - val hash = "BLAKE2b" - var protocolName = "Noise_${pattern}_${dh}_${cipher}_${hash}" val version = 1 + val RELAY_SERVER = "relay.grayjay.app" + val SERVICE_NAME = "_gsync._tcp" + val RELAY_PUBLIC_KEY = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw=" + val APP_ID = 0x534A5247u //GRayJaySync (GRJS) private const val TAG = "StateSync" const val PORT = 12315 diff --git a/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt index 3ecff95a..bddbe862 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt @@ -19,6 +19,11 @@ class CastingDeviceInfoStorage : FragmentedStorageFileJson() { return deviceInfos.toList(); } + @Synchronized + fun getDeviceNames() : List { + return deviceInfos.map { it.name }.toList(); + } + @Synchronized fun addDevice(castingDeviceInfo: CastingDeviceInfo): CastingDeviceInfo { val foundDeviceInfo = deviceInfos.firstOrNull { d -> d.name == castingDeviceInfo.name } diff --git a/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt index 7a5b7cf2..c5ff802a 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt @@ -1,6 +1,7 @@ package com.futo.platformplayer.stores import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.stores.v2.JsonStoreSerializer import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.StoreSerializer diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt new file mode 100644 index 00000000..e17b6309 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt @@ -0,0 +1,404 @@ +package com.futo.platformplayer.sync.internal + +import com.futo.platformplayer.ensureNotMainThread +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.noise.protocol.CipherStatePair +import com.futo.platformplayer.noise.protocol.DHState +import com.futo.platformplayer.noise.protocol.HandshakeState +import com.futo.platformplayer.states.StateSync +import com.futo.polycentric.core.base64ToByteArray +import com.futo.polycentric.core.toBase64 +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.Base64 +import java.util.zip.GZIPOutputStream + +interface IChannel : AutoCloseable { + val remotePublicKey: String? + val remoteVersion: Int? + var authorizable: IAuthorizable? + var syncSession: SyncSession? + fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) + fun send(opcode: UByte, subOpcode: UByte = 0u, data: ByteBuffer? = null, contentEncoding: ContentEncoding? = null) + fun setCloseHandler(onClose: ((IChannel) -> Unit)?) + val linkType: LinkType +} + +class ChannelSocket(private val session: SyncSocketSession) : IChannel { + override val remotePublicKey: String? get() = session.remotePublicKey + override val remoteVersion: Int? get() = session.remoteVersion + private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null + private var onClose: ((IChannel) -> Unit)? = null + override val linkType: LinkType get() = LinkType.Direct + + override var authorizable: IAuthorizable? + get() = session.authorizable + set(value) { session.authorizable = value } + override var syncSession: SyncSession? = null + + override fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) { + this.onData = onData + } + + override fun setCloseHandler(onClose: ((IChannel) -> Unit)?) { + this.onClose = onClose + } + + override fun close() { + session.stop() + onClose?.invoke(this) + } + + fun invokeDataHandler(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { + onData?.invoke(session, this, opcode, subOpcode, data) + } + + override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, contentEncoding: ContentEncoding?) { + ensureNotMainThread() + if (data != null) { + session.send(opcode, subOpcode, data, contentEncoding) + } else { + session.send(opcode, subOpcode) + } + } +} + +class ChannelRelayed( + private val session: SyncSocketSession, + private val localKeyPair: DHState, + private val publicKey: String, + private val initiator: Boolean +) : IChannel { + private val sendLock = Object() + private val decryptLock = Object() + private var handshakeState: HandshakeState? = if (initiator) { + HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR).apply { + localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair) + remotePublicKey.setPublicKey(Base64.getDecoder().decode(publicKey), 0) + } + } else { + HandshakeState(SyncService.protocolName, HandshakeState.RESPONDER).apply { + localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair) + } + } + private var transport: CipherStatePair? = null + override var authorizable: IAuthorizable? = null + val isAuthorized: Boolean get() = authorizable?.isAuthorized ?: false + var connectionId: Long = 0L + override var remotePublicKey: String? = publicKey.base64ToByteArray().toBase64() + private set + override var remoteVersion: Int? = null + private set + override var syncSession: SyncSession? = null + override val linkType: LinkType get() = LinkType.Relayed + + private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null + private var onClose: ((IChannel) -> Unit)? = null + private var disposed = false + private var _lastPongTime: Long = 0 + private val _pingInterval: Long = 5000 // 5 seconds in milliseconds + private val _disconnectTimeout: Long = 30000 // 30 seconds in milliseconds + + init { + handshakeState?.start() + } + + private fun startPingLoop() { + if (remoteVersion!! < 5) { + return + } + + _lastPongTime = System.currentTimeMillis() + + Thread { + try { + while (!disposed) { + Thread.sleep(_pingInterval) + if (System.currentTimeMillis() - _lastPongTime > _disconnectTimeout) { + Logger.e("ChannelRelayed", "Channel timed out waiting for PONG; closing.") + close() + break + } + send(Opcode.PING.value, 0u) + } + } catch (e: Exception) { + Logger.e("ChannelRelayed", "Ping loop failed", e) + close() + } + }.start() + } + + override fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) { + this.onData = onData + } + + override fun setCloseHandler(onClose: ((IChannel) -> Unit)?) { + this.onClose = onClose + } + + override fun close() { + disposed = true + + if (connectionId != 0L) { + Thread { + try { + session.sendRelayError(connectionId, SyncErrorCode.ConnectionClosed) + } catch (e: Exception) { + Logger.e("ChannelRelayed", "Exception while sending relay error", e) + } + }.start() + } + + transport?.sender?.destroy() + transport?.receiver?.destroy() + transport = null + handshakeState?.destroy() + handshakeState = null + + onClose?.invoke(this) + } + + private fun throwIfDisposed() { + if (disposed) throw IllegalStateException("ChannelRelayed is disposed") + } + + fun invokeDataHandler(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { + if (opcode == Opcode.PONG.value) { + _lastPongTime = System.currentTimeMillis() + return + } + onData?.invoke(session, this, opcode, subOpcode, data) + } + + private fun completeHandshake(remoteVersion: Int, transport: CipherStatePair) { + throwIfDisposed() + + this.remoteVersion = remoteVersion + val remoteKeyBytes = ByteArray(handshakeState!!.remotePublicKey.publicKeyLength) + handshakeState!!.remotePublicKey.getPublicKey(remoteKeyBytes, 0) + this.remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes) + handshakeState?.destroy() + handshakeState = null + this.transport = transport + Logger.i("ChannelRelayed", "Completed handshake for connectionId $connectionId") + startPingLoop() + } + + private fun sendPacket(packet: ByteArray) { + throwIfDisposed() + ensureNotMainThread() + + synchronized(sendLock) { + val encryptedPayload = ByteArray(packet.size + 16) + val encryptedLength = transport!!.sender.encryptWithAd(null, packet, 0, encryptedPayload, 0, packet.size) + + val relayedPacket = ByteArray(8 + encryptedLength) + ByteBuffer.wrap(relayedPacket).order(ByteOrder.LITTLE_ENDIAN).apply { + putLong(connectionId) + put(encryptedPayload, 0, encryptedLength) + } + + session.send(Opcode.RELAY.value, RelayOpcode.DATA.value, ByteBuffer.wrap(relayedPacket).order(ByteOrder.LITTLE_ENDIAN)) + } + } + + fun sendError(errorCode: SyncErrorCode) { + throwIfDisposed() + ensureNotMainThread() + + synchronized(sendLock) { + val packet = ByteArray(4) + ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).putInt(errorCode.value) + + val encryptedPayload = ByteArray(4 + 16) + val encryptedLength = transport!!.sender.encryptWithAd(null, packet, 0, encryptedPayload, 0, packet.size) + + val relayedPacket = ByteArray(8 + encryptedLength) + ByteBuffer.wrap(relayedPacket).order(ByteOrder.LITTLE_ENDIAN).apply { + putLong(connectionId) + put(encryptedPayload, 0, encryptedLength) + } + + session.send(Opcode.RELAY.value, RelayOpcode.ERROR.value, ByteBuffer.wrap(relayedPacket)) + } + } + + override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, ce: ContentEncoding?) { + throwIfDisposed() + ensureNotMainThread() + + var contentEncoding: ContentEncoding? = ce + var processedData = data + if (data != null && contentEncoding == ContentEncoding.Gzip) { + val isGzipSupported = opcode == Opcode.DATA.value + if (isGzipSupported) { + val compressedStream = ByteArrayOutputStream() + GZIPOutputStream(compressedStream).use { gzipStream -> + gzipStream.write(data.array(), data.position(), data.remaining()) + gzipStream.finish() + } + processedData = ByteBuffer.wrap(compressedStream.toByteArray()) + } else { + Logger.w(TAG, "Gzip requested but not supported on this (opcode = ${opcode}, subOpcode = ${subOpcode}), falling back.") + contentEncoding = ContentEncoding.Raw + } + } + + val ENCRYPTION_OVERHEAD = 16 + val CONNECTION_ID_SIZE = 8 + val HEADER_SIZE = 7 + val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - HEADER_SIZE - CONNECTION_ID_SIZE - ENCRYPTION_OVERHEAD - 16 + + Logger.v(TAG, "Send (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.size: ${processedData?.remaining()})") + + if (processedData != null && processedData.remaining() > MAX_DATA_PER_PACKET) { + val streamId = session.generateStreamId() + var sendOffset = 0 + + while (sendOffset < processedData.remaining()) { + val bytesRemaining = processedData.remaining() - sendOffset + val bytesToSend = minOf(MAX_DATA_PER_PACKET - 8 - HEADER_SIZE + 4, bytesRemaining) + + val streamData: ByteArray + val streamOpcode: StreamOpcode + if (sendOffset == 0) { + streamOpcode = StreamOpcode.START + streamData = ByteArray(4 + HEADER_SIZE + bytesToSend) + ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply { + putInt(streamId) + putInt(processedData.remaining()) + put(opcode.toByte()) + put(subOpcode.toByte()) + put(contentEncoding?.value?.toByte() ?: 0.toByte()) + put(processedData.array(), processedData.position() + sendOffset, bytesToSend) + } + } else { + streamData = ByteArray(4 + 4 + bytesToSend) + ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply { + putInt(streamId) + putInt(sendOffset) + put(processedData.array(), processedData.position() + sendOffset, bytesToSend) + } + streamOpcode = if (bytesToSend < bytesRemaining) StreamOpcode.DATA else StreamOpcode.END + } + + val fullPacket = ByteArray(HEADER_SIZE + streamData.size) + ByteBuffer.wrap(fullPacket).order(ByteOrder.LITTLE_ENDIAN).apply { + putInt(streamData.size + HEADER_SIZE - 4) + put(Opcode.STREAM.value.toByte()) + put(streamOpcode.value.toByte()) + put(ContentEncoding.Raw.value.toByte()) + put(streamData) + } + + sendPacket(fullPacket) + sendOffset += bytesToSend + } + } else { + val packet = ByteArray(HEADER_SIZE + (processedData?.remaining() ?: 0)) + ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply { + putInt((processedData?.remaining() ?: 0) + HEADER_SIZE - 4) + put(opcode.toByte()) + put(subOpcode.toByte()) + put(contentEncoding?.value?.toByte() ?: ContentEncoding.Raw.value.toByte()) + if (processedData != null && processedData.remaining() > 0) put(processedData.array(), processedData.position(), processedData.remaining()) + } + sendPacket(packet) + } + } + + fun sendRequestTransport(requestId: Int, publicKey: String, appId: UInt, pairingCode: String? = null) { + throwIfDisposed() + ensureNotMainThread() + + synchronized(sendLock) { + val channelMessage = ByteArray(1024) + val channelBytesWritten = handshakeState!!.writeMessage(channelMessage, 0, null, 0, 0) + + val publicKeyBytes = Base64.getDecoder().decode(publicKey) + if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes") + + val (pairingMessageLength, pairingMessage) = if (pairingCode != null) { + val pairingHandshake = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR).apply { + remotePublicKey.setPublicKey(publicKeyBytes, 0) + start() + } + val pairingCodeBytes = pairingCode.toByteArray(Charsets.UTF_8) + if (pairingCodeBytes.size > 32) throw IllegalArgumentException("Pairing code must not exceed 32 bytes") + val pairingMessageBuffer = ByteArray(1024) + val bytesWritten = pairingHandshake.writeMessage(pairingMessageBuffer, 0, pairingCodeBytes, 0, pairingCodeBytes.size) + bytesWritten to pairingMessageBuffer.copyOf(bytesWritten) + } else { + 0 to ByteArray(0) + } + + val packetSize = 4 + 4 + 32 + 4 + pairingMessageLength + 4 + channelBytesWritten + val packet = ByteArray(packetSize) + ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply { + putInt(requestId) + putInt(appId.toInt()) + put(publicKeyBytes) + putInt(pairingMessageLength) + if (pairingMessageLength > 0) put(pairingMessage) + putInt(channelBytesWritten) + put(channelMessage, 0, channelBytesWritten) + } + + session.send(Opcode.REQUEST.value, RequestOpcode.TRANSPORT.value, ByteBuffer.wrap(packet)) + } + } + + fun sendResponseTransport(remoteVersion: Int, requestId: Int, handshakeMessage: ByteArray) { + throwIfDisposed() + ensureNotMainThread() + + synchronized(sendLock) { + val message = ByteArray(1024) + val plaintext = ByteArray(1024) + handshakeState!!.readMessage(handshakeMessage, 0, handshakeMessage.size, plaintext, 0) + val bytesWritten = handshakeState!!.writeMessage(message, 0, null, 0, 0) + val transport = handshakeState!!.split() + + val responsePacket = ByteArray(20 + bytesWritten) + ByteBuffer.wrap(responsePacket).order(ByteOrder.LITTLE_ENDIAN).apply { + putInt(0) // Status code + putLong(connectionId) + putInt(requestId) + putInt(bytesWritten) + put(message, 0, bytesWritten) + } + + completeHandshake(remoteVersion, transport) + session.send(Opcode.RESPONSE.value, ResponseOpcode.TRANSPORT.value, ByteBuffer.wrap(responsePacket)) + } + } + + fun decrypt(encryptedPayload: ByteBuffer): ByteBuffer { + throwIfDisposed() + + synchronized(decryptLock) { + val encryptedBytes = ByteArray(encryptedPayload.remaining()).also { encryptedPayload.get(it) } + val decryptedPayload = ByteArray(encryptedBytes.size - 16) + val plen = transport!!.receiver.decryptWithAd(null, encryptedBytes, 0, decryptedPayload, 0, encryptedBytes.size) + if (plen != decryptedPayload.size) throw IllegalStateException("Expected decrypted payload length to be $plen") + return ByteBuffer.wrap(decryptedPayload).order(ByteOrder.LITTLE_ENDIAN) + } + } + + fun handleTransportRelayed(remoteVersion: Int, connectionId: Long, handshakeMessage: ByteArray) { + throwIfDisposed() + + synchronized(decryptLock) { + this.connectionId = connectionId + val plaintext = ByteArray(1024) + val plen = handshakeState!!.readMessage(handshakeMessage, 0, handshakeMessage.size, plaintext, 0) + val transport = handshakeState!!.split() + completeHandshake(remoteVersion, transport) + } + } + + companion object { + private val TAG = "Channel" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/ContentEncoding.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/ContentEncoding.kt new file mode 100644 index 00000000..ab9ed6a9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/ContentEncoding.kt @@ -0,0 +1,6 @@ +package com.futo.platformplayer.sync.internal + +enum class ContentEncoding(val value: UByte) { + Raw(0u), + Gzip(1u) +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/LinkType.java b/app/src/main/java/com/futo/platformplayer/sync/internal/LinkType.java index a3fe431c..256ed422 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/LinkType.java +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/LinkType.java @@ -2,6 +2,6 @@ package com.futo.platformplayer.sync.internal; public enum LinkType { None, - Local, - Proxied + Direct, + Relayed } diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/Opcode.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/Opcode.kt new file mode 100644 index 00000000..8a12b579 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/Opcode.kt @@ -0,0 +1,60 @@ +package com.futo.platformplayer.sync.internal + +enum class Opcode(val value: UByte) { + PING(0u), + PONG(1u), + NOTIFY(2u), + STREAM(3u), + DATA(4u), + REQUEST(5u), + RESPONSE(6u), + RELAY(7u) +} + +enum class NotifyOpcode(val value: UByte) { + AUTHORIZED(0u), + UNAUTHORIZED(1u), + CONNECTION_INFO(2u) +} + +enum class StreamOpcode(val value: UByte) { + START(0u), + DATA(1u), + END(2u) +} + +enum class RequestOpcode(val value: UByte) { + CONNECTION_INFO(0u), + TRANSPORT(1u), + TRANSPORT_RELAYED(2u), + PUBLISH_RECORD(3u), + DELETE_RECORD(4u), + LIST_RECORD_KEYS(5u), + GET_RECORD(6u), + BULK_PUBLISH_RECORD(7u), + BULK_GET_RECORD(8u), + BULK_CONNECTION_INFO(9u), + BULK_DELETE_RECORD(10u) +} + +enum class ResponseOpcode(val value: UByte) { + CONNECTION_INFO(0u), + TRANSPORT(1u), + TRANSPORT_RELAYED(2u), //TODO: Server errors also included in this one, disentangle? + PUBLISH_RECORD(3u), + DELETE_RECORD(4u), + LIST_RECORD_KEYS(5u), + GET_RECORD(6u), + BULK_PUBLISH_RECORD(7u), + BULK_GET_RECORD(8u), + BULK_CONNECTION_INFO(9u), + BULK_DELETE_RECORD(10u) +} + +enum class RelayOpcode(val value: UByte) { + DATA(0u), + RELAYED_DATA(1u), + ERROR(2u), + RELAYED_ERROR(3u), + RELAY_ERROR(4u) +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncDeviceInfo.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncDeviceInfo.kt index 17a70860..a3bb6e00 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncDeviceInfo.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncDeviceInfo.kt @@ -5,10 +5,12 @@ class SyncDeviceInfo { var publicKey: String var addresses: Array var port: Int + var pairingCode: String? - constructor(publicKey: String, addresses: Array, port: Int) { + constructor(publicKey: String, addresses: Array, port: Int, pairingCode: String?) { this.publicKey = publicKey this.addresses = addresses this.port = port + this.pairingCode = pairingCode } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncErrorCode.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncErrorCode.kt new file mode 100644 index 00000000..0b4be0ce --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncErrorCode.kt @@ -0,0 +1,6 @@ +package com.futo.platformplayer.sync.internal + +enum class SyncErrorCode(val value: Int) { + ConnectionClosed(1), + NotFound(2) +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt new file mode 100644 index 00000000..5e5ad7de --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt @@ -0,0 +1,825 @@ +package com.futo.platformplayer.sync.internal + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.os.Build +import android.util.Log +import com.futo.platformplayer.Settings +import com.futo.platformplayer.generateReadablePassword +import com.futo.platformplayer.getConnectedSocket +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.noise.protocol.DHState +import com.futo.platformplayer.noise.protocol.Noise +import com.futo.platformplayer.states.StateSync +import com.futo.polycentric.core.base64ToByteArray +import com.futo.polycentric.core.toBase64 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.nio.ByteBuffer +import java.util.Base64 +import java.util.Locale +import kotlin.math.min + +public data class SyncServiceSettings( + val listenerPort: Int = 12315, + val mdnsBroadcast: Boolean = true, + val mdnsConnectDiscovered: Boolean = true, + val bindListener: Boolean = true, + val connectLastKnown: Boolean = true, + val relayHandshakeAllowed: Boolean = true, + val relayPairAllowed: Boolean = true, + val relayEnabled: Boolean = true, + val relayConnectDirect: Boolean = true, + val relayConnectRelayed: Boolean = true +) + +interface ISyncDatabaseProvider { + fun isAuthorized(publicKey: String): Boolean + fun addAuthorizedDevice(publicKey: String) + fun removeAuthorizedDevice(publicKey: String) + fun getAllAuthorizedDevices(): Array? + fun getAuthorizedDeviceCount(): Int + fun getSyncKeyPair(): SyncKeyPair? + fun setSyncKeyPair(value: SyncKeyPair) + fun getLastAddress(publicKey: String): String? + fun setLastAddress(publicKey: String, address: String) + fun getDeviceName(publicKey: String): String? + fun setDeviceName(publicKey: String, name: String) +} + +class SyncService( + private val serviceName: String, + private val relayServer: String, + private val relayPublicKey: String, + private val appId: UInt, + private val database: ISyncDatabaseProvider, + private val settings: SyncServiceSettings = SyncServiceSettings() +) { + private var _serverSocket: ServerSocket? = null + private var _thread: Thread? = null + private var _connectThread: Thread? = null + private var _mdnsThread: Thread? = null + @Volatile private var _started = false + private val _sessions: MutableMap = mutableMapOf() + private val _lastConnectTimesMdns: MutableMap = mutableMapOf() + private val _lastConnectTimesIp: MutableMap = mutableMapOf() + var serverSocketFailedToStart = false + var serverSocketStarted = false + var relayConnected = false + //TODO: Should sync mdns and casting mdns be merged? + //TODO: Decrease interval that devices are updated + //TODO: Send less data + + private val _pairingCode: String? = generateReadablePassword(8) + val pairingCode: String? get() = _pairingCode + private var _relaySession: SyncSocketSession? = null + private var _threadRelay: Thread? = null + private val _remotePendingStatusUpdate = mutableMapOf Unit>() + private var _nsdManager: NsdManager? = null + private var _scope: CoroutineScope? = null + private val _mdnsCache = mutableMapOf() + private var _discoveryListener: NsdManager.DiscoveryListener = object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(regType: String) { + Log.d(TAG, "Service discovery started for $regType") + } + + override fun onDiscoveryStopped(serviceType: String) { + Log.i(TAG, "Discovery stopped: $serviceType") + } + + override fun onServiceLost(service: NsdServiceInfo) { + Log.e(TAG, "service lost: $service") + val urlSafePkey = service.attributes["pk"]?.decodeToString() ?: return + val pkey = Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')).toBase64() + synchronized(_mdnsCache) { + _mdnsCache.remove(pkey) + } + } + + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode") + try { + _nsdManager?.stopServiceDiscovery(this) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode") + try { + _nsdManager?.stopServiceDiscovery(this) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } + } + + fun addOrUpdate(name: String, adrs: Array, port: Int, attributes: Map) { + if (!Settings.instance.synchronization.connectDiscovered) { + return + } + + val urlSafePkey = attributes.get("pk")?.decodeToString() ?: return + val pkey = Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')).toBase64() + val syncDeviceInfo = SyncDeviceInfo(pkey, adrs.map { it.hostAddress }.toTypedArray(), port, null) + + synchronized(_mdnsCache) { + _mdnsCache[pkey] = syncDeviceInfo + } + } + + override fun onServiceFound(service: NsdServiceInfo) { + Log.v(TAG, "Service discovery success for ${service.serviceType}: $service") + addOrUpdate(service.serviceName, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + service.hostAddresses.toTypedArray() + } else { + if(service.host != null) + arrayOf(service.host); + else + arrayOf(); + }, service.port, service.attributes) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + _nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback { + override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "onServiceUpdated: $serviceInfo") + addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port, serviceInfo.attributes) + } + + override fun onServiceLost() { + Log.v(TAG, "onServiceLost: $service") + val urlSafePkey = service.attributes["pk"]?.decodeToString() ?: return + val pkey = Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')).toBase64() + synchronized(_mdnsCache) { + _mdnsCache.remove(pkey) + } + } + + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode") + } + + override fun onServiceInfoCallbackUnregistered() { + Log.v(TAG, "onServiceInfoCallbackUnregistered") + } + }) + } else { + _nsdManager?.resolveService(service, object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.v(TAG, "Resolve failed: $errorCode") + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "Resolve Succeeded: $serviceInfo") + addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port, serviceInfo.attributes) + } + }) + } + } + } + + private val _registrationListener = object : NsdManager.RegistrationListener { + override fun onServiceRegistered(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "onServiceRegistered: ${serviceInfo.serviceName}") + } + + override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.v(TAG, "onRegistrationFailed: ${serviceInfo.serviceName} (error code: $errorCode)") + } + + override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "onServiceUnregistered: ${serviceInfo.serviceName}") + } + + override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.v(TAG, "onUnregistrationFailed: ${serviceInfo.serviceName} (error code: $errorCode)") + } + } + + var keyPair: DHState? = null + var publicKey: String? = null + + var onAuthorized: ((SyncSession, Boolean, Boolean) -> Unit)? = null + var onUnauthorized: ((SyncSession) -> Unit)? = null + var onConnectedChanged: ((SyncSession, Boolean) -> Unit)? = null + var onClose: ((SyncSession) -> Unit)? = null + var onData: ((SyncSession, UByte, UByte, ByteBuffer) -> Unit)? = null + var authorizePrompt: ((String, (Boolean) -> Unit) -> Unit)? = null + + fun start(context: Context) { + if (_started) { + Logger.i(TAG, "Already started.") + return + } + _started = true + _scope = CoroutineScope(Dispatchers.IO) + + try { + val syncKeyPair = database.getSyncKeyPair() ?: throw Exception("SyncKeyPair not found") + val p = Noise.createDH(dh) + p.setPublicKey(syncKeyPair.publicKey.base64ToByteArray(), 0) + p.setPrivateKey(syncKeyPair.privateKey.base64ToByteArray(), 0) + keyPair = p + } catch (e: Throwable) { + //Sync key pair non-existing, invalid or lost + val p = Noise.createDH(dh) + p.generateKeyPair() + + val publicKey = ByteArray(p.publicKeyLength) + p.getPublicKey(publicKey, 0) + val privateKey = ByteArray(p.privateKeyLength) + p.getPrivateKey(privateKey, 0) + + val syncKeyPair = SyncKeyPair(1, publicKey.toBase64(), privateKey.toBase64()) + database.setSyncKeyPair(syncKeyPair) + + Logger.e(TAG, "Failed to load existing key pair", e) + keyPair = p + } + + publicKey = keyPair?.let { + val pkey = ByteArray(it.publicKeyLength) + it.getPublicKey(pkey, 0) + return@let pkey.toBase64() + } + + _nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager + if (settings.mdnsConnectDiscovered) { + startMdnsRetryLoop() + } + + if (settings.mdnsBroadcast) { + val pk = publicKey + val nsdManager = _nsdManager + + if (pk != null && nsdManager != null) { + val sn = serviceName + val serviceInfo = NsdServiceInfo().apply { + serviceName = getDeviceName() + serviceType = sn + port = settings.listenerPort + setAttribute("pk", pk.replace('+', '-').replace('/', '_').replace("=", "")) + } + + nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, _registrationListener) + } + } + + Logger.i(TAG, "Sync key pair initialized (public key = $publicKey)") + + serverSocketStarted = false + if (settings.bindListener) { + startListener() + } + + relayConnected = false + if (settings.relayEnabled) { + startRelayLoop() + } + + if (settings.connectLastKnown) { + startConnectLastLoop() + } + } + + private fun startListener() { + serverSocketFailedToStart = false + serverSocketStarted = false + _thread = Thread { + try { + val serverSocket = ServerSocket(settings.listenerPort) + _serverSocket = serverSocket + + serverSocketStarted = true + Log.i(TAG, "Running on port ${settings.listenerPort} (TCP)") + + while (_started) { + val socket = serverSocket.accept() + val session = createSocketSession(socket, true) + session.startAsResponder() + } + + serverSocketStarted = false + } catch (e: Throwable) { + Logger.e(TAG, "Failed to bind server socket to port ${settings.listenerPort}", e) + serverSocketFailedToStart = true + serverSocketStarted = false + } + }.apply { start() } + } + + private fun startMdnsRetryLoop() { + _nsdManager?.apply { + discoverServices(serviceName, NsdManager.PROTOCOL_DNS_SD, _discoveryListener) + } + + _mdnsThread = Thread { + while (_started) { + try { + val now = System.currentTimeMillis() + synchronized(_mdnsCache) { + for ((pkey, info) in _mdnsCache) { + if (!database.isAuthorized(pkey) || isConnected(pkey)) continue + + val last = synchronized(_lastConnectTimesMdns) { + _lastConnectTimesMdns[pkey] ?: 0L + } + if (now - last > 30_000L) { + _lastConnectTimesMdns[pkey] = now + try { + Logger.i(TAG, "MDNS-retry: connecting to $pkey") + connect(info) + } catch (ex: Throwable) { + Logger.w(TAG, "MDNS retry failed for $pkey", ex) + } + } + } + } + } catch (ex: Throwable) { + Logger.e(TAG, "Error in MDNS retry loop", ex) + } + Thread.sleep(5000) + } + }.apply { start() } + } + + + private fun startConnectLastLoop() { + _connectThread = Thread { + Log.i(TAG, "Running auto reconnector") + + while (_started) { + val authorizedDevices = database.getAllAuthorizedDevices() ?: arrayOf() + val addressesToConnect = authorizedDevices.mapNotNull { + val connected = isConnected(it) + if (connected) { + return@mapNotNull null + } + + val lastKnownAddress = database.getLastAddress(it) ?: return@mapNotNull null + return@mapNotNull Pair(it, lastKnownAddress) + } + + for (connectPair in addressesToConnect) { + try { + val now = System.currentTimeMillis() + val lastConnectTime = synchronized(_lastConnectTimesIp) { + _lastConnectTimesIp[connectPair.first] ?: 0 + } + + //Connect once every 30 seconds, max + if (now - lastConnectTime > 30000) { + synchronized(_lastConnectTimesIp) { + _lastConnectTimesIp[connectPair.first] = now + } + + Logger.i(TAG, "Attempting to connect to authorized device by last known IP '${connectPair.first}' with pkey=${connectPair.first}") + connect(arrayOf(connectPair.second), settings.listenerPort, connectPair.first, null) + } + } catch (e: Throwable) { + Logger.i(TAG, "Failed to connect to " + connectPair.first, e) + } + } + Thread.sleep(5000) + } + }.apply { start() } + } + + private fun startRelayLoop() { + relayConnected = false + _threadRelay = Thread { + try { + var backoffs: Array = arrayOf(1000, 5000, 10000, 20000) + var backoffIndex = 0; + + while (_started) { + try { + Log.i(TAG, "Starting relay session...") + relayConnected = false + + var socketClosed = false; + val socket = Socket(relayServer, 9000) + _relaySession = SyncSocketSession( + (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, + keyPair!!, + socket, + isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> + isHandshakeAllowed( + linkType, + syncSocketSession, + publicKey, + pairingCode, + appId + ) + }, + onNewChannel = { _, c -> + val remotePublicKey = c.remotePublicKey + if (remotePublicKey == null) { + Log.e( + TAG, + "Remote public key should never be null in onNewChannel." + ) + return@SyncSocketSession + } + + Log.i( + TAG, + "New channel established from relay (pk: '$remotePublicKey')." + ) + + var session: SyncSession? + synchronized(_sessions) { + session = _sessions[remotePublicKey] + if (session == null) { + val remoteDeviceName = + database.getDeviceName(remotePublicKey) + session = + createNewSyncSession(remotePublicKey, remoteDeviceName) + _sessions[remotePublicKey] = session!! + } + session!!.addChannel(c) + } + + c.setDataHandler { _, channel, opcode, subOpcode, data -> + session?.handlePacket(opcode, subOpcode, data) + } + c.setCloseHandler { channel -> + session?.removeChannel(channel) + } + }, + onChannelEstablished = { _, channel, isResponder -> + handleAuthorization(channel, isResponder) + }, + onClose = { socketClosed = true }, + onHandshakeComplete = { relaySession -> + backoffIndex = 0 + + Thread { + try { + while (_started && !socketClosed) { + val unconnectedAuthorizedDevices = + database.getAllAuthorizedDevices() + ?.filter { !isConnected(it) }?.toTypedArray() + ?: arrayOf() + relaySession.publishConnectionInformation( + unconnectedAuthorizedDevices, + settings.listenerPort, + settings.relayConnectDirect, + false, + false, + settings.relayConnectRelayed + ) + + Logger.v( + TAG, + "Requesting ${unconnectedAuthorizedDevices.size} devices connection information" + ) + val connectionInfos = runBlocking { + relaySession.requestBulkConnectionInfo( + unconnectedAuthorizedDevices + ) + } + Logger.v( + TAG, + "Received ${connectionInfos.size} devices connection information" + ) + + for ((targetKey, connectionInfo) in connectionInfos) { + val potentialLocalAddresses = + connectionInfo.ipv4Addresses + .filter { it != connectionInfo.remoteIp } + if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) { + Thread { + try { + Log.v( + TAG, + "Attempting to connect directly, locally to '$targetKey'." + ) + connect( + potentialLocalAddresses.map { it } + .toTypedArray(), + settings.listenerPort, + targetKey, + null + ) + } catch (e: Throwable) { + Log.e( + TAG, + "Failed to start direct connection using connection info with $targetKey.", + e + ) + } + }.start() + } + + if (connectionInfo.allowRemoteDirect) { + // TODO: Implement direct remote connection if needed + } + + if (connectionInfo.allowRemoteHolePunched) { + // TODO: Implement hole punching if needed + } + + if (connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) { + try { + Logger.v( + TAG, + "Attempting relayed connection with '$targetKey'." + ) + runBlocking { + relaySession.startRelayedChannel( + targetKey, + appId, + null + ) + } + } catch (e: Throwable) { + Logger.e( + TAG, + "Failed to start relayed channel with $targetKey.", + e + ) + } + } + } + + Thread.sleep(15000) + } + } catch (e: Throwable) { + Logger.e(TAG, "Unhandled exception in relay session.", e) + relaySession.stop() + } + }.start() + } + ) + + _relaySession!!.authorizable = object : IAuthorizable { + override val isAuthorized: Boolean get() = true + } + + relayConnected = true + _relaySession!!.runAsInitiator(relayPublicKey, appId, null) + + Log.i(TAG, "Started relay session.") + } catch (e: Throwable) { + Log.e(TAG, "Relay session failed.", e) + } finally { + relayConnected = false + _relaySession?.stop() + _relaySession = null + Thread.sleep(backoffs[min(backoffs.size - 1, backoffIndex++)]) + } + } + } catch (ex: Throwable) { + Log.i(TAG, "Unhandled exception in relay loop.", ex) + } + }.apply { start() } + } + + private fun createSocketSession(socket: Socket, isResponder: Boolean): SyncSocketSession { + var session: SyncSession? = null + var channelSocket: ChannelSocket? = null + return SyncSocketSession( + (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, + keyPair!!, + socket, + onClose = { s -> + if (channelSocket != null) + session?.removeChannel(channelSocket!!) + }, + isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) }, + onHandshakeComplete = { s -> + val remotePublicKey = s.remotePublicKey + if (remotePublicKey == null) { + s.stop() + return@SyncSocketSession + } + + Logger.i(TAG, "Handshake complete with (LocalPublicKey = ${s.localPublicKey}, RemotePublicKey = ${s.remotePublicKey})") + + channelSocket = ChannelSocket(s) + + synchronized(_sessions) { + session = _sessions[s.remotePublicKey] + if (session == null) { + val remoteDeviceName = database.getDeviceName(remotePublicKey) + database.setLastAddress(remotePublicKey, s.remoteAddress) + session = createNewSyncSession(remotePublicKey, remoteDeviceName) + _sessions[remotePublicKey] = session!! + } + session!!.addChannel(channelSocket!!) + } + + handleAuthorization(channelSocket!!, isResponder) + }, + onData = { s, opcode, subOpcode, data -> + session?.handlePacket(opcode, subOpcode, data) + } + ) + } + + private fun handleAuthorization(channel: IChannel, isResponder: Boolean) { + val syncSession = channel.syncSession!! + val remotePublicKey = channel.remotePublicKey!! + + if (isResponder) { + val isAuthorized = database.isAuthorized(remotePublicKey) + if (!isAuthorized) { + val ap = this.authorizePrompt + if (ap == null) { + try { + Logger.i(TAG, "$remotePublicKey unauthorized because AuthorizePrompt is null") + syncSession.unauthorize() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to send authorize result.", e) + } + return; + } + + ap.invoke(remotePublicKey) { + try { + _scope?.launch(Dispatchers.IO) { + if (it) { + Logger.i(TAG, "$remotePublicKey manually authorized") + syncSession.authorize() + } else { + Logger.i(TAG, "$remotePublicKey manually unauthorized") + syncSession.unauthorize() + syncSession.close() + } + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to send authorize result.") + } + } + } else { + //Responder does not need to check because already approved + syncSession.authorize() + Logger.i(TAG, "Connection authorized for $remotePublicKey because already authorized") + } + } else { + //Initiator does not need to check because the manual action of scanning the QR counts as approval + syncSession.authorize() + Logger.i(TAG, "Connection authorized for $remotePublicKey because initiator") + } + } + + private fun isHandshakeAllowed(linkType: LinkType, syncSocketSession: SyncSocketSession, publicKey: String, pairingCode: String?, appId: UInt): Boolean { + Log.v(TAG, "Check if handshake allowed from '$publicKey' (app id: $appId).") + if (publicKey == StateSync.RELAY_PUBLIC_KEY) + return true + + if (database.isAuthorized(publicKey)) { + if (linkType == LinkType.Relayed && !settings.relayHandshakeAllowed) + return false + return true + } + + Log.v(TAG, "Check if handshake allowed with pairing code '$pairingCode' with active pairing code '$_pairingCode' (app id: $appId).") + if (_pairingCode == null || pairingCode.isNullOrEmpty()) + return false + + if (linkType == LinkType.Relayed && !settings.relayPairAllowed) + return false + + return _pairingCode == pairingCode + } + + private fun createNewSyncSession(rpk: String, remoteDeviceName: String?): SyncSession { + val remotePublicKey = rpk.base64ToByteArray().toBase64() + return SyncSession( + remotePublicKey, + onAuthorized = { it, isNewlyAuthorized, isNewSession -> + synchronized(_remotePendingStatusUpdate) { + _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(true, "Authorized") + } + + if (isNewSession) { + it.remoteDeviceName?.let { remoteDeviceName -> + database.setDeviceName(remotePublicKey, remoteDeviceName) + } + + database.addAuthorizedDevice(remotePublicKey) + } + + onAuthorized?.invoke(it, isNewlyAuthorized, isNewSession) + }, + onUnauthorized = { + synchronized(_remotePendingStatusUpdate) { + _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Unauthorized") + } + + onUnauthorized?.invoke(it) + }, + onConnectedChanged = { it, connected -> + Logger.i(TAG, "$remotePublicKey connected: $connected") + onConnectedChanged?.invoke(it, connected) + }, + onClose = { + Logger.i(TAG, "$remotePublicKey closed") + + removeSession(it.remotePublicKey) + synchronized(_remotePendingStatusUpdate) { + _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Connection closed") + } + + onClose?.invoke(it) + }, + dataHandler = { it, opcode, subOpcode, data -> + onData?.invoke(it, opcode, subOpcode, data) + }, + remoteDeviceName + ) + } + + fun isConnected(publicKey: String): Boolean = synchronized(_sessions) { _sessions[publicKey]?.connected ?: false } + fun isAuthorized(publicKey: String): Boolean = database.isAuthorized(publicKey) + fun getSession(publicKey: String): SyncSession? = synchronized(_sessions) { _sessions[publicKey] } + fun getSessions(): List = synchronized(_sessions) { _sessions.values.toList() } + fun removeSession(publicKey: String) = synchronized(_sessions) { _sessions.remove(publicKey) } + fun getCachedName(publicKey: String): String? = database.getDeviceName(publicKey) + fun getAuthorizedDeviceCount(): Int = database.getAuthorizedDeviceCount() + fun getAllAuthorizedDevices(): Array? = database.getAllAuthorizedDevices() + fun removeAuthorizedDevice(publicKey: String) = database.removeAuthorizedDevice(publicKey) + + fun connect(deviceInfo: SyncDeviceInfo, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null) { + try { + connect(deviceInfo.addresses, deviceInfo.port, deviceInfo.publicKey, deviceInfo.pairingCode, onStatusUpdate) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to connect directly", e) + val relaySession = _relaySession + if (relaySession != null && Settings.instance.synchronization.pairThroughRelay) { + onStatusUpdate?.invoke(null, "Connecting via relay...") + + runBlocking { + if (onStatusUpdate != null) { + synchronized(_remotePendingStatusUpdate) { + _remotePendingStatusUpdate[deviceInfo.publicKey.base64ToByteArray().toBase64()] = onStatusUpdate + } + } + relaySession.startRelayedChannel(deviceInfo.publicKey.base64ToByteArray().toBase64(), appId, deviceInfo.pairingCode) + } + } else { + throw e + } + } + } + + fun connect(addresses: Array, port: Int, publicKey: String, pairingCode: String?, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null): SyncSocketSession { + onStatusUpdate?.invoke(null, "Connecting directly...") + val socket = getConnectedSocket(addresses.map { InetAddress.getByName(it) }, port) ?: throw Exception("Failed to connect") + onStatusUpdate?.invoke(null, "Handshaking...") + + val session = createSocketSession(socket, false) + if (onStatusUpdate != null) { + synchronized(_remotePendingStatusUpdate) { + _remotePendingStatusUpdate[publicKey.base64ToByteArray().toBase64()] = onStatusUpdate + } + } + + session.startAsInitiator(publicKey, appId, pairingCode) + return session + } + + fun stop() { + _scope?.cancel() + _scope = null + _relaySession?.stop() + _relaySession = null + _serverSocket?.close() + _serverSocket = null + synchronized(_sessions) { + _sessions.values.forEach { it.close() } + _sessions.clear() + } + } + + private fun getDeviceName(): String { + val manufacturer = Build.MANUFACTURER.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + val model = Build.MODEL + + return if (model.startsWith(manufacturer, ignoreCase = true)) { + model.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + } else { + "$manufacturer $model".replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + } + } + + companion object { + val dh = "25519" + val pattern = "IK" + val cipher = "ChaChaPoly" + val hash = "BLAKE2b" + var protocolName = "Noise_${pattern}_${dh}_${cipher}_${hash}" + + private const val TAG = "SyncService" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt index e4273d63..59e048c6 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt @@ -1,37 +1,14 @@ package com.futo.platformplayer.sync.internal import com.futo.platformplayer.UIDialogs -import com.futo.platformplayer.activities.MainActivity -import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.ensureNotMainThread import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.Subscription -import com.futo.platformplayer.smartMerge -import com.futo.platformplayer.states.StateApp -import com.futo.platformplayer.states.StateBackup -import com.futo.platformplayer.states.StateHistory -import com.futo.platformplayer.states.StatePlaylists -import com.futo.platformplayer.states.StateSubscriptionGroups import com.futo.platformplayer.states.StateSubscriptions -import com.futo.platformplayer.states.StateSync -import com.futo.platformplayer.sync.SyncSessionData -import com.futo.platformplayer.sync.internal.SyncSocketSession.Opcode -import com.futo.platformplayer.sync.models.SendToDevicePackage -import com.futo.platformplayer.sync.models.SyncPlaylistsPackage -import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage -import com.futo.platformplayer.sync.models.SyncWatchLaterPackage -import com.futo.platformplayer.toUtf8String -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import java.io.ByteArrayInputStream import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.time.Instant -import java.time.OffsetDateTime -import java.time.ZoneOffset import java.util.UUID interface IAuthorizable { @@ -39,13 +16,16 @@ interface IAuthorizable { } class SyncSession : IAuthorizable { - private val _socketSessions: MutableList = mutableListOf() + private val _channels: MutableList = mutableListOf() + @Volatile + private var _snapshot: Array = emptyArray() private var _authorized: Boolean = false private var _remoteAuthorized: Boolean = false private val _onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit private val _onUnauthorized: (session: SyncSession) -> Unit private val _onClose: (session: SyncSession) -> Unit private val _onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit + private val _dataHandler: (session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit val remotePublicKey: String override val isAuthorized get() = _authorized && _remoteAuthorized private var _wasAuthorized = false @@ -56,140 +36,150 @@ class SyncSession : IAuthorizable { private set val displayName: String get() = remoteDeviceName ?: remotePublicKey - var connected: Boolean = false - private set(v) { - if (field != v) { - field = v - this._onConnectedChanged(this, v) + val linkType: LinkType get() + { + var linkType = LinkType.None + synchronized(_channels) + { + for (channel in _channels) + { + if (channel.linkType == LinkType.Direct) + return LinkType.Direct + if (channel.linkType == LinkType.Relayed) + linkType = LinkType.Relayed + } } + return linkType } - constructor(remotePublicKey: String, onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit, onUnauthorized: (session: SyncSession) -> Unit, onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit, onClose: (session: SyncSession) -> Unit, remoteDeviceName: String?) { + var connected: Boolean = false + private set(v) { + if (field != v) { + field = v + this._onConnectedChanged(this, v) + } + } + + constructor( + remotePublicKey: String, + onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit, + onUnauthorized: (session: SyncSession) -> Unit, + onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit, + onClose: (session: SyncSession) -> Unit, + dataHandler: (session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit, + remoteDeviceName: String? = null + ) { this.remotePublicKey = remotePublicKey + this.remoteDeviceName = remoteDeviceName _onAuthorized = onAuthorized _onUnauthorized = onUnauthorized _onConnectedChanged = onConnectedChanged _onClose = onClose + _dataHandler = dataHandler } - fun addSocketSession(socketSession: SyncSocketSession) { - if (socketSession.remotePublicKey != remotePublicKey) { - throw Exception("Public key of session must match public key of socket session") + fun addChannel(channel: IChannel) { + if (channel.remotePublicKey != remotePublicKey) { + throw Exception("Public key of session must match public key of channel") } - synchronized(_socketSessions) { - _socketSessions.add(socketSession) - connected = _socketSessions.isNotEmpty() + synchronized(_channels) { + _channels.add(channel) + _channels.sortBy { it.linkType.ordinal } + _snapshot = _channels.toTypedArray() + connected = _channels.isNotEmpty() } - socketSession.authorizable = this + channel.authorizable = this + channel.syncSession = this } - fun authorize(socketSession: SyncSocketSession) { + fun authorize() { Logger.i(TAG, "Sent AUTHORIZED with session id $_id") - - if (socketSession.remoteVersion >= 3) { - val idStringBytes = _id.toString().toByteArray() - val nameBytes = "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}".toByteArray() - val buffer = ByteArray(1 + idStringBytes.size + 1 + nameBytes.size) - socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN).apply { - put(idStringBytes.size.toByte()) - put(idStringBytes) - put(nameBytes.size.toByte()) - put(nameBytes) - }.apply { flip() }) - } else { - socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(_id.toString().toByteArray())) - } + val idString = _id.toString() + val idBytes = idString.toByteArray(Charsets.UTF_8) + val name = "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}" + val nameBytes = name.toByteArray(Charsets.UTF_8) + val buffer = ByteArray(1 + idBytes.size + 1 + nameBytes.size) + buffer[0] = idBytes.size.toByte() + System.arraycopy(idBytes, 0, buffer, 1, idBytes.size) + buffer[1 + idBytes.size] = nameBytes.size.toByte() + System.arraycopy(nameBytes, 0, buffer, 2 + idBytes.size, nameBytes.size) + send(Opcode.NOTIFY.value, NotifyOpcode.AUTHORIZED.value, ByteBuffer.wrap(buffer)) _authorized = true checkAuthorized() } - fun unauthorize(socketSession: SyncSocketSession? = null) { - if (socketSession != null) - socketSession.send(Opcode.NOTIFY_UNAUTHORIZED.value) - else { - val ss = synchronized(_socketSessions) { - _socketSessions.first() - } - - ss.send(Opcode.NOTIFY_UNAUTHORIZED.value) - } + fun unauthorize() { + send(Opcode.NOTIFY.value, NotifyOpcode.UNAUTHORIZED.value) } private fun checkAuthorized() { if (isAuthorized) { - val isNewlyAuthorized = !_wasAuthorized; - val isNewSession = _lastAuthorizedRemoteId != _remoteId; - Logger.i(TAG, "onAuthorized (isNewlyAuthorized = $isNewlyAuthorized, isNewSession = $isNewSession)"); - _onAuthorized.invoke(this, !_wasAuthorized, _lastAuthorizedRemoteId != _remoteId) + val isNewlyAuthorized = !_wasAuthorized + val isNewSession = _lastAuthorizedRemoteId != _remoteId + Logger.i(TAG, "onAuthorized (isNewlyAuthorized = $isNewlyAuthorized, isNewSession = $isNewSession)") + _onAuthorized(this, isNewlyAuthorized, isNewSession) _wasAuthorized = true _lastAuthorizedRemoteId = _remoteId } } - fun removeSocketSession(socketSession: SyncSocketSession) { - synchronized(_socketSessions) { - _socketSessions.remove(socketSession) - connected = _socketSessions.isNotEmpty() + fun removeChannel(channel: IChannel) { + synchronized(_channels) { + _channels.remove(channel) + _snapshot = _channels.toTypedArray() + connected = _channels.isNotEmpty() } } fun close() { - synchronized(_socketSessions) { - for (socketSession in _socketSessions) { - socketSession.stop() - } - - _socketSessions.clear() + val toClose = synchronized(_channels) { + val arr = _channels.toTypedArray() + _channels.clear() + _snapshot = emptyArray() + connected = false + arr } - - _onClose.invoke(this) + toClose.forEach { it.close() } + _onClose(this) } - fun handlePacket(socketSession: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) { + fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { try { - Logger.i(TAG, "Handle packet (opcode: ${opcode}, subOpcode: ${subOpcode}, data.length: ${data.remaining()})") + Logger.i(TAG, "Handle packet (opcode: $opcode, subOpcode: $subOpcode, data.length: ${data.remaining()})") when (opcode) { - Opcode.NOTIFY_AUTHORIZED.value -> { - if (socketSession.remoteVersion >= 3) { + Opcode.NOTIFY.value -> when (subOpcode) { + NotifyOpcode.AUTHORIZED.value -> { val idByteCount = data.get().toInt() if (idByteCount > 64) throw Exception("Id should always be smaller than 64 bytes") - val idBytes = ByteArray(idByteCount) data.get(idBytes) val nameByteCount = data.get().toInt() if (nameByteCount > 64) throw Exception("Name should always be smaller than 64 bytes") - val nameBytes = ByteArray(nameByteCount) data.get(nameBytes) _remoteId = UUID.fromString(idBytes.toString(Charsets.UTF_8)) remoteDeviceName = nameBytes.toString(Charsets.UTF_8) - } else { - val str = data.toUtf8String() - _remoteId = if (data.remaining() >= 0) UUID.fromString(str) else UUID.fromString("00000000-0000-0000-0000-000000000000") - remoteDeviceName = null + _remoteAuthorized = true + Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId (device name: '${remoteDeviceName ?: "not set"}')") + checkAuthorized() + return + } + NotifyOpcode.UNAUTHORIZED.value -> { + _remoteAuthorized = false + _remoteId = null + remoteDeviceName = null + _lastAuthorizedRemoteId = null + _onUnauthorized(this) + return } - - _remoteAuthorized = true - Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId (device name: '${remoteDeviceName ?: "not set"}')") - checkAuthorized() - return } - Opcode.NOTIFY_UNAUTHORIZED.value -> { - _remoteId = null - remoteDeviceName = null - _lastAuthorizedRemoteId = null - _remoteAuthorized = false - _onUnauthorized(this) - return - } - //TODO: Handle any kind of packet (that is not necessarily authorized) } if (!isAuthorized) { @@ -197,282 +187,62 @@ class SyncSession : IAuthorizable { } if (opcode != Opcode.DATA.value) { - Logger.w(TAG, "Unknown opcode received: (opcode = ${opcode}, subOpcode = ${subOpcode})}") + Logger.w(TAG, "Unknown opcode received: (opcode = $opcode, subOpcode = $subOpcode)") return } - Logger.i(TAG, "Received (opcode = ${opcode}, subOpcode = ${subOpcode}) (${data.remaining()} bytes)") - //TODO: Abstract this out - when (subOpcode) { - GJSyncOpcodes.sendToDevices -> { - StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { - val context = StateApp.instance.contextOrNull; - if (context != null && context is MainActivity) { - val dataBody = ByteArray(data.remaining()); - val remainder = data.remaining(); - data.get(dataBody, 0, remainder); - val json = String(dataBody, Charsets.UTF_8); - val obj = Json.decodeFromString(json); - UIDialogs.appToast("Received url from device [${socketSession.remotePublicKey}]:\n{${obj.url}"); - context.handleUrl(obj.url, obj.position); - } - }; - } - - GJSyncOpcodes.syncStateExchange -> { - val dataBody = ByteArray(data.remaining()); - data.get(dataBody); - val json = String(dataBody, Charsets.UTF_8); - val syncSessionData = Serializer.json.decodeFromString(json); - - Logger.i(TAG, "Received SyncSessionData from " + remotePublicKey); - - - sendData(GJSyncOpcodes.syncSubscriptions, StateSubscriptions.instance.getSyncSubscriptionsPackageString()); - sendData(GJSyncOpcodes.syncSubscriptionGroups, StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString()); - sendData(GJSyncOpcodes.syncPlaylists, StatePlaylists.instance.getSyncPlaylistsPackageString()) - - sendData(GJSyncOpcodes.syncWatchLater, Json.encodeToString(StatePlaylists.instance.getWatchLaterSyncPacket(false))); - - val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory); - if(recentHistory.size > 0) - sendJsonData(GJSyncOpcodes.syncHistory, recentHistory); - } - - GJSyncOpcodes.syncExport -> { - val dataBody = ByteArray(data.remaining()); - val bytesStr = ByteArrayInputStream(data.array(), data.position(), data.remaining()); - try { - val exportStruct = StateBackup.ExportStructure.fromZipBytes(bytesStr); - for (store in exportStruct.stores) { - if (store.key.equals("subscriptions", true)) { - val subStore = - StateSubscriptions.instance.getUnderlyingSubscriptionsStore(); - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { - val pack = SyncSubscriptionsPackage( - store.value.map { - subStore.fromReconstruction(it, exportStruct.cache) - }, - StateSubscriptions.instance.getSubscriptionRemovals() - ); - handleSyncSubscriptionPackage(this@SyncSession, pack); - } - } - } - } finally { - bytesStr.close(); - } - } - - GJSyncOpcodes.syncSubscriptions -> { - val dataBody = ByteArray(data.remaining()); - data.get(dataBody); - val json = String(dataBody, Charsets.UTF_8); - val subPackage = Serializer.json.decodeFromString(json); - handleSyncSubscriptionPackage(this, subPackage); - - val newestSub = subPackage.subscriptions.maxOf { it.creationTime }; - - val sesData = StateSync.instance.getSyncSessionData(remotePublicKey); - if(newestSub > sesData.lastSubscription) { - sesData.lastSubscription = newestSub; - StateSync.instance.saveSyncSessionData(sesData); - } - } - - GJSyncOpcodes.syncSubscriptionGroups -> { - val dataBody = ByteArray(data.remaining()); - data.get(dataBody); - val json = String(dataBody, Charsets.UTF_8); - val pack = Serializer.json.decodeFromString(json); - - var lastSubgroupChange = OffsetDateTime.MIN; - for(group in pack.groups){ - if(group.lastChange > lastSubgroupChange) - lastSubgroupChange = group.lastChange; - - val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id); - - if(existing == null) - StateSubscriptionGroups.instance.updateSubscriptionGroup(group, false, true); - else if(existing.lastChange < group.lastChange) { - existing.name = group.name; - existing.urls = group.urls; - existing.image = group.image; - existing.priority = group.priority; - existing.lastChange = group.lastChange; - StateSubscriptionGroups.instance.updateSubscriptionGroup(existing, false, true); - } - } - for(removal in pack.groupRemovals) { - val creation = StateSubscriptionGroups.instance.getSubscriptionGroup(removal.key); - val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC); - if(creation != null && creation.creationTime < removalTime) - StateSubscriptionGroups.instance.deleteSubscriptionGroup(removal.key, false); - } - } - - GJSyncOpcodes.syncPlaylists -> { - val dataBody = ByteArray(data.remaining()); - data.get(dataBody); - val json = String(dataBody, Charsets.UTF_8); - val pack = Serializer.json.decodeFromString(json); - - for(playlist in pack.playlists) { - val existing = StatePlaylists.instance.getPlaylist(playlist.id); - - if(existing == null) - StatePlaylists.instance.createOrUpdatePlaylist(playlist, false); - else if(existing.dateUpdate.toLocalDateTime() < playlist.dateUpdate.toLocalDateTime()) { - existing.dateUpdate = playlist.dateUpdate; - existing.name = playlist.name; - existing.videos = playlist.videos; - existing.dateCreation = playlist.dateCreation; - existing.datePlayed = playlist.datePlayed; - StatePlaylists.instance.createOrUpdatePlaylist(existing, false); - } - } - for(removal in pack.playlistRemovals) { - val creation = StatePlaylists.instance.getPlaylist(removal.key); - val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC); - if(creation != null && creation.dateCreation < removalTime) - StatePlaylists.instance.removePlaylist(creation, false); - - } - } - - GJSyncOpcodes.syncWatchLater -> { - val dataBody = ByteArray(data.remaining()); - data.get(dataBody); - val json = String(dataBody, Charsets.UTF_8); - val pack = Serializer.json.decodeFromString(json); - - Logger.i(TAG, "SyncWatchLater received ${pack.videos.size} (${pack.videoAdds?.size}, ${pack.videoRemovals?.size})"); - - val allExisting = StatePlaylists.instance.getWatchLater(); - for(video in pack.videos) { - val existing = allExisting.firstOrNull { it.url == video.url }; - val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.videoAdds[video.url] ?: 0), ZoneOffset.UTC) else OffsetDateTime.MIN; - - if(existing == null) { - StatePlaylists.instance.addToWatchLater(video, false); - if(time > OffsetDateTime.MIN) - StatePlaylists.instance.setWatchLaterAddTime(video.url, time); - } - } - for(removal in pack.videoRemovals) { - val watchLater = allExisting.firstOrNull { it.url == removal.key } ?: continue; - val creation = StatePlaylists.instance.getWatchLaterRemovalTime(watchLater.url) ?: OffsetDateTime.MIN; - val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value), ZoneOffset.UTC); - if(creation < removalTime) - StatePlaylists.instance.removeFromWatchLater(watchLater, false, removalTime); - } - - val packReorderTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.reorderTime), ZoneOffset.UTC); - val localReorderTime = StatePlaylists.instance.getWatchLaterLastReorderTime(); - if(localReorderTime < packReorderTime && pack.ordering != null) { - StatePlaylists.instance.updateWatchLaterOrdering(smartMerge(pack.ordering!!, StatePlaylists.instance.getWatchLaterOrdering()), true); - } - } - - GJSyncOpcodes.syncHistory -> { - val dataBody = ByteArray(data.remaining()); - data.get(dataBody); - val json = String(dataBody, Charsets.UTF_8); - val history = Serializer.json.decodeFromString>(json); - Logger.i(TAG, "SyncHistory received ${history.size} videos from ${remotePublicKey}"); - - var lastHistory = OffsetDateTime.MIN; - for(video in history){ - val hist = StateHistory.instance.getHistoryByVideo(video.video, true, video.date); - if(hist != null) - StateHistory.instance.updateHistoryPosition(video.video, hist, true, video.position, video.date) - if(lastHistory < video.date) - lastHistory = video.date; - } - - if(lastHistory != OffsetDateTime.MIN && history.size > 1) { - val sesData = StateSync.instance.getSyncSessionData(remotePublicKey); - if (lastHistory > sesData.lastHistory) { - sesData.lastHistory = lastHistory; - StateSync.instance.saveSyncSessionData(sesData); - } - } - } - } + Logger.i(TAG, "Received (opcode = $opcode, subOpcode = $subOpcode) (${data.remaining()} bytes)") + _dataHandler.invoke(this, opcode, subOpcode, data) + } catch (ex: Exception) { + Logger.w(TAG, "Failed to handle sync package $opcode: ${ex.message}", ex) } catch(ex: Exception) { Logger.w(TAG, "Failed to handle sync package ${opcode}: ${ex.message}", ex); } } - private fun handleSyncSubscriptionPackage(origin: SyncSession, pack: SyncSubscriptionsPackage) { - val added = mutableListOf() - for(sub in pack.subscriptions) { - if(!StateSubscriptions.instance.isSubscribed(sub.channel)) { - val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url); - if(sub.creationTime > removalTime) { - val newSub = StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime); - added.add(newSub); - } - } - } - if(added.size > 3) - UIDialogs.appToast("${added.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}"); - else if(added.size > 0) - UIDialogs.appToast("Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" + - added.map { it.channel.name }.joinToString("\n")); - - - if(pack.subscriptions != null && pack.subscriptions.size > 0) { - for (subRemoved in pack.subscriptionRemovals) { - val removed = StateSubscriptions.instance.applySubscriptionRemovals(pack.subscriptionRemovals); - if(removed.size > 3) - UIDialogs.appToast("Removed ${removed.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}"); - else if(removed.size > 0) - UIDialogs.appToast("Subscriptions removed from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" + - removed.map { it.channel.name }.joinToString("\n")); - - } - } - } - inline fun sendJsonData(subOpcode: UByte, data: T) { - send(Opcode.DATA.value, subOpcode, Json.encodeToString(data)); + ensureNotMainThread() + send(Opcode.DATA.value, subOpcode, Json.encodeToString(data)) } - fun sendData(subOpcode: UByte, data: String) { - send(Opcode.DATA.value, subOpcode, data.toByteArray(Charsets.UTF_8)); - } - fun send(opcode: UByte, subOpcode: UByte, data: String) { - send(opcode, subOpcode, data.toByteArray(Charsets.UTF_8)); - } - fun send(opcode: UByte, subOpcode: UByte, data: ByteArray) { - val socketSessions = synchronized(_socketSessions) { - _socketSessions.toList() - } - if (socketSessions.isEmpty()) { - Logger.v(TAG, "Packet was not sent (opcode = ${opcode}, subOpcode = ${subOpcode}) due to no connected sockets") + fun sendData(subOpcode: UByte, data: String) { + ensureNotMainThread() + send(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip) + } + + fun send(opcode: UByte, subOpcode: UByte, data: String) { + ensureNotMainThread() + send(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip) + } + + fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer? = null, contentEncoding: ContentEncoding? = null) { + ensureNotMainThread() + val channels = _snapshot + if (channels.isEmpty()) { + Logger.v(TAG, "Packet was not sent … no connected sockets") return } var sent = false - for (socketSession in socketSessions) { + for (channel in channels) { try { - socketSession.send(opcode, subOpcode, ByteBuffer.wrap(data)) + channel.send(opcode, subOpcode, data, contentEncoding) sent = true break } catch (e: Throwable) { - Logger.w(TAG, "Packet failed to send (opcode = ${opcode}, subOpcode = ${subOpcode})", e) + Logger.w(TAG, "Packet failed to send (opcode = $opcode, subOpcode = $subOpcode), closing channel", e) + channel.close() + removeChannel(channel) } } if (!sent) { - throw Exception("Packet was not sent (opcode = ${opcode}, subOpcode = ${subOpcode}) due to send errors and no remaining candidates") + throw Exception("Packet was not sent (opcode = $opcode, subOpcode = $subOpcode) due to send errors and no remaining candidates") } } - private companion object { - const val TAG = "SyncSession" + companion object { + private const val TAG = "SyncSession" } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index c997cec4..cb67f934 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -1,76 +1,138 @@ package com.futo.platformplayer.sync.internal -import com.futo.platformplayer.LittleEndianDataInputStream -import com.futo.platformplayer.LittleEndianDataOutputStream +import android.os.Build import com.futo.platformplayer.ensureNotMainThread import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.noise.protocol.CipherStatePair import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.noise.protocol.HandshakeState +import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateSync +import com.futo.polycentric.core.base64ToByteArray +import com.futo.polycentric.core.toBase64 +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.net.NetworkInterface +import java.net.Socket import java.nio.ByteBuffer import java.nio.ByteOrder -import java.util.UUID +import java.util.Base64 +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream +import kotlin.system.measureTimeMillis class SyncSocketSession { - enum class Opcode(val value: UByte) { - PING(0u), - PONG(1u), - NOTIFY_AUTHORIZED(2u), - NOTIFY_UNAUTHORIZED(3u), - STREAM_START(4u), - STREAM_DATA(5u), - STREAM_END(6u), - DATA(7u) - } - - private val _inputStream: LittleEndianDataInputStream - private val _outputStream: LittleEndianDataOutputStream + private val _socket: Socket + private val _inputStream: InputStream + private val _outputStream: OutputStream private val _sendLockObject = Object() private val _buffer = ByteArray(MAXIMUM_PACKET_SIZE_ENCRYPTED) private val _bufferDecrypted = ByteArray(MAXIMUM_PACKET_SIZE) private val _sendBuffer = ByteArray(MAXIMUM_PACKET_SIZE) - private val _sendBufferEncrypted = ByteArray(MAXIMUM_PACKET_SIZE_ENCRYPTED) + private val _sendBufferEncrypted = ByteArray(4 + MAXIMUM_PACKET_SIZE_ENCRYPTED) private val _syncStreams = hashMapOf() - private val _streamIdGenerator = 0 + private var _streamIdGenerator = 0 private val _streamIdGeneratorLock = Object() - private val _onClose: (session: SyncSocketSession) -> Unit - private val _onHandshakeComplete: (session: SyncSocketSession) -> Unit - private var _thread: Thread? = null + private var _requestIdGenerator = 0 + private val _requestIdGeneratorLock = Object() + private val _onClose: ((session: SyncSocketSession) -> Unit)? + private val _onHandshakeComplete: ((session: SyncSocketSession) -> Unit)? + private val _onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)? + private val _onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)? + private val _isHandshakeAllowed: ((linkType: LinkType, session: SyncSocketSession, remotePublicKey: String, pairingCode: String?, appId: UInt) -> Boolean)? private var _cipherStatePair: CipherStatePair? = null private var _remotePublicKey: String? = null val remotePublicKey: String? get() = _remotePublicKey private var _started: Boolean = false private val _localKeyPair: DHState + private var _thread: Thread? = null private var _localPublicKey: String val localPublicKey: String get() = _localPublicKey - private val _onData: (session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit + private val _onData: ((session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit)? + val isAuthorized: Boolean + get() = authorizable?.isAuthorized ?: false var authorizable: IAuthorizable? = null var remoteVersion: Int = -1 private set val remoteAddress: String - constructor(remoteAddress: String, localKeyPair: DHState, inputStream: LittleEndianDataInputStream, outputStream: LittleEndianDataOutputStream, onClose: (session: SyncSocketSession) -> Unit, onHandshakeComplete: (session: SyncSocketSession) -> Unit, onData: (session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit) { - _inputStream = inputStream - _outputStream = outputStream + private val _channels = ConcurrentHashMap() + private val _pendingChannels = ConcurrentHashMap>>() + private val _pendingConnectionInfoRequests = ConcurrentHashMap>() + private val _pendingPublishRequests = ConcurrentHashMap>() + private val _pendingDeleteRequests = ConcurrentHashMap>() + private val _pendingListKeysRequests = ConcurrentHashMap>>>() + private val _pendingGetRecordRequests = ConcurrentHashMap?>>() + private val _pendingBulkGetRecordRequests = ConcurrentHashMap>>>() + private val _pendingBulkConnectionInfoRequests = ConcurrentHashMap>>() + + @Volatile + private var _lastPongTime: Long = System.currentTimeMillis() + private val _pingInterval: Long = 5000 // 5 seconds in milliseconds + private val _disconnectTimeout: Long = 30000 // 30 seconds in milliseconds + + data class ConnectionInfo( + val port: UShort, + val name: String, + val remoteIp: String, + val ipv4Addresses: List, + val ipv6Addresses: List, + val allowLocalDirect: Boolean, + val allowRemoteDirect: Boolean, + val allowRemoteHolePunched: Boolean, + val allowRemoteRelayed: Boolean + ) + + constructor( + remoteAddress: String, + localKeyPair: DHState, + socket: Socket, + onClose: ((session: SyncSocketSession) -> Unit)? = null, + onHandshakeComplete: ((session: SyncSocketSession) -> Unit)? = null, + onData: ((session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit)? = null, + onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)? = null, + onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)? = null, + isHandshakeAllowed: ((linkType: LinkType, session: SyncSocketSession, remotePublicKey: String, pairingCode: String?, appId: UInt) -> Boolean)? = null + ) { + _socket = socket + _socket.receiveBufferSize = MAXIMUM_PACKET_SIZE_ENCRYPTED + _socket.sendBufferSize = MAXIMUM_PACKET_SIZE_ENCRYPTED + _socket.tcpNoDelay = true + _inputStream = _socket.getInputStream() + _outputStream = _socket.getOutputStream() _onClose = onClose _onHandshakeComplete = onHandshakeComplete _localKeyPair = localKeyPair _onData = onData + _onNewChannel = onNewChannel + _onChannelEstablished = onChannelEstablished + _isHandshakeAllowed = isHandshakeAllowed this.remoteAddress = remoteAddress val localPublicKey = ByteArray(localKeyPair.publicKeyLength) localKeyPair.getPublicKey(localPublicKey, 0) - _localPublicKey = java.util.Base64.getEncoder().encodeToString(localPublicKey) + _localPublicKey = Base64.getEncoder().encodeToString(localPublicKey) } - fun startAsInitiator(remotePublicKey: String) { + fun startAsInitiator(remotePublicKey: String, appId: UInt = 0u, pairingCode: String? = null) { _started = true _thread = Thread { try { - handshakeAsInitiator(remotePublicKey) - _onHandshakeComplete.invoke(this) + handshakeAsInitiator(remotePublicKey, appId, pairingCode) + _onHandshakeComplete?.invoke(this) + startPingLoop() receiveLoop() } catch (e: Throwable) { Logger.e(TAG, "Failed to run as initiator", e) @@ -80,14 +142,30 @@ class SyncSocketSession { }.apply { start() } } + fun runAsInitiator(remotePublicKey: String, appId: UInt = 0u, pairingCode: String? = null) { + _started = true + try { + handshakeAsInitiator(remotePublicKey, appId, pairingCode) + _onHandshakeComplete?.invoke(this) + startPingLoop() + receiveLoop() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to run as initiator", e) + } finally { + stop() + } + } + fun startAsResponder() { _started = true _thread = Thread { try { - handshakeAsResponder() - _onHandshakeComplete.invoke(this) - receiveLoop() - } catch(e: Throwable) { + if (handshakeAsResponder()) { + _onHandshakeComplete?.invoke(this) + startPingLoop() + receiveLoop() + } + } catch (e: Throwable) { Logger.e(TAG, "Failed to run as responder", e) } finally { stop() @@ -95,30 +173,45 @@ class SyncSocketSession { }.apply { start() } } + private fun readExact(buffer: ByteArray, offset: Int, size: Int) { + var totalBytesReceived: Int = 0 + while (totalBytesReceived < size) { + val bytesReceived = _inputStream.read(buffer, offset + totalBytesReceived, size - totalBytesReceived) + if (bytesReceived <= 0) + throw Exception("Socket disconnected") + totalBytesReceived += bytesReceived + } + } + private fun receiveLoop() { while (_started) { try { - val messageSize = _inputStream.readInt() + //Logger.v(TAG, "Waiting for message size...") + + readExact(_buffer, 0, 4) + val messageSize = ByteBuffer.wrap(_buffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int + + //Logger.v(TAG, "Read message size ${messageSize}.") + if (messageSize > MAXIMUM_PACKET_SIZE_ENCRYPTED) { throw Exception("Message size (${messageSize}) cannot exceed MAXIMUM_PACKET_SIZE ($MAXIMUM_PACKET_SIZE_ENCRYPTED)") } //Logger.i(TAG, "Receiving message (size = ${messageSize})") - var bytesRead = 0 - while (bytesRead < messageSize) { - val read = _inputStream.read(_buffer, bytesRead, messageSize - bytesRead) - if (read == -1) - throw Exception("Stream closed") - bytesRead += read - } + readExact(_buffer, 0, messageSize) + //Logger.v(TAG, "Read ${messageSize}.") + //Logger.v(TAG, "Decrypting ${messageSize} bytes.") val plen: Int = _cipherStatePair!!.receiver.decryptWithAd(null, _buffer, 0, _bufferDecrypted, 0, messageSize) //Logger.i(TAG, "Decrypted message (size = ${plen})") - handleData(_bufferDecrypted, plen) + //Logger.v(TAG, "Decrypted ${messageSize} bytes.") + handleData(_bufferDecrypted, plen, null) + //Logger.v(TAG, "Handled data ${messageSize} bytes.") } catch (e: Throwable) { - Logger.e(TAG, "Exception while receiving data", e) + Logger.e(TAG, "Exception while receiving data, closing socket session", e) + stop() break } } @@ -126,273 +219,1115 @@ class SyncSocketSession { fun stop() { _started = false - _onClose(this) - _inputStream.close() - _outputStream.close() + _pendingConnectionInfoRequests.forEach { it.value.cancel() } + _pendingConnectionInfoRequests.clear() + _pendingPublishRequests.forEach { it.value.cancel() } + _pendingPublishRequests.clear() + _pendingDeleteRequests.forEach { it.value.cancel() } + _pendingDeleteRequests.clear() + _pendingListKeysRequests.forEach { it.value.cancel() } + _pendingListKeysRequests.clear() + _pendingGetRecordRequests.forEach { it.value.cancel() } + _pendingGetRecordRequests.clear() + _pendingBulkGetRecordRequests.forEach { it.value.cancel() } + _pendingBulkGetRecordRequests.clear() + _pendingBulkConnectionInfoRequests.forEach { it.value.cancel() } + _pendingBulkConnectionInfoRequests.clear() + _pendingChannels.forEach { it.value.first.close(); it.value.second.cancel() } + _pendingChannels.clear() + synchronized(_syncStreams) { + _syncStreams.clear() + } + _channels.values.forEach { it.close() } + _channels.clear() + _onClose?.invoke(this) + _socket.close() _thread = null + _cipherStatePair?.sender?.destroy() + _cipherStatePair?.receiver?.destroy() Logger.i(TAG, "Session closed") } - private fun handshakeAsInitiator(remotePublicKey: String) { + private fun handshakeAsInitiator(remotePublicKey: String, appId: UInt, pairingCode: String?) { performVersionCheck() - val initiator = HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR) + val initiator = HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR) initiator.localKeyPair.copyFrom(_localKeyPair) + initiator.remotePublicKey.setPublicKey(Base64.getDecoder().decode(remotePublicKey), 0) + initiator.start() - initiator.remotePublicKey.setPublicKey(java.util.Base64.getDecoder().decode(remotePublicKey), 0) - _cipherStatePair = handshake(initiator) - - _remotePublicKey = initiator.remotePublicKey.let { - val pkey = ByteArray(it.publicKeyLength) - it.getPublicKey(pkey, 0) - return@let java.util.Base64.getEncoder().encodeToString(pkey) + val pairingMessage: ByteArray + val pairingMessageLength: Int + if (pairingCode != null) { + val pairingHandshake = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR) + pairingHandshake.remotePublicKey.setPublicKey(Base64.getDecoder().decode(remotePublicKey), 0) + pairingHandshake.start() + val pairingCodeBytes = pairingCode.toByteArray(Charsets.UTF_8) + val pairingBuffer = ByteArray(512) + pairingMessageLength = pairingHandshake.writeMessage(pairingBuffer, 0, pairingCodeBytes, 0, pairingCodeBytes.size) + pairingMessage = pairingBuffer.copyOf(pairingMessageLength) + } else { + pairingMessage = ByteArray(0) + pairingMessageLength = 0 } + + val mainBuffer = ByteArray(512) + val mainLength = initiator.writeMessage(mainBuffer, 0, null, 0, 0) + + val messageSize = 4 + 4 + pairingMessageLength + mainLength + val messageData = ByteBuffer.allocate(4 + messageSize).order(ByteOrder.LITTLE_ENDIAN) + messageData.putInt(messageSize) + messageData.putInt(appId.toInt()) + messageData.putInt(pairingMessageLength) + if (pairingMessageLength > 0) messageData.put(pairingMessage) + messageData.put(mainBuffer, 0, mainLength) + val messageDataArray = messageData.array() + _outputStream.write(messageDataArray, 0, 4 + messageSize) + + readExact(_buffer, 0, 4) + val responseSize = ByteBuffer.wrap(_buffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int + if (responseSize > MAXIMUM_PACKET_SIZE_ENCRYPTED) { + throw Exception("Message size (${messageSize}) cannot exceed MAXIMUM_PACKET_SIZE ($MAXIMUM_PACKET_SIZE_ENCRYPTED)") + } + + val responseMessage = ByteArray(responseSize) + readExact(responseMessage, 0, responseSize) + + val plaintext = ByteArray(512) // Buffer for any payload (none expected here) + initiator.readMessage(responseMessage, 0, responseSize, plaintext, 0) + + _cipherStatePair = initiator.split() + val remoteKeyBytes = ByteArray(initiator.remotePublicKey.publicKeyLength) + initiator.remotePublicKey.getPublicKey(remoteKeyBytes, 0) + _remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes).base64ToByteArray().toBase64() } - private fun handshakeAsResponder() { + private fun handshakeAsResponder(): Boolean { performVersionCheck() - val responder = HandshakeState(StateSync.protocolName, HandshakeState.RESPONDER) + val responder = HandshakeState(SyncService.protocolName, HandshakeState.RESPONDER) responder.localKeyPair.copyFrom(_localKeyPair) - _cipherStatePair = handshake(responder) + responder.start() - _remotePublicKey = responder.remotePublicKey.let { - val pkey = ByteArray(it.publicKeyLength) - it.getPublicKey(pkey, 0) - return@let java.util.Base64.getEncoder().encodeToString(pkey) + readExact(_buffer, 0, 4) + val messageSize = ByteBuffer.wrap(_buffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int + if (messageSize > MAXIMUM_PACKET_SIZE_ENCRYPTED) { + throw Exception("Message size (${messageSize}) cannot exceed MAXIMUM_PACKET_SIZE ($MAXIMUM_PACKET_SIZE_ENCRYPTED)") } + + val message = ByteArray(messageSize) + readExact(message, 0, messageSize) + + val messageBuffer = ByteBuffer.wrap(message).order(ByteOrder.LITTLE_ENDIAN) + val appId = messageBuffer.int.toUInt() + val pairingMessageLength = messageBuffer.int + val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { messageBuffer.get(it) } else byteArrayOf() + val mainLength = messageBuffer.remaining() + val mainMessage = ByteArray(mainLength).also { messageBuffer.get(it) } + + var pairingCode: String? = null + if (pairingMessageLength > 0) { + val pairingHandshake = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.RESPONDER) + pairingHandshake.localKeyPair.copyFrom(_localKeyPair) + pairingHandshake.start() + val pairingPlaintext = ByteArray(512) + val plaintextLength = pairingHandshake.readMessage(pairingMessage, 0, pairingMessageLength, pairingPlaintext, 0) + pairingCode = String(pairingPlaintext, 0, plaintextLength, Charsets.UTF_8) + } + + val plaintext = ByteArray(512) + responder.readMessage(mainMessage, 0, mainLength, plaintext, 0) + val remoteKeyBytes = ByteArray(responder.remotePublicKey.publicKeyLength) + responder.remotePublicKey.getPublicKey(remoteKeyBytes, 0) + val remotePublicKey = remoteKeyBytes.toBase64() + + val isAllowedToConnect = remotePublicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Direct, this, remotePublicKey, pairingCode, appId) ?: true) + if (!isAllowedToConnect) { + stop() + return false + } + + val responseBuffer = ByteArray(4 + 512) + val responseLength = responder.writeMessage(responseBuffer, 4, null, 0, 0) + ByteBuffer.wrap(responseBuffer).order(ByteOrder.LITTLE_ENDIAN).putInt(responseLength) + _outputStream.write(responseBuffer, 0, 4 + responseLength) + + _cipherStatePair = responder.split() + _remotePublicKey = remotePublicKey.base64ToByteArray().toBase64() + return true } private fun performVersionCheck() { - val CURRENT_VERSION = 3 - val MINIMUM_VERSION = 2 - _outputStream.writeInt(CURRENT_VERSION) - remoteVersion = _inputStream.readInt() + val CURRENT_VERSION = 5 + val MINIMUM_VERSION = 4 + + val versionBytes = ByteArray(4) + ByteBuffer.wrap(versionBytes).order(ByteOrder.LITTLE_ENDIAN).putInt(CURRENT_VERSION) + _outputStream.write(versionBytes, 0, 4) + + readExact(versionBytes, 0, 4) + remoteVersion = ByteBuffer.wrap(versionBytes, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int Logger.i(TAG, "performVersionCheck (version = $remoteVersion)") if (remoteVersion < MINIMUM_VERSION) throw Exception("Invalid version") } - private fun handshake(handshakeState: HandshakeState): CipherStatePair { - handshakeState.start() + fun generateStreamId(): Int = synchronized(_streamIdGeneratorLock) { _streamIdGenerator++ } + private fun generateRequestId(): Int = synchronized(_requestIdGeneratorLock) { _requestIdGenerator++ } - val message = ByteArray(8192) - val plaintext = ByteArray(8192) + fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer, ce: ContentEncoding? = null) { + ensureNotMainThread() - while (_started) { - when (handshakeState.action) { - HandshakeState.READ_MESSAGE -> { - val messageSize = _inputStream.readInt() - Logger.i(TAG, "Handshake read message (size = ${messageSize})") + Logger.v(TAG, "send (opcode: ${opcode}, subOpcode: ${subOpcode}, data.remaining(): ${data.remaining()})") - var bytesRead = 0 - while (bytesRead < messageSize) { - val read = _inputStream.read(message, bytesRead, messageSize - bytesRead) - if (read == -1) - throw Exception("Stream closed") - bytesRead += read - } - - handshakeState.readMessage(message, 0, messageSize, plaintext, 0) + var contentEncoding: ContentEncoding? = ce + var processedData = data + if (contentEncoding == ContentEncoding.Gzip) { + val isGzipSupported = opcode == Opcode.DATA.value + if (isGzipSupported) { + val compressedStream = ByteArrayOutputStream() + GZIPOutputStream(compressedStream).use { gzipStream -> + gzipStream.write(data.array(), data.position(), data.remaining()) + gzipStream.finish() } - HandshakeState.WRITE_MESSAGE -> { - val messageSize = handshakeState.writeMessage(message, 0, null, 0, 0) - Logger.i(TAG, "Handshake wrote message (size = ${messageSize})") - _outputStream.writeInt(messageSize) - _outputStream.write(message, 0, messageSize) - } - HandshakeState.SPLIT -> { - //Logger.i(TAG, "Handshake split") - return handshakeState.split() - } - else -> throw Exception("Unexpected state (handshakeState.action = ${handshakeState.action})") + processedData = ByteBuffer.wrap(compressedStream.toByteArray()) + } else { + Logger.w(TAG, "Gzip requested but not supported on this (opcode = ${opcode}, subOpcode = ${subOpcode}), falling back.") + contentEncoding = ContentEncoding.Raw } } - throw Exception("Handshake finished without completing") - } - - fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { - ensureNotMainThread() - - if (data.remaining() + HEADER_SIZE > MAXIMUM_PACKET_SIZE) { + if (processedData.remaining() + HEADER_SIZE > MAXIMUM_PACKET_SIZE) { val segmentSize = MAXIMUM_PACKET_SIZE - HEADER_SIZE val segmentData = ByteArray(segmentSize) var sendOffset = 0 - val id = synchronized(_streamIdGeneratorLock) { - _streamIdGenerator + 1 - } + val id = generateStreamId() - while (sendOffset < data.remaining()) { - val bytesRemaining = data.remaining() - sendOffset + while (sendOffset < processedData.remaining()) { + val bytesRemaining = processedData.remaining() - sendOffset var bytesToSend: Int var segmentPacketSize: Int - val segmentOpcode: UByte + val streamOp: StreamOpcode if (sendOffset == 0) { - segmentOpcode = Opcode.STREAM_START.value - bytesToSend = segmentSize - 4 - 4 - 1 - 1 - segmentPacketSize = bytesToSend + 4 + 4 + 1 + 1 + streamOp = StreamOpcode.START + bytesToSend = segmentSize - 4 - HEADER_SIZE + segmentPacketSize = bytesToSend + 4 + HEADER_SIZE } else { bytesToSend = minOf(segmentSize - 4 - 4, bytesRemaining) - segmentOpcode = if (bytesToSend >= bytesRemaining) Opcode.STREAM_END.value else Opcode.STREAM_DATA.value + streamOp = if (bytesToSend >= bytesRemaining) StreamOpcode.END else StreamOpcode.DATA segmentPacketSize = bytesToSend + 4 + 4 } ByteBuffer.wrap(segmentData).order(ByteOrder.LITTLE_ENDIAN).apply { putInt(id) - putInt(if (segmentOpcode == Opcode.STREAM_START.value) data.remaining() else sendOffset) - if (segmentOpcode == Opcode.STREAM_START.value) { + putInt(if (streamOp == StreamOpcode.START) processedData.remaining() else sendOffset) + if (streamOp == StreamOpcode.START) { put(opcode.toByte()) put(subOpcode.toByte()) + put(contentEncoding?.value?.toByte() ?: ContentEncoding.Raw.value.toByte()) } - put(data.array(), data.position() + sendOffset, bytesToSend) + put(processedData.array(), processedData.position() + sendOffset, bytesToSend) } - send(segmentOpcode, 0u, ByteBuffer.wrap(segmentData, 0, segmentPacketSize)) + send(Opcode.STREAM.value, streamOp.value, ByteBuffer.wrap(segmentData, 0, segmentPacketSize)) sendOffset += bytesToSend } } else { synchronized(_sendLockObject) { ByteBuffer.wrap(_sendBuffer).order(ByteOrder.LITTLE_ENDIAN).apply { - putInt(data.remaining() + 2) + putInt(processedData.remaining() + HEADER_SIZE - 4) put(opcode.toByte()) put(subOpcode.toByte()) - put(data.array(), data.position(), data.remaining()) + put(contentEncoding?.value?.toByte() ?: ContentEncoding.Raw.value.toByte()) + put(processedData.array(), processedData.position(), processedData.remaining()) } - //Logger.i(TAG, "Encrypting message (size = ${data.size + HEADER_SIZE})") - val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 0, data.remaining() + HEADER_SIZE) - //Logger.i(TAG, "Sending encrypted message (size = ${len})") - _outputStream.writeInt(len) - _outputStream.write(_sendBufferEncrypted, 0, len) + val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 4, processedData.remaining() + HEADER_SIZE) + val sendDuration = measureTimeMillis { + ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len) + _outputStream.write(_sendBufferEncrypted, 0, 4 + len) + } + Logger.v(TAG, "_outputStream.write (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.remaining(): ${processedData.remaining()}, sendDuration: ${sendDuration})") } } } + @OptIn(ExperimentalUnsignedTypes::class) fun send(opcode: UByte, subOpcode: UByte = 0u) { ensureNotMainThread() synchronized(_sendLockObject) { - ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(2) + ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(HEADER_SIZE - 4) _sendBuffer.asUByteArray()[4] = opcode _sendBuffer.asUByteArray()[5] = subOpcode + _sendBuffer.asUByteArray()[6] = ContentEncoding.Raw.value //Logger.i(TAG, "Encrypting message (opcode = ${opcode}, subOpcode = ${subOpcode}, size = ${HEADER_SIZE})") - val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 0, HEADER_SIZE) + val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 4, HEADER_SIZE) //Logger.i(TAG, "Sending encrypted message (size = ${len})") - _outputStream.writeInt(len) - _outputStream.write(_sendBufferEncrypted, 0, len) + ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len) + _outputStream.write(_sendBufferEncrypted, 0, 4 + len) } } - private fun handleData(data: ByteArray, length: Int) { - if (length < HEADER_SIZE) - throw Exception("Packet must be at least 6 bytes (header size)") + private fun handleData(data: ByteArray, length: Int, sourceChannel: ChannelRelayed?) { + return handleData(ByteBuffer.wrap(data, 0, length).order(ByteOrder.LITTLE_ENDIAN), sourceChannel) + } - val size = ByteBuffer.wrap(data, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int + private fun handleData(data: ByteBuffer, sourceChannel: ChannelRelayed?) { + val length = data.remaining() + if (length < HEADER_SIZE) + throw Exception("Packet must be at least ${HEADER_SIZE} bytes (header size)") + + val size = data.int if (size != length - 4) throw Exception("Incomplete packet received") - val opcode = data.asUByteArray()[4] - val subOpcode = data.asUByteArray()[5] - val packetData = ByteBuffer.wrap(data, HEADER_SIZE, size - 2) - handlePacket(opcode, subOpcode, packetData.order(ByteOrder.LITTLE_ENDIAN)) + val opcode = data.get().toUByte() + val subOpcode = data.get().toUByte() + val contentEncoding = data.get().toUByte() + + //Logger.v(TAG, "handleData (opcode: ${opcode}, subOpcode: ${subOpcode}, data.size: ${data.remaining()}, sourceChannel.connectionId: ${sourceChannel?.connectionId})") + handlePacket(opcode, subOpcode, data, contentEncoding, sourceChannel) } - private fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { + private fun handleRequest(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { + when (subOpcode) { + RequestOpcode.TRANSPORT_RELAYED.value -> { + Logger.i(TAG, "Received request for a relayed transport") + if (data.remaining() < 52) { + Logger.e(TAG, "HandleRequestTransport: Packet too short") + return + } + val remoteVersion = data.int + val connectionId = data.long + val requestId = data.int + val appId = data.int.toUInt() + val publicKeyBytes = ByteArray(32).also { data.get(it) } + val pairingMessageLength = data.int + if (pairingMessageLength > 128) throw IllegalArgumentException("Pairing message length ($pairingMessageLength) exceeds maximum (128) (app id: $appId)") + val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { data.get(it) } else ByteArray(0) + val channelMessageLength = data.int + if (data.remaining() != channelMessageLength) { + Logger.e(TAG, "Invalid packet size. Expected ${52 + pairingMessageLength + 4 + channelMessageLength}, got ${data.capacity()} (app id: $appId)") + return + } + val channelHandshakeMessage = ByteArray(channelMessageLength).also { data.get(it) } + val publicKey = Base64.getEncoder().encodeToString(publicKeyBytes) + val pairingCode = if (pairingMessageLength > 0) { + val pairingProtocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.RESPONDER).apply { + localKeyPair.copyFrom(_localKeyPair) + start() + } + val plaintext = ByteArray(1024) + val length = pairingProtocol.readMessage(pairingMessage, 0, pairingMessageLength, plaintext, 0) + String(plaintext, 0, length, Charsets.UTF_8) + } else null + val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode, appId) ?: true) + if (!isAllowed) { + val rp = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN) + rp.putInt(7) // Status code for not allowed + rp.putLong(connectionId) + rp.putInt(requestId) + rp.rewind() + send(Opcode.RESPONSE.value, ResponseOpcode.TRANSPORT.value, rp) + return + } + val channel = ChannelRelayed(this, _localKeyPair, publicKey, false) + channel.connectionId = connectionId + _onNewChannel?.invoke(this, channel) + _channels[connectionId] = channel + channel.sendResponseTransport(remoteVersion, requestId, channelHandshakeMessage) + _onChannelEstablished?.invoke(this, channel, true) + } + else -> Logger.w(TAG, "Unhandled request opcode: $subOpcode") + } + } + + private fun handleResponse(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { + if (data.remaining() < 8) { + Logger.e(TAG, "Response packet too short") + return + } + val requestId = data.int + val statusCode = data.int + when (subOpcode) { + ResponseOpcode.CONNECTION_INFO.value -> { + _pendingConnectionInfoRequests.remove(requestId)?.let { tcs -> + if (statusCode == 0) { + try { + val connectionInfo = parseConnectionInfo(data) + tcs.complete(connectionInfo) + } catch (e: Exception) { + tcs.completeExceptionally(e) + } + } else { + tcs.complete(null) + } + } ?: Logger.e(TAG, "No pending request for requestId $requestId") + } + ResponseOpcode.TRANSPORT_RELAYED.value -> { + if (statusCode == 0) { + if (data.remaining() < 16) { + Logger.e(TAG, "RESPONSE_TRANSPORT packet too short") + return + } + val remoteVersion = data.int + val connectionId = data.long + val messageLength = data.int + if (data.remaining() != messageLength) { + Logger.e(TAG, "Invalid RESPONSE_TRANSPORT packet size. Expected ${16 + messageLength}, got ${data.remaining() + 16}") + return + } + val handshakeMessage = ByteArray(messageLength).also { data.get(it) } + _pendingChannels.remove(requestId)?.let { (channel, tcs) -> + channel.handleTransportRelayed(remoteVersion, connectionId, handshakeMessage) + _channels[connectionId] = channel + tcs.complete(channel) + _onChannelEstablished?.invoke(this, channel, false) + } ?: Logger.e(TAG, "No pending channel for requestId $requestId") + } else { + _pendingChannels.remove(requestId)?.let { (channel, tcs) -> + channel.close() + tcs.completeExceptionally(Exception("Relayed transport request $requestId failed with code $statusCode")) + } + } + } + ResponseOpcode.PUBLISH_RECORD.value, ResponseOpcode.BULK_PUBLISH_RECORD.value -> { + _pendingPublishRequests.remove(requestId)?.complete(statusCode == 0) + ?: Logger.e(TAG, "No pending publish request for requestId $requestId") + } + ResponseOpcode.DELETE_RECORD.value, ResponseOpcode.BULK_DELETE_RECORD.value -> { + _pendingDeleteRequests.remove(requestId)?.complete(statusCode == 0) + ?: Logger.e(TAG, "No pending delete request for requestId $requestId") + } + ResponseOpcode.LIST_RECORD_KEYS.value -> { + _pendingListKeysRequests.remove(requestId)?.let { tcs -> + if (statusCode == 0) { + try { + val keyCount = data.int + val keys = mutableListOf>() + repeat(keyCount) { + val keyLength = data.get().toInt() + val key = ByteArray(keyLength).also { data.get(it) }.toString(Charsets.UTF_8) + val timestamp = data.long + keys.add(key to timestamp) + } + tcs.complete(keys) + } catch (e: Exception) { + tcs.completeExceptionally(e) + } + } else { + tcs.completeExceptionally(Exception("Error listing keys: status code $statusCode")) + } + } ?: Logger.e(TAG, "No pending list keys request for requestId $requestId") + } + ResponseOpcode.GET_RECORD.value -> { + _pendingGetRecordRequests.remove(requestId)?.let { tcs -> + if (statusCode == 0) { + try { + val blobLength = data.int + val encryptedBlob = ByteArray(blobLength).also { data.get(it) } + val timestamp = data.long + val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.RESPONDER).apply { + localKeyPair.copyFrom(_localKeyPair) + start() + } + val handshakeMessage = encryptedBlob.copyOf(48) + val plaintext = ByteArray(0) + protocol.readMessage(handshakeMessage, 0, 48, plaintext, 0) + val transportPair = protocol.split() + var blobOffset = 48 + val chunks = mutableListOf() + while (blobOffset + 4 <= encryptedBlob.size) { + val chunkLength = ByteBuffer.wrap(encryptedBlob, blobOffset, 4).order(ByteOrder.LITTLE_ENDIAN).int + blobOffset += 4 + val encryptedChunk = encryptedBlob.copyOfRange(blobOffset, blobOffset + chunkLength) + val decryptedChunk = ByteArray(chunkLength - 16) + transportPair.receiver.decryptWithAd(null, encryptedChunk, 0, decryptedChunk, 0, encryptedChunk.size) + chunks.add(decryptedChunk) + blobOffset += chunkLength + } + val dataResult = chunks.reduce { acc, bytes -> acc + bytes } + tcs.complete(dataResult to timestamp) + } catch (e: Exception) { + tcs.completeExceptionally(e) + } + } else if (statusCode == 2) { + tcs.complete(null) + } else { + tcs.completeExceptionally(Exception("Error getting record: statusCode $statusCode")) + } + } + } + ResponseOpcode.BULK_GET_RECORD.value -> { + _pendingBulkGetRecordRequests.remove(requestId)?.let { tcs -> + if (statusCode == 0) { + try { + val recordCount = data.get().toInt() + val records = mutableMapOf>() + repeat(recordCount) { + val publisherBytes = ByteArray(32).also { data.get(it) } + val publisher = Base64.getEncoder().encodeToString(publisherBytes) + val blobLength = data.int + val encryptedBlob = ByteArray(blobLength).also { data.get(it) } + val timestamp = data.long + val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.RESPONDER).apply { + localKeyPair.copyFrom(_localKeyPair) + start() + } + val handshakeMessage = encryptedBlob.copyOf(48) + val plaintext = ByteArray(0) + protocol.readMessage(handshakeMessage, 0, 48, plaintext, 0) + val transportPair = protocol.split() + var blobOffset = 48 + val chunks = mutableListOf() + while (blobOffset + 4 <= encryptedBlob.size) { + val chunkLength = ByteBuffer.wrap(encryptedBlob, blobOffset, 4).order(ByteOrder.LITTLE_ENDIAN).int + blobOffset += 4 + val encryptedChunk = encryptedBlob.copyOfRange(blobOffset, blobOffset + chunkLength) + val decryptedChunk = ByteArray(chunkLength - 16) + transportPair.receiver.decryptWithAd(null, encryptedChunk, 0, decryptedChunk, 0, encryptedChunk.size) + chunks.add(decryptedChunk) + blobOffset += chunkLength + } + val dataResult = chunks.reduce { acc, bytes -> acc + bytes } + records[publisher] = dataResult to timestamp + } + tcs.complete(records) + } catch (e: Exception) { + tcs.completeExceptionally(e) + } + } else { + tcs.completeExceptionally(Exception("Error getting bulk records: statusCode $statusCode")) + } + } + } + ResponseOpcode.BULK_CONNECTION_INFO.value -> { + _pendingBulkConnectionInfoRequests.remove(requestId)?.let { tcs -> + try { + val numResponses = data.get().toInt() + val result = mutableMapOf() + repeat(numResponses) { + val publicKey = Base64.getEncoder().encodeToString(ByteArray(32).also { data.get(it) }) + val status = data.get().toInt() + if (status == 0) { + val infoSize = data.int + val infoData = ByteArray(infoSize).also { data.get(it) } + result[publicKey] = parseConnectionInfo(ByteBuffer.wrap(infoData).order(ByteOrder.LITTLE_ENDIAN)) + } + } + tcs.complete(result) + } catch (e: Exception) { + tcs.completeExceptionally(e) + } + } ?: Logger.e(TAG, "No pending bulk request for requestId $requestId") + } + } + } + + private fun parseConnectionInfo(data: ByteBuffer): ConnectionInfo { + val ipSize = data.get().toInt() + val remoteIpBytes = ByteArray(ipSize).also { data.get(it) } + val remoteIp = remoteIpBytes.joinToString(".") { it.toUByte().toString() } + val handshakeMessage = ByteArray(48).also { data.get(it) } + val ciphertext = ByteArray(data.remaining()).also { data.get(it) } + val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.RESPONDER).apply { + localKeyPair.copyFrom(_localKeyPair) + start() + } + val plaintext = ByteArray(0) + protocol.readMessage(handshakeMessage, 0, 48, plaintext, 0) + val transportPair = protocol.split() + val decryptedData = ByteArray(ciphertext.size - 16) + transportPair.receiver.decryptWithAd(null, ciphertext, 0, decryptedData, 0, ciphertext.size) + val info = ByteBuffer.wrap(decryptedData).order(ByteOrder.LITTLE_ENDIAN) + val port = info.short.toUShort() + val nameLength = info.get().toInt() + val name = ByteArray(nameLength).also { info.get(it) }.toString(Charsets.UTF_8) + val ipv4Count = info.get().toInt() + val ipv4Addresses = List(ipv4Count) { ByteArray(4).also { info.get(it) }.joinToString(".") { it.toUByte().toString() } } + val ipv6Count = info.get().toInt() + val ipv6Addresses = List(ipv6Count) { ByteArray(16).also { info.get(it) }.joinToString(":") { it.toUByte().toString(16).padStart(2, '0') } } + val allowLocalDirect = info.get() != 0.toByte() + val allowRemoteDirect = info.get() != 0.toByte() + val allowRemoteHolePunched = info.get() != 0.toByte() + val allowRemoteRelayed = info.get() != 0.toByte() + return ConnectionInfo(port, name, remoteIp, ipv4Addresses, ipv6Addresses, allowLocalDirect, allowRemoteDirect, allowRemoteHolePunched, allowRemoteRelayed) + } + + private fun handleNotify(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { + when (subOpcode) { + NotifyOpcode.AUTHORIZED.value, NotifyOpcode.UNAUTHORIZED.value -> { + if (sourceChannel != null) + sourceChannel.invokeDataHandler(Opcode.NOTIFY.value, subOpcode, data) + else + _onData?.invoke(this, Opcode.NOTIFY.value, subOpcode, data) + } + NotifyOpcode.CONNECTION_INFO.value -> { /* Handle connection info if needed */ } + } + } + + fun sendRelayError(connectionId: Long, errorCode: SyncErrorCode) { + val packet = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN) + packet.putLong(connectionId) + packet.putInt(errorCode.value) + packet.rewind() + send(Opcode.RELAY.value, RelayOpcode.RELAY_ERROR.value, packet) + } + + private fun handleRelay(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { + when (subOpcode) { + RelayOpcode.RELAYED_DATA.value -> { + if (data.remaining() < 8) { + Logger.e(TAG, "RELAYED_DATA packet too short") + return + } + val connectionId = data.long + val channel = _channels[connectionId] ?: run { + Logger.e(TAG, "No channel found for connectionId $connectionId") + return + } + val decryptedPayload = channel.decrypt(data) + try { + handleData(decryptedPayload, channel) + } catch (e: Exception) { + Logger.e(TAG, "Exception while handling relayed data", e) + channel.sendError(SyncErrorCode.ConnectionClosed) + channel.close() + _channels.remove(connectionId) + } + } + RelayOpcode.RELAYED_ERROR.value -> { + if (data.remaining() < 8) { + Logger.e(TAG, "RELAYED_ERROR packet too short") + return + } + val connectionId = data.long + val channel = _channels[connectionId] ?: run { + Logger.e(TAG, "No channel found for connectionId $connectionId") + sendRelayError(connectionId, SyncErrorCode.NotFound) + return + } + val decryptedPayload = channel.decrypt(data) + val errorCode = SyncErrorCode.entries.find { it.value == decryptedPayload.int } ?: SyncErrorCode.ConnectionClosed + Logger.e(TAG, "Received relayed error (errorCode = $errorCode) on connectionId $connectionId, closing") + channel.close() + _channels.remove(connectionId) + } + RelayOpcode.RELAY_ERROR.value -> { + if (data.remaining() < 12) { + Logger.e(TAG, "RELAY_ERROR packet too short") + return + } + val connectionId = data.long + val errorCode = SyncErrorCode.entries.find { it.value == data.int } ?: SyncErrorCode.ConnectionClosed + val channel = _channels[connectionId] ?: run { + Logger.e(TAG, "Received error code $errorCode for non-existent channel with connectionId $connectionId") + return + } + Logger.i(TAG, "Received relay error (errorCode = $errorCode) on connectionId $connectionId, closing") + channel.close() + _channels.remove(connectionId) + _pendingChannels.entries.find { it.value.first == channel }?.let { + _pendingChannels.remove(it.key)?.second?.cancel() + } + } + } + } + + private fun startPingLoop() { + if (remoteVersion < 5) return + + _lastPongTime = System.currentTimeMillis() + + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + while (_started) { + delay(_pingInterval) + + if (System.currentTimeMillis() - _lastPongTime > _disconnectTimeout) { + Logger.e(TAG, "Session timed out waiting for PONG; closing.") + stop() + break + } + send(Opcode.PING.value) + } + } catch (e: Exception) { + Logger.e(TAG, "Ping loop failed", e) + stop() + } + } + } + + private fun handlePacket(opcode: UByte, subOpcode: UByte, d: ByteBuffer, contentEncoding: UByte, sourceChannel: ChannelRelayed?) { Logger.i(TAG, "Handle packet (opcode = ${opcode}, subOpcode = ${subOpcode})") + var data = d + if (contentEncoding == ContentEncoding.Gzip.value) { + val isGzipSupported = opcode == Opcode.DATA.value + if (!isGzipSupported) + throw Exception("Failed to handle packet, gzip is not supported for this opcode (opcode = ${opcode}, subOpcode = ${subOpcode}, data.length = ${data.remaining()}).") + + val compressedStream = ByteArrayInputStream(data.array(), data.position(), data.remaining()) + val outputStream = ByteArrayOutputStream() + GZIPInputStream(compressedStream).use { gzipStream -> + val buffer = ByteArray(8192) // 8KB buffer + var bytesRead: Int + while (gzipStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + } + } + data = ByteBuffer.wrap(outputStream.toByteArray()) + } + when (opcode) { Opcode.PING.value -> { - send(Opcode.PONG.value) + if (sourceChannel != null) + sourceChannel.send(Opcode.PONG.value) + else + send(Opcode.PONG.value) //Logger.i(TAG, "Received ping, sent pong") return } Opcode.PONG.value -> { - //Logger.i(TAG, "Received pong") + if (sourceChannel != null) { + sourceChannel.invokeDataHandler(opcode, subOpcode, data) + } else { + _lastPongTime = System.currentTimeMillis() + } + Logger.v(TAG, "Received pong") return } - Opcode.NOTIFY_AUTHORIZED.value, - Opcode.NOTIFY_UNAUTHORIZED.value -> { - _onData.invoke(this, opcode, subOpcode, data) + Opcode.REQUEST.value -> { + handleRequest(subOpcode, data, sourceChannel) return } - } - - if (authorizable?.isAuthorized != true) { - return - } - - when (opcode) { - Opcode.STREAM_START.value -> { - val id = data.int - val expectedSize = data.int - val op = data.get().toUByte() - val subOp = data.get().toUByte() - - val syncStream = SyncStream(expectedSize, op, subOp) - if (data.remaining() > 0) { - syncStream.add(data.array(), data.position(), data.remaining()) - } - - synchronized(_syncStreams) { - _syncStreams[id] = syncStream - } + Opcode.RESPONSE.value -> { + handleResponse(subOpcode, data, sourceChannel) + return } - Opcode.STREAM_DATA.value -> { - val id = data.int - val expectedOffset = data.int - - val syncStream = synchronized(_syncStreams) { - _syncStreams[id] ?: throw Exception("Received data for sync stream that does not exist") - } - - if (expectedOffset != syncStream.bytesReceived) { - throw Exception("Expected offset does not match the amount of received bytes") - } - - if (data.remaining() > 0) { - syncStream.add(data.array(), data.position(), data.remaining()) - } + Opcode.NOTIFY.value -> { + handleNotify(subOpcode, data, sourceChannel) + return } - Opcode.STREAM_END.value -> { - val id = data.int - val expectedOffset = data.int - - val syncStream = synchronized(_syncStreams) { - _syncStreams.remove(id) ?: throw Exception("Received data for sync stream that does not exist") - } - - if (expectedOffset != syncStream.bytesReceived) { - throw Exception("Expected offset does not match the amount of received bytes") - } - - if (data.remaining() > 0) { - syncStream.add(data.array(), data.position(), data.remaining()) - } - - if (!syncStream.isComplete) { - throw Exception("After sync stream end, the stream must be complete") - } - - handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }) + Opcode.RELAY.value -> { + handleRelay(subOpcode, data, sourceChannel) + return } - Opcode.DATA.value -> { - _onData.invoke(this, opcode, subOpcode, data) - } - else -> { - Logger.w(TAG, "Unknown opcode received (opcode = ${opcode}, subOpcode = ${subOpcode})") + else -> if (isAuthorized) when (opcode) { + Opcode.STREAM.value -> when (subOpcode) + { + StreamOpcode.START.value -> { + val id = data.int + val expectedSize = data.int + val op = data.get().toUByte() + val subOp = data.get().toUByte() + val ce = data.get().toUByte() + + val syncStream = SyncStream(expectedSize, op, subOp, ce) + if (data.remaining() > 0) { + syncStream.add(data.array(), data.position(), data.remaining()) + } + + synchronized(_syncStreams) { + _syncStreams[id] = syncStream + } + } + StreamOpcode.DATA.value -> { + val id = data.int + val expectedOffset = data.int + + val syncStream = synchronized(_syncStreams) { + _syncStreams[id] ?: throw Exception("Received data for sync stream that does not exist") + } + + if (expectedOffset != syncStream.bytesReceived) { + throw Exception("Expected offset does not match the amount of received bytes") + } + + if (data.remaining() > 0) { + syncStream.add(data.array(), data.position(), data.remaining()) + } + } + StreamOpcode.END.value -> { + val id = data.int + val expectedOffset = data.int + + val syncStream = synchronized(_syncStreams) { + _syncStreams.remove(id) ?: throw Exception("Received data for sync stream that does not exist") + } + + if (expectedOffset != syncStream.bytesReceived) { + throw Exception("Expected offset does not match the amount of received bytes") + } + + if (data.remaining() > 0) { + syncStream.add(data.array(), data.position(), data.remaining()) + } + + if (!syncStream.isComplete) { + throw Exception("After sync stream end, the stream must be complete") + } + + handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }, syncStream.contentEncoding, sourceChannel) + } + } + Opcode.DATA.value -> { + if (sourceChannel != null) + sourceChannel.invokeDataHandler(opcode, subOpcode, data) + else + _onData?.invoke(this, opcode, subOpcode, data) + } + else -> { + Logger.w(TAG, "Unknown opcode received (opcode = ${opcode}, subOpcode = ${subOpcode})") + } } } } + suspend fun requestConnectionInfo(publicKey: String): ConnectionInfo? { + val requestId = generateRequestId() + val deferred = CompletableDeferred() + _pendingConnectionInfoRequests[requestId] = deferred + try { + val publicKeyBytes = Base64.getDecoder().decode(publicKey) + if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes") + val packet = ByteBuffer.allocate(4 + 32).order(ByteOrder.LITTLE_ENDIAN) + packet.putInt(requestId) + packet.put(publicKeyBytes) + packet.rewind() + send(Opcode.REQUEST.value, RequestOpcode.CONNECTION_INFO.value, packet) + } catch (e: Exception) { + _pendingConnectionInfoRequests.remove(requestId)?.completeExceptionally(e) + throw e + } + return deferred.await() + } + + suspend fun requestBulkConnectionInfo(publicKeys: Array): Map { + val requestId = generateRequestId() + val deferred = CompletableDeferred>() + _pendingBulkConnectionInfoRequests[requestId] = deferred + try { + val packet = ByteBuffer.allocate(4 + 1 + publicKeys.size * 32).order(ByteOrder.LITTLE_ENDIAN) + packet.putInt(requestId) + packet.put(publicKeys.size.toByte()) + for (pk in publicKeys) { + val pkBytes = Base64.getDecoder().decode(pk) + if (pkBytes.size != 32) throw IllegalArgumentException("Invalid public key length for $pk") + packet.put(pkBytes) + } + packet.rewind() + send(Opcode.REQUEST.value, RequestOpcode.BULK_CONNECTION_INFO.value, packet) + } catch (e: Exception) { + _pendingBulkConnectionInfoRequests.remove(requestId)?.completeExceptionally(e) + throw e + } + return deferred.await() + } + + suspend fun startRelayedChannel(publicKey: String, appId: UInt = 0u, pairingCode: String? = null): ChannelRelayed? { + val requestId = generateRequestId() + val deferred = CompletableDeferred() + val channel = ChannelRelayed(this, _localKeyPair, publicKey.base64ToByteArray().toBase64(), true) + _onNewChannel?.invoke(this, channel) + _pendingChannels[requestId] = channel to deferred + try { + channel.sendRequestTransport(requestId, publicKey, appId, pairingCode) + } catch (e: Exception) { + _pendingChannels.remove(requestId)?.let { it.first.close(); it.second.completeExceptionally(e) } + throw e + } + return deferred.await() + } + + private fun getDeviceName(): String { + val manufacturer = Build.MANUFACTURER.replaceFirstChar { if (it.isLowerCase()) it.titlecase( + Locale.getDefault()) else it.toString() } + val model = Build.MODEL + + return if (model.startsWith(manufacturer, ignoreCase = true)) { + model.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + } else { + "$manufacturer $model".replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + } + } + + private fun getLimitedUtf8Bytes(str: String, maxByteLength: Int): ByteArray { + val bytes = str.toByteArray(Charsets.UTF_8) + if (bytes.size <= maxByteLength) return bytes + + var truncateAt = maxByteLength + while (truncateAt > 0 && (bytes[truncateAt].toInt() and 0xC0) == 0x80) { + truncateAt-- + } + return bytes.copyOf(truncateAt) + } + + fun publishConnectionInformation( + authorizedKeys: Array, + port: Int, + allowLocalDirect: Boolean, + allowRemoteDirect: Boolean, + allowRemoteHolePunched: Boolean, + allowRemoteRelayed: Boolean + ) { + if (authorizedKeys.size > 255) throw IllegalArgumentException("Number of authorized keys exceeds 255") + + val ipv4Addresses = mutableListOf() + val ipv6Addresses = mutableListOf() + for (nic in NetworkInterface.getNetworkInterfaces()) { + if (nic.isUp) { + for (addr in nic.inetAddresses) { + if (!addr.isLoopbackAddress) { + when (addr) { + is Inet4Address -> ipv4Addresses.add(addr.hostAddress) + is Inet6Address -> ipv6Addresses.add(addr.hostAddress) + } + } + } + } + } + + val deviceName = getDeviceName() + val nameBytes = getLimitedUtf8Bytes(deviceName, 255) + + val blobSize = 2 + 1 + nameBytes.size + 1 + ipv4Addresses.size * 4 + 1 + ipv6Addresses.size * 16 + 1 + 1 + 1 + 1 + val data = ByteBuffer.allocate(blobSize).order(ByteOrder.LITTLE_ENDIAN) + data.putShort(port.toShort()) + data.put(nameBytes.size.toByte()) + data.put(nameBytes) + data.put(ipv4Addresses.size.toByte()) + for (addr in ipv4Addresses) { + val addrBytes = InetAddress.getByName(addr).address + data.put(addrBytes) + } + data.put(ipv6Addresses.size.toByte()) + for (addr in ipv6Addresses) { + val addrBytes = InetAddress.getByName(addr).address + data.put(addrBytes) + } + data.put(if (allowLocalDirect) 1 else 0) + data.put(if (allowRemoteDirect) 1 else 0) + data.put(if (allowRemoteHolePunched) 1 else 0) + data.put(if (allowRemoteRelayed) 1 else 0) + + val handshakeSize = 48 // Noise handshake size for N pattern + + data.rewind() + val ciphertextSize = data.remaining() + 16 // Encrypted data size + val totalSize = 1 + authorizedKeys.size * (32 + handshakeSize + 4 + ciphertextSize) + val publishBytes = ByteBuffer.allocate(totalSize).order(ByteOrder.LITTLE_ENDIAN) + publishBytes.put(authorizedKeys.size.toByte()) + + for (key in authorizedKeys) { + val publicKeyBytes = Base64.getDecoder().decode(key) + if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes") + + val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR) + protocol.remotePublicKey.setPublicKey(publicKeyBytes, 0) + protocol.start() + + val handshakeMessage = ByteArray(handshakeSize) + val handshakeBytesWritten = protocol.writeMessage(handshakeMessage, 0, null, 0, 0) + if (handshakeBytesWritten != handshakeSize) throw IllegalStateException("Handshake message size mismatch") + + val transportPair = protocol.split() + + publishBytes.put(publicKeyBytes) + publishBytes.put(handshakeMessage) + + val ciphertext = ByteArray(ciphertextSize) + val ciphertextBytesWritten = transportPair.sender.encryptWithAd(null, data.array(), data.position(), ciphertext, 0, data.remaining()) + if (ciphertextBytesWritten != ciphertextSize) throw IllegalStateException("Ciphertext size mismatch") + + publishBytes.putInt(ciphertextBytesWritten) + publishBytes.put(ciphertext, 0, ciphertextBytesWritten) + } + + publishBytes.rewind() + send(Opcode.NOTIFY.value, NotifyOpcode.CONNECTION_INFO.value, publishBytes) + } + + suspend fun publishRecords(consumerPublicKeys: List, key: String, data: ByteArray, contentEncoding: ContentEncoding? = null): Boolean { + val keyBytes = key.toByteArray(Charsets.UTF_8) + if (key.isEmpty() || keyBytes.size > 32) throw IllegalArgumentException("Key must be 1-32 bytes") + if (consumerPublicKeys.isEmpty()) throw IllegalArgumentException("At least one consumer required") + val requestId = generateRequestId() + val deferred = CompletableDeferred() + _pendingPublishRequests[requestId] = deferred + try { + val MAX_PLAINTEXT_SIZE = 65535 + val HANDSHAKE_SIZE = 48 + val LENGTH_SIZE = 4 + val TAG_SIZE = 16 + val chunkCount = (data.size + MAX_PLAINTEXT_SIZE - 1) / MAX_PLAINTEXT_SIZE + + var blobSize = HANDSHAKE_SIZE + var dataOffset = 0 + for (i in 0 until chunkCount) { + val chunkSize = minOf(MAX_PLAINTEXT_SIZE, data.size - dataOffset) + blobSize += LENGTH_SIZE + (chunkSize + TAG_SIZE) + dataOffset += chunkSize + } + + val totalPacketSize = 4 + 1 + keyBytes.size + 1 + consumerPublicKeys.size * (32 + 4 + blobSize) + val packet = ByteBuffer.allocate(totalPacketSize).order(ByteOrder.LITTLE_ENDIAN) + packet.putInt(requestId) + packet.put(keyBytes.size.toByte()) + packet.put(keyBytes) + packet.put(consumerPublicKeys.size.toByte()) + + for (consumer in consumerPublicKeys) { + val consumerBytes = Base64.getDecoder().decode(consumer) + if (consumerBytes.size != 32) throw IllegalArgumentException("Consumer public key must be 32 bytes") + packet.put(consumerBytes) + val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR).apply { + remotePublicKey.setPublicKey(consumerBytes, 0) + start() + } + val handshakeMessage = ByteArray(HANDSHAKE_SIZE) + protocol.writeMessage(handshakeMessage, 0, null, 0, 0) + val transportPair = protocol.split() + packet.putInt(blobSize) + packet.put(handshakeMessage) + + dataOffset = 0 + for (i in 0 until chunkCount) { + val chunkSize = minOf(MAX_PLAINTEXT_SIZE, data.size - dataOffset) + val plaintext = data.copyOfRange(dataOffset, dataOffset + chunkSize) + val ciphertext = ByteArray(chunkSize + TAG_SIZE) + val written = transportPair.sender.encryptWithAd(null, plaintext, 0, ciphertext, 0, plaintext.size) + packet.putInt(written) + packet.put(ciphertext, 0, written) + dataOffset += chunkSize + } + } + packet.rewind() + send(Opcode.REQUEST.value, RequestOpcode.BULK_PUBLISH_RECORD.value, packet, ce = contentEncoding) + } catch (e: Exception) { + _pendingPublishRequests.remove(requestId)?.completeExceptionally(e) + throw e + } + return deferred.await() + } + + suspend fun getRecord(publisherPublicKey: String, key: String): Pair? { + if (key.isEmpty() || key.length > 32) throw IllegalArgumentException("Key must be 1-32 bytes") + val requestId = generateRequestId() + val deferred = CompletableDeferred?>() + _pendingGetRecordRequests[requestId] = deferred + try { + val publisherBytes = Base64.getDecoder().decode(publisherPublicKey) + if (publisherBytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes") + val keyBytes = key.toByteArray(Charsets.UTF_8) + val packet = ByteBuffer.allocate(4 + 32 + 1 + keyBytes.size).order(ByteOrder.LITTLE_ENDIAN) + packet.putInt(requestId) + packet.put(publisherBytes) + packet.put(keyBytes.size.toByte()) + packet.put(keyBytes) + packet.rewind() + send(Opcode.REQUEST.value, RequestOpcode.GET_RECORD.value, packet) + } catch (e: Exception) { + _pendingGetRecordRequests.remove(requestId)?.completeExceptionally(e) + throw e + } + return deferred.await() + } + + suspend fun getRecords(publisherPublicKeys: List, key: String): Map> { + if (key.isEmpty() || key.length > 32) throw IllegalArgumentException("Key must be 1-32 bytes") + if (publisherPublicKeys.isEmpty()) return emptyMap() + val requestId = generateRequestId() + val deferred = CompletableDeferred>>() + _pendingBulkGetRecordRequests[requestId] = deferred + try { + val keyBytes = key.toByteArray(Charsets.UTF_8) + val packet = ByteBuffer.allocate(4 + 1 + keyBytes.size + 1 + publisherPublicKeys.size * 32).order(ByteOrder.LITTLE_ENDIAN) + packet.putInt(requestId) + packet.put(keyBytes.size.toByte()) + packet.put(keyBytes) + packet.put(publisherPublicKeys.size.toByte()) + for (publisher in publisherPublicKeys) { + val bytes = Base64.getDecoder().decode(publisher) + if (bytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes") + packet.put(bytes) + } + packet.rewind() + send(Opcode.REQUEST.value, RequestOpcode.BULK_GET_RECORD.value, packet) + } catch (e: Exception) { + _pendingBulkGetRecordRequests.remove(requestId)?.completeExceptionally(e) + throw e + } + return deferred.await() + } + + suspend fun deleteRecords(publisherPublicKey: String, consumerPublicKey: String, keys: List): Boolean { + if (keys.any { it.toByteArray(Charsets.UTF_8).size > 32 }) throw IllegalArgumentException("Keys must be at most 32 bytes") + val requestId = generateRequestId() + val deferred = CompletableDeferred() + _pendingDeleteRequests[requestId] = deferred + try { + val publisherBytes = Base64.getDecoder().decode(publisherPublicKey) + if (publisherBytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes") + val consumerBytes = Base64.getDecoder().decode(consumerPublicKey) + if (consumerBytes.size != 32) throw IllegalArgumentException("Consumer public key must be 32 bytes") + val packetSize = 4 + 32 + 32 + 1 + keys.sumOf { 1 + it.toByteArray(Charsets.UTF_8).size } + val packet = ByteBuffer.allocate(packetSize).order(ByteOrder.LITTLE_ENDIAN) + packet.putInt(requestId) + packet.put(publisherBytes) + packet.put(consumerBytes) + packet.put(keys.size.toByte()) + for (key in keys) { + val keyBytes = key.toByteArray(Charsets.UTF_8) + packet.put(keyBytes.size.toByte()) + packet.put(keyBytes) + } + packet.rewind() + send(Opcode.REQUEST.value, RequestOpcode.BULK_DELETE_RECORD.value, packet) + } catch (e: Exception) { + _pendingDeleteRequests.remove(requestId)?.completeExceptionally(e) + throw e + } + return deferred.await() + } + + suspend fun listRecordKeys(publisherPublicKey: String, consumerPublicKey: String): List> { + val requestId = generateRequestId() + val deferred = CompletableDeferred>>() + _pendingListKeysRequests[requestId] = deferred + try { + val publisherBytes = Base64.getDecoder().decode(publisherPublicKey) + if (publisherBytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes") + val consumerBytes = Base64.getDecoder().decode(consumerPublicKey) + if (consumerBytes.size != 32) throw IllegalArgumentException("Consumer public key must be 32 bytes") + val packet = ByteBuffer.allocate(4 + 32 + 32).order(ByteOrder.LITTLE_ENDIAN) + packet.putInt(requestId) + packet.put(publisherBytes) + packet.put(consumerBytes) + packet.rewind() + send(Opcode.REQUEST.value, RequestOpcode.LIST_RECORD_KEYS.value, packet) + } catch (e: Exception) { + _pendingListKeysRequests.remove(requestId)?.completeExceptionally(e) + throw e + } + return deferred.await() + } + companion object { + val dh = "25519" + val pattern = "N" + val cipher = "ChaChaPoly" + val hash = "BLAKE2b" + var nProtocolName = "Noise_${pattern}_${dh}_${cipher}_${hash}" + private const val TAG = "SyncSocketSession" const val MAXIMUM_PACKET_SIZE = 65535 - 16 const val MAXIMUM_PACKET_SIZE_ENCRYPTED = MAXIMUM_PACKET_SIZE + 16 - const val HEADER_SIZE = 6 + const val HEADER_SIZE = 7 } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncStream.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncStream.kt index d558feef..b7ed0626 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncStream.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncStream.kt @@ -1,6 +1,6 @@ package com.futo.platformplayer.sync.internal -class SyncStream(expectedSize: Int, val opcode: UByte, val subOpcode: UByte) { +class SyncStream(expectedSize: Int, val opcode: UByte, val subOpcode: UByte, val contentEncoding: UByte) { companion object { const val MAXIMUM_SIZE = 10_000_000 } diff --git a/app/src/main/java/com/futo/platformplayer/views/SearchView.kt b/app/src/main/java/com/futo/platformplayer/views/SearchView.kt index 77cf431e..c7d68127 100644 --- a/app/src/main/java/com/futo/platformplayer/views/SearchView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/SearchView.kt @@ -1,7 +1,11 @@ package com.futo.platformplayer.views import android.content.Context +import android.text.TextWatcher import android.util.AttributeSet +import android.view.View +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import android.widget.ImageButton import android.widget.ImageView @@ -18,6 +22,9 @@ class SearchView : FrameLayout { val buttonClear: ImageButton; var onSearchChanged = Event1(); + var onEnter = Event1(); + + val text: String get() = textSearch.text.toString(); constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { inflate(context, R.layout.view_search_bar, this); @@ -25,9 +32,26 @@ class SearchView : FrameLayout { textSearch = findViewById(R.id.edit_search) buttonClear = findViewById(R.id.button_clear_search) - buttonClear.setOnClickListener { textSearch.text = "" }; + buttonClear.setOnClickListener { + textSearch.text = "" + textSearch?.clearFocus() + (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(textSearch.windowToken, 0) + onSearchChanged.emit("") + onEnter.emit("") + } + textSearch.setOnEditorActionListener { _, i, _ -> + if (i == EditorInfo.IME_ACTION_DONE) { + textSearch?.clearFocus() + (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(textSearch.windowToken, 0) + onEnter.emit(textSearch.text.toString()) + return@setOnEditorActionListener true + } + return@setOnEditorActionListener false + + } textSearch.addTextChangedListener { - onSearchChanged.emit(it.toString()); + buttonClear.visibility = if ((it?.length ?: 0) > 0) View.VISIBLE else View.GONE + onSearchChanged.emit(it.toString()) }; } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelView.kt new file mode 100644 index 00000000..1d467732 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelView.kt @@ -0,0 +1,95 @@ +package com.futo.platformplayer.views.adapters + +import android.content.Context +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.IPlatformChannelContent +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.subscriptions.SubscribeButton + + +open class ChannelView : LinearLayout { + protected val _feedStyle : FeedStyle; + protected val _tiny: Boolean + + private val _textName: TextView; + private val _creatorThumbnail: CreatorThumbnail; + private val _textMetadata: TextView; + private val _buttonSubscribe: SubscribeButton; + private val _platformIndicator: PlatformIndicator; + + val onClick = Event1(); + + var currentChannel: IPlatformChannelContent? = null + private set + + val content: IPlatformContent? get() = currentChannel; + + constructor(context: Context, feedStyle: FeedStyle, tiny: Boolean) : super(context) { + inflate(feedStyle); + _feedStyle = feedStyle; + _tiny = tiny + + _textName = findViewById(R.id.text_channel_name); + _creatorThumbnail = findViewById(R.id.creator_thumbnail); + _textMetadata = findViewById(R.id.text_channel_metadata); + _buttonSubscribe = findViewById(R.id.button_subscribe); + _platformIndicator = findViewById(R.id.platform_indicator); + + //_textName.setOnClickListener { currentChannel?.let { onClick.emit(it) }; } + //_creatorThumbnail.setOnClickListener { currentChannel?.let { onClick.emit(it) }; } + //_textMetadata.setOnClickListener { currentChannel?.let { onClick.emit(it) }; } + + if (_tiny) { + _buttonSubscribe.visibility = View.GONE; + _textMetadata.visibility = View.GONE; + } + + findViewById(R.id.root).setOnClickListener { + val s = currentChannel ?: return@setOnClickListener; + onClick.emit(s); + } + } + + protected open fun inflate(feedStyle: FeedStyle) { + inflate(context, when(feedStyle) { + FeedStyle.PREVIEW -> R.layout.list_creator + else -> R.layout.list_creator + }, this) + } + + open fun bind(content: IPlatformContent) { + isClickable = true; + + if(content !is IPlatformChannelContent) { + currentChannel = null; + return; + } + currentChannel = content; + + _creatorThumbnail.setThumbnail(content.thumbnail, false); + _textName.text = content.name; + + if(content.subscribers == null || (content.subscribers ?: 0) <= 0L) + _textMetadata.visibility = View.GONE; + else { + _textMetadata.text = if((content.subscribers ?: 0) > 0) content.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else ""; + _textMetadata.visibility = View.VISIBLE; + } + _buttonSubscribe.setSubscribeChannel(content.url); + _platformIndicator.setPlatformFromClientID(content.id.pluginId); + } + + companion object { + private val TAG = "ChannelView" + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt index 711a1675..a2ff2435 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt @@ -7,16 +7,16 @@ import com.futo.platformplayer.R import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.constructs.Event1 -class DeviceAdapter : RecyclerView.Adapter { - private val _devices: ArrayList; - private val _isRememberedDevice: Boolean; +data class DeviceAdapterEntry(val castingDevice: CastingDevice, val isPinnedDevice: Boolean, val isOnlineDevice: Boolean) - var onRemove = Event1(); +class DeviceAdapter : RecyclerView.Adapter { + private val _devices: List; + + var onPin = Event1(); var onConnect = Event1(); - constructor(devices: ArrayList, isRememberedDevice: Boolean) : super() { + constructor(devices: List) : super() { _devices = devices; - _isRememberedDevice = isRememberedDevice; } override fun getItemCount() = _devices.size; @@ -24,13 +24,13 @@ class DeviceAdapter : RecyclerView.Adapter { override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): DeviceViewHolder { val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_device, viewGroup, false); val holder = DeviceViewHolder(view); - holder.setIsRememberedDevice(_isRememberedDevice); - holder.onRemove.subscribe { d -> onRemove.emit(d); }; + holder.onPin.subscribe { d -> onPin.emit(d); }; holder.onConnect.subscribe { d -> onConnect.emit(d); } return holder; } override fun onBindViewHolder(viewHolder: DeviceViewHolder, position: Int) { - viewHolder.bind(_devices[position]); + val p = _devices[position]; + viewHolder.bind(p.castingDevice, p.isOnlineDevice, p.isPinnedDevice); } } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt index 3fa42219..133dd26b 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt @@ -2,9 +2,11 @@ package com.futo.platformplayer.views.adapters import android.graphics.drawable.Animatable import android.view.View +import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.RecyclerView.ViewHolder import com.futo.platformplayer.R import com.futo.platformplayer.casting.AirPlayCastingDevice @@ -14,70 +16,71 @@ import com.futo.platformplayer.casting.ChromecastCastingDevice import com.futo.platformplayer.casting.FCastCastingDevice import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 +import androidx.core.view.isVisible +import com.futo.platformplayer.UIDialogs class DeviceViewHolder : ViewHolder { + private val _layoutDevice: FrameLayout; private val _imageDevice: ImageView; private val _textName: TextView; private val _textType: TextView; private val _textNotReady: TextView; - private val _buttonDisconnect: LinearLayout; - private val _buttonConnect: LinearLayout; - private val _buttonRemove: LinearLayout; private val _imageLoader: ImageView; + private val _imageOnline: ImageView; + private val _root: ConstraintLayout; private var _animatableLoader: Animatable? = null; - private var _isRememberedDevice: Boolean = false; + private var _imagePin: ImageView; var device: CastingDevice? = null private set - var onRemove = Event1(); + var onPin = Event1(); val onConnect = Event1(); constructor(view: View) : super(view) { + _root = view.findViewById(R.id.layout_root); + _layoutDevice = view.findViewById(R.id.layout_device); _imageDevice = view.findViewById(R.id.image_device); _textName = view.findViewById(R.id.text_name); _textType = view.findViewById(R.id.text_type); _textNotReady = view.findViewById(R.id.text_not_ready); - _buttonDisconnect = view.findViewById(R.id.button_disconnect); - _buttonConnect = view.findViewById(R.id.button_connect); - _buttonRemove = view.findViewById(R.id.button_remove); _imageLoader = view.findViewById(R.id.image_loader); + _imageOnline = view.findViewById(R.id.image_online); + _imagePin = view.findViewById(R.id.image_pin); val d = _imageLoader.drawable; if (d is Animatable) { _animatableLoader = d; } - _buttonDisconnect.setOnClickListener { - StateCasting.instance.activeDevice?.stopCasting(); - updateButton(); - }; - - _buttonConnect.setOnClickListener { - val dev = device ?: return@setOnClickListener; - StateCasting.instance.activeDevice?.stopCasting(); - StateCasting.instance.connectDevice(dev); - onConnect.emit(dev); - }; - - _buttonRemove.setOnClickListener { - val dev = device ?: return@setOnClickListener; - onRemove.emit(dev); - }; - - StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ -> - updateButton(); + val connect = { + device?.let { dev -> + if (dev.isReady) { + StateCasting.instance.activeDevice?.stopCasting() + StateCasting.instance.connectDevice(dev) + onConnect.emit(dev) + } else { + try { + view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") } + } catch (e: Throwable) { + //Ignored + } + } + } } - setIsRememberedDevice(false); + _textName.setOnClickListener { connect() }; + _textType.setOnClickListener { connect() }; + _layoutDevice.setOnClickListener { connect() }; + + _imagePin.setOnClickListener { + val dev = device ?: return@setOnClickListener; + onPin.emit(dev); + } } - fun setIsRememberedDevice(isRememberedDevice: Boolean) { - _isRememberedDevice = isRememberedDevice; - _buttonRemove.visibility = if (isRememberedDevice) View.VISIBLE else View.GONE; - } - - fun bind(d: CastingDevice) { + fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) { if (d is ChromecastCastingDevice) { _imageDevice.setImageResource(R.drawable.ic_chromecast); _textType.text = "Chromecast"; @@ -90,54 +93,47 @@ class DeviceViewHolder : ViewHolder { } _textName.text = d.name; - device = d; - updateButton(); - } - - private fun updateButton() { - val d = device ?: return; + _imageOnline.visibility = if (isOnlineDevice && d.isReady) View.VISIBLE else View.GONE if (!d.isReady) { - _buttonConnect.visibility = View.GONE; - _buttonDisconnect.visibility = View.GONE; _imageLoader.visibility = View.GONE; _textNotReady.visibility = View.VISIBLE; - return; - } - - _textNotReady.visibility = View.GONE; - - val dev = StateCasting.instance.activeDevice; - if (dev == d) { - if (dev.connectionState == CastConnectionState.CONNECTED) { - _buttonConnect.visibility = View.GONE; - _buttonDisconnect.visibility = View.VISIBLE; - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.GONE; - } else { - _buttonConnect.visibility = View.GONE; - _buttonDisconnect.visibility = View.VISIBLE; - _imageLoader.visibility = View.VISIBLE; - _textNotReady.visibility = View.GONE; - } + _imagePin.visibility = View.GONE; } else { - if (d.isReady) { - _buttonConnect.visibility = View.VISIBLE; - _buttonDisconnect.visibility = View.GONE; - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.GONE; + _textNotReady.visibility = View.GONE; + + val dev = StateCasting.instance.activeDevice; + if (dev == d) { + if (dev.connectionState == CastConnectionState.CONNECTED) { + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.GONE; + _imagePin.visibility = View.VISIBLE; + } else { + _imageLoader.visibility = View.VISIBLE; + _textNotReady.visibility = View.GONE; + _imagePin.visibility = View.VISIBLE; + } } else { - _buttonConnect.visibility = View.GONE; - _buttonDisconnect.visibility = View.GONE; - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.VISIBLE; + if (d.isReady) { + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.GONE; + _imagePin.visibility = View.VISIBLE; + } else { + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.VISIBLE; + _imagePin.visibility = View.VISIBLE; + } + } + + _imagePin.setImageResource(if (isPinnedDevice) R.drawable.keep_24px else R.drawable.ic_pin) + + if (_imageLoader.isVisible) { + _animatableLoader?.start(); + } else { + _animatableLoader?.stop(); } } - if (_imageLoader.visibility == View.VISIBLE) { - _animatableLoader?.start(); - } else { - _animatableLoader?.stop(); - } + device = d; } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt index 2f018c9d..2fb9dd32 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt @@ -14,9 +14,11 @@ import com.futo.platformplayer.R import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.models.HistoryVideo +import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.views.others.ProgressBar +import com.futo.platformplayer.views.platform.PlatformIndicator class HistoryListViewHolder : ViewHolder { private val _root: ConstraintLayout; @@ -30,6 +32,7 @@ class HistoryListViewHolder : ViewHolder { private val _imageRemove: ImageButton; private val _textHeader: TextView; private val _timeBar: ProgressBar; + private val _thumbnailPlatform: PlatformIndicator var video: HistoryVideo? = null private set; @@ -47,6 +50,7 @@ class HistoryListViewHolder : ViewHolder { _textVideoDuration = itemView.findViewById(R.id.thumbnail_duration); _containerDuration = itemView.findViewById(R.id.thumbnail_duration_container); _containerLive = itemView.findViewById(R.id.thumbnail_live_container); + _thumbnailPlatform = itemView.findViewById(R.id.thumbnail_platform) _imageRemove = itemView.findViewById(R.id.image_trash); _textHeader = itemView.findViewById(R.id.text_header); _timeBar = itemView.findViewById(R.id.time_bar); @@ -73,6 +77,9 @@ class HistoryListViewHolder : ViewHolder { _textAuthor.text = v.video.author.name; _textVideoDuration.text = v.video.duration.toHumanTime(false); + val pluginId = v.video.id.pluginId ?: StatePlatform.instance.getContentClientOrNull(v.video.url)?.id + _thumbnailPlatform.setPlatformFromClientID(pluginId) + if(v.video.isLive) { _containerDuration.visibility = View.GONE; _containerLive.visibility = View.VISIBLE; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ItemMoveCallback.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ItemMoveCallback.kt index 4b34d353..3d0bec35 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/ItemMoveCallback.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ItemMoveCallback.kt @@ -10,10 +10,11 @@ class ItemMoveCallback : ItemTouchHelper.Callback { var onRowMoved = Event2(); var onRowSelected = Event1(); var onRowClear = Event1(); + var canEdit = true constructor() : super() { } - override fun isLongPressDragEnabled(): Boolean { return true; } + override fun isLongPressDragEnabled(): Boolean { return canEdit; } override fun isItemViewSwipeEnabled(): Boolean { return false; } override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt index c9cb8b73..2f70996f 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt @@ -126,7 +126,7 @@ open class PlaylistView : LinearLayout { } else { currentPlaylist = null; - _imageThumbnail.setImageResource(0); + _imageThumbnail.setImageDrawable(null); } } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt index ef3f7cb0..33783e67 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt @@ -37,9 +37,10 @@ class SubscriptionAdapter : RecyclerView.Adapter { _onDatasetChanged = onDatasetChanged; StateSubscriptions.instance.onSubscriptionsChanged.subscribe { _, _ -> if(Looper.myLooper() != Looper.getMainLooper()) - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { updateDataset() } + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { updateDataset() } else - updateDataset(); } + updateDataset(); + } updateDataset(); } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt index f7c313f5..6f3f870a 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt @@ -1,6 +1,7 @@ package com.futo.platformplayer.views.adapters import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt index 77df0665..42cef197 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt @@ -17,9 +17,11 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.states.StateDownloads +import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.toHumanNowDiffString import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.toHumanTime +import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.platform.PlatformIndicator class VideoListEditorViewHolder : ViewHolder { @@ -36,6 +38,7 @@ class VideoListEditorViewHolder : ViewHolder { private val _imageDragDrop: ImageButton; private val _platformIndicator: PlatformIndicator; private val _layoutDownloaded: FrameLayout; + private val _timeBar: ProgressBar var video: IPlatformVideo? = null private set; @@ -59,6 +62,7 @@ class VideoListEditorViewHolder : ViewHolder { _imageOptions = view.findViewById(R.id.image_settings); _imageDragDrop = view.findViewById(R.id.image_drag_drop); _platformIndicator = view.findViewById(R.id.thumbnail_platform); + _timeBar = view.findViewById(R.id.time_bar); _layoutDownloaded = view.findViewById(R.id.layout_downloaded); _imageDragDrop.setOnTouchListener { _, event -> @@ -93,6 +97,9 @@ class VideoListEditorViewHolder : ViewHolder { _textAuthor.text = v.author.name; _textVideoDuration.text = v.duration.toHumanTime(false); + val historyPosition = StateHistory.instance.getHistoryPosition(v.url) + _timeBar.progress = historyPosition.toFloat() / v.duration.toFloat(); + if(v.isLive) { _containerDuration.visibility = View.GONE; _containerLive.visibility = View.VISIBLE; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewChannelViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewChannelViewHolder.kt new file mode 100644 index 00000000..17754984 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewChannelViewHolder.kt @@ -0,0 +1,40 @@ +package com.futo.platformplayer.views.adapters.feedtypes + +import android.view.ViewGroup +import com.futo.platformplayer.api.media.models.IPlatformChannelContent +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.fragment.mainactivity.main.CreatorFeedView +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.adapters.ChannelView +import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder +import com.futo.platformplayer.views.adapters.PlaylistView + + +class PreviewChannelViewHolder : ContentPreviewViewHolder { + val onClick = Event1(); + + val currentChannel: IPlatformChannelContent? get() = view.currentChannel; + + override val content: IPlatformContent? get() = currentChannel; + + private val view: ChannelView get() = itemView as ChannelView; + + constructor(viewGroup: ViewGroup, feedStyle: FeedStyle, tiny: Boolean): super(ChannelView(viewGroup.context, feedStyle, tiny)) { + view.onClick.subscribe(onClick::emit); + } + + override fun bind(content: IPlatformContent) = view.bind(content); + + override fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit; + override fun stopPreview() = Unit; + override fun pausePreview() = Unit; + override fun resumePreview() = Unit; + + companion object { + private val TAG = "PreviewChannelViewHolder" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt index 225cf6d7..327497fb 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt @@ -23,6 +23,7 @@ import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.EmptyPreviewViewHolder import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import okhttp3.internal.platform.Platform class PreviewContentListAdapter : InsertedViewAdapterWithLoader { private var _initialPlay = true; @@ -78,10 +79,13 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader createPlaceholderViewHolder(viewGroup); ContentType.MEDIA -> createVideoPreviewViewHolder(viewGroup); + ContentType.ARTICLE -> createPostViewHolder(viewGroup); + ContentType.WEB -> createPostViewHolder(viewGroup); ContentType.POST -> createPostViewHolder(viewGroup); ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup); ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup); ContentType.LOCKED -> createLockedViewHolder(viewGroup); + ContentType.CHANNEL -> createChannelViewHolder(viewGroup) else -> EmptyPreviewViewHolder(viewGroup) } } @@ -115,6 +119,10 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader() @OptIn(UnstableApi::class) constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { @@ -185,11 +189,11 @@ class CastView : ConstraintLayout { } fun setIsPlaying(isPlaying: Boolean) { - _updateTimeJob?.cancel(); + stopTimeJob() if(isPlaying) { val d = StateCasting.instance.activeDevice; - if (d is AirPlayCastingDevice) { + if (d is AirPlayCastingDevice || d is ChromecastCastingDevice) { _updateTimeJob = _scope.launch { while (true) { val device = StateCasting.instance.activeDevice; @@ -198,7 +202,9 @@ class CastView : ConstraintLayout { } delay(1000); - setTime((device.expectedCurrentTime * 1000.0).toLong()); + val time_ms = (device.expectedCurrentTime * 1000.0).toLong() + setTime(time_ms); + onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong()) } } } diff --git a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt index 08d32ac3..67be4058 100644 --- a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt @@ -26,6 +26,7 @@ class VideoListEditorView : FrameLayout { val onVideoOptions = Event1(); val onVideoClicked = Event1(); val isEmpty get() = _videos.isEmpty(); + val itemMoveCallback: ItemMoveCallback constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { val recyclerPlaylist = RecyclerView(context, attrs); @@ -34,14 +35,14 @@ class VideoListEditorView : FrameLayout { recyclerPlaylist.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); addView(recyclerPlaylist); - val callback = ItemMoveCallback(); - val touchHelper = ItemTouchHelper(callback); + itemMoveCallback = ItemMoveCallback(); + val touchHelper = ItemTouchHelper(itemMoveCallback); val adapterVideos = VideoListEditorAdapter(touchHelper); recyclerPlaylist.adapter = adapterVideos; recyclerPlaylist.layoutManager = LinearLayoutManager(context); touchHelper.attachToRecyclerView(recyclerPlaylist); - callback.onRowMoved.subscribe { fromPosition, toPosition -> + itemMoveCallback.onRowMoved.subscribe { fromPosition, toPosition -> synchronized(_videos) { if (fromPosition < toPosition) { for (i in fromPosition until toPosition) @@ -94,6 +95,7 @@ class VideoListEditorView : FrameLayout { synchronized(_videos) { _videos.clear(); _videos.addAll(videos ?: listOf()); + itemMoveCallback.canEdit = canEdit _adapterVideos?.setVideos(_videos, canEdit); } } diff --git a/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt b/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt index 472a516f..d655d7dd 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt @@ -9,6 +9,7 @@ import android.view.View import android.widget.ImageView import androidx.constraintlayout.widget.ConstraintLayout import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy import com.futo.platformplayer.R import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.getDataLinkFromUrl @@ -81,12 +82,14 @@ class CreatorThumbnail : ConstraintLayout { Glide.with(_imageChannelThumbnail) .load(url) .placeholder(R.drawable.placeholder_channel_thumbnail) + .diskCacheStrategy(DiskCacheStrategy.DATA) .crossfade() .into(_imageChannelThumbnail); } else { Glide.with(_imageChannelThumbnail) .load(url) .placeholder(R.drawable.placeholder_channel_thumbnail) + .diskCacheStrategy(DiskCacheStrategy.DATA) .into(_imageChannelThumbnail); } } diff --git a/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt b/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt index 22a5d21f..8c26d1e1 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt @@ -13,6 +13,17 @@ class RadioGroupView : FlexboxLayout { val selectedOptions = arrayListOf(); val onSelectedChange = Event1>(); + constructor(context: Context) : super(context) { + flexWrap = FlexWrap.WRAP; + _padding_px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, _padding_dp, context.resources.displayMetrics).toInt(); + + if (isInEditMode) { + setOptions(listOf("Example 1" to 1, "Example 2" to 2, "Example 3" to 3, "Example 4" to 4, "Example 5" to 5), listOf("Example 1", "Example 2"), + multiSelect = true, + atLeastOne = false + ); + } + } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { flexWrap = FlexWrap.WRAP; _padding_px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, _padding_dp, context.resources.displayMetrics).toInt(); diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuFilters.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuFilters.kt index 75b50a26..9180a3f1 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuFilters.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuFilters.kt @@ -28,17 +28,14 @@ class SlideUpMenuFilters { private var _changed: Boolean = false; private val _lifecycleScope: CoroutineScope; - private var _isChannelSearch = false; - var commonCapabilities: ResultCapabilities? = null; - constructor(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List, filterValues: HashMap>, isChannelSearch: Boolean = false) { + constructor(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List, filterValues: HashMap>) { _lifecycleScope = lifecycleScope; _container = container; _enabledClientsIds = enabledClientsIds; _filterValues = filterValues; - _isChannelSearch = isChannelSearch; _slideUpMenuOverlay = SlideUpMenuOverlay(_container.context, _container, container.context.getString(R.string.filters), container.context.getString(R.string.done), true, listOf()); _slideUpMenuOverlay.onOK.subscribe { onOK.emit(_enabledClientsIds, _changed); @@ -51,10 +48,7 @@ class SlideUpMenuFilters { private fun updateCommonCapabilities() { _lifecycleScope.launch(Dispatchers.IO) { try { - val caps = if(!_isChannelSearch) - StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds); - else - StatePlatform.instance.getCommonSearchChannelContentsCapabilities(_enabledClientsIds); + val caps = StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds); synchronized(_filterValues) { if (caps != null) { val keysToRemove = arrayListOf(); diff --git a/app/src/main/java/com/futo/platformplayer/views/platform/PlatformIndicator.kt b/app/src/main/java/com/futo/platformplayer/views/platform/PlatformIndicator.kt index 8ef8520c..2ac4c2ee 100644 --- a/app/src/main/java/com/futo/platformplayer/views/platform/PlatformIndicator.kt +++ b/app/src/main/java/com/futo/platformplayer/views/platform/PlatformIndicator.kt @@ -9,17 +9,28 @@ class PlatformIndicator : androidx.appcompat.widget.AppCompatImageView { } fun clearPlatform() { - setImageResource(0); + setImageDrawable(null); } fun setPlatformFromClientID(platformType : String?) { if(platformType == null) - setImageResource(0); + setImageDrawable(null); else { val result = StatePlatform.instance.getPlatformIcon(platformType); if (result != null) result.setImageView(this); else - setImageResource(0); + setImageDrawable(null); + } + } + fun setPlatformFromClientName(name: String?) { + if(name == null) + setImageDrawable(null); + else { + val result = StatePlatform.instance.getPlatformIconByName(name); + if (result != null) + result.setImageView(this); + else + setImageDrawable(null); } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/sync/SyncDeviceView.kt b/app/src/main/java/com/futo/platformplayer/views/sync/SyncDeviceView.kt index 6bdcd3ed..c091d3cd 100644 --- a/app/src/main/java/com/futo/platformplayer/views/sync/SyncDeviceView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/sync/SyncDeviceView.kt @@ -43,13 +43,13 @@ class SyncDeviceView : ConstraintLayout { _layoutLinkType.visibility = View.VISIBLE _imageLinkType.setImageResource(when (linkType) { - LinkType.Proxied -> R.drawable.ic_internet - LinkType.Local -> R.drawable.ic_lan + LinkType.Relayed -> R.drawable.ic_internet + LinkType.Direct -> R.drawable.ic_lan else -> 0 }) _textLinkType.text = when(linkType) { - LinkType.Proxied -> "Proxied" - LinkType.Local -> "Local" + LinkType.Relayed -> "Relayed" + LinkType.Direct -> "Direct" else -> null } 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 1daa7808..e209f937 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 @@ -531,6 +531,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase { fun setLoopVisible(visible: Boolean) { _control_loop.visibility = if (visible) View.VISIBLE else View.GONE; _control_loop_fullscreen.visibility = if (visible) View.VISIBLE else View.GONE; + if (StatePlayer.instance.loopVideo && !visible) + StatePlayer.instance.loopVideo = false } fun stopAllGestures() { diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 00000000..405bd330 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/keep_24px.xml b/app/src/main/res/drawable/keep_24px.xml new file mode 100644 index 00000000..767284d6 --- /dev/null +++ b/app/src/main/res/drawable/keep_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_sync_pair.xml b/app/src/main/res/layout/activity_sync_pair.xml index e5355ecc..2e5e5651 100644 --- a/app/src/main/res/layout/activity_sync_pair.xml +++ b/app/src/main/res/layout/activity_sync_pair.xml @@ -233,7 +233,7 @@ android:isScrollContainer="true" android:scrollbars="vertical" android:maxHeight="200dp" - android:text="An error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurred" /> + android:text="An error has occurred" /> \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_casting_connect.xml b/app/src/main/res/layout/dialog_casting_connect.xml index e76b8c65..a6153053 100644 --- a/app/src/main/res/layout/dialog_casting_connect.xml +++ b/app/src/main/res/layout/dialog_casting_connect.xml @@ -5,31 +5,54 @@ android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@color/gray_1d"> + android:background="#101010"> + android:orientation="horizontal" + android:layout_marginTop="12dp"> - + android:orientation="vertical"> - + + + + + + + + + + android:layout_marginEnd="20dp" + android:layout_marginTop="10dp" + android:layout_marginBottom="20dp"/> + + + + - - - - + android:orientation="horizontal" + android:layout_marginTop="12dp" + android:layout_marginBottom="20dp" + android:layout_marginStart="20dp" + android:layout_marginEnd="20dp"> - - - - - - + android:gravity="center"> - + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_casting_connected.xml b/app/src/main/res/layout/dialog_casting_connected.xml index 027db929..dc9aaed8 100644 --- a/app/src/main/res/layout/dialog_casting_connected.xml +++ b/app/src/main/res/layout/dialog_casting_connected.xml @@ -97,27 +97,7 @@ app:layout_constraintTop_toBottomOf="@id/text_name" app:layout_constraintLeft_toRightOf="@id/image_device" /> - - - @@ -253,4 +233,30 @@ android:gravity="center_vertical" android:paddingBottom="15dp"> + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_buy.xml b/app/src/main/res/layout/fragment_buy.xml index d12534bd..27464791 100644 --- a/app/src/main/res/layout/fragment_buy.xml +++ b/app/src/main/res/layout/fragment_buy.xml @@ -76,7 +76,7 @@ android:id="@+id/button_buy_text" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="$9.99 + Tax" + android:text="$19 + Tax" android:textSize="14dp" android:textColor="@color/white" android:fontFamily="@font/inter_regular" diff --git a/app/src/main/res/layout/fragment_history.xml b/app/src/main/res/layout/fragment_history.xml index 019bf08c..ec5bbd5f 100644 --- a/app/src/main/res/layout/fragment_history.xml +++ b/app/src/main/res/layout/fragment_history.xml @@ -94,6 +94,25 @@ android:id="@+id/tags_text" android:layout_width="match_parent" android:layout_height="wrap_content" /> + + + + diff --git a/app/src/main/res/layout/fragment_playlists.xml b/app/src/main/res/layout/fragment_playlists.xml index b8ca23df..a03f36ac 100644 --- a/app/src/main/res/layout/fragment_playlists.xml +++ b/app/src/main/res/layout/fragment_playlists.xml @@ -144,6 +144,9 @@ android:layout_marginTop="10dp" android:layout_marginLeft="15dp" android:layout_marginRight="15dp" + android:inputType="text" + android:imeOptions="actionDone" + android:singleLine="true" android:background="@drawable/background_button_round" android:hint="Search.." /> diff --git a/app/src/main/res/layout/fragment_suggestion_list.xml b/app/src/main/res/layout/fragment_suggestion_list.xml index 3fec1999..ef241b7b 100644 --- a/app/src/main/res/layout/fragment_suggestion_list.xml +++ b/app/src/main/res/layout/fragment_suggestion_list.xml @@ -1,14 +1,56 @@ - + + + + + + + + + + + + + + android:orientation="vertical" + app:layout_behavior="@string/appbar_scrolling_view_behavior" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/fragview_article_detail.xml b/app/src/main/res/layout/fragview_article_detail.xml new file mode 100644 index 00000000..9476c0de --- /dev/null +++ b/app/src/main/res/layout/fragview_article_detail.xml @@ -0,0 +1,317 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +