Compare commits

...

24 Commits
289 ... master

Author SHA1 Message Date
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
Kelvin K
1755d03a6b Fcast clearer connection/reconnection overlay, disable ipv6 by default 2025-04-09 00:56:49 +02:00
Kelvin K
869b1fc15e Fix pager for landscape 2025-04-08 00:34:52 +02:00
Kelvin K
ce2a2f8582 submods 2025-04-07 23:32:57 +02:00
Kelvin K
7b355139fb Subscription persistence fixes, home toggle fixes, subs exchange gzip, etc 2025-04-07 23:31:00 +02:00
Kelvin K
b14518edb1 Home filter fixes, persistent sorts, subs exchange fixes, playlist video options 2025-04-05 01:02:50 +02:00
90 changed files with 4447 additions and 3353 deletions

View File

@ -0,0 +1,338 @@
package com.futo.platformplayer
import com.futo.platformplayer.noise.protocol.Noise
import com.futo.platformplayer.sync.internal.*
import kotlinx.coroutines.*
import kotlinx.coroutines.selects.select
import org.junit.Assert.*
import org.junit.Test
import java.net.Socket
import java.nio.ByteBuffer
import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class SyncServerTests {
//private val relayHost = "relay.grayjay.app"
//private val relayKey = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw="
private val relayKey = "XlUaSpIlRaCg0TGzZ7JYmPupgUHDqTZXUUBco2K7ejw="
private val relayHost = "192.168.1.138"
private val relayPort = 9000
/** Creates a client connected to the live relay server. */
private suspend fun createClient(
onHandshakeComplete: ((SyncSocketSession) -> Unit)? = null,
onData: ((SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit)? = null,
onNewChannel: ((SyncSocketSession, ChannelRelayed) -> Unit)? = null,
isHandshakeAllowed: ((LinkType, SyncSocketSession, String, String?, UInt) -> Boolean)? = null,
onException: ((Throwable) -> Unit)? = null
): SyncSocketSession = withContext(Dispatchers.IO) {
val p = Noise.createDH("25519")
p.generateKeyPair()
val socket = Socket(relayHost, relayPort)
val inputStream = LittleEndianDataInputStream(socket.getInputStream())
val outputStream = LittleEndianDataOutputStream(socket.getOutputStream())
val tcs = CompletableDeferred<Boolean>()
val socketSession = SyncSocketSession(
relayHost,
p,
inputStream,
outputStream,
onClose = { socket.close() },
onHandshakeComplete = { s ->
onHandshakeComplete?.invoke(s)
tcs.complete(true)
},
onData = onData ?: { _, _, _, _ -> },
onNewChannel = onNewChannel ?: { _, _ -> },
isHandshakeAllowed = isHandshakeAllowed ?: { _, _, _, _, _ -> true }
)
socketSession.authorizable = AlwaysAuthorized()
try {
socketSession.startAsInitiator(relayKey)
} catch (e: Throwable) {
onException?.invoke(e)
}
withTimeout(5000.milliseconds) { tcs.await() }
return@withContext socketSession
}
@Test
fun multipleClientsHandshake_Success() = runBlocking {
val client1 = createClient()
val client2 = createClient()
assertNotNull(client1.remotePublicKey, "Client 1 handshake failed")
assertNotNull(client2.remotePublicKey, "Client 2 handshake failed")
client1.stop()
client2.stop()
}
@Test
fun publishAndRequestConnectionInfo_Authorized_Success() = runBlocking {
val clientA = createClient()
val clientB = createClient()
val clientC = createClient()
clientA.publishConnectionInformation(arrayOf(clientB.localPublicKey), 12345, true, true, true, true)
delay(100.milliseconds)
val infoB = clientB.requestConnectionInfo(clientA.localPublicKey)
val infoC = clientC.requestConnectionInfo(clientA.localPublicKey)
assertNotNull("Client B should receive connection info", infoB)
assertEquals(12345.toUShort(), infoB!!.port)
assertNull("Client C should not receive connection info (unauthorized)", infoC)
clientA.stop()
clientB.stop()
clientC.stop()
}
@Test
fun relayedTransport_Bidirectional_Success() = runBlocking {
val tcsA = CompletableDeferred<ChannelRelayed>()
val tcsB = CompletableDeferred<ChannelRelayed>()
val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
channelA.authorizable = AlwaysAuthorized()
val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
channelB.authorizable = AlwaysAuthorized()
channelTask.await()
val tcsDataB = CompletableDeferred<ByteArray>()
channelB.setDataHandler { _, _, o, so, d ->
val b = ByteArray(d.remaining())
d.get(b)
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
}
channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(1, 2, 3)))
val tcsDataA = CompletableDeferred<ByteArray>()
channelA.setDataHandler { _, _, o, so, d ->
val b = ByteArray(d.remaining())
d.get(b)
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataA.complete(b)
}
channelB.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(4, 5, 6)))
val receivedB = withTimeout(5000.milliseconds) { tcsDataB.await() }
val receivedA = withTimeout(5000.milliseconds) { tcsDataA.await() }
assertArrayEquals(byteArrayOf(1, 2, 3), receivedB)
assertArrayEquals(byteArrayOf(4, 5, 6), receivedA)
clientA.stop()
clientB.stop()
}
@Test
fun relayedTransport_MaximumMessageSize_Success() = runBlocking {
val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE - 8 - 16 - 16
val maxSizeData = ByteArray(MAX_DATA_PER_PACKET).apply { Random.nextBytes(this) }
val tcsA = CompletableDeferred<ChannelRelayed>()
val tcsB = CompletableDeferred<ChannelRelayed>()
val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
channelA.authorizable = AlwaysAuthorized()
val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
channelB.authorizable = AlwaysAuthorized()
channelTask.await()
val tcsDataB = CompletableDeferred<ByteArray>()
channelB.setDataHandler { _, _, o, so, d ->
val b = ByteArray(d.remaining())
d.get(b)
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
}
channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxSizeData))
val receivedData = withTimeout(5000.milliseconds) { tcsDataB.await() }
assertArrayEquals(maxSizeData, receivedData)
clientA.stop()
clientB.stop()
}
@Test
fun publishAndGetRecord_Success() = runBlocking {
val clientA = createClient()
val clientB = createClient()
val clientC = createClient()
val data = byteArrayOf(1, 2, 3)
val success = clientA.publishRecords(listOf(clientB.localPublicKey), "testKey", data)
val recordB = clientB.getRecord(clientA.localPublicKey, "testKey")
val recordC = clientC.getRecord(clientA.localPublicKey, "testKey")
assertTrue(success)
assertNotNull(recordB)
assertArrayEquals(data, recordB!!.first)
assertNull("Unauthorized client should not access record", recordC)
clientA.stop()
clientB.stop()
clientC.stop()
}
@Test
fun getNonExistentRecord_ReturnsNull() = runBlocking {
val clientA = createClient()
val clientB = createClient()
val record = clientB.getRecord(clientA.localPublicKey, "nonExistentKey")
assertNull("Getting non-existent record should return null", record)
clientA.stop()
clientB.stop()
}
@Test
fun updateRecord_TimestampUpdated() = runBlocking {
val clientA = createClient()
val clientB = createClient()
val key = "updateKey"
val data1 = byteArrayOf(1)
val data2 = byteArrayOf(2)
clientA.publishRecords(listOf(clientB.localPublicKey), key, data1)
val record1 = clientB.getRecord(clientA.localPublicKey, key)
delay(1000.milliseconds)
clientA.publishRecords(listOf(clientB.localPublicKey), key, data2)
val record2 = clientB.getRecord(clientA.localPublicKey, key)
assertNotNull(record1)
assertNotNull(record2)
assertTrue(record2!!.second > record1!!.second)
assertArrayEquals(data2, record2.first)
clientA.stop()
clientB.stop()
}
@Test
fun deleteRecord_Success() = runBlocking {
val clientA = createClient()
val clientB = createClient()
val data = byteArrayOf(1, 2, 3)
clientA.publishRecords(listOf(clientB.localPublicKey), "toDelete", data)
val success = clientB.deleteRecords(clientA.localPublicKey, clientB.localPublicKey, listOf("toDelete"))
val record = clientB.getRecord(clientA.localPublicKey, "toDelete")
assertTrue(success)
assertNull(record)
clientA.stop()
clientB.stop()
}
@Test
fun listRecordKeys_Success() = runBlocking {
val clientA = createClient()
val clientB = createClient()
val keys = arrayOf("key1", "key2", "key3")
keys.forEach { key ->
clientA.publishRecords(listOf(clientB.localPublicKey), key, byteArrayOf(1))
}
val listedKeys = clientB.listRecordKeys(clientA.localPublicKey, clientB.localPublicKey)
assertArrayEquals(keys, listedKeys.map { it.first }.toTypedArray())
clientA.stop()
clientB.stop()
}
@Test
fun singleLargeMessageViaRelayedChannel_Success() = runBlocking {
val largeData = ByteArray(100000).apply { Random.nextBytes(this) }
val tcsA = CompletableDeferred<ChannelRelayed>()
val tcsB = CompletableDeferred<ChannelRelayed>()
val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
channelA.authorizable = AlwaysAuthorized()
val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
channelB.authorizable = AlwaysAuthorized()
channelTask.await()
val tcsDataB = CompletableDeferred<ByteArray>()
channelB.setDataHandler { _, _, o, so, d ->
val b = ByteArray(d.remaining())
d.get(b)
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
}
channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData))
val receivedData = withTimeout(10000.milliseconds) { tcsDataB.await() }
assertArrayEquals(largeData, receivedData)
clientA.stop()
clientB.stop()
}
@Test
fun publishAndGetLargeRecord_Success() = runBlocking {
val largeData = ByteArray(1000000).apply { Random.nextBytes(this) }
val clientA = createClient()
val clientB = createClient()
val success = clientA.publishRecords(listOf(clientB.localPublicKey), "largeRecord", largeData)
val record = clientB.getRecord(clientA.localPublicKey, "largeRecord")
assertTrue(success)
assertNotNull(record)
assertArrayEquals(largeData, record!!.first)
clientA.stop()
clientB.stop()
}
@Test
fun relayedTransport_WithValidAppId_Success() = runBlocking {
// Arrange: Set up clients
val allowedAppId = 1234u
val tcsB = CompletableDeferred<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 {
override val isAuthorized: Boolean get() = true
}

View File

@ -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
}

View File

@ -216,9 +216,14 @@ private fun ByteArray.toInetAddress(): InetAddress {
return InetAddress.getByAddress(this);
}
fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
val timeout = 2000
val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses;
if(addresses.isEmpty())
throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})");
if (addresses.isEmpty()) {
return null;
}

View File

