Compare commits

..

55 Commits

Author SHA1 Message Date
Koen J e0b5e7b808 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-06 16:55:51 +02:00
Koen J ac3a8da002 Various fixes for android to android pairing. 2025-05-06 16:54:58 +02:00
Kelvin 1aa45c2156 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-06 13:23:10 +02:00
Kelvin 3cf8abd409 Fix racecondition watchlater adds 2025-05-06 13:23:00 +02:00
Koen J db8426779c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-06 13:04:53 +02:00
Koen J b419e033f3 Casting device more clearly communicates when not ready. Implemented backoffs for SyncServer. 2025-05-06 13:04:42 +02:00
Kelvin d686fa327b Incorrect gzip compression 2025-05-06 12:40:24 +02:00
Kelvin a1ce5eda43 Fix synced ImageVariables showing black images 2025-05-06 11:53:30 +02:00
Koen J 1e790d1aa9 Added toggle to be able to disable local functionality for sync. Sync now automatically closes when pairing is successful. Pairing in progress layouts now properly show again. 2025-05-05 13:34:52 +02:00
Koen J d1d304b758 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-05 12:00:15 +02:00
Koen J e12b500144 Sync disabled by default. 2025-05-05 12:00:04 +02:00
Kelvin bd77651a1e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-05 11:18:57 +02:00
Kelvin 35dc186395 Login edgecase fix 2025-05-05 11:18:46 +02:00
Koen J 07e78e0d12 Fixed sending packets without data in sync protocol. 2025-05-05 10:41:21 +02:00
Koen J 5b8905c1d2 Made ipv6 casting URL fix. 2025-05-04 00:50:12 +02:00
Koen J 158a27cbae Casting fixes. 2025-05-03 21:20:19 +02:00
Koen J 5769b39d78 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-03 21:05:23 +02:00
Koen J 5c96262c75 Crashfix. 2025-05-03 21:05:12 +02:00
Koen J 766f57dc9d Crashfix. 2025-05-03 21:04:39 +02:00
Kelvin 9986078582 Fix sync crash and responsiveness for subs sync 2025-05-03 19:30:47 +02:00
Koen J e047ab5684 Crashfixes. 2025-05-03 17:35:17 +02:00
Koen J a100785ad7 Gzip only for data packets. 2025-05-03 17:09:34 +02:00
Koen J 156eb4d15e Implemented sync protocol gzip. 2025-05-03 15:00:17 +02:00
Koen J dabcfd965f Fixed send on wrong thread. 2025-05-03 12:11:08 +02:00
Koen J d44a71f3be Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-03 11:22:06 +02:00
Koen J f8edd6cf3d Possibel performance improvements to sync under high lat conditions. 2025-05-03 11:21:57 +02:00
Kelvin 2baf53c5a4 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-02 12:10:58 +02:00
Kelvin c26e9c281f Easier search type switching on results page, fix search result click channels 2025-05-02 12:10:47 +02:00
Koen J 9f78e9b7dd Crashfix in nsdmanager. StateSync reconnects less often. Channels are closed when sending fails in sync. 2025-05-01 21:53:48 +02:00
Kelvin fdaf41b605 BuildPlatform property 2025-05-01 16:31:51 +02:00
Koen J 89526efe7a Updated submodules. 2025-05-01 14:11:22 +02:00
Koen J 5e3a25c18f Added dialog with loader before QR code scanner shows. 2025-05-01 11:00:33 +02:00
Koen J cf11c4283e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-01 09:56:25 +02:00
Koen J 2dde04b979 Reduced padding on content types search. 2025-05-01 09:55:33 +02:00
Kelvin 8384f227be Plugin refs 2025-04-30 20:02:09 +02:00
Kelvin 697b3bc5f5 SLD domain checking fix, download notification if on metered, check for unstarted downloads on opening ui, minor fixes/imrpovements 2025-04-30 20:00:48 +02:00
Koen J 9e2041521e Made the disconnect button easier to click on casting connected dialog. 2025-04-29 15:27:24 +02:00
Koen J ee7b89ec6e Added new casting dialog. 2025-04-29 15:22:06 +02:00
Koen J 5b143bdc76 Switch to NsdManager. 2025-04-29 08:39:05 +02:00
Koen J d9d00e452e Explicitly set network interface in joinGroup. 2025-04-28 16:59:11 +02:00
Koen J 14500e281c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-04-28 13:59:59 +02:00
Koen J c4623c80ff Implemented app id and updated unit tests. 2025-04-28 13:59:50 +02:00
Kelvin 9e17dce9a9 Fix edgecase where activity killed before 5s after opening 2025-04-25 17:40:45 +02:00
Koen J daa91986ef Add search type selector to suggestions fragment. 2025-04-22 13:40:05 +02:00
Koen J 63761cfc9a Simplified all searches to use ContentSearchResultsFragment. 2025-04-22 13:08:23 +02:00
Koen J d10026acd1 Added ping loop. 2025-04-21 13:32:58 +02:00
Koen J 9347351c37 Fixed issue where it would continuously try to connect over relay. 2025-04-15 09:39:35 +02:00
Koen J 0ef1f2d40f Added LinkType to Channel. 2025-04-14 15:19:16 +02:00
Koen J b460f9915d Added settings for enabling/disabling remote sync features. Fixed device pairing success showing too early. 2025-04-14 14:41:47 +02:00
Koen J 4e195dfbc3 Rename to direct and relayed. 2025-04-14 10:38:42 +02:00
Koen 3c7f7bfca7 Merge branch 'remote-sync' into 'master'
Implemented remote sync.