@ -218,6 +218,8 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.show_home_filters, FieldForm.TOGGLE, R.string.show_home_filters_description, 4)
var showHomeFilters: Boolean = true;
@FormField(R.string.show_home_filters_plugin_names, FieldForm.TOGGLE, R.string.show_home_filters_plugin_names_description, 5)
var showHomeFiltersPluginNames: Boolean = false;
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
var previewFeedItems: Boolean = true;
@ -581,10 +583,15 @@ class Settings : FragmentedStorageFileJson() {
@Serializable(with = FlexibleBooleanSerializer::class)
var keepScreenOn: Boolean = true;
@FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 1)
@FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 3)
@Serializable(with = FlexibleBooleanSerializer::class)
var alwaysProxyRequests: Boolean = false;
@FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4)
@Serializable(with = FlexibleBooleanSerializer::class)
var allowIpv6: Boolean = false;
/*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@DropdownFieldOptionsId(R.array.preferred_quality_array)
@ -929,6 +936,15 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.connect_last, FieldForm.TOGGLE, R.string.connect_last_description, 3)
var connectLast: Boolean = true;
@FormField(R.string.discover_through_relay, FieldForm.TOGGLE, R.string.discover_through_relay_description, 3)
var discoverThroughRelay: Boolean = true;
@FormField(R.string.pair_through_relay, FieldForm.TOGGLE, R.string.pair_through_relay_description, 3)
var pairThroughRelay: Boolean = true;
@FormField(R.string.connect_through_relay, FieldForm.TOGGLE, R.string.connect_through_relay_description, 3)
var connectThroughRelay: Boolean = true;
}
@FormField(R.string.info, FieldForm.GROUP, -1, 21)

View File

@ -5,6 +5,7 @@ import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.Animatable
import android.net.Uri
import android.text.Layout
import android.text.method.ScrollingMovementMethod
@ -199,16 +200,21 @@ class UIDialogs {
dialog.show();
}
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action) {
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions);
}
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
val builder = AlertDialog.Builder(context);
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
builder.setView(view);
builder.setCancelable(defaultCloseAction > -2);
val dialog = builder.create();
registerDialogOpened(dialog);
view.findViewById<ImageView>(R.id.dialog_icon).apply {
this.setImageResource(icon);
if(animated)
this.drawable.assume<Animatable, Unit> { it.start() };
}
view.findViewById<TextView>(R.id.dialog_text).apply {
this.text = text;
@ -275,6 +281,7 @@ class UIDialogs {
registerDialogClosed(dialog);
}
dialog.show();
return dialog;
}
fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) {

View File

@ -27,14 +27,17 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.PlatformVideoWithTime
import com.futo.platformplayer.others.PlatformLinkMovementMethod
import java.io.ByteArrayInputStream
import java.io.File
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.SecureRandom
import java.time.OffsetDateTime
import java.util.*
import java.util.concurrent.ThreadLocalRandom
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
fun getRandomString(sizeOfRandomString: Int): String {
@ -66,7 +69,14 @@ fun warnIfMainThread(context: String) {
}
fun ensureNotMainThread() {
if (Looper.myLooper() == Looper.getMainLooper()) {
val isMainLooper = try {
Looper.myLooper() == Looper.getMainLooper()
} catch (e: Throwable) {
//Ignore, for unit tests where its not mocked
false
}
if (isMainLooper) {
Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread")
throw IllegalStateException("Cannot run on main thread")
}
@ -279,3 +289,46 @@ fun ByteBuffer.toUtf8String(): String {
get(remainingBytes)
return String(remainingBytes, Charsets.UTF_8)
}
fun generateReadablePassword(length: Int): String {
val validChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"
val secureRandom = SecureRandom()
val randomBytes = ByteArray(length)
secureRandom.nextBytes(randomBytes)
val sb = StringBuilder(length)
for (byte in randomBytes) {
val index = (byte.toInt() and 0xFF) % validChars.length
sb.append(validChars[index])
}
return sb.toString()
}
fun ByteArray.toGzip(): ByteArray {
if (this == null || this.isEmpty()) return ByteArray(0)
val gzipTimeStart = OffsetDateTime.now();
val outputStream = ByteArrayOutputStream()
GZIPOutputStream(outputStream).use { gzip ->
gzip.write(this)
}
val result = outputStream.toByteArray();
Logger.i("Utility", "Gzip compression time: ${gzipTimeStart.getNowDiffMiliseconds()}ms");
return result;
}
fun ByteArray.fromGzip(): ByteArray {
if (this == null || this.isEmpty()) return ByteArray(0)
val inputStream = ByteArrayInputStream(this)
val outputStream = ByteArrayOutputStream()
GZIPInputStream(inputStream).use { gzip ->
val buffer = ByteArray(1024)
var bytesRead: Int
while (gzip.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
}
}
return outputStream.toByteArray()
}

View File

@ -100,7 +100,8 @@ class SyncHomeActivity : AppCompatActivity() {
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
val connected = session?.connected ?: false
syncDeviceView.setLinkType(if (connected) LinkType.Local else LinkType.None)
syncDeviceView.setLinkType(session?.linkType ?: LinkType.None)
.setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey)
//TODO: also display public key?
.setStatus(if (connected) "Connected" else "Disconnected")

View File

@ -109,9 +109,9 @@ class SyncPairActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.IO) {
try {
StateSync.instance.connect(deviceInfo) { session, complete, message ->
StateSync.instance.connect(deviceInfo) { complete, message ->
lifecycleScope.launch(Dispatchers.Main) {
if (complete) {
if (complete != null && complete) {
_layoutPairingSuccess.visibility = View.VISIBLE
_layoutPairing.visibility = View.GONE
} else {

View File

@ -67,7 +67,7 @@ class SyncShowPairingCodeActivity : AppCompatActivity() {
}
val ips = getIPs()
val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT)
val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT, StateSync.instance.pairingCode)
val json = Json.encodeToString(selfDeviceInfo)
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
val url = "grayjay://sync/${base64}"

View File

@ -1,5 +1,6 @@
package com.futo.platformplayer.api.media
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
@ -66,6 +67,11 @@ interface IPlatformClient {
*/
fun searchChannels(query: String): IPager<PlatformAuthorLink>;
/**
* Searches for channels and returns a content pager
*/
fun searchChannelsAsContent(query: String): IPager<IPlatformContent>;
//Video Pages
/**

View File

@ -2,7 +2,10 @@ package com.futo.platformplayer.api.media.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.models.JSContent
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
@ -42,4 +45,21 @@ open class PlatformAuthorLink {
);
}
}
}
interface IPlatformChannelContent : IPlatformContent {
val thumbnail: String?
val subscribers: Long?
}
open class JSChannelContent : JSContent, IPlatformChannelContent {
override val contentType: ContentType get() = ContentType.CHANNEL
override val thumbnail: String?
override val subscribers: Long?
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
val contextName = "Channel";
thumbnail = obj.getOrDefault<String>(config, "thumbnail", contextName, null)
subscribers = if(obj.has("subscribers")) obj.getOrThrow(config,"subscribers", contextName) else null
}
}

View File

@ -12,6 +12,7 @@ enum class ContentType(val value: Int) {
URL(9),
NESTED_VIDEO(11),
CHANNEL(60),
LOCKED(70),

View File

@ -10,6 +10,7 @@ import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformClientCapabilities
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
@ -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.IJSContentDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSChannel
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelContentPager
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager
import com.futo.platformplayer.api.media.platforms.js.models.JSChapter
import com.futo.platformplayer.api.media.platforms.js.models.JSComment
@ -361,6 +363,10 @@ open class JSClient : IPlatformClient {
return@isBusyWith JSChannelPager(config, this,
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")
@JSDocsParameter("url", "A channel url (May not be your platform)")

View File

@ -1,6 +1,7 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.JSChannelContent
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient
@ -26,6 +27,7 @@ interface IJSContent: IPlatformContent {
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
ContentType.PLAYLIST -> JSPlaylist(config, obj);
ContentType.LOCKED -> JSLockedContent(config, obj);
ContentType.CHANNEL -> JSChannelContent(config, obj)
else -> throw NotImplementedError("Unknown content type ${type}");
}
}

View File

@ -5,7 +5,6 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.V8Plugin
class JSChannelPager : JSPager<PlatformAuthorLink>, IPager<PlatformAuthorLink> {

View File

@ -49,8 +49,8 @@ open class JSContent : IPlatformContent, IPluginSourced {
else
author = PlatformAuthorLink.UNKNOWN;
val datetimeInt = _content.getOrThrow<Int>(config, "datetime", contextName).toLong();
if(datetimeInt == 0.toLong())
val datetimeInt = _content.getOrDefault<Int>(config, "datetime", contextName, null)?.toLong();
if(datetimeInt == null || datetimeInt == 0.toLong())
datetime = null;
else
datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);

View File

@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.models.JSChannelContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@ -15,4 +16,14 @@ class JSContentPager : JSPager<IPlatformContent>, IPluginSourced {
override fun convertResult(obj: V8ValueObject): IPlatformContent {
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);
}
}

View File

@ -3,6 +3,7 @@ package com.futo.platformplayer.casting
import android.os.Looper
import android.util.Base64
import android.util.Log
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
@ -32,6 +33,7 @@ import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.math.BigInteger
import java.net.Inet4Address
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
@ -90,7 +92,7 @@ class FCastCastingDevice : CastingDevice {
private var _version: Long = 1;
private var _thread: Thread? = null
private var _pingThread: Thread? = null
private var _lastPongTime = -1L
@Volatile private var _lastPongTime = System.currentTimeMillis()
private var _outputStreamLock = Object()
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
@ -324,9 +326,9 @@ class FCastCastingDevice : CastingDevice {
continue;
}
localAddress = _socket?.localAddress;
connectionState = CastConnectionState.CONNECTED;
_lastPongTime = -1L
localAddress = _socket?.localAddress
_lastPongTime = System.currentTimeMillis()
connectionState = CastConnectionState.CONNECTED
val buffer = ByteArray(4096);
@ -402,36 +404,32 @@ class FCastCastingDevice : CastingDevice {
_pingThread = Thread {
Logger.i(TAG, "Started ping loop.")
while (_scopeIO?.isActive == true) {
try {
send(Opcode.Ping)
} catch (e: Throwable) {
Log.w(TAG, "Failed to send ping.")
if (connectionState == CastConnectionState.CONNECTED) {
try {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
send(Opcode.Ping)
if (System.currentTimeMillis() - _lastPongTime > 15000) {
Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.")
try {
_socket?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
Log.w(TAG, "Failed to send ping.")
try {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}
}
/*if (_lastPongTime != -1L && System.currentTimeMillis() - _lastPongTime > 6000) {
Logger.w(TAG, "Closing socket due to last pong time being larger than 6 seconds.")
try {
_socket?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}*/
Thread.sleep(2000)
Thread.sleep(5000)
}
Logger.i(TAG, "Stopped ping loop.");
Logger.i(TAG, "Stopped ping loop.")
}.apply { start() }
} else {
Log.i(TAG, "Thread was still alive, not restarted")

View File

@ -1,14 +1,18 @@
package com.futo.platformplayer.casting
import android.app.AlertDialog
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.Looper
import android.util.Base64
import android.util.Log
import android.util.Xml
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
@ -38,8 +42,6 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.mdns.DnsService
import com.futo.platformplayer.mdns.ServiceDiscoverer
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.StateApp
@ -53,7 +55,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.net.InetAddress
import java.net.URLDecoder
import java.net.URLEncoder
@ -68,7 +69,6 @@ class StateCasting {
private var _started = false;
var devices: HashMap<String, CastingDevice> = hashMapOf();
var rememberedDevices: ArrayList<CastingDevice> = arrayListOf();
val onDeviceAdded = Event1<CastingDevice>();
val onDeviceChanged = Event1<CastingDevice>();
val onDeviceRemoved = Event1<CastingDevice>();
@ -82,48 +82,15 @@ class StateCasting {
private var _audioExecutor: JSRequestExecutor? = null
private val _client = ManagedHttpClient();
var _resumeCastingDevice: CastingDeviceInfo? = null;
val _serviceDiscoverer = ServiceDiscoverer(arrayOf(
"_googlecast._tcp.local",
"_airplay._tcp.local",
"_fastcast._tcp.local",
"_fcast._tcp.local"
)) { handleServiceUpdated(it) }
private var _nsdManager: NsdManager? = null
val isCasting: Boolean get() = activeDevice != null;
private fun handleServiceUpdated(services: List<DnsService>) {
for (s in services) {
//TODO: Addresses IPv4 only?
val addresses = s.addresses.toTypedArray()
val port = s.port.toInt()
var name = s.texts.firstOrNull { it.startsWith("md=") }?.substring("md=".length)
if (s.name.endsWith("._googlecast._tcp.local")) {
if (name == null) {
name = s.name.substring(0, s.name.length - "._googlecast._tcp.local".length)
}
addOrUpdateChromeCastDevice(name, addresses, port)
} else if (s.name.endsWith("._airplay._tcp.local")) {
if (name == null) {
name = s.name.substring(0, s.name.length - "._airplay._tcp.local".length)
}
addOrUpdateAirPlayDevice(name, addresses, port)
} else if (s.name.endsWith("._fastcast._tcp.local")) {
if (name == null) {
name = s.name.substring(0, s.name.length - "._fastcast._tcp.local".length)
}
addOrUpdateFastCastDevice(name, addresses, port)
} else if (s.name.endsWith("._fcast._tcp.local")) {
if (name == null) {
name = s.name.substring(0, s.name.length - "._fcast._tcp.local".length)
}
addOrUpdateFastCastDevice(name, addresses, port)
}
}
}
private val _discoveryListeners = mapOf(
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
)
fun handleUrl(context: Context, url: String) {
val uri = Uri.parse(url)
@ -188,30 +155,29 @@ class StateCasting {
Logger.i(TAG, "CastingService starting...");
rememberedDevices.clear();
rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) });
_castServer.start();
enableDeveloper(true);
Logger.i(TAG, "CastingService started.");
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
}
@Synchronized
fun startDiscovering() {
try {
_serviceDiscoverer.start()
} catch (e: Throwable) {
Logger.i(TAG, "Failed to start ServiceDiscoverer", e)
_nsdManager?.apply {
_discoveryListeners.forEach {
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
}
}
}
@Synchronized
fun stopDiscovering() {
try {
_serviceDiscoverer.stop()
} catch (e: Throwable) {
Logger.i(TAG, "Failed to stop ServiceDiscoverer", e)
_nsdManager?.apply {
_discoveryListeners.forEach {
stopServiceDiscovery(it.value)
}
}
}
@ -237,8 +203,82 @@ class StateCasting {
_castServer.removeAllHandlers();
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")
_nsdManager?.stopServiceDiscovery(this)
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
_nsdManager?.stopServiceDiscovery(this)
}
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 var _currentDialog: AlertDialog? = null;
@Synchronized
fun connectDevice(device: CastingDevice) {
if (activeDevice == device)
@ -272,10 +312,41 @@ class StateCasting {
invokeInMainScopeIfRequired {
StateApp.withContext(false) { context ->
context.let {
Logger.i(TAG, "Casting state changed to ${castConnectionState}");
when (castConnectionState) {
CastConnectionState.CONNECTED -> UIDialogs.toast(it, "Connected to device")
CastConnectionState.CONNECTING -> UIDialogs.toast(it, "Connecting to device...")
CastConnectionState.DISCONNECTED -> UIDialogs.toast(it, "Disconnected from device")
CastConnectionState.CONNECTED -> {
Logger.i(TAG, "Casting connected to [${device.name}]");
UIDialogs.appToast("Connected to device")
synchronized(_castingDialogLock) {
if(_currentDialog != null) {
_currentDialog?.hide();
_currentDialog = null;
}
}
}
CastConnectionState.CONNECTING -> {
Logger.i(TAG, "Casting connecting to [${device.name}]");
UIDialogs.toast(it, "Connecting to device...")
synchronized(_castingDialogLock) {
if(_currentDialog == null) {
_currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true,
"Connecting to [${device.name}]",
"Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2,
UIDialogs.Action("Disconnect", {
device.stop();
}));
}
}
}
CastConnectionState.DISCONNECTED -> {
UIDialogs.toast(it, "Disconnected from device")
synchronized(_castingDialogLock) {
if(_currentDialog != null) {
_currentDialog?.hide();
_currentDialog = null;
}
}
}
}
}
};
@ -295,9 +366,6 @@ class StateCasting {
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
};
addRememberedDevice(device);
Logger.i(TAG, "Device added to active discovery. Active discovery now contains ${_storage.getDevicesCount()} devices.")
try {
device.start();
} catch (e: Throwable) {
@ -319,21 +387,22 @@ class StateCasting {
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 {
val deviceInfo = device.getDeviceInfo()
val foundInfo = _storage.addDevice(deviceInfo)
if (foundInfo == deviceInfo) {
rememberedDevices.add(device);
return foundInfo;
}
return foundInfo;
return _storage.addDevice(deviceInfo)
}
fun removeRememberedDevice(device: CastingDevice) {
val name = device.name ?: return;
_storage.removeDevice(name);
rememberedDevices.remove(device);
val name = device.name ?: return
_storage.removeDevice(name)
}
private fun invokeInMainScopeIfRequired(action: () -> Unit){

View File

@ -9,7 +9,9 @@ import android.view.View
import android.widget.Button
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
@ -21,22 +23,21 @@ import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.adapters.DeviceAdapter
import com.futo.platformplayer.views.adapters.DeviceAdapterEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
private lateinit var _imageLoader: ImageView;
private lateinit var _buttonClose: Button;
private lateinit var _buttonAdd: ImageButton;
private lateinit var _buttonScanQR: ImageButton;
private lateinit var _buttonAdd: LinearLayout;
private lateinit var _buttonScanQR: LinearLayout;
private lateinit var _textNoDevicesFound: TextView;
private lateinit var _textNoDevicesRemembered: TextView;
private lateinit var _recyclerDevices: RecyclerView;
private lateinit var _recyclerRememberedDevices: RecyclerView;
private lateinit var _adapter: DeviceAdapter;
private lateinit var _rememberedAdapter: DeviceAdapter;
private val _devices: ArrayList<CastingDevice> = arrayListOf();
private val _rememberedDevices: ArrayList<CastingDevice> = arrayListOf();
private val _devices: MutableSet<String> = mutableSetOf()
private val _rememberedDevices: MutableSet<String> = mutableSetOf()
private val _unifiedDevices: MutableList<DeviceAdapterEntry> = mutableListOf()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
@ -45,42 +46,40 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
_imageLoader = findViewById(R.id.image_loader);
_buttonClose = findViewById(R.id.button_close);
_buttonAdd = findViewById(R.id.button_add);
_buttonScanQR = findViewById(R.id.button_scan_qr);
_buttonScanQR = findViewById(R.id.button_qr);
_recyclerDevices = findViewById(R.id.recycler_devices);
_recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices);
_textNoDevicesFound = findViewById(R.id.text_no_devices_found);
_textNoDevicesRemembered = findViewById(R.id.text_no_devices_remembered);
_adapter = DeviceAdapter(_devices, false);
_adapter = DeviceAdapter(_unifiedDevices)
_recyclerDevices.adapter = _adapter;
_recyclerDevices.layoutManager = LinearLayoutManager(context);
_rememberedAdapter = DeviceAdapter(_rememberedDevices, true);
_rememberedAdapter.onRemove.subscribe { d ->
if (StateCasting.instance.activeDevice == d) {
d.stopCasting();
_adapter.onPin.subscribe { d ->
val isRemembered = _rememberedDevices.contains(d.name)
val newIsRemembered = !isRemembered
if (newIsRemembered) {
StateCasting.instance.addRememberedDevice(d)
val name = d.name
if (name != null) {
_rememberedDevices.add(name)
}
} else {
StateCasting.instance.removeRememberedDevice(d)
_rememberedDevices.remove(d.name)
}
StateCasting.instance.removeRememberedDevice(d);
val index = _rememberedDevices.indexOf(d);
if (index != -1) {
_rememberedDevices.removeAt(index);
_rememberedAdapter.notifyItemRemoved(index);
}
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
};
_rememberedAdapter.onConnect.subscribe { _ ->
dismiss()
UIDialogs.showCastingDialog(context)
updateUnifiedList()
}
//TODO: Integrate remembered into the main list
//TODO: Add green indicator to indicate a device is oneline
//TODO: Add pinning
//TODO: Implement QR code as an option in add manually
//TODO: Remove start button
_adapter.onConnect.subscribe { _ ->
dismiss()
UIDialogs.showCastingDialog(context)
//UIDialogs.showCastingDialog(context)
}
_recyclerRememberedDevices.adapter = _rememberedAdapter;
_recyclerRememberedDevices.layoutManager = LinearLayoutManager(context);
_buttonClose.setOnClickListener { dismiss(); };
_buttonAdd.setOnClickListener {
@ -105,77 +104,112 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
Logger.i(TAG, "Dialog shown.");
StateCasting.instance.startDiscovering()
(_imageLoader.drawable as Animatable?)?.start();
_devices.clear();
synchronized (StateCasting.instance.devices) {
_devices.addAll(StateCasting.instance.devices.values);
synchronized(StateCasting.instance.devices) {
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
}
_rememberedDevices.clear();
synchronized (StateCasting.instance.rememberedDevices) {
_rememberedDevices.addAll(StateCasting.instance.rememberedDevices);
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
updateUnifiedList()
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
val name = d.name
if (name != null)
_devices.add(name)
updateUnifiedList()
}
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name }
if (index != -1) {
_unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice)
_adapter.notifyItemChanged(index)
}
}
StateCasting.instance.onDeviceRemoved.subscribe(this) { d ->
_devices.remove(d.name)
updateUnifiedList()
}
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
if (connectionState == CastConnectionState.CONNECTED) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
dismiss()
}
}
}
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
_devices.add(d);
_adapter.notifyItemInserted(_devices.size - 1);
_textNoDevicesFound.visibility = View.GONE;
_recyclerDevices.visibility = View.VISIBLE;
};
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
val index = _devices.indexOf(d);
if (index == -1) {
return@subscribe;
}
_devices[index] = d;
_adapter.notifyItemChanged(index);
};
StateCasting.instance.onDeviceRemoved.subscribe(this) { d ->
val index = _devices.indexOf(d);
if (index == -1) {
return@subscribe;
}
_devices.removeAt(index);
_adapter.notifyItemRemoved(index);
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
};
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
if (connectionState != CastConnectionState.CONNECTED) {
return@subscribe;
}
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
dismiss();
};
};
_adapter.notifyDataSetChanged();
_rememberedAdapter.notifyDataSetChanged();
}
override fun dismiss() {
super.dismiss();
(_imageLoader.drawable as Animatable?)?.stop();
super.dismiss()
(_imageLoader.drawable as Animatable?)?.stop()
StateCasting.instance.stopDiscovering()
StateCasting.instance.onDeviceAdded.remove(this);
StateCasting.instance.onDeviceChanged.remove(this);
StateCasting.instance.onDeviceRemoved.remove(this);
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
StateCasting.instance.onDeviceAdded.remove(this)
StateCasting.instance.onDeviceChanged.remove(this)
StateCasting.instance.onDeviceRemoved.remove(this)
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this)
}
private fun updateUnifiedList() {
val oldList = ArrayList(_unifiedDevices)
val newList = buildUnifiedList()
val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun getOldListSize() = oldList.size
override fun getNewListSize() = newList.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return oldItem.castingDevice.name == newItem.castingDevice.name
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return oldItem.castingDevice.name == newItem.castingDevice.name
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
}
})
_unifiedDevices.clear()
_unifiedDevices.addAll(newList)
diffResult.dispatchUpdatesTo(_adapter)
_textNoDevicesFound.visibility = if (_unifiedDevices.isEmpty()) View.VISIBLE else View.GONE
_recyclerDevices.visibility = if (_unifiedDevices.isNotEmpty()) View.VISIBLE else View.GONE
}
private fun buildUnifiedList(): List<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 {

View File

@ -201,11 +201,12 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
protected open fun onContentUrlClicked(url: String, contentType: ContentType) {
when(contentType) {
ContentType.MEDIA -> {
StatePlayer.instance.clearQueue();
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail();
};
ContentType.PLAYLIST -> fragment.navigate<RemotePlaylistFragment>(url);
ContentType.URL -> fragment.navigate<BrowserFragment>(url);
StatePlayer.instance.clearQueue()
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
}
ContentType.PLAYLIST -> fragment.navigate<RemotePlaylistFragment>(url)
ContentType.URL -> fragment.navigate<BrowserFragment>(url)
ContentType.CHANNEL -> fragment.navigate<ChannelFragment>(url)
else -> {};
}
}