See merge request videostreaming/grayjay!93
2025-04-11 14:31:47 +00:00
Koen 05230971b3 Implemented remote sync. 2025-04-11 14:31:47 +00:00
Kelvin K dccdf72c73 Message change 2025-04-09 23:35:44 +02:00
Kelvin K ca15983a72 Casting message, caching creator images 2025-04-09 23:26:35 +02:00
Kelvin K 4b6a2c9829 Keyboard hide on search end 2025-04-09 21:02:19 +02:00
95 changed files with 2444 additions and 2908 deletions
@@ -3,19 +3,21 @@ package com.futo.platformplayer
import com.futo.platformplayer.noise.protocol.Noise import com.futo.platformplayer.noise.protocol.Noise
import com.futo.platformplayer.sync.internal.* import com.futo.platformplayer.sync.internal.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.selects.select
import org.junit.Assert.* import org.junit.Assert.*
import org.junit.Test import org.junit.Test
import java.net.Socket import java.net.Socket
import java.nio.ByteBuffer import java.nio.ByteBuffer
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class SyncServerTests { class SyncServerTests {
//private val relayHost = "relay.grayjay.app" //private val relayHost = "relay.grayjay.app"
//private val relayKey = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw=" //private val relayKey = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw="
private val relayKey = "XlUaSpIlRaCg0TGzZ7JYmPupgUHDqTZXUUBco2K7ejw=" private val relayKey = "XlUaSpIlRaCg0TGzZ7JYmPupgUHDqTZXUUBco2K7ejw="
private val relayHost = "192.168.1.175" private val relayHost = "192.168.1.138"
private val relayPort = 9000 private val relayPort = 9000
/** Creates a client connected to the live relay server. */ /** Creates a client connected to the live relay server. */
@@ -23,7 +25,8 @@ class SyncServerTests {
onHandshakeComplete: ((SyncSocketSession) -> Unit)? = null, onHandshakeComplete: ((SyncSocketSession) -> Unit)? = null,
onData: ((SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit)? = null, onData: ((SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit)? = null,
onNewChannel: ((SyncSocketSession, ChannelRelayed) -> Unit)? = null, onNewChannel: ((SyncSocketSession, ChannelRelayed) -> Unit)? = null,
isHandshakeAllowed: ((SyncSocketSession, String, String?) -> Boolean)? = null isHandshakeAllowed: ((LinkType, SyncSocketSession, String, String?, UInt) -> Boolean)? = null,
onException: ((Throwable) -> Unit)? = null
): SyncSocketSession = withContext(Dispatchers.IO) { ): SyncSocketSession = withContext(Dispatchers.IO) {
val p = Noise.createDH("25519") val p = Noise.createDH("25519")
p.generateKeyPair() p.generateKeyPair()
@@ -43,10 +46,14 @@ class SyncServerTests {
}, },
onData = onData ?: { _, _, _, _ -> }, onData = onData ?: { _, _, _, _ -> },
onNewChannel = onNewChannel ?: { _, _ -> }, onNewChannel = onNewChannel ?: { _, _ -> },
isHandshakeAllowed = isHandshakeAllowed ?: { _, _, _ -> true } isHandshakeAllowed = isHandshakeAllowed ?: { _, _, _, _, _ -> true }
) )
socketSession.authorizable = AlwaysAuthorized() socketSession.authorizable = AlwaysAuthorized()
socketSession.startAsInitiator(relayKey) try {
socketSession.startAsInitiator(relayKey)
} catch (e: Throwable) {
onException?.invoke(e)
}
withTimeout(5000.milliseconds) { tcs.await() } withTimeout(5000.milliseconds) { tcs.await() }
return@withContext socketSession return@withContext socketSession
} }
@@ -259,6 +266,71 @@ class SyncServerTests {
clientA.stop() clientA.stop()
clientB.stop() clientB.stop()
} }
@Test
fun relayedTransport_WithValidAppId_Success() = runBlocking {
// Arrange: Set up clients
val allowedAppId = 1234u
val tcsB = CompletableDeferred<ChannelRelayed>()
// 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<ChannelRelayed>()
// 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 { class AlwaysAuthorized : IAuthorizable {
@@ -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<SyncSocketSession, SyncSocketSession> {
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<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
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<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val initiatorClosed = CompletableDeferred<Boolean>()
val responderClosed = CompletableDeferred<Boolean>()
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<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val initiatorClosed = CompletableDeferred<Boolean>()
val responderClosed = CompletableDeferred<Boolean>()
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<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
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<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
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<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
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<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
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<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
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<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
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<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
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<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val initiatorClosed = CompletableDeferred<Boolean>()
val responderClosed = CompletableDeferred<Boolean>()
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
}
@@ -14,7 +14,6 @@ import java.text.DecimalFormat
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.math.roundToLong 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 { fun String.matchesDomain(queryDomain: String): Boolean {
if(queryDomain.startsWith(".")) { if(queryDomain.startsWith(".")) {
val parts = this.lowercase().split(".");
val parts = queryDomain.lowercase().split("."); val queryParts = queryDomain.lowercase().trimStart("."[0]).split(".");
if(parts.size < 3) if(queryParts.size < 2)
throw IllegalStateException("Illegal use of wildcards on First-Level-Domain (" + queryDomain + ")"); throw IllegalStateException("Illegal use of wildcards on First-Level-Domain (" + queryDomain + ")");
if(parts.size >= 3){ else {
val isSLD = slds.contains("." + parts[parts.size - 2] + "." + parts[parts.size - 1]); val possibleDomain = "." + queryParts.joinToString(".");
if(isSLD && parts.size <= 3) if(slds.contains(possibleDomain))
throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")"); 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 //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 { fun String.getSubdomainWildcardQuery(): String {
val domainParts = this.split("."); val domainParts = this.split(".");
val sldParts = "." + domainParts[domainParts.size - 2].lowercase() + "." + domainParts[domainParts.size - 1].lowercase(); var wildcardDomain = if(domainParts.size > 2)
if(slds.contains(sldParts)) "." + domainParts.drop(1).joinToString(".")
return "." + domainParts.drop(domainParts.size - 3).joinToString(".");
else else
return "." + domainParts.drop(domainParts.size - 2).joinToString("."); "." + domainParts.joinToString(".");
if(slds.contains(wildcardDomain.lowercase()))
"." + domainParts.joinToString(".");
return wildcardDomain;
} }
@@ -7,6 +7,9 @@ import java.net.InetAddress
import java.net.URI import java.net.URI
import java.net.URISyntaxException import java.net.URISyntaxException
import java.net.URLEncoder import java.net.URLEncoder
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
//Syntax sugaring //Syntax sugaring
inline fun <reified T> Any.assume(): T?{ inline fun <reified T> Any.assume(): T?{
@@ -33,13 +36,37 @@ fun Boolean?.toYesNo(): String {
fun InetAddress?.toUrlAddress(): String { fun InetAddress?.toUrlAddress(): String {
return when (this) { return when (this) {
is Inet6Address -> { 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 -> { is Inet4Address -> {
hostAddress this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
} }
else -> { else -> {
throw Exception("Invalid address type") 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)
} }
@@ -590,7 +590,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4) @FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var allowIpv6: Boolean = false; var allowIpv6: Boolean = true;
/*TODO: Should we have a different casting quality? /*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@@ -926,7 +926,7 @@ class Settings : FragmentedStorageFileJson() {
@Serializable @Serializable
class Synchronization { class Synchronization {
@FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1) @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) @FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1)
var broadcast: Boolean = false; var broadcast: Boolean = false;
@@ -936,6 +936,21 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.connect_last, FieldForm.TOGGLE, R.string.connect_last_description, 3) @FormField(R.string.connect_last, FieldForm.TOGGLE, R.string.connect_last_description, 3)
var connectLast: Boolean = true; 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) @FormField(R.string.info, FieldForm.GROUP, -1, 21)
@@ -684,6 +684,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() }; return menu.apply { show() };
@@ -69,7 +69,14 @@ fun warnIfMainThread(context: String) {
} }
fun ensureNotMainThread() { 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") 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") throw IllegalStateException("Cannot run on main thread")
} }
@@ -272,7 +279,7 @@ fun <T> findNewIndex(originalArr: List<T>, newArr: List<T>, item: T): Int{
} }
} }
if(newIndex < 0) if(newIndex < 0)
return originalArr.size; return newArr.size;
else else
return newIndex; return newIndex;
} }
@@ -1,6 +1,7 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -613,8 +614,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.toast(this, "No external file permissions\nExporting and auto backups will not work"); UIDialogs.toast(this, "No external file permissions\nExporting and auto backups will not work");
}*/ }*/
private var _qrCodeLoadingDialog: AlertDialog? = null
fun showUrlQrCodeScanner() { fun showUrlQrCodeScanner() {
try { 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) val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt(getString(R.string.scan_a_qr_code)) integrator.setPrompt(getString(R.string.scan_a_qr_code))
@@ -640,6 +651,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
super.onPause(); super.onPause();
Logger.v(TAG, "onPause") Logger.v(TAG, "onPause")
_isVisible = false; _isVisible = false;
_qrCodeLoadingDialog?.dismiss()
_qrCodeLoadingDialog = null
} }
override fun onStop() { override fun onStop() {
@@ -89,6 +89,14 @@ class SyncHomeActivity : AppCompatActivity() {
updateEmptyVisibility() updateEmptyVisibility()
} }
} }
StateSync.instance.confirmStarted(this, {
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity)
}, {
finish()
}, {
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity)
})
} }
override fun onDestroy() { override fun onDestroy() {
@@ -83,6 +83,7 @@ class SyncPairActivity : AppCompatActivity() {
_layoutPairingSuccess.setOnClickListener { _layoutPairingSuccess.setOnClickListener {
_layoutPairingSuccess.visibility = View.GONE _layoutPairingSuccess.visibility = View.GONE
finish()
} }
_layoutPairingError.setOnClickListener { _layoutPairingError.setOnClickListener {
_layoutPairingError.visibility = View.GONE _layoutPairingError.visibility = View.GONE
@@ -111,9 +112,15 @@ class SyncPairActivity : AppCompatActivity() {
try { try {
StateSync.instance.connect(deviceInfo) { complete, message -> StateSync.instance.connect(deviceInfo) { complete, message ->
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
if (complete != null && complete) { if (complete != null) {
_layoutPairingSuccess.visibility = View.VISIBLE if (complete) {
_layoutPairing.visibility = View.GONE _layoutPairingSuccess.visibility = View.VISIBLE
_layoutPairing.visibility = View.GONE
} else {
_textError.text = message
_layoutPairingError.visibility = View.VISIBLE
_layoutPairing.visibility = View.GONE
}
} else { } else {
_textPairingStatus.text = message _textPairingStatus.text = message
} }
@@ -137,8 +144,6 @@ class SyncPairActivity : AppCompatActivity() {
_textError.text = e.message _textError.text = e.message
_layoutPairing.visibility = View.GONE _layoutPairing.visibility = View.GONE
Logger.e(TAG, "Failed to pair", e) Logger.e(TAG, "Failed to pair", e)
} finally {
_layoutPairing.visibility = View.GONE
} }
} }
@@ -1,5 +1,6 @@
package com.futo.platformplayer.api.media 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.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
@@ -66,6 +67,11 @@ interface IPlatformClient {
*/ */
fun searchChannels(query: String): IPager<PlatformAuthorLink>; fun searchChannels(query: String): IPager<PlatformAuthorLink>;
/**
* Searches for channels and returns a content pager
*/
fun searchChannelsAsContent(query: String): IPager<IPlatformContent>;
//Video Pages //Video Pages
/** /**
@@ -2,7 +2,10 @@ package com.futo.platformplayer.api.media.models
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.PlatformID 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.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.models.JSContent
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow 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<String>(config, "thumbnail", contextName, null)
subscribers = if(obj.has("subscribers")) obj.getOrThrow(config,"subscribers", contextName) else null
}
} }
@@ -12,6 +12,7 @@ enum class ContentType(val value: Int) {
URL(9), URL(9),
NESTED_VIDEO(11), NESTED_VIDEO(11),
CHANNEL(60),
LOCKED(70), LOCKED(70),
@@ -10,6 +10,7 @@ import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformClientCapabilities 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.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
@@ -31,6 +32,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.IJSContent
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails 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.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.JSChannelPager
import com.futo.platformplayer.api.media.platforms.js.models.JSChapter import com.futo.platformplayer.api.media.platforms.js.models.JSChapter
import com.futo.platformplayer.api.media.platforms.js.models.JSComment import com.futo.platformplayer.api.media.platforms.js.models.JSComment
@@ -361,6 +363,10 @@ open class JSClient : IPlatformClient {
return@isBusyWith JSChannelPager(config, this, return@isBusyWith JSChannelPager(config, this,
plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})")); plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"));
} }
override fun searchChannelsAsContent(query: String): IPager<IPlatformContent> = 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") @JSDocs(6, "source.isChannelUrl(url)", "Validates if an channel url is for this platform")
@JSDocsParameter("url", "A channel url (May not be your platform)") @JSDocsParameter("url", "A channel url (May not be your platform)")
@@ -1,6 +1,7 @@
package com.futo.platformplayer.api.media.platforms.js.models package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject 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.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent 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.JSClient
@@ -26,6 +27,7 @@ interface IJSContent: IPlatformContent {
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj); ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
ContentType.PLAYLIST -> JSPlaylist(config, obj); ContentType.PLAYLIST -> JSPlaylist(config, obj);
ContentType.LOCKED -> JSLockedContent(config, obj); ContentType.LOCKED -> JSLockedContent(config, obj);
ContentType.CHANNEL -> JSChannelContent(config, obj)
else -> throw NotImplementedError("Unknown content type ${type}"); else -> throw NotImplementedError("Unknown content type ${type}");
} }
} }
@@ -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.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.V8Plugin
class JSChannelPager : JSPager<PlatformAuthorLink>, IPager<PlatformAuthorLink> { class JSChannelPager : JSPager<PlatformAuthorLink>, IPager<PlatformAuthorLink> {
@@ -49,8 +49,8 @@ open class JSContent : IPlatformContent, IPluginSourced {
else else
author = PlatformAuthorLink.UNKNOWN; author = PlatformAuthorLink.UNKNOWN;
val datetimeInt = _content.getOrThrow<Int>(config, "datetime", contextName).toLong(); val datetimeInt = _content.getOrDefault<Int>(config, "datetime", contextName, null)?.toLong();
if(datetimeInt == 0.toLong()) if(datetimeInt == null || datetimeInt == 0.toLong())
datetime = null; datetime = null;
else else
datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC); datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPluginSourced 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.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -15,4 +16,14 @@ class JSContentPager : JSPager<IPlatformContent>, IPluginSourced {
override fun convertResult(obj: V8ValueObject): IPlatformContent { override fun convertResult(obj: V8ValueObject): IPlatformContent {
return IJSContent.fromV8(plugin, obj); return IJSContent.fromV8(plugin, obj);
} }
}
class JSChannelContentPager : JSPager<IPlatformContent>, 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);
}
} }
@@ -149,6 +149,7 @@ class AirPlayCastingDevice : CastingDevice {
break; break;
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e) Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
delay(1000);
} }
} }
@@ -322,6 +322,7 @@ class ChromecastCastingDevice : CastingDevice {
break; break;
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e) Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
Thread.sleep(1000);
} }
} }
@@ -25,6 +25,7 @@ import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@@ -92,7 +93,7 @@ class FCastCastingDevice : CastingDevice {
private var _version: Long = 1; private var _version: Long = 1;
private var _thread: Thread? = null private var _thread: Thread? = null
private var _pingThread: Thread? = null private var _pingThread: Thread? = null
private var _lastPongTime = -1L @Volatile private var _lastPongTime = System.currentTimeMillis()
private var _outputStreamLock = Object() private var _outputStreamLock = Object()
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() { constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
@@ -289,6 +290,7 @@ class FCastCastingDevice : CastingDevice {
break; break;
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e) Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
Thread.sleep(1000);
} }
} }
@@ -326,9 +328,9 @@ class FCastCastingDevice : CastingDevice {
continue; continue;
} }
localAddress = _socket?.localAddress; localAddress = _socket?.localAddress
connectionState = CastConnectionState.CONNECTED; _lastPongTime = System.currentTimeMillis()
_lastPongTime = -1L connectionState = CastConnectionState.CONNECTED
val buffer = ByteArray(4096); val buffer = ByteArray(4096);
@@ -404,36 +406,32 @@ class FCastCastingDevice : CastingDevice {
_pingThread = Thread { _pingThread = Thread {
Logger.i(TAG, "Started ping loop.") Logger.i(TAG, "Started ping loop.")
while (_scopeIO?.isActive == true) { while (_scopeIO?.isActive == true) {
try { if (connectionState == CastConnectionState.CONNECTED) {
send(Opcode.Ping)
} catch (e: Throwable) {
Log.w(TAG, "Failed to send ping.")
try { try {
_socket?.close() send(Opcode.Ping)
_inputStream?.close() if (System.currentTimeMillis() - _lastPongTime > 15000) {
_outputStream?.close() 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) { } 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)
}
} }
} }
Thread.sleep(5000)
/*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)
} }
Logger.i(TAG, "Stopped ping loop.")
Logger.i(TAG, "Stopped ping loop.");
}.apply { start() } }.apply { start() }
} else { } else {
Log.i(TAG, "Thread was still alive, not restarted") Log.i(TAG, "Thread was still alive, not restarted")
@@ -4,10 +4,12 @@ import android.app.AlertDialog
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.Looper import android.os.Looper
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import android.util.Xml
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R import com.futo.platformplayer.R
@@ -40,11 +42,11 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.logging.Logger 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.models.CastingDeviceInfo
import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.states.StateSync.Companion
import com.futo.platformplayer.stores.CastingDeviceInfoStorage import com.futo.platformplayer.stores.CastingDeviceInfoStorage
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.toUrlAddress import com.futo.platformplayer.toUrlAddress
@@ -55,7 +57,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.net.InetAddress import java.net.InetAddress
import java.net.URLDecoder import java.net.URLDecoder
import java.net.URLEncoder import java.net.URLEncoder
@@ -70,7 +71,6 @@ class StateCasting {
private var _started = false; private var _started = false;
var devices: HashMap<String, CastingDevice> = hashMapOf(); var devices: HashMap<String, CastingDevice> = hashMapOf();
var rememberedDevices: ArrayList<CastingDevice> = arrayListOf();
val onDeviceAdded = Event1<CastingDevice>(); val onDeviceAdded = Event1<CastingDevice>();
val onDeviceChanged = Event1<CastingDevice>(); val onDeviceChanged = Event1<CastingDevice>();
val onDeviceRemoved = Event1<CastingDevice>(); val onDeviceRemoved = Event1<CastingDevice>();
@@ -84,48 +84,15 @@ class StateCasting {
private var _audioExecutor: JSRequestExecutor? = null private var _audioExecutor: JSRequestExecutor? = null
private val _client = ManagedHttpClient(); private val _client = ManagedHttpClient();
var _resumeCastingDevice: CastingDeviceInfo? = null; var _resumeCastingDevice: CastingDeviceInfo? = null;
val _serviceDiscoverer = ServiceDiscoverer(arrayOf( private var _nsdManager: NsdManager? = null
"_googlecast._tcp.local",
"_airplay._tcp.local",
"_fastcast._tcp.local",
"_fcast._tcp.local"
)) { handleServiceUpdated(it) }
val isCasting: Boolean get() = activeDevice != null; val isCasting: Boolean get() = activeDevice != null;
private fun handleServiceUpdated(services: List<DnsService>) { private val _discoveryListeners = mapOf(
for (s in services) { "_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
//TODO: Addresses IPv4 only? "_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
val addresses = s.addresses.toTypedArray() "_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
val port = s.port.toInt() "_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
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)
}
}
}
fun handleUrl(context: Context, url: String) { fun handleUrl(context: Context, url: String) {
val uri = Uri.parse(url) val uri = Uri.parse(url)
@@ -190,30 +157,33 @@ class StateCasting {
Logger.i(TAG, "CastingService starting..."); Logger.i(TAG, "CastingService starting...");
rememberedDevices.clear();
rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) });
_castServer.start(); _castServer.start();
enableDeveloper(true); enableDeveloper(true);
Logger.i(TAG, "CastingService started."); Logger.i(TAG, "CastingService started.");
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
} }
@Synchronized @Synchronized
fun startDiscovering() { fun startDiscovering() {
try { _nsdManager?.apply {
_serviceDiscoverer.start() _discoveryListeners.forEach {
} catch (e: Throwable) { discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
Logger.i(TAG, "Failed to start ServiceDiscoverer", e) }
} }
} }
@Synchronized @Synchronized
fun stopDiscovering() { fun stopDiscovering() {
try { _nsdManager?.apply {
_serviceDiscoverer.stop() _discoveryListeners.forEach {
} catch (e: Throwable) { try {
Logger.i(TAG, "Failed to stop ServiceDiscoverer", e) stopServiceDiscovery(it.value)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
} }
} }
@@ -239,6 +209,85 @@ class StateCasting {
_castServer.removeAllHandlers(); _castServer.removeAllHandlers();
Logger.i(TAG, "CastingService stopped.") Logger.i(TAG, "CastingService stopped.")
_nsdManager = null
}
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, 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(); private val _castingDialogLock = Any();
@@ -294,7 +343,9 @@ class StateCasting {
UIDialogs.toast(it, "Connecting to device...") UIDialogs.toast(it, "Connecting to device...")
synchronized(_castingDialogLock) { synchronized(_castingDialogLock) {
if(_currentDialog == null) { 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", { UIDialogs.Action("Disconnect", {
device.stop(); device.stop();
})); }));
@@ -329,9 +380,6 @@ class StateCasting {
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) }; invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
}; };
addRememberedDevice(device);
Logger.i(TAG, "Device added to active discovery. Active discovery now contains ${_storage.getDevicesCount()} devices.")
try { try {
device.start(); device.start();
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -353,21 +401,22 @@ class StateCasting {
return addRememberedDevice(device); return addRememberedDevice(device);
} }
fun getRememberedCastingDevices(): List<CastingDevice> {
return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) }
}
fun getRememberedCastingDeviceNames(): List<String> {
return _storage.getDeviceNames()
}
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo { fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
val deviceInfo = device.getDeviceInfo() val deviceInfo = device.getDeviceInfo()
val foundInfo = _storage.addDevice(deviceInfo) return _storage.addDevice(deviceInfo)
if (foundInfo == deviceInfo) {
rememberedDevices.add(device);
return foundInfo;
}
return foundInfo;
} }
fun removeRememberedDevice(device: CastingDevice) { fun removeRememberedDevice(device: CastingDevice) {
val name = device.name ?: return; val name = device.name ?: return
_storage.removeDevice(name); _storage.removeDevice(name)
rememberedDevices.remove(device);
} }
private fun invokeInMainScopeIfRequired(action: () -> Unit){ private fun invokeInMainScopeIfRequired(action: () -> Unit){
@@ -9,7 +9,9 @@ import android.view.View
import android.widget.Button import android.widget.Button
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R 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.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.adapters.DeviceAdapter import com.futo.platformplayer.views.adapters.DeviceAdapter
import com.futo.platformplayer.views.adapters.DeviceAdapterEntry
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class ConnectCastingDialog(context: Context?) : AlertDialog(context) { class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
private lateinit var _imageLoader: ImageView; private lateinit var _imageLoader: ImageView;
private lateinit var _buttonClose: Button; private lateinit var _buttonClose: Button;
private lateinit var _buttonAdd: ImageButton; private lateinit var _buttonAdd: LinearLayout;
private lateinit var _buttonScanQR: ImageButton; private lateinit var _buttonScanQR: LinearLayout;
private lateinit var _textNoDevicesFound: TextView; private lateinit var _textNoDevicesFound: TextView;
private lateinit var _textNoDevicesRemembered: TextView;
private lateinit var _recyclerDevices: RecyclerView; private lateinit var _recyclerDevices: RecyclerView;
private lateinit var _recyclerRememberedDevices: RecyclerView;
private lateinit var _adapter: DeviceAdapter; private lateinit var _adapter: DeviceAdapter;
private lateinit var _rememberedAdapter: DeviceAdapter; private val _devices: MutableSet<String> = mutableSetOf()
private val _devices: ArrayList<CastingDevice> = arrayListOf(); private val _rememberedDevices: MutableSet<String> = mutableSetOf()
private val _rememberedDevices: ArrayList<CastingDevice> = arrayListOf(); private val _unifiedDevices: MutableList<DeviceAdapterEntry> = mutableListOf()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@@ -45,42 +46,40 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
_imageLoader = findViewById(R.id.image_loader); _imageLoader = findViewById(R.id.image_loader);
_buttonClose = findViewById(R.id.button_close); _buttonClose = findViewById(R.id.button_close);
_buttonAdd = findViewById(R.id.button_add); _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); _recyclerDevices = findViewById(R.id.recycler_devices);
_recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices);
_textNoDevicesFound = findViewById(R.id.text_no_devices_found); _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.adapter = _adapter;
_recyclerDevices.layoutManager = LinearLayoutManager(context); _recyclerDevices.layoutManager = LinearLayoutManager(context);
_rememberedAdapter = DeviceAdapter(_rememberedDevices, true); _adapter.onPin.subscribe { d ->
_rememberedAdapter.onRemove.subscribe { d -> val isRemembered = _rememberedDevices.contains(d.name)
if (StateCasting.instance.activeDevice == d) { val newIsRemembered = !isRemembered
d.stopCasting(); 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)
} }
updateUnifiedList()
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)
} }
//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 { _ -> _adapter.onConnect.subscribe { _ ->
dismiss() dismiss()
//UIDialogs.showCastingDialog(context) //UIDialogs.showCastingDialog(context)
} }
_recyclerRememberedDevices.adapter = _rememberedAdapter;
_recyclerRememberedDevices.layoutManager = LinearLayoutManager(context);
_buttonClose.setOnClickListener { dismiss(); }; _buttonClose.setOnClickListener { dismiss(); };
_buttonAdd.setOnClickListener { _buttonAdd.setOnClickListener {
@@ -105,77 +104,112 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
Logger.i(TAG, "Dialog shown."); Logger.i(TAG, "Dialog shown.");
StateCasting.instance.startDiscovering() StateCasting.instance.startDiscovering()
(_imageLoader.drawable as Animatable?)?.start(); (_imageLoader.drawable as Animatable?)?.start();
_devices.clear(); synchronized(StateCasting.instance.devices) {
synchronized (StateCasting.instance.devices) { _devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
_devices.addAll(StateCasting.instance.devices.values);
} }
_rememberedDevices.clear(); _rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
synchronized (StateCasting.instance.rememberedDevices) { updateUnifiedList()
_rememberedDevices.addAll(StateCasting.instance.rememberedDevices);
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; _textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerDevices.visibility = if (_devices.isNotEmpty()) 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() { override fun dismiss() {
super.dismiss(); super.dismiss()
(_imageLoader.drawable as Animatable?)?.stop()
(_imageLoader.drawable as Animatable?)?.stop();
StateCasting.instance.stopDiscovering() StateCasting.instance.stopDiscovering()
StateCasting.instance.onDeviceAdded.remove(this); StateCasting.instance.onDeviceAdded.remove(this)
StateCasting.instance.onDeviceChanged.remove(this); StateCasting.instance.onDeviceChanged.remove(this)
StateCasting.instance.onDeviceRemoved.remove(this); StateCasting.instance.onDeviceRemoved.remove(this)
StateCasting.instance.onActiveDeviceConnectionStateChanged.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<DeviceAdapterEntry> {
val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name }
val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name }
val unifiedList = mutableListOf<DeviceAdapterEntry>()
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 { companion object {
@@ -72,6 +72,10 @@ class PackageBridge : V8Package {
fun buildSpecVersion(): Int { fun buildSpecVersion(): Int {
return JSClientConstants.PLUGIN_SPEC_VERSION; return JSClientConstants.PLUGIN_SPEC_VERSION;
} }
@V8Property
fun buildPlatform(): String {
return "android";
}
@V8Function @V8Function
fun dispose(value: V8Value) { fun dispose(value: V8Value) {
@@ -201,11 +201,12 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
protected open fun onContentUrlClicked(url: String, contentType: ContentType) { protected open fun onContentUrlClicked(url: String, contentType: ContentType) {
when(contentType) { when(contentType) {
ContentType.MEDIA -> { ContentType.MEDIA -> {
StatePlayer.instance.clearQueue(); StatePlayer.instance.clearQueue()
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail(); fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
}; }
ContentType.PLAYLIST -> fragment.navigate<RemotePlaylistFragment>(url); ContentType.PLAYLIST -> fragment.navigate<RemotePlaylistFragment>(url)
ContentType.URL -> fragment.navigate<BrowserFragment>(url); ContentType.URL -> fragment.navigate<BrowserFragment>(url)
ContentType.CHANNEL -> fragment.navigate<ChannelFragment>(url)
else -> {}; else -> {};
} }
} }
@@ -2,15 +2,18 @@ package com.futo.platformplayer.fragment.mainactivity.main
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.allViews
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.ResultCapabilities 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.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.TaskHandler 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.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.isHttpUrl import com.futo.platformplayer.isHttpUrl
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.FeedStyle 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -84,6 +90,7 @@ class ContentSearchResultsFragment : MainFragment() {
private var _filterValues: HashMap<String, List<String>> = hashMapOf(); private var _filterValues: HashMap<String, List<String>> = hashMapOf();
private var _enabledClientIds: List<String>? = null; private var _enabledClientIds: List<String>? = null;
private var _channelUrl: String? = null; private var _channelUrl: String? = null;
private var _searchType: SearchType? = null;
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>; private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar
@@ -95,7 +102,13 @@ class ContentSearchResultsFragment : MainFragment() {
if (channelUrl != null) { if (channelUrl != null) {
StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds) StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds)
} else { } 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<ScriptCaptchaRequiredException> { } .success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
@@ -105,6 +118,25 @@ class ContentSearchResultsFragment : MainFragment() {
} }
setPreviewsEnabled(Settings.instance.search.previewFeedItems); 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() { override fun cleanup() {
@@ -116,6 +148,7 @@ class ContentSearchResultsFragment : MainFragment() {
if(parameter is SuggestionsFragmentData) { if(parameter is SuggestionsFragmentData) {
setQuery(parameter.query, false); setQuery(parameter.query, false);
setChannelUrl(parameter.channelUrl, false); setChannelUrl(parameter.channelUrl, false);
setSearchType(parameter.searchType, false)
fragment.topBar?.apply { fragment.topBar?.apply {
if (this is SearchTopBarFragment) { if (this is SearchTopBarFragment) {
@@ -258,6 +291,15 @@ class ContentSearchResultsFragment : MainFragment() {
} }
} }
private fun setSearchType(searchType: SearchType, updateResults: Boolean = true) {
_searchType = searchType
if (updateResults) {
clearResults();
loadResults();
}
}
private fun setSortBy(sortBy: String?, updateResults: Boolean = true) { private fun setSortBy(sortBy: String?, updateResults: Boolean = true) {
_sortBy = sortBy; _sortBy = sortBy;
@@ -14,10 +14,14 @@ import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R 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.VideoDownload
import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Playlist 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.StateDownloads
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlaylists
@@ -54,6 +58,15 @@ class DownloadsFragment : MainFragment() {
super.onResume() super.onResume()
_view?.reloadUI(); _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) { StateDownloads.instance.onDownloadsChanged.subscribe(this) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
try { try {
@@ -136,7 +136,6 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_toolbarContentView = findViewById(R.id.container_toolbar_content); _toolbarContentView = findViewById(R.id.container_toolbar_content);
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, { _nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
if (it is IAsyncPager<*>) if (it is IAsyncPager<*>)
it.nextPageAsync(); it.nextPageAsync();
else else
@@ -18,6 +18,8 @@ import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.SearchHistoryStorage import com.futo.platformplayer.stores.SearchHistoryStorage
import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter 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, val channelUrl: String? = null);
@@ -28,6 +30,7 @@ class SuggestionsFragment : MainFragment {
private var _recyclerSuggestions: RecyclerView? = null; private var _recyclerSuggestions: RecyclerView? = null;
private var _llmSuggestions: LinearLayoutManager? = null; private var _llmSuggestions: LinearLayoutManager? = null;
private var _radioGroupView: RadioGroupView? = null;
private val _suggestions: ArrayList<String> = ArrayList(); private val _suggestions: ArrayList<String> = ArrayList();
private var _query: String? = null; private var _query: String? = null;
private var _searchType: SearchType = SearchType.VIDEO; private var _searchType: SearchType = SearchType.VIDEO;
@@ -49,14 +52,7 @@ class SuggestionsFragment : MainFragment {
_adapterSuggestions.onClicked.subscribe { suggestion -> _adapterSuggestions.onClicked.subscribe { suggestion ->
val storage = FragmentedStorage.get<SearchHistoryStorage>(); val storage = FragmentedStorage.get<SearchHistoryStorage>();
storage.add(suggestion); storage.add(suggestion);
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType, _channelUrl));
if (_searchType == SearchType.CREATOR) {
navigate<CreatorSearchResultsFragment>(suggestion);
} else if (_searchType == SearchType.PLAYLIST) {
navigate<PlaylistSearchResultsFragment>(suggestion);
} else {
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, SearchType.VIDEO, _channelUrl));
}
} }
_adapterSuggestions.onRemove.subscribe { suggestion -> _adapterSuggestions.onRemove.subscribe { suggestion ->
val index = _suggestions.indexOf(suggestion); val index = _suggestions.indexOf(suggestion);
@@ -80,6 +76,15 @@ class SuggestionsFragment : MainFragment {
recyclerSuggestions.adapter = _adapterSuggestions; recyclerSuggestions.adapter = _adapterSuggestions;
_recyclerSuggestions = recyclerSuggestions; _recyclerSuggestions = recyclerSuggestions;
_radioGroupView = view.findViewById<RadioGroupView>(R.id.radio_group).apply {
onSelectedChange.subscribe {
if (it.size != 1)
_searchType = SearchType.VIDEO
else
_searchType = (it[0] ?: SearchType.VIDEO) as SearchType
}
}
loadSuggestions(); loadSuggestions();
return view; return view;
} }
@@ -110,31 +115,27 @@ class SuggestionsFragment : MainFragment {
_channelUrl = null; _channelUrl = null;
} }
_radioGroupView?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true)
topBar?.apply { topBar?.apply {
if (this is SearchTopBarFragment) { if (this is SearchTopBarFragment) {
onSearch.subscribe(this) { onSearch.subscribe(this) {
if (_searchType == SearchType.CREATOR) { if(it.isHttpUrl()) {
navigate<CreatorSearchResultsFragment>(it); if(StatePlatform.instance.hasEnabledPlaylistClient(it))
} else if (_searchType == SearchType.PLAYLIST) { navigate<RemotePlaylistFragment>(it);
navigate<PlaylistSearchResultsFragment>(it); else if(StatePlatform.instance.hasEnabledChannelClient(it))
} else { navigate<ChannelFragment>(it);
if(it.isHttpUrl()) { else {
if(StatePlatform.instance.hasEnabledPlaylistClient(it)) val url = it;
navigate<RemotePlaylistFragment>(it); activity?.let {
else if(StatePlatform.instance.hasEnabledChannelClient(it)) close()
navigate<ChannelFragment>(it); if(it is MainActivity)
else { it.navigate(it.getFragment<VideoDetailFragment>(), url);
val url = it;
activity?.let {
close()
if(it is MainActivity)
it.navigate(it.getFragment<VideoDetailFragment>(), url);
}
} }
} }
else
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
} }
else
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType, _channelUrl));
}; };
onTextChange.subscribe(this) { onTextChange.subscribe(this) {
@@ -196,6 +197,7 @@ class SuggestionsFragment : MainFragment {
super.onDestroyMainView(); super.onDestroyMainView();
_getSuggestions.onError.clear(); _getSuggestions.onError.clear();
_recyclerSuggestions = null; _recyclerSuggestions = null;
_radioGroupView = null
} }
override fun onDestroy() { override fun onDestroy() {
@@ -1,9 +1,11 @@
package com.futo.platformplayer.fragment.mainactivity.main package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.util.TypedValue import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
@@ -48,6 +50,11 @@ abstract class VideoListEditorView : LinearLayout {
private var _loadedVideos: List<IPlatformVideo>? = null; private var _loadedVideos: List<IPlatformVideo>? = null;
private var _loadedVideosCanEdit: Boolean = false; 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) { constructor(inflater: LayoutInflater) : super(inflater.context) {
inflater.inflate(R.layout.fragment_video_list_editor, this); inflater.inflate(R.layout.fragment_video_list_editor, this);
@@ -79,6 +86,7 @@ abstract class VideoListEditorView : LinearLayout {
_search.textSearch.text = ""; _search.textSearch.text = "";
updateVideoFilters(); updateVideoFilters();
_buttonSearch.setImageResource(R.drawable.ic_search); _buttonSearch.setImageResource(R.drawable.ic_search);
hideSearchKeyboard();
} }
else { else {
_search.visibility = View.VISIBLE; _search.visibility = View.VISIBLE;
@@ -89,23 +97,23 @@ abstract class VideoListEditorView : LinearLayout {
_buttonShare = findViewById(R.id.button_share); _buttonShare = findViewById(R.id.button_share);
val onShare = _onShare; val onShare = _onShare;
if(onShare != null) { if(onShare != null) {
_buttonShare.setOnClickListener { onShare.invoke() }; _buttonShare.setOnClickListener { hideSearchKeyboard(); onShare.invoke() };
_buttonShare.visibility = View.VISIBLE; _buttonShare.visibility = View.VISIBLE;
} }
else else
_buttonShare.visibility = View.GONE; _buttonShare.visibility = View.GONE;
buttonPlayAll.setOnClickListener { onPlayAllClick(); }; buttonPlayAll.setOnClickListener { hideSearchKeyboard();onPlayAllClick(); hideSearchKeyboard(); };
buttonShuffle.setOnClickListener { onShuffleClick(); }; buttonShuffle.setOnClickListener { hideSearchKeyboard();onShuffleClick(); hideSearchKeyboard(); };
_buttonEdit.setOnClickListener { onEditClick(); }; _buttonEdit.setOnClickListener { hideSearchKeyboard(); onEditClick(); };
setButtonExportVisible(false); setButtonExportVisible(false);
setButtonDownloadVisible(canEdit()); setButtonDownloadVisible(canEdit());
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged); videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved); videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved);
videoListEditorView.onVideoOptions.subscribe(::onVideoOptions); videoListEditorView.onVideoOptions.subscribe(::onVideoOptions);
videoListEditorView.onVideoClicked.subscribe(::onVideoClicked); videoListEditorView.onVideoClicked.subscribe { hideSearchKeyboard(); onVideoClicked(it)};
_videoListEditorView = videoListEditorView; _videoListEditorView = videoListEditorView;
} }
@@ -113,6 +121,7 @@ abstract class VideoListEditorView : LinearLayout {
fun setOnShare(onShare: (()-> Unit)? = null) { fun setOnShare(onShare: (()-> Unit)? = null) {
_onShare = onShare; _onShare = onShare;
_buttonShare.setOnClickListener { _buttonShare.setOnClickListener {
hideSearchKeyboard();
onShare?.invoke(); onShare?.invoke();
}; };
_buttonShare.visibility = View.VISIBLE; _buttonShare.visibility = View.VISIBLE;
@@ -145,7 +154,7 @@ abstract class VideoListEditorView : LinearLayout {
setButtonExportVisible(false); setButtonExportVisible(false);
_buttonDownload.setImageResource(R.drawable.ic_loader_animated); _buttonDownload.setImageResource(R.drawable.ic_loader_animated);
_buttonDownload.drawable.assume<Animatable, Unit> { it.start() }; _buttonDownload.drawable.assume<Animatable, Unit> { it.start() };
_buttonDownload.setOnClickListener { _buttonDownload.setOnClickListener { hideSearchKeyboard();
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), { UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
StateDownloads.instance.deleteCachedPlaylist(playlistId); StateDownloads.instance.deleteCachedPlaylist(playlistId);
}); });
@@ -154,7 +163,7 @@ abstract class VideoListEditorView : LinearLayout {
else if(isDownloaded) { else if(isDownloaded) {
setButtonExportVisible(true) setButtonExportVisible(true)
_buttonDownload.setImageResource(R.drawable.ic_download_off); _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), { UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
StateDownloads.instance.deleteCachedPlaylist(playlistId); StateDownloads.instance.deleteCachedPlaylist(playlistId);
}); });
@@ -163,7 +172,7 @@ abstract class VideoListEditorView : LinearLayout {
else { else {
setButtonExportVisible(false); setButtonExportVisible(false);
_buttonDownload.setImageResource(R.drawable.ic_download); _buttonDownload.setImageResource(R.drawable.ic_download);
_buttonDownload.setOnClickListener { _buttonDownload.setOnClickListener { hideSearchKeyboard();
onDownload(); onDownload();
//UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer); //UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer);
} }
@@ -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<String>? = 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<DnsQuestion>,
val answers: List<DnsResourceRecord>,
val authorities: List<DnsResourceRecord>,
val additionals: List<DnsResourceRecord>
) {
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
)
}
}
}
@@ -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<DnsQuestion, Int> {
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
)
@@ -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<String>)
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<Pair<Byte, ByteArray>>)
data class NSEC3Record(
val hashAlgorithm: Byte,
val flags: Byte,
val iterations: UShort,
val salt: ByteArray,
val nextHashedOwnerName: ByteArray,
val typeBitMaps: List<UShort>
)
data class NSEC3PARAMRecord(val hashAlgorithm: Byte, val flags: Byte, val iterations: UShort, val salt: ByteArray)
data class SPFRecord(val texts: List<String>)
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<OPTRecordOption>)
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<String>()
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<Pair<Byte, ByteArray>>()
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<UShort>()
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<String>()
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<OPTRecordOption>()
while (position < endPosition) {
val optionCode = readUInt16()
val optionLength = readUInt16().toInt()
val optionData = readBytes(optionLength)
options.add(OPTRecordOption(optionCode, optionData))
}
return OPTRecord(options)
}
}
@@ -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<DnsResourceRecord, Int> {
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)
}
}
@@ -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<Byte>()
private val namePositions = mutableMapOf<String, Int>()
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)
}
}
}
@@ -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<String, Int> {
var position = startPosition
return readDomainName(position, 0)
}
private fun UByteArray.readDomainName(position: Int, depth: Int = 0): Pair<String, Int> {
if (depth > 16) throw Exception("Exceeded maximum recursion depth in DNS packet. Possible circular reference.")
val domainParts = mutableListOf<String>()
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
}
}
@@ -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<MulticastSocket>()
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<DnsService>) -> Unit)? = null
private val _recordLockObject = ReentrantLock()
private val _recordsA = mutableListOf<Pair<DnsResourceRecord, ARecord>>()
private val _recordsAAAA = mutableListOf<Pair<DnsResourceRecord, AAAARecord>>()
private val _recordsPTR = mutableListOf<Pair<DnsResourceRecord, PTRRecord>>()
private val _recordsTXT = mutableListOf<Pair<DnsResourceRecord, TXTRecord>>()
private val _recordsSRV = mutableListOf<Pair<DnsResourceRecord, SRVRecord>>()
private val _services = mutableListOf<BroadcastService>()
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<String>) {
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<String>) {
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<NetworkInterface>) {
_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<NetworkInterface>) {
_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<String>? = 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<DnsQuestion>? = null) {
val writer = DnsWriter()
_recordLockObject.withLock {
val recordsA: List<Pair<DnsResourceRecord, ARecord>>
val recordsAAAA: List<Pair<DnsResourceRecord, AAAARecord>>
val recordsPTR: List<Pair<DnsResourceRecord, PTRRecord>>
val recordsTXT: List<Pair<DnsResourceRecord, TXTRecord>>
val recordsSRV: List<Pair<DnsResourceRecord, SRVRecord>>
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
}
}
@@ -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<NetworkInterface>()
private var cts: Job? = null
val current: List<NetworkInterface>
get() = synchronized(nics) { nics.toList() }
var added: ((List<NetworkInterface>) -> Unit)? = null
var removed: ((List<NetworkInterface>) -> 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<NetworkInterface> {
val nics = NetworkInterface.getNetworkInterfaces().toList()
.filter { it.isUp && !it.isLoopback }
return if (nics.isNotEmpty()) nics else NetworkInterface.getNetworkInterfaces().toList()
.filter { it.isUp }
}
}
@@ -1,71 +0,0 @@
package com.futo.platformplayer.mdns
import com.futo.platformplayer.logging.Logger
import java.lang.Thread.sleep
class ServiceDiscoverer(names: Array<String>, private val _onServicesUpdated: (List<DnsService>) -> Unit) {
private val _names: Array<String>
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<String>? = 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"
}
}
@@ -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<InetAddress> = mutableListOf(),
val pointers: MutableList<String> = mutableListOf(),
val texts: MutableList<String> = mutableListOf()
)
data class CachedDnsAddressRecord(
val expirationTime: Date,
val address: InetAddress
)
data class CachedDnsTxtRecord(
val expirationTime: Date,
val texts: List<String>
)
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<String, MutableList<CachedDnsAddressRecord>>()
private val _cachedTxtRecords = mutableMapOf<String, CachedDnsTxtRecord>()
private val _cachedPtrRecords = mutableMapOf<String, MutableList<CachedDnsPtrRecord>>()
private val _cachedSrvRecords = mutableMapOf<String, CachedDnsSrvRecord>()
private val _currentServices = mutableListOf<DnsService>()
private var _cts: Job? = null
var onServicesUpdated: ((List<DnsService>) -> 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<DnsService>
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<DnsQuestion> {
val questions = mutableListOf<DnsQuestion>()
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<DnsService> {
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 <T> MutableList<T>.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"
}
}
@@ -29,7 +29,7 @@ data class ImageVariable(
Glide.with(imageView) Glide.with(imageView)
.load(bitmap) .load(bitmap)
.into(imageView) .into(imageView)
} else if(resId != null) { } else if(resId != null && resId > 0) {
Glide.with(imageView) Glide.with(imageView)
.load(resId) .load(resId)
.into(imageView) .into(imageView)
@@ -113,7 +113,7 @@ class LoginWebViewClient : WebViewClient {
//val domainParts = domain!!.split("."); //val domainParts = domain!!.split(".");
//val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString("."); //val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString(".");
val cookieDomain = domain!!.getSubdomainWildcardQuery(); 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 -> _authConfig.cookiesToFind?.let { cookiesToFind ->
val cookies = cookieString.split(";"); val cookies = cookieString.split(";");
for(cookieStr in cookies) { for(cookieStr in cookies) {
@@ -67,7 +67,7 @@ class WebViewRequirementExtractor {
if(cookieString != null) { if(cookieString != null) {
//val domainParts = domain!!.split("."); //val domainParts = domain!!.split(".");
val cookieDomain = domain!!.getSubdomainWildcardQuery()//"." + domainParts.drop(domainParts.size - 2).joinToString("."); 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 -> cookiesToFind?.let { cookiesToFind ->
val cookies = cookieString.split(";"); val cookies = cookieString.split(";");
for(cookieStr in cookies) { for(cookieStr in cookies) {
@@ -29,6 +29,7 @@ import com.futo.platformplayer.activities.CaptchaActivity
import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity 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.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.background.BackgroundWorker import com.futo.platformplayer.background.BackgroundWorker
@@ -411,7 +412,27 @@ class StateApp {
} }
if (Settings.instance.synchronization.enabled) { if (Settings.instance.synchronization.enabled) {
StateSync.instance.start() StateSync.instance.start(context, {
try {
UIDialogs.toast("Failed to start sync, port in use")
} catch (e: Throwable) {
//Ignored
}
})
}
settingsActivityClosed.subscribe {
if (Settings.instance.synchronization.enabled) {
StateSync.instance.start(context, {
try {
UIDialogs.toast("Failed to start sync, port in use")
} catch (e: Throwable) {
//Ignored
}
})
} else {
StateSync.instance.stop()
}
} }
Logger.onLogSubmitted.subscribe { Logger.onLogSubmitted.subscribe {
@@ -519,12 +540,16 @@ class StateApp {
Logger.i(TAG, "MainApp Started: Fetch [Subscriptions]"); Logger.i(TAG, "MainApp Started: Fetch [Subscriptions]");
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount(); val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n"); 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 }; val isBelowRateLimit = !subRequestCounts.any { clientCount ->
if (isRateLimitReached) { clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true
};
if (isBelowRateLimit) {
Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}"); Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}");
delay(5000); delay(5000);
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5) scopeOrNull?.let {
StateSubscriptions.instance.updateSubscriptionFeed(scope, false); if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
StateSubscriptions.instance.updateSubscriptionFeed(it, false);
}
} }
else else
Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}"); Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}");
@@ -703,6 +728,7 @@ class StateApp {
StatePlayer.instance.closeMediaSession(); StatePlayer.instance.closeMediaSession();
StateCasting.instance.stop(); StateCasting.instance.stop();
StateSync.instance.stop();
StatePlayer.dispose(); StatePlayer.dispose();
Companion.dispose(); Companion.dispose();
_fileLogConsumer?.close(); _fileLogConsumer?.close();
@@ -632,6 +632,27 @@ class StatePlatform {
return pager; return pager;
} }
fun searchChannelsAsContent(query: String): IPager<IPlatformContent> {
Logger.i(TAG, "Platform - searchChannels");
val pagers = mutableMapOf<IPager<IPlatformContent>, 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<IPlatformContent>();
val pager = MultiDistributionContentPager(pagers);
pager.initialize();
return pager;
}
//Video //Video
fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { it.isContentDetailsUrl(url) }; fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { it.isContentDetailsUrl(url) };
@@ -19,6 +19,7 @@ import com.futo.platformplayer.exceptions.ReconstructionException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.sToOffsetDateTimeUTC
import com.futo.platformplayer.smartMerge import com.futo.platformplayer.smartMerge
import com.futo.platformplayer.states.StateSubscriptionGroups.Companion import com.futo.platformplayer.states.StateSubscriptionGroups.Companion
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
@@ -85,7 +86,7 @@ class StatePlaylists {
if(value.isEmpty()) if(value.isEmpty())
return OffsetDateTime.MIN; return OffsetDateTime.MIN;
val tryParse = value.toLongOrNull() ?: 0; val tryParse = value.toLongOrNull() ?: 0;
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(tryParse), ZoneOffset.UTC); return tryParse.sToOffsetDateTimeUTC();
} }
private fun setWatchLaterReorderTime() { private fun setWatchLaterReorderTime() {
val now = OffsetDateTime.now(ZoneOffset.UTC); val now = OffsetDateTime.now(ZoneOffset.UTC);
@@ -1,26 +1,31 @@
package com.futo.platformplayer.states package com.futo.platformplayer.states
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import com.futo.platformplayer.LittleEndianDataInputStream import com.futo.platformplayer.LittleEndianDataInputStream
import com.futo.platformplayer.LittleEndianDataOutputStream import com.futo.platformplayer.LittleEndianDataOutputStream
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SyncShowPairingCodeActivity import com.futo.platformplayer.activities.SyncShowPairingCodeActivity
import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.casting.StateCasting.Companion
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.encryption.GEncryptionProvider import com.futo.platformplayer.encryption.GEncryptionProvider
import com.futo.platformplayer.generateReadablePassword import com.futo.platformplayer.generateReadablePassword
import com.futo.platformplayer.getConnectedSocket import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.mdns.DnsService
import com.futo.platformplayer.mdns.ServiceDiscoverer
import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.noise.protocol.DHState
import com.futo.platformplayer.noise.protocol.Noise import com.futo.platformplayer.noise.protocol.Noise
import com.futo.platformplayer.sToOffsetDateTimeUTC
import com.futo.platformplayer.smartMerge import com.futo.platformplayer.smartMerge
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStringMapStorage import com.futo.platformplayer.stores.StringStringMapStorage
@@ -32,11 +37,11 @@ import com.futo.platformplayer.sync.internal.ChannelSocket
import com.futo.platformplayer.sync.internal.GJSyncOpcodes import com.futo.platformplayer.sync.internal.GJSyncOpcodes
import com.futo.platformplayer.sync.internal.IAuthorizable import com.futo.platformplayer.sync.internal.IAuthorizable
import com.futo.platformplayer.sync.internal.IChannel import com.futo.platformplayer.sync.internal.IChannel
import com.futo.platformplayer.sync.internal.LinkType
import com.futo.platformplayer.sync.internal.Opcode import com.futo.platformplayer.sync.internal.Opcode
import com.futo.platformplayer.sync.internal.SyncDeviceInfo import com.futo.platformplayer.sync.internal.SyncDeviceInfo
import com.futo.platformplayer.sync.internal.SyncKeyPair import com.futo.platformplayer.sync.internal.SyncKeyPair
import com.futo.platformplayer.sync.internal.SyncSession import com.futo.platformplayer.sync.internal.SyncSession
import com.futo.platformplayer.sync.internal.SyncSession.Companion
import com.futo.platformplayer.sync.internal.SyncSocketSession import com.futo.platformplayer.sync.internal.SyncSocketSession
import com.futo.platformplayer.sync.models.SendToDevicePackage import com.futo.platformplayer.sync.models.SendToDevicePackage
import com.futo.platformplayer.sync.models.SyncPlaylistsPackage import com.futo.platformplayer.sync.models.SyncPlaylistsPackage
@@ -52,18 +57,17 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.lang.Thread.sleep
import java.net.InetAddress import java.net.InetAddress
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.ServerSocket import java.net.ServerSocket
import java.net.Socket import java.net.Socket
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.channels.Channel
import java.time.Instant import java.time.Instant
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.Base64 import java.util.Base64
import java.util.Locale import java.util.Locale
import kotlin.math.min
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
class StateSync { class StateSync {
@@ -76,18 +80,149 @@ class StateSync {
private var _serverSocket: ServerSocket? = null private var _serverSocket: ServerSocket? = null
private var _thread: Thread? = null private var _thread: Thread? = null
private var _connectThread: Thread? = null private var _connectThread: Thread? = null
private var _started = false @Volatile private var _started = false
private val _sessions: MutableMap<String, SyncSession> = mutableMapOf() private val _sessions: MutableMap<String, SyncSession> = mutableMapOf()
private val _lastConnectTimesMdns: MutableMap<String, Long> = mutableMapOf() private val _lastConnectTimesMdns: MutableMap<String, Long> = mutableMapOf()
private val _lastConnectTimesIp: MutableMap<String, Long> = mutableMapOf() private val _lastConnectTimesIp: MutableMap<String, Long> = mutableMapOf()
private var _serverStarted = false
//TODO: Should sync mdns and casting mdns be merged? //TODO: Should sync mdns and casting mdns be merged?
//TODO: Decrease interval that devices are updated //TODO: Decrease interval that devices are updated
//TODO: Send less data //TODO: Send less data
private val _serviceDiscoverer = ServiceDiscoverer(arrayOf("_gsync._tcp.local")) { handleServiceUpdated(it) }
private val _pairingCode: String? = generateReadablePassword(8) private val _pairingCode: String? = generateReadablePassword(8)
val pairingCode: String? get() = _pairingCode val pairingCode: String? get() = _pairingCode
private var _relaySession: SyncSocketSession? = null private var _relaySession: SyncSocketSession? = null
private var _threadRelay: Thread? = null private var _threadRelay: Thread? = null
private val _remotePendingStatusUpdate = mutableMapOf<String, (complete: Boolean?, message: String) -> Unit>()
private var _nsdManager: NsdManager? = null
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")
// 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)
}
}
fun addOrUpdate(name: String, adrs: Array<InetAddress>, port: Int, attributes: Map<String, ByteArray>) {
if (!Settings.instance.synchronization.connectDiscovered) {
return
}
val urlSafePkey = attributes.get("pk")?.decodeToString() ?: return
val pkey = Base64.getEncoder().encodeToString(Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')))
val syncDeviceInfo = SyncDeviceInfo(pkey, adrs.map { it.hostAddress }.toTypedArray(), port, null)
val authorized = isAuthorized(pkey)
if (authorized && !isConnected(pkey)) {
val now = System.currentTimeMillis()
val lastConnectTime = synchronized(_lastConnectTimesMdns) {
_lastConnectTimesMdns[pkey] ?: 0
}
//Connect once every 30 seconds, max
if (now - lastConnectTime > 30000) {
synchronized(_lastConnectTimesMdns) {
_lastConnectTimesMdns[pkey] = now
}
Logger.i(TAG, "Found device authorized device '${name}' with pkey=$pkey, attempting to connect")
try {
connect(syncDeviceInfo)
} catch (e: Throwable) {
Logger.i(TAG, "Failed to connect to $pkey", e)
}
}
}
}
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")
// 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, 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 keyPair: DHState? = null
var publicKey: String? = null var publicKey: String? = null
@@ -102,15 +237,18 @@ class StateSync {
} }
} }
fun start() { fun start(context: Context, onServerBindFail: () -> Unit) {
if (_started) { if (_started) {
Logger.i(TAG, "Already started.") Logger.i(TAG, "Already started.")
return return
} }
_started = true _started = true
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
if (Settings.instance.synchronization.broadcast || Settings.instance.synchronization.connectDiscovered) { if (Settings.instance.synchronization.connectDiscovered) {
_serviceDiscoverer.start() _nsdManager?.apply {
discoverServices("_gsync._tcp", NsdManager.PROTOCOL_DNS_SD, _discoveryListener)
}
} }
try { try {
@@ -143,31 +281,48 @@ class StateSync {
} }
if (Settings.instance.synchronization.broadcast) { if (Settings.instance.synchronization.broadcast) {
publicKey?.let { _serviceDiscoverer.broadcastService(getDeviceName(), "_gsync._tcp.local", PORT.toUShort(), texts = arrayListOf("pk=${it.replace('+', '-').replace('/', '_').replace("=", "")}")) } val pk = publicKey
val nsdManager = _nsdManager
if (pk != null && nsdManager != null) {
val serviceInfo = NsdServiceInfo().apply {
serviceName = getDeviceName()
serviceType = "_gsync._tcp"
port = PORT
setAttribute("pk", pk.replace('+', '-').replace('/', '_').replace("=", ""))
}
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, _registrationListener)
}
} }
Logger.i(TAG, "Sync key pair initialized (public key = ${publicKey})") Logger.i(TAG, "Sync key pair initialized (public key = ${publicKey})")
_thread = Thread { if (Settings.instance.synchronization.localConnections) {
try { _serverStarted = true
val serverSocket = ServerSocket(PORT) _thread = Thread {
_serverSocket = serverSocket try {
val serverSocket = ServerSocket(PORT)
_serverSocket = serverSocket
Log.i(TAG, "Running on port ${PORT} (TCP)") Log.i(TAG, "Running on port ${PORT} (TCP)")
while (_started) {
val socket = serverSocket.accept()
val session = createSocketSession(socket, true) { session ->
while (_started) {
val socket = serverSocket.accept()
val session = createSocketSession(socket, true)
session.startAsResponder()
} }
} catch (e: Throwable) {
session.startAsResponder() _serverStarted = false
Logger.e(TAG, "Failed to bind server socket to port ${PORT}", e)
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
onServerBindFail.invoke()
}
} finally {
_serverStarted = false
} }
} catch (e: Throwable) { }.apply { start() }
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) { if (Settings.instance.synchronization.connectLast) {
_connectThread = Thread { _connectThread = Thread {
@@ -194,8 +349,6 @@ class StateSync {
for (connectPair in addressesToConnect) { for (connectPair in addressesToConnect) {
try { try {
val syncDeviceInfo = SyncDeviceInfo(connectPair.first, arrayOf(connectPair.second), PORT, null)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val lastConnectTime = synchronized(_lastConnectTimesIp) { val lastConnectTime = synchronized(_lastConnectTimesIp) {
_lastConnectTimesIp[connectPair.first] ?: 0 _lastConnectTimesIp[connectPair.first] ?: 0
@@ -208,7 +361,7 @@ class StateSync {
} }
Logger.i(TAG, "Attempting to connect to authorized device by last known IP '${connectPair.first}' with pkey=${connectPair.first}") Logger.i(TAG, "Attempting to connect to authorized device by last known IP '${connectPair.first}' with pkey=${connectPair.first}")
connect(syncDeviceInfo) connect(arrayOf(connectPair.second), PORT, connectPair.first, null)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.i(TAG, "Failed to connect to " + connectPair.first, e) Logger.i(TAG, "Failed to connect to " + connectPair.first, e)
@@ -219,137 +372,155 @@ class StateSync {
}.apply { start() } }.apply { start() }
} }
_threadRelay = Thread { if (Settings.instance.synchronization.discoverThroughRelay) {
while (_started) { _threadRelay = Thread {
try { var backoffs: Array<Long> = arrayOf(1000, 5000, 10000, 20000)
Log.i(TAG, "Starting relay session...") var backoffIndex = 0;
var socketClosed = false; while (_started) {
val socket = Socket(RELAY_SERVER, 9000) try {
_relaySession = SyncSocketSession( Log.i(TAG, "Starting relay session...")
(socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!,
keyPair!!,
LittleEndianDataInputStream(socket.getInputStream()),
LittleEndianDataOutputStream(socket.getOutputStream()),
isHandshakeAllowed = { _, pk, pairingCode ->
Log.v(TAG, "Check if handshake allowed from '$pk'.")
if (pk == RELAY_PUBLIC_KEY)
return@SyncSocketSession true
synchronized(_authorizedDevices) { var socketClosed = false;
if (_authorizedDevices.values.contains(pk)) val socket = Socket(RELAY_SERVER, 9000)
return@SyncSocketSession true _relaySession = SyncSocketSession(
} (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!,
keyPair!!,
Log.v(TAG, "Check if handshake allowed with pairing code '$pairingCode' with active pairing code '$_pairingCode'.") socket,
if (_pairingCode == null || pairingCode.isNullOrEmpty()) isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) },
return@SyncSocketSession false onNewChannel = { _, c ->
val remotePublicKey = c.remotePublicKey
_pairingCode == pairingCode if (remotePublicKey == null) {
}, Log.e(TAG, "Remote public key should never be null in onNewChannel.")
onNewChannel = { _, c -> return@SyncSocketSession
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 = synchronized(_nameStorage) {
_nameStorage.get(remotePublicKey)
}
session = createNewSyncSession(remotePublicKey, remoteDeviceName) { }
_sessions[remotePublicKey] = session!!
} }
session!!.addChannel(c)
}
c.setDataHandler { _, channel, opcode, subOpcode, data -> Log.i(TAG, "New channel established from relay (pk: '$remotePublicKey').")
session?.handlePacket(opcode, subOpcode, data)
} var session: SyncSession?
c.setCloseHandler { channel -> synchronized(_sessions) {
session?.removeChannel(channel) session = _sessions[remotePublicKey]
} if (session == null) {
}, val remoteDeviceName = synchronized(_nameStorage) {
onChannelEstablished = { _, channel, isResponder -> _nameStorage.get(remotePublicKey)
handleAuthorization(channel, isResponder)
},
onClose = { socketClosed = true },
onHandshakeComplete = { relaySession ->
Thread {
try {
while (_started && !socketClosed) {
val unconnectedAuthorizedDevices = synchronized(_authorizedDevices) {
_authorizedDevices.values.filter { !isConnected(it) }.toTypedArray()
} }
session = createNewSyncSession(remotePublicKey, remoteDeviceName)
_sessions[remotePublicKey] = session!!
}
session!!.addChannel(c)
}
relaySession.publishConnectionInformation(unconnectedAuthorizedDevices, PORT, true, false, false, true) 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
val connectionInfos = runBlocking { relaySession.requestBulkConnectionInfo(unconnectedAuthorizedDevices) } Thread {
try {
while (_started && !socketClosed) {
val unconnectedAuthorizedDevices = synchronized(_authorizedDevices) {
_authorizedDevices.values.filter { !isConnected(it) }.toTypedArray()
}
for ((targetKey, connectionInfo) in connectionInfos) { relaySession.publishConnectionInformation(unconnectedAuthorizedDevices, PORT, Settings.instance.synchronization.discoverThroughRelay, false, false, Settings.instance.synchronization.discoverThroughRelay && Settings.instance.synchronization.connectThroughRelay)
val potentialLocalAddresses = connectionInfo.ipv4Addresses.union(connectionInfo.ipv6Addresses)
.filter { it != connectionInfo.remoteIp } Logger.v(TAG, "Requesting ${unconnectedAuthorizedDevices.size} devices connection information")
if (connectionInfo.allowLocalDirect) { val connectionInfos = runBlocking { relaySession.requestBulkConnectionInfo(unconnectedAuthorizedDevices) }
Thread { Logger.v(TAG, "Received ${connectionInfos.size} devices connection information")
for ((targetKey, connectionInfo) in connectionInfos) {
val potentialLocalAddresses = connectionInfo.ipv4Addresses.union(connectionInfo.ipv6Addresses)
.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(), PORT, 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 { try {
Log.v(TAG, "Attempting to connect directly, locally to '$targetKey'.") Logger.v(TAG, "Attempting relayed connection with '$targetKey'.")
connect(potentialLocalAddresses.map { it }.toTypedArray(), PORT, targetKey, null) runBlocking { relaySession.startRelayedChannel(targetKey, APP_ID, null) }
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, "Failed to start direct connection using connection info with $targetKey.", e) Logger.e(TAG, "Failed to start relayed channel 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.allowRemoteProxied) {
try {
Log.v(TAG, "Attempting relayed connection with '$targetKey'.")
runBlocking { relaySession.startRelayedChannel(targetKey, null) }
} catch (e: Throwable) {
Log.e(TAG, "Failed to start relayed channel with $targetKey.", e)
} }
} }
Thread.sleep(15000)
} }
} catch (e: Throwable) {
Thread.sleep(15000) Logger.e(TAG, "Unhandled exception in relay session.", e)
relaySession.stop()
} }
} catch (e: Throwable) { }.start()
Log.e(TAG, "Unhandled exception in relay session.", e) }
relaySession.stop() )
}
}.start() _relaySession!!.authorizable = object : IAuthorizable {
override val isAuthorized: Boolean get() = true
} }
)
_relaySession!!.authorizable = object : IAuthorizable { _relaySession!!.runAsInitiator(RELAY_PUBLIC_KEY, APP_ID, null)
override val isAuthorized: Boolean get() = true
Log.i(TAG, "Started relay session.")
} catch (e: Throwable) {
Log.e(TAG, "Relay session failed.", e)
} finally {
_relaySession?.stop()
_relaySession = null
Thread.sleep(backoffs[min(backoffs.size - 1, backoffIndex++)])
} }
_relaySession!!.startAsInitiator(RELAY_PUBLIC_KEY, null)
Log.i(TAG, "Started relay session.")
} catch (e: Throwable) {
Log.e(TAG, "Relay session failed.", e)
Thread.sleep(5000)
} finally {
_relaySession?.stop()
_relaySession = null
} }
}.apply { start() }
}
}
fun showFailedToBindDialogIfNecessary(context: Context) {
if (!_serverStarted && Settings.instance.synchronization.localConnections) {
try {
UIDialogs.showDialogOk(context, R.drawable.ic_warning, "Local discovery unavailable, port was in use")
} catch (e: Throwable) {
//Ignored
} }
}.apply { start() } }
}
fun confirmStarted(context: Context, onStarted: () -> Unit, onNotStarted: () -> Unit, onServerBindFail: () -> Unit) {
if (!_started) {
UIDialogs.showConfirmationDialog(context, "Sync has not been enabled yet, would you like to enable sync?", {
Settings.instance.synchronization.enabled = true
StateSync.instance.start(context, onServerBindFail)
Settings.instance.save()
onStarted.invoke()
}, {
onNotStarted.invoke()
})
} else {
onStarted.invoke()
}
} }
private fun getDeviceName(): String { private fun getDeviceName(): String {
@@ -401,48 +572,6 @@ class StateSync {
_syncSessionData.setAndSave(data.publicKey, data); _syncSessionData.setAndSave(data.publicKey, data);
} }
private fun handleServiceUpdated(services: List<DnsService>) {
if (!Settings.instance.synchronization.connectDiscovered) {
return
}
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, null)
val authorized = isAuthorized(pkey)
if (authorized && !isConnected(pkey)) {
val now = System.currentTimeMillis()
val lastConnectTime = synchronized(_lastConnectTimesMdns) {
_lastConnectTimesMdns[pkey] ?: 0
}
//Connect once every 30 seconds, max
if (now - lastConnectTime > 30000) {
synchronized(_lastConnectTimesMdns) {
_lastConnectTimesMdns[pkey] = now
}
Logger.i(TAG, "Found device authorized device '${name}' with pkey=$pkey, attempting to connect")
try {
connect(syncDeviceInfo)
} catch (e: Throwable) {
Logger.i(TAG, "Failed to connect to $pkey", e)
}
}
}
}
}
}
private fun unauthorize(remotePublicKey: String) { private fun unauthorize(remotePublicKey: String) {
Logger.i(TAG, "${remotePublicKey} unauthorized received") Logger.i(TAG, "${remotePublicKey} unauthorized received")
_authorizedDevices.remove(remotePublicKey) _authorizedDevices.remove(remotePublicKey)
@@ -469,7 +598,7 @@ class StateSync {
added.map { it.channel.name }.joinToString("\n")); added.map { it.channel.name }.joinToString("\n"));
if(pack.subscriptions.isNotEmpty()) { if(pack.subscriptionRemovals.isNotEmpty()) {
for (subRemoved in pack.subscriptionRemovals) { for (subRemoved in pack.subscriptionRemovals) {
val removed = StateSubscriptions.instance.applySubscriptionRemovals(pack.subscriptionRemovals); val removed = StateSubscriptions.instance.applySubscriptionRemovals(pack.subscriptionRemovals);
if(removed.size > 3) { if(removed.size > 3) {
@@ -507,16 +636,33 @@ class StateSync {
Logger.i(TAG, "Received SyncSessionData from $remotePublicKey"); 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})")
session.sendData(GJSyncOpcodes.syncSubscriptions, StateSubscriptions.instance.getSyncSubscriptionsPackageString()); val subscriptionGroupPackageString = StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString()
session.sendData(GJSyncOpcodes.syncSubscriptionGroups, StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString()); Logger.i(TAG, "syncStateExchange syncSubscriptionGroups b (size: ${subscriptionGroupPackageString.length})")
session.sendData(GJSyncOpcodes.syncPlaylists, StatePlaylists.instance.getSyncPlaylistsPackageString()) session.sendData(GJSyncOpcodes.syncSubscriptionGroups, subscriptionGroupPackageString);
Logger.i(TAG, "syncStateExchange syncSubscriptionGroups (size: ${subscriptionGroupPackageString.length})")
session.sendData(GJSyncOpcodes.syncWatchLater, Json.encodeToString(StatePlaylists.instance.getWatchLaterSyncPacket(false))); 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); val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory);
Logger.i(TAG, "syncStateExchange syncHistory b (size: ${recentHistory.size})")
if(recentHistory.isNotEmpty()) if(recentHistory.isNotEmpty())
session.sendJsonData(GJSyncOpcodes.syncHistory, recentHistory); session.sendJsonData(GJSyncOpcodes.syncHistory, recentHistory);
Logger.i(TAG, "syncStateExchange syncHistory (size: ${recentHistory.size})")
} }
GJSyncOpcodes.syncExport -> { GJSyncOpcodes.syncExport -> {
@@ -549,12 +695,14 @@ class StateSync {
val subPackage = Serializer.json.decodeFromString<SyncSubscriptionsPackage>(json); val subPackage = Serializer.json.decodeFromString<SyncSubscriptionsPackage>(json);
handleSyncSubscriptionPackage(session, subPackage); handleSyncSubscriptionPackage(session, subPackage);
val newestSub = subPackage.subscriptions.maxOf { it.creationTime }; if(subPackage.subscriptions.size > 0) {
val newestSub = subPackage.subscriptions.maxOf { it.creationTime };
val sesData = getSyncSessionData(remotePublicKey); val sesData = getSyncSessionData(remotePublicKey);
if(newestSub > sesData.lastSubscription) { if (newestSub > sesData.lastSubscription) {
sesData.lastSubscription = newestSub; sesData.lastSubscription = newestSub;
saveSyncSessionData(sesData); saveSyncSessionData(sesData);
}
} }
} }
@@ -584,7 +732,7 @@ class StateSync {
} }
for(removal in pack.groupRemovals) { for(removal in pack.groupRemovals) {
val creation = StateSubscriptionGroups.instance.getSubscriptionGroup(removal.key); val creation = StateSubscriptionGroups.instance.getSubscriptionGroup(removal.key);
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC); val removalTime = removal.value.sToOffsetDateTimeUTC();
if(creation != null && creation.creationTime < removalTime) if(creation != null && creation.creationTime < removalTime)
StateSubscriptionGroups.instance.deleteSubscriptionGroup(removal.key, false); StateSubscriptionGroups.instance.deleteSubscriptionGroup(removal.key, false);
} }
@@ -612,7 +760,7 @@ class StateSync {
} }
for(removal in pack.playlistRemovals) { for(removal in pack.playlistRemovals) {
val creation = StatePlaylists.instance.getPlaylist(removal.key); val creation = StatePlaylists.instance.getPlaylist(removal.key);
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC); val removalTime = removal.value.sToOffsetDateTimeUTC();
if(creation != null && creation.dateCreation < removalTime) if(creation != null && creation.dateCreation < removalTime)
StatePlaylists.instance.removePlaylist(creation, false); StatePlaylists.instance.removePlaylist(creation, false);
@@ -630,9 +778,9 @@ class StateSync {
val allExisting = StatePlaylists.instance.getWatchLater(); val allExisting = StatePlaylists.instance.getWatchLater();
for(video in pack.videos) { for(video in pack.videos) {
val existing = allExisting.firstOrNull { it.url == video.url }; 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; 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) { if(existing == null && time > removalTime) {
StatePlaylists.instance.addToWatchLater(video, false); StatePlaylists.instance.addToWatchLater(video, false);
if(time > OffsetDateTime.MIN) if(time > OffsetDateTime.MIN)
StatePlaylists.instance.setWatchLaterAddTime(video.url, time); StatePlaylists.instance.setWatchLaterAddTime(video.url, time);
@@ -641,12 +789,12 @@ class StateSync {
for(removal in pack.videoRemovals) { for(removal in pack.videoRemovals) {
val watchLater = allExisting.firstOrNull { it.url == removal.key } ?: continue; val watchLater = allExisting.firstOrNull { it.url == removal.key } ?: continue;
val creation = StatePlaylists.instance.getWatchLaterRemovalTime(watchLater.url) ?: OffsetDateTime.MIN; val creation = StatePlaylists.instance.getWatchLaterRemovalTime(watchLater.url) ?: OffsetDateTime.MIN;
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value), ZoneOffset.UTC); val removalTime = removal.value.sToOffsetDateTimeUTC()
if(creation < removalTime) if(creation < removalTime)
StatePlaylists.instance.removeFromWatchLater(watchLater, false, removalTime); StatePlaylists.instance.removeFromWatchLater(watchLater, false, removalTime);
} }
val packReorderTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.reorderTime), ZoneOffset.UTC); val packReorderTime = pack.reorderTime.sToOffsetDateTimeUTC()
val localReorderTime = StatePlaylists.instance.getWatchLaterLastReorderTime(); val localReorderTime = StatePlaylists.instance.getWatchLaterLastReorderTime();
if(localReorderTime < packReorderTime && pack.ordering != null) { if(localReorderTime < packReorderTime && pack.ordering != null) {
StatePlaylists.instance.updateWatchLaterOrdering(smartMerge(pack.ordering!!, StatePlaylists.instance.getWatchLaterOrdering()), true); StatePlaylists.instance.updateWatchLaterOrdering(smartMerge(pack.ordering!!, StatePlaylists.instance.getWatchLaterOrdering()), true);
@@ -659,6 +807,9 @@ class StateSync {
val json = String(dataBody, Charsets.UTF_8); val json = String(dataBody, Charsets.UTF_8);
val history = Serializer.json.decodeFromString<List<HistoryVideo>>(json); val history = Serializer.json.decodeFromString<List<HistoryVideo>>(json);
Logger.i(TAG, "SyncHistory received ${history.size} videos from ${remotePublicKey}"); 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; var lastHistory = OffsetDateTime.MIN;
for(video in history){ for(video in history){
@@ -680,10 +831,15 @@ class StateSync {
} }
} }
private fun createNewSyncSession(remotePublicKey: String, remoteDeviceName: String?, onAuthorized: ((SyncSession) -> Unit)?): SyncSession { private fun createNewSyncSession(rpk: String, remoteDeviceName: String?): SyncSession {
val remotePublicKey = rpk.base64ToByteArray().toBase64()
return SyncSession( return SyncSession(
remotePublicKey, remotePublicKey,
onAuthorized = { it, isNewlyAuthorized, isNewSession -> onAuthorized = { it, isNewlyAuthorized, isNewSession ->
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(true, "Authorized")
}
if (!isNewSession) { if (!isNewSession) {
return@SyncSession return@SyncSession
} }
@@ -694,8 +850,7 @@ class StateSync {
} }
} }
Logger.i(TAG, "${remotePublicKey} authorized (name: ${it.displayName})") Logger.i(TAG, "$remotePublicKey authorized (name: ${it.displayName})")
onAuthorized?.invoke(it)
_authorizedDevices.addDistinct(remotePublicKey) _authorizedDevices.addDistinct(remotePublicKey)
_authorizedDevices.save() _authorizedDevices.save()
deviceUpdatedOrAdded.emit(it.remotePublicKey, it) deviceUpdatedOrAdded.emit(it.remotePublicKey, it)
@@ -703,7 +858,12 @@ class StateSync {
checkForSync(it); checkForSync(it);
}, },
onUnauthorized = { onUnauthorized = {
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Unauthorized")
}
unauthorize(remotePublicKey) unauthorize(remotePublicKey)
Logger.i(TAG, "$remotePublicKey unauthorized (name: ${it.displayName})")
synchronized(_sessions) { synchronized(_sessions) {
it.close() it.close()
@@ -711,7 +871,7 @@ class StateSync {
} }
}, },
onConnectedChanged = { it, connected -> onConnectedChanged = { it, connected ->
Logger.i(TAG, "$remotePublicKey connected: " + connected) Logger.i(TAG, "$remotePublicKey connected: $connected")
deviceUpdatedOrAdded.emit(it.remotePublicKey, it) deviceUpdatedOrAdded.emit(it.remotePublicKey, it)
}, },
onClose = { onClose = {
@@ -723,41 +883,63 @@ class StateSync {
} }
deviceRemoved.emit(it.remotePublicKey) deviceRemoved.emit(it.remotePublicKey)
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Connection closed")
}
}, },
dataHandler = { it, opcode, subOpcode, data -> dataHandler = { it, opcode, subOpcode, data ->
handleData(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()
}
}
}, },
remoteDeviceName remoteDeviceName
) )
} }
private fun createSocketSession(socket: Socket, isResponder: Boolean, onAuthorized: (session: SyncSession) -> Unit): SyncSocketSession { 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 == RELAY_PUBLIC_KEY)
return true
synchronized(_authorizedDevices) {
if (_authorizedDevices.values.contains(publicKey)) {
if (linkType == LinkType.Relayed && !Settings.instance.synchronization.connectThroughRelay)
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.instance.synchronization.pairThroughRelay)
return false
return _pairingCode == pairingCode
}
private fun createSocketSession(socket: Socket, isResponder: Boolean): SyncSocketSession {
var session: SyncSession? = null var session: SyncSession? = null
var channelSocket: ChannelSocket? = null var channelSocket: ChannelSocket? = null
return SyncSocketSession( return SyncSocketSession(
(socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!,
keyPair!!, keyPair!!,
LittleEndianDataInputStream(socket.getInputStream()), socket,
LittleEndianDataOutputStream(socket.getOutputStream()),
onClose = { s -> onClose = { s ->
if (channelSocket != null) if (channelSocket != null)
session?.removeChannel(channelSocket!!) session?.removeChannel(channelSocket!!)
}, },
isHandshakeAllowed = { _, pk, pairingCode -> isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) },
Logger.v(TAG, "Check if handshake allowed from '${pk}'.")
synchronized (_authorizedDevices)
{
if (_authorizedDevices.values.contains(pk))
return@SyncSocketSession true
}
Logger.v(TAG, "Check if handshake allowed with pairing code '${pairingCode}' with active pairing code '${_pairingCode}'.");
if (_pairingCode == null || pairingCode.isNullOrEmpty())
return@SyncSocketSession false
return@SyncSocketSession _pairingCode == pairingCode
},
onHandshakeComplete = { s -> onHandshakeComplete = { s ->
val remotePublicKey = s.remotePublicKey val remotePublicKey = s.remotePublicKey
if (remotePublicKey == null) { if (remotePublicKey == null) {
@@ -780,7 +962,7 @@ class StateSync {
_lastAddressStorage.setAndSave(remotePublicKey, s.remoteAddress) _lastAddressStorage.setAndSave(remotePublicKey, s.remoteAddress)
} }
session = createNewSyncSession(remotePublicKey, remoteDeviceName, onAuthorized) session = createNewSyncSession(remotePublicKey, remoteDeviceName)
_sessions[remotePublicKey] = session!! _sessions[remotePublicKey] = session!!
} }
session!!.addChannel(channelSocket!!) session!!.addChannel(channelSocket!!)
@@ -890,19 +1072,31 @@ class StateSync {
fun stop() { fun stop() {
_started = false _started = false
_serviceDiscoverer.stop()
try {
_nsdManager?.stopServiceDiscovery(_discoveryListener)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to stop discovery listener", e)
}
try {
_nsdManager?.unregisterService(_registrationListener)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to unregister service", e)
}
_relaySession?.stop()
_serverSocket?.close() _serverSocket?.close()
_serverSocket = null _serverSocket = null
_thread?.interrupt() synchronized(_sessions) {
_thread = null _sessions.values.forEach { it.close() }
_connectThread?.interrupt() _sessions.clear()
_connectThread = null }
_threadRelay?.interrupt()
_threadRelay = null
_relaySession?.stop() _thread = null
_connectThread = null
_threadRelay = null
_relaySession = null _relaySession = null
} }
@@ -912,15 +1106,19 @@ class StateSync {
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to connect directly", e) Logger.e(TAG, "Failed to connect directly", e)
val relaySession = _relaySession val relaySession = _relaySession
if (relaySession != null) { if (relaySession != null && Settings.instance.synchronization.pairThroughRelay) {
onStatusUpdate?.invoke(null, "Connecting via relay...") onStatusUpdate?.invoke(null, "Connecting via relay...")
runBlocking { runBlocking {
relaySession.startRelayedChannel(deviceInfo.publicKey, deviceInfo.pairingCode) if (onStatusUpdate != null) {
onStatusUpdate?.invoke(true, "Connected") synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate[deviceInfo.publicKey.base64ToByteArray().toBase64()] = onStatusUpdate
}
}
relaySession.startRelayedChannel(deviceInfo.publicKey, APP_ID, deviceInfo.pairingCode)
} }
} else { } else {
throw Exception("Failed to connect.") throw e
} }
} }
} }
@@ -930,11 +1128,14 @@ class StateSync {
val socket = getConnectedSocket(addresses.map { InetAddress.getByName(it) }, port) ?: throw Exception("Failed to connect") val socket = getConnectedSocket(addresses.map { InetAddress.getByName(it) }, port) ?: throw Exception("Failed to connect")
onStatusUpdate?.invoke(null, "Handshaking...") onStatusUpdate?.invoke(null, "Handshaking...")
val session = createSocketSession(socket, false) { s -> val session = createSocketSession(socket, false)
onStatusUpdate?.invoke(true, "Authorized") if (onStatusUpdate != null) {
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate[publicKey.base64ToByteArray().toBase64()] = onStatusUpdate
}
} }
session.startAsInitiator(publicKey, pairingCode) session.startAsInitiator(publicKey, APP_ID, pairingCode)
return session return session
} }
@@ -992,6 +1193,7 @@ class StateSync {
val version = 1 val version = 1
val RELAY_SERVER = "relay.grayjay.app" val RELAY_SERVER = "relay.grayjay.app"
val RELAY_PUBLIC_KEY = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw=" val RELAY_PUBLIC_KEY = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw="
val APP_ID = 0x534A5247u //GRayJaySync (GRJS)
private const val TAG = "StateSync" private const val TAG = "StateSync"
const val PORT = 12315 const val PORT = 12315
@@ -19,6 +19,11 @@ class CastingDeviceInfoStorage : FragmentedStorageFileJson() {
return deviceInfos.toList(); return deviceInfos.toList();
} }
@Synchronized
fun getDeviceNames() : List<String> {
return deviceInfos.map { it.name }.toList();
}
@Synchronized @Synchronized
fun addDevice(castingDeviceInfo: CastingDeviceInfo): CastingDeviceInfo { fun addDevice(castingDeviceInfo: CastingDeviceInfo): CastingDeviceInfo {
val foundDeviceInfo = deviceInfos.firstOrNull { d -> d.name == castingDeviceInfo.name } val foundDeviceInfo = deviceInfos.firstOrNull { d -> d.name == castingDeviceInfo.name }
@@ -5,9 +5,11 @@ import com.futo.platformplayer.noise.protocol.CipherStatePair
import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.noise.protocol.DHState
import com.futo.platformplayer.noise.protocol.HandshakeState import com.futo.platformplayer.noise.protocol.HandshakeState
import com.futo.platformplayer.states.StateSync import com.futo.platformplayer.states.StateSync
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.util.Base64 import java.util.Base64
import java.util.zip.GZIPOutputStream
interface IChannel : AutoCloseable { interface IChannel : AutoCloseable {
val remotePublicKey: String? val remotePublicKey: String?
@@ -15,8 +17,9 @@ interface IChannel : AutoCloseable {
var authorizable: IAuthorizable? var authorizable: IAuthorizable?
var syncSession: SyncSession? var syncSession: SyncSession?
fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?)
fun send(opcode: UByte, subOpcode: UByte = 0u, data: ByteBuffer? = null) fun send(opcode: UByte, subOpcode: UByte = 0u, data: ByteBuffer? = null, contentEncoding: ContentEncoding? = null)
fun setCloseHandler(onClose: ((IChannel) -> Unit)?) fun setCloseHandler(onClose: ((IChannel) -> Unit)?)
val linkType: LinkType
} }
class ChannelSocket(private val session: SyncSocketSession) : IChannel { class ChannelSocket(private val session: SyncSocketSession) : IChannel {
@@ -24,6 +27,7 @@ class ChannelSocket(private val session: SyncSocketSession) : IChannel {
override val remoteVersion: Int? get() = session.remoteVersion override val remoteVersion: Int? get() = session.remoteVersion
private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null
private var onClose: ((IChannel) -> Unit)? = null private var onClose: ((IChannel) -> Unit)? = null
override val linkType: LinkType get() = LinkType.Direct
override var authorizable: IAuthorizable? override var authorizable: IAuthorizable?
get() = session.authorizable get() = session.authorizable
@@ -47,9 +51,9 @@ class ChannelSocket(private val session: SyncSocketSession) : IChannel {
onData?.invoke(session, this, opcode, subOpcode, data) onData?.invoke(session, this, opcode, subOpcode, data)
} }
override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?) { override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, contentEncoding: ContentEncoding?) {
if (data != null) { if (data != null) {
session.send(opcode, subOpcode, data) session.send(opcode, subOpcode, data, contentEncoding)
} else { } else {
session.send(opcode, subOpcode) session.send(opcode, subOpcode)
} }
@@ -83,6 +87,7 @@ class ChannelRelayed(
override var remoteVersion: Int? = null override var remoteVersion: Int? = null
private set private set
override var syncSession: SyncSession? = null override var syncSession: SyncSession? = null
override val linkType: LinkType get() = LinkType.Relayed
private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null
private var onClose: ((IChannel) -> Unit)? = null private var onClose: ((IChannel) -> Unit)? = null
@@ -180,51 +185,70 @@ class ChannelRelayed(
} }
} }
override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?) { override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, ce: ContentEncoding?) {
throwIfDisposed() throwIfDisposed()
val actualCount = data?.remaining() ?: 0 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 ENCRYPTION_OVERHEAD = 16
val CONNECTION_ID_SIZE = 8 val CONNECTION_ID_SIZE = 8
val HEADER_SIZE = 6 val HEADER_SIZE = 7
val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - HEADER_SIZE - CONNECTION_ID_SIZE - ENCRYPTION_OVERHEAD - 16 val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - HEADER_SIZE - CONNECTION_ID_SIZE - ENCRYPTION_OVERHEAD - 16
if (actualCount > MAX_DATA_PER_PACKET && data != null) { 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() val streamId = session.generateStreamId()
val totalSize = actualCount
var sendOffset = 0 var sendOffset = 0
while (sendOffset < totalSize) { while (sendOffset < processedData.remaining()) {
val bytesRemaining = totalSize - sendOffset val bytesRemaining = processedData.remaining() - sendOffset
val bytesToSend = minOf(MAX_DATA_PER_PACKET - 8 - 2, bytesRemaining) val bytesToSend = minOf(MAX_DATA_PER_PACKET - 8 - HEADER_SIZE + 4, bytesRemaining)
val streamData: ByteArray val streamData: ByteArray
val streamOpcode: StreamOpcode val streamOpcode: StreamOpcode
if (sendOffset == 0) { if (sendOffset == 0) {
streamOpcode = StreamOpcode.START streamOpcode = StreamOpcode.START
streamData = ByteArray(4 + 4 + 1 + 1 + bytesToSend) streamData = ByteArray(4 + HEADER_SIZE + bytesToSend)
ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply { ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(streamId) putInt(streamId)
putInt(totalSize) putInt(processedData.remaining())
put(opcode.toByte()) put(opcode.toByte())
put(subOpcode.toByte()) put(subOpcode.toByte())
put(data.array(), data.position() + sendOffset, bytesToSend) put(contentEncoding?.value?.toByte() ?: 0.toByte())
put(processedData.array(), processedData.position() + sendOffset, bytesToSend)
} }
} else { } else {
streamData = ByteArray(4 + 4 + bytesToSend) streamData = ByteArray(4 + 4 + bytesToSend)
ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply { ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(streamId) putInt(streamId)
putInt(sendOffset) putInt(sendOffset)
put(data.array(), data.position() + sendOffset, bytesToSend) put(processedData.array(), processedData.position() + sendOffset, bytesToSend)
} }
streamOpcode = if (bytesToSend < bytesRemaining) StreamOpcode.DATA else StreamOpcode.END streamOpcode = if (bytesToSend < bytesRemaining) StreamOpcode.DATA else StreamOpcode.END
} }
val fullPacket = ByteArray(HEADER_SIZE + streamData.size) val fullPacket = ByteArray(HEADER_SIZE + streamData.size)
ByteBuffer.wrap(fullPacket).order(ByteOrder.LITTLE_ENDIAN).apply { ByteBuffer.wrap(fullPacket).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(streamData.size + 2) putInt(streamData.size + HEADER_SIZE - 4)
put(Opcode.STREAM.value.toByte()) put(Opcode.STREAM.value.toByte())
put(streamOpcode.value.toByte()) put(streamOpcode.value.toByte())
put(ContentEncoding.Raw.value.toByte())
put(streamData) put(streamData)
} }
@@ -232,18 +256,19 @@ class ChannelRelayed(
sendOffset += bytesToSend sendOffset += bytesToSend
} }
} else { } else {
val packet = ByteArray(HEADER_SIZE + actualCount) val packet = ByteArray(HEADER_SIZE + (processedData?.remaining() ?: 0))
ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply { ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(actualCount + 2) putInt((processedData?.remaining() ?: 0) + HEADER_SIZE - 4)
put(opcode.toByte()) put(opcode.toByte())
put(subOpcode.toByte()) put(subOpcode.toByte())
if (actualCount > 0 && data != null) put(data.array(), data.position(), actualCount) put(contentEncoding?.value?.toByte() ?: ContentEncoding.Raw.value.toByte())
if (processedData != null && processedData.remaining() > 0) put(processedData.array(), processedData.position(), processedData.remaining())
} }
sendPacket(packet) sendPacket(packet)
} }
} }
fun sendRequestTransport(requestId: Int, publicKey: String, pairingCode: String? = null) { fun sendRequestTransport(requestId: Int, publicKey: String, appId: UInt, pairingCode: String? = null) {
throwIfDisposed() throwIfDisposed()
synchronized(sendLock) { synchronized(sendLock) {
@@ -267,10 +292,11 @@ class ChannelRelayed(
0 to ByteArray(0) 0 to ByteArray(0)
} }
val packetSize = 4 + 32 + 4 + pairingMessageLength + 4 + channelBytesWritten val packetSize = 4 + 4 + 32 + 4 + pairingMessageLength + 4 + channelBytesWritten
val packet = ByteArray(packetSize) val packet = ByteArray(packetSize)
ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply { ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(requestId) putInt(requestId)
putInt(appId.toInt())
put(publicKeyBytes) put(publicKeyBytes)
putInt(pairingMessageLength) putInt(pairingMessageLength)
if (pairingMessageLength > 0) put(pairingMessage) if (pairingMessageLength > 0) put(pairingMessage)
@@ -329,4 +355,8 @@ class ChannelRelayed(
completeHandshake(remoteVersion, transport) completeHandshake(remoteVersion, transport)
} }
} }
companion object {
private val TAG = "Channel"
}
} }
@@ -0,0 +1,6 @@
package com.futo.platformplayer.sync.internal
enum class ContentEncoding(val value: UByte) {
Raw(0u),
Gzip(1u)
}
@@ -35,26 +35,18 @@ class SyncSession : IAuthorizable {
val linkType: LinkType get() val linkType: LinkType get()
{ {
var hasRelayed = false var linkType = LinkType.None
var hasDirect = false
synchronized(_channels) synchronized(_channels)
{ {
for (channel in _channels) for (channel in _channels)
{ {
if (channel is ChannelRelayed) if (channel.linkType == LinkType.Direct)
hasRelayed = true
if (channel is ChannelSocket)
hasDirect = true
if (hasRelayed && hasDirect)
return LinkType.Direct return LinkType.Direct
if (channel.linkType == LinkType.Relayed)
linkType = LinkType.Relayed
} }
} }
return linkType
if (hasRelayed)
return LinkType.Relayed
if (hasDirect)
return LinkType.Direct
return LinkType.None
} }
var connected: Boolean = false var connected: Boolean = false
@@ -137,9 +129,9 @@ class SyncSession : IAuthorizable {
fun close() { fun close() {
synchronized(_channels) { synchronized(_channels) {
_channels.forEach { it.close() } _channels.toTypedArray()
_channels.clear() }.forEach { it.close() }
}
_onClose(this) _onClose(this)
} }
@@ -204,16 +196,15 @@ class SyncSession : IAuthorizable {
} }
fun sendData(subOpcode: UByte, data: String) { fun sendData(subOpcode: UByte, data: String) {
send(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8))) send(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip)
} }
fun send(opcode: UByte, subOpcode: UByte, data: String) { fun send(opcode: UByte, subOpcode: UByte, data: String) {
send(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8))) send(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip)
} }
fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer? = null) { fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer? = null, contentEncoding: ContentEncoding? = null) {
//TODO: Prioritize local connections val channels = synchronized(_channels) { _channels.sortedBy { it.linkType.ordinal }.toList() }
val channels = synchronized(_channels) { _channels.toList() }
if (channels.isEmpty()) { if (channels.isEmpty()) {
//TODO: Should this throw? //TODO: Should this throw?
Logger.v(TAG, "Packet was not sent (opcode = $opcode, subOpcode = $subOpcode) due to no connected sockets") Logger.v(TAG, "Packet was not sent (opcode = $opcode, subOpcode = $subOpcode) due to no connected sockets")
@@ -223,11 +214,13 @@ class SyncSession : IAuthorizable {
var sent = false var sent = false
for (channel in channels) { for (channel in channels) {
try { try {
channel.send(opcode, subOpcode, data) channel.send(opcode, subOpcode, data, contentEncoding)
sent = true sent = true
break break
} catch (e: Throwable) { } 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)
} }
} }
@@ -3,32 +3,46 @@ package com.futo.platformplayer.sync.internal
import android.os.Build import android.os.Build
import com.futo.platformplayer.LittleEndianDataInputStream import com.futo.platformplayer.LittleEndianDataInputStream
import com.futo.platformplayer.LittleEndianDataOutputStream import com.futo.platformplayer.LittleEndianDataOutputStream
import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.ensureNotMainThread import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.noise.protocol.CipherStatePair import com.futo.platformplayer.noise.protocol.CipherStatePair
import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.noise.protocol.DHState
import com.futo.platformplayer.noise.protocol.HandshakeState import com.futo.platformplayer.noise.protocol.HandshakeState
import com.futo.platformplayer.states.StateSync import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.sync.internal.ChannelRelayed.Companion
import com.futo.polycentric.core.base64ToByteArray
import com.futo.polycentric.core.toBase64
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.OutputStream
import java.net.Inet4Address import java.net.Inet4Address
import java.net.Inet6Address import java.net.Inet6Address
import java.net.InetAddress import java.net.InetAddress
import java.net.NetworkInterface import java.net.NetworkInterface
import java.net.Socket
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.util.Base64 import java.util.Base64
import java.util.Locale import java.util.Locale
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
import kotlin.math.min import kotlin.math.min
import kotlin.system.measureTimeMillis
import kotlin.time.measureTime
class SyncSocketSession { class SyncSocketSession {
private val _inputStream: LittleEndianDataInputStream private val _socket: Socket
private val _outputStream: LittleEndianDataOutputStream private val _inputStream: InputStream
private val _outputStream: OutputStream
private val _sendLockObject = Object() private val _sendLockObject = Object()
private val _buffer = ByteArray(MAXIMUM_PACKET_SIZE_ENCRYPTED) private val _buffer = ByteArray(MAXIMUM_PACKET_SIZE_ENCRYPTED)
private val _bufferDecrypted = ByteArray(MAXIMUM_PACKET_SIZE) private val _bufferDecrypted = ByteArray(MAXIMUM_PACKET_SIZE)
private val _sendBuffer = 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<Int, SyncStream>() private val _syncStreams = hashMapOf<Int, SyncStream>()
private var _streamIdGenerator = 0 private var _streamIdGenerator = 0
private val _streamIdGeneratorLock = Object() private val _streamIdGeneratorLock = Object()
@@ -38,12 +52,13 @@ class SyncSocketSession {
private val _onHandshakeComplete: ((session: SyncSocketSession) -> Unit)? private val _onHandshakeComplete: ((session: SyncSocketSession) -> Unit)?
private val _onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)? private val _onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)?
private val _onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)? private val _onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)?
private val _isHandshakeAllowed: ((session: SyncSocketSession, remotePublicKey: String, pairingCode: String?) -> Boolean)? private val _isHandshakeAllowed: ((linkType: LinkType, session: SyncSocketSession, remotePublicKey: String, pairingCode: String?, appId: UInt) -> Boolean)?
private var _cipherStatePair: CipherStatePair? = null private var _cipherStatePair: CipherStatePair? = null
private var _remotePublicKey: String? = null private var _remotePublicKey: String? = null
val remotePublicKey: String? get() = _remotePublicKey val remotePublicKey: String? get() = _remotePublicKey
private var _started: Boolean = false private var _started: Boolean = false
private val _localKeyPair: DHState private val _localKeyPair: DHState
private var _thread: Thread? = null
private var _localPublicKey: String private var _localPublicKey: String
val localPublicKey: String get() = _localPublicKey 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)?
@@ -74,23 +89,26 @@ class SyncSocketSession {
val allowLocalDirect: Boolean, val allowLocalDirect: Boolean,
val allowRemoteDirect: Boolean, val allowRemoteDirect: Boolean,
val allowRemoteHolePunched: Boolean, val allowRemoteHolePunched: Boolean,
val allowRemoteProxied: Boolean val allowRemoteRelayed: Boolean
) )
constructor( constructor(
remoteAddress: String, remoteAddress: String,
localKeyPair: DHState, localKeyPair: DHState,
inputStream: LittleEndianDataInputStream, socket: Socket,
outputStream: LittleEndianDataOutputStream,
onClose: ((session: SyncSocketSession) -> Unit)? = null, onClose: ((session: SyncSocketSession) -> Unit)? = null,
onHandshakeComplete: ((session: SyncSocketSession) -> Unit)? = null, onHandshakeComplete: ((session: SyncSocketSession) -> Unit)? = null,
onData: ((session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit)? = null, onData: ((session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit)? = null,
onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)? = null, onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)? = null,
onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)? = null, onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)? = null,
isHandshakeAllowed: ((session: SyncSocketSession, remotePublicKey: String, pairingCode: String?) -> Boolean)? = null isHandshakeAllowed: ((linkType: LinkType, session: SyncSocketSession, remotePublicKey: String, pairingCode: String?, appId: UInt) -> Boolean)? = null
) { ) {
_inputStream = inputStream _socket = socket
_outputStream = outputStream _socket.receiveBufferSize = MAXIMUM_PACKET_SIZE_ENCRYPTED
_socket.sendBufferSize = MAXIMUM_PACKET_SIZE_ENCRYPTED
_socket.tcpNoDelay = true
_inputStream = _socket.getInputStream()
_outputStream = _socket.getOutputStream()
_onClose = onClose _onClose = onClose
_onHandshakeComplete = onHandshakeComplete _onHandshakeComplete = onHandshakeComplete
_localKeyPair = localKeyPair _localKeyPair = localKeyPair
@@ -105,10 +123,25 @@ class SyncSocketSession {
_localPublicKey = Base64.getEncoder().encodeToString(localPublicKey) _localPublicKey = Base64.getEncoder().encodeToString(localPublicKey)
} }
fun startAsInitiator(remotePublicKey: String, pairingCode: String? = null) { fun startAsInitiator(remotePublicKey: String, appId: UInt = 0u, pairingCode: String? = null) {
_started = true
_thread = Thread {
try {
handshakeAsInitiator(remotePublicKey, appId, pairingCode)
_onHandshakeComplete?.invoke(this)
receiveLoop()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to run as initiator", e)
} finally {
stop()
}
}.apply { start() }
}
fun runAsInitiator(remotePublicKey: String, appId: UInt = 0u, pairingCode: String? = null) {
_started = true _started = true
try { try {
handshakeAsInitiator(remotePublicKey, pairingCode) handshakeAsInitiator(remotePublicKey, appId, pairingCode)
_onHandshakeComplete?.invoke(this) _onHandshakeComplete?.invoke(this)
receiveLoop() receiveLoop()
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -120,42 +153,59 @@ class SyncSocketSession {
fun startAsResponder() { fun startAsResponder() {
_started = true _started = true
try { _thread = Thread {
if (handshakeAsResponder()) { try {
_onHandshakeComplete?.invoke(this) if (handshakeAsResponder()) {
receiveLoop() _onHandshakeComplete?.invoke(this)
receiveLoop()
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to run as responder", e)
} finally {
stop()
} }
} catch (e: Throwable) { }.apply { start() }
Logger.e(TAG, "Failed to run as responder", e) }
} finally {
stop() 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() { private fun receiveLoop() {
while (_started) { while (_started) {
try { 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) { if (messageSize > MAXIMUM_PACKET_SIZE_ENCRYPTED) {
throw Exception("Message size (${messageSize}) cannot exceed MAXIMUM_PACKET_SIZE ($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})") //Logger.i(TAG, "Receiving message (size = ${messageSize})")
var bytesRead = 0 readExact(_buffer, 0, messageSize)
while (bytesRead < messageSize) { //Logger.v(TAG, "Read ${messageSize}.")
val read = _inputStream.read(_buffer, bytesRead, messageSize - bytesRead)
if (read == -1)
throw Exception("Stream closed")
bytesRead += read
}
//Logger.v(TAG, "Decrypting ${messageSize} bytes.")
val plen: Int = _cipherStatePair!!.receiver.decryptWithAd(null, _buffer, 0, _bufferDecrypted, 0, messageSize) val plen: Int = _cipherStatePair!!.receiver.decryptWithAd(null, _buffer, 0, _bufferDecrypted, 0, messageSize)
//Logger.i(TAG, "Decrypted message (size = ${plen})") //Logger.i(TAG, "Decrypted message (size = ${plen})")
//Logger.v(TAG, "Decrypted ${messageSize} bytes.")
handleData(_bufferDecrypted, plen, null) handleData(_bufferDecrypted, plen, null)
//Logger.v(TAG, "Handled data ${messageSize} bytes.")
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Exception while receiving data", e) Logger.e(TAG, "Exception while receiving data, closing socket session", e)
stop()
break break
} }
} }
@@ -185,14 +235,14 @@ class SyncSocketSession {
_channels.values.forEach { it.close() } _channels.values.forEach { it.close() }
_channels.clear() _channels.clear()
_onClose?.invoke(this) _onClose?.invoke(this)
_inputStream.close() _socket.close()
_outputStream.close() _thread = null
_cipherStatePair?.sender?.destroy() _cipherStatePair?.sender?.destroy()
_cipherStatePair?.receiver?.destroy() _cipherStatePair?.receiver?.destroy()
Logger.i(TAG, "Session closed") Logger.i(TAG, "Session closed")
} }
private fun handshakeAsInitiator(remotePublicKey: String, pairingCode: String?) { private fun handshakeAsInitiator(remotePublicKey: String, appId: UInt, pairingCode: String?) {
performVersionCheck() performVersionCheck()
val initiator = HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR) val initiator = HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR)
@@ -218,24 +268,32 @@ class SyncSocketSession {
val mainBuffer = ByteArray(512) val mainBuffer = ByteArray(512)
val mainLength = initiator.writeMessage(mainBuffer, 0, null, 0, 0) val mainLength = initiator.writeMessage(mainBuffer, 0, null, 0, 0)
val messageData = ByteBuffer.allocate(4 + pairingMessageLength + mainLength).order(ByteOrder.LITTLE_ENDIAN) 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) messageData.putInt(pairingMessageLength)
if (pairingMessageLength > 0) messageData.put(pairingMessage) if (pairingMessageLength > 0) messageData.put(pairingMessage)
messageData.put(mainBuffer, 0, mainLength) messageData.put(mainBuffer, 0, mainLength)
val messageDataArray = messageData.array() val messageDataArray = messageData.array()
_outputStream.writeInt(messageDataArray.size) _outputStream.write(messageDataArray, 0, 4 + messageSize)
_outputStream.write(messageDataArray)
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 responseSize = _inputStream.readInt()
val responseMessage = ByteArray(responseSize) val responseMessage = ByteArray(responseSize)
_inputStream.readFully(responseMessage) readExact(responseMessage, 0, responseSize)
val plaintext = ByteArray(512) // Buffer for any payload (none expected here) val plaintext = ByteArray(512) // Buffer for any payload (none expected here)
initiator.readMessage(responseMessage, 0, responseSize, plaintext, 0) initiator.readMessage(responseMessage, 0, responseSize, plaintext, 0)
_cipherStatePair = initiator.split() _cipherStatePair = initiator.split()
val remoteKeyBytes = ByteArray(initiator.remotePublicKey.publicKeyLength) val remoteKeyBytes = ByteArray(initiator.remotePublicKey.publicKeyLength)
initiator.remotePublicKey.getPublicKey(remoteKeyBytes, 0) initiator.remotePublicKey.getPublicKey(remoteKeyBytes, 0)
_remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes) _remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes).base64ToByteArray().toBase64()
} }
private fun handshakeAsResponder(): Boolean { private fun handshakeAsResponder(): Boolean {
@@ -245,14 +303,20 @@ class SyncSocketSession {
responder.localKeyPair.copyFrom(_localKeyPair) responder.localKeyPair.copyFrom(_localKeyPair)
responder.start() responder.start()
val messageSize = _inputStream.readInt() readExact(_buffer, 0, 4)
val message = ByteArray(messageSize) val messageSize = ByteBuffer.wrap(_buffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int
_inputStream.readFully(message) if (messageSize > MAXIMUM_PACKET_SIZE_ENCRYPTED) {
val messageBuffer = ByteBuffer.wrap(message).order(ByteOrder.LITTLE_ENDIAN) 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 pairingMessageLength = messageBuffer.int
val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { messageBuffer.get(it) } else byteArrayOf() val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { messageBuffer.get(it) } else byteArrayOf()
val mainLength = messageSize - 4 - pairingMessageLength val mainLength = messageSize - 4 - 4 - pairingMessageLength
val mainMessage = ByteArray(mainLength).also { messageBuffer.get(it) } val mainMessage = ByteArray(mainLength).also { messageBuffer.get(it) }
var pairingCode: String? = null var pairingCode: String? = null
@@ -267,27 +331,36 @@ class SyncSocketSession {
val plaintext = ByteArray(512) val plaintext = ByteArray(512)
responder.readMessage(mainMessage, 0, mainLength, plaintext, 0) responder.readMessage(mainMessage, 0, mainLength, plaintext, 0)
val responseBuffer = ByteArray(512)
val responseLength = responder.writeMessage(responseBuffer, 0, null, 0, 0)
_outputStream.writeInt(responseLength)
_outputStream.write(responseBuffer, 0, responseLength)
_cipherStatePair = responder.split()
val remoteKeyBytes = ByteArray(responder.remotePublicKey.publicKeyLength) val remoteKeyBytes = ByteArray(responder.remotePublicKey.publicKeyLength)
responder.remotePublicKey.getPublicKey(remoteKeyBytes, 0) responder.remotePublicKey.getPublicKey(remoteKeyBytes, 0)
_remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes) val remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes)
return (_remotePublicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(this, _remotePublicKey!!, pairingCode) ?: true)).also { val isAllowedToConnect = remotePublicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Direct, this, remotePublicKey, pairingCode, appId) ?: true)
if (!it) stop() 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() { private fun performVersionCheck() {
val CURRENT_VERSION = 4 val CURRENT_VERSION = 4
val MINIMUM_VERSION = 4 val MINIMUM_VERSION = 4
_outputStream.writeInt(CURRENT_VERSION)
remoteVersion = _inputStream.readInt() 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)") Logger.i(TAG, "performVersionCheck (version = $remoteVersion)")
if (remoteVersion < MINIMUM_VERSION) if (remoteVersion < MINIMUM_VERSION)
throw Exception("Invalid version") throw Exception("Invalid version")
@@ -296,25 +369,44 @@ class SyncSocketSession {
fun generateStreamId(): Int = synchronized(_streamIdGeneratorLock) { _streamIdGenerator++ } fun generateStreamId(): Int = synchronized(_streamIdGeneratorLock) { _streamIdGenerator++ }
private fun generateRequestId(): Int = synchronized(_requestIdGeneratorLock) { _requestIdGenerator++ } private fun generateRequestId(): Int = synchronized(_requestIdGeneratorLock) { _requestIdGenerator++ }
fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer, ce: ContentEncoding? = null) {
ensureNotMainThread() ensureNotMainThread()
if (data.remaining() + HEADER_SIZE > MAXIMUM_PACKET_SIZE) { Logger.v(TAG, "send (opcode: ${opcode}, subOpcode: ${subOpcode}, data.remaining(): ${data.remaining()})")
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()
}
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
}
}
if (processedData.remaining() + HEADER_SIZE > MAXIMUM_PACKET_SIZE) {
val segmentSize = MAXIMUM_PACKET_SIZE - HEADER_SIZE val segmentSize = MAXIMUM_PACKET_SIZE - HEADER_SIZE
val segmentData = ByteArray(segmentSize) val segmentData = ByteArray(segmentSize)
var sendOffset = 0 var sendOffset = 0
val id = generateStreamId() val id = generateStreamId()
while (sendOffset < data.remaining()) { while (sendOffset < processedData.remaining()) {
val bytesRemaining = data.remaining() - sendOffset val bytesRemaining = processedData.remaining() - sendOffset
var bytesToSend: Int var bytesToSend: Int
var segmentPacketSize: Int var segmentPacketSize: Int
val streamOp: StreamOpcode val streamOp: StreamOpcode
if (sendOffset == 0) { if (sendOffset == 0) {
streamOp = StreamOpcode.START streamOp = StreamOpcode.START
bytesToSend = segmentSize - 4 - 4 - 1 - 1 bytesToSend = segmentSize - 4 - HEADER_SIZE
segmentPacketSize = bytesToSend + 4 + 4 + 1 + 1 segmentPacketSize = bytesToSend + 4 + HEADER_SIZE
} else { } else {
bytesToSend = minOf(segmentSize - 4 - 4, bytesRemaining) bytesToSend = minOf(segmentSize - 4 - 4, bytesRemaining)
streamOp = if (bytesToSend >= bytesRemaining) StreamOpcode.END else StreamOpcode.DATA streamOp = if (bytesToSend >= bytesRemaining) StreamOpcode.END else StreamOpcode.DATA
@@ -323,12 +415,13 @@ class SyncSocketSession {
ByteBuffer.wrap(segmentData).order(ByteOrder.LITTLE_ENDIAN).apply { ByteBuffer.wrap(segmentData).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(id) putInt(id)
putInt(if (streamOp == StreamOpcode.START) data.remaining() else sendOffset) putInt(if (streamOp == StreamOpcode.START) processedData.remaining() else sendOffset)
if (streamOp == StreamOpcode.START) { if (streamOp == StreamOpcode.START) {
put(opcode.toByte()) put(opcode.toByte())
put(subOpcode.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(Opcode.STREAM.value, streamOp.value, ByteBuffer.wrap(segmentData, 0, segmentPacketSize)) send(Opcode.STREAM.value, streamOp.value, ByteBuffer.wrap(segmentData, 0, segmentPacketSize))
@@ -337,17 +430,19 @@ class SyncSocketSession {
} else { } else {
synchronized(_sendLockObject) { synchronized(_sendLockObject) {
ByteBuffer.wrap(_sendBuffer).order(ByteOrder.LITTLE_ENDIAN).apply { ByteBuffer.wrap(_sendBuffer).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(data.remaining() + 2) putInt(processedData.remaining() + HEADER_SIZE - 4)
put(opcode.toByte()) put(opcode.toByte())
put(subOpcode.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, 4, processedData.remaining() + HEADER_SIZE)
val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 0, data.remaining() + HEADER_SIZE) val sendDuration = measureTimeMillis {
//Logger.i(TAG, "Sending encrypted message (size = ${len})") ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len)
_outputStream.writeInt(len) _outputStream.write(_sendBufferEncrypted, 0, 4 + len)
_outputStream.write(_sendBufferEncrypted, 0, len) }
Logger.v(TAG, "_outputStream.write (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.remaining(): ${processedData.remaining()}, sendDuration: ${sendDuration})")
} }
} }
} }
@@ -357,17 +452,18 @@ class SyncSocketSession {
ensureNotMainThread() ensureNotMainThread()
synchronized(_sendLockObject) { 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()[4] = opcode
_sendBuffer.asUByteArray()[5] = subOpcode _sendBuffer.asUByteArray()[5] = subOpcode
_sendBuffer.asUByteArray()[6] = ContentEncoding.Raw.value
//Logger.i(TAG, "Encrypting message (opcode = ${opcode}, subOpcode = ${subOpcode}, size = ${HEADER_SIZE})") //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})") //Logger.i(TAG, "Sending encrypted message (size = ${len})")
_outputStream.writeInt(len) ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len)
_outputStream.write(_sendBufferEncrypted, 0, len) _outputStream.write(_sendBufferEncrypted, 0, 4 + len)
} }
} }
@@ -378,7 +474,7 @@ class SyncSocketSession {
private fun handleData(data: ByteBuffer, sourceChannel: ChannelRelayed?) { private fun handleData(data: ByteBuffer, sourceChannel: ChannelRelayed?) {
val length = data.remaining() val length = data.remaining()
if (length < HEADER_SIZE) if (length < HEADER_SIZE)
throw Exception("Packet must be at least 6 bytes (header size)") throw Exception("Packet must be at least ${HEADER_SIZE} bytes (header size)")
val size = data.int val size = data.int
if (size != length - 4) if (size != length - 4)
@@ -386,7 +482,10 @@ class SyncSocketSession {
val opcode = data.get().toUByte() val opcode = data.get().toUByte()
val subOpcode = data.get().toUByte() val subOpcode = data.get().toUByte()
handlePacket(opcode, subOpcode, data, sourceChannel) 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 handleRequest(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { private fun handleRequest(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) {
@@ -400,13 +499,14 @@ class SyncSocketSession {
val remoteVersion = data.int val remoteVersion = data.int
val connectionId = data.long val connectionId = data.long
val requestId = data.int val requestId = data.int
val appId = data.int.toUInt()
val publicKeyBytes = ByteArray(32).also { data.get(it) } val publicKeyBytes = ByteArray(32).also { data.get(it) }
val pairingMessageLength = data.int val pairingMessageLength = data.int
if (pairingMessageLength > 128) throw IllegalArgumentException("Pairing message length ($pairingMessageLength) exceeds maximum (128)") 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 pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { data.get(it) } else ByteArray(0)
val channelMessageLength = data.int val channelMessageLength = data.int
if (data.remaining() != channelMessageLength) { if (data.remaining() != channelMessageLength) {
Logger.e(TAG, "Invalid packet size. Expected ${52 + pairingMessageLength + 4 + channelMessageLength}, got ${data.capacity()}") Logger.e(TAG, "Invalid packet size. Expected ${52 + pairingMessageLength + 4 + channelMessageLength}, got ${data.capacity()} (app id: $appId)")
return return
} }
val channelHandshakeMessage = ByteArray(channelMessageLength).also { data.get(it) } val channelHandshakeMessage = ByteArray(channelMessageLength).also { data.get(it) }
@@ -420,7 +520,7 @@ class SyncSocketSession {
val length = pairingProtocol.readMessage(pairingMessage, 0, pairingMessageLength, plaintext, 0) val length = pairingProtocol.readMessage(pairingMessage, 0, pairingMessageLength, plaintext, 0)
String(plaintext, 0, length, Charsets.UTF_8) String(plaintext, 0, length, Charsets.UTF_8)
} else null } else null
val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(this, publicKey, pairingCode) ?: true) val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode, appId) ?: true)
if (!isAllowed) { if (!isAllowed) {
val rp = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN) val rp = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN)
rp.putInt(2) // Status code for not allowed rp.putInt(2) // Status code for not allowed
@@ -649,8 +749,8 @@ class SyncSocketSession {
val allowLocalDirect = info.get() != 0.toByte() val allowLocalDirect = info.get() != 0.toByte()
val allowRemoteDirect = info.get() != 0.toByte() val allowRemoteDirect = info.get() != 0.toByte()
val allowRemoteHolePunched = info.get() != 0.toByte() val allowRemoteHolePunched = info.get() != 0.toByte()
val allowRemoteProxied = info.get() != 0.toByte() val allowRemoteRelayed = info.get() != 0.toByte()
return ConnectionInfo(port, name, remoteIp, ipv4Addresses, ipv6Addresses, allowLocalDirect, allowRemoteDirect, allowRemoteHolePunched, allowRemoteProxied) return ConnectionInfo(port, name, remoteIp, ipv4Addresses, ipv6Addresses, allowLocalDirect, allowRemoteDirect, allowRemoteHolePunched, allowRemoteRelayed)
} }
private fun handleNotify(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { private fun handleNotify(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) {
@@ -733,9 +833,27 @@ class SyncSocketSession {
} }
} }
private fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { private fun handlePacket(opcode: UByte, subOpcode: UByte, d: ByteBuffer, contentEncoding: UByte, sourceChannel: ChannelRelayed?) {
Logger.i(TAG, "Handle packet (opcode = ${opcode}, subOpcode = ${subOpcode})") 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) { when (opcode) {
Opcode.PING.value -> { Opcode.PING.value -> {
if (sourceChannel != null) if (sourceChannel != null)
@@ -773,8 +891,9 @@ class SyncSocketSession {
val expectedSize = data.int val expectedSize = data.int
val op = data.get().toUByte() val op = data.get().toUByte()
val subOp = data.get().toUByte() val subOp = data.get().toUByte()
val ce = data.get().toUByte()
val syncStream = SyncStream(expectedSize, op, subOp) val syncStream = SyncStream(expectedSize, op, subOp, ce)
if (data.remaining() > 0) { if (data.remaining() > 0) {
syncStream.add(data.array(), data.position(), data.remaining()) syncStream.add(data.array(), data.position(), data.remaining())
} }
@@ -819,7 +938,7 @@ class SyncSocketSession {
throw Exception("After sync stream end, the stream must be complete") 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) }, sourceChannel) handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }, syncStream.contentEncoding, sourceChannel)
} }
} }
Opcode.DATA.value -> { Opcode.DATA.value -> {
@@ -876,14 +995,14 @@ class SyncSocketSession {
return deferred.await() return deferred.await()
} }
suspend fun startRelayedChannel(publicKey: String, pairingCode: String? = null): ChannelRelayed? { suspend fun startRelayedChannel(publicKey: String, appId: UInt = 0u, pairingCode: String? = null): ChannelRelayed? {
val requestId = generateRequestId() val requestId = generateRequestId()
val deferred = CompletableDeferred<ChannelRelayed>() val deferred = CompletableDeferred<ChannelRelayed>()
val channel = ChannelRelayed(this, _localKeyPair, publicKey, true) val channel = ChannelRelayed(this, _localKeyPair, publicKey, true)
_onNewChannel?.invoke(this, channel) _onNewChannel?.invoke(this, channel)
_pendingChannels[requestId] = channel to deferred _pendingChannels[requestId] = channel to deferred
try { try {
channel.sendRequestTransport(requestId, publicKey, pairingCode) channel.sendRequestTransport(requestId, publicKey, appId, pairingCode)
} catch (e: Exception) { } catch (e: Exception) {
_pendingChannels.remove(requestId)?.let { it.first.close(); it.second.completeExceptionally(e) } _pendingChannels.remove(requestId)?.let { it.first.close(); it.second.completeExceptionally(e) }
throw e throw e
@@ -920,7 +1039,7 @@ class SyncSocketSession {
allowLocalDirect: Boolean, allowLocalDirect: Boolean,
allowRemoteDirect: Boolean, allowRemoteDirect: Boolean,
allowRemoteHolePunched: Boolean, allowRemoteHolePunched: Boolean,
allowRemoteProxied: Boolean allowRemoteRelayed: Boolean
) { ) {
if (authorizedKeys.size > 255) throw IllegalArgumentException("Number of authorized keys exceeds 255") if (authorizedKeys.size > 255) throw IllegalArgumentException("Number of authorized keys exceeds 255")
@@ -960,7 +1079,7 @@ class SyncSocketSession {
data.put(if (allowLocalDirect) 1 else 0) data.put(if (allowLocalDirect) 1 else 0)
data.put(if (allowRemoteDirect) 1 else 0) data.put(if (allowRemoteDirect) 1 else 0)
data.put(if (allowRemoteHolePunched) 1 else 0) data.put(if (allowRemoteHolePunched) 1 else 0)
data.put(if (allowRemoteProxied) 1 else 0) data.put(if (allowRemoteRelayed) 1 else 0)
val handshakeSize = 48 // Noise handshake size for N pattern val handshakeSize = 48 // Noise handshake size for N pattern
@@ -999,7 +1118,7 @@ class SyncSocketSession {
send(Opcode.NOTIFY.value, NotifyOpcode.CONNECTION_INFO.value, publishBytes) send(Opcode.NOTIFY.value, NotifyOpcode.CONNECTION_INFO.value, publishBytes)
} }
suspend fun publishRecords(consumerPublicKeys: List<String>, key: String, data: ByteArray): Boolean { suspend fun publishRecords(consumerPublicKeys: List<String>, key: String, data: ByteArray, contentEncoding: ContentEncoding? = null): Boolean {
val keyBytes = key.toByteArray(Charsets.UTF_8) val keyBytes = key.toByteArray(Charsets.UTF_8)
if (key.isEmpty() || keyBytes.size > 32) throw IllegalArgumentException("Key must be 1-32 bytes") if (key.isEmpty() || keyBytes.size > 32) throw IllegalArgumentException("Key must be 1-32 bytes")
if (consumerPublicKeys.isEmpty()) throw IllegalArgumentException("At least one consumer required") if (consumerPublicKeys.isEmpty()) throw IllegalArgumentException("At least one consumer required")
@@ -1054,7 +1173,7 @@ class SyncSocketSession {
} }
} }
packet.rewind() packet.rewind()
send(Opcode.REQUEST.value, RequestOpcode.BULK_PUBLISH_RECORD.value, packet) send(Opcode.REQUEST.value, RequestOpcode.BULK_PUBLISH_RECORD.value, packet, ce = contentEncoding)
} catch (e: Exception) { } catch (e: Exception) {
_pendingPublishRequests.remove(requestId)?.completeExceptionally(e) _pendingPublishRequests.remove(requestId)?.completeExceptionally(e)
throw e throw e
@@ -1174,6 +1293,6 @@ class SyncSocketSession {
private const val TAG = "SyncSocketSession" private const val TAG = "SyncSocketSession"
const val MAXIMUM_PACKET_SIZE = 65535 - 16 const val MAXIMUM_PACKET_SIZE = 65535 - 16
const val MAXIMUM_PACKET_SIZE_ENCRYPTED = MAXIMUM_PACKET_SIZE + 16 const val MAXIMUM_PACKET_SIZE_ENCRYPTED = MAXIMUM_PACKET_SIZE + 16
const val HEADER_SIZE = 6 const val HEADER_SIZE = 7
} }
} }
@@ -1,6 +1,6 @@
package com.futo.platformplayer.sync.internal 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 { companion object {
const val MAXIMUM_SIZE = 10_000_000 const val MAXIMUM_SIZE = 10_000_000
} }
@@ -1,7 +1,9 @@
package com.futo.platformplayer.views package com.futo.platformplayer.views
import android.content.Context import android.content.Context
import android.text.TextWatcher
import android.util.AttributeSet import android.util.AttributeSet
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
@@ -18,6 +20,9 @@ class SearchView : FrameLayout {
val buttonClear: ImageButton; val buttonClear: ImageButton;
var onSearchChanged = Event1<String>(); var onSearchChanged = Event1<String>();
var onEnter = Event1<String>();
val text: String get() = textSearch.text.toString();
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_search_bar, this); inflate(context, R.layout.view_search_bar, this);
@@ -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<IPlatformChannelContent>();
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<ConstraintLayout>(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"
}
}
@@ -7,16 +7,16 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> { data class DeviceAdapterEntry(val castingDevice: CastingDevice, val isPinnedDevice: Boolean, val isOnlineDevice: Boolean)
private val _devices: ArrayList<CastingDevice>;
private val _isRememberedDevice: Boolean;
var onRemove = Event1<CastingDevice>(); class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
private val _devices: List<DeviceAdapterEntry>;
var onPin = Event1<CastingDevice>();
var onConnect = Event1<CastingDevice>(); var onConnect = Event1<CastingDevice>();
constructor(devices: ArrayList<CastingDevice>, isRememberedDevice: Boolean) : super() { constructor(devices: List<DeviceAdapterEntry>) : super() {
_devices = devices; _devices = devices;
_isRememberedDevice = isRememberedDevice;
} }
override fun getItemCount() = _devices.size; override fun getItemCount() = _devices.size;
@@ -24,13 +24,13 @@ class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): DeviceViewHolder { override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): DeviceViewHolder {
val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_device, viewGroup, false); val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_device, viewGroup, false);
val holder = DeviceViewHolder(view); val holder = DeviceViewHolder(view);
holder.setIsRememberedDevice(_isRememberedDevice); holder.onPin.subscribe { d -> onPin.emit(d); };
holder.onRemove.subscribe { d -> onRemove.emit(d); };
holder.onConnect.subscribe { d -> onConnect.emit(d); } holder.onConnect.subscribe { d -> onConnect.emit(d); }
return holder; return holder;
} }
override fun onBindViewHolder(viewHolder: DeviceViewHolder, position: Int) { override fun onBindViewHolder(viewHolder: DeviceViewHolder, position: Int) {
viewHolder.bind(_devices[position]); val p = _devices[position];
viewHolder.bind(p.castingDevice, p.isOnlineDevice, p.isPinnedDevice);
} }
} }
@@ -2,9 +2,11 @@ package com.futo.platformplayer.views.adapters
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.view.View import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.casting.AirPlayCastingDevice 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.FCastCastingDevice
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1 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 { class DeviceViewHolder : ViewHolder {
private val _layoutDevice: FrameLayout;
private val _imageDevice: ImageView; private val _imageDevice: ImageView;
private val _textName: TextView; private val _textName: TextView;
private val _textType: TextView; private val _textType: TextView;
private val _textNotReady: TextView; private val _textNotReady: TextView;
private val _buttonDisconnect: LinearLayout;
private val _buttonConnect: LinearLayout;
private val _buttonRemove: LinearLayout;
private val _imageLoader: ImageView; private val _imageLoader: ImageView;
private val _imageOnline: ImageView;
private val _root: ConstraintLayout;
private var _animatableLoader: Animatable? = null; private var _animatableLoader: Animatable? = null;
private var _isRememberedDevice: Boolean = false; private var _imagePin: ImageView;
var device: CastingDevice? = null var device: CastingDevice? = null
private set private set
var onRemove = Event1<CastingDevice>(); var onPin = Event1<CastingDevice>();
val onConnect = Event1<CastingDevice>(); val onConnect = Event1<CastingDevice>();
constructor(view: View) : super(view) { 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); _imageDevice = view.findViewById(R.id.image_device);
_textName = view.findViewById(R.id.text_name); _textName = view.findViewById(R.id.text_name);
_textType = view.findViewById(R.id.text_type); _textType = view.findViewById(R.id.text_type);
_textNotReady = view.findViewById(R.id.text_not_ready); _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); _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; val d = _imageLoader.drawable;
if (d is Animatable) { if (d is Animatable) {
_animatableLoader = d; _animatableLoader = d;
} }
_buttonDisconnect.setOnClickListener { val connect = {
StateCasting.instance.activeDevice?.stopCasting(); device?.let { dev ->
updateButton(); if (dev.isReady) {
}; StateCasting.instance.activeDevice?.stopCasting()
StateCasting.instance.connectDevice(dev)
_buttonConnect.setOnClickListener { onConnect.emit(dev)
val dev = device ?: return@setOnClickListener; } else {
StateCasting.instance.activeDevice?.stopCasting(); try {
StateCasting.instance.connectDevice(dev); view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") }
onConnect.emit(dev); } catch (e: Throwable) {
}; //Ignored
}
_buttonRemove.setOnClickListener { }
val dev = device ?: return@setOnClickListener; }
onRemove.emit(dev);
};
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ ->
updateButton();
} }
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) { fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) {
_isRememberedDevice = isRememberedDevice;
_buttonRemove.visibility = if (isRememberedDevice) View.VISIBLE else View.GONE;
}
fun bind(d: CastingDevice) {
if (d is ChromecastCastingDevice) { if (d is ChromecastCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_chromecast); _imageDevice.setImageResource(R.drawable.ic_chromecast);
_textType.text = "Chromecast"; _textType.text = "Chromecast";
@@ -90,54 +93,47 @@ class DeviceViewHolder : ViewHolder {
} }
_textName.text = d.name; _textName.text = d.name;
device = d; _imageOnline.visibility = if (isOnlineDevice && d.isReady) View.VISIBLE else View.GONE
updateButton();
}
private fun updateButton() {
val d = device ?: return;
if (!d.isReady) { if (!d.isReady) {
_buttonConnect.visibility = View.GONE;
_buttonDisconnect.visibility = View.GONE;
_imageLoader.visibility = View.GONE; _imageLoader.visibility = View.GONE;
_textNotReady.visibility = View.VISIBLE; _textNotReady.visibility = View.VISIBLE;
return; _imagePin.visibility = View.GONE;
}
_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;
}
} else { } else {
if (d.isReady) { _textNotReady.visibility = View.GONE;
_buttonConnect.visibility = View.VISIBLE;
_buttonDisconnect.visibility = View.GONE; val dev = StateCasting.instance.activeDevice;
_imageLoader.visibility = View.GONE; if (dev == d) {
_textNotReady.visibility = View.GONE; 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 { } else {
_buttonConnect.visibility = View.GONE; if (d.isReady) {
_buttonDisconnect.visibility = View.GONE; _imageLoader.visibility = View.GONE;
_imageLoader.visibility = View.GONE; _textNotReady.visibility = View.GONE;
_textNotReady.visibility = View.VISIBLE; _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) { device = d;
_animatableLoader?.start();
} else {
_animatableLoader?.stop();
}
} }
} }
@@ -37,9 +37,10 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
_onDatasetChanged = onDatasetChanged; _onDatasetChanged = onDatasetChanged;
StateSubscriptions.instance.onSubscriptionsChanged.subscribe { _, _ -> if(Looper.myLooper() != Looper.getMainLooper()) StateSubscriptions.instance.onSubscriptionsChanged.subscribe { _, _ -> if(Looper.myLooper() != Looper.getMainLooper())
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { updateDataset() } StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { updateDataset() }
else else
updateDataset(); } updateDataset();
}
updateDataset(); updateDataset();
} }
@@ -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<IPlatformChannelContent>();
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"
}
}
@@ -23,6 +23,7 @@ import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.EmptyPreviewViewHolder import com.futo.platformplayer.views.adapters.EmptyPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import okhttp3.internal.platform.Platform
class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewViewHolder> { class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewViewHolder> {
private var _initialPlay = true; private var _initialPlay = true;
@@ -82,6 +83,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup); ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup);
ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup); ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup);
ContentType.LOCKED -> createLockedViewHolder(viewGroup); ContentType.LOCKED -> createLockedViewHolder(viewGroup);
ContentType.CHANNEL -> createChannelViewHolder(viewGroup)
else -> EmptyPreviewViewHolder(viewGroup) else -> EmptyPreviewViewHolder(viewGroup)
} }
} }
@@ -115,6 +117,10 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
this.onPlaylistClicked.subscribe { this@PreviewContentListAdapter.onContentClicked.emit(it, 0L) }; this.onPlaylistClicked.subscribe { this@PreviewContentListAdapter.onContentClicked.emit(it, 0L) };
this.onChannelClicked.subscribe(this@PreviewContentListAdapter.onChannelClicked::emit); this.onChannelClicked.subscribe(this@PreviewContentListAdapter.onChannelClicked::emit);
}; };
private fun createChannelViewHolder(viewGroup: ViewGroup): PreviewChannelViewHolder = PreviewChannelViewHolder(viewGroup, _feedStyle, false).apply {
//TODO: Maybe PlatformAuthorLink as is needs to be phased out?
this.onClick.subscribe { this@PreviewContentListAdapter.onChannelClicked.emit(PlatformAuthorLink(it.id, it.name, it.url, it.thumbnail, it.subscribers)) };
};
override fun bindChild(holder: ContentPreviewViewHolder, pos: Int) { override fun bindChild(holder: ContentPreviewViewHolder, pos: Int) {
val value = _dataSet[pos]; val value = _dataSet[pos];
@@ -9,6 +9,7 @@ import android.view.View
import android.widget.ImageView import android.widget.ImageView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.getDataLinkFromUrl import com.futo.platformplayer.getDataLinkFromUrl
@@ -81,12 +82,14 @@ class CreatorThumbnail : ConstraintLayout {
Glide.with(_imageChannelThumbnail) Glide.with(_imageChannelThumbnail)
.load(url) .load(url)
.placeholder(R.drawable.placeholder_channel_thumbnail) .placeholder(R.drawable.placeholder_channel_thumbnail)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.crossfade() .crossfade()
.into(_imageChannelThumbnail); .into(_imageChannelThumbnail);
} else { } else {
Glide.with(_imageChannelThumbnail) Glide.with(_imageChannelThumbnail)
.load(url) .load(url)
.placeholder(R.drawable.placeholder_channel_thumbnail) .placeholder(R.drawable.placeholder_channel_thumbnail)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.into(_imageChannelThumbnail); .into(_imageChannelThumbnail);
} }
} }
@@ -13,6 +13,17 @@ class RadioGroupView : FlexboxLayout {
val selectedOptions = arrayListOf<Any?>(); val selectedOptions = arrayListOf<Any?>();
val onSelectedChange = Event1<List<Any?>>(); val onSelectedChange = Event1<List<Any?>>();
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) { constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
flexWrap = FlexWrap.WRAP; flexWrap = FlexWrap.WRAP;
_padding_px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, _padding_dp, context.resources.displayMetrics).toInt(); _padding_px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, _padding_dp, context.resources.displayMetrics).toInt();
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M600,496.92L663.08,560L663.08,600L500,600L500,800L480,820L460,800L460,600L296.92,600L296.92,560L360,496.92L360,200L320,200L320,160L640,160L640,200L600,200L600,496.92Z"/>
</vector>
@@ -5,31 +5,54 @@
android:orientation="vertical" android:orientation="vertical"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@color/gray_1d"> android:background="#101010">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal"> android:orientation="horizontal"
android:layout_marginTop="12dp">
<TextView <LinearLayout
android:id="@+id/text_devices"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/discovered_devices" android:orientation="vertical">
android:layout_marginStart="20dp"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular" />
<ImageView <TextView
android:id="@+id/image_loader" android:id="@+id/text_devices"
android:layout_width="22dp" android:layout_width="wrap_content"
android:layout_height="22dp" android:layout_height="wrap_content"
android:scaleType="fitCenter" android:text="@string/discovered_devices"
app:srcCompat="@drawable/ic_loader_animated" android:layout_marginStart="20dp"
android:layout_marginStart="5dp"/> android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/available_devices"
android:layout_marginStart="20dp"
android:textSize="11dp"
android:textColor="@color/gray_ac"
android:fontFamily="@font/inter_medium" />
<ImageView
android:id="@+id/image_loader"
android:layout_width="18dp"
android:layout_height="18dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_loader_animated"
android:layout_marginStart="5dp"/>
</LinearLayout>
</LinearLayout>
<Space android:layout_width="0dp" <Space android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="match_parent"
@@ -38,7 +61,7 @@
<Button <Button
android:id="@+id/button_close" android:id="@+id/button_close"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:text="@string/close" android:text="@string/close"
android:textSize="14dp" android:textSize="14dp"
android:fontFamily="@font/inter_regular" android:fontFamily="@font/inter_regular"
@@ -67,79 +90,102 @@
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_devices" android:id="@+id/recycler_devices"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="100dp" android:layout_height="200dp"
android:layout_marginStart="20dp" android:layout_marginStart="20dp"
android:layout_marginEnd="20dp" /> android:layout_marginEnd="20dp"
android:layout_marginTop="10dp"
android:layout_marginBottom="20dp"/>
</LinearLayout> </LinearLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="1px"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:background="@color/gray_ac" />
<TextView
android:id="@+id/text_remembered_devices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/unable_to_see_the_Device_youre_looking_for_try_add_the_device_manually"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:textSize="9dp"
android:ellipsize="end"
android:textColor="@color/gray_c3"
android:maxLines="3"
android:fontFamily="@font/inter_light"
android:layout_marginTop="12dp"/>
<LinearLayout <LinearLayout
android:id="@+id/layout_remembered_devices_header" android:id="@+id/layout_remembered_devices_header"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal"> android:orientation="horizontal"
android:layout_marginTop="12dp"
<TextView android:layout_marginBottom="20dp"
android:id="@+id/text_remembered_devices" android:layout_marginStart="20dp"
android:layout_width="0dp" android:layout_marginEnd="20dp">
android:layout_weight="3"
android:layout_height="wrap_content"
android:text="@string/remembered_devices"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:textSize="14dp"
android:ellipsize="end"
android:textColor="@color/white"
android:maxLines="1"
android:fontFamily="@font/inter_regular" />
<ImageButton
android:id="@+id/button_scan_qr"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_scan_qr"
android:scaleType="centerCrop"
app:srcCompat="@drawable/ic_qr"
app:tint="@color/primary" />
<Space android:layout_width="0dp" <Space android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1" />
<ImageButton <LinearLayout
android:id="@+id/button_add" android:id="@+id/button_add"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_add"
android:scaleType="centerCrop"
app:srcCompat="@drawable/ic_add"
app:tint="@color/primary"
android:layout_marginEnd="20dp"/>
</LinearLayout>
<LinearLayout
android:id="@+id/layout_remembered_devices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/text_no_devices_remembered"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="10dp" android:orientation="horizontal"
android:text="@string/there_are_no_remembered_devices" android:background="@drawable/background_border_2e_round_6dp"
android:layout_marginTop="10dp"
android:layout_marginBottom="20dp"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp" android:layout_marginEnd="20dp"
android:textColor="@color/gray_e0" /> android:gravity="center">
<androidx.recyclerview.widget.RecyclerView <ImageView
android:id="@+id/recycler_remembered_devices" android:layout_width="22dp"
android:layout_width="match_parent" android:layout_height="22dp"
android:layout_height="100dp" app:srcCompat="@drawable/ic_add"
android:layout_marginStart="20dp" android:layout_marginStart="8dp"/>
android:layout_marginEnd="20dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/add_manually"
android:textSize="12dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_medium"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="4dp"
android:paddingEnd="12dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/button_qr"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@drawable/background_border_2e_round_6dp"
android:gravity="center">
<ImageView
android:layout_width="22dp"
android:layout_height="22dp"
app:srcCompat="@drawable/ic_qr"
android:layout_marginStart="8dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/scan_qr"
android:textSize="12dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_medium"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="4dp"
android:paddingEnd="12dp" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
@@ -97,27 +97,7 @@
app:layout_constraintTop_toBottomOf="@id/text_name" app:layout_constraintTop_toBottomOf="@id/text_name"
app:layout_constraintLeft_toRightOf="@id/image_device" /> app:layout_constraintLeft_toRightOf="@id/image_device" />
<LinearLayout
android:id="@+id/button_disconnect"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_button_accent"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_light"
android:text="@string/stop" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
@@ -253,4 +233,30 @@
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingBottom="15dp"> android:paddingBottom="15dp">
</LinearLayout> </LinearLayout>
<LinearLayout
android:id="@+id/button_disconnect"
android:layout_width="match_parent"
android:layout_height="35dp"
android:background="@drawable/background_button_accent"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:layout_marginBottom="20dp"
android:gravity="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_light"
android:text="@string/stop_casting" />
</LinearLayout>
</LinearLayout> </LinearLayout>
@@ -144,6 +144,9 @@
android:layout_marginTop="10dp" android:layout_marginTop="10dp"
android:layout_marginLeft="15dp" android:layout_marginLeft="15dp"
android:layout_marginRight="15dp" android:layout_marginRight="15dp"
android:inputType="text"
android:imeOptions="actionDone"
android:singleLine="true"
android:background="@drawable/background_button_round" android:background="@drawable/background_button_round"
android:hint="Search.." /> android:hint="Search.." />
@@ -1,14 +1,56 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".fragment.mainactivity.main.SuggestionsFragment"> tools:context=".fragment.mainactivity.main.SuggestionsFragment">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/transparent"
app:elevation="0dp">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="0dp"
app:layout_scrollFlags="scroll"
app:contentInsetStart="0dp"
app:contentInsetEnd="0dp">
<LinearLayout
android:id="@+id/container_toolbar_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.futo.platformplayer.views.announcements.AnnouncementView
android:id="@+id/announcement_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone" />
<com.futo.platformplayer.views.others.RadioGroupView
android:id="@+id/radio_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="8dp"
android:paddingEnd="8dp" />
</LinearLayout>
</androidx.appcompat.widget.Toolbar>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/list_suggestions" android:id="@+id/list_suggestions"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" /> android:orientation="vertical"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</FrameLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>
+35 -78
View File
@@ -4,18 +4,34 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="35dp" android:layout_height="35dp"
android:clickable="true"> android:clickable="true"
android:id="@+id/layout_root">
<ImageView <FrameLayout
android:id="@+id/image_device" android:id="@+id/layout_device"
android:layout_width="25dp" android:layout_width="25dp"
android:layout_height="25dp" android:layout_height="25dp"
android:contentDescription="@string/cd_image_device"
app:srcCompat="@drawable/ic_chromecast"
android:scaleType="fitCenter"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/> app:layout_constraintBottom_toBottomOf="parent">
<ImageView
android:id="@+id/image_device"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/cd_image_device"
app:srcCompat="@drawable/ic_chromecast"
android:scaleType="fitCenter" />
<ImageView
android:id="@+id/image_online"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_gravity="end|top"
android:contentDescription="@string/cd_image_device"
app:srcCompat="@drawable/ic_online"
android:scaleType="fitCenter" />
</FrameLayout>
<TextView <TextView
android:id="@+id/text_name" android:id="@+id/text_name"
@@ -31,8 +47,8 @@
android:layout_marginStart="10dp" android:layout_marginStart="10dp"
android:layout_marginEnd="10dp" android:layout_marginEnd="10dp"
android:includeFontPadding="false" android:includeFontPadding="false"
app:layout_constraintTop_toTopOf="@id/image_device" app:layout_constraintTop_toTopOf="@id/layout_device"
app:layout_constraintLeft_toRightOf="@id/image_device" app:layout_constraintLeft_toRightOf="@id/layout_device"
app:layout_constraintRight_toLeftOf="@id/layout_button" /> app:layout_constraintRight_toLeftOf="@id/layout_button" />
<TextView <TextView
@@ -43,12 +59,12 @@
tools:text="Chromecast" tools:text="Chromecast"
android:textSize="10dp" android:textSize="10dp"
android:fontFamily="@font/inter_extra_light" android:fontFamily="@font/inter_extra_light"
android:textColor="@color/white" android:textColor="@color/gray_ac"
android:includeFontPadding="false" android:includeFontPadding="false"
android:layout_marginStart="10dp" android:layout_marginStart="10dp"
android:layout_marginEnd="10dp" android:layout_marginEnd="10dp"
app:layout_constraintTop_toBottomOf="@id/text_name" app:layout_constraintTop_toBottomOf="@id/text_name"
app:layout_constraintLeft_toRightOf="@id/image_device" app:layout_constraintLeft_toRightOf="@id/layout_device"
app:layout_constraintRight_toLeftOf="@id/layout_button" /> app:layout_constraintRight_toLeftOf="@id/layout_button" />
<LinearLayout <LinearLayout
@@ -68,74 +84,15 @@
app:srcCompat="@drawable/ic_loader_animated" app:srcCompat="@drawable/ic_loader_animated"
android:layout_marginEnd="8dp"/> android:layout_marginEnd="8dp"/>
<LinearLayout <ImageView
android:id="@+id/image_pin"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="25dp"
android:orientation="horizontal" android:contentDescription="@string/cd_image_loader"
app:layout_constraintRight_toRightOf="parent" app:srcCompat="@drawable/ic_pin"
app:layout_constraintTop_toTopOf="parent" android:layout_marginEnd="8dp"
app:layout_constraintBottom_toBottomOf="parent"> android:scaleType="fitEnd"
android:paddingStart="10dp" />
<LinearLayout
android:id="@+id/button_remove"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_button_accent"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:layout_marginEnd="7dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_light"
android:text="@string/remove" />
</LinearLayout>
<LinearLayout
android:id="@+id/button_disconnect"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_button_accent"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_light"
android:text="@string/stop" />
</LinearLayout>
<LinearLayout
android:id="@+id/button_connect"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_button_primary"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_light"
android:text="@string/start" />
</LinearLayout>
</LinearLayout>
<TextView <TextView
android:id="@+id/text_not_ready" android:id="@+id/text_not_ready"
+3 -3
View File
@@ -57,15 +57,15 @@
<ImageView <ImageView
android:id="@+id/image_clear" android:id="@+id/image_clear"
android:layout_width="16dp" android:layout_width="36dp"
android:layout_height="16dp" android:layout_height="36dp"
app:srcCompat="@drawable/ic_clear_16dp" app:srcCompat="@drawable/ic_clear_16dp"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toRightOf="parent" app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginEnd="6dp" android:layout_marginEnd="6dp"
android:layout_marginStart="6dp" android:layout_marginStart="6dp"
android:padding="2dp" /> android:padding="12dp" />
<TextView <TextView
android:id="@+id/text_name" android:id="@+id/text_name"
+15 -1
View File
@@ -135,6 +135,7 @@
<string name="not_ready">Not ready</string> <string name="not_ready">Not ready</string>
<string name="connect">Connect</string> <string name="connect">Connect</string>
<string name="stop">Stop</string> <string name="stop">Stop</string>
<string name="stop_casting">Stop casting</string>
<string name="start">Start</string> <string name="start">Start</string>
<string name="storage_space">Storage Space</string> <string name="storage_space">Storage Space</string>
<string name="downloads">Downloads</string> <string name="downloads">Downloads</string>
@@ -194,7 +195,9 @@
<string name="ip">IP</string> <string name="ip">IP</string>
<string name="port">Port</string> <string name="port">Port</string>
<string name="discovered_devices">Discovered Devices</string> <string name="discovered_devices">Discovered Devices</string>
<string name="available_devices">Available devices</string>
<string name="remembered_devices">Remembered Devices</string> <string name="remembered_devices">Remembered Devices</string>
<string name="unable_to_see_the_Device_youre_looking_for_try_add_the_device_manually">Unable to see the device you\'re looking for? Try to add the device manually.</string>
<string name="there_are_no_remembered_devices">There are no remembered devices</string> <string name="there_are_no_remembered_devices">There are no remembered devices</string>
<string name="connected_to">Connected to</string> <string name="connected_to">Connected to</string>
<string name="volume">Volume</string> <string name="volume">Volume</string>
@@ -204,6 +207,7 @@
<string name="previous">Previous</string> <string name="previous">Previous</string>
<string name="next">Next</string> <string name="next">Next</string>
<string name="comment">Comment</string> <string name="comment">Comment</string>
<string name="add_manually">Add manually</string>
<string name="not_empty_close">Comment is not empty, close anyway?</string> <string name="not_empty_close">Comment is not empty, close anyway?</string>
<string name="str_import">Import</string> <string name="str_import">Import</string>
<string name="my_playlist_name">My Playlist Name</string> <string name="my_playlist_name">My Playlist Name</string>
@@ -335,7 +339,7 @@
<string name="configure_if_background_download_should_be_used">Configure if background download should be used</string> <string name="configure_if_background_download_should_be_used">Configure if background download should be used</string>
<string name="configure_the_auto_updater">Configure the auto updater</string> <string name="configure_the_auto_updater">Configure the auto updater</string>
<string name="configure_when_updates_should_be_downloaded">Configure when updates should be downloaded</string> <string name="configure_when_updates_should_be_downloaded">Configure when updates should be downloaded</string>
<string name="configure_when_videos_should_be_downloaded">Configure when videos should be downloaded</string> <string name="configure_when_videos_should_be_downloaded">Configure when videos should be downloaded, if they should only be downloaded on unmetered networks (wifi/ethernet)</string>
<string name="creates_a_zip_file_with_your_data_which_can_be_imported_by_opening_it_with_grayjay">Creates a zip file with your data which can be imported by opening it with Grayjay</string> <string name="creates_a_zip_file_with_your_data_which_can_be_imported_by_opening_it_with_grayjay">Creates a zip file with your data which can be imported by opening it with Grayjay</string>
<string name="default_audio_quality">Default Audio Quality</string> <string name="default_audio_quality">Default Audio Quality</string>
<string name="default_playback_speed">Default Playback Speed</string> <string name="default_playback_speed">Default Playback Speed</string>
@@ -372,6 +376,16 @@
<string name="connect_discovered_description">Allow device to search for and initiate connection with known paired devices</string> <string name="connect_discovered_description">Allow device to search for and initiate connection with known paired devices</string>
<string name="connect_last">Try connect last</string> <string name="connect_last">Try connect last</string>
<string name="connect_last_description">Allow device to automatically connect to last known</string> <string name="connect_last_description">Allow device to automatically connect to last known</string>
<string name="discover_through_relay">Discover through relay</string>
<string name="discover_through_relay_description">Allow paired devices to be discovered and connected to through the relay</string>
<string name="pair_through_relay">Pair through relay</string>
<string name="pair_through_relay_description">Allow devices to be paired through the relay</string>
<string name="connect_through_relay">Connection through relay</string>
<string name="connect_through_relay_description">Allow devices to be connected to through the relay</string>
<string name="connect_local_direct_through_relay">Connect direct through relay</string>
<string name="connect_local_direct_through_relay_description">Allow devices to be directly locally connected to through information discovered from the relay</string>
<string name="local_connections">Local connections</string>
<string name="local_connections_description">Allow device to be directly locally connected</string>
<string name="gesture_controls">Gesture controls</string> <string name="gesture_controls">Gesture controls</string>
<string name="volume_slider">Volume slider</string> <string name="volume_slider">Volume slider</string>
<string name="volume_slider_descr">Enable slide gesture to change volume</string> <string name="volume_slider_descr">Enable slide gesture to change volume</string>
@@ -1,5 +1,6 @@
package com.futo.platformplayer package com.futo.platformplayer
/*
import com.futo.platformplayer.mdns.DnsOpcode import com.futo.platformplayer.mdns.DnsOpcode
import com.futo.platformplayer.mdns.DnsPacket import com.futo.platformplayer.mdns.DnsPacket
import com.futo.platformplayer.mdns.DnsPacketHeader import com.futo.platformplayer.mdns.DnsPacketHeader
@@ -12,6 +13,7 @@ import com.futo.platformplayer.mdns.QuestionClass
import com.futo.platformplayer.mdns.QuestionType import com.futo.platformplayer.mdns.QuestionType
import com.futo.platformplayer.mdns.ResourceRecordClass import com.futo.platformplayer.mdns.ResourceRecordClass
import com.futo.platformplayer.mdns.ResourceRecordType import com.futo.platformplayer.mdns.ResourceRecordType
*/
import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue import junit.framework.TestCase.assertTrue
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
@@ -20,8 +22,9 @@ import kotlin.test.Test
import kotlin.test.assertContentEquals import kotlin.test.assertContentEquals
//TODO: Update tests.
class MdnsTests { class MdnsTests {
/*
@Test @Test
fun `BasicOperation`() { fun `BasicOperation`() {
val expectedData = byteArrayOf( val expectedData = byteArrayOf(
@@ -391,4 +394,5 @@ class MdnsTests {
assertContentEquals(data, writer.toByteArray()) assertContentEquals(data, writer.toByteArray())
} }
*/
} }
@@ -9,6 +9,7 @@ import com.futo.platformplayer.noise.protocol.HandshakeState
import com.futo.platformplayer.noise.protocol.Noise import com.futo.platformplayer.noise.protocol.Noise
import com.futo.platformplayer.states.StateSync import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.sync.internal.IAuthorizable import com.futo.platformplayer.sync.internal.IAuthorizable
import com.futo.platformplayer.sync.internal.Opcode
import com.futo.platformplayer.sync.internal.SyncSocketSession import com.futo.platformplayer.sync.internal.SyncSocketSession
import com.futo.platformplayer.sync.internal.SyncStream import com.futo.platformplayer.sync.internal.SyncStream
import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertEquals
@@ -586,16 +587,16 @@ class NoiseProtocolTest {
handshakeLatch.await(10, TimeUnit.SECONDS) handshakeLatch.await(10, TimeUnit.SECONDS)
// Simulate initiator sending a PING and responder replying with PONG // Simulate initiator sending a PING and responder replying with PONG
initiatorSession.send(SyncSocketSession.Opcode.PING.value) initiatorSession.send(Opcode.PING.value)
responderSession.send(SyncSocketSession.Opcode.PONG.value) responderSession.send(Opcode.PONG.value)
// Test data transfer // Test data transfer
responderSession.send(SyncSocketSession.Opcode.DATA.value, 0u, randomBytesExactlyOnePacket) responderSession.send(Opcode.DATA.value, 0u, randomBytesExactlyOnePacket)
initiatorSession.send(SyncSocketSession.Opcode.DATA.value, 1u, randomBytes) initiatorSession.send(Opcode.DATA.value, 1u, randomBytes)
// Send large data to test stream handling // Send large data to test stream handling
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
responderSession.send(SyncSocketSession.Opcode.DATA.value, 0u, randomBytesBig) responderSession.send(Opcode.DATA.value, 0u, randomBytesBig)
println("Sent 10MB in ${System.currentTimeMillis() - start}ms") println("Sent 10MB in ${System.currentTimeMillis() - start}ms")
// Wait for a brief period to simulate delay and allow communication // Wait for a brief period to simulate delay and allow communication