View File

@ -11,6 +11,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.TaskHandler
@ -18,6 +19,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.isHttpUrl
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.FeedStyle
@ -84,6 +86,7 @@ class ContentSearchResultsFragment : MainFragment() {
private var _filterValues: HashMap<String, List<String>> = hashMapOf();
private var _enabledClientIds: List<String>? = null;
private var _channelUrl: String? = null;
private var _searchType: SearchType? = null;
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar
@ -95,7 +98,13 @@ class ContentSearchResultsFragment : MainFragment() {
if (channelUrl != null) {
StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds)
} else {
StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
when (_searchType)
{
SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query)
SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query)
else -> throw Exception("Search type must be specified")
}
}
})
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
@ -116,6 +125,7 @@ class ContentSearchResultsFragment : MainFragment() {
if(parameter is SuggestionsFragmentData) {
setQuery(parameter.query, false);
setChannelUrl(parameter.channelUrl, false);
setSearchType(parameter.searchType, false)
fragment.topBar?.apply {
if (this is SearchTopBarFragment) {
@ -258,6 +268,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) {
_sortBy = sortBy;

View File

@ -21,6 +21,8 @@ import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.toHumanBytesSize
import com.futo.platformplayer.toHumanDuration
import com.futo.platformplayer.views.AnyInsertedAdapterView
@ -103,12 +105,15 @@ class DownloadsFragment : MainFragment() {
private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>;
private var lastDownloads: List<VideoLocal>? = null;
private var ordering: String? = "nameAsc";
private var ordering = FragmentedStorage.get<StringStorage>("downloads_ordering")
constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) {
inflater.inflate(R.layout.fragment_downloads, this);
_frag = frag;
if(ordering.value.isNullOrBlank())
ordering.value = "nameAsc";
_usageUsed = findViewById(R.id.downloads_usage_used);
_usageAvailable = findViewById(R.id.downloads_usage_available);
_usageProgress = findViewById(R.id.downloads_usage_progress);
@ -132,22 +137,23 @@ class DownloadsFragment : MainFragment() {
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
};
spinnerSortBy.setSelection(0);
val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc");
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
when(pos) {
0 -> ordering = "nameAsc"
1 -> ordering = "nameDesc"
2 -> ordering = "downloadDateAsc"
3 -> ordering = "downloadDateDesc"
4 -> ordering = "releasedAsc"
5 -> ordering = "releasedDesc"
else -> ordering = null
0 -> ordering.setAndSave("nameAsc")
1 -> ordering.setAndSave("nameDesc")
2 -> ordering.setAndSave("downloadDateAsc")
3 -> ordering.setAndSave("downloadDateDesc")
4 -> ordering.setAndSave("releasedAsc")
5 -> ordering.setAndSave("releasedDesc")
else -> ordering.setAndSave("")
}
updateContentFilters()
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
};
spinnerSortBy.setSelection(Math.max(0, options.indexOf(ordering.value)));
_listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded)
.asAnyWithTop(findViewById(R.id.downloads_top)) {
@ -230,8 +236,8 @@ class DownloadsFragment : MainFragment() {
var vidsToReturn = vids;
if(!_listDownloadSearch.text.isNullOrEmpty())
vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) || it.author.name.contains(_listDownloadSearch.text, true) };
if(!ordering.isNullOrEmpty()) {
vidsToReturn = when(ordering){
if(!ordering.value.isNullOrEmpty()) {
vidsToReturn = when(ordering.value){
"downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX };
"downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN };
"nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() }

View File

@ -136,7 +136,6 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_toolbarContentView = findViewById(R.id.container_toolbar_content);
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
if (it is IAsyncPager<*>)
it.nextPageAsync();
else
@ -197,10 +196,12 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null;
val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition();
val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null;
val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1;
val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows;
if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) {
false;
}
else if (firstVisibleItemView != null && height != null && firstVisibleItemView.height * recyclerData.results.size < height) {
else if (firstVisibleItemView != null && height != null && rowsHeight < height) {
false;
} else {
true;
@ -240,6 +241,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_automaticNextPageCounter = 0;
}
}
fun resetAutomaticNextPageCounter(){
_automaticNextPageCounter = 0;
}
protected fun setTextCentered(text: String?) {
_textCentered.text = text;

View File

@ -6,7 +6,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.allViews
import androidx.core.view.contains
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import com.futo.platformplayer.*
@ -29,6 +28,7 @@ import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.views.FeedStyle
@ -37,7 +37,6 @@ import com.futo.platformplayer.views.ToggleBar
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.announcements.AnnouncementView
import com.futo.platformplayer.views.buttons.BigButton
import kotlinx.coroutines.runBlocking
import java.time.OffsetDateTime
@ -49,6 +48,12 @@ class HomeFragment : MainFragment() {
private var _view: HomeView? = null;
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
private var _cachedLastPager: IReusablePager<IPlatformContent>? = null
private var _toggleRecent = false;
private var _toggleWatched = false;
private var _togglePluginsDisabled = mutableListOf<String>();
fun reloadFeed() {
_view?.reloadFeed()
@ -74,7 +79,7 @@ class HomeFragment : MainFragment() {
}
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = HomeView(this, inflater, _cachedRecyclerData);
val view = HomeView(this, inflater, _cachedRecyclerData, _cachedLastPager);
_view = view;
return view;
}
@ -92,6 +97,7 @@ class HomeFragment : MainFragment() {
val view = _view;
if (view != null) {
_cachedRecyclerData = view.recyclerData;
_cachedLastPager = view.lastPager;
view.cleanup();
_view = null;
}
@ -111,9 +117,10 @@ class HomeFragment : MainFragment() {
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar
private var _lastPager: IReusablePager<IPlatformContent>? = null;
var lastPager: IReusablePager<IPlatformContent>? = null;
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null, cachedLastPager: IReusablePager<IPlatformContent>? = null) : super(fragment, inflater, cachedRecyclerData) {
lastPager = cachedLastPager
_taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({ fragment.lifecycleScope }, {
StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope)
})
@ -122,7 +129,8 @@ class HomeFragment : MainFragment() {
ReusableRefreshPager(it);
else
ReusablePager(it);
_lastPager = wrappedPager;
lastPager = wrappedPager;
resetAutomaticNextPageCounter();
loadedResult(wrappedPager.getWindow());
}
.exception<ScriptCaptchaRequiredException> { }
@ -227,9 +235,6 @@ class HomeFragment : MainFragment() {
}
private val _filterLock = Object();
private var _toggleRecent = false;
private var _toggleWatched = false;
private var _togglePluginsDisabled = mutableListOf<String>();
private var _togglesConfig = FragmentedStorage.get<StringArrayStorage>("home_toggles");
fun initializeToolbarContent() {
if(_toolbarContentView.allViews.any { it is ToggleBar })
@ -245,38 +250,53 @@ class HomeFragment : MainFragment() {
layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
}
_togglePluginsDisabled.clear();
synchronized(_filterLock) {
val buttonsPlugins = (if (_togglesConfig.contains("plugins"))
var buttonsPlugins: List<ToggleBar.Toggle> = listOf()
buttonsPlugins = (if (_togglesConfig.contains("plugins"))
(StatePlatform.instance.getEnabledClients()
.filter { it is JSClient && it.enableInHome }
.map { plugin ->
ToggleBar.Toggle(plugin.name, plugin.icon, true, {
if (it) {
if (_togglePluginsDisabled.contains(plugin.id))
_togglePluginsDisabled.remove(plugin.id);
ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) plugin.name else "", plugin.icon, !fragment._togglePluginsDisabled.contains(plugin.id), { view, active ->
var dontSwap = false;
if (active) {
if (fragment._togglePluginsDisabled.contains(plugin.id))
fragment._togglePluginsDisabled.remove(plugin.id);
} else {
if (!_togglePluginsDisabled.contains(plugin.id))
_togglePluginsDisabled.add(plugin.id);
if (!fragment._togglePluginsDisabled.contains(plugin.id)) {
val enabledClients = StatePlatform.instance.getEnabledClients();
val availableAfterDisable = enabledClients.count { !fragment._togglePluginsDisabled.contains(it.id) && it.id != plugin.id };
if(availableAfterDisable > 0)
fragment._togglePluginsDisabled.add(plugin.id);
else {
UIDialogs.appToast("Home needs atleast 1 plugin active");
dontSwap = true;
}
}
}
if(!dontSwap)
reloadForFilters();
else {
view.setToggle(!active);
}
reloadForFilters();
}).withTag("plugins")
})
else listOf())
val buttons = (listOf<ToggleBar.Toggle?>(
(if (_togglesConfig.contains("today"))
ToggleBar.Toggle("Today", _toggleRecent) {
_toggleRecent = it; reloadForFilters()
ToggleBar.Toggle("Today", fragment._toggleRecent) { view, active ->
fragment._toggleRecent = active; reloadForFilters()
}
.withTag("today") else null),
(if (_togglesConfig.contains("watched"))
ToggleBar.Toggle("Unwatched", _toggleWatched) {
_toggleWatched = it; reloadForFilters()
ToggleBar.Toggle("Unwatched", fragment._toggleWatched) { view, active ->
fragment._toggleWatched = active; reloadForFilters()
}
.withTag("watched") else null),
).filterNotNull() + buttonsPlugins)
.sortedBy { _togglesConfig.indexOf(it.tag ?: "") } ?: listOf()
val buttonSettings = ToggleBar.Toggle("", R.drawable.ic_settings, true, {
val buttonSettings = ToggleBar.Toggle("", R.drawable.ic_settings, true, { view, active ->
showOrderOverlay(_overlayContainer,
"Visible home filters",
listOf(
@ -302,7 +322,7 @@ class HomeFragment : MainFragment() {
}
}
fun reloadForFilters() {
_lastPager?.let { loadedResult(it.getWindow()) };
lastPager?.let { loadedResult(it.getWindow()) };
}
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
@ -312,11 +332,11 @@ class HomeFragment : MainFragment() {
if(StateMeta.instance.isCreatorHidden(it.author.url))
return@filter false;
if(_toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25)
if(fragment._toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25)
return@filter false;
if(_toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0))
if(fragment._toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0))
return@filter false;
if(_togglePluginsDisabled.any() && it.id.pluginId != null && _togglePluginsDisabled.contains(it.id.pluginId)) {
if(fragment._togglePluginsDisabled.any() && it.id.pluginId != null && fragment._togglePluginsDisabled.contains(it.id.pluginId)) {
return@filter false;
}

View File

@ -326,6 +326,10 @@ class PlaylistFragment : MainFragment() {
playlist.videos = ArrayList(playlist.videos.filter { it != video });
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
}
override fun onVideoOptions(video: IPlatformVideo) {
UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer);
}
override fun onVideoClicked(video: IPlatformVideo) {
val playlist = _playlist;
if (playlist != null) {

View File

@ -26,6 +26,8 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.SearchView
import com.futo.platformplayer.views.adapters.*
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
@ -82,7 +84,7 @@ class PlaylistsFragment : MainFragment() {
private var _listPlaylistsSearch: EditText;
private var _ordering: String? = null;
private var _ordering = FragmentedStorage.get<StringStorage>("playlists_ordering")
constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) {
@ -145,24 +147,25 @@ class PlaylistsFragment : MainFragment() {
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.playlists_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
};
spinnerSortBy.setSelection(0);
val options = listOf("nameAsc", "nameDesc", "dateEditAsc", "dateEditDesc", "dateCreateAsc", "dateCreateDesc", "datePlayAsc", "datePlayDesc");
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
when(pos) {
0 -> _ordering = "nameAsc"
1 -> _ordering = "nameDesc"
2 -> _ordering = "dateEditAsc"
3 -> _ordering = "dateEditDesc"
4 -> _ordering = "dateCreateAsc"
5 -> _ordering = "dateCreateDesc"
6 -> _ordering = "datePlayAsc"
7 -> _ordering = "datePlayDesc"
else -> _ordering = null
0 -> _ordering.setAndSave("nameAsc")
1 -> _ordering.setAndSave("nameDesc")
2 -> _ordering.setAndSave("dateEditAsc")
3 -> _ordering.setAndSave("dateEditDesc")
4 -> _ordering.setAndSave("dateCreateAsc")
5 -> _ordering.setAndSave("dateCreateDesc")
6 -> _ordering.setAndSave("datePlayAsc")
7 -> _ordering.setAndSave("datePlayDesc")
else -> _ordering.setAndSave("")
}
updatePlaylistsFiltering()
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
};
spinnerSortBy.setSelection(Math.max(0, options.indexOf(_ordering.value)));
findViewById<TextView>(R.id.text_view_all).setOnClickListener { _fragment.navigate<WatchLaterFragment>(context.getString(R.string.watch_later)); };
@ -214,8 +217,8 @@ class PlaylistsFragment : MainFragment() {
var playlistsToReturn = pls;
if(!_listPlaylistsSearch.text.isNullOrEmpty())
playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) };
if(!_ordering.isNullOrEmpty()){
playlistsToReturn = when(_ordering){
if(!_ordering.value.isNullOrEmpty()){
playlistsToReturn = when(_ordering.value){
"nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() }
"nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() };
"dateEditAsc" -> playlistsToReturn.sortedBy { it.dateUpdate ?: OffsetDateTime.MAX };

View File

@ -18,6 +18,7 @@ import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.exceptions.RateLimitException
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment.SubscriptionsFeedView.FeedFilterSettings
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.models.SubscriptionGroup
@ -56,6 +57,9 @@ class SubscriptionsFeedFragment : MainFragment() {
private var _group: SubscriptionGroup? = null;
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
private val _filterLock = Object();
private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter");
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
_view?.onShown();
@ -184,8 +188,6 @@ class SubscriptionsFeedFragment : MainFragment() {
return Json.encodeToString(this);
}
}
private val _filterLock = Object();
private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter");
private var _bypassRateLimit = false;
private val _lastExceptions: List<Throwable>? = null;
@ -284,13 +286,18 @@ class SubscriptionsFeedFragment : MainFragment() {
fragment.navigate<SubscriptionGroupFragment>(g);
};
synchronized(_filterLock) {
synchronized(fragment._filterLock) {
_subscriptionBar?.setToggles(
SubscriptionBar.Toggle(context.getString(R.string.videos), _filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), it); },
SubscriptionBar.Toggle(context.getString(R.string.posts), _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); },
SubscriptionBar.Toggle(context.getString(R.string.live), _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); },
SubscriptionBar.Toggle(context.getString(R.string.planned), _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); },
SubscriptionBar.Toggle(context.getString(R.string.watched), _filterSettings.allowWatched) { _filterSettings.allowWatched = it; _filterSettings.save(); loadResults(false); }
SubscriptionBar.Toggle(context.getString(R.string.videos), fragment._filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { view, active ->
toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), active); },
SubscriptionBar.Toggle(context.getString(R.string.posts), fragment._filterSettings.allowContentTypes.contains(ContentType.POST)) { view, active ->
toggleFilterContentType(ContentType.POST, active); },
SubscriptionBar.Toggle(context.getString(R.string.live), fragment._filterSettings.allowLive) { view, active ->
fragment._filterSettings.allowLive = active; fragment._filterSettings.save(); loadResults(false); },
SubscriptionBar.Toggle(context.getString(R.string.planned), fragment._filterSettings.allowPlanned) { view, active ->
fragment._filterSettings.allowPlanned = active; fragment._filterSettings.save(); loadResults(false); },
SubscriptionBar.Toggle(context.getString(R.string.watched), fragment._filterSettings.allowWatched) { view, active ->
fragment._filterSettings.allowWatched = active; fragment._filterSettings.save(); loadResults(false); }
);
}
@ -301,13 +308,13 @@ class SubscriptionsFeedFragment : MainFragment() {
toggleFilterContentType(contentType, isTrue);
}
private fun toggleFilterContentType(contentType: ContentType, isTrue: Boolean) {
synchronized(_filterLock) {
synchronized(fragment._filterLock) {
if(!isTrue) {
_filterSettings.allowContentTypes.remove(contentType);
} else if(!_filterSettings.allowContentTypes.contains(contentType)) {
_filterSettings.allowContentTypes.add(contentType)
fragment._filterSettings.allowContentTypes.remove(contentType);
} else if(!fragment._filterSettings.allowContentTypes.contains(contentType)) {
fragment._filterSettings.allowContentTypes.add(contentType)
}
_filterSettings.save();
fragment._filterSettings.save();
};
if(Settings.instance.subscriptions.fetchOnTabOpen) { //TODO: Do this different, temporary workaround
loadResults(false);
@ -320,9 +327,9 @@ class SubscriptionsFeedFragment : MainFragment() {
val nowSoon = OffsetDateTime.now().plusMinutes(5);
val filterGroup = subGroup;
return results.filter {
val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType);
val allowedContentType = fragment._filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType);
if(it is IPlatformVideo && it.duration > 0 && !_filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration))
if(it is IPlatformVideo && it.duration > 0 && !fragment._filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration))
return@filter false;
//TODO: Check against a sub cache
@ -331,11 +338,11 @@ class SubscriptionsFeedFragment : MainFragment() {
if(it.datetime?.isAfter(nowSoon) == true) {
if(!_filterSettings.allowPlanned)
if(!fragment._filterSettings.allowPlanned)
return@filter false;
}
if(_filterSettings.allowLive) { //If allowLive, always show live
if(fragment._filterSettings.allowLive) { //If allowLive, always show live
if(it is IPlatformVideo && it.isLive)
return@filter true;
}

View File

@ -18,6 +18,8 @@ import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.SearchHistoryStorage
import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter
import com.futo.platformplayer.views.others.RadioGroupView
import com.futo.platformplayer.views.others.TagsView
data class SuggestionsFragmentData(val query: String, val searchType: SearchType, val channelUrl: String? = null);
@ -28,6 +30,7 @@ class SuggestionsFragment : MainFragment {
private var _recyclerSuggestions: RecyclerView? = null;
private var _llmSuggestions: LinearLayoutManager? = null;
private var _radioGroupView: RadioGroupView? = null;
private val _suggestions: ArrayList<String> = ArrayList();
private var _query: String? = null;
private var _searchType: SearchType = SearchType.VIDEO;
@ -49,14 +52,7 @@ class SuggestionsFragment : MainFragment {
_adapterSuggestions.onClicked.subscribe { suggestion ->
val storage = FragmentedStorage.get<SearchHistoryStorage>();
storage.add(suggestion);
if (_searchType == SearchType.CREATOR) {
navigate<CreatorSearchResultsFragment>(suggestion);
} else if (_searchType == SearchType.PLAYLIST) {
navigate<PlaylistSearchResultsFragment>(suggestion);
} else {
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, SearchType.VIDEO, _channelUrl));
}
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType, _channelUrl));
}
_adapterSuggestions.onRemove.subscribe { suggestion ->
val index = _suggestions.indexOf(suggestion);
@ -80,6 +76,15 @@ class SuggestionsFragment : MainFragment {
recyclerSuggestions.adapter = _adapterSuggestions;
_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();
return view;
}
@ -110,31 +115,27 @@ class SuggestionsFragment : MainFragment {
_channelUrl = null;
}
_radioGroupView?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true)
topBar?.apply {
if (this is SearchTopBarFragment) {
onSearch.subscribe(this) {
if (_searchType == SearchType.CREATOR) {
navigate<CreatorSearchResultsFragment>(it);
} else if (_searchType == SearchType.PLAYLIST) {
navigate<PlaylistSearchResultsFragment>(it);
} else {
if(it.isHttpUrl()) {
if(StatePlatform.instance.hasEnabledPlaylistClient(it))
navigate<RemotePlaylistFragment>(it);
else if(StatePlatform.instance.hasEnabledChannelClient(it))
navigate<ChannelFragment>(it);
else {
val url = it;
activity?.let {
close()
if(it is MainActivity)
it.navigate(it.getFragment<VideoDetailFragment>(), url);
}
if(it.isHttpUrl()) {
if(StatePlatform.instance.hasEnabledPlaylistClient(it))
navigate<RemotePlaylistFragment>(it);
else if(StatePlatform.instance.hasEnabledChannelClient(it))
navigate<ChannelFragment>(it);
else {
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) {
@ -196,6 +197,7 @@ class SuggestionsFragment : MainFragment {
super.onDestroyMainView();
_getSuggestions.onError.clear();
_recyclerSuggestions = null;
_radioGroupView = null
}
override fun onDestroy() {

View File

@ -693,6 +693,9 @@ class VideoDetailView : ConstraintLayout {
_container_content_description.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_liveChat.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_queue.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_queue.onOptions.subscribe {
UISlideOverlays.showVideoOptionsOverlay(it, _overlayContainer);
}
_container_content_replies.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_support.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_browser.onClose.subscribe { switchContentView(_container_content_main); };

View File

@ -1,9 +1,11 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.graphics.drawable.Animatable
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
@ -48,6 +50,11 @@ abstract class VideoListEditorView : LinearLayout {
private var _loadedVideos: List<IPlatformVideo>? = null;
private var _loadedVideosCanEdit: Boolean = false;
fun hideSearchKeyboard() {
(context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager)?.hideSoftInputFromWindow(_search.textSearch.windowToken, 0)
_search.textSearch.clearFocus();
}
constructor(inflater: LayoutInflater) : super(inflater.context) {
inflater.inflate(R.layout.fragment_video_list_editor, this);
@ -79,6 +86,7 @@ abstract class VideoListEditorView : LinearLayout {
_search.textSearch.text = "";
updateVideoFilters();
_buttonSearch.setImageResource(R.drawable.ic_search);
hideSearchKeyboard();
}
else {
_search.visibility = View.VISIBLE;
@ -89,22 +97,23 @@ abstract class VideoListEditorView : LinearLayout {
_buttonShare = findViewById(R.id.button_share);
val onShare = _onShare;
if(onShare != null) {
_buttonShare.setOnClickListener { onShare.invoke() };
_buttonShare.setOnClickListener { hideSearchKeyboard(); onShare.invoke() };
_buttonShare.visibility = View.VISIBLE;
}
else
_buttonShare.visibility = View.GONE;
buttonPlayAll.setOnClickListener { onPlayAllClick(); };
buttonShuffle.setOnClickListener { onShuffleClick(); };
buttonPlayAll.setOnClickListener { hideSearchKeyboard();onPlayAllClick(); hideSearchKeyboard(); };
buttonShuffle.setOnClickListener { hideSearchKeyboard();onShuffleClick(); hideSearchKeyboard(); };
_buttonEdit.setOnClickListener { onEditClick(); };
_buttonEdit.setOnClickListener { hideSearchKeyboard(); onEditClick(); };
setButtonExportVisible(false);
setButtonDownloadVisible(canEdit());
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved);
videoListEditorView.onVideoClicked.subscribe(::onVideoClicked);
videoListEditorView.onVideoOptions.subscribe(::onVideoOptions);
videoListEditorView.onVideoClicked.subscribe { hideSearchKeyboard(); onVideoClicked(it)};
_videoListEditorView = videoListEditorView;
}
@ -112,6 +121,7 @@ abstract class VideoListEditorView : LinearLayout {
fun setOnShare(onShare: (()-> Unit)? = null) {
_onShare = onShare;
_buttonShare.setOnClickListener {
hideSearchKeyboard();
onShare?.invoke();
};
_buttonShare.visibility = View.VISIBLE;
@ -122,6 +132,7 @@ abstract class VideoListEditorView : LinearLayout {
open fun onShuffleClick() { }
open fun onEditClick() { }
open fun onVideoRemoved(video: IPlatformVideo) {}
open fun onVideoOptions(video: IPlatformVideo) {}
open fun onVideoOrderChanged(videos : List<IPlatformVideo>) {}
open fun onVideoClicked(video: IPlatformVideo) {
@ -143,7 +154,7 @@ abstract class VideoListEditorView : LinearLayout {
setButtonExportVisible(false);
_buttonDownload.setImageResource(R.drawable.ic_loader_animated);
_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), {
StateDownloads.instance.deleteCachedPlaylist(playlistId);
});
@ -152,7 +163,7 @@ abstract class VideoListEditorView : LinearLayout {
else if(isDownloaded) {
setButtonExportVisible(true)
_buttonDownload.setImageResource(R.drawable.ic_download_off);
_buttonDownload.setOnClickListener {
_buttonDownload.setOnClickListener { hideSearchKeyboard();
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
StateDownloads.instance.deleteCachedPlaylist(playlistId);
});
@ -161,7 +172,7 @@ abstract class VideoListEditorView : LinearLayout {
else {
setButtonExportVisible(false);
_buttonDownload.setImageResource(R.drawable.ic_download);
_buttonDownload.setOnClickListener {
_buttonDownload.setOnClickListener { hideSearchKeyboard();
onDownload();
//UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer);
}

View File

@ -103,6 +103,9 @@ class WatchLaterFragment : MainFragment() {
StatePlaylists.instance.removeFromWatchLater(video, true);
}
}
override fun onVideoOptions(video: IPlatformVideo) {
UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer);
}
override fun onVideoClicked(video: IPlatformVideo) {
val watchLater = StatePlaylists.instance.getWatchLater();

View File

@ -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
)

View File

@ -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
)
}
}
}

View File

@ -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
)

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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 }
}
}

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -411,7 +411,7 @@ class StateApp {
}
if (Settings.instance.synchronization.enabled) {
StateSync.instance.start()
StateSync.instance.start(context)
}
Logger.onLogSubmitted.subscribe {
@ -519,12 +519,16 @@ class StateApp {
Logger.i(TAG, "MainApp Started: Fetch [Subscriptions]");
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true };
if (isRateLimitReached) {
val isBelowRateLimit = !subRequestCounts.any { clientCount ->
clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true
};
if (isBelowRateLimit) {
Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}");
delay(5000);
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
StateSubscriptions.instance.updateSubscriptionFeed(scope, false);
scopeOrNull?.let {
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
StateSubscriptions.instance.updateSubscriptionFeed(it, false);
}
}
else
Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}");

View File

@ -632,6 +632,27 @@ class StatePlatform {
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
fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { it.isContentDetailsUrl(url) };

View File

@ -69,7 +69,7 @@ class StateSubscriptions {
val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>();
private val _subsExchangeServer = "http://10.10.15.159"//"https://exchange.grayjay.app/";
private val _subsExchangeServer = "https://exchange.grayjay.app/";
private val _subscriptionKey = FragmentedStorage.get<StringStorage>("sub_exchange_key");
init {

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,11 @@ class CastingDeviceInfoStorage : FragmentedStorageFileJson() {
return deviceInfos.toList();
}
@Synchronized
fun getDeviceNames() : List<String> {
return deviceInfos.map { it.name }.toList();
}
@Synchronized
fun addDevice(castingDeviceInfo: CastingDeviceInfo): CastingDeviceInfo {
val foundDeviceInfo = deviceInfos.firstOrNull { d -> d.name == castingDeviceInfo.name }

View File

@ -15,12 +15,14 @@ import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
import com.futo.platformplayer.api.media.structures.PlatformContentPager
import com.futo.platformplayer.debug.Stopwatch
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.findNonRuntimeException
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment
import com.futo.platformplayer.getNowDiffMiliseconds
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.states.StateApp
@ -32,6 +34,8 @@ import com.futo.platformplayer.subsexchange.ChannelRequest
import com.futo.platformplayer.subsexchange.ChannelResolve
import com.futo.platformplayer.subsexchange.ExchangeContract
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.OffsetDateTime
import java.util.concurrent.ExecutionException
import java.util.concurrent.ForkJoinPool
@ -82,23 +86,30 @@ abstract class SubscriptionsTaskFetchAlgorithm(
var providedTasks: MutableList<SubscriptionTask>? = null;
try {
val contractableTasks =
tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) };
contract =
if (contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map {
ChannelRequest(it.url)
}.toTypedArray()) else null;
if (contract?.provided?.isNotEmpty() == true)
Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}");
if (contract != null && contract.required.isNotEmpty()) {
providedTasks = mutableListOf()
for (task in tasks.toList()) {
if (!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) {
providedTasks.add(task);
tasks.remove(task);
val contractingTime = measureTimeMillis {
val contractableTasks =
tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) };
contract =
if (contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map {
ChannelRequest(it.url)
}.toTypedArray()) else null;
if (contract?.provided?.isNotEmpty() == true)
Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}");
if (contract != null && contract!!.required.isNotEmpty()) {
providedTasks = mutableListOf()
for (task in tasks.toList()) {
if (!task.fromCache && !task.fromPeek && contract!!.provided.contains(task.url)) {
providedTasks!!.add(task);
tasks.remove(task);
}
}
}
}
if(contract != null)
Logger.i(TAG, "Subscription Exchange contract received in ${contractingTime}ms");
else if(contractingTime > 100)
Logger.i(TAG, "Subscription Exchange contract failed to received in${contractingTime}ms");
}
catch(ex: Throwable){
Logger.e("SubscriptionsTaskFetchAlgorithm", "Failed to retrieve SubsExchange contract due to: " + ex.message, ex);
@ -109,6 +120,8 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val forkTasks = executeSubscriptionTasks(tasks, failedPlugins, cachedChannels);
val taskResults = arrayListOf<SubscriptionTaskResult>();
var resolveCount = 0;
var resolveTime = 0L;
val timeTotal = measureTimeMillis {
for(task in forkTasks) {
try {
@ -137,51 +150,82 @@ abstract class SubscriptionsTaskFetchAlgorithm(
}
};
}
}
//Resolve Subscription Exchange
if(contract != null) {
try {
val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract.required.contains(it.task.url) }.map {
ChannelResolve(
it.task.url,
it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) }
)
}.toTypedArray()
val resolve = subsExchangeClient?.resolveContract(
contract,
*resolves
);
if (resolve != null) {
val invalids = resolve.filter { it.content.any { it.datetime == null } };
UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size})")
for(result in resolve){
val task = providedTasks?.find { it.url == result.channelUrl };
if(task != null) {
taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null));
providedTasks?.remove(task);
//Resolve Subscription Exchange
if(contract != null) {
fun resolve() {
try {
resolveTime = measureTimeMillis {
val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract!!.required.contains(it.task.url) }.map {
ChannelResolve(
it.task.url,
it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) }
)
}.toTypedArray()
val resolveRequestStart = OffsetDateTime.now();
val resolve = subsExchangeClient?.resolveContract(
contract!!,
*resolves
);
Logger.i(TAG, "Subscription Exchange contract resolved request in ${resolveRequestStart.getNowDiffMiliseconds()}ms");
if (resolve != null) {
resolveCount = resolves.size;
UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size}")
for(result in resolve){
val task = providedTasks?.find { it.url == result.channelUrl };
if(task != null) {
taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null));
providedTasks?.remove(task);
}
}
}
if (providedTasks != null) {
for(task in providedTasks!!) {
taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange")));
}
}
}
Logger.i(TAG, "Subscription Exchange contract resolved in ${resolveTime}ms");
}
catch(ex: Throwable) {
//TODO: fetch remainder after all?
Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex);
}
}
if (providedTasks != null) {
for(task in providedTasks) {
taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange")));
if(providedTasks?.size ?: 0 == 0)
scope.launch(Dispatchers.IO) {
resolve();
}
}
}
catch(ex: Throwable) {
//TODO: fetch remainder after all?
Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex);
else
resolve();
}
}
Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms")
Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms");
if(resolveCount > 0) {
val selfFetchTime = timeTotal - resolveTime;
val selfFetchCount = tasks.count { !it.fromPeek && !it.fromCache };
if(selfFetchCount > 0) {
val selfResolvePercentage = resolveCount.toDouble() / selfFetchCount;
val estimateSelfFetchTime = selfFetchTime + selfFetchTime * selfResolvePercentage;
val selfFetchDelta = timeTotal - estimateSelfFetchTime;
if(selfFetchDelta > 0)
UIDialogs.appToast("Subscription Exchange lost ${selfFetchDelta}ms (out of ${timeTotal}ms)", true);
else
UIDialogs.appToast("Subscription Exchange saved ${(selfFetchDelta * -1).toInt()}ms (out of ${timeTotal}ms)", true);
}
}
//Cache pagers grouped by channel
val groupedPagers = taskResults.groupBy { it.task.sub.channel.url }
.map { entry ->
val sub = if(!entry.value.isEmpty()) entry.value[0].task.sub else null;
val liveTasks = entry.value.filter { !it.task.fromCache };
val liveTasks = entry.value.filter { !it.task.fromCache && it.pager != null };
val cachedTasks = entry.value.filter { it.task.fromCache };
val livePager = if(liveTasks.isNotEmpty()) StateCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }) {
onNewCacheHit.emit(sub!!, it);

View File

@ -1,10 +1,14 @@
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.getNowDiffMiliseconds
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm.Companion.TAG
import com.futo.platformplayer.subsexchange.ChannelRequest
import com.futo.platformplayer.subsexchange.ChannelResolve
import com.futo.platformplayer.subsexchange.ChannelResult
import com.futo.platformplayer.subsexchange.ExchangeContract
import com.futo.platformplayer.subsexchange.ExchangeContractResolve
import com.futo.platformplayer.toGzip
import com.futo.platformplayer.toHumanBytesSize
import kotlinx.serialization.*
import kotlinx.serialization.json.*
import kotlinx.coroutines.Dispatchers
@ -26,9 +30,10 @@ import java.nio.charset.StandardCharsets
import java.security.KeyPairGenerator
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.RSAPublicKeySpec
import java.time.OffsetDateTime
class SubsExchangeClient(private val server: String, private val privateKey: String) {
class SubsExchangeClient(private val server: String, private val privateKey: String, private val contractTimeout: Int = 1000) {
private val json = Json {
ignoreUnknownKeys = true
@ -40,24 +45,27 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str
// Endpoint: Contract
fun requestContract(vararg channels: ChannelRequest): ExchangeContract {
val data = post("/api/Channel/Contract", Json.encodeToString(channels), "application/json")
val data = post("/api/Channel/Contract", Json.encodeToString(channels).toByteArray(Charsets.UTF_8), "application/json", contractTimeout)
return Json.decodeFromString(data)
}
suspend fun requestContractAsync(vararg channels: ChannelRequest): ExchangeContract {
val data = postAsync("/api/Channel/Contract", Json.encodeToString(channels), "application/json")
val data = postAsync("/api/Channel/Contract", Json.encodeToString(channels).toByteArray(Charsets.UTF_8), "application/json")
return Json.decodeFromString(data)
}
// Endpoint: Resolve
fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> {
val contractResolve = convertResolves(*resolves)
val result = post("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json")
Logger.v("SubsExchangeClient", "Resolve:" + result);
val contractResolveJson = Serializer.json.encodeToString(contractResolve);
val contractResolveTimeStart = OffsetDateTime.now();
val result = post("/api/Channel/Resolve?contractId=${contract.id}", contractResolveJson.toByteArray(Charsets.UTF_8), "application/json", 0, true)
val contractResolveTime = contractResolveTimeStart.getNowDiffMiliseconds();
Logger.v("SubsExchangeClient", "Subscription Exchange Resolve Request [${contractResolveTime}ms]:" + result);
return Serializer.json.decodeFromString(result)
}
suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> {
val contractResolve = convertResolves(*resolves)
val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json")
val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve).toByteArray(Charsets.UTF_8), "application/json", true)
return Serializer.json.decodeFromString(result)
}
@ -74,13 +82,24 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str
}
// IO methods
private fun post(query: String, body: String, contentType: String): String {
private fun post(query: String, body: ByteArray, contentType: String, timeout: Int = 0, gzip: Boolean = false): String {
val url = URL("${server.trim('/')}$query")
with(url.openConnection() as HttpURLConnection) {
if(timeout > 0)
this.connectTimeout = timeout
requestMethod = "POST"
setRequestProperty("Content-Type", contentType)
doOutput = true
OutputStreamWriter(outputStream, StandardCharsets.UTF_8).use { it.write(body); it.flush() }
if(gzip) {
val gzipData = body.toGzip();
setRequestProperty("Content-Encoding", "gzip");
outputStream.write(gzipData);
Logger.i("SubsExchangeClient", "SubsExchange using gzip (${body.size.toHumanBytesSize()} => ${gzipData.size.toHumanBytesSize()}");
}
else
outputStream.write(body);
val status = responseCode;
Logger.i("SubsExchangeClient", "POST [${url}]: ${status}");
@ -103,9 +122,9 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str
}
}
}
private suspend fun postAsync(query: String, body: String, contentType: String): String {
private suspend fun postAsync(query: String, body: ByteArray, contentType: String, gzip: Boolean = false): String {
return withContext(Dispatchers.IO) {
post(query, body, contentType)
post(query, body, contentType, 0, gzip)
}
}

View File

@ -0,0 +1,336 @@
package com.futo.platformplayer.sync.internal
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.noise.protocol.CipherStatePair
import com.futo.platformplayer.noise.protocol.DHState
import com.futo.platformplayer.noise.protocol.HandshakeState
import com.futo.platformplayer.states.StateSync
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.Base64
interface IChannel : AutoCloseable {
val remotePublicKey: String?
val remoteVersion: Int?
var authorizable: IAuthorizable?
var syncSession: SyncSession?
fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?)
fun send(opcode: UByte, subOpcode: UByte = 0u, data: ByteBuffer? = null)
fun setCloseHandler(onClose: ((IChannel) -> Unit)?)
val linkType: LinkType
}
class ChannelSocket(private val session: SyncSocketSession) : IChannel {
override val remotePublicKey: String? get() = session.remotePublicKey
override val remoteVersion: Int? get() = session.remoteVersion
private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null
private var onClose: ((IChannel) -> Unit)? = null
override val linkType: LinkType get() = LinkType.Direct
override var authorizable: IAuthorizable?
get() = session.authorizable
set(value) { session.authorizable = value }
override var syncSession: SyncSession? = null
override fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) {
this.onData = onData
}
override fun setCloseHandler(onClose: ((IChannel) -> Unit)?) {
this.onClose = onClose
}
override fun close() {
session.stop()
onClose?.invoke(this)
}
fun invokeDataHandler(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
onData?.invoke(session, this, opcode, subOpcode, data)
}
override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?) {
if (data != null) {
session.send(opcode, subOpcode, data)
} else {
session.send(opcode, subOpcode)
}
}
}
class ChannelRelayed(
private val session: SyncSocketSession,
private val localKeyPair: DHState,
private val publicKey: String,
private val initiator: Boolean
) : IChannel {
private val sendLock = Object()
private val decryptLock = Object()
private var handshakeState: HandshakeState? = if (initiator) {
HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR).apply {
localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair)
remotePublicKey.setPublicKey(Base64.getDecoder().decode(publicKey), 0)
}
} else {
HandshakeState(StateSync.protocolName, HandshakeState.RESPONDER).apply {
localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair)
}
}
private var transport: CipherStatePair? = null
override var authorizable: IAuthorizable? = null
val isAuthorized: Boolean get() = authorizable?.isAuthorized ?: false
var connectionId: Long = 0L
override var remotePublicKey: String? = publicKey
private set
override var remoteVersion: Int? = null
private set
override var syncSession: SyncSession? = null
override val linkType: LinkType get() = LinkType.Relayed
private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null
private var onClose: ((IChannel) -> Unit)? = null
private var disposed = false
init {
handshakeState?.start()
}
override fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) {
this.onData = onData
}
override fun setCloseHandler(onClose: ((IChannel) -> Unit)?) {
this.onClose = onClose
}
override fun close() {
disposed = true
if (connectionId != 0L) {
Thread {
try {
session.sendRelayError(connectionId, SyncErrorCode.ConnectionClosed)
} catch (e: Exception) {
Logger.e("ChannelRelayed", "Exception while sending relay error", e)
}
}.start()
}
transport?.sender?.destroy()
transport?.receiver?.destroy()
transport = null
handshakeState?.destroy()
handshakeState = null
onClose?.invoke(this)
}
private fun throwIfDisposed() {
if (disposed) throw IllegalStateException("ChannelRelayed is disposed")
}
fun invokeDataHandler(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
onData?.invoke(session, this, opcode, subOpcode, data)
}
private fun completeHandshake(remoteVersion: Int, transport: CipherStatePair) {
throwIfDisposed()
this.remoteVersion = remoteVersion
val remoteKeyBytes = ByteArray(handshakeState!!.remotePublicKey.publicKeyLength)
handshakeState!!.remotePublicKey.getPublicKey(remoteKeyBytes, 0)
this.remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes)
handshakeState?.destroy()
handshakeState = null
this.transport = transport
Logger.i("ChannelRelayed", "Completed handshake for connectionId $connectionId")
}
private fun sendPacket(packet: ByteArray) {
throwIfDisposed()
synchronized(sendLock) {
val encryptedPayload = ByteArray(packet.size + 16)
val encryptedLength = transport!!.sender.encryptWithAd(null, packet, 0, encryptedPayload, 0, packet.size)
val relayedPacket = ByteArray(8 + encryptedLength)
ByteBuffer.wrap(relayedPacket).order(ByteOrder.LITTLE_ENDIAN).apply {
putLong(connectionId)
put(encryptedPayload, 0, encryptedLength)
}
session.send(Opcode.RELAY.value, RelayOpcode.DATA.value, ByteBuffer.wrap(relayedPacket).order(ByteOrder.LITTLE_ENDIAN))
}
}
fun sendError(errorCode: SyncErrorCode) {
throwIfDisposed()
synchronized(sendLock) {
val packet = ByteArray(4)
ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).putInt(errorCode.value)
val encryptedPayload = ByteArray(4 + 16)
val encryptedLength = transport!!.sender.encryptWithAd(null, packet, 0, encryptedPayload, 0, packet.size)
val relayedPacket = ByteArray(8 + encryptedLength)
ByteBuffer.wrap(relayedPacket).order(ByteOrder.LITTLE_ENDIAN).apply {
putLong(connectionId)
put(encryptedPayload, 0, encryptedLength)
}
session.send(Opcode.RELAY.value, RelayOpcode.ERROR.value, ByteBuffer.wrap(relayedPacket))
}
}
override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?) {
throwIfDisposed()
val actualCount = data?.remaining() ?: 0
val ENCRYPTION_OVERHEAD = 16
val CONNECTION_ID_SIZE = 8
val HEADER_SIZE = 6
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) {
val streamId = session.generateStreamId()
val totalSize = actualCount
var sendOffset = 0
while (sendOffset < totalSize) {
val bytesRemaining = totalSize - sendOffset
val bytesToSend = minOf(MAX_DATA_PER_PACKET - 8 - 2, bytesRemaining)
val streamData: ByteArray
val streamOpcode: StreamOpcode
if (sendOffset == 0) {
streamOpcode = StreamOpcode.START
streamData = ByteArray(4 + 4 + 1 + 1 + bytesToSend)
ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(streamId)
putInt(totalSize)
put(opcode.toByte())
put(subOpcode.toByte())
put(data.array(), data.position() + sendOffset, bytesToSend)
}
} else {
streamData = ByteArray(4 + 4 + bytesToSend)
ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(streamId)
putInt(sendOffset)
put(data.array(), data.position() + sendOffset, bytesToSend)
}
streamOpcode = if (bytesToSend < bytesRemaining) StreamOpcode.DATA else StreamOpcode.END
}
val fullPacket = ByteArray(HEADER_SIZE + streamData.size)
ByteBuffer.wrap(fullPacket).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(streamData.size + 2)
put(Opcode.STREAM.value.toByte())
put(streamOpcode.value.toByte())
put(streamData)
}
sendPacket(fullPacket)
sendOffset += bytesToSend
}
} else {
val packet = ByteArray(HEADER_SIZE + actualCount)
ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(actualCount + 2)
put(opcode.toByte())
put(subOpcode.toByte())
if (actualCount > 0 && data != null) put(data.array(), data.position(), actualCount)
}
sendPacket(packet)
}
}
fun sendRequestTransport(requestId: Int, publicKey: String, appId: UInt, pairingCode: String? = null) {
throwIfDisposed()
synchronized(sendLock) {
val channelMessage = ByteArray(1024)
val channelBytesWritten = handshakeState!!.writeMessage(channelMessage, 0, null, 0, 0)
val publicKeyBytes = Base64.getDecoder().decode(publicKey)
if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes")
val (pairingMessageLength, pairingMessage) = if (pairingCode != null) {
val pairingHandshake = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR).apply {
remotePublicKey.setPublicKey(publicKeyBytes, 0)
start()
}
val pairingCodeBytes = pairingCode.toByteArray(Charsets.UTF_8)
if (pairingCodeBytes.size > 32) throw IllegalArgumentException("Pairing code must not exceed 32 bytes")
val pairingMessageBuffer = ByteArray(1024)
val bytesWritten = pairingHandshake.writeMessage(pairingMessageBuffer, 0, pairingCodeBytes, 0, pairingCodeBytes.size)
bytesWritten to pairingMessageBuffer.copyOf(bytesWritten)
} else {
0 to ByteArray(0)
}
val packetSize = 4 + 4 + 32 + 4 + pairingMessageLength + 4 + channelBytesWritten
val packet = ByteArray(packetSize)
ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(requestId)
putInt(appId.toInt())
put(publicKeyBytes)
putInt(pairingMessageLength)
if (pairingMessageLength > 0) put(pairingMessage)
putInt(channelBytesWritten)
put(channelMessage, 0, channelBytesWritten)
}
session.send(Opcode.REQUEST.value, RequestOpcode.TRANSPORT.value, ByteBuffer.wrap(packet))
}
}
fun sendResponseTransport(remoteVersion: Int, requestId: Int, handshakeMessage: ByteArray) {
throwIfDisposed()
synchronized(sendLock) {
val message = ByteArray(1024)
val plaintext = ByteArray(1024)
handshakeState!!.readMessage(handshakeMessage, 0, handshakeMessage.size, plaintext, 0)
val bytesWritten = handshakeState!!.writeMessage(message, 0, null, 0, 0)
val transport = handshakeState!!.split()
val responsePacket = ByteArray(20 + bytesWritten)
ByteBuffer.wrap(responsePacket).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(0) // Status code
putLong(connectionId)
putInt(requestId)
putInt(bytesWritten)
put(message, 0, bytesWritten)
}
completeHandshake(remoteVersion, transport)
session.send(Opcode.RESPONSE.value, ResponseOpcode.TRANSPORT.value, ByteBuffer.wrap(responsePacket))
}
}
fun decrypt(encryptedPayload: ByteBuffer): ByteBuffer {
throwIfDisposed()
synchronized(decryptLock) {
val encryptedBytes = ByteArray(encryptedPayload.remaining()).also { encryptedPayload.get(it) }
val decryptedPayload = ByteArray(encryptedBytes.size - 16)
val plen = transport!!.receiver.decryptWithAd(null, encryptedBytes, 0, decryptedPayload, 0, encryptedBytes.size)
if (plen != decryptedPayload.size) throw IllegalStateException("Expected decrypted payload length to be $plen")
return ByteBuffer.wrap(decryptedPayload).order(ByteOrder.LITTLE_ENDIAN)
}
}
fun handleTransportRelayed(remoteVersion: Int, connectionId: Long, handshakeMessage: ByteArray) {
throwIfDisposed()
synchronized(decryptLock) {
this.connectionId = connectionId
val plaintext = ByteArray(1024)
val plen = handshakeState!!.readMessage(handshakeMessage, 0, handshakeMessage.size, plaintext, 0)
val transport = handshakeState!!.split()
completeHandshake(remoteVersion, transport)
}
}
}

View File

@ -2,6 +2,6 @@ package com.futo.platformplayer.sync.internal;
public enum LinkType {
None,
Local,
Proxied
Direct,
Relayed
}

View File

@ -0,0 +1,60 @@
package com.futo.platformplayer.sync.internal
enum class Opcode(val value: UByte) {
PING(0u),
PONG(1u),
NOTIFY(2u),
STREAM(3u),
DATA(4u),
REQUEST(5u),
RESPONSE(6u),
RELAY(7u)
}
enum class NotifyOpcode(val value: UByte) {
AUTHORIZED(0u),
UNAUTHORIZED(1u),
CONNECTION_INFO(2u)
}
enum class StreamOpcode(val value: UByte) {
START(0u),
DATA(1u),
END(2u)
}
enum class RequestOpcode(val value: UByte) {
CONNECTION_INFO(0u),
TRANSPORT(1u),
TRANSPORT_RELAYED(2u),
PUBLISH_RECORD(3u),
DELETE_RECORD(4u),
LIST_RECORD_KEYS(5u),
GET_RECORD(6u),
BULK_PUBLISH_RECORD(7u),
BULK_GET_RECORD(8u),
BULK_CONNECTION_INFO(9u),
BULK_DELETE_RECORD(10u)
}
enum class ResponseOpcode(val value: UByte) {
CONNECTION_INFO(0u),
TRANSPORT(1u),
TRANSPORT_RELAYED(2u), //TODO: Server errors also included in this one, disentangle?
PUBLISH_RECORD(3u),
DELETE_RECORD(4u),
LIST_RECORD_KEYS(5u),
GET_RECORD(6u),
BULK_PUBLISH_RECORD(7u),
BULK_GET_RECORD(8u),
BULK_CONNECTION_INFO(9u),
BULK_DELETE_RECORD(10u)
}
enum class RelayOpcode(val value: UByte) {
DATA(0u),
RELAYED_DATA(1u),
ERROR(2u),
RELAYED_ERROR(3u),
RELAY_ERROR(4u)
}

View File

@ -5,10 +5,12 @@ class SyncDeviceInfo {
var publicKey: String
var addresses: Array<String>
var port: Int
var pairingCode: String?
constructor(publicKey: String, addresses: Array<String>, port: Int) {
constructor(publicKey: String, addresses: Array<String>, port: Int, pairingCode: String?) {
this.publicKey = publicKey
this.addresses = addresses
this.port = port
this.pairingCode = pairingCode
}
}

View File

@ -0,0 +1,6 @@
package com.futo.platformplayer.sync.internal
enum class SyncErrorCode(val value: Int) {
ConnectionClosed(1),
NotFound(2)
}

View File

@ -1,37 +1,13 @@
package com.futo.platformplayer.sync.internal
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.smartMerge
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StateSubscriptionGroups
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.sync.SyncSessionData
import com.futo.platformplayer.sync.internal.SyncSocketSession.Opcode
import com.futo.platformplayer.sync.models.SendToDevicePackage
import com.futo.platformplayer.sync.models.SyncPlaylistsPackage
import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
import com.futo.platformplayer.sync.models.SyncWatchLaterPackage
import com.futo.platformplayer.toUtf8String
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
interface IAuthorizable {
@ -39,13 +15,14 @@ interface IAuthorizable {
}
class SyncSession : IAuthorizable {
private val _socketSessions: MutableList<SyncSocketSession> = mutableListOf()
private val _channels: MutableList<IChannel> = mutableListOf()
private var _authorized: Boolean = false
private var _remoteAuthorized: Boolean = false
private val _onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit
private val _onUnauthorized: (session: SyncSession) -> Unit
private val _onClose: (session: SyncSession) -> Unit
private val _onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit
private val _dataHandler: (session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit
val remotePublicKey: String
override val isAuthorized get() = _authorized && _remoteAuthorized
private var _wasAuthorized = false
@ -56,140 +33,143 @@ class SyncSession : IAuthorizable {
private set
val displayName: String get() = remoteDeviceName ?: remotePublicKey
var connected: Boolean = false
private set(v) {
if (field != v) {
field = v
this._onConnectedChanged(this, v)
val linkType: LinkType get()
{
var linkType = LinkType.None
synchronized(_channels)
{
for (channel in _channels)
{
if (channel.linkType == LinkType.Direct)
return LinkType.Direct
if (channel.linkType == LinkType.Relayed)
linkType = LinkType.Relayed
}
}
return linkType
}
constructor(remotePublicKey: String, onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit, onUnauthorized: (session: SyncSession) -> Unit, onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit, onClose: (session: SyncSession) -> Unit, remoteDeviceName: String?) {
var connected: Boolean = false
private set(v) {
if (field != v) {
field = v
this._onConnectedChanged(this, v)
}
}
constructor(
remotePublicKey: String,
onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit,
onUnauthorized: (session: SyncSession) -> Unit,
onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit,
onClose: (session: SyncSession) -> Unit,
dataHandler: (session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit,
remoteDeviceName: String? = null
) {
this.remotePublicKey = remotePublicKey
this.remoteDeviceName = remoteDeviceName
_onAuthorized = onAuthorized
_onUnauthorized = onUnauthorized
_onConnectedChanged = onConnectedChanged
_onClose = onClose
_dataHandler = dataHandler
}
fun addSocketSession(socketSession: SyncSocketSession) {
if (socketSession.remotePublicKey != remotePublicKey) {
throw Exception("Public key of session must match public key of socket session")
fun addChannel(channel: IChannel) {
if (channel.remotePublicKey != remotePublicKey) {
throw Exception("Public key of session must match public key of channel")
}
synchronized(_socketSessions) {
_socketSessions.add(socketSession)
connected = _socketSessions.isNotEmpty()
synchronized(_channels) {
_channels.add(channel)
connected = _channels.isNotEmpty()
}
socketSession.authorizable = this
channel.authorizable = this
channel.syncSession = this
}
fun authorize(socketSession: SyncSocketSession) {
fun authorize() {
Logger.i(TAG, "Sent AUTHORIZED with session id $_id")
if (socketSession.remoteVersion >= 3) {
val idStringBytes = _id.toString().toByteArray()
val nameBytes = "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}".toByteArray()
val buffer = ByteArray(1 + idStringBytes.size + 1 + nameBytes.size)
socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN).apply {
put(idStringBytes.size.toByte())
put(idStringBytes)
put(nameBytes.size.toByte())
put(nameBytes)
}.apply { flip() })
} else {
socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(_id.toString().toByteArray()))
}
val idString = _id.toString()
val idBytes = idString.toByteArray(Charsets.UTF_8)
val name = "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}"
val nameBytes = name.toByteArray(Charsets.UTF_8)
val buffer = ByteArray(1 + idBytes.size + 1 + nameBytes.size)
buffer[0] = idBytes.size.toByte()
System.arraycopy(idBytes, 0, buffer, 1, idBytes.size)
buffer[1 + idBytes.size] = nameBytes.size.toByte()
System.arraycopy(nameBytes, 0, buffer, 2 + idBytes.size, nameBytes.size)
send(Opcode.NOTIFY.value, NotifyOpcode.AUTHORIZED.value, ByteBuffer.wrap(buffer))
_authorized = true
checkAuthorized()
}
fun unauthorize(socketSession: SyncSocketSession? = null) {
if (socketSession != null)
socketSession.send(Opcode.NOTIFY_UNAUTHORIZED.value)
else {
val ss = synchronized(_socketSessions) {
_socketSessions.first()
}
ss.send(Opcode.NOTIFY_UNAUTHORIZED.value)
}
fun unauthorize() {
send(Opcode.NOTIFY.value, NotifyOpcode.UNAUTHORIZED.value)
}
private fun checkAuthorized() {
if (isAuthorized) {
val isNewlyAuthorized = !_wasAuthorized;
val isNewSession = _lastAuthorizedRemoteId != _remoteId;
Logger.i(TAG, "onAuthorized (isNewlyAuthorized = $isNewlyAuthorized, isNewSession = $isNewSession)");
_onAuthorized.invoke(this, !_wasAuthorized, _lastAuthorizedRemoteId != _remoteId)
val isNewlyAuthorized = !_wasAuthorized
val isNewSession = _lastAuthorizedRemoteId != _remoteId
Logger.i(TAG, "onAuthorized (isNewlyAuthorized = $isNewlyAuthorized, isNewSession = $isNewSession)")
_onAuthorized(this, isNewlyAuthorized, isNewSession)
_wasAuthorized = true
_lastAuthorizedRemoteId = _remoteId
}
}
fun removeSocketSession(socketSession: SyncSocketSession) {
synchronized(_socketSessions) {
_socketSessions.remove(socketSession)
connected = _socketSessions.isNotEmpty()
fun removeChannel(channel: IChannel) {
synchronized(_channels) {
_channels.remove(channel)
connected = _channels.isNotEmpty()
}
}
fun close() {
synchronized(_socketSessions) {
for (socketSession in _socketSessions) {
socketSession.stop()
}
_socketSessions.clear()
synchronized(_channels) {
_channels.forEach { it.close() }
_channels.clear()
}
_onClose.invoke(this)
_onClose(this)
}
fun handlePacket(socketSession: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
try {
Logger.i(TAG, "Handle packet (opcode: ${opcode}, subOpcode: ${subOpcode}, data.length: ${data.remaining()})")
Logger.i(TAG, "Handle packet (opcode: $opcode, subOpcode: $subOpcode, data.length: ${data.remaining()})")
when (opcode) {
Opcode.NOTIFY_AUTHORIZED.value -> {
if (socketSession.remoteVersion >= 3) {
Opcode.NOTIFY.value -> when (subOpcode) {
NotifyOpcode.AUTHORIZED.value -> {
val idByteCount = data.get().toInt()
if (idByteCount > 64)
throw Exception("Id should always be smaller than 64 bytes")
val idBytes = ByteArray(idByteCount)
data.get(idBytes)
val nameByteCount = data.get().toInt()
if (nameByteCount > 64)
throw Exception("Name should always be smaller than 64 bytes")
val nameBytes = ByteArray(nameByteCount)
data.get(nameBytes)
_remoteId = UUID.fromString(idBytes.toString(Charsets.UTF_8))
remoteDeviceName = nameBytes.toString(Charsets.UTF_8)
} else {
val str = data.toUtf8String()
_remoteId = if (data.remaining() >= 0) UUID.fromString(str) else UUID.fromString("00000000-0000-0000-0000-000000000000")
remoteDeviceName = null
_remoteAuthorized = true
Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId (device name: '${remoteDeviceName ?: "not set"}')")
checkAuthorized()
return
}
NotifyOpcode.UNAUTHORIZED.value -> {
_remoteAuthorized = false
_remoteId = null
remoteDeviceName = null
_lastAuthorizedRemoteId = null
_onUnauthorized(this)
return
}
_remoteAuthorized = true
Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId (device name: '${remoteDeviceName ?: "not set"}')")
checkAuthorized()
return
}
Opcode.NOTIFY_UNAUTHORIZED.value -> {
_remoteId = null
remoteDeviceName = null
_lastAuthorizedRemoteId = null
_remoteAuthorized = false
_onUnauthorized(this)
return
}
//TODO: Handle any kind of packet (that is not necessarily authorized)
}
if (!isAuthorized) {
@ -197,282 +177,57 @@ class SyncSession : IAuthorizable {
}
if (opcode != Opcode.DATA.value) {
Logger.w(TAG, "Unknown opcode received: (opcode = ${opcode}, subOpcode = ${subOpcode})}")
Logger.w(TAG, "Unknown opcode received: (opcode = $opcode, subOpcode = $subOpcode)")
return
}
Logger.i(TAG, "Received (opcode = ${opcode}, subOpcode = ${subOpcode}) (${data.remaining()} bytes)")
//TODO: Abstract this out
when (subOpcode) {
GJSyncOpcodes.sendToDevices -> {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
val context = StateApp.instance.contextOrNull;
if (context != null && context is MainActivity) {
val dataBody = ByteArray(data.remaining());
val remainder = data.remaining();
data.get(dataBody, 0, remainder);
val json = String(dataBody, Charsets.UTF_8);
val obj = Json.decodeFromString<SendToDevicePackage>(json);
UIDialogs.appToast("Received url from device [${socketSession.remotePublicKey}]:\n{${obj.url}");
context.handleUrl(obj.url, obj.position);
}
};
}
GJSyncOpcodes.syncStateExchange -> {
val dataBody = ByteArray(data.remaining());
data.get(dataBody);
val json = String(dataBody, Charsets.UTF_8);
val syncSessionData = Serializer.json.decodeFromString<SyncSessionData>(json);
Logger.i(TAG, "Received SyncSessionData from " + remotePublicKey);
sendData(GJSyncOpcodes.syncSubscriptions, StateSubscriptions.instance.getSyncSubscriptionsPackageString());
sendData(GJSyncOpcodes.syncSubscriptionGroups, StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString());
sendData(GJSyncOpcodes.syncPlaylists, StatePlaylists.instance.getSyncPlaylistsPackageString())
sendData(GJSyncOpcodes.syncWatchLater, Json.encodeToString(StatePlaylists.instance.getWatchLaterSyncPacket(false)));
val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory);
if(recentHistory.size > 0)
sendJsonData(GJSyncOpcodes.syncHistory, recentHistory);
}
GJSyncOpcodes.syncExport -> {
val dataBody = ByteArray(data.remaining());
val bytesStr = ByteArrayInputStream(data.array(), data.position(), data.remaining());
try {
val exportStruct = StateBackup.ExportStructure.fromZipBytes(bytesStr);
for (store in exportStruct.stores) {
if (store.key.equals("subscriptions", true)) {
val subStore =
StateSubscriptions.instance.getUnderlyingSubscriptionsStore();
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val pack = SyncSubscriptionsPackage(
store.value.map {
subStore.fromReconstruction(it, exportStruct.cache)
},
StateSubscriptions.instance.getSubscriptionRemovals()
);
handleSyncSubscriptionPackage(this@SyncSession, pack);
}
}
}
} finally {
bytesStr.close();
}
}
GJSyncOpcodes.syncSubscriptions -> {
val dataBody = ByteArray(data.remaining());
data.get(dataBody);
val json = String(dataBody, Charsets.UTF_8);
val subPackage = Serializer.json.decodeFromString<SyncSubscriptionsPackage>(json);
handleSyncSubscriptionPackage(this, subPackage);
val newestSub = subPackage.subscriptions.maxOf { it.creationTime };
val sesData = StateSync.instance.getSyncSessionData(remotePublicKey);
if(newestSub > sesData.lastSubscription) {
sesData.lastSubscription = newestSub;
StateSync.instance.saveSyncSessionData(sesData);
}
}
GJSyncOpcodes.syncSubscriptionGroups -> {
val dataBody = ByteArray(data.remaining());
data.get(dataBody);
val json = String(dataBody, Charsets.UTF_8);
val pack = Serializer.json.decodeFromString<SyncSubscriptionGroupsPackage>(json);
var lastSubgroupChange = OffsetDateTime.MIN;
for(group in pack.groups){
if(group.lastChange > lastSubgroupChange)
lastSubgroupChange = group.lastChange;
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
if(existing == null)
StateSubscriptionGroups.instance.updateSubscriptionGroup(group, false, true);
else if(existing.lastChange < group.lastChange) {
existing.name = group.name;
existing.urls = group.urls;
existing.image = group.image;
existing.priority = group.priority;
existing.lastChange = group.lastChange;
StateSubscriptionGroups.instance.updateSubscriptionGroup(existing, false, true);
}
}
for(removal in pack.groupRemovals) {
val creation = StateSubscriptionGroups.instance.getSubscriptionGroup(removal.key);
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC);
if(creation != null && creation.creationTime < removalTime)
StateSubscriptionGroups.instance.deleteSubscriptionGroup(removal.key, false);
}
}
GJSyncOpcodes.syncPlaylists -> {
val dataBody = ByteArray(data.remaining());
data.get(dataBody);
val json = String(dataBody, Charsets.UTF_8);
val pack = Serializer.json.decodeFromString<SyncPlaylistsPackage>(json);
for(playlist in pack.playlists) {
val existing = StatePlaylists.instance.getPlaylist(playlist.id);
if(existing == null)
StatePlaylists.instance.createOrUpdatePlaylist(playlist, false);
else if(existing.dateUpdate.toLocalDateTime() < playlist.dateUpdate.toLocalDateTime()) {
existing.dateUpdate = playlist.dateUpdate;
existing.name = playlist.name;
existing.videos = playlist.videos;
existing.dateCreation = playlist.dateCreation;
existing.datePlayed = playlist.datePlayed;
StatePlaylists.instance.createOrUpdatePlaylist(existing, false);
}
}
for(removal in pack.playlistRemovals) {
val creation = StatePlaylists.instance.getPlaylist(removal.key);
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC);
if(creation != null && creation.dateCreation < removalTime)
StatePlaylists.instance.removePlaylist(creation, false);
}
}
GJSyncOpcodes.syncWatchLater -> {
val dataBody = ByteArray(data.remaining());
data.get(dataBody);
val json = String(dataBody, Charsets.UTF_8);
val pack = Serializer.json.decodeFromString<SyncWatchLaterPackage>(json);
Logger.i(TAG, "SyncWatchLater received ${pack.videos.size} (${pack.videoAdds?.size}, ${pack.videoRemovals?.size})");
val allExisting = StatePlaylists.instance.getWatchLater();
for(video in pack.videos) {
val existing = allExisting.firstOrNull { it.url == video.url };
val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.videoAdds[video.url] ?: 0), ZoneOffset.UTC) else OffsetDateTime.MIN;
if(existing == null) {
StatePlaylists.instance.addToWatchLater(video, false);
if(time > OffsetDateTime.MIN)
StatePlaylists.instance.setWatchLaterAddTime(video.url, time);
}
}
for(removal in pack.videoRemovals) {
val watchLater = allExisting.firstOrNull { it.url == removal.key } ?: continue;
val creation = StatePlaylists.instance.getWatchLaterRemovalTime(watchLater.url) ?: OffsetDateTime.MIN;
val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value), ZoneOffset.UTC);
if(creation < removalTime)
StatePlaylists.instance.removeFromWatchLater(watchLater, false, removalTime);
}
val packReorderTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.reorderTime), ZoneOffset.UTC);
val localReorderTime = StatePlaylists.instance.getWatchLaterLastReorderTime();
if(localReorderTime < packReorderTime && pack.ordering != null) {
StatePlaylists.instance.updateWatchLaterOrdering(smartMerge(pack.ordering!!, StatePlaylists.instance.getWatchLaterOrdering()), true);
}
}
GJSyncOpcodes.syncHistory -> {
val dataBody = ByteArray(data.remaining());
data.get(dataBody);
val json = String(dataBody, Charsets.UTF_8);
val history = Serializer.json.decodeFromString<List<HistoryVideo>>(json);
Logger.i(TAG, "SyncHistory received ${history.size} videos from ${remotePublicKey}");
var lastHistory = OffsetDateTime.MIN;
for(video in history){
val hist = StateHistory.instance.getHistoryByVideo(video.video, true, video.date);
if(hist != null)
StateHistory.instance.updateHistoryPosition(video.video, hist, true, video.position, video.date)
if(lastHistory < video.date)
lastHistory = video.date;
}
if(lastHistory != OffsetDateTime.MIN && history.size > 1) {
val sesData = StateSync.instance.getSyncSessionData(remotePublicKey);
if (lastHistory > sesData.lastHistory) {
sesData.lastHistory = lastHistory;
StateSync.instance.saveSyncSessionData(sesData);
}
}
}
}
Logger.i(TAG, "Received (opcode = $opcode, subOpcode = $subOpcode) (${data.remaining()} bytes)")
_dataHandler.invoke(this, opcode, subOpcode, data)
} catch (ex: Exception) {
Logger.w(TAG, "Failed to handle sync package $opcode: ${ex.message}", ex)
}
catch(ex: Exception) {
Logger.w(TAG, "Failed to handle sync package ${opcode}: ${ex.message}", ex);
}
}
private fun handleSyncSubscriptionPackage(origin: SyncSession, pack: SyncSubscriptionsPackage) {
val added = mutableListOf<Subscription>()
for(sub in pack.subscriptions) {
if(!StateSubscriptions.instance.isSubscribed(sub.channel)) {
val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url);
if(sub.creationTime > removalTime) {
val newSub = StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime);
added.add(newSub);
}
}
}
if(added.size > 3)
UIDialogs.appToast("${added.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}");
else if(added.size > 0)
UIDialogs.appToast("Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" +
added.map { it.channel.name }.joinToString("\n"));
if(pack.subscriptions != null && pack.subscriptions.size > 0) {
for (subRemoved in pack.subscriptionRemovals) {
val removed = StateSubscriptions.instance.applySubscriptionRemovals(pack.subscriptionRemovals);
if(removed.size > 3)
UIDialogs.appToast("Removed ${removed.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}");
else if(removed.size > 0)
UIDialogs.appToast("Subscriptions removed from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" +
removed.map { it.channel.name }.joinToString("\n"));
}
}
}
inline fun <reified T> sendJsonData(subOpcode: UByte, data: T) {
send(Opcode.DATA.value, subOpcode, Json.encodeToString<T>(data));
send(Opcode.DATA.value, subOpcode, Json.encodeToString(data))
}
fun sendData(subOpcode: UByte, data: String) {
send(Opcode.DATA.value, subOpcode, data.toByteArray(Charsets.UTF_8));
}
fun send(opcode: UByte, subOpcode: UByte, data: String) {
send(opcode, subOpcode, data.toByteArray(Charsets.UTF_8));
}
fun send(opcode: UByte, subOpcode: UByte, data: ByteArray) {
val socketSessions = synchronized(_socketSessions) {
_socketSessions.toList()
}
if (socketSessions.isEmpty()) {
Logger.v(TAG, "Packet was not sent (opcode = ${opcode}, subOpcode = ${subOpcode}) due to no connected sockets")
fun sendData(subOpcode: UByte, data: String) {
send(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)))
}
fun send(opcode: UByte, subOpcode: UByte, data: String) {
send(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)))
}
fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer? = null) {
val channels = synchronized(_channels) { _channels.sortedBy { it.linkType.ordinal }.toList() }
if (channels.isEmpty()) {
//TODO: Should this throw?
Logger.v(TAG, "Packet was not sent (opcode = $opcode, subOpcode = $subOpcode) due to no connected sockets")
return
}
var sent = false
for (socketSession in socketSessions) {
for (channel in channels) {
try {
socketSession.send(opcode, subOpcode, ByteBuffer.wrap(data))
channel.send(opcode, subOpcode, data)
sent = true
break
} catch (e: Throwable) {
Logger.w(TAG, "Packet failed to send (opcode = ${opcode}, subOpcode = ${subOpcode})", e)
Logger.w(TAG, "Packet failed to send (opcode = $opcode, subOpcode = $subOpcode)", e)
}
}
if (!sent) {
throw Exception("Packet was not sent (opcode = ${opcode}, subOpcode = ${subOpcode}) due to send errors and no remaining candidates")
throw Exception("Packet was not sent (opcode = $opcode, subOpcode = $subOpcode) due to send errors and no remaining candidates")
}
}
private companion object {
const val TAG = "SyncSession"
companion object {
private const val TAG = "SyncSession"
}
}

View File

@ -1,7 +1,9 @@
package com.futo.platformplayer.views
import android.content.Context
import android.text.TextWatcher
import android.util.AttributeSet
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
@ -18,6 +20,9 @@ class SearchView : FrameLayout {
val buttonClear: ImageButton;
var onSearchChanged = Event1<String>();
var onEnter = Event1<String>();
val text: String get() = textSearch.text.toString();
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_search_bar, this);

View File

@ -53,7 +53,7 @@ class ToggleBar : LinearLayout {
this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton);
else
this.setInfo(button.name, button.isActive, button.isButton);
this.onClick.subscribe { button.action(it); };
this.onClick.subscribe({ view, enabled -> button.action(view, enabled); });
});
}
}
@ -62,27 +62,27 @@ class ToggleBar : LinearLayout {
val name: String;
val icon: Int;
val iconVariable: ImageVariable?;
val action: (Boolean)->Unit;
val action: (ToggleTagView, Boolean)->Unit;
val isActive: Boolean;
var isButton: Boolean = false
private set;
var tag: String? = null;
constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (Boolean)->Unit) {
constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
this.name = name;
this.icon = 0;
this.iconVariable = icon;
this.action = action;
this.isActive = isActive;
}
constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) {
constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
this.name = name;
this.icon = icon;
this.iconVariable = null;
this.action = action;
this.isActive = isActive;
}
constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) {
constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
this.name = name;
this.icon = 0;
this.iconVariable = null;

View File

@ -0,0 +1,88 @@
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);
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)
return
_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"
}
}

View File

@ -7,16 +7,16 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.constructs.Event1
class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
private val _devices: ArrayList<CastingDevice>;
private val _isRememberedDevice: Boolean;
data class DeviceAdapterEntry(val castingDevice: CastingDevice, val isPinnedDevice: Boolean, val isOnlineDevice: Boolean)
var onRemove = Event1<CastingDevice>();
class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
private val _devices: List<DeviceAdapterEntry>;
var onPin = Event1<CastingDevice>();
var onConnect = Event1<CastingDevice>();
constructor(devices: ArrayList<CastingDevice>, isRememberedDevice: Boolean) : super() {
constructor(devices: List<DeviceAdapterEntry>) : super() {
_devices = devices;
_isRememberedDevice = isRememberedDevice;
}
override fun getItemCount() = _devices.size;
@ -24,13 +24,13 @@ class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): DeviceViewHolder {
val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_device, viewGroup, false);
val holder = DeviceViewHolder(view);
holder.setIsRememberedDevice(_isRememberedDevice);
holder.onRemove.subscribe { d -> onRemove.emit(d); };
holder.onPin.subscribe { d -> onPin.emit(d); };
holder.onConnect.subscribe { d -> onConnect.emit(d); }
return holder;
}
override fun onBindViewHolder(viewHolder: DeviceViewHolder, position: Int) {
viewHolder.bind(_devices[position]);
val p = _devices[position];
viewHolder.bind(p.castingDevice, p.isOnlineDevice, p.isPinnedDevice);
}
}

View File

@ -2,9 +2,11 @@ package com.futo.platformplayer.views.adapters
import android.graphics.drawable.Animatable
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R
import com.futo.platformplayer.casting.AirPlayCastingDevice
@ -14,70 +16,62 @@ import com.futo.platformplayer.casting.ChromecastCastingDevice
import com.futo.platformplayer.casting.FCastCastingDevice
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import androidx.core.view.isVisible
class DeviceViewHolder : ViewHolder {
private val _layoutDevice: FrameLayout;
private val _imageDevice: ImageView;
private val _textName: TextView;
private val _textType: TextView;
private val _textNotReady: TextView;
private val _buttonDisconnect: LinearLayout;
private val _buttonConnect: LinearLayout;
private val _buttonRemove: LinearLayout;
private val _imageLoader: ImageView;
private val _imageOnline: ImageView;
private val _root: ConstraintLayout;
private var _animatableLoader: Animatable? = null;
private var _isRememberedDevice: Boolean = false;
private var _imagePin: ImageView;
var device: CastingDevice? = null
private set
var onRemove = Event1<CastingDevice>();
var onPin = Event1<CastingDevice>();
val onConnect = Event1<CastingDevice>();
constructor(view: View) : super(view) {
_root = view.findViewById(R.id.layout_root);
_layoutDevice = view.findViewById(R.id.layout_device);
_imageDevice = view.findViewById(R.id.image_device);
_textName = view.findViewById(R.id.text_name);
_textType = view.findViewById(R.id.text_type);
_textNotReady = view.findViewById(R.id.text_not_ready);
_buttonDisconnect = view.findViewById(R.id.button_disconnect);
_buttonConnect = view.findViewById(R.id.button_connect);
_buttonRemove = view.findViewById(R.id.button_remove);
_imageLoader = view.findViewById(R.id.image_loader);
_imageOnline = view.findViewById(R.id.image_online);
_imagePin = view.findViewById(R.id.image_pin);
val d = _imageLoader.drawable;
if (d is Animatable) {
_animatableLoader = d;
}
_buttonDisconnect.setOnClickListener {
StateCasting.instance.activeDevice?.stopCasting();
updateButton();
};
_buttonConnect.setOnClickListener {
val dev = device ?: return@setOnClickListener;
StateCasting.instance.activeDevice?.stopCasting();
StateCasting.instance.connectDevice(dev);
onConnect.emit(dev);
};
_buttonRemove.setOnClickListener {
val dev = device ?: return@setOnClickListener;
onRemove.emit(dev);
};
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ ->
updateButton();
val connect = {
device?.let { dev ->
StateCasting.instance.activeDevice?.stopCasting();
StateCasting.instance.connectDevice(dev);
onConnect.emit(dev);
}
}
setIsRememberedDevice(false);
_textName.setOnClickListener { connect() };
_textType.setOnClickListener { connect() };
_layoutDevice.setOnClickListener { connect() };
_imagePin.setOnClickListener {
val dev = device ?: return@setOnClickListener;
onPin.emit(dev);
}
}
fun setIsRememberedDevice(isRememberedDevice: Boolean) {
_isRememberedDevice = isRememberedDevice;
_buttonRemove.visibility = if (isRememberedDevice) View.VISIBLE else View.GONE;
}
fun bind(d: CastingDevice) {
fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) {
if (d is ChromecastCastingDevice) {
_imageDevice.setImageResource(R.drawable.ic_chromecast);
_textType.text = "Chromecast";
@ -90,54 +84,47 @@ class DeviceViewHolder : ViewHolder {
}
_textName.text = d.name;
device = d;
updateButton();
}
private fun updateButton() {
val d = device ?: return;
_imageOnline.visibility = if (isOnlineDevice) View.VISIBLE else View.GONE
if (!d.isReady) {
_buttonConnect.visibility = View.GONE;
_buttonDisconnect.visibility = View.GONE;
_imageLoader.visibility = View.GONE;
_textNotReady.visibility = View.VISIBLE;
return;
}
_textNotReady.visibility = View.GONE;
val dev = StateCasting.instance.activeDevice;
if (dev == d) {
if (dev.connectionState == CastConnectionState.CONNECTED) {
_buttonConnect.visibility = View.GONE;
_buttonDisconnect.visibility = View.VISIBLE;
_imageLoader.visibility = View.GONE;
_textNotReady.visibility = View.GONE;
} else {
_buttonConnect.visibility = View.GONE;
_buttonDisconnect.visibility = View.VISIBLE;
_imageLoader.visibility = View.VISIBLE;
_textNotReady.visibility = View.GONE;
}
_imagePin.visibility = View.GONE;
} else {
if (d.isReady) {
_buttonConnect.visibility = View.VISIBLE;
_buttonDisconnect.visibility = View.GONE;
_imageLoader.visibility = View.GONE;
_textNotReady.visibility = View.GONE;
_textNotReady.visibility = View.GONE;
val dev = StateCasting.instance.activeDevice;
if (dev == d) {
if (dev.connectionState == CastConnectionState.CONNECTED) {
_imageLoader.visibility = View.GONE;
_textNotReady.visibility = View.GONE;
_imagePin.visibility = View.VISIBLE;
} else {
_imageLoader.visibility = View.VISIBLE;
_textNotReady.visibility = View.GONE;
_imagePin.visibility = View.VISIBLE;
}
} else {
_buttonConnect.visibility = View.GONE;
_buttonDisconnect.visibility = View.GONE;
_imageLoader.visibility = View.GONE;
_textNotReady.visibility = View.VISIBLE;
if (d.isReady) {
_imageLoader.visibility = View.GONE;
_textNotReady.visibility = View.GONE;
_imagePin.visibility = View.VISIBLE;
} else {
_imageLoader.visibility = View.GONE;
_textNotReady.visibility = View.VISIBLE;
_imagePin.visibility = View.VISIBLE;
}
}
_imagePin.setImageResource(if (isPinnedDevice) R.drawable.keep_24px else R.drawable.ic_pin)
if (_imageLoader.isVisible) {
_animatableLoader?.start();
} else {
_animatableLoader?.stop();
}
}
if (_imageLoader.visibility == View.VISIBLE) {
_animatableLoader?.start();
} else {
_animatableLoader?.stop();
}
device = d;
}
}

View File

@ -14,6 +14,7 @@ class VideoListEditorAdapter : RecyclerView.Adapter<VideoListEditorViewHolder> {
val onClick = Event1<IPlatformVideo>();
val onRemove = Event1<IPlatformVideo>();
val onOptions = Event1<IPlatformVideo>();
var canEdit = false
private set;
@ -28,6 +29,7 @@ class VideoListEditorAdapter : RecyclerView.Adapter<VideoListEditorViewHolder> {
val holder = VideoListEditorViewHolder(view, _touchHelper);
holder.onRemove.subscribe { v -> onRemove.emit(v); };
holder.onOptions.subscribe { v -> onOptions.emit(v); };
holder.onClick.subscribe { v -> onClick.emit(v); };
return holder;

View File

@ -32,6 +32,7 @@ class VideoListEditorViewHolder : ViewHolder {
private val _containerDuration: LinearLayout;
private val _containerLive: LinearLayout;
private val _imageRemove: ImageButton;
private val _imageOptions: ImageButton;
private val _imageDragDrop: ImageButton;
private val _platformIndicator: PlatformIndicator;
private val _layoutDownloaded: FrameLayout;
@ -41,6 +42,7 @@ class VideoListEditorViewHolder : ViewHolder {
val onClick = Event1<IPlatformVideo>();
val onRemove = Event1<IPlatformVideo>();
val onOptions = Event1<IPlatformVideo>();
@SuppressLint("ClickableViewAccessibility")
constructor(view: View, touchHelper: ItemTouchHelper? = null) : super(view) {
@ -54,6 +56,7 @@ class VideoListEditorViewHolder : ViewHolder {
_containerDuration = view.findViewById(R.id.thumbnail_duration_container);
_containerLive = view.findViewById(R.id.thumbnail_live_container);
_imageRemove = view.findViewById(R.id.image_trash);
_imageOptions = view.findViewById(R.id.image_settings);
_imageDragDrop = view.findViewById<ImageButton>(R.id.image_drag_drop);
_platformIndicator = view.findViewById(R.id.thumbnail_platform);
_layoutDownloaded = view.findViewById(R.id.layout_downloaded);
@ -74,6 +77,10 @@ class VideoListEditorViewHolder : ViewHolder {
val v = video ?: return@setOnClickListener;
onRemove.emit(v);
};
_imageOptions?.setOnClickListener {
val v = video ?: return@setOnClickListener;
onOptions.emit(v);
}
}
fun bind(v: IPlatformVideo, canEdit: Boolean) {

View File

@ -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"
}
}

View File

@ -23,6 +23,7 @@ import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.EmptyPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import okhttp3.internal.platform.Platform
class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewViewHolder> {
private var _initialPlay = true;
@ -82,6 +83,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup);
ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup);
ContentType.LOCKED -> createLockedViewHolder(viewGroup);
ContentType.CHANNEL -> createChannelViewHolder(viewGroup)
else -> EmptyPreviewViewHolder(viewGroup)
}
}
@ -115,6 +117,10 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
this.onPlaylistClicked.subscribe { this@PreviewContentListAdapter.onContentClicked.emit(it, 0L) };
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) {
val value = _dataSet[pos];

View File

@ -8,6 +8,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
@ -22,6 +23,7 @@ class VideoListEditorView : FrameLayout {
val onVideoOrderChanged = Event1<List<IPlatformVideo>>()
val onVideoRemoved = Event1<IPlatformVideo>();
val onVideoOptions = Event1<IPlatformVideo>();
val onVideoClicked = Event1<IPlatformVideo>();
val isEmpty get() = _videos.isEmpty();
@ -54,6 +56,9 @@ class VideoListEditorView : FrameLayout {
}
};
adapterVideos.onOptions.subscribe { v ->
onVideoOptions?.emit(v);
}
adapterVideos.onRemove.subscribe { v ->
val executeDelete = {
synchronized(_videos) {

View File

@ -9,6 +9,7 @@ import android.view.View
import android.widget.ImageView
import androidx.constraintlayout.widget.ConstraintLayout
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.getDataLinkFromUrl
@ -81,12 +82,14 @@ class CreatorThumbnail : ConstraintLayout {
Glide.with(_imageChannelThumbnail)
.load(url)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.crossfade()
.into(_imageChannelThumbnail);
} else {
Glide.with(_imageChannelThumbnail)
.load(url)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.into(_imageChannelThumbnail);
}
}

View File

@ -12,8 +12,10 @@ import android.widget.TextView
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.images.GlideHelper
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.views.ToggleBar
class ToggleTagView : LinearLayout {
private val _root: FrameLayout;
@ -26,7 +28,7 @@ class ToggleTagView : LinearLayout {
var isButton: Boolean = false
private set;
var onClick = Event1<Boolean>();
var onClick = Event2<ToggleTagView, Boolean>();
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true);
@ -36,7 +38,7 @@ class ToggleTagView : LinearLayout {
_root.setOnClickListener {
if(!isButton)
setToggle(!isActive);
onClick.emit(isActive);
onClick.emit(this, isActive);
}
}
@ -52,12 +54,31 @@ class ToggleTagView : LinearLayout {
}
}
fun setInfo(toggle: ToggleBar.Toggle){
_text = toggle.name;
_textTag.text = toggle.name;
setToggle(toggle.isActive);
if(toggle.iconVariable != null) {
toggle.iconVariable.setImageView(_image, R.drawable.ic_error_pred);
_image.visibility = View.GONE;
}
else if(toggle.icon > 0) {
_image.setImageResource(toggle.icon);
_image.visibility = View.GONE;
}
else
_image.visibility = View.VISIBLE;
_textTag.visibility = if(!toggle.name.isNullOrEmpty()) View.VISIBLE else View.GONE;
this.isButton = isButton;
}
fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false) {
_text = text;
_textTag.text = text;
setToggle(isActive);
_image.setImageResource(imageResource);
_image.visibility = View.VISIBLE;
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
this.isButton = isButton;
}
fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) {
@ -66,12 +87,14 @@ class ToggleTagView : LinearLayout {
setToggle(isActive);
image.setImageView(_image, R.drawable.ic_error_pred);
_image.visibility = View.VISIBLE;
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
this.isButton = isButton;
}
fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) {
_image.visibility = View.GONE;
_text = text;
_textTag.text = text;
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
setToggle(isActive);
this.isButton = isButton;
}

View File

@ -8,7 +8,9 @@ import android.widget.LinearLayout
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.views.lists.VideoListEditorView
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
@ -23,6 +25,7 @@ class QueueEditorOverlay : LinearLayout {
private val _overlayContainer: FrameLayout;
val onOptions = Event1<IPlatformVideo>();
val onClose = Event0();
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
@ -35,6 +38,9 @@ class QueueEditorOverlay : LinearLayout {
_topbar.onClose.subscribe(this, onClose::emit);
_editor.onVideoOrderChanged.subscribe { StatePlayer.instance.setQueueWithExisting(it) }
_editor.onVideoOptions.subscribe { v ->
onOptions?.emit(v);
}
_editor.onVideoRemoved.subscribe { v ->
StatePlayer.instance.removeFromQueue(v);
_topbar.setInfo(context.getString(R.string.queue), "${StatePlayer.instance.queueSize} " + context.getString(R.string.videos));

View File

@ -158,7 +158,7 @@ class SubscriptionBar : LinearLayout {
for(button in buttons) {
_tagsContainer.addView(ToggleTagView(context).apply {
this.setInfo(button.name, button.isActive);
this.onClick.subscribe { button.action(it); };
this.onClick.subscribe({ view, value -> button.action(view, value); });
});
}
}
@ -166,16 +166,16 @@ class SubscriptionBar : LinearLayout {
class Toggle {
val name: String;
val icon: Int;
val action: (Boolean)->Unit;
val action: (ToggleTagView, Boolean)->Unit;
val isActive: Boolean;
constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) {
constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
this.name = name;
this.icon = icon;
this.action = action;
this.isActive = isActive;
}
constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) {
constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
this.name = name;
this.icon = 0;
this.action = action;

View File

@ -43,13 +43,13 @@ class SyncDeviceView : ConstraintLayout {
_layoutLinkType.visibility = View.VISIBLE
_imageLinkType.setImageResource(when (linkType) {
LinkType.Proxied -> R.drawable.ic_internet
LinkType.Local -> R.drawable.ic_lan
LinkType.Relayed -> R.drawable.ic_internet
LinkType.Direct -> R.drawable.ic_lan
else -> 0
})
_textLinkType.text = when(linkType) {
LinkType.Proxied -> "Proxied"
LinkType.Local -> "Local"
LinkType.Relayed -> "Relayed"
LinkType.Direct -> "Direct"
else -> null
}

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>

View File

@ -11,25 +11,48 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
android:orientation="horizontal"
android:layout_marginTop="12dp">
<TextView
android:id="@+id/text_devices"
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/discovered_devices"
android:layout_marginStart="20dp"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular" />
android:orientation="vertical">
<ImageView
android:id="@+id/image_loader"
android:layout_width="22dp"
android:layout_height="22dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_loader_animated"
android:layout_marginStart="5dp"/>
<TextView
android:id="@+id/text_devices"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/discovered_devices"
android:layout_marginStart="20dp"
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"
android:layout_height="match_parent"
@ -38,7 +61,7 @@
<Button
android:id="@+id/button_close"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:text="@string/close"
android:textSize="14dp"
android:fontFamily="@font/inter_regular"
@ -67,79 +90,102 @@
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_devices"
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_height="200dp"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp" />
android:layout_marginEnd="20dp"
android:layout_marginTop="10dp"
android:layout_marginBottom="20dp"/>
</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
android:id="@+id/layout_remembered_devices_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/text_remembered_devices"
android:layout_width="0dp"
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" />
android:orientation="horizontal"
android:layout_marginTop="12dp"
android:layout_marginBottom="20dp"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp">
<Space android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<ImageButton
<LinearLayout
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_height="wrap_content"
android:textSize="10dp"
android:text="@string/there_are_no_remembered_devices"
android:layout_marginTop="10dp"
android:layout_marginBottom="20dp"
android:layout_marginStart="20dp"
android:orientation="horizontal"
android:background="@drawable/background_border_2e_round_6dp"
android:layout_marginEnd="20dp"
android:textColor="@color/gray_e0" />
android:gravity="center">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_remembered_devices"
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp" />
<ImageView
android:layout_width="22dp"
android:layout_height="22dp"
app:srcCompat="@drawable/ic_add"
android:layout_marginStart="8dp"/>
<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>

View File

@ -97,27 +97,7 @@
app:layout_constraintTop_toBottomOf="@id/text_name"
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>
@ -253,4 +233,30 @@
android:gravity="center_vertical"
android:paddingBottom="15dp">
</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>

View File

@ -144,6 +144,9 @@
android:layout_marginTop="10dp"
android:layout_marginLeft="15dp"
android:layout_marginRight="15dp"
android:inputType="text"
android:imeOptions="actionDone"
android:singleLine="true"
android:background="@drawable/background_button_round"
android:hint="Search.." />

View File

@ -1,14 +1,58 @@
<?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:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
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:paddingTop="8dp"
android:paddingBottom="8dp"
android:paddingStart="8dp"
android:paddingEnd="8dp" />
</LinearLayout>
</androidx.appcompat.widget.Toolbar>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list_suggestions"
android:layout_width="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>

View File

@ -4,18 +4,34 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="35dp"
android:clickable="true">
android:clickable="true"
android:id="@+id/layout_root">
<ImageView
android:id="@+id/image_device"
<FrameLayout
android:id="@+id/layout_device"
android:layout_width="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_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
android:id="@+id/text_name"
@ -31,8 +47,8 @@
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
android:includeFontPadding="false"
app:layout_constraintTop_toTopOf="@id/image_device"
app:layout_constraintLeft_toRightOf="@id/image_device"
app:layout_constraintTop_toTopOf="@id/layout_device"
app:layout_constraintLeft_toRightOf="@id/layout_device"
app:layout_constraintRight_toLeftOf="@id/layout_button" />
<TextView
@ -43,12 +59,12 @@
tools:text="Chromecast"
android:textSize="10dp"
android:fontFamily="@font/inter_extra_light"
android:textColor="@color/white"
android:textColor="@color/gray_ac"
android:includeFontPadding="false"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
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" />
<LinearLayout
@ -68,74 +84,15 @@
app:srcCompat="@drawable/ic_loader_animated"
android:layout_marginEnd="8dp"/>
<LinearLayout
<ImageView
android:id="@+id/image_pin"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<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>
android:layout_height="25dp"
android:contentDescription="@string/cd_image_loader"
app:srcCompat="@drawable/ic_pin"
android:layout_marginEnd="8dp"
android:scaleType="fitEnd"
android:paddingStart="10dp" />
<TextView
android:id="@+id/text_not_ready"

View File

@ -135,7 +135,7 @@
android:ellipsize="end"
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toLeftOf="@id/image_trash"
app:layout_constraintRight_toLeftOf="@id/buttons"
app:layout_constraintBottom_toTopOf="@id/text_author"
android:layout_marginStart="10dp" />
@ -152,7 +152,7 @@
android:ellipsize="end"
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
app:layout_constraintTop_toBottomOf="@id/text_video_name"
app:layout_constraintRight_toLeftOf="@id/image_trash"
app:layout_constraintRight_toLeftOf="@id/buttons"
app:layout_constraintBottom_toTopOf="@id/text_video_metadata"
android:layout_marginStart="10dp" />
@ -169,19 +169,35 @@
android:ellipsize="end"
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
app:layout_constraintTop_toBottomOf="@id/text_author"
app:layout_constraintRight_toLeftOf="@id/image_trash"
app:layout_constraintRight_toLeftOf="@id/buttons"
android:layout_marginStart="10dp" />
<ImageButton
android:id="@+id/image_trash"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_delete"
app:srcCompat="@drawable/ic_trash_18dp"
android:scaleType="fitCenter"
android:paddingTop="10dp"
android:paddingBottom="10dp"
<LinearLayout
android:id="@+id/buttons"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="@id/layout_video_thumbnail"
app:layout_constraintBottom_toBottomOf="@id/layout_video_thumbnail" />
app:layout_constraintBottom_toBottomOf="@id/layout_video_thumbnail" >
<ImageButton
android:id="@+id/image_trash"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_delete"
app:srcCompat="@drawable/ic_trash_18dp"
android:scaleType="fitCenter"
android:paddingTop="10dp"
android:paddingBottom="10dp"/>
<ImageButton
android:id="@+id/image_settings"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_settings"
app:srcCompat="@drawable/ic_settings"
android:scaleType="fitCenter"
android:paddingTop="10dp"
android:paddingBottom="10dp" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -3,8 +3,8 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:paddingStart="15dp"
android:paddingEnd="15dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:background="@drawable/background_pill"
android:layout_marginEnd="6dp"
android:layout_marginTop="17dp"
@ -19,12 +19,15 @@
android:visibility="gone"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginRight="5dp"
android:layout_marginTop="4dp" />
android:layout_gravity="center"
android:layout_marginLeft="2.5dp"
android:layout_marginRight="2.5dp" />
<TextView
android:id="@+id/text_tag"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="2.5dp"
android:layout_marginRight="2.5dp"
android:textColor="@color/white"
android:layout_gravity="center"
android:gravity="center"

View File

@ -72,6 +72,8 @@
<string name="keep_screen_on_while_casting">Keep screen on while casting</string>
<string name="always_proxy_requests">Always proxy requests</string>
<string name="always_proxy_requests_description">Always proxy requests when casting data through the device.</string>
<string name="allow_ipv6">Allow IPV6</string>
<string name="allow_ipv6_description">If casting over IPV6 is allowed, can cause issues on some networks</string>
<string name="discover">Discover</string>
<string name="find_new_video_sources_to_add">Find new video sources to add</string>
<string name="these_sources_have_been_disabled">These sources have been disabled</string>
@ -133,6 +135,7 @@
<string name="not_ready">Not ready</string>
<string name="connect">Connect</string>
<string name="stop">Stop</string>
<string name="stop_casting">Stop casting</string>
<string name="start">Start</string>
<string name="storage_space">Storage Space</string>
<string name="downloads">Downloads</string>
@ -192,7 +195,9 @@
<string name="ip">IP</string>
<string name="port">Port</string>
<string name="discovered_devices">Discovered Devices</string>
<string name="available_devices">Available 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="connected_to">Connected to</string>
<string name="volume">Volume</string>
@ -202,6 +207,7 @@
<string name="previous">Previous</string>
<string name="next">Next</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="str_import">Import</string>
<string name="my_playlist_name">My Playlist Name</string>
@ -370,6 +376,12 @@
<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_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="gesture_controls">Gesture controls</string>
<string name="volume_slider">Volume slider</string>
<string name="volume_slider_descr">Enable slide gesture to change volume</string>
@ -419,6 +431,8 @@
<string name="preview_feed_items_description">When the preview feedstyle is used, if items should auto-preview when scrolling over them</string>
<string name="show_home_filters">Show Home Filters</string>
<string name="show_home_filters_description">If the home filters should be shown above home</string>
<string name="show_home_filters_plugin_names">Home filter Plugin Names</string>
<string name="show_home_filters_plugin_names_description">If home filters should show full plugin names or just icons</string>
<string name="log_level">Log Level</string>
<string name="logging">Logging</string>
<string name="sync_grayjay">Sync Grayjay</string>

@ -1 +1 @@
Subproject commit f2f83344ebc905b36c0689bfef407bb95e6d9af0
Subproject commit 215cd9bd70d3cc68e25441f7696dcbe5beee2709

@ -1 +1 @@
Subproject commit bff981c3ce7abad363e79705214a4710fb347f7d
Subproject commit f8234d6af8573414d07fd364bc136aa67ad0e379

@ -1 +1 @@
Subproject commit 331dd929293614875af80e3ab4cb162dc6183410
Subproject commit b61095ec200284a686edb8f3b2a595599ad8b5ed

@ -1 +1 @@
Subproject commit ae7b62f4d85cf398d9566a6ed07a6afc193f6b48
Subproject commit 6f1266a038d11998fef429ae0eac0798b3280d75

View File

@ -9,6 +9,7 @@ import com.futo.platformplayer.noise.protocol.HandshakeState
import com.futo.platformplayer.noise.protocol.Noise
import com.futo.platformplayer.states.StateSync
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.SyncStream
import junit.framework.TestCase.assertEquals
@ -586,16 +587,16 @@ class NoiseProtocolTest {
handshakeLatch.await(10, TimeUnit.SECONDS)
// Simulate initiator sending a PING and responder replying with PONG
initiatorSession.send(SyncSocketSession.Opcode.PING.value)
responderSession.send(SyncSocketSession.Opcode.PONG.value)
initiatorSession.send(Opcode.PING.value)
responderSession.send(Opcode.PONG.value)
// Test data transfer
responderSession.send(SyncSocketSession.Opcode.DATA.value, 0u, randomBytesExactlyOnePacket)
initiatorSession.send(SyncSocketSession.Opcode.DATA.value, 1u, randomBytes)
responderSession.send(Opcode.DATA.value, 0u, randomBytesExactlyOnePacket)
initiatorSession.send(Opcode.DATA.value, 1u, randomBytes)
// Send large data to test stream handling
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")
// Wait for a brief period to simulate delay and allow communication

@ -1 +1 @@
Subproject commit f2f83344ebc905b36c0689bfef407bb95e6d9af0
Subproject commit 215cd9bd70d3cc68e25441f7696dcbe5beee2709

@ -1 +1 @@
Subproject commit bff981c3ce7abad363e79705214a4710fb347f7d
Subproject commit f8234d6af8573414d07fd364bc136aa67ad0e379

@ -1 +1 @@
Subproject commit 331dd929293614875af80e3ab4cb162dc6183410
Subproject commit b61095ec200284a686edb8f3b2a595599ad8b5ed

@ -1 +1 @@
Subproject commit ae7b62f4d85cf398d9566a6ed07a6afc193f6b48
Subproject commit 6f1266a038d11998fef429ae0eac0798b3280d75