mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-04-30 06:34:34 +02:00
Compare commits
24 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
9e2041521e | ||
![]() |
ee7b89ec6e | ||
![]() |
5b143bdc76 | ||
![]() |
d9d00e452e | ||
![]() |
14500e281c | ||
![]() |
c4623c80ff | ||
![]() |
9e17dce9a9 | ||
![]() |
daa91986ef | ||
![]() |
63761cfc9a | ||
![]() |
d10026acd1 | ||
![]() |
9347351c37 | ||
![]() |
0ef1f2d40f | ||
![]() |
b460f9915d | ||
![]() |
4e195dfbc3 | ||
![]() |
3c7f7bfca7 | ||
![]() |
05230971b3 | ||
![]() |
dccdf72c73 | ||
![]() |
ca15983a72 | ||
![]() |
4b6a2c9829 | ||
![]() |
1755d03a6b | ||
![]() |
869b1fc15e | ||
![]() |
ce2a2f8582 | ||
![]() |
7b355139fb | ||
![]() |
b14518edb1 |
@ -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
|
||||||
|
}
|
512
app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt
Normal file
512
app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt
Normal 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
|
||||||
|
}
|
@ -216,9 +216,14 @@ private fun ByteArray.toInetAddress(): InetAddress {
|
|||||||
return InetAddress.getByAddress(this);
|
return InetAddress.getByAddress(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
|
||||||
val timeout = 2000
|
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()) {
|
if (addresses.isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -218,6 +218,8 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.show_home_filters, FieldForm.TOGGLE, R.string.show_home_filters_description, 4)
|
@FormField(R.string.show_home_filters, FieldForm.TOGGLE, R.string.show_home_filters_description, 4)
|
||||||
var showHomeFilters: Boolean = true;
|
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)
|
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
||||||
var previewFeedItems: Boolean = true;
|
var previewFeedItems: Boolean = true;
|
||||||
@ -581,10 +583,15 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
var keepScreenOn: Boolean = true;
|
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)
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
var alwaysProxyRequests: Boolean = false;
|
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?
|
/*TODO: Should we have a different casting quality?
|
||||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
@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)
|
@FormField(R.string.connect_last, FieldForm.TOGGLE, R.string.connect_last_description, 3)
|
||||||
var connectLast: Boolean = true;
|
var connectLast: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.discover_through_relay, FieldForm.TOGGLE, R.string.discover_through_relay_description, 3)
|
||||||
|
var discoverThroughRelay: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.pair_through_relay, FieldForm.TOGGLE, R.string.pair_through_relay_description, 3)
|
||||||
|
var pairThroughRelay: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.connect_through_relay, FieldForm.TOGGLE, R.string.connect_through_relay_description, 3)
|
||||||
|
var connectThroughRelay: Boolean = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
|
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
|
||||||
|
@ -5,6 +5,7 @@ import android.app.AlertDialog
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.graphics.drawable.Animatable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.text.Layout
|
import android.text.Layout
|
||||||
import android.text.method.ScrollingMovementMethod
|
import android.text.method.ScrollingMovementMethod
|
||||||
@ -199,16 +200,21 @@ class UIDialogs {
|
|||||||
dialog.show();
|
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 builder = AlertDialog.Builder(context);
|
||||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
|
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
|
||||||
builder.setView(view);
|
builder.setView(view);
|
||||||
|
builder.setCancelable(defaultCloseAction > -2);
|
||||||
val dialog = builder.create();
|
val dialog = builder.create();
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog);
|
||||||
|
|
||||||
view.findViewById<ImageView>(R.id.dialog_icon).apply {
|
view.findViewById<ImageView>(R.id.dialog_icon).apply {
|
||||||
this.setImageResource(icon);
|
this.setImageResource(icon);
|
||||||
|
if(animated)
|
||||||
|
this.drawable.assume<Animatable, Unit> { it.start() };
|
||||||
}
|
}
|
||||||
view.findViewById<TextView>(R.id.dialog_text).apply {
|
view.findViewById<TextView>(R.id.dialog_text).apply {
|
||||||
this.text = text;
|
this.text = text;
|
||||||
@ -275,6 +281,7 @@ class UIDialogs {
|
|||||||
registerDialogClosed(dialog);
|
registerDialogClosed(dialog);
|
||||||
}
|
}
|
||||||
dialog.show();
|
dialog.show();
|
||||||
|
return dialog;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) {
|
fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) {
|
||||||
|
@ -27,14 +27,17 @@ import com.futo.platformplayer.logging.Logger
|
|||||||
import com.futo.platformplayer.models.PlatformVideoWithTime
|
import com.futo.platformplayer.models.PlatformVideoWithTime
|
||||||
import com.futo.platformplayer.others.PlatformLinkMovementMethod
|
import com.futo.platformplayer.others.PlatformLinkMovementMethod
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.File
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.security.SecureRandom
|
||||||
|
import java.time.OffsetDateTime
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.ThreadLocalRandom
|
import java.util.concurrent.ThreadLocalRandom
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
|
import java.util.zip.GZIPOutputStream
|
||||||
|
|
||||||
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
|
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
|
||||||
fun getRandomString(sizeOfRandomString: Int): String {
|
fun getRandomString(sizeOfRandomString: Int): String {
|
||||||
@ -66,7 +69,14 @@ fun warnIfMainThread(context: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun ensureNotMainThread() {
|
fun ensureNotMainThread() {
|
||||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
val isMainLooper = try {
|
||||||
|
Looper.myLooper() == Looper.getMainLooper()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
//Ignore, for unit tests where its not mocked
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMainLooper) {
|
||||||
Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread")
|
Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread")
|
||||||
throw IllegalStateException("Cannot run on main thread")
|
throw IllegalStateException("Cannot run on main thread")
|
||||||
}
|
}
|
||||||
@ -279,3 +289,46 @@ fun ByteBuffer.toUtf8String(): String {
|
|||||||
get(remainingBytes)
|
get(remainingBytes)
|
||||||
return String(remainingBytes, Charsets.UTF_8)
|
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()
|
||||||
|
}
|
@ -100,7 +100,8 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
|
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
|
||||||
val connected = session?.connected ?: false
|
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)
|
.setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey)
|
||||||
//TODO: also display public key?
|
//TODO: also display public key?
|
||||||
.setStatus(if (connected) "Connected" else "Disconnected")
|
.setStatus(if (connected) "Connected" else "Disconnected")
|
||||||
|
@ -109,9 +109,9 @@ class SyncPairActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
StateSync.instance.connect(deviceInfo) { session, complete, message ->
|
StateSync.instance.connect(deviceInfo) { complete, message ->
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
if (complete) {
|
if (complete != null && complete) {
|
||||||
_layoutPairingSuccess.visibility = View.VISIBLE
|
_layoutPairingSuccess.visibility = View.VISIBLE
|
||||||
_layoutPairing.visibility = View.GONE
|
_layoutPairing.visibility = View.GONE
|
||||||
} else {
|
} else {
|
||||||
|
@ -67,7 +67,7 @@ class SyncShowPairingCodeActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val ips = getIPs()
|
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 json = Json.encodeToString(selfDeviceInfo)
|
||||||
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||||
val url = "grayjay://sync/${base64}"
|
val url = "grayjay://sync/${base64}"
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package com.futo.platformplayer.api.media
|
package com.futo.platformplayer.api.media
|
||||||
|
|
||||||
|
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||||
@ -66,6 +67,11 @@ interface IPlatformClient {
|
|||||||
*/
|
*/
|
||||||
fun searchChannels(query: String): IPager<PlatformAuthorLink>;
|
fun searchChannels(query: String): IPager<PlatformAuthorLink>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches for channels and returns a content pager
|
||||||
|
*/
|
||||||
|
fun searchChannelsAsContent(query: String): IPager<IPlatformContent>;
|
||||||
|
|
||||||
|
|
||||||
//Video Pages
|
//Video Pages
|
||||||
/**
|
/**
|
||||||
|
@ -2,7 +2,10 @@ package com.futo.platformplayer.api.media.models
|
|||||||
|
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.models.JSContent
|
||||||
import com.futo.platformplayer.getOrDefault
|
import com.futo.platformplayer.getOrDefault
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
|
||||||
@ -43,3 +46,20 @@ open class PlatformAuthorLink {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IPlatformChannelContent : IPlatformContent {
|
||||||
|
val thumbnail: String?
|
||||||
|
val subscribers: Long?
|
||||||
|
}
|
||||||
|
|
||||||
|
open class JSChannelContent : JSContent, IPlatformChannelContent {
|
||||||
|
override val contentType: ContentType get() = ContentType.CHANNEL
|
||||||
|
override val thumbnail: String?
|
||||||
|
override val subscribers: Long?
|
||||||
|
|
||||||
|
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
|
||||||
|
val contextName = "Channel";
|
||||||
|
thumbnail = obj.getOrDefault<String>(config, "thumbnail", contextName, null)
|
||||||
|
subscribers = if(obj.has("subscribers")) obj.getOrThrow(config,"subscribers", contextName) else null
|
||||||
|
}
|
||||||
|
}
|
@ -12,6 +12,7 @@ enum class ContentType(val value: Int) {
|
|||||||
URL(9),
|
URL(9),
|
||||||
|
|
||||||
NESTED_VIDEO(11),
|
NESTED_VIDEO(11),
|
||||||
|
CHANNEL(60),
|
||||||
|
|
||||||
LOCKED(70),
|
LOCKED(70),
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
|||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.IPlatformClient
|
import com.futo.platformplayer.api.media.IPlatformClient
|
||||||
import com.futo.platformplayer.api.media.PlatformClientCapabilities
|
import com.futo.platformplayer.api.media.PlatformClientCapabilities
|
||||||
|
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||||
@ -31,6 +32,7 @@ import com.futo.platformplayer.api.media.platforms.js.internal.JSParameterDocs
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.models.IJSContent
|
import com.futo.platformplayer.api.media.platforms.js.models.IJSContent
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
|
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChannel
|
import com.futo.platformplayer.api.media.platforms.js.models.JSChannel
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelContentPager
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager
|
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChapter
|
import com.futo.platformplayer.api.media.platforms.js.models.JSChapter
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.JSComment
|
import com.futo.platformplayer.api.media.platforms.js.models.JSComment
|
||||||
@ -361,6 +363,10 @@ open class JSClient : IPlatformClient {
|
|||||||
return@isBusyWith JSChannelPager(config, this,
|
return@isBusyWith JSChannelPager(config, this,
|
||||||
plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"));
|
plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"));
|
||||||
}
|
}
|
||||||
|
override fun searchChannelsAsContent(query: String): IPager<IPlatformContent> = isBusyWith("searchChannels") {
|
||||||
|
ensureEnabled();
|
||||||
|
return@isBusyWith JSChannelContentPager(config, this, plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"), );
|
||||||
|
}
|
||||||
|
|
||||||
@JSDocs(6, "source.isChannelUrl(url)", "Validates if an channel url is for this platform")
|
@JSDocs(6, "source.isChannelUrl(url)", "Validates if an channel url is for this platform")
|
||||||
@JSDocsParameter("url", "A channel url (May not be your platform)")
|
@JSDocsParameter("url", "A channel url (May not be your platform)")
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package com.futo.platformplayer.api.media.platforms.js.models
|
package com.futo.platformplayer.api.media.platforms.js.models
|
||||||
|
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
|
import com.futo.platformplayer.api.media.models.JSChannelContent
|
||||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
@ -26,6 +27,7 @@ interface IJSContent: IPlatformContent {
|
|||||||
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
|
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
|
||||||
ContentType.PLAYLIST -> JSPlaylist(config, obj);
|
ContentType.PLAYLIST -> JSPlaylist(config, obj);
|
||||||
ContentType.LOCKED -> JSLockedContent(config, obj);
|
ContentType.LOCKED -> JSLockedContent(config, obj);
|
||||||
|
ContentType.CHANNEL -> JSChannelContent(config, obj)
|
||||||
else -> throw NotImplementedError("Unknown content type ${type}");
|
else -> throw NotImplementedError("Unknown content type ${type}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,6 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.engine.V8Plugin
|
|
||||||
|
|
||||||
class JSChannelPager : JSPager<PlatformAuthorLink>, IPager<PlatformAuthorLink> {
|
class JSChannelPager : JSPager<PlatformAuthorLink>, IPager<PlatformAuthorLink> {
|
||||||
|
|
||||||
|
@ -49,8 +49,8 @@ open class JSContent : IPlatformContent, IPluginSourced {
|
|||||||
else
|
else
|
||||||
author = PlatformAuthorLink.UNKNOWN;
|
author = PlatformAuthorLink.UNKNOWN;
|
||||||
|
|
||||||
val datetimeInt = _content.getOrThrow<Int>(config, "datetime", contextName).toLong();
|
val datetimeInt = _content.getOrDefault<Int>(config, "datetime", contextName, null)?.toLong();
|
||||||
if(datetimeInt == 0.toLong())
|
if(datetimeInt == null || datetimeInt == 0.toLong())
|
||||||
datetime = null;
|
datetime = null;
|
||||||
else
|
else
|
||||||
datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
||||||
|
@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js.models
|
|||||||
|
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.api.media.IPluginSourced
|
import com.futo.platformplayer.api.media.IPluginSourced
|
||||||
|
import com.futo.platformplayer.api.media.models.JSChannelContent
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
@ -16,3 +17,13 @@ class JSContentPager : JSPager<IPlatformContent>, IPluginSourced {
|
|||||||
return IJSContent.fromV8(plugin, obj);
|
return IJSContent.fromV8(plugin, obj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class JSChannelContentPager : JSPager<IPlatformContent>, IPluginSourced {
|
||||||
|
override val sourceConfig: SourcePluginConfig get() = config;
|
||||||
|
|
||||||
|
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {}
|
||||||
|
|
||||||
|
override fun convertResult(obj: V8ValueObject): IPlatformContent {
|
||||||
|
return JSChannelContent(config, obj);
|
||||||
|
}
|
||||||
|
}
|
@ -3,6 +3,7 @@ package com.futo.platformplayer.casting
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
||||||
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
||||||
@ -32,6 +33,7 @@ import java.io.IOException
|
|||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
|
import java.net.Inet4Address
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
@ -90,7 +92,7 @@ class FCastCastingDevice : CastingDevice {
|
|||||||
private var _version: Long = 1;
|
private var _version: Long = 1;
|
||||||
private var _thread: Thread? = null
|
private var _thread: Thread? = null
|
||||||
private var _pingThread: Thread? = null
|
private var _pingThread: Thread? = null
|
||||||
private var _lastPongTime = -1L
|
@Volatile private var _lastPongTime = System.currentTimeMillis()
|
||||||
private var _outputStreamLock = Object()
|
private var _outputStreamLock = Object()
|
||||||
|
|
||||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||||
@ -324,9 +326,9 @@ class FCastCastingDevice : CastingDevice {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
localAddress = _socket?.localAddress;
|
localAddress = _socket?.localAddress
|
||||||
connectionState = CastConnectionState.CONNECTED;
|
_lastPongTime = System.currentTimeMillis()
|
||||||
_lastPongTime = -1L
|
connectionState = CastConnectionState.CONNECTED
|
||||||
|
|
||||||
val buffer = ByteArray(4096);
|
val buffer = ByteArray(4096);
|
||||||
|
|
||||||
@ -402,36 +404,32 @@ class FCastCastingDevice : CastingDevice {
|
|||||||
|
|
||||||
_pingThread = Thread {
|
_pingThread = Thread {
|
||||||
Logger.i(TAG, "Started ping loop.")
|
Logger.i(TAG, "Started ping loop.")
|
||||||
|
|
||||||
while (_scopeIO?.isActive == true) {
|
while (_scopeIO?.isActive == true) {
|
||||||
try {
|
if (connectionState == CastConnectionState.CONNECTED) {
|
||||||
send(Opcode.Ping)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.w(TAG, "Failed to send ping.")
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_socket?.close()
|
send(Opcode.Ping)
|
||||||
_inputStream?.close()
|
if (System.currentTimeMillis() - _lastPongTime > 15000) {
|
||||||
_outputStream?.close()
|
Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.")
|
||||||
|
try {
|
||||||
|
_socket?.close()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.w(TAG, "Failed to close socket.", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Log.w(TAG, "Failed to close socket.", e)
|
Log.w(TAG, "Failed to send ping.")
|
||||||
|
try {
|
||||||
|
_socket?.close()
|
||||||
|
_inputStream?.close()
|
||||||
|
_outputStream?.close()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.w(TAG, "Failed to close socket.", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Thread.sleep(5000)
|
||||||
/*if (_lastPongTime != -1L && System.currentTimeMillis() - _lastPongTime > 6000) {
|
|
||||||
Logger.w(TAG, "Closing socket due to last pong time being larger than 6 seconds.")
|
|
||||||
|
|
||||||
try {
|
|
||||||
_socket?.close()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.w(TAG, "Failed to close socket.", e)
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
|
|
||||||
Thread.sleep(2000)
|
|
||||||
}
|
}
|
||||||
|
Logger.i(TAG, "Stopped ping loop.")
|
||||||
Logger.i(TAG, "Stopped ping loop.");
|
|
||||||
}.apply { start() }
|
}.apply { start() }
|
||||||
} else {
|
} else {
|
||||||
Log.i(TAG, "Thread was still alive, not restarted")
|
Log.i(TAG, "Thread was still alive, not restarted")
|
||||||
|
@ -1,14 +1,18 @@
|
|||||||
package com.futo.platformplayer.casting
|
package com.futo.platformplayer.casting
|
||||||
|
|
||||||
|
import android.app.AlertDialog
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.net.nsd.NsdManager
|
||||||
|
import android.net.nsd.NsdServiceInfo
|
||||||
|
import android.os.Build
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.Xml
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
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.constructs.Event2
|
||||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.mdns.DnsService
|
|
||||||
import com.futo.platformplayer.mdns.ServiceDiscoverer
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
import com.futo.platformplayer.parsers.HLS
|
import com.futo.platformplayer.parsers.HLS
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
@ -53,7 +55,6 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
@ -68,7 +69,6 @@ class StateCasting {
|
|||||||
private var _started = false;
|
private var _started = false;
|
||||||
|
|
||||||
var devices: HashMap<String, CastingDevice> = hashMapOf();
|
var devices: HashMap<String, CastingDevice> = hashMapOf();
|
||||||
var rememberedDevices: ArrayList<CastingDevice> = arrayListOf();
|
|
||||||
val onDeviceAdded = Event1<CastingDevice>();
|
val onDeviceAdded = Event1<CastingDevice>();
|
||||||
val onDeviceChanged = Event1<CastingDevice>();
|
val onDeviceChanged = Event1<CastingDevice>();
|
||||||
val onDeviceRemoved = Event1<CastingDevice>();
|
val onDeviceRemoved = Event1<CastingDevice>();
|
||||||
@ -82,48 +82,15 @@ class StateCasting {
|
|||||||
private var _audioExecutor: JSRequestExecutor? = null
|
private var _audioExecutor: JSRequestExecutor? = null
|
||||||
private val _client = ManagedHttpClient();
|
private val _client = ManagedHttpClient();
|
||||||
var _resumeCastingDevice: CastingDeviceInfo? = null;
|
var _resumeCastingDevice: CastingDeviceInfo? = null;
|
||||||
val _serviceDiscoverer = ServiceDiscoverer(arrayOf(
|
private var _nsdManager: NsdManager? = null
|
||||||
"_googlecast._tcp.local",
|
|
||||||
"_airplay._tcp.local",
|
|
||||||
"_fastcast._tcp.local",
|
|
||||||
"_fcast._tcp.local"
|
|
||||||
)) { handleServiceUpdated(it) }
|
|
||||||
|
|
||||||
val isCasting: Boolean get() = activeDevice != null;
|
val isCasting: Boolean get() = activeDevice != null;
|
||||||
|
|
||||||
private fun handleServiceUpdated(services: List<DnsService>) {
|
private val _discoveryListeners = mapOf(
|
||||||
for (s in services) {
|
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
|
||||||
//TODO: Addresses IPv4 only?
|
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
|
||||||
val addresses = s.addresses.toTypedArray()
|
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
|
||||||
val port = s.port.toInt()
|
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
|
||||||
var name = s.texts.firstOrNull { it.startsWith("md=") }?.substring("md=".length)
|
)
|
||||||
if (s.name.endsWith("._googlecast._tcp.local")) {
|
|
||||||
if (name == null) {
|
|
||||||
name = s.name.substring(0, s.name.length - "._googlecast._tcp.local".length)
|
|
||||||
}
|
|
||||||
|
|
||||||
addOrUpdateChromeCastDevice(name, addresses, port)
|
|
||||||
} else if (s.name.endsWith("._airplay._tcp.local")) {
|
|
||||||
if (name == null) {
|
|
||||||
name = s.name.substring(0, s.name.length - "._airplay._tcp.local".length)
|
|
||||||
}
|
|
||||||
|
|
||||||
addOrUpdateAirPlayDevice(name, addresses, port)
|
|
||||||
} else if (s.name.endsWith("._fastcast._tcp.local")) {
|
|
||||||
if (name == null) {
|
|
||||||
name = s.name.substring(0, s.name.length - "._fastcast._tcp.local".length)
|
|
||||||
}
|
|
||||||
|
|
||||||
addOrUpdateFastCastDevice(name, addresses, port)
|
|
||||||
} else if (s.name.endsWith("._fcast._tcp.local")) {
|
|
||||||
if (name == null) {
|
|
||||||
name = s.name.substring(0, s.name.length - "._fcast._tcp.local".length)
|
|
||||||
}
|
|
||||||
|
|
||||||
addOrUpdateFastCastDevice(name, addresses, port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleUrl(context: Context, url: String) {
|
fun handleUrl(context: Context, url: String) {
|
||||||
val uri = Uri.parse(url)
|
val uri = Uri.parse(url)
|
||||||
@ -188,30 +155,29 @@ class StateCasting {
|
|||||||
|
|
||||||
Logger.i(TAG, "CastingService starting...");
|
Logger.i(TAG, "CastingService starting...");
|
||||||
|
|
||||||
rememberedDevices.clear();
|
|
||||||
rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) });
|
|
||||||
|
|
||||||
_castServer.start();
|
_castServer.start();
|
||||||
enableDeveloper(true);
|
enableDeveloper(true);
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService started.");
|
Logger.i(TAG, "CastingService started.");
|
||||||
|
|
||||||
|
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun startDiscovering() {
|
fun startDiscovering() {
|
||||||
try {
|
_nsdManager?.apply {
|
||||||
_serviceDiscoverer.start()
|
_discoveryListeners.forEach {
|
||||||
} catch (e: Throwable) {
|
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
|
||||||
Logger.i(TAG, "Failed to start ServiceDiscoverer", e)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun stopDiscovering() {
|
fun stopDiscovering() {
|
||||||
try {
|
_nsdManager?.apply {
|
||||||
_serviceDiscoverer.stop()
|
_discoveryListeners.forEach {
|
||||||
} catch (e: Throwable) {
|
stopServiceDiscovery(it.value)
|
||||||
Logger.i(TAG, "Failed to stop ServiceDiscoverer", e)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,8 +203,82 @@ class StateCasting {
|
|||||||
_castServer.removeAllHandlers();
|
_castServer.removeAllHandlers();
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService stopped.")
|
Logger.i(TAG, "CastingService stopped.")
|
||||||
|
|
||||||
|
_nsdManager = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
|
||||||
|
return object : NsdManager.DiscoveryListener {
|
||||||
|
override fun onDiscoveryStarted(regType: String) {
|
||||||
|
Log.d(TAG, "Service discovery started for $regType")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDiscoveryStopped(serviceType: String) {
|
||||||
|
Log.i(TAG, "Discovery stopped: $serviceType")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceLost(service: NsdServiceInfo) {
|
||||||
|
Log.e(TAG, "service lost: $service")
|
||||||
|
// TODO: Handle service lost, e.g., remove device
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||||
|
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
|
||||||
|
_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
|
@Synchronized
|
||||||
fun connectDevice(device: CastingDevice) {
|
fun connectDevice(device: CastingDevice) {
|
||||||
if (activeDevice == device)
|
if (activeDevice == device)
|
||||||
@ -272,10 +312,41 @@ class StateCasting {
|
|||||||
invokeInMainScopeIfRequired {
|
invokeInMainScopeIfRequired {
|
||||||
StateApp.withContext(false) { context ->
|
StateApp.withContext(false) { context ->
|
||||||
context.let {
|
context.let {
|
||||||
|
Logger.i(TAG, "Casting state changed to ${castConnectionState}");
|
||||||
when (castConnectionState) {
|
when (castConnectionState) {
|
||||||
CastConnectionState.CONNECTED -> UIDialogs.toast(it, "Connected to device")
|
CastConnectionState.CONNECTED -> {
|
||||||
CastConnectionState.CONNECTING -> UIDialogs.toast(it, "Connecting to device...")
|
Logger.i(TAG, "Casting connected to [${device.name}]");
|
||||||
CastConnectionState.DISCONNECTED -> UIDialogs.toast(it, "Disconnected from device")
|
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) };
|
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
|
||||||
};
|
};
|
||||||
|
|
||||||
addRememberedDevice(device);
|
|
||||||
Logger.i(TAG, "Device added to active discovery. Active discovery now contains ${_storage.getDevicesCount()} devices.")
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
device.start();
|
device.start();
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@ -319,21 +387,22 @@ class StateCasting {
|
|||||||
return addRememberedDevice(device);
|
return addRememberedDevice(device);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getRememberedCastingDevices(): List<CastingDevice> {
|
||||||
|
return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getRememberedCastingDeviceNames(): List<String> {
|
||||||
|
return _storage.getDeviceNames()
|
||||||
|
}
|
||||||
|
|
||||||
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
|
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
|
||||||
val deviceInfo = device.getDeviceInfo()
|
val deviceInfo = device.getDeviceInfo()
|
||||||
val foundInfo = _storage.addDevice(deviceInfo)
|
return _storage.addDevice(deviceInfo)
|
||||||
if (foundInfo == deviceInfo) {
|
|
||||||
rememberedDevices.add(device);
|
|
||||||
return foundInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
return foundInfo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeRememberedDevice(device: CastingDevice) {
|
fun removeRememberedDevice(device: CastingDevice) {
|
||||||
val name = device.name ?: return;
|
val name = device.name ?: return
|
||||||
_storage.removeDevice(name);
|
_storage.removeDevice(name)
|
||||||
rememberedDevices.remove(device);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun invokeInMainScopeIfRequired(action: () -> Unit){
|
private fun invokeInMainScopeIfRequired(action: () -> Unit){
|
||||||
|
@ -9,7 +9,9 @@ import android.view.View
|
|||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
@ -21,22 +23,21 @@ import com.futo.platformplayer.casting.StateCasting
|
|||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.views.adapters.DeviceAdapter
|
import com.futo.platformplayer.views.adapters.DeviceAdapter
|
||||||
|
import com.futo.platformplayer.views.adapters.DeviceAdapterEntry
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||||
private lateinit var _imageLoader: ImageView;
|
private lateinit var _imageLoader: ImageView;
|
||||||
private lateinit var _buttonClose: Button;
|
private lateinit var _buttonClose: Button;
|
||||||
private lateinit var _buttonAdd: ImageButton;
|
private lateinit var _buttonAdd: LinearLayout;
|
||||||
private lateinit var _buttonScanQR: ImageButton;
|
private lateinit var _buttonScanQR: LinearLayout;
|
||||||
private lateinit var _textNoDevicesFound: TextView;
|
private lateinit var _textNoDevicesFound: TextView;
|
||||||
private lateinit var _textNoDevicesRemembered: TextView;
|
|
||||||
private lateinit var _recyclerDevices: RecyclerView;
|
private lateinit var _recyclerDevices: RecyclerView;
|
||||||
private lateinit var _recyclerRememberedDevices: RecyclerView;
|
|
||||||
private lateinit var _adapter: DeviceAdapter;
|
private lateinit var _adapter: DeviceAdapter;
|
||||||
private lateinit var _rememberedAdapter: DeviceAdapter;
|
private val _devices: MutableSet<String> = mutableSetOf()
|
||||||
private val _devices: ArrayList<CastingDevice> = arrayListOf();
|
private val _rememberedDevices: MutableSet<String> = mutableSetOf()
|
||||||
private val _rememberedDevices: ArrayList<CastingDevice> = arrayListOf();
|
private val _unifiedDevices: MutableList<DeviceAdapterEntry> = mutableListOf()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
@ -45,42 +46,40 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
_imageLoader = findViewById(R.id.image_loader);
|
_imageLoader = findViewById(R.id.image_loader);
|
||||||
_buttonClose = findViewById(R.id.button_close);
|
_buttonClose = findViewById(R.id.button_close);
|
||||||
_buttonAdd = findViewById(R.id.button_add);
|
_buttonAdd = findViewById(R.id.button_add);
|
||||||
_buttonScanQR = findViewById(R.id.button_scan_qr);
|
_buttonScanQR = findViewById(R.id.button_qr);
|
||||||
_recyclerDevices = findViewById(R.id.recycler_devices);
|
_recyclerDevices = findViewById(R.id.recycler_devices);
|
||||||
_recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices);
|
|
||||||
_textNoDevicesFound = findViewById(R.id.text_no_devices_found);
|
_textNoDevicesFound = findViewById(R.id.text_no_devices_found);
|
||||||
_textNoDevicesRemembered = findViewById(R.id.text_no_devices_remembered);
|
|
||||||
|
|
||||||
_adapter = DeviceAdapter(_devices, false);
|
_adapter = DeviceAdapter(_unifiedDevices)
|
||||||
_recyclerDevices.adapter = _adapter;
|
_recyclerDevices.adapter = _adapter;
|
||||||
_recyclerDevices.layoutManager = LinearLayoutManager(context);
|
_recyclerDevices.layoutManager = LinearLayoutManager(context);
|
||||||
|
|
||||||
_rememberedAdapter = DeviceAdapter(_rememberedDevices, true);
|
_adapter.onPin.subscribe { d ->
|
||||||
_rememberedAdapter.onRemove.subscribe { d ->
|
val isRemembered = _rememberedDevices.contains(d.name)
|
||||||
if (StateCasting.instance.activeDevice == d) {
|
val newIsRemembered = !isRemembered
|
||||||
d.stopCasting();
|
if (newIsRemembered) {
|
||||||
|
StateCasting.instance.addRememberedDevice(d)
|
||||||
|
val name = d.name
|
||||||
|
if (name != null) {
|
||||||
|
_rememberedDevices.add(name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
StateCasting.instance.removeRememberedDevice(d)
|
||||||
|
_rememberedDevices.remove(d.name)
|
||||||
}
|
}
|
||||||
|
updateUnifiedList()
|
||||||
StateCasting.instance.removeRememberedDevice(d);
|
|
||||||
val index = _rememberedDevices.indexOf(d);
|
|
||||||
if (index != -1) {
|
|
||||||
_rememberedDevices.removeAt(index);
|
|
||||||
_rememberedAdapter.notifyItemRemoved(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
|
|
||||||
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
|
|
||||||
};
|
|
||||||
_rememberedAdapter.onConnect.subscribe { _ ->
|
|
||||||
dismiss()
|
|
||||||
UIDialogs.showCastingDialog(context)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//TODO: Integrate remembered into the main list
|
||||||
|
//TODO: Add green indicator to indicate a device is oneline
|
||||||
|
//TODO: Add pinning
|
||||||
|
//TODO: Implement QR code as an option in add manually
|
||||||
|
//TODO: Remove start button
|
||||||
|
|
||||||
_adapter.onConnect.subscribe { _ ->
|
_adapter.onConnect.subscribe { _ ->
|
||||||
dismiss()
|
dismiss()
|
||||||
UIDialogs.showCastingDialog(context)
|
//UIDialogs.showCastingDialog(context)
|
||||||
}
|
}
|
||||||
_recyclerRememberedDevices.adapter = _rememberedAdapter;
|
|
||||||
_recyclerRememberedDevices.layoutManager = LinearLayoutManager(context);
|
|
||||||
|
|
||||||
_buttonClose.setOnClickListener { dismiss(); };
|
_buttonClose.setOnClickListener { dismiss(); };
|
||||||
_buttonAdd.setOnClickListener {
|
_buttonAdd.setOnClickListener {
|
||||||
@ -105,77 +104,112 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
Logger.i(TAG, "Dialog shown.");
|
Logger.i(TAG, "Dialog shown.");
|
||||||
|
|
||||||
StateCasting.instance.startDiscovering()
|
StateCasting.instance.startDiscovering()
|
||||||
|
|
||||||
(_imageLoader.drawable as Animatable?)?.start();
|
(_imageLoader.drawable as Animatable?)?.start();
|
||||||
|
|
||||||
_devices.clear();
|
synchronized(StateCasting.instance.devices) {
|
||||||
synchronized (StateCasting.instance.devices) {
|
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
|
||||||
_devices.addAll(StateCasting.instance.devices.values);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_rememberedDevices.clear();
|
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
|
||||||
synchronized (StateCasting.instance.rememberedDevices) {
|
updateUnifiedList()
|
||||||
_rememberedDevices.addAll(StateCasting.instance.rememberedDevices);
|
|
||||||
|
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
|
||||||
|
val name = d.name
|
||||||
|
if (name != null)
|
||||||
|
_devices.add(name)
|
||||||
|
updateUnifiedList()
|
||||||
|
}
|
||||||
|
|
||||||
|
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
|
||||||
|
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name }
|
||||||
|
if (index != -1) {
|
||||||
|
_unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice)
|
||||||
|
_adapter.notifyItemChanged(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StateCasting.instance.onDeviceRemoved.subscribe(this) { d ->
|
||||||
|
_devices.remove(d.name)
|
||||||
|
updateUnifiedList()
|
||||||
|
}
|
||||||
|
|
||||||
|
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
|
||||||
|
if (connectionState == CastConnectionState.CONNECTED) {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
|
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
|
||||||
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
|
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
|
||||||
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
|
|
||||||
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
|
|
||||||
|
|
||||||
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
|
|
||||||
_devices.add(d);
|
|
||||||
_adapter.notifyItemInserted(_devices.size - 1);
|
|
||||||
_textNoDevicesFound.visibility = View.GONE;
|
|
||||||
_recyclerDevices.visibility = View.VISIBLE;
|
|
||||||
};
|
|
||||||
|
|
||||||
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
|
|
||||||
val index = _devices.indexOf(d);
|
|
||||||
if (index == -1) {
|
|
||||||
return@subscribe;
|
|
||||||
}
|
|
||||||
|
|
||||||
_devices[index] = d;
|
|
||||||
_adapter.notifyItemChanged(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
StateCasting.instance.onDeviceRemoved.subscribe(this) { d ->
|
|
||||||
val index = _devices.indexOf(d);
|
|
||||||
if (index == -1) {
|
|
||||||
return@subscribe;
|
|
||||||
}
|
|
||||||
|
|
||||||
_devices.removeAt(index);
|
|
||||||
_adapter.notifyItemRemoved(index);
|
|
||||||
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
|
|
||||||
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
|
|
||||||
};
|
|
||||||
|
|
||||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
|
|
||||||
if (connectionState != CastConnectionState.CONNECTED) {
|
|
||||||
return@subscribe;
|
|
||||||
}
|
|
||||||
|
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
|
||||||
dismiss();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
_adapter.notifyDataSetChanged();
|
|
||||||
_rememberedAdapter.notifyDataSetChanged();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun dismiss() {
|
override fun dismiss() {
|
||||||
super.dismiss();
|
super.dismiss()
|
||||||
|
(_imageLoader.drawable as Animatable?)?.stop()
|
||||||
(_imageLoader.drawable as Animatable?)?.stop();
|
|
||||||
|
|
||||||
StateCasting.instance.stopDiscovering()
|
StateCasting.instance.stopDiscovering()
|
||||||
StateCasting.instance.onDeviceAdded.remove(this);
|
StateCasting.instance.onDeviceAdded.remove(this)
|
||||||
StateCasting.instance.onDeviceChanged.remove(this);
|
StateCasting.instance.onDeviceChanged.remove(this)
|
||||||
StateCasting.instance.onDeviceRemoved.remove(this);
|
StateCasting.instance.onDeviceRemoved.remove(this)
|
||||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
|
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateUnifiedList() {
|
||||||
|
val oldList = ArrayList(_unifiedDevices)
|
||||||
|
val newList = buildUnifiedList()
|
||||||
|
|
||||||
|
val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||||
|
override fun getOldListSize() = oldList.size
|
||||||
|
override fun getNewListSize() = newList.size
|
||||||
|
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||||
|
val oldItem = oldList[oldItemPosition]
|
||||||
|
val newItem = newList[newItemPosition]
|
||||||
|
return oldItem.castingDevice.name == newItem.castingDevice.name
|
||||||
|
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
|
||||||
|
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
|
||||||
|
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
|
||||||
|
}
|
||||||
|
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||||
|
val oldItem = oldList[oldItemPosition]
|
||||||
|
val newItem = newList[newItemPosition]
|
||||||
|
return oldItem.castingDevice.name == newItem.castingDevice.name
|
||||||
|
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
|
||||||
|
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
|
||||||
|
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
_unifiedDevices.clear()
|
||||||
|
_unifiedDevices.addAll(newList)
|
||||||
|
diffResult.dispatchUpdatesTo(_adapter)
|
||||||
|
|
||||||
|
_textNoDevicesFound.visibility = if (_unifiedDevices.isEmpty()) View.VISIBLE else View.GONE
|
||||||
|
_recyclerDevices.visibility = if (_unifiedDevices.isNotEmpty()) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildUnifiedList(): List<DeviceAdapterEntry> {
|
||||||
|
val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name }
|
||||||
|
val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name }
|
||||||
|
|
||||||
|
val unifiedList = mutableListOf<DeviceAdapterEntry>()
|
||||||
|
|
||||||
|
val intersectionNames = _devices.intersect(_rememberedDevices)
|
||||||
|
for (name in intersectionNames) {
|
||||||
|
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, true)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val onlineOnlyNames = _devices - _rememberedDevices
|
||||||
|
for (name in onlineOnlyNames) {
|
||||||
|
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, false, true)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val rememberedOnlyNames = _rememberedDevices - _devices
|
||||||
|
for (name in rememberedOnlyNames) {
|
||||||
|
rememberedDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, false)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return unifiedList
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
@ -201,11 +201,12 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
|||||||
protected open fun onContentUrlClicked(url: String, contentType: ContentType) {
|
protected open fun onContentUrlClicked(url: String, contentType: ContentType) {
|
||||||
when(contentType) {
|
when(contentType) {
|
||||||
ContentType.MEDIA -> {
|
ContentType.MEDIA -> {
|
||||||
StatePlayer.instance.clearQueue();
|
StatePlayer.instance.clearQueue()
|
||||||
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail();
|
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
|
||||||
};
|
}
|
||||||
ContentType.PLAYLIST -> fragment.navigate<RemotePlaylistFragment>(url);
|
ContentType.PLAYLIST -> fragment.navigate<RemotePlaylistFragment>(url)
|
||||||
ContentType.URL -> fragment.navigate<BrowserFragment>(url);
|
ContentType.URL -> fragment.navigate<BrowserFragment>(url)
|
||||||
|
ContentType.CHANNEL -> fragment.navigate<ChannelFragment>(url)
|
||||||
else -> {};
|
else -> {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ import com.futo.platformplayer.UIDialogs
|
|||||||
import com.futo.platformplayer.UISlideOverlays
|
import com.futo.platformplayer.UISlideOverlays
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
@ -18,6 +19,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
|||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
||||||
import com.futo.platformplayer.isHttpUrl
|
import com.futo.platformplayer.isHttpUrl
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.models.SearchType
|
||||||
import com.futo.platformplayer.states.StateMeta
|
import com.futo.platformplayer.states.StateMeta
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.views.FeedStyle
|
import com.futo.platformplayer.views.FeedStyle
|
||||||
@ -84,6 +86,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||||||
private var _filterValues: HashMap<String, List<String>> = hashMapOf();
|
private var _filterValues: HashMap<String, List<String>> = hashMapOf();
|
||||||
private var _enabledClientIds: List<String>? = null;
|
private var _enabledClientIds: List<String>? = null;
|
||||||
private var _channelUrl: String? = null;
|
private var _channelUrl: String? = null;
|
||||||
|
private var _searchType: SearchType? = null;
|
||||||
|
|
||||||
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
|
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
|
||||||
override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar
|
override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar
|
||||||
@ -95,7 +98,13 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||||||
if (channelUrl != null) {
|
if (channelUrl != null) {
|
||||||
StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds)
|
StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds)
|
||||||
} else {
|
} else {
|
||||||
StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
|
when (_searchType)
|
||||||
|
{
|
||||||
|
SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
|
||||||
|
SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query)
|
||||||
|
SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query)
|
||||||
|
else -> throw Exception("Search type must be specified")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
|
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
|
||||||
@ -116,6 +125,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||||||
if(parameter is SuggestionsFragmentData) {
|
if(parameter is SuggestionsFragmentData) {
|
||||||
setQuery(parameter.query, false);
|
setQuery(parameter.query, false);
|
||||||
setChannelUrl(parameter.channelUrl, false);
|
setChannelUrl(parameter.channelUrl, false);
|
||||||
|
setSearchType(parameter.searchType, false)
|
||||||
|
|
||||||
fragment.topBar?.apply {
|
fragment.topBar?.apply {
|
||||||
if (this is SearchTopBarFragment) {
|
if (this is SearchTopBarFragment) {
|
||||||
@ -258,6 +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) {
|
private fun setSortBy(sortBy: String?, updateResults: Boolean = true) {
|
||||||
_sortBy = sortBy;
|
_sortBy = sortBy;
|
||||||
|
|
||||||
|
@ -21,6 +21,8 @@ import com.futo.platformplayer.models.Playlist
|
|||||||
import com.futo.platformplayer.states.StateDownloads
|
import com.futo.platformplayer.states.StateDownloads
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.states.StatePlaylists
|
import com.futo.platformplayer.states.StatePlaylists
|
||||||
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
|
import com.futo.platformplayer.stores.StringStorage
|
||||||
import com.futo.platformplayer.toHumanBytesSize
|
import com.futo.platformplayer.toHumanBytesSize
|
||||||
import com.futo.platformplayer.toHumanDuration
|
import com.futo.platformplayer.toHumanDuration
|
||||||
import com.futo.platformplayer.views.AnyInsertedAdapterView
|
import com.futo.platformplayer.views.AnyInsertedAdapterView
|
||||||
@ -103,12 +105,15 @@ class DownloadsFragment : MainFragment() {
|
|||||||
private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>;
|
private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>;
|
||||||
|
|
||||||
private var lastDownloads: List<VideoLocal>? = null;
|
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()) {
|
constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) {
|
||||||
inflater.inflate(R.layout.fragment_downloads, this);
|
inflater.inflate(R.layout.fragment_downloads, this);
|
||||||
_frag = frag;
|
_frag = frag;
|
||||||
|
|
||||||
|
if(ordering.value.isNullOrBlank())
|
||||||
|
ordering.value = "nameAsc";
|
||||||
|
|
||||||
_usageUsed = findViewById(R.id.downloads_usage_used);
|
_usageUsed = findViewById(R.id.downloads_usage_used);
|
||||||
_usageAvailable = findViewById(R.id.downloads_usage_available);
|
_usageAvailable = findViewById(R.id.downloads_usage_available);
|
||||||
_usageProgress = findViewById(R.id.downloads_usage_progress);
|
_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 {
|
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also {
|
||||||
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||||
};
|
};
|
||||||
spinnerSortBy.setSelection(0);
|
val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc");
|
||||||
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||||
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
|
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
|
||||||
when(pos) {
|
when(pos) {
|
||||||
0 -> ordering = "nameAsc"
|
0 -> ordering.setAndSave("nameAsc")
|
||||||
1 -> ordering = "nameDesc"
|
1 -> ordering.setAndSave("nameDesc")
|
||||||
2 -> ordering = "downloadDateAsc"
|
2 -> ordering.setAndSave("downloadDateAsc")
|
||||||
3 -> ordering = "downloadDateDesc"
|
3 -> ordering.setAndSave("downloadDateDesc")
|
||||||
4 -> ordering = "releasedAsc"
|
4 -> ordering.setAndSave("releasedAsc")
|
||||||
5 -> ordering = "releasedDesc"
|
5 -> ordering.setAndSave("releasedDesc")
|
||||||
else -> ordering = null
|
else -> ordering.setAndSave("")
|
||||||
}
|
}
|
||||||
updateContentFilters()
|
updateContentFilters()
|
||||||
}
|
}
|
||||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||||
};
|
};
|
||||||
|
spinnerSortBy.setSelection(Math.max(0, options.indexOf(ordering.value)));
|
||||||
|
|
||||||
_listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded)
|
_listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded)
|
||||||
.asAnyWithTop(findViewById(R.id.downloads_top)) {
|
.asAnyWithTop(findViewById(R.id.downloads_top)) {
|
||||||
@ -230,8 +236,8 @@ class DownloadsFragment : MainFragment() {
|
|||||||
var vidsToReturn = vids;
|
var vidsToReturn = vids;
|
||||||
if(!_listDownloadSearch.text.isNullOrEmpty())
|
if(!_listDownloadSearch.text.isNullOrEmpty())
|
||||||
vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) || it.author.name.contains(_listDownloadSearch.text, true) };
|
vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) || it.author.name.contains(_listDownloadSearch.text, true) };
|
||||||
if(!ordering.isNullOrEmpty()) {
|
if(!ordering.value.isNullOrEmpty()) {
|
||||||
vidsToReturn = when(ordering){
|
vidsToReturn = when(ordering.value){
|
||||||
"downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX };
|
"downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX };
|
||||||
"downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN };
|
"downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN };
|
||||||
"nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() }
|
"nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() }
|
||||||
|
@ -136,7 +136,6 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
_toolbarContentView = findViewById(R.id.container_toolbar_content);
|
_toolbarContentView = findViewById(R.id.container_toolbar_content);
|
||||||
|
|
||||||
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
|
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
|
||||||
|
|
||||||
if (it is IAsyncPager<*>)
|
if (it is IAsyncPager<*>)
|
||||||
it.nextPageAsync();
|
it.nextPageAsync();
|
||||||
else
|
else
|
||||||
@ -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 firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null;
|
||||||
val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition();
|
val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition();
|
||||||
val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null;
|
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)) {
|
if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) {
|
||||||
false;
|
false;
|
||||||
}
|
}
|
||||||
else if (firstVisibleItemView != null && height != null && firstVisibleItemView.height * recyclerData.results.size < height) {
|
else if (firstVisibleItemView != null && height != null && rowsHeight < height) {
|
||||||
false;
|
false;
|
||||||
} else {
|
} else {
|
||||||
true;
|
true;
|
||||||
@ -240,6 +241,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
_automaticNextPageCounter = 0;
|
_automaticNextPageCounter = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fun resetAutomaticNextPageCounter(){
|
||||||
|
_automaticNextPageCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
protected fun setTextCentered(text: String?) {
|
protected fun setTextCentered(text: String?) {
|
||||||
_textCentered.text = text;
|
_textCentered.text = text;
|
||||||
|
@ -6,7 +6,6 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.core.view.allViews
|
import androidx.core.view.allViews
|
||||||
import androidx.core.view.contains
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import com.futo.platformplayer.*
|
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.StateHistory
|
||||||
import com.futo.platformplayer.states.StateMeta
|
import com.futo.platformplayer.states.StateMeta
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
|
import com.futo.platformplayer.states.StatePlugins
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.stores.StringArrayStorage
|
import com.futo.platformplayer.stores.StringArrayStorage
|
||||||
import com.futo.platformplayer.views.FeedStyle
|
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.ContentPreviewViewHolder
|
||||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||||
import com.futo.platformplayer.views.adapters.InsertedViewHolder
|
import com.futo.platformplayer.views.adapters.InsertedViewHolder
|
||||||
import com.futo.platformplayer.views.announcements.AnnouncementView
|
|
||||||
import com.futo.platformplayer.views.buttons.BigButton
|
import com.futo.platformplayer.views.buttons.BigButton
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
@ -49,6 +48,12 @@ class HomeFragment : MainFragment() {
|
|||||||
|
|
||||||
private var _view: HomeView? = null;
|
private var _view: HomeView? = null;
|
||||||
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = 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() {
|
fun reloadFeed() {
|
||||||
_view?.reloadFeed()
|
_view?.reloadFeed()
|
||||||
@ -74,7 +79,7 @@ class HomeFragment : MainFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
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;
|
_view = view;
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
@ -92,6 +97,7 @@ class HomeFragment : MainFragment() {
|
|||||||
val view = _view;
|
val view = _view;
|
||||||
if (view != null) {
|
if (view != null) {
|
||||||
_cachedRecyclerData = view.recyclerData;
|
_cachedRecyclerData = view.recyclerData;
|
||||||
|
_cachedLastPager = view.lastPager;
|
||||||
view.cleanup();
|
view.cleanup();
|
||||||
_view = null;
|
_view = null;
|
||||||
}
|
}
|
||||||
@ -111,9 +117,10 @@ class HomeFragment : MainFragment() {
|
|||||||
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
|
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
|
||||||
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar
|
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 }, {
|
_taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({ fragment.lifecycleScope }, {
|
||||||
StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope)
|
StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope)
|
||||||
})
|
})
|
||||||
@ -122,7 +129,8 @@ class HomeFragment : MainFragment() {
|
|||||||
ReusableRefreshPager(it);
|
ReusableRefreshPager(it);
|
||||||
else
|
else
|
||||||
ReusablePager(it);
|
ReusablePager(it);
|
||||||
_lastPager = wrappedPager;
|
lastPager = wrappedPager;
|
||||||
|
resetAutomaticNextPageCounter();
|
||||||
loadedResult(wrappedPager.getWindow());
|
loadedResult(wrappedPager.getWindow());
|
||||||
}
|
}
|
||||||
.exception<ScriptCaptchaRequiredException> { }
|
.exception<ScriptCaptchaRequiredException> { }
|
||||||
@ -227,9 +235,6 @@ class HomeFragment : MainFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val _filterLock = Object();
|
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");
|
private var _togglesConfig = FragmentedStorage.get<StringArrayStorage>("home_toggles");
|
||||||
fun initializeToolbarContent() {
|
fun initializeToolbarContent() {
|
||||||
if(_toolbarContentView.allViews.any { it is ToggleBar })
|
if(_toolbarContentView.allViews.any { it is ToggleBar })
|
||||||
@ -245,38 +250,53 @@ class HomeFragment : MainFragment() {
|
|||||||
layoutParams =
|
layoutParams =
|
||||||
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
|
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
|
||||||
}
|
}
|
||||||
_togglePluginsDisabled.clear();
|
|
||||||
synchronized(_filterLock) {
|
synchronized(_filterLock) {
|
||||||
val buttonsPlugins = (if (_togglesConfig.contains("plugins"))
|
var buttonsPlugins: List<ToggleBar.Toggle> = listOf()
|
||||||
|
buttonsPlugins = (if (_togglesConfig.contains("plugins"))
|
||||||
(StatePlatform.instance.getEnabledClients()
|
(StatePlatform.instance.getEnabledClients()
|
||||||
|
.filter { it is JSClient && it.enableInHome }
|
||||||
.map { plugin ->
|
.map { plugin ->
|
||||||
ToggleBar.Toggle(plugin.name, plugin.icon, true, {
|
ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) plugin.name else "", plugin.icon, !fragment._togglePluginsDisabled.contains(plugin.id), { view, active ->
|
||||||
if (it) {
|
var dontSwap = false;
|
||||||
if (_togglePluginsDisabled.contains(plugin.id))
|
if (active) {
|
||||||
_togglePluginsDisabled.remove(plugin.id);
|
if (fragment._togglePluginsDisabled.contains(plugin.id))
|
||||||
|
fragment._togglePluginsDisabled.remove(plugin.id);
|
||||||
} else {
|
} else {
|
||||||
if (!_togglePluginsDisabled.contains(plugin.id))
|
if (!fragment._togglePluginsDisabled.contains(plugin.id)) {
|
||||||
_togglePluginsDisabled.add(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")
|
}).withTag("plugins")
|
||||||
})
|
})
|
||||||
else listOf())
|
else listOf())
|
||||||
val buttons = (listOf<ToggleBar.Toggle?>(
|
val buttons = (listOf<ToggleBar.Toggle?>(
|
||||||
(if (_togglesConfig.contains("today"))
|
(if (_togglesConfig.contains("today"))
|
||||||
ToggleBar.Toggle("Today", _toggleRecent) {
|
ToggleBar.Toggle("Today", fragment._toggleRecent) { view, active ->
|
||||||
_toggleRecent = it; reloadForFilters()
|
fragment._toggleRecent = active; reloadForFilters()
|
||||||
}
|
}
|
||||||
.withTag("today") else null),
|
.withTag("today") else null),
|
||||||
(if (_togglesConfig.contains("watched"))
|
(if (_togglesConfig.contains("watched"))
|
||||||
ToggleBar.Toggle("Unwatched", _toggleWatched) {
|
ToggleBar.Toggle("Unwatched", fragment._toggleWatched) { view, active ->
|
||||||
_toggleWatched = it; reloadForFilters()
|
fragment._toggleWatched = active; reloadForFilters()
|
||||||
}
|
}
|
||||||
.withTag("watched") else null),
|
.withTag("watched") else null),
|
||||||
).filterNotNull() + buttonsPlugins)
|
).filterNotNull() + buttonsPlugins)
|
||||||
.sortedBy { _togglesConfig.indexOf(it.tag ?: "") } ?: listOf()
|
.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,
|
showOrderOverlay(_overlayContainer,
|
||||||
"Visible home filters",
|
"Visible home filters",
|
||||||
listOf(
|
listOf(
|
||||||
@ -302,7 +322,7 @@ class HomeFragment : MainFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun reloadForFilters() {
|
fun reloadForFilters() {
|
||||||
_lastPager?.let { loadedResult(it.getWindow()) };
|
lastPager?.let { loadedResult(it.getWindow()) };
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
|
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
|
||||||
@ -312,11 +332,11 @@ class HomeFragment : MainFragment() {
|
|||||||
if(StateMeta.instance.isCreatorHidden(it.author.url))
|
if(StateMeta.instance.isCreatorHidden(it.author.url))
|
||||||
return@filter false;
|
return@filter false;
|
||||||
|
|
||||||
if(_toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25)
|
if(fragment._toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25)
|
||||||
return@filter false;
|
return@filter false;
|
||||||
if(_toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0))
|
if(fragment._toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0))
|
||||||
return@filter false;
|
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;
|
return@filter false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -326,6 +326,10 @@ class PlaylistFragment : MainFragment() {
|
|||||||
playlist.videos = ArrayList(playlist.videos.filter { it != video });
|
playlist.videos = ArrayList(playlist.videos.filter { it != video });
|
||||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onVideoOptions(video: IPlatformVideo) {
|
||||||
|
UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer);
|
||||||
|
}
|
||||||
override fun onVideoClicked(video: IPlatformVideo) {
|
override fun onVideoClicked(video: IPlatformVideo) {
|
||||||
val playlist = _playlist;
|
val playlist = _playlist;
|
||||||
if (playlist != null) {
|
if (playlist != null) {
|
||||||
|
@ -26,6 +26,8 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
|||||||
import com.futo.platformplayer.models.Playlist
|
import com.futo.platformplayer.models.Playlist
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.states.StatePlaylists
|
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.SearchView
|
||||||
import com.futo.platformplayer.views.adapters.*
|
import com.futo.platformplayer.views.adapters.*
|
||||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||||
@ -82,7 +84,7 @@ class PlaylistsFragment : MainFragment() {
|
|||||||
|
|
||||||
private var _listPlaylistsSearch: EditText;
|
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) {
|
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 {
|
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.playlists_sortby_array)).also {
|
||||||
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
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 {
|
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||||
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
|
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
|
||||||
when(pos) {
|
when(pos) {
|
||||||
0 -> _ordering = "nameAsc"
|
0 -> _ordering.setAndSave("nameAsc")
|
||||||
1 -> _ordering = "nameDesc"
|
1 -> _ordering.setAndSave("nameDesc")
|
||||||
2 -> _ordering = "dateEditAsc"
|
2 -> _ordering.setAndSave("dateEditAsc")
|
||||||
3 -> _ordering = "dateEditDesc"
|
3 -> _ordering.setAndSave("dateEditDesc")
|
||||||
4 -> _ordering = "dateCreateAsc"
|
4 -> _ordering.setAndSave("dateCreateAsc")
|
||||||
5 -> _ordering = "dateCreateDesc"
|
5 -> _ordering.setAndSave("dateCreateDesc")
|
||||||
6 -> _ordering = "datePlayAsc"
|
6 -> _ordering.setAndSave("datePlayAsc")
|
||||||
7 -> _ordering = "datePlayDesc"
|
7 -> _ordering.setAndSave("datePlayDesc")
|
||||||
else -> _ordering = null
|
else -> _ordering.setAndSave("")
|
||||||
}
|
}
|
||||||
updatePlaylistsFiltering()
|
updatePlaylistsFiltering()
|
||||||
}
|
}
|
||||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
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)); };
|
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;
|
var playlistsToReturn = pls;
|
||||||
if(!_listPlaylistsSearch.text.isNullOrEmpty())
|
if(!_listPlaylistsSearch.text.isNullOrEmpty())
|
||||||
playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) };
|
playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) };
|
||||||
if(!_ordering.isNullOrEmpty()){
|
if(!_ordering.value.isNullOrEmpty()){
|
||||||
playlistsToReturn = when(_ordering){
|
playlistsToReturn = when(_ordering.value){
|
||||||
"nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() }
|
"nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() }
|
||||||
"nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() };
|
"nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() };
|
||||||
"dateEditAsc" -> playlistsToReturn.sortedBy { it.dateUpdate ?: OffsetDateTime.MAX };
|
"dateEditAsc" -> playlistsToReturn.sortedBy { it.dateUpdate ?: OffsetDateTime.MAX };
|
||||||
|
@ -18,6 +18,7 @@ import com.futo.platformplayer.constructs.TaskHandler
|
|||||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||||
import com.futo.platformplayer.exceptions.ChannelException
|
import com.futo.platformplayer.exceptions.ChannelException
|
||||||
import com.futo.platformplayer.exceptions.RateLimitException
|
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.logging.Logger
|
||||||
import com.futo.platformplayer.models.SearchType
|
import com.futo.platformplayer.models.SearchType
|
||||||
import com.futo.platformplayer.models.SubscriptionGroup
|
import com.futo.platformplayer.models.SubscriptionGroup
|
||||||
@ -56,6 +57,9 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||||||
private var _group: SubscriptionGroup? = null;
|
private var _group: SubscriptionGroup? = null;
|
||||||
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = 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) {
|
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||||
super.onShownWithView(parameter, isBack);
|
super.onShownWithView(parameter, isBack);
|
||||||
_view?.onShown();
|
_view?.onShown();
|
||||||
@ -184,8 +188,6 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||||||
return Json.encodeToString(this);
|
return Json.encodeToString(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private val _filterLock = Object();
|
|
||||||
private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter");
|
|
||||||
|
|
||||||
private var _bypassRateLimit = false;
|
private var _bypassRateLimit = false;
|
||||||
private val _lastExceptions: List<Throwable>? = null;
|
private val _lastExceptions: List<Throwable>? = null;
|
||||||
@ -284,13 +286,18 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||||||
fragment.navigate<SubscriptionGroupFragment>(g);
|
fragment.navigate<SubscriptionGroupFragment>(g);
|
||||||
};
|
};
|
||||||
|
|
||||||
synchronized(_filterLock) {
|
synchronized(fragment._filterLock) {
|
||||||
_subscriptionBar?.setToggles(
|
_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.videos), fragment._filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { view, active ->
|
||||||
SubscriptionBar.Toggle(context.getString(R.string.posts), _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); },
|
toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), active); },
|
||||||
SubscriptionBar.Toggle(context.getString(R.string.live), _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); },
|
SubscriptionBar.Toggle(context.getString(R.string.posts), fragment._filterSettings.allowContentTypes.contains(ContentType.POST)) { view, active ->
|
||||||
SubscriptionBar.Toggle(context.getString(R.string.planned), _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); },
|
toggleFilterContentType(ContentType.POST, active); },
|
||||||
SubscriptionBar.Toggle(context.getString(R.string.watched), _filterSettings.allowWatched) { _filterSettings.allowWatched = it; _filterSettings.save(); loadResults(false); }
|
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);
|
toggleFilterContentType(contentType, isTrue);
|
||||||
}
|
}
|
||||||
private fun toggleFilterContentType(contentType: ContentType, isTrue: Boolean) {
|
private fun toggleFilterContentType(contentType: ContentType, isTrue: Boolean) {
|
||||||
synchronized(_filterLock) {
|
synchronized(fragment._filterLock) {
|
||||||
if(!isTrue) {
|
if(!isTrue) {
|
||||||
_filterSettings.allowContentTypes.remove(contentType);
|
fragment._filterSettings.allowContentTypes.remove(contentType);
|
||||||
} else if(!_filterSettings.allowContentTypes.contains(contentType)) {
|
} else if(!fragment._filterSettings.allowContentTypes.contains(contentType)) {
|
||||||
_filterSettings.allowContentTypes.add(contentType)
|
fragment._filterSettings.allowContentTypes.add(contentType)
|
||||||
}
|
}
|
||||||
_filterSettings.save();
|
fragment._filterSettings.save();
|
||||||
};
|
};
|
||||||
if(Settings.instance.subscriptions.fetchOnTabOpen) { //TODO: Do this different, temporary workaround
|
if(Settings.instance.subscriptions.fetchOnTabOpen) { //TODO: Do this different, temporary workaround
|
||||||
loadResults(false);
|
loadResults(false);
|
||||||
@ -320,9 +327,9 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||||||
val nowSoon = OffsetDateTime.now().plusMinutes(5);
|
val nowSoon = OffsetDateTime.now().plusMinutes(5);
|
||||||
val filterGroup = subGroup;
|
val filterGroup = subGroup;
|
||||||
return results.filter {
|
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;
|
return@filter false;
|
||||||
|
|
||||||
//TODO: Check against a sub cache
|
//TODO: Check against a sub cache
|
||||||
@ -331,11 +338,11 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||||||
|
|
||||||
|
|
||||||
if(it.datetime?.isAfter(nowSoon) == true) {
|
if(it.datetime?.isAfter(nowSoon) == true) {
|
||||||
if(!_filterSettings.allowPlanned)
|
if(!fragment._filterSettings.allowPlanned)
|
||||||
return@filter false;
|
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)
|
if(it is IPlatformVideo && it.isLive)
|
||||||
return@filter true;
|
return@filter true;
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,8 @@ import com.futo.platformplayer.states.StatePlatform
|
|||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.stores.SearchHistoryStorage
|
import com.futo.platformplayer.stores.SearchHistoryStorage
|
||||||
import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter
|
import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter
|
||||||
|
import com.futo.platformplayer.views.others.RadioGroupView
|
||||||
|
import com.futo.platformplayer.views.others.TagsView
|
||||||
|
|
||||||
data class SuggestionsFragmentData(val query: String, val searchType: SearchType, val channelUrl: String? = null);
|
data class SuggestionsFragmentData(val query: String, val searchType: SearchType, val channelUrl: String? = null);
|
||||||
|
|
||||||
@ -28,6 +30,7 @@ class SuggestionsFragment : MainFragment {
|
|||||||
|
|
||||||
private var _recyclerSuggestions: RecyclerView? = null;
|
private var _recyclerSuggestions: RecyclerView? = null;
|
||||||
private var _llmSuggestions: LinearLayoutManager? = null;
|
private var _llmSuggestions: LinearLayoutManager? = null;
|
||||||
|
private var _radioGroupView: RadioGroupView? = null;
|
||||||
private val _suggestions: ArrayList<String> = ArrayList();
|
private val _suggestions: ArrayList<String> = ArrayList();
|
||||||
private var _query: String? = null;
|
private var _query: String? = null;
|
||||||
private var _searchType: SearchType = SearchType.VIDEO;
|
private var _searchType: SearchType = SearchType.VIDEO;
|
||||||
@ -49,14 +52,7 @@ class SuggestionsFragment : MainFragment {
|
|||||||
_adapterSuggestions.onClicked.subscribe { suggestion ->
|
_adapterSuggestions.onClicked.subscribe { suggestion ->
|
||||||
val storage = FragmentedStorage.get<SearchHistoryStorage>();
|
val storage = FragmentedStorage.get<SearchHistoryStorage>();
|
||||||
storage.add(suggestion);
|
storage.add(suggestion);
|
||||||
|
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType, _channelUrl));
|
||||||
if (_searchType == SearchType.CREATOR) {
|
|
||||||
navigate<CreatorSearchResultsFragment>(suggestion);
|
|
||||||
} else if (_searchType == SearchType.PLAYLIST) {
|
|
||||||
navigate<PlaylistSearchResultsFragment>(suggestion);
|
|
||||||
} else {
|
|
||||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, SearchType.VIDEO, _channelUrl));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_adapterSuggestions.onRemove.subscribe { suggestion ->
|
_adapterSuggestions.onRemove.subscribe { suggestion ->
|
||||||
val index = _suggestions.indexOf(suggestion);
|
val index = _suggestions.indexOf(suggestion);
|
||||||
@ -80,6 +76,15 @@ class SuggestionsFragment : MainFragment {
|
|||||||
recyclerSuggestions.adapter = _adapterSuggestions;
|
recyclerSuggestions.adapter = _adapterSuggestions;
|
||||||
_recyclerSuggestions = recyclerSuggestions;
|
_recyclerSuggestions = recyclerSuggestions;
|
||||||
|
|
||||||
|
_radioGroupView = view.findViewById<RadioGroupView>(R.id.radio_group).apply {
|
||||||
|
onSelectedChange.subscribe {
|
||||||
|
if (it.size != 1)
|
||||||
|
_searchType = SearchType.VIDEO
|
||||||
|
else
|
||||||
|
_searchType = (it[0] ?: SearchType.VIDEO) as SearchType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadSuggestions();
|
loadSuggestions();
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
@ -110,31 +115,27 @@ class SuggestionsFragment : MainFragment {
|
|||||||
_channelUrl = null;
|
_channelUrl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_radioGroupView?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true)
|
||||||
|
|
||||||
topBar?.apply {
|
topBar?.apply {
|
||||||
if (this is SearchTopBarFragment) {
|
if (this is SearchTopBarFragment) {
|
||||||
onSearch.subscribe(this) {
|
onSearch.subscribe(this) {
|
||||||
if (_searchType == SearchType.CREATOR) {
|
if(it.isHttpUrl()) {
|
||||||
navigate<CreatorSearchResultsFragment>(it);
|
if(StatePlatform.instance.hasEnabledPlaylistClient(it))
|
||||||
} else if (_searchType == SearchType.PLAYLIST) {
|
navigate<RemotePlaylistFragment>(it);
|
||||||
navigate<PlaylistSearchResultsFragment>(it);
|
else if(StatePlatform.instance.hasEnabledChannelClient(it))
|
||||||
} else {
|
navigate<ChannelFragment>(it);
|
||||||
if(it.isHttpUrl()) {
|
else {
|
||||||
if(StatePlatform.instance.hasEnabledPlaylistClient(it))
|
val url = it;
|
||||||
navigate<RemotePlaylistFragment>(it);
|
activity?.let {
|
||||||
else if(StatePlatform.instance.hasEnabledChannelClient(it))
|
close()
|
||||||
navigate<ChannelFragment>(it);
|
if(it is MainActivity)
|
||||||
else {
|
it.navigate(it.getFragment<VideoDetailFragment>(), url);
|
||||||
val url = it;
|
|
||||||
activity?.let {
|
|
||||||
close()
|
|
||||||
if(it is MainActivity)
|
|
||||||
it.navigate(it.getFragment<VideoDetailFragment>(), url);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType, _channelUrl));
|
||||||
};
|
};
|
||||||
|
|
||||||
onTextChange.subscribe(this) {
|
onTextChange.subscribe(this) {
|
||||||
@ -196,6 +197,7 @@ class SuggestionsFragment : MainFragment {
|
|||||||
super.onDestroyMainView();
|
super.onDestroyMainView();
|
||||||
_getSuggestions.onError.clear();
|
_getSuggestions.onError.clear();
|
||||||
_recyclerSuggestions = null;
|
_recyclerSuggestions = null;
|
||||||
|
_radioGroupView = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
@ -693,6 +693,9 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_container_content_description.onClose.subscribe { switchContentView(_container_content_main); };
|
_container_content_description.onClose.subscribe { switchContentView(_container_content_main); };
|
||||||
_container_content_liveChat.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.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_replies.onClose.subscribe { switchContentView(_container_content_main); };
|
||||||
_container_content_support.onClose.subscribe { switchContentView(_container_content_main); };
|
_container_content_support.onClose.subscribe { switchContentView(_container_content_main); };
|
||||||
_container_content_browser.onClose.subscribe { switchContentView(_container_content_main); };
|
_container_content_browser.onClose.subscribe { switchContentView(_container_content_main); };
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
package com.futo.platformplayer.fragment.mainactivity.main
|
package com.futo.platformplayer.fragment.mainactivity.main
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.graphics.drawable.Animatable
|
import android.graphics.drawable.Animatable
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
@ -48,6 +50,11 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
private var _loadedVideos: List<IPlatformVideo>? = null;
|
private var _loadedVideos: List<IPlatformVideo>? = null;
|
||||||
private var _loadedVideosCanEdit: Boolean = false;
|
private var _loadedVideosCanEdit: Boolean = false;
|
||||||
|
|
||||||
|
fun hideSearchKeyboard() {
|
||||||
|
(context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager)?.hideSoftInputFromWindow(_search.textSearch.windowToken, 0)
|
||||||
|
_search.textSearch.clearFocus();
|
||||||
|
}
|
||||||
|
|
||||||
constructor(inflater: LayoutInflater) : super(inflater.context) {
|
constructor(inflater: LayoutInflater) : super(inflater.context) {
|
||||||
inflater.inflate(R.layout.fragment_video_list_editor, this);
|
inflater.inflate(R.layout.fragment_video_list_editor, this);
|
||||||
|
|
||||||
@ -79,6 +86,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
_search.textSearch.text = "";
|
_search.textSearch.text = "";
|
||||||
updateVideoFilters();
|
updateVideoFilters();
|
||||||
_buttonSearch.setImageResource(R.drawable.ic_search);
|
_buttonSearch.setImageResource(R.drawable.ic_search);
|
||||||
|
hideSearchKeyboard();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
_search.visibility = View.VISIBLE;
|
_search.visibility = View.VISIBLE;
|
||||||
@ -89,22 +97,23 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
_buttonShare = findViewById(R.id.button_share);
|
_buttonShare = findViewById(R.id.button_share);
|
||||||
val onShare = _onShare;
|
val onShare = _onShare;
|
||||||
if(onShare != null) {
|
if(onShare != null) {
|
||||||
_buttonShare.setOnClickListener { onShare.invoke() };
|
_buttonShare.setOnClickListener { hideSearchKeyboard(); onShare.invoke() };
|
||||||
_buttonShare.visibility = View.VISIBLE;
|
_buttonShare.visibility = View.VISIBLE;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
_buttonShare.visibility = View.GONE;
|
_buttonShare.visibility = View.GONE;
|
||||||
|
|
||||||
buttonPlayAll.setOnClickListener { onPlayAllClick(); };
|
buttonPlayAll.setOnClickListener { hideSearchKeyboard();onPlayAllClick(); hideSearchKeyboard(); };
|
||||||
buttonShuffle.setOnClickListener { onShuffleClick(); };
|
buttonShuffle.setOnClickListener { hideSearchKeyboard();onShuffleClick(); hideSearchKeyboard(); };
|
||||||
|
|
||||||
_buttonEdit.setOnClickListener { onEditClick(); };
|
_buttonEdit.setOnClickListener { hideSearchKeyboard(); onEditClick(); };
|
||||||
setButtonExportVisible(false);
|
setButtonExportVisible(false);
|
||||||
setButtonDownloadVisible(canEdit());
|
setButtonDownloadVisible(canEdit());
|
||||||
|
|
||||||
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
|
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
|
||||||
videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved);
|
videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved);
|
||||||
videoListEditorView.onVideoClicked.subscribe(::onVideoClicked);
|
videoListEditorView.onVideoOptions.subscribe(::onVideoOptions);
|
||||||
|
videoListEditorView.onVideoClicked.subscribe { hideSearchKeyboard(); onVideoClicked(it)};
|
||||||
|
|
||||||
_videoListEditorView = videoListEditorView;
|
_videoListEditorView = videoListEditorView;
|
||||||
}
|
}
|
||||||
@ -112,6 +121,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
fun setOnShare(onShare: (()-> Unit)? = null) {
|
fun setOnShare(onShare: (()-> Unit)? = null) {
|
||||||
_onShare = onShare;
|
_onShare = onShare;
|
||||||
_buttonShare.setOnClickListener {
|
_buttonShare.setOnClickListener {
|
||||||
|
hideSearchKeyboard();
|
||||||
onShare?.invoke();
|
onShare?.invoke();
|
||||||
};
|
};
|
||||||
_buttonShare.visibility = View.VISIBLE;
|
_buttonShare.visibility = View.VISIBLE;
|
||||||
@ -122,6 +132,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
open fun onShuffleClick() { }
|
open fun onShuffleClick() { }
|
||||||
open fun onEditClick() { }
|
open fun onEditClick() { }
|
||||||
open fun onVideoRemoved(video: IPlatformVideo) {}
|
open fun onVideoRemoved(video: IPlatformVideo) {}
|
||||||
|
open fun onVideoOptions(video: IPlatformVideo) {}
|
||||||
open fun onVideoOrderChanged(videos : List<IPlatformVideo>) {}
|
open fun onVideoOrderChanged(videos : List<IPlatformVideo>) {}
|
||||||
open fun onVideoClicked(video: IPlatformVideo) {
|
open fun onVideoClicked(video: IPlatformVideo) {
|
||||||
|
|
||||||
@ -143,7 +154,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
setButtonExportVisible(false);
|
setButtonExportVisible(false);
|
||||||
_buttonDownload.setImageResource(R.drawable.ic_loader_animated);
|
_buttonDownload.setImageResource(R.drawable.ic_loader_animated);
|
||||||
_buttonDownload.drawable.assume<Animatable, Unit> { it.start() };
|
_buttonDownload.drawable.assume<Animatable, Unit> { it.start() };
|
||||||
_buttonDownload.setOnClickListener {
|
_buttonDownload.setOnClickListener { hideSearchKeyboard();
|
||||||
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
|
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
|
||||||
StateDownloads.instance.deleteCachedPlaylist(playlistId);
|
StateDownloads.instance.deleteCachedPlaylist(playlistId);
|
||||||
});
|
});
|
||||||
@ -152,7 +163,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
else if(isDownloaded) {
|
else if(isDownloaded) {
|
||||||
setButtonExportVisible(true)
|
setButtonExportVisible(true)
|
||||||
_buttonDownload.setImageResource(R.drawable.ic_download_off);
|
_buttonDownload.setImageResource(R.drawable.ic_download_off);
|
||||||
_buttonDownload.setOnClickListener {
|
_buttonDownload.setOnClickListener { hideSearchKeyboard();
|
||||||
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
|
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
|
||||||
StateDownloads.instance.deleteCachedPlaylist(playlistId);
|
StateDownloads.instance.deleteCachedPlaylist(playlistId);
|
||||||
});
|
});
|
||||||
@ -161,7 +172,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
else {
|
else {
|
||||||
setButtonExportVisible(false);
|
setButtonExportVisible(false);
|
||||||
_buttonDownload.setImageResource(R.drawable.ic_download);
|
_buttonDownload.setImageResource(R.drawable.ic_download);
|
||||||
_buttonDownload.setOnClickListener {
|
_buttonDownload.setOnClickListener { hideSearchKeyboard();
|
||||||
onDownload();
|
onDownload();
|
||||||
//UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer);
|
//UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer);
|
||||||
}
|
}
|
||||||
|
@ -103,6 +103,9 @@ class WatchLaterFragment : MainFragment() {
|
|||||||
StatePlaylists.instance.removeFromWatchLater(video, true);
|
StatePlaylists.instance.removeFromWatchLater(video, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
override fun onVideoOptions(video: IPlatformVideo) {
|
||||||
|
UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer);
|
||||||
|
}
|
||||||
|
|
||||||
override fun onVideoClicked(video: IPlatformVideo) {
|
override fun onVideoClicked(video: IPlatformVideo) {
|
||||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||||
|
@ -1,11 +0,0 @@
|
|||||||
package com.futo.platformplayer.mdns
|
|
||||||
|
|
||||||
data class BroadcastService(
|
|
||||||
val deviceName: String,
|
|
||||||
val serviceName: String,
|
|
||||||
val port: UShort,
|
|
||||||
val ttl: UInt,
|
|
||||||
val weight: UShort,
|
|
||||||
val priority: UShort,
|
|
||||||
val texts: List<String>? = null
|
|
||||||
)
|
|
@ -1,93 +0,0 @@
|
|||||||
package com.futo.platformplayer.mdns
|
|
||||||
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.nio.ByteOrder
|
|
||||||
|
|
||||||
enum class QueryResponse(val value: Byte) {
|
|
||||||
Query(0),
|
|
||||||
Response(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class DnsOpcode(val value: Byte) {
|
|
||||||
StandardQuery(0),
|
|
||||||
InverseQuery(1),
|
|
||||||
ServerStatusRequest(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class DnsResponseCode(val value: Byte) {
|
|
||||||
NoError(0),
|
|
||||||
FormatError(1),
|
|
||||||
ServerFailure(2),
|
|
||||||
NameError(3),
|
|
||||||
NotImplemented(4),
|
|
||||||
Refused(5)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class DnsPacketHeader(
|
|
||||||
val identifier: UShort,
|
|
||||||
val queryResponse: Int,
|
|
||||||
val opcode: Int,
|
|
||||||
val authoritativeAnswer: Boolean,
|
|
||||||
val truncated: Boolean,
|
|
||||||
val recursionDesired: Boolean,
|
|
||||||
val recursionAvailable: Boolean,
|
|
||||||
val answerAuthenticated: Boolean,
|
|
||||||
val nonAuthenticatedData: Boolean,
|
|
||||||
val responseCode: DnsResponseCode
|
|
||||||
)
|
|
||||||
|
|
||||||
data class DnsPacket(
|
|
||||||
val header: DnsPacketHeader,
|
|
||||||
val questions: List<DnsQuestion>,
|
|
||||||
val answers: List<DnsResourceRecord>,
|
|
||||||
val authorities: List<DnsResourceRecord>,
|
|
||||||
val additionals: List<DnsResourceRecord>
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun parse(data: ByteArray): DnsPacket {
|
|
||||||
val span = data.asUByteArray()
|
|
||||||
val flags = (span[2].toInt() shl 8 or span[3].toInt()).toUShort()
|
|
||||||
val questionCount = (span[4].toInt() shl 8 or span[5].toInt()).toUShort()
|
|
||||||
val answerCount = (span[6].toInt() shl 8 or span[7].toInt()).toUShort()
|
|
||||||
val authorityCount = (span[8].toInt() shl 8 or span[9].toInt()).toUShort()
|
|
||||||
val additionalCount = (span[10].toInt() shl 8 or span[11].toInt()).toUShort()
|
|
||||||
|
|
||||||
var position = 12
|
|
||||||
|
|
||||||
val questions = List(questionCount.toInt()) {
|
|
||||||
DnsQuestion.parse(data, position).also { position = it.second }
|
|
||||||
}.map { it.first }
|
|
||||||
|
|
||||||
val answers = List(answerCount.toInt()) {
|
|
||||||
DnsResourceRecord.parse(data, position).also { position = it.second }
|
|
||||||
}.map { it.first }
|
|
||||||
|
|
||||||
val authorities = List(authorityCount.toInt()) {
|
|
||||||
DnsResourceRecord.parse(data, position).also { position = it.second }
|
|
||||||
}.map { it.first }
|
|
||||||
|
|
||||||
val additionals = List(additionalCount.toInt()) {
|
|
||||||
DnsResourceRecord.parse(data, position).also { position = it.second }
|
|
||||||
}.map { it.first }
|
|
||||||
|
|
||||||
return DnsPacket(
|
|
||||||
header = DnsPacketHeader(
|
|
||||||
identifier = (span[0].toInt() shl 8 or span[1].toInt()).toUShort(),
|
|
||||||
queryResponse = ((flags.toUInt() shr 15) and 0b1u).toInt(),
|
|
||||||
opcode = ((flags.toUInt() shr 11) and 0b1111u).toInt(),
|
|
||||||
authoritativeAnswer = (flags.toInt() shr 10) and 0b1 != 0,
|
|
||||||
truncated = (flags.toInt() shr 9) and 0b1 != 0,
|
|
||||||
recursionDesired = (flags.toInt() shr 8) and 0b1 != 0,
|
|
||||||
recursionAvailable = (flags.toInt() shr 7) and 0b1 != 0,
|
|
||||||
answerAuthenticated = (flags.toInt() shr 5) and 0b1 != 0,
|
|
||||||
nonAuthenticatedData = (flags.toInt() shr 4) and 0b1 != 0,
|
|
||||||
responseCode = DnsResponseCode.entries[flags.toInt() and 0b1111]
|
|
||||||
),
|
|
||||||
questions = questions,
|
|
||||||
answers = answers,
|
|
||||||
authorities = authorities,
|
|
||||||
additionals = additionals
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,110 +0,0 @@
|
|||||||
package com.futo.platformplayer.mdns
|
|
||||||
|
|
||||||
import com.futo.platformplayer.mdns.Extensions.readDomainName
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.nio.ByteOrder
|
|
||||||
|
|
||||||
|
|
||||||
enum class QuestionType(val value: UShort) {
|
|
||||||
A(1u),
|
|
||||||
NS(2u),
|
|
||||||
MD(3u),
|
|
||||||
MF(4u),
|
|
||||||
CNAME(5u),
|
|
||||||
SOA(6u),
|
|
||||||
MB(7u),
|
|
||||||
MG(8u),
|
|
||||||
MR(9u),
|
|
||||||
NULL(10u),
|
|
||||||
WKS(11u),
|
|
||||||
PTR(12u),
|
|
||||||
HINFO(13u),
|
|
||||||
MINFO(14u),
|
|
||||||
MX(15u),
|
|
||||||
TXT(16u),
|
|
||||||
RP(17u),
|
|
||||||
AFSDB(18u),
|
|
||||||
SIG(24u),
|
|
||||||
KEY(25u),
|
|
||||||
AAAA(28u),
|
|
||||||
LOC(29u),
|
|
||||||
SRV(33u),
|
|
||||||
NAPTR(35u),
|
|
||||||
KX(36u),
|
|
||||||
CERT(37u),
|
|
||||||
DNAME(39u),
|
|
||||||
APL(42u),
|
|
||||||
DS(43u),
|
|
||||||
SSHFP(44u),
|
|
||||||
IPSECKEY(45u),
|
|
||||||
RRSIG(46u),
|
|
||||||
NSEC(47u),
|
|
||||||
DNSKEY(48u),
|
|
||||||
DHCID(49u),
|
|
||||||
NSEC3(50u),
|
|
||||||
NSEC3PARAM(51u),
|
|
||||||
TSLA(52u),
|
|
||||||
SMIMEA(53u),
|
|
||||||
HIP(55u),
|
|
||||||
CDS(59u),
|
|
||||||
CDNSKEY(60u),
|
|
||||||
OPENPGPKEY(61u),
|
|
||||||
CSYNC(62u),
|
|
||||||
ZONEMD(63u),
|
|
||||||
SVCB(64u),
|
|
||||||
HTTPS(65u),
|
|
||||||
EUI48(108u),
|
|
||||||
EUI64(109u),
|
|
||||||
TKEY(249u),
|
|
||||||
TSIG(250u),
|
|
||||||
URI(256u),
|
|
||||||
CAA(257u),
|
|
||||||
TA(32768u),
|
|
||||||
DLV(32769u),
|
|
||||||
AXFR(252u),
|
|
||||||
IXFR(251u),
|
|
||||||
OPT(41u),
|
|
||||||
MAILB(253u),
|
|
||||||
MALA(254u),
|
|
||||||
All(252u)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class QuestionClass(val value: UShort) {
|
|
||||||
IN(1u),
|
|
||||||
CS(2u),
|
|
||||||
CH(3u),
|
|
||||||
HS(4u),
|
|
||||||
All(255u)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class DnsQuestion(
|
|
||||||
override val name: String,
|
|
||||||
override val type: Int,
|
|
||||||
override val clazz: Int,
|
|
||||||
val queryUnicast: Boolean
|
|
||||||
) : DnsResourceRecordBase(name, type, clazz) {
|
|
||||||
companion object {
|
|
||||||
fun parse(data: ByteArray, startPosition: Int): Pair<DnsQuestion, Int> {
|
|
||||||
val span = data.asUByteArray()
|
|
||||||
var position = startPosition
|
|
||||||
val qname = span.readDomainName(position).also { position = it.second }
|
|
||||||
val qtype = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
|
||||||
position += 2
|
|
||||||
val qclass = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
|
||||||
position += 2
|
|
||||||
|
|
||||||
return DnsQuestion(
|
|
||||||
name = qname.first,
|
|
||||||
type = qtype.toInt(),
|
|
||||||
queryUnicast = ((qclass.toInt() shr 15) and 0b1) != 0,
|
|
||||||
clazz = qclass.toInt() and 0b111111111111111
|
|
||||||
) to position
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
open class DnsResourceRecordBase(
|
|
||||||
open val name: String,
|
|
||||||
open val type: Int,
|
|
||||||
open val clazz: Int
|
|
||||||
)
|
|
@ -1,514 +0,0 @@
|
|||||||
package com.futo.platformplayer.mdns
|
|
||||||
|
|
||||||
import com.futo.platformplayer.mdns.Extensions.readDomainName
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.nio.ByteOrder
|
|
||||||
import java.nio.charset.StandardCharsets
|
|
||||||
import kotlin.math.pow
|
|
||||||
import java.net.InetAddress
|
|
||||||
|
|
||||||
data class PTRRecord(val domainName: String)
|
|
||||||
|
|
||||||
data class ARecord(val address: InetAddress)
|
|
||||||
|
|
||||||
data class AAAARecord(val address: InetAddress)
|
|
||||||
|
|
||||||
data class MXRecord(val preference: UShort, val exchange: String)
|
|
||||||
|
|
||||||
data class CNAMERecord(val cname: String)
|
|
||||||
|
|
||||||
data class TXTRecord(val texts: List<String>)
|
|
||||||
|
|
||||||
data class SOARecord(
|
|
||||||
val primaryNameServer: String,
|
|
||||||
val responsibleAuthorityMailbox: String,
|
|
||||||
val serialNumber: Int,
|
|
||||||
val refreshInterval: Int,
|
|
||||||
val retryInterval: Int,
|
|
||||||
val expiryLimit: Int,
|
|
||||||
val minimumTTL: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
data class SRVRecord(val priority: UShort, val weight: UShort, val port: UShort, val target: String)
|
|
||||||
|
|
||||||
data class NSRecord(val nameServer: String)
|
|
||||||
|
|
||||||
data class CAARecord(val flags: Byte, val tag: String, val value: String)
|
|
||||||
|
|
||||||
data class HINFORecord(val cpu: String, val os: String)
|
|
||||||
|
|
||||||
data class RPRecord(val mailbox: String, val txtDomainName: String)
|
|
||||||
|
|
||||||
|
|
||||||
data class AFSDBRecord(val subtype: UShort, val hostname: String)
|
|
||||||
data class LOCRecord(
|
|
||||||
val version: Byte,
|
|
||||||
val size: Double,
|
|
||||||
val horizontalPrecision: Double,
|
|
||||||
val verticalPrecision: Double,
|
|
||||||
val latitude: Double,
|
|
||||||
val longitude: Double,
|
|
||||||
val altitude: Double
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun decodeSizeOrPrecision(coded: Byte): Double {
|
|
||||||
val baseValue = (coded.toInt() shr 4) and 0x0F
|
|
||||||
val exponent = coded.toInt() and 0x0F
|
|
||||||
return baseValue * 10.0.pow(exponent.toDouble())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun decodeLatitudeOrLongitude(coded: Int): Double {
|
|
||||||
val arcSeconds = coded / 1E3
|
|
||||||
return arcSeconds / 3600.0
|
|
||||||
}
|
|
||||||
|
|
||||||
fun decodeAltitude(coded: Int): Double {
|
|
||||||
return (coded / 100.0) - 100000.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class NAPTRRecord(
|
|
||||||
val order: UShort,
|
|
||||||
val preference: UShort,
|
|
||||||
val flags: String,
|
|
||||||
val services: String,
|
|
||||||
val regexp: String,
|
|
||||||
val replacement: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class RRSIGRecord(
|
|
||||||
val typeCovered: UShort,
|
|
||||||
val algorithm: Byte,
|
|
||||||
val labels: Byte,
|
|
||||||
val originalTTL: UInt,
|
|
||||||
val signatureExpiration: UInt,
|
|
||||||
val signatureInception: UInt,
|
|
||||||
val keyTag: UShort,
|
|
||||||
val signersName: String,
|
|
||||||
val signature: ByteArray
|
|
||||||
)
|
|
||||||
|
|
||||||
data class KXRecord(val preference: UShort, val exchanger: String)
|
|
||||||
|
|
||||||
data class CERTRecord(val type: UShort, val keyTag: UShort, val algorithm: Byte, val certificate: ByteArray)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
data class DNAMERecord(val target: String)
|
|
||||||
|
|
||||||
data class DSRecord(val keyTag: UShort, val algorithm: Byte, val digestType: Byte, val digest: ByteArray)
|
|
||||||
|
|
||||||
data class SSHFPRecord(val algorithm: Byte, val fingerprintType: Byte, val fingerprint: ByteArray)
|
|
||||||
|
|
||||||
data class TLSARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray)
|
|
||||||
|
|
||||||
data class SMIMEARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray)
|
|
||||||
|
|
||||||
data class URIRecord(val priority: UShort, val weight: UShort, val target: String)
|
|
||||||
|
|
||||||
data class NSECRecord(val ownerName: String, val typeBitMaps: List<Pair<Byte, ByteArray>>)
|
|
||||||
data class NSEC3Record(
|
|
||||||
val hashAlgorithm: Byte,
|
|
||||||
val flags: Byte,
|
|
||||||
val iterations: UShort,
|
|
||||||
val salt: ByteArray,
|
|
||||||
val nextHashedOwnerName: ByteArray,
|
|
||||||
val typeBitMaps: List<UShort>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class NSEC3PARAMRecord(val hashAlgorithm: Byte, val flags: Byte, val iterations: UShort, val salt: ByteArray)
|
|
||||||
data class SPFRecord(val texts: List<String>)
|
|
||||||
data class TKEYRecord(
|
|
||||||
val algorithm: String,
|
|
||||||
val inception: UInt,
|
|
||||||
val expiration: UInt,
|
|
||||||
val mode: UShort,
|
|
||||||
val error: UShort,
|
|
||||||
val keyData: ByteArray,
|
|
||||||
val otherData: ByteArray
|
|
||||||
)
|
|
||||||
|
|
||||||
data class TSIGRecord(
|
|
||||||
val algorithmName: String,
|
|
||||||
val timeSigned: UInt,
|
|
||||||
val fudge: UShort,
|
|
||||||
val mac: ByteArray,
|
|
||||||
val originalID: UShort,
|
|
||||||
val error: UShort,
|
|
||||||
val otherData: ByteArray
|
|
||||||
)
|
|
||||||
|
|
||||||
data class OPTRecordOption(val code: UShort, val data: ByteArray)
|
|
||||||
data class OPTRecord(val options: List<OPTRecordOption>)
|
|
||||||
|
|
||||||
class DnsReader(private val data: ByteArray, private var position: Int = 0, private val length: Int = data.size) {
|
|
||||||
|
|
||||||
private val endPosition: Int = position + length
|
|
||||||
|
|
||||||
fun readDomainName(): String {
|
|
||||||
return data.asUByteArray().readDomainName(position).also { position = it.second }.first
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readDouble(): Double {
|
|
||||||
checkRemainingBytes(Double.SIZE_BYTES)
|
|
||||||
val result = ByteBuffer.wrap(data, position, Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).double
|
|
||||||
position += Double.SIZE_BYTES
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readInt16(): Short {
|
|
||||||
checkRemainingBytes(Short.SIZE_BYTES)
|
|
||||||
val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short
|
|
||||||
position += Short.SIZE_BYTES
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readInt32(): Int {
|
|
||||||
checkRemainingBytes(Int.SIZE_BYTES)
|
|
||||||
val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int
|
|
||||||
position += Int.SIZE_BYTES
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readInt64(): Long {
|
|
||||||
checkRemainingBytes(Long.SIZE_BYTES)
|
|
||||||
val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long
|
|
||||||
position += Long.SIZE_BYTES
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readSingle(): Float {
|
|
||||||
checkRemainingBytes(Float.SIZE_BYTES)
|
|
||||||
val result = ByteBuffer.wrap(data, position, Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).float
|
|
||||||
position += Float.SIZE_BYTES
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readByte(): Byte {
|
|
||||||
checkRemainingBytes(Byte.SIZE_BYTES)
|
|
||||||
return data[position++]
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readBytes(length: Int): ByteArray {
|
|
||||||
checkRemainingBytes(length)
|
|
||||||
return ByteArray(length).also { data.copyInto(it, startIndex = position, endIndex = position + length) }
|
|
||||||
.also { position += length }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readUInt16(): UShort {
|
|
||||||
checkRemainingBytes(Short.SIZE_BYTES)
|
|
||||||
val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short.toUShort()
|
|
||||||
position += Short.SIZE_BYTES
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readUInt32(): UInt {
|
|
||||||
checkRemainingBytes(Int.SIZE_BYTES)
|
|
||||||
val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int.toUInt()
|
|
||||||
position += Int.SIZE_BYTES
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readUInt64(): ULong {
|
|
||||||
checkRemainingBytes(Long.SIZE_BYTES)
|
|
||||||
val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long.toULong()
|
|
||||||
position += Long.SIZE_BYTES
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readString(): String {
|
|
||||||
val length = data[position++].toInt()
|
|
||||||
checkRemainingBytes(length)
|
|
||||||
return String(data, position, length, StandardCharsets.UTF_8).also { position += length }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkRemainingBytes(requiredBytes: Int) {
|
|
||||||
if (position + requiredBytes > endPosition) throw IndexOutOfBoundsException()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readRPRecord(): RPRecord {
|
|
||||||
return RPRecord(readDomainName(), readDomainName())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readKXRecord(): KXRecord {
|
|
||||||
val preference = readUInt16()
|
|
||||||
val exchanger = readDomainName()
|
|
||||||
return KXRecord(preference, exchanger)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readCERTRecord(): CERTRecord {
|
|
||||||
val type = readUInt16()
|
|
||||||
val keyTag = readUInt16()
|
|
||||||
val algorithm = readByte()
|
|
||||||
val certificateLength = readUInt16().toInt() - 5
|
|
||||||
val certificate = readBytes(certificateLength)
|
|
||||||
return CERTRecord(type, keyTag, algorithm, certificate)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readPTRRecord(): PTRRecord {
|
|
||||||
return PTRRecord(readDomainName())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readARecord(): ARecord {
|
|
||||||
val address = readBytes(4)
|
|
||||||
return ARecord(InetAddress.getByAddress(address))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readAAAARecord(): AAAARecord {
|
|
||||||
val address = readBytes(16)
|
|
||||||
return AAAARecord(InetAddress.getByAddress(address))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readMXRecord(): MXRecord {
|
|
||||||
val preference = readUInt16()
|
|
||||||
val exchange = readDomainName()
|
|
||||||
return MXRecord(preference, exchange)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readCNAMERecord(): CNAMERecord {
|
|
||||||
return CNAMERecord(readDomainName())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readTXTRecord(): TXTRecord {
|
|
||||||
val texts = mutableListOf<String>()
|
|
||||||
while (position < endPosition) {
|
|
||||||
val textLength = data[position++].toInt()
|
|
||||||
checkRemainingBytes(textLength)
|
|
||||||
val text = String(data, position, textLength, StandardCharsets.UTF_8)
|
|
||||||
texts.add(text)
|
|
||||||
position += textLength
|
|
||||||
}
|
|
||||||
return TXTRecord(texts)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readSOARecord(): SOARecord {
|
|
||||||
val primaryNameServer = readDomainName()
|
|
||||||
val responsibleAuthorityMailbox = readDomainName()
|
|
||||||
val serialNumber = readInt32()
|
|
||||||
val refreshInterval = readInt32()
|
|
||||||
val retryInterval = readInt32()
|
|
||||||
val expiryLimit = readInt32()
|
|
||||||
val minimumTTL = readInt32()
|
|
||||||
return SOARecord(primaryNameServer, responsibleAuthorityMailbox, serialNumber, refreshInterval, retryInterval, expiryLimit, minimumTTL)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readSRVRecord(): SRVRecord {
|
|
||||||
val priority = readUInt16()
|
|
||||||
val weight = readUInt16()
|
|
||||||
val port = readUInt16()
|
|
||||||
val target = readDomainName()
|
|
||||||
return SRVRecord(priority, weight, port, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readNSRecord(): NSRecord {
|
|
||||||
return NSRecord(readDomainName())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readCAARecord(): CAARecord {
|
|
||||||
val length = readUInt16().toInt()
|
|
||||||
val flags = readByte()
|
|
||||||
val tagLength = readByte().toInt()
|
|
||||||
val tag = String(data, position, tagLength, StandardCharsets.US_ASCII).also { position += tagLength }
|
|
||||||
val valueLength = length - 1 - 1 - tagLength
|
|
||||||
val value = String(data, position, valueLength, StandardCharsets.US_ASCII).also { position += valueLength }
|
|
||||||
return CAARecord(flags, tag, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readHINFORecord(): HINFORecord {
|
|
||||||
val cpuLength = readByte().toInt()
|
|
||||||
val cpu = String(data, position, cpuLength, StandardCharsets.US_ASCII).also { position += cpuLength }
|
|
||||||
val osLength = readByte().toInt()
|
|
||||||
val os = String(data, position, osLength, StandardCharsets.US_ASCII).also { position += osLength }
|
|
||||||
return HINFORecord(cpu, os)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readAFSDBRecord(): AFSDBRecord {
|
|
||||||
return AFSDBRecord(readUInt16(), readDomainName())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readLOCRecord(): LOCRecord {
|
|
||||||
val version = readByte()
|
|
||||||
val size = LOCRecord.decodeSizeOrPrecision(readByte())
|
|
||||||
val horizontalPrecision = LOCRecord.decodeSizeOrPrecision(readByte())
|
|
||||||
val verticalPrecision = LOCRecord.decodeSizeOrPrecision(readByte())
|
|
||||||
val latitudeCoded = readInt32()
|
|
||||||
val longitudeCoded = readInt32()
|
|
||||||
val altitudeCoded = readInt32()
|
|
||||||
val latitude = LOCRecord.decodeLatitudeOrLongitude(latitudeCoded)
|
|
||||||
val longitude = LOCRecord.decodeLatitudeOrLongitude(longitudeCoded)
|
|
||||||
val altitude = LOCRecord.decodeAltitude(altitudeCoded)
|
|
||||||
return LOCRecord(version, size, horizontalPrecision, verticalPrecision, latitude, longitude, altitude)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readNAPTRRecord(): NAPTRRecord {
|
|
||||||
val order = readUInt16()
|
|
||||||
val preference = readUInt16()
|
|
||||||
val flags = readString()
|
|
||||||
val services = readString()
|
|
||||||
val regexp = readString()
|
|
||||||
val replacement = readDomainName()
|
|
||||||
return NAPTRRecord(order, preference, flags, services, regexp, replacement)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readDNAMERecord(): DNAMERecord {
|
|
||||||
return DNAMERecord(readDomainName())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readDSRecord(): DSRecord {
|
|
||||||
val keyTag = readUInt16()
|
|
||||||
val algorithm = readByte()
|
|
||||||
val digestType = readByte()
|
|
||||||
val digestLength = readUInt16().toInt() - 4
|
|
||||||
val digest = readBytes(digestLength)
|
|
||||||
return DSRecord(keyTag, algorithm, digestType, digest)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readSSHFPRecord(): SSHFPRecord {
|
|
||||||
val algorithm = readByte()
|
|
||||||
val fingerprintType = readByte()
|
|
||||||
val fingerprintLength = readUInt16().toInt() - 2
|
|
||||||
val fingerprint = readBytes(fingerprintLength)
|
|
||||||
return SSHFPRecord(algorithm, fingerprintType, fingerprint)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readTLSARecord(): TLSARecord {
|
|
||||||
val usage = readByte()
|
|
||||||
val selector = readByte()
|
|
||||||
val matchingType = readByte()
|
|
||||||
val dataLength = readUInt16().toInt() - 3
|
|
||||||
val certificateAssociationData = readBytes(dataLength)
|
|
||||||
return TLSARecord(usage, selector, matchingType, certificateAssociationData)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readSMIMEARecord(): SMIMEARecord {
|
|
||||||
val usage = readByte()
|
|
||||||
val selector = readByte()
|
|
||||||
val matchingType = readByte()
|
|
||||||
val dataLength = readUInt16().toInt() - 3
|
|
||||||
val certificateAssociationData = readBytes(dataLength)
|
|
||||||
return SMIMEARecord(usage, selector, matchingType, certificateAssociationData)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readURIRecord(): URIRecord {
|
|
||||||
val priority = readUInt16()
|
|
||||||
val weight = readUInt16()
|
|
||||||
val length = readUInt16().toInt()
|
|
||||||
val target = String(data, position, length, StandardCharsets.US_ASCII).also { position += length }
|
|
||||||
return URIRecord(priority, weight, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readRRSIGRecord(): RRSIGRecord {
|
|
||||||
val typeCovered = readUInt16()
|
|
||||||
val algorithm = readByte()
|
|
||||||
val labels = readByte()
|
|
||||||
val originalTTL = readUInt32()
|
|
||||||
val signatureExpiration = readUInt32()
|
|
||||||
val signatureInception = readUInt32()
|
|
||||||
val keyTag = readUInt16()
|
|
||||||
val signersName = readDomainName()
|
|
||||||
val signatureLength = readUInt16().toInt()
|
|
||||||
val signature = readBytes(signatureLength)
|
|
||||||
return RRSIGRecord(
|
|
||||||
typeCovered,
|
|
||||||
algorithm,
|
|
||||||
labels,
|
|
||||||
originalTTL,
|
|
||||||
signatureExpiration,
|
|
||||||
signatureInception,
|
|
||||||
keyTag,
|
|
||||||
signersName,
|
|
||||||
signature
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readNSECRecord(): NSECRecord {
|
|
||||||
val ownerName = readDomainName()
|
|
||||||
val typeBitMaps = mutableListOf<Pair<Byte, ByteArray>>()
|
|
||||||
while (position < endPosition) {
|
|
||||||
val windowBlock = readByte()
|
|
||||||
val bitmapLength = readByte().toInt()
|
|
||||||
val bitmap = readBytes(bitmapLength)
|
|
||||||
typeBitMaps.add(windowBlock to bitmap)
|
|
||||||
}
|
|
||||||
return NSECRecord(ownerName, typeBitMaps)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readNSEC3Record(): NSEC3Record {
|
|
||||||
val hashAlgorithm = readByte()
|
|
||||||
val flags = readByte()
|
|
||||||
val iterations = readUInt16()
|
|
||||||
val saltLength = readByte().toInt()
|
|
||||||
val salt = readBytes(saltLength)
|
|
||||||
val hashLength = readByte().toInt()
|
|
||||||
val nextHashedOwnerName = readBytes(hashLength)
|
|
||||||
val bitMapLength = readUInt16().toInt()
|
|
||||||
val typeBitMaps = mutableListOf<UShort>()
|
|
||||||
val endPos = position + bitMapLength
|
|
||||||
while (position < endPos) {
|
|
||||||
typeBitMaps.add(readUInt16())
|
|
||||||
}
|
|
||||||
return NSEC3Record(hashAlgorithm, flags, iterations, salt, nextHashedOwnerName, typeBitMaps)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readNSEC3PARAMRecord(): NSEC3PARAMRecord {
|
|
||||||
val hashAlgorithm = readByte()
|
|
||||||
val flags = readByte()
|
|
||||||
val iterations = readUInt16()
|
|
||||||
val saltLength = readByte().toInt()
|
|
||||||
val salt = readBytes(saltLength)
|
|
||||||
return NSEC3PARAMRecord(hashAlgorithm, flags, iterations, salt)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun readSPFRecord(): SPFRecord {
|
|
||||||
val length = readUInt16().toInt()
|
|
||||||
val texts = mutableListOf<String>()
|
|
||||||
val endPos = position + length
|
|
||||||
while (position < endPos) {
|
|
||||||
val textLength = readByte().toInt()
|
|
||||||
val text = String(data, position, textLength, StandardCharsets.US_ASCII).also { position += textLength }
|
|
||||||
texts.add(text)
|
|
||||||
}
|
|
||||||
return SPFRecord(texts)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readTKEYRecord(): TKEYRecord {
|
|
||||||
val algorithm = readDomainName()
|
|
||||||
val inception = readUInt32()
|
|
||||||
val expiration = readUInt32()
|
|
||||||
val mode = readUInt16()
|
|
||||||
val error = readUInt16()
|
|
||||||
val keySize = readUInt16().toInt()
|
|
||||||
val keyData = readBytes(keySize)
|
|
||||||
val otherSize = readUInt16().toInt()
|
|
||||||
val otherData = readBytes(otherSize)
|
|
||||||
return TKEYRecord(algorithm, inception, expiration, mode, error, keyData, otherData)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readTSIGRecord(): TSIGRecord {
|
|
||||||
val algorithmName = readDomainName()
|
|
||||||
val timeSigned = readUInt32()
|
|
||||||
val fudge = readUInt16()
|
|
||||||
val macSize = readUInt16().toInt()
|
|
||||||
val mac = readBytes(macSize)
|
|
||||||
val originalID = readUInt16()
|
|
||||||
val error = readUInt16()
|
|
||||||
val otherSize = readUInt16().toInt()
|
|
||||||
val otherData = readBytes(otherSize)
|
|
||||||
return TSIGRecord(algorithmName, timeSigned, fudge, mac, originalID, error, otherData)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fun readOPTRecord(): OPTRecord {
|
|
||||||
val options = mutableListOf<OPTRecordOption>()
|
|
||||||
while (position < endPosition) {
|
|
||||||
val optionCode = readUInt16()
|
|
||||||
val optionLength = readUInt16().toInt()
|
|
||||||
val optionData = readBytes(optionLength)
|
|
||||||
options.add(OPTRecordOption(optionCode, optionData))
|
|
||||||
}
|
|
||||||
return OPTRecord(options)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,117 +0,0 @@
|
|||||||
package com.futo.platformplayer.mdns
|
|
||||||
|
|
||||||
import com.futo.platformplayer.mdns.Extensions.readDomainName
|
|
||||||
|
|
||||||
enum class ResourceRecordType(val value: UShort) {
|
|
||||||
None(0u),
|
|
||||||
A(1u),
|
|
||||||
NS(2u),
|
|
||||||
MD(3u),
|
|
||||||
MF(4u),
|
|
||||||
CNAME(5u),
|
|
||||||
SOA(6u),
|
|
||||||
MB(7u),
|
|
||||||
MG(8u),
|
|
||||||
MR(9u),
|
|
||||||
NULL(10u),
|
|
||||||
WKS(11u),
|
|
||||||
PTR(12u),
|
|
||||||
HINFO(13u),
|
|
||||||
MINFO(14u),
|
|
||||||
MX(15u),
|
|
||||||
TXT(16u),
|
|
||||||
RP(17u),
|
|
||||||
AFSDB(18u),
|
|
||||||
SIG(24u),
|
|
||||||
KEY(25u),
|
|
||||||
AAAA(28u),
|
|
||||||
LOC(29u),
|
|
||||||
SRV(33u),
|
|
||||||
NAPTR(35u),
|
|
||||||
KX(36u),
|
|
||||||
CERT(37u),
|
|
||||||
DNAME(39u),
|
|
||||||
APL(42u),
|
|
||||||
DS(43u),
|
|
||||||
SSHFP(44u),
|
|
||||||
IPSECKEY(45u),
|
|
||||||
RRSIG(46u),
|
|
||||||
NSEC(47u),
|
|
||||||
DNSKEY(48u),
|
|
||||||
DHCID(49u),
|
|
||||||
NSEC3(50u),
|
|
||||||
NSEC3PARAM(51u),
|
|
||||||
TSLA(52u),
|
|
||||||
SMIMEA(53u),
|
|
||||||
HIP(55u),
|
|
||||||
CDS(59u),
|
|
||||||
CDNSKEY(60u),
|
|
||||||
OPENPGPKEY(61u),
|
|
||||||
CSYNC(62u),
|
|
||||||
ZONEMD(63u),
|
|
||||||
SVCB(64u),
|
|
||||||
HTTPS(65u),
|
|
||||||
EUI48(108u),
|
|
||||||
EUI64(109u),
|
|
||||||
TKEY(249u),
|
|
||||||
TSIG(250u),
|
|
||||||
URI(256u),
|
|
||||||
CAA(257u),
|
|
||||||
TA(32768u),
|
|
||||||
DLV(32769u),
|
|
||||||
AXFR(252u),
|
|
||||||
IXFR(251u),
|
|
||||||
OPT(41u)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class ResourceRecordClass(val value: UShort) {
|
|
||||||
IN(1u),
|
|
||||||
CS(2u),
|
|
||||||
CH(3u),
|
|
||||||
HS(4u)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class DnsResourceRecord(
|
|
||||||
override val name: String,
|
|
||||||
override val type: Int,
|
|
||||||
override val clazz: Int,
|
|
||||||
val timeToLive: UInt,
|
|
||||||
val cacheFlush: Boolean,
|
|
||||||
val dataPosition: Int = -1,
|
|
||||||
val dataLength: Int = -1,
|
|
||||||
private val data: ByteArray? = null
|
|
||||||
) : DnsResourceRecordBase(name, type, clazz) {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun parse(data: ByteArray, startPosition: Int): Pair<DnsResourceRecord, Int> {
|
|
||||||
val span = data.asUByteArray()
|
|
||||||
var position = startPosition
|
|
||||||
val name = span.readDomainName(position).also { position = it.second }
|
|
||||||
val type = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
|
||||||
position += 2
|
|
||||||
val clazz = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
|
||||||
position += 2
|
|
||||||
val ttl = (span[position].toInt() shl 24 or (span[position + 1].toInt() shl 16) or
|
|
||||||
(span[position + 2].toInt() shl 8) or span[position + 3].toInt()).toUInt()
|
|
||||||
position += 4
|
|
||||||
val rdlength = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
|
||||||
val rdposition = position + 2
|
|
||||||
position += 2 + rdlength.toInt()
|
|
||||||
|
|
||||||
return DnsResourceRecord(
|
|
||||||
name = name.first,
|
|
||||||
type = type.toInt(),
|
|
||||||
clazz = clazz.toInt() and 0b1111111_11111111,
|
|
||||||
timeToLive = ttl,
|
|
||||||
cacheFlush = ((clazz.toInt() shr 15) and 0b1) != 0,
|
|
||||||
dataPosition = rdposition,
|
|
||||||
dataLength = rdlength.toInt(),
|
|
||||||
data = data
|
|
||||||
) to position
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDataReader(): DnsReader {
|
|
||||||
return DnsReader(data!!, dataPosition, dataLength)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,208 +0,0 @@
|
|||||||
package com.futo.platformplayer.mdns
|
|
||||||
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.nio.ByteOrder
|
|
||||||
import java.nio.charset.StandardCharsets
|
|
||||||
|
|
||||||
class DnsWriter {
|
|
||||||
private val data = mutableListOf<Byte>()
|
|
||||||
private val namePositions = mutableMapOf<String, Int>()
|
|
||||||
|
|
||||||
fun toByteArray(): ByteArray = data.toByteArray()
|
|
||||||
|
|
||||||
fun writePacket(
|
|
||||||
header: DnsPacketHeader,
|
|
||||||
questionCount: Int? = null, questionWriter: ((DnsWriter, Int) -> Unit)? = null,
|
|
||||||
answerCount: Int? = null, answerWriter: ((DnsWriter, Int) -> Unit)? = null,
|
|
||||||
authorityCount: Int? = null, authorityWriter: ((DnsWriter, Int) -> Unit)? = null,
|
|
||||||
additionalsCount: Int? = null, additionalWriter: ((DnsWriter, Int) -> Unit)? = null
|
|
||||||
) {
|
|
||||||
if (questionCount != null && questionWriter == null || questionCount == null && questionWriter != null)
|
|
||||||
throw Exception("When question count is given, question writer should also be given.")
|
|
||||||
if (answerCount != null && answerWriter == null || answerCount == null && answerWriter != null)
|
|
||||||
throw Exception("When answer count is given, answer writer should also be given.")
|
|
||||||
if (authorityCount != null && authorityWriter == null || authorityCount == null && authorityWriter != null)
|
|
||||||
throw Exception("When authority count is given, authority writer should also be given.")
|
|
||||||
if (additionalsCount != null && additionalWriter == null || additionalsCount == null && additionalWriter != null)
|
|
||||||
throw Exception("When additionals count is given, additional writer should also be given.")
|
|
||||||
|
|
||||||
writeHeader(header, questionCount ?: 0, answerCount ?: 0, authorityCount ?: 0, additionalsCount ?: 0)
|
|
||||||
|
|
||||||
repeat(questionCount ?: 0) { questionWriter?.invoke(this, it) }
|
|
||||||
repeat(answerCount ?: 0) { answerWriter?.invoke(this, it) }
|
|
||||||
repeat(authorityCount ?: 0) { authorityWriter?.invoke(this, it) }
|
|
||||||
repeat(additionalsCount ?: 0) { additionalWriter?.invoke(this, it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeHeader(header: DnsPacketHeader, questionCount: Int, answerCount: Int, authorityCount: Int, additionalsCount: Int) {
|
|
||||||
write(header.identifier)
|
|
||||||
|
|
||||||
var flags: UShort = 0u
|
|
||||||
flags = flags or ((header.queryResponse.toUInt() and 0xFFFFu) shl 15).toUShort()
|
|
||||||
flags = flags or ((header.opcode.toUInt() and 0xFFFFu) shl 11).toUShort()
|
|
||||||
flags = flags or ((if (header.authoritativeAnswer) 1u else 0u) shl 10).toUShort()
|
|
||||||
flags = flags or ((if (header.truncated) 1u else 0u) shl 9).toUShort()
|
|
||||||
flags = flags or ((if (header.recursionDesired) 1u else 0u) shl 8).toUShort()
|
|
||||||
flags = flags or ((if (header.recursionAvailable) 1u else 0u) shl 7).toUShort()
|
|
||||||
flags = flags or ((if (header.answerAuthenticated) 1u else 0u) shl 5).toUShort()
|
|
||||||
flags = flags or ((if (header.nonAuthenticatedData) 1u else 0u) shl 4).toUShort()
|
|
||||||
flags = flags or header.responseCode.value.toUShort()
|
|
||||||
write(flags)
|
|
||||||
|
|
||||||
write(questionCount.toUShort())
|
|
||||||
write(answerCount.toUShort())
|
|
||||||
write(authorityCount.toUShort())
|
|
||||||
write(additionalsCount.toUShort())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeDomainName(name: String) {
|
|
||||||
synchronized(namePositions) {
|
|
||||||
val labels = name.split('.')
|
|
||||||
for (label in labels) {
|
|
||||||
val nameAtOffset = name.substring(name.indexOf(label))
|
|
||||||
if (namePositions.containsKey(nameAtOffset)) {
|
|
||||||
val position = namePositions[nameAtOffset]!!
|
|
||||||
val pointer = (0b11000000_00000000 or position).toUShort()
|
|
||||||
write(pointer)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (label.isNotEmpty()) {
|
|
||||||
val labelBytes = label.toByteArray(StandardCharsets.UTF_8)
|
|
||||||
val nameStartPos = data.size
|
|
||||||
write(labelBytes.size.toByte())
|
|
||||||
write(labelBytes)
|
|
||||||
namePositions[nameAtOffset] = nameStartPos
|
|
||||||
}
|
|
||||||
}
|
|
||||||
write(0.toByte())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: DnsResourceRecord, dataWriter: (DnsWriter) -> Unit) {
|
|
||||||
writeDomainName(value.name)
|
|
||||||
write(value.type.toUShort())
|
|
||||||
val cls = ((if (value.cacheFlush) 1u else 0u) shl 15).toUShort() or value.clazz.toUShort()
|
|
||||||
write(cls)
|
|
||||||
write(value.timeToLive)
|
|
||||||
|
|
||||||
val lengthOffset = data.size
|
|
||||||
write(0.toUShort())
|
|
||||||
dataWriter(this)
|
|
||||||
val rdLength = data.size - lengthOffset - 2
|
|
||||||
val rdLengthBytes = ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN).putShort(rdLength.toShort()).array()
|
|
||||||
data[lengthOffset] = rdLengthBytes[0]
|
|
||||||
data[lengthOffset + 1] = rdLengthBytes[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: DnsQuestion) {
|
|
||||||
writeDomainName(value.name)
|
|
||||||
write(value.type.toUShort())
|
|
||||||
write(((if (value.queryUnicast) 1u else 0u shl 15).toUShort() or value.clazz.toUShort()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: Double) {
|
|
||||||
val bytes = ByteBuffer.allocate(Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putDouble(value).array()
|
|
||||||
write(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: Short) {
|
|
||||||
val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value).array()
|
|
||||||
write(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: Int) {
|
|
||||||
val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value).array()
|
|
||||||
write(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: Long) {
|
|
||||||
val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value).array()
|
|
||||||
write(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: Float) {
|
|
||||||
val bytes = ByteBuffer.allocate(Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putFloat(value).array()
|
|
||||||
write(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: Byte) {
|
|
||||||
data.add(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: ByteArray) {
|
|
||||||
data.addAll(value.asIterable())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: ByteArray, offset: Int, length: Int) {
|
|
||||||
data.addAll(value.slice(offset until offset + length))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: UShort) {
|
|
||||||
val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value.toShort()).array()
|
|
||||||
write(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: UInt) {
|
|
||||||
val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value.toInt()).array()
|
|
||||||
write(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: ULong) {
|
|
||||||
val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value.toLong()).array()
|
|
||||||
write(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: String) {
|
|
||||||
val bytes = value.toByteArray(StandardCharsets.UTF_8)
|
|
||||||
write(bytes.size.toByte())
|
|
||||||
write(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: PTRRecord) {
|
|
||||||
writeDomainName(value.domainName)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: ARecord) {
|
|
||||||
val bytes = value.address.address
|
|
||||||
if (bytes.size != 4) throw Exception("Unexpected amount of address bytes.")
|
|
||||||
write(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: AAAARecord) {
|
|
||||||
val bytes = value.address.address
|
|
||||||
if (bytes.size != 16) throw Exception("Unexpected amount of address bytes.")
|
|
||||||
write(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: TXTRecord) {
|
|
||||||
value.texts.forEach {
|
|
||||||
val bytes = it.toByteArray(StandardCharsets.UTF_8)
|
|
||||||
write(bytes.size.toByte())
|
|
||||||
write(bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: SRVRecord) {
|
|
||||||
write(value.priority)
|
|
||||||
write(value.weight)
|
|
||||||
write(value.port)
|
|
||||||
writeDomainName(value.target)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: NSECRecord) {
|
|
||||||
writeDomainName(value.ownerName)
|
|
||||||
value.typeBitMaps.forEach { (windowBlock, bitmap) ->
|
|
||||||
write(windowBlock)
|
|
||||||
write(bitmap.size.toByte())
|
|
||||||
write(bitmap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(value: OPTRecord) {
|
|
||||||
value.options.forEach { option ->
|
|
||||||
write(option.code)
|
|
||||||
write(option.data.size.toUShort())
|
|
||||||
write(option.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,63 +0,0 @@
|
|||||||
package com.futo.platformplayer.mdns
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
|
|
||||||
object Extensions {
|
|
||||||
fun ByteArray.toByteDump(): String {
|
|
||||||
val result = StringBuilder()
|
|
||||||
for (i in indices) {
|
|
||||||
result.append(String.format("%02X ", this[i]))
|
|
||||||
|
|
||||||
if ((i + 1) % 16 == 0 || i == size - 1) {
|
|
||||||
val padding = 3 * (16 - (i % 16 + 1))
|
|
||||||
if (i == size - 1 && (i + 1) % 16 != 0) result.append(" ".repeat(padding))
|
|
||||||
|
|
||||||
result.append("; ")
|
|
||||||
val start = i - (i % 16)
|
|
||||||
val end = minOf(i, size - 1)
|
|
||||||
for (j in start..end) {
|
|
||||||
val ch = if (this[j] in 32..127) this[j].toChar() else '.'
|
|
||||||
result.append(ch)
|
|
||||||
}
|
|
||||||
if (i != size - 1) result.appendLine()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun UByteArray.readDomainName(startPosition: Int): Pair<String, Int> {
|
|
||||||
var position = startPosition
|
|
||||||
return readDomainName(position, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun UByteArray.readDomainName(position: Int, depth: Int = 0): Pair<String, Int> {
|
|
||||||
if (depth > 16) throw Exception("Exceeded maximum recursion depth in DNS packet. Possible circular reference.")
|
|
||||||
|
|
||||||
val domainParts = mutableListOf<String>()
|
|
||||||
var newPosition = position
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
if (newPosition < 0)
|
|
||||||
println()
|
|
||||||
|
|
||||||
val length = this[newPosition].toUByte()
|
|
||||||
if ((length and 0b11000000u).toUInt() == 0b11000000u) {
|
|
||||||
val offset = (((length and 0b00111111u).toUInt()) shl 8) or this[newPosition + 1].toUInt()
|
|
||||||
val (part, _) = this.readDomainName(offset.toInt(), depth + 1)
|
|
||||||
domainParts.add(part)
|
|
||||||
newPosition += 2
|
|
||||||
break
|
|
||||||
} else if (length.toUInt() == 0u) {
|
|
||||||
newPosition++
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
newPosition++
|
|
||||||
val part = String(this.asByteArray(), newPosition, length.toInt(), Charsets.UTF_8)
|
|
||||||
domainParts.add(part)
|
|
||||||
newPosition += length.toInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return domainParts.joinToString(".") to newPosition
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,495 +0,0 @@
|
|||||||
package com.futo.platformplayer.mdns
|
|
||||||
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import java.net.*
|
|
||||||
import java.util.*
|
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
|
||||||
import kotlin.concurrent.withLock
|
|
||||||
|
|
||||||
class MDNSListener {
|
|
||||||
companion object {
|
|
||||||
private val TAG = "MDNSListener"
|
|
||||||
const val MulticastPort = 5353
|
|
||||||
val MulticastAddressIPv4: InetAddress = InetAddress.getByName("224.0.0.251")
|
|
||||||
val MulticastAddressIPv6: InetAddress = InetAddress.getByName("FF02::FB")
|
|
||||||
val MdnsEndpointIPv6: InetSocketAddress = InetSocketAddress(MulticastAddressIPv6, MulticastPort)
|
|
||||||
val MdnsEndpointIPv4: InetSocketAddress = InetSocketAddress(MulticastAddressIPv4, MulticastPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val _lockObject = ReentrantLock()
|
|
||||||
private var _receiver4: MulticastSocket? = null
|
|
||||||
private var _receiver6: MulticastSocket? = null
|
|
||||||
private val _senders = mutableListOf<MulticastSocket>()
|
|
||||||
private val _nicMonitor = NICMonitor()
|
|
||||||
private val _serviceRecordAggregator = ServiceRecordAggregator()
|
|
||||||
private var _started = false
|
|
||||||
private var _threadReceiver4: Thread? = null
|
|
||||||
private var _threadReceiver6: Thread? = null
|
|
||||||
private var _scope: CoroutineScope? = null
|
|
||||||
|
|
||||||
var onPacket: ((DnsPacket) -> Unit)? = null
|
|
||||||
var onServicesUpdated: ((List<DnsService>) -> Unit)? = null
|
|
||||||
|
|
||||||
private val _recordLockObject = ReentrantLock()
|
|
||||||
private val _recordsA = mutableListOf<Pair<DnsResourceRecord, ARecord>>()
|
|
||||||
private val _recordsAAAA = mutableListOf<Pair<DnsResourceRecord, AAAARecord>>()
|
|
||||||
private val _recordsPTR = mutableListOf<Pair<DnsResourceRecord, PTRRecord>>()
|
|
||||||
private val _recordsTXT = mutableListOf<Pair<DnsResourceRecord, TXTRecord>>()
|
|
||||||
private val _recordsSRV = mutableListOf<Pair<DnsResourceRecord, SRVRecord>>()
|
|
||||||
private val _services = mutableListOf<BroadcastService>()
|
|
||||||
|
|
||||||
init {
|
|
||||||
_nicMonitor.added = { onNicsAdded(it) }
|
|
||||||
_nicMonitor.removed = { onNicsRemoved(it) }
|
|
||||||
_serviceRecordAggregator.onServicesUpdated = { onServicesUpdated?.invoke(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
if (_started) {
|
|
||||||
Logger.i(TAG, "Already started.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_started = true
|
|
||||||
|
|
||||||
_scope = CoroutineScope(Dispatchers.IO);
|
|
||||||
|
|
||||||
Logger.i(TAG, "Starting")
|
|
||||||
_lockObject.withLock {
|
|
||||||
val receiver4 = MulticastSocket(null).apply {
|
|
||||||
reuseAddress = true
|
|
||||||
bind(InetSocketAddress(InetAddress.getByName("0.0.0.0"), MulticastPort))
|
|
||||||
}
|
|
||||||
_receiver4 = receiver4
|
|
||||||
|
|
||||||
val receiver6 = MulticastSocket(null).apply {
|
|
||||||
reuseAddress = true
|
|
||||||
bind(InetSocketAddress(InetAddress.getByName("::"), MulticastPort))
|
|
||||||
}
|
|
||||||
_receiver6 = receiver6
|
|
||||||
|
|
||||||
_nicMonitor.start()
|
|
||||||
_serviceRecordAggregator.start()
|
|
||||||
onNicsAdded(_nicMonitor.current)
|
|
||||||
|
|
||||||
_threadReceiver4 = Thread {
|
|
||||||
receiveLoop(receiver4)
|
|
||||||
}.apply { start() }
|
|
||||||
|
|
||||||
_threadReceiver6 = Thread {
|
|
||||||
receiveLoop(receiver6)
|
|
||||||
}.apply { start() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun queryServices(names: Array<String>) {
|
|
||||||
if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.")
|
|
||||||
|
|
||||||
val writer = DnsWriter()
|
|
||||||
writer.writePacket(
|
|
||||||
DnsPacketHeader(
|
|
||||||
identifier = 0u,
|
|
||||||
queryResponse = QueryResponse.Query.value.toInt(),
|
|
||||||
opcode = DnsOpcode.StandardQuery.value.toInt(),
|
|
||||||
truncated = false,
|
|
||||||
nonAuthenticatedData = false,
|
|
||||||
recursionDesired = false,
|
|
||||||
answerAuthenticated = false,
|
|
||||||
authoritativeAnswer = false,
|
|
||||||
recursionAvailable = false,
|
|
||||||
responseCode = DnsResponseCode.NoError
|
|
||||||
),
|
|
||||||
questionCount = names.size,
|
|
||||||
questionWriter = { w, i ->
|
|
||||||
w.write(
|
|
||||||
DnsQuestion(
|
|
||||||
name = names[i],
|
|
||||||
type = QuestionType.PTR.value.toInt(),
|
|
||||||
clazz = QuestionClass.IN.value.toInt(),
|
|
||||||
queryUnicast = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
send(writer.toByteArray())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun send(data: ByteArray) {
|
|
||||||
_lockObject.withLock {
|
|
||||||
for (sender in _senders) {
|
|
||||||
try {
|
|
||||||
val endPoint = if (sender.localAddress is Inet4Address) MdnsEndpointIPv4 else MdnsEndpointIPv6
|
|
||||||
sender.send(DatagramPacket(data, data.size, endPoint))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.i(TAG, "Failed to send on ${sender.localSocketAddress}: ${e.message}.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun queryAllQuestions(names: Array<String>) {
|
|
||||||
if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.")
|
|
||||||
|
|
||||||
val questions = names.flatMap { _serviceRecordAggregator.getAllQuestions(it) }
|
|
||||||
questions.groupBy { it.name }.forEach { (_, questionsForHost) ->
|
|
||||||
val writer = DnsWriter()
|
|
||||||
writer.writePacket(
|
|
||||||
DnsPacketHeader(
|
|
||||||
identifier = 0u,
|
|
||||||
queryResponse = QueryResponse.Query.value.toInt(),
|
|
||||||
opcode = DnsOpcode.StandardQuery.value.toInt(),
|
|
||||||
truncated = false,
|
|
||||||
nonAuthenticatedData = false,
|
|
||||||
recursionDesired = false,
|
|
||||||
answerAuthenticated = false,
|
|
||||||
authoritativeAnswer = false,
|
|
||||||
recursionAvailable = false,
|
|
||||||
responseCode = DnsResponseCode.NoError
|
|
||||||
),
|
|
||||||
questionCount = questionsForHost.size,
|
|
||||||
questionWriter = { w, i -> w.write(questionsForHost[i]) }
|
|
||||||
)
|
|
||||||
send(writer.toByteArray())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onNicsAdded(nics: List<NetworkInterface>) {
|
|
||||||
_lockObject.withLock {
|
|
||||||
if (!_started) return
|
|
||||||
|
|
||||||
val addresses = nics.flatMap { nic ->
|
|
||||||
nic.interfaceAddresses.map { it.address }
|
|
||||||
.filter { it is Inet4Address || (it is Inet6Address && it.isLinkLocalAddress) }
|
|
||||||
}
|
|
||||||
|
|
||||||
addresses.forEach { address ->
|
|
||||||
Logger.i(TAG, "New address discovered $address")
|
|
||||||
|
|
||||||
try {
|
|
||||||
when (address) {
|
|
||||||
is Inet4Address -> {
|
|
||||||
_receiver4?.let { receiver4 ->
|
|
||||||
//receiver4.setOption(StandardSocketOptions.IP_MULTICAST_IF, NetworkInterface.getByInetAddress(address))
|
|
||||||
receiver4.joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address))
|
|
||||||
}
|
|
||||||
|
|
||||||
val sender = MulticastSocket(null).apply {
|
|
||||||
reuseAddress = true
|
|
||||||
bind(InetSocketAddress(address, MulticastPort))
|
|
||||||
joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address))
|
|
||||||
}
|
|
||||||
_senders.add(sender)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Inet6Address -> {
|
|
||||||
_receiver6?.let { receiver6 ->
|
|
||||||
//receiver6.setOption(StandardSocketOptions.IP_MULTICAST_IF, NetworkInterface.getByInetAddress(address))
|
|
||||||
receiver6.joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), NetworkInterface.getByInetAddress(address))
|
|
||||||
}
|
|
||||||
|
|
||||||
val sender = MulticastSocket(null).apply {
|
|
||||||
reuseAddress = true
|
|
||||||
bind(InetSocketAddress(address, MulticastPort))
|
|
||||||
joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), NetworkInterface.getByInetAddress(address))
|
|
||||||
}
|
|
||||||
_senders.add(sender)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> throw UnsupportedOperationException("Address type ${address.javaClass.name} is not supported.")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.i(TAG, "Exception occurred when processing added address $address: ${e.message}.")
|
|
||||||
// Close the socket if there was an error
|
|
||||||
(_senders.lastOrNull() as? MulticastSocket)?.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nics.isNotEmpty()) {
|
|
||||||
try {
|
|
||||||
updateBroadcastRecords()
|
|
||||||
broadcastRecords()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.i(TAG, "Exception occurred when broadcasting records: ${e.message}.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onNicsRemoved(nics: List<NetworkInterface>) {
|
|
||||||
_lockObject.withLock {
|
|
||||||
if (!_started) return
|
|
||||||
//TODO: Cleanup?
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nics.isNotEmpty()) {
|
|
||||||
try {
|
|
||||||
updateBroadcastRecords()
|
|
||||||
broadcastRecords()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.e(TAG, "Exception occurred when broadcasting records", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun receiveLoop(client: DatagramSocket) {
|
|
||||||
Logger.i(TAG, "Started receive loop")
|
|
||||||
|
|
||||||
val buffer = ByteArray(8972)
|
|
||||||
val packet = DatagramPacket(buffer, buffer.size)
|
|
||||||
while (_started) {
|
|
||||||
try {
|
|
||||||
client.receive(packet)
|
|
||||||
handleResult(packet)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.e(TAG, "An exception occurred while handling UDP result:", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopped receive loop")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun broadcastService(
|
|
||||||
deviceName: String,
|
|
||||||
serviceName: String,
|
|
||||||
port: UShort,
|
|
||||||
ttl: UInt = 120u,
|
|
||||||
weight: UShort = 0u,
|
|
||||||
priority: UShort = 0u,
|
|
||||||
texts: List<String>? = null
|
|
||||||
) {
|
|
||||||
_recordLockObject.withLock {
|
|
||||||
_services.add(
|
|
||||||
BroadcastService(
|
|
||||||
deviceName = deviceName,
|
|
||||||
port = port,
|
|
||||||
priority = priority,
|
|
||||||
serviceName = serviceName,
|
|
||||||
texts = texts,
|
|
||||||
ttl = ttl,
|
|
||||||
weight = weight
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateBroadcastRecords()
|
|
||||||
broadcastRecords()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateBroadcastRecords() {
|
|
||||||
_recordLockObject.withLock {
|
|
||||||
_recordsSRV.clear()
|
|
||||||
_recordsPTR.clear()
|
|
||||||
_recordsA.clear()
|
|
||||||
_recordsAAAA.clear()
|
|
||||||
_recordsTXT.clear()
|
|
||||||
|
|
||||||
_services.forEach { service ->
|
|
||||||
val id = UUID.randomUUID().toString()
|
|
||||||
val deviceDomainName = "${service.deviceName}.${service.serviceName}"
|
|
||||||
val addressName = "$id.local"
|
|
||||||
|
|
||||||
_recordsSRV.add(
|
|
||||||
DnsResourceRecord(
|
|
||||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
|
||||||
type = ResourceRecordType.SRV.value.toInt(),
|
|
||||||
timeToLive = service.ttl,
|
|
||||||
name = deviceDomainName,
|
|
||||||
cacheFlush = false
|
|
||||||
) to SRVRecord(
|
|
||||||
target = addressName,
|
|
||||||
port = service.port,
|
|
||||||
priority = service.priority,
|
|
||||||
weight = service.weight
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
_recordsPTR.add(
|
|
||||||
DnsResourceRecord(
|
|
||||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
|
||||||
type = ResourceRecordType.PTR.value.toInt(),
|
|
||||||
timeToLive = service.ttl,
|
|
||||||
name = service.serviceName,
|
|
||||||
cacheFlush = false
|
|
||||||
) to PTRRecord(
|
|
||||||
domainName = deviceDomainName
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val addresses = _nicMonitor.current.flatMap { nic ->
|
|
||||||
nic.interfaceAddresses.map { it.address }
|
|
||||||
}
|
|
||||||
|
|
||||||
addresses.forEach { address ->
|
|
||||||
when (address) {
|
|
||||||
is Inet4Address -> _recordsA.add(
|
|
||||||
DnsResourceRecord(
|
|
||||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
|
||||||
type = ResourceRecordType.A.value.toInt(),
|
|
||||||
timeToLive = service.ttl,
|
|
||||||
name = addressName,
|
|
||||||
cacheFlush = false
|
|
||||||
) to ARecord(
|
|
||||||
address = address
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
is Inet6Address -> _recordsAAAA.add(
|
|
||||||
DnsResourceRecord(
|
|
||||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
|
||||||
type = ResourceRecordType.AAAA.value.toInt(),
|
|
||||||
timeToLive = service.ttl,
|
|
||||||
name = addressName,
|
|
||||||
cacheFlush = false
|
|
||||||
) to AAAARecord(
|
|
||||||
address = address
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> Logger.i(TAG, "Invalid address type: $address.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (service.texts != null) {
|
|
||||||
_recordsTXT.add(
|
|
||||||
DnsResourceRecord(
|
|
||||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
|
||||||
type = ResourceRecordType.TXT.value.toInt(),
|
|
||||||
timeToLive = service.ttl,
|
|
||||||
name = deviceDomainName,
|
|
||||||
cacheFlush = false
|
|
||||||
) to TXTRecord(
|
|
||||||
texts = service.texts
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun broadcastRecords(questions: List<DnsQuestion>? = null) {
|
|
||||||
val writer = DnsWriter()
|
|
||||||
_recordLockObject.withLock {
|
|
||||||
val recordsA: List<Pair<DnsResourceRecord, ARecord>>
|
|
||||||
val recordsAAAA: List<Pair<DnsResourceRecord, AAAARecord>>
|
|
||||||
val recordsPTR: List<Pair<DnsResourceRecord, PTRRecord>>
|
|
||||||
val recordsTXT: List<Pair<DnsResourceRecord, TXTRecord>>
|
|
||||||
val recordsSRV: List<Pair<DnsResourceRecord, SRVRecord>>
|
|
||||||
|
|
||||||
if (questions != null) {
|
|
||||||
recordsA = _recordsA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
|
||||||
recordsAAAA = _recordsAAAA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
|
||||||
recordsPTR = _recordsPTR.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
|
||||||
recordsSRV = _recordsSRV.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
|
||||||
recordsTXT = _recordsTXT.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
|
||||||
} else {
|
|
||||||
recordsA = _recordsA
|
|
||||||
recordsAAAA = _recordsAAAA
|
|
||||||
recordsPTR = _recordsPTR
|
|
||||||
recordsSRV = _recordsSRV
|
|
||||||
recordsTXT = _recordsTXT
|
|
||||||
}
|
|
||||||
|
|
||||||
val answerCount = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size + recordsTXT.size
|
|
||||||
if (answerCount < 1) return
|
|
||||||
|
|
||||||
val txtOffset = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size
|
|
||||||
val srvOffset = recordsA.size + recordsAAAA.size + recordsPTR.size
|
|
||||||
val ptrOffset = recordsA.size + recordsAAAA.size
|
|
||||||
val aaaaOffset = recordsA.size
|
|
||||||
|
|
||||||
writer.writePacket(
|
|
||||||
DnsPacketHeader(
|
|
||||||
identifier = 0u,
|
|
||||||
queryResponse = QueryResponse.Response.value.toInt(),
|
|
||||||
opcode = DnsOpcode.StandardQuery.value.toInt(),
|
|
||||||
truncated = false,
|
|
||||||
nonAuthenticatedData = false,
|
|
||||||
recursionDesired = false,
|
|
||||||
answerAuthenticated = false,
|
|
||||||
authoritativeAnswer = true,
|
|
||||||
recursionAvailable = false,
|
|
||||||
responseCode = DnsResponseCode.NoError
|
|
||||||
),
|
|
||||||
answerCount = answerCount,
|
|
||||||
answerWriter = { w, i ->
|
|
||||||
when {
|
|
||||||
i >= txtOffset -> {
|
|
||||||
val record = recordsTXT[i - txtOffset]
|
|
||||||
w.write(record.first) { it.write(record.second) }
|
|
||||||
}
|
|
||||||
|
|
||||||
i >= srvOffset -> {
|
|
||||||
val record = recordsSRV[i - srvOffset]
|
|
||||||
w.write(record.first) { it.write(record.second) }
|
|
||||||
}
|
|
||||||
|
|
||||||
i >= ptrOffset -> {
|
|
||||||
val record = recordsPTR[i - ptrOffset]
|
|
||||||
w.write(record.first) { it.write(record.second) }
|
|
||||||
}
|
|
||||||
|
|
||||||
i >= aaaaOffset -> {
|
|
||||||
val record = recordsAAAA[i - aaaaOffset]
|
|
||||||
w.write(record.first) { it.write(record.second) }
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
val record = recordsA[i]
|
|
||||||
w.write(record.first) { it.write(record.second) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
send(writer.toByteArray())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleResult(result: DatagramPacket) {
|
|
||||||
try {
|
|
||||||
val packet = DnsPacket.parse(result.data)
|
|
||||||
if (packet.questions.isNotEmpty()) {
|
|
||||||
_scope?.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
broadcastRecords(packet.questions)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.i(TAG, "Broadcasting records failed", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
_serviceRecordAggregator.add(packet)
|
|
||||||
onPacket?.invoke(packet)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.v(TAG, "Failed to handle packet: ${Base64.getEncoder().encodeToString(result.data.slice(IntRange(0, result.length - 1)).toByteArray())}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
_lockObject.withLock {
|
|
||||||
_started = false
|
|
||||||
|
|
||||||
_scope?.cancel()
|
|
||||||
_scope = null
|
|
||||||
|
|
||||||
_nicMonitor.stop()
|
|
||||||
_serviceRecordAggregator.stop()
|
|
||||||
|
|
||||||
_receiver4?.close()
|
|
||||||
_receiver4 = null
|
|
||||||
|
|
||||||
_receiver6?.close()
|
|
||||||
_receiver6 = null
|
|
||||||
|
|
||||||
_senders.forEach { it.close() }
|
|
||||||
_senders.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
_threadReceiver4?.join()
|
|
||||||
_threadReceiver4 = null
|
|
||||||
|
|
||||||
_threadReceiver6?.join()
|
|
||||||
_threadReceiver6 = null
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,66 +0,0 @@
|
|||||||
package com.futo.platformplayer.mdns
|
|
||||||
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import java.net.NetworkInterface
|
|
||||||
|
|
||||||
class NICMonitor {
|
|
||||||
private val lockObject = Any()
|
|
||||||
private val nics = mutableListOf<NetworkInterface>()
|
|
||||||
private var cts: Job? = null
|
|
||||||
|
|
||||||
val current: List<NetworkInterface>
|
|
||||||
get() = synchronized(nics) { nics.toList() }
|
|
||||||
|
|
||||||
var added: ((List<NetworkInterface>) -> Unit)? = null
|
|
||||||
var removed: ((List<NetworkInterface>) -> Unit)? = null
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
synchronized(lockObject) {
|
|
||||||
if (cts != null) throw Exception("Already started.")
|
|
||||||
|
|
||||||
cts = CoroutineScope(Dispatchers.Default).launch {
|
|
||||||
loopAsync()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nics.clear()
|
|
||||||
nics.addAll(getCurrentInterfaces().toList())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
synchronized(lockObject) {
|
|
||||||
cts?.cancel()
|
|
||||||
cts = null
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(nics) {
|
|
||||||
nics.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loopAsync() {
|
|
||||||
while (cts?.isActive == true) {
|
|
||||||
try {
|
|
||||||
val currentNics = getCurrentInterfaces().toList()
|
|
||||||
removed?.invoke(nics.filter { k -> currentNics.none { n -> k.name == n.name } })
|
|
||||||
added?.invoke(currentNics.filter { nic -> nics.none { k -> k.name == nic.name } })
|
|
||||||
|
|
||||||
synchronized(nics) {
|
|
||||||
nics.clear()
|
|
||||||
nics.addAll(currentNics)
|
|
||||||
}
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
// Ignored
|
|
||||||
}
|
|
||||||
delay(5000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getCurrentInterfaces(): List<NetworkInterface> {
|
|
||||||
val nics = NetworkInterface.getNetworkInterfaces().toList()
|
|
||||||
.filter { it.isUp && !it.isLoopback }
|
|
||||||
|
|
||||||
return if (nics.isNotEmpty()) nics else NetworkInterface.getNetworkInterfaces().toList()
|
|
||||||
.filter { it.isUp }
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,71 +0,0 @@
|
|||||||
package com.futo.platformplayer.mdns
|
|
||||||
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import java.lang.Thread.sleep
|
|
||||||
|
|
||||||
class ServiceDiscoverer(names: Array<String>, private val _onServicesUpdated: (List<DnsService>) -> Unit) {
|
|
||||||
private val _names: Array<String>
|
|
||||||
private var _listener: MDNSListener? = null
|
|
||||||
private var _started = false
|
|
||||||
private var _thread: Thread? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.")
|
|
||||||
_names = names
|
|
||||||
}
|
|
||||||
|
|
||||||
fun broadcastService(
|
|
||||||
deviceName: String,
|
|
||||||
serviceName: String,
|
|
||||||
port: UShort,
|
|
||||||
ttl: UInt = 120u,
|
|
||||||
weight: UShort = 0u,
|
|
||||||
priority: UShort = 0u,
|
|
||||||
texts: List<String>? = null
|
|
||||||
) {
|
|
||||||
_listener?.let {
|
|
||||||
it.broadcastService(deviceName, serviceName, port, ttl, weight, priority, texts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
_started = false
|
|
||||||
_listener?.stop()
|
|
||||||
_listener = null
|
|
||||||
_thread?.join()
|
|
||||||
_thread = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
if (_started) {
|
|
||||||
Logger.i(TAG, "Already started.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_started = true
|
|
||||||
|
|
||||||
val listener = MDNSListener()
|
|
||||||
_listener = listener
|
|
||||||
listener.onServicesUpdated = { _onServicesUpdated?.invoke(it) }
|
|
||||||
listener.start()
|
|
||||||
|
|
||||||
_thread = Thread {
|
|
||||||
try {
|
|
||||||
sleep(2000)
|
|
||||||
|
|
||||||
while (_started) {
|
|
||||||
listener.queryServices(_names)
|
|
||||||
sleep(2000)
|
|
||||||
listener.queryAllQuestions(_names)
|
|
||||||
sleep(2000)
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.i(TAG, "Exception in loop thread", e)
|
|
||||||
stop()
|
|
||||||
}
|
|
||||||
}.apply { start() }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = "ServiceDiscoverer"
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,226 +0,0 @@
|
|||||||
package com.futo.platformplayer.mdns
|
|
||||||
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.net.InetAddress
|
|
||||||
import java.util.Date
|
|
||||||
|
|
||||||
data class DnsService(
|
|
||||||
var name: String,
|
|
||||||
var target: String,
|
|
||||||
var port: UShort,
|
|
||||||
val addresses: MutableList<InetAddress> = mutableListOf(),
|
|
||||||
val pointers: MutableList<String> = mutableListOf(),
|
|
||||||
val texts: MutableList<String> = mutableListOf()
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CachedDnsAddressRecord(
|
|
||||||
val expirationTime: Date,
|
|
||||||
val address: InetAddress
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CachedDnsTxtRecord(
|
|
||||||
val expirationTime: Date,
|
|
||||||
val texts: List<String>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CachedDnsPtrRecord(
|
|
||||||
val expirationTime: Date,
|
|
||||||
val target: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CachedDnsSrvRecord(
|
|
||||||
val expirationTime: Date,
|
|
||||||
val service: SRVRecord
|
|
||||||
)
|
|
||||||
|
|
||||||
class ServiceRecordAggregator {
|
|
||||||
private val _lockObject = Any()
|
|
||||||
private val _cachedAddressRecords = mutableMapOf<String, MutableList<CachedDnsAddressRecord>>()
|
|
||||||
private val _cachedTxtRecords = mutableMapOf<String, CachedDnsTxtRecord>()
|
|
||||||
private val _cachedPtrRecords = mutableMapOf<String, MutableList<CachedDnsPtrRecord>>()
|
|
||||||
private val _cachedSrvRecords = mutableMapOf<String, CachedDnsSrvRecord>()
|
|
||||||
private val _currentServices = mutableListOf<DnsService>()
|
|
||||||
private var _cts: Job? = null
|
|
||||||
|
|
||||||
var onServicesUpdated: ((List<DnsService>) -> Unit)? = null
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
synchronized(_lockObject) {
|
|
||||||
if (_cts != null) throw Exception("Already started.")
|
|
||||||
|
|
||||||
_cts = CoroutineScope(Dispatchers.Default).launch {
|
|
||||||
try {
|
|
||||||
while (isActive) {
|
|
||||||
val now = Date()
|
|
||||||
synchronized(_currentServices) {
|
|
||||||
_cachedAddressRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } }
|
|
||||||
_cachedTxtRecords.entries.removeIf { now.after(it.value.expirationTime) }
|
|
||||||
_cachedSrvRecords.entries.removeIf { now.after(it.value.expirationTime) }
|
|
||||||
_cachedPtrRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } }
|
|
||||||
|
|
||||||
val newServices = getCurrentServices()
|
|
||||||
_currentServices.clear()
|
|
||||||
_currentServices.addAll(newServices)
|
|
||||||
}
|
|
||||||
|
|
||||||
onServicesUpdated?.invoke(_currentServices.toList())
|
|
||||||
delay(5000)
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Unexpected failure in MDNS loop", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
synchronized(_lockObject) {
|
|
||||||
_cts?.cancel()
|
|
||||||
_cts = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun add(packet: DnsPacket) {
|
|
||||||
val currentServices: List<DnsService>
|
|
||||||
val dnsResourceRecords = packet.answers + packet.additionals + packet.authorities
|
|
||||||
val txtRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.TXT.value.toInt() }.map { it to it.getDataReader().readTXTRecord() }
|
|
||||||
val aRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.A.value.toInt() }.map { it to it.getDataReader().readARecord() }
|
|
||||||
val aaaaRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.AAAA.value.toInt() }.map { it to it.getDataReader().readAAAARecord() }
|
|
||||||
val srvRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.SRV.value.toInt() }.map { it to it.getDataReader().readSRVRecord() }
|
|
||||||
val ptrRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.PTR.value.toInt() }.map { it to it.getDataReader().readPTRRecord() }
|
|
||||||
|
|
||||||
/*val builder = StringBuilder()
|
|
||||||
builder.appendLine("Received records:")
|
|
||||||
srvRecords.forEach { builder.appendLine("SRV ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: (Port: ${it.second.port}, Target: ${it.second.target}, Priority: ${it.second.priority}, Weight: ${it.second.weight})") }
|
|
||||||
ptrRecords.forEach { builder.appendLine("PTR ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.domainName}") }
|
|
||||||
txtRecords.forEach { builder.appendLine("TXT ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.texts.joinToString(", ")}") }
|
|
||||||
aRecords.forEach { builder.appendLine("A ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") }
|
|
||||||
aaaaRecords.forEach { builder.appendLine("AAAA ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") }
|
|
||||||
Logger.i(TAG, "$builder")*/
|
|
||||||
|
|
||||||
synchronized(this._currentServices) {
|
|
||||||
ptrRecords.forEach { record ->
|
|
||||||
val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() }
|
|
||||||
val newPtrRecord = CachedDnsPtrRecord(Date(System.currentTimeMillis() + record.first.timeToLive.toLong() * 1000L), record.second.domainName)
|
|
||||||
cachedPtrRecord.replaceOrAdd(newPtrRecord) { it.target == record.second.domainName }
|
|
||||||
}
|
|
||||||
|
|
||||||
aRecords.forEach { aRecord ->
|
|
||||||
val cachedARecord = _cachedAddressRecords.getOrPut(aRecord.first.name) { mutableListOf() }
|
|
||||||
val newARecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aRecord.first.timeToLive.toLong() * 1000L), aRecord.second.address)
|
|
||||||
cachedARecord.replaceOrAdd(newARecord) { it.address == newARecord.address }
|
|
||||||
}
|
|
||||||
|
|
||||||
aaaaRecords.forEach { aaaaRecord ->
|
|
||||||
val cachedAaaaRecord = _cachedAddressRecords.getOrPut(aaaaRecord.first.name) { mutableListOf() }
|
|
||||||
val newAaaaRecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aaaaRecord.first.timeToLive.toLong() * 1000L), aaaaRecord.second.address)
|
|
||||||
cachedAaaaRecord.replaceOrAdd(newAaaaRecord) { it.address == newAaaaRecord.address }
|
|
||||||
}
|
|
||||||
|
|
||||||
txtRecords.forEach { txtRecord ->
|
|
||||||
_cachedTxtRecords[txtRecord.first.name] = CachedDnsTxtRecord(Date(System.currentTimeMillis() + txtRecord.first.timeToLive.toLong() * 1000L), txtRecord.second.texts)
|
|
||||||
}
|
|
||||||
|
|
||||||
srvRecords.forEach { srvRecord ->
|
|
||||||
_cachedSrvRecords[srvRecord.first.name] = CachedDnsSrvRecord(Date(System.currentTimeMillis() + srvRecord.first.timeToLive.toLong() * 1000L), srvRecord.second)
|
|
||||||
}
|
|
||||||
|
|
||||||
currentServices = getCurrentServices()
|
|
||||||
this._currentServices.clear()
|
|
||||||
this._currentServices.addAll(currentServices)
|
|
||||||
}
|
|
||||||
|
|
||||||
onServicesUpdated?.invoke(currentServices)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAllQuestions(serviceName: String): List<DnsQuestion> {
|
|
||||||
val questions = mutableListOf<DnsQuestion>()
|
|
||||||
synchronized(_currentServices) {
|
|
||||||
val servicePtrRecords = _cachedPtrRecords[serviceName] ?: return emptyList()
|
|
||||||
|
|
||||||
val ptrWithoutSrvRecord = servicePtrRecords.filterNot { _cachedSrvRecords.containsKey(it.target) }.map { it.target }
|
|
||||||
questions.addAll(ptrWithoutSrvRecord.flatMap { s ->
|
|
||||||
listOf(
|
|
||||||
DnsQuestion(
|
|
||||||
name = s,
|
|
||||||
type = QuestionType.SRV.value.toInt(),
|
|
||||||
clazz = QuestionClass.IN.value.toInt(),
|
|
||||||
queryUnicast = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
val incompleteCurrentServices = _currentServices.filter { it.addresses.isEmpty() && it.name.endsWith(serviceName) }
|
|
||||||
questions.addAll(incompleteCurrentServices.flatMap { s ->
|
|
||||||
listOf(
|
|
||||||
DnsQuestion(
|
|
||||||
name = s.name,
|
|
||||||
type = QuestionType.TXT.value.toInt(),
|
|
||||||
clazz = QuestionClass.IN.value.toInt(),
|
|
||||||
queryUnicast = false
|
|
||||||
),
|
|
||||||
DnsQuestion(
|
|
||||||
name = s.target,
|
|
||||||
type = QuestionType.A.value.toInt(),
|
|
||||||
clazz = QuestionClass.IN.value.toInt(),
|
|
||||||
queryUnicast = false
|
|
||||||
),
|
|
||||||
DnsQuestion(
|
|
||||||
name = s.target,
|
|
||||||
type = QuestionType.AAAA.value.toInt(),
|
|
||||||
clazz = QuestionClass.IN.value.toInt(),
|
|
||||||
queryUnicast = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return questions
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getCurrentServices(): MutableList<DnsService> {
|
|
||||||
val currentServices = _cachedSrvRecords.map { (key, value) ->
|
|
||||||
DnsService(
|
|
||||||
name = key,
|
|
||||||
target = value.service.target,
|
|
||||||
port = value.service.port
|
|
||||||
)
|
|
||||||
}.toMutableList()
|
|
||||||
|
|
||||||
currentServices.forEach { service ->
|
|
||||||
_cachedAddressRecords[service.target]?.let {
|
|
||||||
service.addresses.addAll(it.map { record -> record.address })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
currentServices.forEach { service ->
|
|
||||||
service.pointers.addAll(_cachedPtrRecords.filter { it.value.any { ptr -> ptr.target == service.name } }.map { it.key })
|
|
||||||
}
|
|
||||||
|
|
||||||
currentServices.forEach { service ->
|
|
||||||
_cachedTxtRecords[service.name]?.let {
|
|
||||||
service.texts.addAll(it.texts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentServices
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <T> MutableList<T>.replaceOrAdd(newElement: T, predicate: (T) -> Boolean) {
|
|
||||||
val index = indexOfFirst(predicate)
|
|
||||||
if (index >= 0) {
|
|
||||||
this[index] = newElement
|
|
||||||
} else {
|
|
||||||
add(newElement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
private const val TAG = "ServiceRecordAggregator"
|
|
||||||
}
|
|
||||||
}
|
|
@ -411,7 +411,7 @@ class StateApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Settings.instance.synchronization.enabled) {
|
if (Settings.instance.synchronization.enabled) {
|
||||||
StateSync.instance.start()
|
StateSync.instance.start(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.onLogSubmitted.subscribe {
|
Logger.onLogSubmitted.subscribe {
|
||||||
@ -519,12 +519,16 @@ class StateApp {
|
|||||||
Logger.i(TAG, "MainApp Started: Fetch [Subscriptions]");
|
Logger.i(TAG, "MainApp Started: Fetch [Subscriptions]");
|
||||||
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
|
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
|
||||||
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
|
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
|
||||||
val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true };
|
val isBelowRateLimit = !subRequestCounts.any { clientCount ->
|
||||||
if (isRateLimitReached) {
|
clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true
|
||||||
|
};
|
||||||
|
if (isBelowRateLimit) {
|
||||||
Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}");
|
Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}");
|
||||||
delay(5000);
|
delay(5000);
|
||||||
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
|
scopeOrNull?.let {
|
||||||
StateSubscriptions.instance.updateSubscriptionFeed(scope, false);
|
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
|
||||||
|
StateSubscriptions.instance.updateSubscriptionFeed(it, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}");
|
Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}");
|
||||||
|
@ -632,6 +632,27 @@ class StatePlatform {
|
|||||||
return pager;
|
return pager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun searchChannelsAsContent(query: String): IPager<IPlatformContent> {
|
||||||
|
Logger.i(TAG, "Platform - searchChannels");
|
||||||
|
val pagers = mutableMapOf<IPager<IPlatformContent>, Float>();
|
||||||
|
getSortedEnabledClient().parallelStream().forEach {
|
||||||
|
try {
|
||||||
|
if (it.capabilities.hasChannelSearch)
|
||||||
|
pagers.put(it.searchChannelsAsContent(query), 1f);
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed search channels", ex)
|
||||||
|
UIDialogs.toast("Failed search channels on [${it.name}]\n(${ex.message})");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if(pagers.isEmpty())
|
||||||
|
return EmptyPager<IPlatformContent>();
|
||||||
|
|
||||||
|
val pager = MultiDistributionContentPager(pagers);
|
||||||
|
pager.initialize();
|
||||||
|
return pager;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
//Video
|
//Video
|
||||||
fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { it.isContentDetailsUrl(url) };
|
fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { it.isContentDetailsUrl(url) };
|
||||||
|
@ -69,7 +69,7 @@ class StateSubscriptions {
|
|||||||
|
|
||||||
val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>();
|
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");
|
private val _subscriptionKey = FragmentedStorage.get<StringStorage>("sub_exchange_key");
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -19,6 +19,11 @@ class CastingDeviceInfoStorage : FragmentedStorageFileJson() {
|
|||||||
return deviceInfos.toList();
|
return deviceInfos.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun getDeviceNames() : List<String> {
|
||||||
|
return deviceInfos.map { it.name }.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun addDevice(castingDeviceInfo: CastingDeviceInfo): CastingDeviceInfo {
|
fun addDevice(castingDeviceInfo: CastingDeviceInfo): CastingDeviceInfo {
|
||||||
val foundDeviceInfo = deviceInfos.firstOrNull { d -> d.name == castingDeviceInfo.name }
|
val foundDeviceInfo = deviceInfos.firstOrNull { d -> d.name == castingDeviceInfo.name }
|
||||||
|
@ -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.IPager
|
||||||
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
|
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
|
||||||
import com.futo.platformplayer.api.media.structures.PlatformContentPager
|
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.PluginException
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
|
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
|
||||||
import com.futo.platformplayer.exceptions.ChannelException
|
import com.futo.platformplayer.exceptions.ChannelException
|
||||||
import com.futo.platformplayer.findNonRuntimeException
|
import com.futo.platformplayer.findNonRuntimeException
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment
|
||||||
|
import com.futo.platformplayer.getNowDiffMiliseconds
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.Subscription
|
import com.futo.platformplayer.models.Subscription
|
||||||
import com.futo.platformplayer.states.StateApp
|
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.ChannelResolve
|
||||||
import com.futo.platformplayer.subsexchange.ExchangeContract
|
import com.futo.platformplayer.subsexchange.ExchangeContract
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.concurrent.ExecutionException
|
import java.util.concurrent.ExecutionException
|
||||||
import java.util.concurrent.ForkJoinPool
|
import java.util.concurrent.ForkJoinPool
|
||||||
@ -82,23 +86,30 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
|||||||
var providedTasks: MutableList<SubscriptionTask>? = null;
|
var providedTasks: MutableList<SubscriptionTask>? = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val contractableTasks =
|
val contractingTime = measureTimeMillis {
|
||||||
tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) };
|
val contractableTasks =
|
||||||
contract =
|
tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) };
|
||||||
if (contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map {
|
contract =
|
||||||
ChannelRequest(it.url)
|
if (contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map {
|
||||||
}.toTypedArray()) else null;
|
ChannelRequest(it.url)
|
||||||
if (contract?.provided?.isNotEmpty() == true)
|
}.toTypedArray()) else null;
|
||||||
Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}");
|
if (contract?.provided?.isNotEmpty() == true)
|
||||||
if (contract != null && contract.required.isNotEmpty()) {
|
Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}");
|
||||||
providedTasks = mutableListOf()
|
if (contract != null && contract!!.required.isNotEmpty()) {
|
||||||
for (task in tasks.toList()) {
|
providedTasks = mutableListOf()
|
||||||
if (!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) {
|
for (task in tasks.toList()) {
|
||||||
providedTasks.add(task);
|
if (!task.fromCache && !task.fromPeek && contract!!.provided.contains(task.url)) {
|
||||||
tasks.remove(task);
|
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){
|
catch(ex: Throwable){
|
||||||
Logger.e("SubscriptionsTaskFetchAlgorithm", "Failed to retrieve SubsExchange contract due to: " + ex.message, ex);
|
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 forkTasks = executeSubscriptionTasks(tasks, failedPlugins, cachedChannels);
|
||||||
|
|
||||||
val taskResults = arrayListOf<SubscriptionTaskResult>();
|
val taskResults = arrayListOf<SubscriptionTaskResult>();
|
||||||
|
var resolveCount = 0;
|
||||||
|
var resolveTime = 0L;
|
||||||
val timeTotal = measureTimeMillis {
|
val timeTotal = measureTimeMillis {
|
||||||
for(task in forkTasks) {
|
for(task in forkTasks) {
|
||||||
try {
|
try {
|
||||||
@ -137,51 +150,82 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
//Resolve Subscription Exchange
|
//Resolve Subscription Exchange
|
||||||
if(contract != null) {
|
if(contract != null) {
|
||||||
try {
|
fun resolve() {
|
||||||
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 {
|
try {
|
||||||
ChannelResolve(
|
resolveTime = measureTimeMillis {
|
||||||
it.task.url,
|
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 {
|
||||||
it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) }
|
ChannelResolve(
|
||||||
)
|
it.task.url,
|
||||||
}.toTypedArray()
|
it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) }
|
||||||
val resolve = subsExchangeClient?.resolveContract(
|
)
|
||||||
contract,
|
}.toTypedArray()
|
||||||
*resolves
|
|
||||||
);
|
val resolveRequestStart = OffsetDateTime.now();
|
||||||
if (resolve != null) {
|
|
||||||
val invalids = resolve.filter { it.content.any { it.datetime == null } };
|
val resolve = subsExchangeClient?.resolveContract(
|
||||||
UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size})")
|
contract!!,
|
||||||
for(result in resolve){
|
*resolves
|
||||||
val task = providedTasks?.find { it.url == result.channelUrl };
|
);
|
||||||
if(task != null) {
|
|
||||||
taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null));
|
Logger.i(TAG, "Subscription Exchange contract resolved request in ${resolveRequestStart.getNowDiffMiliseconds()}ms");
|
||||||
providedTasks?.remove(task);
|
|
||||||
|
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) {
|
if(providedTasks?.size ?: 0 == 0)
|
||||||
for(task in providedTasks) {
|
scope.launch(Dispatchers.IO) {
|
||||||
taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange")));
|
resolve();
|
||||||
}
|
}
|
||||||
}
|
else
|
||||||
}
|
resolve();
|
||||||
catch(ex: Throwable) {
|
|
||||||
//TODO: fetch remainder after all?
|
|
||||||
Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
//Cache pagers grouped by channel
|
||||||
val groupedPagers = taskResults.groupBy { it.task.sub.channel.url }
|
val groupedPagers = taskResults.groupBy { it.task.sub.channel.url }
|
||||||
.map { entry ->
|
.map { entry ->
|
||||||
val sub = if(!entry.value.isEmpty()) entry.value[0].task.sub else null;
|
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 cachedTasks = entry.value.filter { it.task.fromCache };
|
||||||
val livePager = if(liveTasks.isNotEmpty()) StateCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }) {
|
val livePager = if(liveTasks.isNotEmpty()) StateCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }) {
|
||||||
onNewCacheHit.emit(sub!!, it);
|
onNewCacheHit.emit(sub!!, it);
|
||||||
|
@ -1,10 +1,14 @@
|
|||||||
import com.futo.platformplayer.api.media.Serializer
|
import com.futo.platformplayer.api.media.Serializer
|
||||||
|
import com.futo.platformplayer.getNowDiffMiliseconds
|
||||||
import com.futo.platformplayer.logging.Logger
|
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.ChannelRequest
|
||||||
import com.futo.platformplayer.subsexchange.ChannelResolve
|
import com.futo.platformplayer.subsexchange.ChannelResolve
|
||||||
import com.futo.platformplayer.subsexchange.ChannelResult
|
import com.futo.platformplayer.subsexchange.ChannelResult
|
||||||
import com.futo.platformplayer.subsexchange.ExchangeContract
|
import com.futo.platformplayer.subsexchange.ExchangeContract
|
||||||
import com.futo.platformplayer.subsexchange.ExchangeContractResolve
|
import com.futo.platformplayer.subsexchange.ExchangeContractResolve
|
||||||
|
import com.futo.platformplayer.toGzip
|
||||||
|
import com.futo.platformplayer.toHumanBytesSize
|
||||||
import kotlinx.serialization.*
|
import kotlinx.serialization.*
|
||||||
import kotlinx.serialization.json.*
|
import kotlinx.serialization.json.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -26,9 +30,10 @@ import java.nio.charset.StandardCharsets
|
|||||||
import java.security.KeyPairGenerator
|
import java.security.KeyPairGenerator
|
||||||
import java.security.spec.PKCS8EncodedKeySpec
|
import java.security.spec.PKCS8EncodedKeySpec
|
||||||
import java.security.spec.RSAPublicKeySpec
|
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 {
|
private val json = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
@ -40,24 +45,27 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str
|
|||||||
|
|
||||||
// Endpoint: Contract
|
// Endpoint: Contract
|
||||||
fun requestContract(vararg channels: ChannelRequest): ExchangeContract {
|
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)
|
return Json.decodeFromString(data)
|
||||||
}
|
}
|
||||||
suspend fun requestContractAsync(vararg channels: ChannelRequest): ExchangeContract {
|
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)
|
return Json.decodeFromString(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Endpoint: Resolve
|
// Endpoint: Resolve
|
||||||
fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> {
|
fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> {
|
||||||
val contractResolve = convertResolves(*resolves)
|
val contractResolve = convertResolves(*resolves)
|
||||||
val result = post("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json")
|
val contractResolveJson = Serializer.json.encodeToString(contractResolve);
|
||||||
Logger.v("SubsExchangeClient", "Resolve:" + result);
|
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)
|
return Serializer.json.decodeFromString(result)
|
||||||
}
|
}
|
||||||
suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> {
|
suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> {
|
||||||
val contractResolve = convertResolves(*resolves)
|
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)
|
return Serializer.json.decodeFromString(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,13 +82,24 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IO methods
|
// 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")
|
val url = URL("${server.trim('/')}$query")
|
||||||
with(url.openConnection() as HttpURLConnection) {
|
with(url.openConnection() as HttpURLConnection) {
|
||||||
|
if(timeout > 0)
|
||||||
|
this.connectTimeout = timeout
|
||||||
requestMethod = "POST"
|
requestMethod = "POST"
|
||||||
setRequestProperty("Content-Type", contentType)
|
setRequestProperty("Content-Type", contentType)
|
||||||
doOutput = true
|
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;
|
val status = responseCode;
|
||||||
Logger.i("SubsExchangeClient", "POST [${url}]: ${status}");
|
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) {
|
return withContext(Dispatchers.IO) {
|
||||||
post(query, body, contentType)
|
post(query, body, contentType, 0, gzip)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -2,6 +2,6 @@ package com.futo.platformplayer.sync.internal;
|
|||||||
|
|
||||||
public enum LinkType {
|
public enum LinkType {
|
||||||
None,
|
None,
|
||||||
Local,
|
Direct,
|
||||||
Proxied
|
Relayed
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
}
|
@ -5,10 +5,12 @@ class SyncDeviceInfo {
|
|||||||
var publicKey: String
|
var publicKey: String
|
||||||
var addresses: Array<String>
|
var addresses: Array<String>
|
||||||
var port: Int
|
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.publicKey = publicKey
|
||||||
this.addresses = addresses
|
this.addresses = addresses
|
||||||
this.port = port
|
this.port = port
|
||||||
|
this.pairingCode = pairingCode
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
package com.futo.platformplayer.sync.internal
|
||||||
|
|
||||||
|
enum class SyncErrorCode(val value: Int) {
|
||||||
|
ConnectionClosed(1),
|
||||||
|
NotFound(2)
|
||||||
|
}
|
@ -1,37 +1,13 @@
|
|||||||
package com.futo.platformplayer.sync.internal
|
package com.futo.platformplayer.sync.internal
|
||||||
|
|
||||||
import com.futo.platformplayer.UIDialogs
|
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.logging.Logger
|
||||||
import com.futo.platformplayer.models.HistoryVideo
|
|
||||||
import com.futo.platformplayer.models.Subscription
|
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.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.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.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import java.time.ZoneOffset
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
interface IAuthorizable {
|
interface IAuthorizable {
|
||||||
@ -39,13 +15,14 @@ interface IAuthorizable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SyncSession : IAuthorizable {
|
class SyncSession : IAuthorizable {
|
||||||
private val _socketSessions: MutableList<SyncSocketSession> = mutableListOf()
|
private val _channels: MutableList<IChannel> = mutableListOf()
|
||||||
private var _authorized: Boolean = false
|
private var _authorized: Boolean = false
|
||||||
private var _remoteAuthorized: Boolean = false
|
private var _remoteAuthorized: Boolean = false
|
||||||
private val _onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit
|
private val _onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit
|
||||||
private val _onUnauthorized: (session: SyncSession) -> Unit
|
private val _onUnauthorized: (session: SyncSession) -> Unit
|
||||||
private val _onClose: (session: SyncSession) -> Unit
|
private val _onClose: (session: SyncSession) -> Unit
|
||||||
private val _onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit
|
private val _onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit
|
||||||
|
private val _dataHandler: (session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit
|
||||||
val remotePublicKey: String
|
val remotePublicKey: String
|
||||||
override val isAuthorized get() = _authorized && _remoteAuthorized
|
override val isAuthorized get() = _authorized && _remoteAuthorized
|
||||||
private var _wasAuthorized = false
|
private var _wasAuthorized = false
|
||||||
@ -56,140 +33,143 @@ class SyncSession : IAuthorizable {
|
|||||||
private set
|
private set
|
||||||
val displayName: String get() = remoteDeviceName ?: remotePublicKey
|
val displayName: String get() = remoteDeviceName ?: remotePublicKey
|
||||||
|
|
||||||
var connected: Boolean = false
|
val linkType: LinkType get()
|
||||||
private set(v) {
|
{
|
||||||
if (field != v) {
|
var linkType = LinkType.None
|
||||||
field = v
|
synchronized(_channels)
|
||||||
this._onConnectedChanged(this, v)
|
{
|
||||||
|
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.remotePublicKey = remotePublicKey
|
||||||
|
this.remoteDeviceName = remoteDeviceName
|
||||||
_onAuthorized = onAuthorized
|
_onAuthorized = onAuthorized
|
||||||
_onUnauthorized = onUnauthorized
|
_onUnauthorized = onUnauthorized
|
||||||
_onConnectedChanged = onConnectedChanged
|
_onConnectedChanged = onConnectedChanged
|
||||||
_onClose = onClose
|
_onClose = onClose
|
||||||
|
_dataHandler = dataHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addSocketSession(socketSession: SyncSocketSession) {
|
fun addChannel(channel: IChannel) {
|
||||||
if (socketSession.remotePublicKey != remotePublicKey) {
|
if (channel.remotePublicKey != remotePublicKey) {
|
||||||
throw Exception("Public key of session must match public key of socket session")
|
throw Exception("Public key of session must match public key of channel")
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized(_socketSessions) {
|
synchronized(_channels) {
|
||||||
_socketSessions.add(socketSession)
|
_channels.add(channel)
|
||||||
connected = _socketSessions.isNotEmpty()
|
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")
|
Logger.i(TAG, "Sent AUTHORIZED with session id $_id")
|
||||||
|
val idString = _id.toString()
|
||||||
if (socketSession.remoteVersion >= 3) {
|
val idBytes = idString.toByteArray(Charsets.UTF_8)
|
||||||
val idStringBytes = _id.toString().toByteArray()
|
val name = "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}"
|
||||||
val nameBytes = "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}".toByteArray()
|
val nameBytes = name.toByteArray(Charsets.UTF_8)
|
||||||
val buffer = ByteArray(1 + idStringBytes.size + 1 + nameBytes.size)
|
val buffer = ByteArray(1 + idBytes.size + 1 + nameBytes.size)
|
||||||
socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN).apply {
|
buffer[0] = idBytes.size.toByte()
|
||||||
put(idStringBytes.size.toByte())
|
System.arraycopy(idBytes, 0, buffer, 1, idBytes.size)
|
||||||
put(idStringBytes)
|
buffer[1 + idBytes.size] = nameBytes.size.toByte()
|
||||||
put(nameBytes.size.toByte())
|
System.arraycopy(nameBytes, 0, buffer, 2 + idBytes.size, nameBytes.size)
|
||||||
put(nameBytes)
|
send(Opcode.NOTIFY.value, NotifyOpcode.AUTHORIZED.value, ByteBuffer.wrap(buffer))
|
||||||
}.apply { flip() })
|
|
||||||
} else {
|
|
||||||
socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(_id.toString().toByteArray()))
|
|
||||||
}
|
|
||||||
_authorized = true
|
_authorized = true
|
||||||
checkAuthorized()
|
checkAuthorized()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unauthorize(socketSession: SyncSocketSession? = null) {
|
fun unauthorize() {
|
||||||
if (socketSession != null)
|
send(Opcode.NOTIFY.value, NotifyOpcode.UNAUTHORIZED.value)
|
||||||
socketSession.send(Opcode.NOTIFY_UNAUTHORIZED.value)
|
|
||||||
else {
|
|
||||||
val ss = synchronized(_socketSessions) {
|
|
||||||
_socketSessions.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
ss.send(Opcode.NOTIFY_UNAUTHORIZED.value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkAuthorized() {
|
private fun checkAuthorized() {
|
||||||
if (isAuthorized) {
|
if (isAuthorized) {
|
||||||
val isNewlyAuthorized = !_wasAuthorized;
|
val isNewlyAuthorized = !_wasAuthorized
|
||||||
val isNewSession = _lastAuthorizedRemoteId != _remoteId;
|
val isNewSession = _lastAuthorizedRemoteId != _remoteId
|
||||||
Logger.i(TAG, "onAuthorized (isNewlyAuthorized = $isNewlyAuthorized, isNewSession = $isNewSession)");
|
Logger.i(TAG, "onAuthorized (isNewlyAuthorized = $isNewlyAuthorized, isNewSession = $isNewSession)")
|
||||||
_onAuthorized.invoke(this, !_wasAuthorized, _lastAuthorizedRemoteId != _remoteId)
|
_onAuthorized(this, isNewlyAuthorized, isNewSession)
|
||||||
_wasAuthorized = true
|
_wasAuthorized = true
|
||||||
_lastAuthorizedRemoteId = _remoteId
|
_lastAuthorizedRemoteId = _remoteId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeSocketSession(socketSession: SyncSocketSession) {
|
fun removeChannel(channel: IChannel) {
|
||||||
synchronized(_socketSessions) {
|
synchronized(_channels) {
|
||||||
_socketSessions.remove(socketSession)
|
_channels.remove(channel)
|
||||||
connected = _socketSessions.isNotEmpty()
|
connected = _channels.isNotEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun close() {
|
fun close() {
|
||||||
synchronized(_socketSessions) {
|
synchronized(_channels) {
|
||||||
for (socketSession in _socketSessions) {
|
_channels.forEach { it.close() }
|
||||||
socketSession.stop()
|
_channels.clear()
|
||||||
}
|
|
||||||
|
|
||||||
_socketSessions.clear()
|
|
||||||
}
|
}
|
||||||
|
_onClose(this)
|
||||||
_onClose.invoke(this)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handlePacket(socketSession: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
|
fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
|
||||||
try {
|
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) {
|
when (opcode) {
|
||||||
Opcode.NOTIFY_AUTHORIZED.value -> {
|
Opcode.NOTIFY.value -> when (subOpcode) {
|
||||||
if (socketSession.remoteVersion >= 3) {
|
NotifyOpcode.AUTHORIZED.value -> {
|
||||||
val idByteCount = data.get().toInt()
|
val idByteCount = data.get().toInt()
|
||||||
if (idByteCount > 64)
|
if (idByteCount > 64)
|
||||||
throw Exception("Id should always be smaller than 64 bytes")
|
throw Exception("Id should always be smaller than 64 bytes")
|
||||||
|
|
||||||
val idBytes = ByteArray(idByteCount)
|
val idBytes = ByteArray(idByteCount)
|
||||||
data.get(idBytes)
|
data.get(idBytes)
|
||||||
|
|
||||||
val nameByteCount = data.get().toInt()
|
val nameByteCount = data.get().toInt()
|
||||||
if (nameByteCount > 64)
|
if (nameByteCount > 64)
|
||||||
throw Exception("Name should always be smaller than 64 bytes")
|
throw Exception("Name should always be smaller than 64 bytes")
|
||||||
|
|
||||||
val nameBytes = ByteArray(nameByteCount)
|
val nameBytes = ByteArray(nameByteCount)
|
||||||
data.get(nameBytes)
|
data.get(nameBytes)
|
||||||
|
|
||||||
_remoteId = UUID.fromString(idBytes.toString(Charsets.UTF_8))
|
_remoteId = UUID.fromString(idBytes.toString(Charsets.UTF_8))
|
||||||
remoteDeviceName = nameBytes.toString(Charsets.UTF_8)
|
remoteDeviceName = nameBytes.toString(Charsets.UTF_8)
|
||||||
} else {
|
_remoteAuthorized = true
|
||||||
val str = data.toUtf8String()
|
Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId (device name: '${remoteDeviceName ?: "not set"}')")
|
||||||
_remoteId = if (data.remaining() >= 0) UUID.fromString(str) else UUID.fromString("00000000-0000-0000-0000-000000000000")
|
checkAuthorized()
|
||||||
remoteDeviceName = null
|
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) {
|
if (!isAuthorized) {
|
||||||
@ -197,282 +177,57 @@ class SyncSession : IAuthorizable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (opcode != Opcode.DATA.value) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "Received (opcode = ${opcode}, subOpcode = ${subOpcode}) (${data.remaining()} bytes)")
|
Logger.i(TAG, "Received (opcode = $opcode, subOpcode = $subOpcode) (${data.remaining()} bytes)")
|
||||||
//TODO: Abstract this out
|
_dataHandler.invoke(this, opcode, subOpcode, data)
|
||||||
when (subOpcode) {
|
} catch (ex: Exception) {
|
||||||
GJSyncOpcodes.sendToDevices -> {
|
Logger.w(TAG, "Failed to handle sync package $opcode: ${ex.message}", ex)
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch(ex: Exception) {
|
catch(ex: Exception) {
|
||||||
Logger.w(TAG, "Failed to handle sync package ${opcode}: ${ex.message}", ex);
|
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) {
|
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()) {
|
fun sendData(subOpcode: UByte, data: String) {
|
||||||
Logger.v(TAG, "Packet was not sent (opcode = ${opcode}, subOpcode = ${subOpcode}) due to no connected sockets")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var sent = false
|
var sent = false
|
||||||
for (socketSession in socketSessions) {
|
for (channel in channels) {
|
||||||
try {
|
try {
|
||||||
socketSession.send(opcode, subOpcode, ByteBuffer.wrap(data))
|
channel.send(opcode, subOpcode, data)
|
||||||
sent = true
|
sent = true
|
||||||
break
|
break
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Packet failed to send (opcode = ${opcode}, subOpcode = ${subOpcode})", e)
|
Logger.w(TAG, "Packet failed to send (opcode = $opcode, subOpcode = $subOpcode)", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sent) {
|
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 {
|
companion object {
|
||||||
const val TAG = "SyncSession"
|
private const val TAG = "SyncSession"
|
||||||
}
|
}
|
||||||
}
|
}
|
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,9 @@
|
|||||||
package com.futo.platformplayer.views
|
package com.futo.platformplayer.views
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.text.TextWatcher
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
@ -18,6 +20,9 @@ class SearchView : FrameLayout {
|
|||||||
val buttonClear: ImageButton;
|
val buttonClear: ImageButton;
|
||||||
|
|
||||||
var onSearchChanged = Event1<String>();
|
var onSearchChanged = Event1<String>();
|
||||||
|
var onEnter = Event1<String>();
|
||||||
|
|
||||||
|
val text: String get() = textSearch.text.toString();
|
||||||
|
|
||||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
||||||
inflate(context, R.layout.view_search_bar, this);
|
inflate(context, R.layout.view_search_bar, this);
|
||||||
|
@ -53,7 +53,7 @@ class ToggleBar : LinearLayout {
|
|||||||
this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton);
|
this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton);
|
||||||
else
|
else
|
||||||
this.setInfo(button.name, button.isActive, button.isButton);
|
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 name: String;
|
||||||
val icon: Int;
|
val icon: Int;
|
||||||
val iconVariable: ImageVariable?;
|
val iconVariable: ImageVariable?;
|
||||||
val action: (Boolean)->Unit;
|
val action: (ToggleTagView, Boolean)->Unit;
|
||||||
val isActive: Boolean;
|
val isActive: Boolean;
|
||||||
var isButton: Boolean = false
|
var isButton: Boolean = false
|
||||||
private set;
|
private set;
|
||||||
var tag: String? = null;
|
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.name = name;
|
||||||
this.icon = 0;
|
this.icon = 0;
|
||||||
this.iconVariable = icon;
|
this.iconVariable = icon;
|
||||||
this.action = action;
|
this.action = action;
|
||||||
this.isActive = isActive;
|
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.name = name;
|
||||||
this.icon = icon;
|
this.icon = icon;
|
||||||
this.iconVariable = null;
|
this.iconVariable = null;
|
||||||
this.action = action;
|
this.action = action;
|
||||||
this.isActive = isActive;
|
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.name = name;
|
||||||
this.icon = 0;
|
this.icon = 0;
|
||||||
this.iconVariable = null;
|
this.iconVariable = null;
|
||||||
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
@ -7,16 +7,16 @@ import com.futo.platformplayer.R
|
|||||||
import com.futo.platformplayer.casting.CastingDevice
|
import com.futo.platformplayer.casting.CastingDevice
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
|
||||||
class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
|
data class DeviceAdapterEntry(val castingDevice: CastingDevice, val isPinnedDevice: Boolean, val isOnlineDevice: Boolean)
|
||||||
private val _devices: ArrayList<CastingDevice>;
|
|
||||||
private val _isRememberedDevice: Boolean;
|
|
||||||
|
|
||||||
var onRemove = Event1<CastingDevice>();
|
class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
|
||||||
|
private val _devices: List<DeviceAdapterEntry>;
|
||||||
|
|
||||||
|
var onPin = Event1<CastingDevice>();
|
||||||
var onConnect = Event1<CastingDevice>();
|
var onConnect = Event1<CastingDevice>();
|
||||||
|
|
||||||
constructor(devices: ArrayList<CastingDevice>, isRememberedDevice: Boolean) : super() {
|
constructor(devices: List<DeviceAdapterEntry>) : super() {
|
||||||
_devices = devices;
|
_devices = devices;
|
||||||
_isRememberedDevice = isRememberedDevice;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount() = _devices.size;
|
override fun getItemCount() = _devices.size;
|
||||||
@ -24,13 +24,13 @@ class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
|
|||||||
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): DeviceViewHolder {
|
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): DeviceViewHolder {
|
||||||
val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_device, viewGroup, false);
|
val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_device, viewGroup, false);
|
||||||
val holder = DeviceViewHolder(view);
|
val holder = DeviceViewHolder(view);
|
||||||
holder.setIsRememberedDevice(_isRememberedDevice);
|
holder.onPin.subscribe { d -> onPin.emit(d); };
|
||||||
holder.onRemove.subscribe { d -> onRemove.emit(d); };
|
|
||||||
holder.onConnect.subscribe { d -> onConnect.emit(d); }
|
holder.onConnect.subscribe { d -> onConnect.emit(d); }
|
||||||
return holder;
|
return holder;
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(viewHolder: DeviceViewHolder, position: Int) {
|
override fun onBindViewHolder(viewHolder: DeviceViewHolder, position: Int) {
|
||||||
viewHolder.bind(_devices[position]);
|
val p = _devices[position];
|
||||||
|
viewHolder.bind(p.castingDevice, p.isOnlineDevice, p.isPinnedDevice);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,9 +2,11 @@ package com.futo.platformplayer.views.adapters
|
|||||||
|
|
||||||
import android.graphics.drawable.Animatable
|
import android.graphics.drawable.Animatable
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.casting.AirPlayCastingDevice
|
import com.futo.platformplayer.casting.AirPlayCastingDevice
|
||||||
@ -14,70 +16,62 @@ import com.futo.platformplayer.casting.ChromecastCastingDevice
|
|||||||
import com.futo.platformplayer.casting.FCastCastingDevice
|
import com.futo.platformplayer.casting.FCastCastingDevice
|
||||||
import com.futo.platformplayer.casting.StateCasting
|
import com.futo.platformplayer.casting.StateCasting
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
import com.futo.platformplayer.constructs.Event2
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
|
||||||
class DeviceViewHolder : ViewHolder {
|
class DeviceViewHolder : ViewHolder {
|
||||||
|
private val _layoutDevice: FrameLayout;
|
||||||
private val _imageDevice: ImageView;
|
private val _imageDevice: ImageView;
|
||||||
private val _textName: TextView;
|
private val _textName: TextView;
|
||||||
private val _textType: TextView;
|
private val _textType: TextView;
|
||||||
private val _textNotReady: TextView;
|
private val _textNotReady: TextView;
|
||||||
private val _buttonDisconnect: LinearLayout;
|
|
||||||
private val _buttonConnect: LinearLayout;
|
|
||||||
private val _buttonRemove: LinearLayout;
|
|
||||||
private val _imageLoader: ImageView;
|
private val _imageLoader: ImageView;
|
||||||
|
private val _imageOnline: ImageView;
|
||||||
|
private val _root: ConstraintLayout;
|
||||||
private var _animatableLoader: Animatable? = null;
|
private var _animatableLoader: Animatable? = null;
|
||||||
private var _isRememberedDevice: Boolean = false;
|
private var _imagePin: ImageView;
|
||||||
|
|
||||||
var device: CastingDevice? = null
|
var device: CastingDevice? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var onRemove = Event1<CastingDevice>();
|
var onPin = Event1<CastingDevice>();
|
||||||
val onConnect = Event1<CastingDevice>();
|
val onConnect = Event1<CastingDevice>();
|
||||||
|
|
||||||
constructor(view: View) : super(view) {
|
constructor(view: View) : super(view) {
|
||||||
|
_root = view.findViewById(R.id.layout_root);
|
||||||
|
_layoutDevice = view.findViewById(R.id.layout_device);
|
||||||
_imageDevice = view.findViewById(R.id.image_device);
|
_imageDevice = view.findViewById(R.id.image_device);
|
||||||
_textName = view.findViewById(R.id.text_name);
|
_textName = view.findViewById(R.id.text_name);
|
||||||
_textType = view.findViewById(R.id.text_type);
|
_textType = view.findViewById(R.id.text_type);
|
||||||
_textNotReady = view.findViewById(R.id.text_not_ready);
|
_textNotReady = view.findViewById(R.id.text_not_ready);
|
||||||
_buttonDisconnect = view.findViewById(R.id.button_disconnect);
|
|
||||||
_buttonConnect = view.findViewById(R.id.button_connect);
|
|
||||||
_buttonRemove = view.findViewById(R.id.button_remove);
|
|
||||||
_imageLoader = view.findViewById(R.id.image_loader);
|
_imageLoader = view.findViewById(R.id.image_loader);
|
||||||
|
_imageOnline = view.findViewById(R.id.image_online);
|
||||||
|
_imagePin = view.findViewById(R.id.image_pin);
|
||||||
|
|
||||||
val d = _imageLoader.drawable;
|
val d = _imageLoader.drawable;
|
||||||
if (d is Animatable) {
|
if (d is Animatable) {
|
||||||
_animatableLoader = d;
|
_animatableLoader = d;
|
||||||
}
|
}
|
||||||
|
|
||||||
_buttonDisconnect.setOnClickListener {
|
val connect = {
|
||||||
StateCasting.instance.activeDevice?.stopCasting();
|
device?.let { dev ->
|
||||||
updateButton();
|
StateCasting.instance.activeDevice?.stopCasting();
|
||||||
};
|
StateCasting.instance.connectDevice(dev);
|
||||||
|
onConnect.emit(dev);
|
||||||
_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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsRememberedDevice(false);
|
_textName.setOnClickListener { connect() };
|
||||||
|
_textType.setOnClickListener { connect() };
|
||||||
|
_layoutDevice.setOnClickListener { connect() };
|
||||||
|
|
||||||
|
_imagePin.setOnClickListener {
|
||||||
|
val dev = device ?: return@setOnClickListener;
|
||||||
|
onPin.emit(dev);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setIsRememberedDevice(isRememberedDevice: Boolean) {
|
fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) {
|
||||||
_isRememberedDevice = isRememberedDevice;
|
|
||||||
_buttonRemove.visibility = if (isRememberedDevice) View.VISIBLE else View.GONE;
|
|
||||||
}
|
|
||||||
|
|
||||||
fun bind(d: CastingDevice) {
|
|
||||||
if (d is ChromecastCastingDevice) {
|
if (d is ChromecastCastingDevice) {
|
||||||
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
||||||
_textType.text = "Chromecast";
|
_textType.text = "Chromecast";
|
||||||
@ -90,54 +84,47 @@ class DeviceViewHolder : ViewHolder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_textName.text = d.name;
|
_textName.text = d.name;
|
||||||
device = d;
|
_imageOnline.visibility = if (isOnlineDevice) View.VISIBLE else View.GONE
|
||||||
updateButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateButton() {
|
|
||||||
val d = device ?: return;
|
|
||||||
|
|
||||||
if (!d.isReady) {
|
if (!d.isReady) {
|
||||||
_buttonConnect.visibility = View.GONE;
|
|
||||||
_buttonDisconnect.visibility = View.GONE;
|
|
||||||
_imageLoader.visibility = View.GONE;
|
_imageLoader.visibility = View.GONE;
|
||||||
_textNotReady.visibility = View.VISIBLE;
|
_textNotReady.visibility = View.VISIBLE;
|
||||||
return;
|
_imagePin.visibility = View.GONE;
|
||||||
}
|
|
||||||
|
|
||||||
_textNotReady.visibility = View.GONE;
|
|
||||||
|
|
||||||
val dev = StateCasting.instance.activeDevice;
|
|
||||||
if (dev == d) {
|
|
||||||
if (dev.connectionState == CastConnectionState.CONNECTED) {
|
|
||||||
_buttonConnect.visibility = View.GONE;
|
|
||||||
_buttonDisconnect.visibility = View.VISIBLE;
|
|
||||||
_imageLoader.visibility = View.GONE;
|
|
||||||
_textNotReady.visibility = View.GONE;
|
|
||||||
} else {
|
|
||||||
_buttonConnect.visibility = View.GONE;
|
|
||||||
_buttonDisconnect.visibility = View.VISIBLE;
|
|
||||||
_imageLoader.visibility = View.VISIBLE;
|
|
||||||
_textNotReady.visibility = View.GONE;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (d.isReady) {
|
_textNotReady.visibility = View.GONE;
|
||||||
_buttonConnect.visibility = View.VISIBLE;
|
|
||||||
_buttonDisconnect.visibility = View.GONE;
|
val dev = StateCasting.instance.activeDevice;
|
||||||
_imageLoader.visibility = View.GONE;
|
if (dev == d) {
|
||||||
_textNotReady.visibility = View.GONE;
|
if (dev.connectionState == CastConnectionState.CONNECTED) {
|
||||||
|
_imageLoader.visibility = View.GONE;
|
||||||
|
_textNotReady.visibility = View.GONE;
|
||||||
|
_imagePin.visibility = View.VISIBLE;
|
||||||
|
} else {
|
||||||
|
_imageLoader.visibility = View.VISIBLE;
|
||||||
|
_textNotReady.visibility = View.GONE;
|
||||||
|
_imagePin.visibility = View.VISIBLE;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
_buttonConnect.visibility = View.GONE;
|
if (d.isReady) {
|
||||||
_buttonDisconnect.visibility = View.GONE;
|
_imageLoader.visibility = View.GONE;
|
||||||
_imageLoader.visibility = View.GONE;
|
_textNotReady.visibility = View.GONE;
|
||||||
_textNotReady.visibility = View.VISIBLE;
|
_imagePin.visibility = View.VISIBLE;
|
||||||
|
} else {
|
||||||
|
_imageLoader.visibility = View.GONE;
|
||||||
|
_textNotReady.visibility = View.VISIBLE;
|
||||||
|
_imagePin.visibility = View.VISIBLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_imagePin.setImageResource(if (isPinnedDevice) R.drawable.keep_24px else R.drawable.ic_pin)
|
||||||
|
|
||||||
|
if (_imageLoader.isVisible) {
|
||||||
|
_animatableLoader?.start();
|
||||||
|
} else {
|
||||||
|
_animatableLoader?.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_imageLoader.visibility == View.VISIBLE) {
|
device = d;
|
||||||
_animatableLoader?.start();
|
|
||||||
} else {
|
|
||||||
_animatableLoader?.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -14,6 +14,7 @@ class VideoListEditorAdapter : RecyclerView.Adapter<VideoListEditorViewHolder> {
|
|||||||
|
|
||||||
val onClick = Event1<IPlatformVideo>();
|
val onClick = Event1<IPlatformVideo>();
|
||||||
val onRemove = Event1<IPlatformVideo>();
|
val onRemove = Event1<IPlatformVideo>();
|
||||||
|
val onOptions = Event1<IPlatformVideo>();
|
||||||
var canEdit = false
|
var canEdit = false
|
||||||
private set;
|
private set;
|
||||||
|
|
||||||
@ -28,6 +29,7 @@ class VideoListEditorAdapter : RecyclerView.Adapter<VideoListEditorViewHolder> {
|
|||||||
val holder = VideoListEditorViewHolder(view, _touchHelper);
|
val holder = VideoListEditorViewHolder(view, _touchHelper);
|
||||||
|
|
||||||
holder.onRemove.subscribe { v -> onRemove.emit(v); };
|
holder.onRemove.subscribe { v -> onRemove.emit(v); };
|
||||||
|
holder.onOptions.subscribe { v -> onOptions.emit(v); };
|
||||||
holder.onClick.subscribe { v -> onClick.emit(v); };
|
holder.onClick.subscribe { v -> onClick.emit(v); };
|
||||||
|
|
||||||
return holder;
|
return holder;
|
||||||
|
@ -32,6 +32,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
|||||||
private val _containerDuration: LinearLayout;
|
private val _containerDuration: LinearLayout;
|
||||||
private val _containerLive: LinearLayout;
|
private val _containerLive: LinearLayout;
|
||||||
private val _imageRemove: ImageButton;
|
private val _imageRemove: ImageButton;
|
||||||
|
private val _imageOptions: ImageButton;
|
||||||
private val _imageDragDrop: ImageButton;
|
private val _imageDragDrop: ImageButton;
|
||||||
private val _platformIndicator: PlatformIndicator;
|
private val _platformIndicator: PlatformIndicator;
|
||||||
private val _layoutDownloaded: FrameLayout;
|
private val _layoutDownloaded: FrameLayout;
|
||||||
@ -41,6 +42,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
|||||||
|
|
||||||
val onClick = Event1<IPlatformVideo>();
|
val onClick = Event1<IPlatformVideo>();
|
||||||
val onRemove = Event1<IPlatformVideo>();
|
val onRemove = Event1<IPlatformVideo>();
|
||||||
|
val onOptions = Event1<IPlatformVideo>();
|
||||||
|
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
constructor(view: View, touchHelper: ItemTouchHelper? = null) : super(view) {
|
constructor(view: View, touchHelper: ItemTouchHelper? = null) : super(view) {
|
||||||
@ -54,6 +56,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
|||||||
_containerDuration = view.findViewById(R.id.thumbnail_duration_container);
|
_containerDuration = view.findViewById(R.id.thumbnail_duration_container);
|
||||||
_containerLive = view.findViewById(R.id.thumbnail_live_container);
|
_containerLive = view.findViewById(R.id.thumbnail_live_container);
|
||||||
_imageRemove = view.findViewById(R.id.image_trash);
|
_imageRemove = view.findViewById(R.id.image_trash);
|
||||||
|
_imageOptions = view.findViewById(R.id.image_settings);
|
||||||
_imageDragDrop = view.findViewById<ImageButton>(R.id.image_drag_drop);
|
_imageDragDrop = view.findViewById<ImageButton>(R.id.image_drag_drop);
|
||||||
_platformIndicator = view.findViewById(R.id.thumbnail_platform);
|
_platformIndicator = view.findViewById(R.id.thumbnail_platform);
|
||||||
_layoutDownloaded = view.findViewById(R.id.layout_downloaded);
|
_layoutDownloaded = view.findViewById(R.id.layout_downloaded);
|
||||||
@ -74,6 +77,10 @@ class VideoListEditorViewHolder : ViewHolder {
|
|||||||
val v = video ?: return@setOnClickListener;
|
val v = video ?: return@setOnClickListener;
|
||||||
onRemove.emit(v);
|
onRemove.emit(v);
|
||||||
};
|
};
|
||||||
|
_imageOptions?.setOnClickListener {
|
||||||
|
val v = video ?: return@setOnClickListener;
|
||||||
|
onOptions.emit(v);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bind(v: IPlatformVideo, canEdit: Boolean) {
|
fun bind(v: IPlatformVideo, canEdit: Boolean) {
|
||||||
|
@ -0,0 +1,40 @@
|
|||||||
|
package com.futo.platformplayer.views.adapters.feedtypes
|
||||||
|
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
|
||||||
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||||
|
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||||
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.CreatorFeedView
|
||||||
|
import com.futo.platformplayer.views.FeedStyle
|
||||||
|
import com.futo.platformplayer.views.adapters.ChannelView
|
||||||
|
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||||
|
import com.futo.platformplayer.views.adapters.PlaylistView
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewChannelViewHolder : ContentPreviewViewHolder {
|
||||||
|
val onClick = Event1<IPlatformChannelContent>();
|
||||||
|
|
||||||
|
val currentChannel: IPlatformChannelContent? get() = view.currentChannel;
|
||||||
|
|
||||||
|
override val content: IPlatformContent? get() = currentChannel;
|
||||||
|
|
||||||
|
private val view: ChannelView get() = itemView as ChannelView;
|
||||||
|
|
||||||
|
constructor(viewGroup: ViewGroup, feedStyle: FeedStyle, tiny: Boolean): super(ChannelView(viewGroup.context, feedStyle, tiny)) {
|
||||||
|
view.onClick.subscribe(onClick::emit);
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(content: IPlatformContent) = view.bind(content);
|
||||||
|
|
||||||
|
override fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit;
|
||||||
|
override fun stopPreview() = Unit;
|
||||||
|
override fun pausePreview() = Unit;
|
||||||
|
override fun resumePreview() = Unit;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = "PreviewChannelViewHolder"
|
||||||
|
}
|
||||||
|
}
|
@ -23,6 +23,7 @@ import com.futo.platformplayer.views.FeedStyle
|
|||||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||||
import com.futo.platformplayer.views.adapters.EmptyPreviewViewHolder
|
import com.futo.platformplayer.views.adapters.EmptyPreviewViewHolder
|
||||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||||
|
import okhttp3.internal.platform.Platform
|
||||||
|
|
||||||
class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewViewHolder> {
|
class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewViewHolder> {
|
||||||
private var _initialPlay = true;
|
private var _initialPlay = true;
|
||||||
@ -82,6 +83,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
|||||||
ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup);
|
ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup);
|
||||||
ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup);
|
ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup);
|
||||||
ContentType.LOCKED -> createLockedViewHolder(viewGroup);
|
ContentType.LOCKED -> createLockedViewHolder(viewGroup);
|
||||||
|
ContentType.CHANNEL -> createChannelViewHolder(viewGroup)
|
||||||
else -> EmptyPreviewViewHolder(viewGroup)
|
else -> EmptyPreviewViewHolder(viewGroup)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -115,6 +117,10 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
|||||||
this.onPlaylistClicked.subscribe { this@PreviewContentListAdapter.onContentClicked.emit(it, 0L) };
|
this.onPlaylistClicked.subscribe { this@PreviewContentListAdapter.onContentClicked.emit(it, 0L) };
|
||||||
this.onChannelClicked.subscribe(this@PreviewContentListAdapter.onChannelClicked::emit);
|
this.onChannelClicked.subscribe(this@PreviewContentListAdapter.onChannelClicked::emit);
|
||||||
};
|
};
|
||||||
|
private fun createChannelViewHolder(viewGroup: ViewGroup): PreviewChannelViewHolder = PreviewChannelViewHolder(viewGroup, _feedStyle, false).apply {
|
||||||
|
//TODO: Maybe PlatformAuthorLink as is needs to be phased out?
|
||||||
|
this.onClick.subscribe { this@PreviewContentListAdapter.onChannelClicked.emit(PlatformAuthorLink(it.id, it.name, it.url, it.thumbnail, it.subscribers)) };
|
||||||
|
};
|
||||||
|
|
||||||
override fun bindChild(holder: ContentPreviewViewHolder, pos: Int) {
|
override fun bindChild(holder: ContentPreviewViewHolder, pos: Int) {
|
||||||
val value = _dataSet[pos];
|
val value = _dataSet[pos];
|
||||||
|
@ -8,6 +8,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.UISlideOverlays
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
@ -22,6 +23,7 @@ class VideoListEditorView : FrameLayout {
|
|||||||
|
|
||||||
val onVideoOrderChanged = Event1<List<IPlatformVideo>>()
|
val onVideoOrderChanged = Event1<List<IPlatformVideo>>()
|
||||||
val onVideoRemoved = Event1<IPlatformVideo>();
|
val onVideoRemoved = Event1<IPlatformVideo>();
|
||||||
|
val onVideoOptions = Event1<IPlatformVideo>();
|
||||||
val onVideoClicked = Event1<IPlatformVideo>();
|
val onVideoClicked = Event1<IPlatformVideo>();
|
||||||
val isEmpty get() = _videos.isEmpty();
|
val isEmpty get() = _videos.isEmpty();
|
||||||
|
|
||||||
@ -54,6 +56,9 @@ class VideoListEditorView : FrameLayout {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
adapterVideos.onOptions.subscribe { v ->
|
||||||
|
onVideoOptions?.emit(v);
|
||||||
|
}
|
||||||
adapterVideos.onRemove.subscribe { v ->
|
adapterVideos.onRemove.subscribe { v ->
|
||||||
val executeDelete = {
|
val executeDelete = {
|
||||||
synchronized(_videos) {
|
synchronized(_videos) {
|
||||||
|
@ -9,6 +9,7 @@ import android.view.View
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.getDataLinkFromUrl
|
import com.futo.platformplayer.getDataLinkFromUrl
|
||||||
@ -81,12 +82,14 @@ class CreatorThumbnail : ConstraintLayout {
|
|||||||
Glide.with(_imageChannelThumbnail)
|
Glide.with(_imageChannelThumbnail)
|
||||||
.load(url)
|
.load(url)
|
||||||
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
||||||
|
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
||||||
.crossfade()
|
.crossfade()
|
||||||
.into(_imageChannelThumbnail);
|
.into(_imageChannelThumbnail);
|
||||||
} else {
|
} else {
|
||||||
Glide.with(_imageChannelThumbnail)
|
Glide.with(_imageChannelThumbnail)
|
||||||
.load(url)
|
.load(url)
|
||||||
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
||||||
|
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
||||||
.into(_imageChannelThumbnail);
|
.into(_imageChannelThumbnail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,8 +12,10 @@ import android.widget.TextView
|
|||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
import com.futo.platformplayer.constructs.Event2
|
||||||
import com.futo.platformplayer.images.GlideHelper
|
import com.futo.platformplayer.images.GlideHelper
|
||||||
import com.futo.platformplayer.models.ImageVariable
|
import com.futo.platformplayer.models.ImageVariable
|
||||||
|
import com.futo.platformplayer.views.ToggleBar
|
||||||
|
|
||||||
class ToggleTagView : LinearLayout {
|
class ToggleTagView : LinearLayout {
|
||||||
private val _root: FrameLayout;
|
private val _root: FrameLayout;
|
||||||
@ -26,7 +28,7 @@ class ToggleTagView : LinearLayout {
|
|||||||
var isButton: Boolean = false
|
var isButton: Boolean = false
|
||||||
private set;
|
private set;
|
||||||
|
|
||||||
var onClick = Event1<Boolean>();
|
var onClick = Event2<ToggleTagView, Boolean>();
|
||||||
|
|
||||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
||||||
LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true);
|
LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true);
|
||||||
@ -36,7 +38,7 @@ class ToggleTagView : LinearLayout {
|
|||||||
_root.setOnClickListener {
|
_root.setOnClickListener {
|
||||||
if(!isButton)
|
if(!isButton)
|
||||||
setToggle(!isActive);
|
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) {
|
fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false) {
|
||||||
_text = text;
|
_text = text;
|
||||||
_textTag.text = text;
|
_textTag.text = text;
|
||||||
setToggle(isActive);
|
setToggle(isActive);
|
||||||
_image.setImageResource(imageResource);
|
_image.setImageResource(imageResource);
|
||||||
_image.visibility = View.VISIBLE;
|
_image.visibility = View.VISIBLE;
|
||||||
|
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
|
||||||
this.isButton = isButton;
|
this.isButton = isButton;
|
||||||
}
|
}
|
||||||
fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) {
|
fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) {
|
||||||
@ -66,12 +87,14 @@ class ToggleTagView : LinearLayout {
|
|||||||
setToggle(isActive);
|
setToggle(isActive);
|
||||||
image.setImageView(_image, R.drawable.ic_error_pred);
|
image.setImageView(_image, R.drawable.ic_error_pred);
|
||||||
_image.visibility = View.VISIBLE;
|
_image.visibility = View.VISIBLE;
|
||||||
|
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
|
||||||
this.isButton = isButton;
|
this.isButton = isButton;
|
||||||
}
|
}
|
||||||
fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) {
|
fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) {
|
||||||
_image.visibility = View.GONE;
|
_image.visibility = View.GONE;
|
||||||
_text = text;
|
_text = text;
|
||||||
_textTag.text = text;
|
_textTag.text = text;
|
||||||
|
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
|
||||||
setToggle(isActive);
|
setToggle(isActive);
|
||||||
this.isButton = isButton;
|
this.isButton = isButton;
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,9 @@ import android.widget.LinearLayout
|
|||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UISlideOverlays
|
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.Event0
|
||||||
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.views.lists.VideoListEditorView
|
import com.futo.platformplayer.views.lists.VideoListEditorView
|
||||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||||
@ -23,6 +25,7 @@ class QueueEditorOverlay : LinearLayout {
|
|||||||
private val _overlayContainer: FrameLayout;
|
private val _overlayContainer: FrameLayout;
|
||||||
|
|
||||||
|
|
||||||
|
val onOptions = Event1<IPlatformVideo>();
|
||||||
val onClose = Event0();
|
val onClose = Event0();
|
||||||
|
|
||||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
||||||
@ -35,6 +38,9 @@ class QueueEditorOverlay : LinearLayout {
|
|||||||
|
|
||||||
_topbar.onClose.subscribe(this, onClose::emit);
|
_topbar.onClose.subscribe(this, onClose::emit);
|
||||||
_editor.onVideoOrderChanged.subscribe { StatePlayer.instance.setQueueWithExisting(it) }
|
_editor.onVideoOrderChanged.subscribe { StatePlayer.instance.setQueueWithExisting(it) }
|
||||||
|
_editor.onVideoOptions.subscribe { v ->
|
||||||
|
onOptions?.emit(v);
|
||||||
|
}
|
||||||
_editor.onVideoRemoved.subscribe { v ->
|
_editor.onVideoRemoved.subscribe { v ->
|
||||||
StatePlayer.instance.removeFromQueue(v);
|
StatePlayer.instance.removeFromQueue(v);
|
||||||
_topbar.setInfo(context.getString(R.string.queue), "${StatePlayer.instance.queueSize} " + context.getString(R.string.videos));
|
_topbar.setInfo(context.getString(R.string.queue), "${StatePlayer.instance.queueSize} " + context.getString(R.string.videos));
|
||||||
|
@ -158,7 +158,7 @@ class SubscriptionBar : LinearLayout {
|
|||||||
for(button in buttons) {
|
for(button in buttons) {
|
||||||
_tagsContainer.addView(ToggleTagView(context).apply {
|
_tagsContainer.addView(ToggleTagView(context).apply {
|
||||||
this.setInfo(button.name, button.isActive);
|
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 {
|
class Toggle {
|
||||||
val name: String;
|
val name: String;
|
||||||
val icon: Int;
|
val icon: Int;
|
||||||
val action: (Boolean)->Unit;
|
val action: (ToggleTagView, Boolean)->Unit;
|
||||||
val isActive: Boolean;
|
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.name = name;
|
||||||
this.icon = icon;
|
this.icon = icon;
|
||||||
this.action = action;
|
this.action = action;
|
||||||
this.isActive = isActive;
|
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.name = name;
|
||||||
this.icon = 0;
|
this.icon = 0;
|
||||||
this.action = action;
|
this.action = action;
|
||||||
|
@ -43,13 +43,13 @@ class SyncDeviceView : ConstraintLayout {
|
|||||||
|
|
||||||
_layoutLinkType.visibility = View.VISIBLE
|
_layoutLinkType.visibility = View.VISIBLE
|
||||||
_imageLinkType.setImageResource(when (linkType) {
|
_imageLinkType.setImageResource(when (linkType) {
|
||||||
LinkType.Proxied -> R.drawable.ic_internet
|
LinkType.Relayed -> R.drawable.ic_internet
|
||||||
LinkType.Local -> R.drawable.ic_lan
|
LinkType.Direct -> R.drawable.ic_lan
|
||||||
else -> 0
|
else -> 0
|
||||||
})
|
})
|
||||||
_textLinkType.text = when(linkType) {
|
_textLinkType.text = when(linkType) {
|
||||||
LinkType.Proxied -> "Proxied"
|
LinkType.Relayed -> "Relayed"
|
||||||
LinkType.Local -> "Local"
|
LinkType.Direct -> "Direct"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
9
app/src/main/res/drawable/keep_24px.xml
Normal file
9
app/src/main/res/drawable/keep_24px.xml
Normal 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>
|
@ -11,25 +11,48 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginTop="12dp">
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/text_devices"
|
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/discovered_devices"
|
android:orientation="vertical">
|
||||||
android:layout_marginStart="20dp"
|
|
||||||
android:textSize="14dp"
|
|
||||||
android:textColor="@color/white"
|
|
||||||
android:fontFamily="@font/inter_regular" />
|
|
||||||
|
|
||||||
<ImageView
|
<TextView
|
||||||
android:id="@+id/image_loader"
|
android:id="@+id/text_devices"
|
||||||
android:layout_width="22dp"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="22dp"
|
android:layout_height="wrap_content"
|
||||||
android:scaleType="fitCenter"
|
android:text="@string/discovered_devices"
|
||||||
app:srcCompat="@drawable/ic_loader_animated"
|
android:layout_marginStart="20dp"
|
||||||
android:layout_marginStart="5dp"/>
|
android:textSize="14dp"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:fontFamily="@font/inter_regular" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/available_devices"
|
||||||
|
android:layout_marginStart="20dp"
|
||||||
|
android:textSize="11dp"
|
||||||
|
android:textColor="@color/gray_ac"
|
||||||
|
android:fontFamily="@font/inter_medium" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/image_loader"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
app:srcCompat="@drawable/ic_loader_animated"
|
||||||
|
android:layout_marginStart="5dp"/>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<Space android:layout_width="0dp"
|
<Space android:layout_width="0dp"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
@ -38,7 +61,7 @@
|
|||||||
<Button
|
<Button
|
||||||
android:id="@+id/button_close"
|
android:id="@+id/button_close"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:text="@string/close"
|
android:text="@string/close"
|
||||||
android:textSize="14dp"
|
android:textSize="14dp"
|
||||||
android:fontFamily="@font/inter_regular"
|
android:fontFamily="@font/inter_regular"
|
||||||
@ -67,79 +90,102 @@
|
|||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/recycler_devices"
|
android:id="@+id/recycler_devices"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="100dp"
|
android:layout_height="200dp"
|
||||||
android:layout_marginStart="20dp"
|
android:layout_marginStart="20dp"
|
||||||
android:layout_marginEnd="20dp" />
|
android:layout_marginEnd="20dp"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:layout_marginBottom="20dp"/>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1px"
|
||||||
|
android:layout_marginStart="20dp"
|
||||||
|
android:layout_marginEnd="20dp"
|
||||||
|
android:background="@color/gray_ac" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/text_remembered_devices"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/unable_to_see_the_Device_youre_looking_for_try_add_the_device_manually"
|
||||||
|
android:layout_marginStart="20dp"
|
||||||
|
android:layout_marginEnd="20dp"
|
||||||
|
android:textSize="9dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:textColor="@color/gray_c3"
|
||||||
|
android:maxLines="3"
|
||||||
|
android:fontFamily="@font/inter_light"
|
||||||
|
android:layout_marginTop="12dp"/>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/layout_remembered_devices_header"
|
android:id="@+id/layout_remembered_devices_header"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
<TextView
|
android:layout_marginBottom="20dp"
|
||||||
android:id="@+id/text_remembered_devices"
|
android:layout_marginStart="20dp"
|
||||||
android:layout_width="0dp"
|
android:layout_marginEnd="20dp">
|
||||||
android:layout_weight="3"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/remembered_devices"
|
|
||||||
android:layout_marginStart="20dp"
|
|
||||||
android:layout_marginEnd="20dp"
|
|
||||||
android:textSize="14dp"
|
|
||||||
android:ellipsize="end"
|
|
||||||
android:textColor="@color/white"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:fontFamily="@font/inter_regular" />
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/button_scan_qr"
|
|
||||||
android:layout_width="40dp"
|
|
||||||
android:layout_height="40dp"
|
|
||||||
android:contentDescription="@string/cd_button_scan_qr"
|
|
||||||
android:scaleType="centerCrop"
|
|
||||||
app:srcCompat="@drawable/ic_qr"
|
|
||||||
app:tint="@color/primary" />
|
|
||||||
|
|
||||||
<Space android:layout_width="0dp"
|
<Space android:layout_width="0dp"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<ImageButton
|
<LinearLayout
|
||||||
android:id="@+id/button_add"
|
android:id="@+id/button_add"
|
||||||
android:layout_width="40dp"
|
|
||||||
android:layout_height="40dp"
|
|
||||||
android:contentDescription="@string/cd_button_add"
|
|
||||||
android:scaleType="centerCrop"
|
|
||||||
app:srcCompat="@drawable/ic_add"
|
|
||||||
app:tint="@color/primary"
|
|
||||||
android:layout_marginEnd="20dp"/>
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/layout_remembered_devices"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/text_no_devices_remembered"
|
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="10dp"
|
android:orientation="horizontal"
|
||||||
android:text="@string/there_are_no_remembered_devices"
|
android:background="@drawable/background_border_2e_round_6dp"
|
||||||
android:layout_marginTop="10dp"
|
|
||||||
android:layout_marginBottom="20dp"
|
|
||||||
android:layout_marginStart="20dp"
|
|
||||||
android:layout_marginEnd="20dp"
|
android:layout_marginEnd="20dp"
|
||||||
android:textColor="@color/gray_e0" />
|
android:gravity="center">
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<ImageView
|
||||||
android:id="@+id/recycler_remembered_devices"
|
android:layout_width="22dp"
|
||||||
android:layout_width="match_parent"
|
android:layout_height="22dp"
|
||||||
android:layout_height="100dp"
|
app:srcCompat="@drawable/ic_add"
|
||||||
android:layout_marginStart="20dp"
|
android:layout_marginStart="8dp"/>
|
||||||
android:layout_marginEnd="20dp" />
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/add_manually"
|
||||||
|
android:textSize="12dp"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:fontFamily="@font/inter_medium"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="10dp"
|
||||||
|
android:paddingStart="4dp"
|
||||||
|
android:paddingEnd="12dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/button_qr"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:background="@drawable/background_border_2e_round_6dp"
|
||||||
|
android:gravity="center">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="22dp"
|
||||||
|
android:layout_height="22dp"
|
||||||
|
app:srcCompat="@drawable/ic_qr"
|
||||||
|
android:layout_marginStart="8dp"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/scan_qr"
|
||||||
|
android:textSize="12dp"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:fontFamily="@font/inter_medium"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="10dp"
|
||||||
|
android:paddingStart="4dp"
|
||||||
|
android:paddingEnd="12dp" />
|
||||||
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
@ -97,27 +97,7 @@
|
|||||||
app:layout_constraintTop_toBottomOf="@id/text_name"
|
app:layout_constraintTop_toBottomOf="@id/text_name"
|
||||||
app:layout_constraintLeft_toRightOf="@id/image_device" />
|
app:layout_constraintLeft_toRightOf="@id/image_device" />
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/button_disconnect"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:background="@drawable/background_button_accent"
|
|
||||||
android:paddingStart="8dp"
|
|
||||||
android:paddingEnd="8dp"
|
|
||||||
android:paddingTop="4dp"
|
|
||||||
android:paddingBottom="4dp"
|
|
||||||
app:layout_constraintRight_toRightOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="12dp"
|
|
||||||
android:textColor="@color/white"
|
|
||||||
android:fontFamily="@font/inter_light"
|
|
||||||
android:text="@string/stop" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
@ -253,4 +233,30 @@
|
|||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:paddingBottom="15dp">
|
android:paddingBottom="15dp">
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/button_disconnect"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="35dp"
|
||||||
|
android:background="@drawable/background_button_accent"
|
||||||
|
android:paddingStart="8dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="4dp"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
android:layout_marginStart="20dp"
|
||||||
|
android:layout_marginEnd="20dp"
|
||||||
|
android:layout_marginBottom="20dp"
|
||||||
|
android:gravity="center">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="12dp"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:fontFamily="@font/inter_light"
|
||||||
|
android:text="@string/stop_casting" />
|
||||||
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
@ -144,6 +144,9 @@
|
|||||||
android:layout_marginTop="10dp"
|
android:layout_marginTop="10dp"
|
||||||
android:layout_marginLeft="15dp"
|
android:layout_marginLeft="15dp"
|
||||||
android:layout_marginRight="15dp"
|
android:layout_marginRight="15dp"
|
||||||
|
android:inputType="text"
|
||||||
|
android:imeOptions="actionDone"
|
||||||
|
android:singleLine="true"
|
||||||
android:background="@drawable/background_button_round"
|
android:background="@drawable/background_button_round"
|
||||||
android:hint="Search.." />
|
android:hint="Search.." />
|
||||||
|
|
||||||
|
@ -1,14 +1,58 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
tools:context=".fragment.mainactivity.main.SuggestionsFragment">
|
tools:context=".fragment.mainactivity.main.SuggestionsFragment">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.AppBarLayout
|
||||||
|
android:id="@+id/app_bar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@color/transparent"
|
||||||
|
app:elevation="0dp">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.Toolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="0dp"
|
||||||
|
app:layout_scrollFlags="scroll"
|
||||||
|
app:contentInsetStart="0dp"
|
||||||
|
app:contentInsetEnd="0dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/container_toolbar_content"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.futo.platformplayer.views.announcements.AnnouncementView
|
||||||
|
android:id="@+id/announcement_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<com.futo.platformplayer.views.others.RadioGroupView
|
||||||
|
android:id="@+id/radio_group"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android: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
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/list_suggestions"
|
android:id="@+id/list_suggestions"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical" />
|
android:orientation="vertical"
|
||||||
|
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||||
|
|
||||||
</FrameLayout>
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
@ -4,18 +4,34 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="35dp"
|
android:layout_height="35dp"
|
||||||
android:clickable="true">
|
android:clickable="true"
|
||||||
|
android:id="@+id/layout_root">
|
||||||
|
|
||||||
<ImageView
|
<FrameLayout
|
||||||
android:id="@+id/image_device"
|
android:id="@+id/layout_device"
|
||||||
android:layout_width="25dp"
|
android:layout_width="25dp"
|
||||||
android:layout_height="25dp"
|
android:layout_height="25dp"
|
||||||
android:contentDescription="@string/cd_image_device"
|
|
||||||
app:srcCompat="@drawable/ic_chromecast"
|
|
||||||
android:scaleType="fitCenter"
|
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"/>
|
app:layout_constraintBottom_toBottomOf="parent">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/image_device"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:contentDescription="@string/cd_image_device"
|
||||||
|
app:srcCompat="@drawable/ic_chromecast"
|
||||||
|
android:scaleType="fitCenter" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/image_online"
|
||||||
|
android:layout_width="10dp"
|
||||||
|
android:layout_height="10dp"
|
||||||
|
android:layout_gravity="end|top"
|
||||||
|
android:contentDescription="@string/cd_image_device"
|
||||||
|
app:srcCompat="@drawable/ic_online"
|
||||||
|
android:scaleType="fitCenter" />
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/text_name"
|
android:id="@+id/text_name"
|
||||||
@ -31,8 +47,8 @@
|
|||||||
android:layout_marginStart="10dp"
|
android:layout_marginStart="10dp"
|
||||||
android:layout_marginEnd="10dp"
|
android:layout_marginEnd="10dp"
|
||||||
android:includeFontPadding="false"
|
android:includeFontPadding="false"
|
||||||
app:layout_constraintTop_toTopOf="@id/image_device"
|
app:layout_constraintTop_toTopOf="@id/layout_device"
|
||||||
app:layout_constraintLeft_toRightOf="@id/image_device"
|
app:layout_constraintLeft_toRightOf="@id/layout_device"
|
||||||
app:layout_constraintRight_toLeftOf="@id/layout_button" />
|
app:layout_constraintRight_toLeftOf="@id/layout_button" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
@ -43,12 +59,12 @@
|
|||||||
tools:text="Chromecast"
|
tools:text="Chromecast"
|
||||||
android:textSize="10dp"
|
android:textSize="10dp"
|
||||||
android:fontFamily="@font/inter_extra_light"
|
android:fontFamily="@font/inter_extra_light"
|
||||||
android:textColor="@color/white"
|
android:textColor="@color/gray_ac"
|
||||||
android:includeFontPadding="false"
|
android:includeFontPadding="false"
|
||||||
android:layout_marginStart="10dp"
|
android:layout_marginStart="10dp"
|
||||||
android:layout_marginEnd="10dp"
|
android:layout_marginEnd="10dp"
|
||||||
app:layout_constraintTop_toBottomOf="@id/text_name"
|
app:layout_constraintTop_toBottomOf="@id/text_name"
|
||||||
app:layout_constraintLeft_toRightOf="@id/image_device"
|
app:layout_constraintLeft_toRightOf="@id/layout_device"
|
||||||
app:layout_constraintRight_toLeftOf="@id/layout_button" />
|
app:layout_constraintRight_toLeftOf="@id/layout_button" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
@ -68,74 +84,15 @@
|
|||||||
app:srcCompat="@drawable/ic_loader_animated"
|
app:srcCompat="@drawable/ic_loader_animated"
|
||||||
android:layout_marginEnd="8dp"/>
|
android:layout_marginEnd="8dp"/>
|
||||||
|
|
||||||
<LinearLayout
|
<ImageView
|
||||||
|
android:id="@+id/image_pin"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="25dp"
|
||||||
android:orientation="horizontal"
|
android:contentDescription="@string/cd_image_loader"
|
||||||
app:layout_constraintRight_toRightOf="parent"
|
app:srcCompat="@drawable/ic_pin"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
android:layout_marginEnd="8dp"
|
||||||
app:layout_constraintBottom_toBottomOf="parent">
|
android:scaleType="fitEnd"
|
||||||
|
android:paddingStart="10dp" />
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/button_remove"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:background="@drawable/background_button_accent"
|
|
||||||
android:paddingStart="8dp"
|
|
||||||
android:paddingEnd="8dp"
|
|
||||||
android:paddingTop="4dp"
|
|
||||||
android:paddingBottom="4dp"
|
|
||||||
android:layout_marginEnd="7dp"
|
|
||||||
android:visibility="gone">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="12dp"
|
|
||||||
android:textColor="@color/white"
|
|
||||||
android:fontFamily="@font/inter_light"
|
|
||||||
android:text="@string/remove" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/button_disconnect"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:background="@drawable/background_button_accent"
|
|
||||||
android:paddingStart="8dp"
|
|
||||||
android:paddingEnd="8dp"
|
|
||||||
android:paddingTop="4dp"
|
|
||||||
android:paddingBottom="4dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="12dp"
|
|
||||||
android:textColor="@color/white"
|
|
||||||
android:fontFamily="@font/inter_light"
|
|
||||||
android:text="@string/stop" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/button_connect"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:background="@drawable/background_button_primary"
|
|
||||||
android:paddingStart="8dp"
|
|
||||||
android:paddingEnd="8dp"
|
|
||||||
android:paddingTop="4dp"
|
|
||||||
android:paddingBottom="4dp"
|
|
||||||
android:visibility="gone">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="12dp"
|
|
||||||
android:textColor="@color/white"
|
|
||||||
android:fontFamily="@font/inter_light"
|
|
||||||
android:text="@string/start" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/text_not_ready"
|
android:id="@+id/text_not_ready"
|
||||||
|
@ -135,7 +135,7 @@
|
|||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
|
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
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"
|
app:layout_constraintBottom_toTopOf="@id/text_author"
|
||||||
android:layout_marginStart="10dp" />
|
android:layout_marginStart="10dp" />
|
||||||
|
|
||||||
@ -152,7 +152,7 @@
|
|||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
|
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
|
||||||
app:layout_constraintTop_toBottomOf="@id/text_video_name"
|
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"
|
app:layout_constraintBottom_toTopOf="@id/text_video_metadata"
|
||||||
android:layout_marginStart="10dp" />
|
android:layout_marginStart="10dp" />
|
||||||
|
|
||||||
@ -169,19 +169,35 @@
|
|||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
|
app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail"
|
||||||
app:layout_constraintTop_toBottomOf="@id/text_author"
|
app:layout_constraintTop_toBottomOf="@id/text_author"
|
||||||
app:layout_constraintRight_toLeftOf="@id/image_trash"
|
app:layout_constraintRight_toLeftOf="@id/buttons"
|
||||||
android:layout_marginStart="10dp" />
|
android:layout_marginStart="10dp" />
|
||||||
|
|
||||||
<ImageButton
|
<LinearLayout
|
||||||
android:id="@+id/image_trash"
|
android:id="@+id/buttons"
|
||||||
android:layout_width="40dp"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="40dp"
|
android:layout_height="wrap_content"
|
||||||
android:contentDescription="@string/cd_button_delete"
|
android:orientation="vertical"
|
||||||
app:srcCompat="@drawable/ic_trash_18dp"
|
|
||||||
android:scaleType="fitCenter"
|
|
||||||
android:paddingTop="10dp"
|
|
||||||
android:paddingBottom="10dp"
|
|
||||||
app:layout_constraintRight_toRightOf="parent"
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="@id/layout_video_thumbnail"
|
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>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
@ -3,8 +3,8 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="32dp"
|
android:layout_height="32dp"
|
||||||
android:paddingStart="15dp"
|
android:paddingStart="12dp"
|
||||||
android:paddingEnd="15dp"
|
android:paddingEnd="12dp"
|
||||||
android:background="@drawable/background_pill"
|
android:background="@drawable/background_pill"
|
||||||
android:layout_marginEnd="6dp"
|
android:layout_marginEnd="6dp"
|
||||||
android:layout_marginTop="17dp"
|
android:layout_marginTop="17dp"
|
||||||
@ -19,12 +19,15 @@
|
|||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
android:layout_width="24dp"
|
android:layout_width="24dp"
|
||||||
android:layout_height="24dp"
|
android:layout_height="24dp"
|
||||||
android:layout_marginRight="5dp"
|
android:layout_gravity="center"
|
||||||
android:layout_marginTop="4dp" />
|
android:layout_marginLeft="2.5dp"
|
||||||
|
android:layout_marginRight="2.5dp" />
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/text_tag"
|
android:id="@+id/text_tag"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginLeft="2.5dp"
|
||||||
|
android:layout_marginRight="2.5dp"
|
||||||
android:textColor="@color/white"
|
android:textColor="@color/white"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
|
@ -72,6 +72,8 @@
|
|||||||
<string name="keep_screen_on_while_casting">Keep screen on while casting</string>
|
<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">Always proxy requests</string>
|
||||||
<string name="always_proxy_requests_description">Always proxy requests when casting data through the device.</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="discover">Discover</string>
|
||||||
<string name="find_new_video_sources_to_add">Find new video sources to add</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>
|
<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="not_ready">Not ready</string>
|
||||||
<string name="connect">Connect</string>
|
<string name="connect">Connect</string>
|
||||||
<string name="stop">Stop</string>
|
<string name="stop">Stop</string>
|
||||||
|
<string name="stop_casting">Stop casting</string>
|
||||||
<string name="start">Start</string>
|
<string name="start">Start</string>
|
||||||
<string name="storage_space">Storage Space</string>
|
<string name="storage_space">Storage Space</string>
|
||||||
<string name="downloads">Downloads</string>
|
<string name="downloads">Downloads</string>
|
||||||
@ -192,7 +195,9 @@
|
|||||||
<string name="ip">IP</string>
|
<string name="ip">IP</string>
|
||||||
<string name="port">Port</string>
|
<string name="port">Port</string>
|
||||||
<string name="discovered_devices">Discovered Devices</string>
|
<string name="discovered_devices">Discovered Devices</string>
|
||||||
|
<string name="available_devices">Available devices</string>
|
||||||
<string name="remembered_devices">Remembered Devices</string>
|
<string name="remembered_devices">Remembered Devices</string>
|
||||||
|
<string name="unable_to_see_the_Device_youre_looking_for_try_add_the_device_manually">Unable to see the device you\'re looking for? Try to add the device manually.</string>
|
||||||
<string name="there_are_no_remembered_devices">There are no remembered devices</string>
|
<string name="there_are_no_remembered_devices">There are no remembered devices</string>
|
||||||
<string name="connected_to">Connected to</string>
|
<string name="connected_to">Connected to</string>
|
||||||
<string name="volume">Volume</string>
|
<string name="volume">Volume</string>
|
||||||
@ -202,6 +207,7 @@
|
|||||||
<string name="previous">Previous</string>
|
<string name="previous">Previous</string>
|
||||||
<string name="next">Next</string>
|
<string name="next">Next</string>
|
||||||
<string name="comment">Comment</string>
|
<string name="comment">Comment</string>
|
||||||
|
<string name="add_manually">Add manually</string>
|
||||||
<string name="not_empty_close">Comment is not empty, close anyway?</string>
|
<string name="not_empty_close">Comment is not empty, close anyway?</string>
|
||||||
<string name="str_import">Import</string>
|
<string name="str_import">Import</string>
|
||||||
<string name="my_playlist_name">My Playlist Name</string>
|
<string name="my_playlist_name">My Playlist Name</string>
|
||||||
@ -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_discovered_description">Allow device to search for and initiate connection with known paired devices</string>
|
||||||
<string name="connect_last">Try connect last</string>
|
<string name="connect_last">Try connect last</string>
|
||||||
<string name="connect_last_description">Allow device to automatically connect to last known</string>
|
<string name="connect_last_description">Allow device to automatically connect to last known</string>
|
||||||
|
<string name="discover_through_relay">Discover through relay</string>
|
||||||
|
<string name="discover_through_relay_description">Allow paired devices to be discovered and connected to through the relay</string>
|
||||||
|
<string name="pair_through_relay">Pair through relay</string>
|
||||||
|
<string name="pair_through_relay_description">Allow devices to be paired through the relay</string>
|
||||||
|
<string name="connect_through_relay">Connection through relay</string>
|
||||||
|
<string name="connect_through_relay_description">Allow devices to be connected to through the relay</string>
|
||||||
<string name="gesture_controls">Gesture controls</string>
|
<string name="gesture_controls">Gesture controls</string>
|
||||||
<string name="volume_slider">Volume slider</string>
|
<string name="volume_slider">Volume slider</string>
|
||||||
<string name="volume_slider_descr">Enable slide gesture to change volume</string>
|
<string name="volume_slider_descr">Enable slide gesture to change volume</string>
|
||||||
@ -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="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">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_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="log_level">Log Level</string>
|
||||||
<string name="logging">Logging</string>
|
<string name="logging">Logging</string>
|
||||||
<string name="sync_grayjay">Sync Grayjay</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
|
@ -9,6 +9,7 @@ import com.futo.platformplayer.noise.protocol.HandshakeState
|
|||||||
import com.futo.platformplayer.noise.protocol.Noise
|
import com.futo.platformplayer.noise.protocol.Noise
|
||||||
import com.futo.platformplayer.states.StateSync
|
import com.futo.platformplayer.states.StateSync
|
||||||
import com.futo.platformplayer.sync.internal.IAuthorizable
|
import com.futo.platformplayer.sync.internal.IAuthorizable
|
||||||
|
import com.futo.platformplayer.sync.internal.Opcode
|
||||||
import com.futo.platformplayer.sync.internal.SyncSocketSession
|
import com.futo.platformplayer.sync.internal.SyncSocketSession
|
||||||
import com.futo.platformplayer.sync.internal.SyncStream
|
import com.futo.platformplayer.sync.internal.SyncStream
|
||||||
import junit.framework.TestCase.assertEquals
|
import junit.framework.TestCase.assertEquals
|
||||||
@ -586,16 +587,16 @@ class NoiseProtocolTest {
|
|||||||
handshakeLatch.await(10, TimeUnit.SECONDS)
|
handshakeLatch.await(10, TimeUnit.SECONDS)
|
||||||
|
|
||||||
// Simulate initiator sending a PING and responder replying with PONG
|
// Simulate initiator sending a PING and responder replying with PONG
|
||||||
initiatorSession.send(SyncSocketSession.Opcode.PING.value)
|
initiatorSession.send(Opcode.PING.value)
|
||||||
responderSession.send(SyncSocketSession.Opcode.PONG.value)
|
responderSession.send(Opcode.PONG.value)
|
||||||
|
|
||||||
// Test data transfer
|
// Test data transfer
|
||||||
responderSession.send(SyncSocketSession.Opcode.DATA.value, 0u, randomBytesExactlyOnePacket)
|
responderSession.send(Opcode.DATA.value, 0u, randomBytesExactlyOnePacket)
|
||||||
initiatorSession.send(SyncSocketSession.Opcode.DATA.value, 1u, randomBytes)
|
initiatorSession.send(Opcode.DATA.value, 1u, randomBytes)
|
||||||
|
|
||||||
// Send large data to test stream handling
|
// Send large data to test stream handling
|
||||||
val start = System.currentTimeMillis()
|
val start = System.currentTimeMillis()
|
||||||
responderSession.send(SyncSocketSession.Opcode.DATA.value, 0u, randomBytesBig)
|
responderSession.send(Opcode.DATA.value, 0u, randomBytesBig)
|
||||||
println("Sent 10MB in ${System.currentTimeMillis() - start}ms")
|
println("Sent 10MB in ${System.currentTimeMillis() - start}ms")
|
||||||
|
|
||||||
// Wait for a brief period to simulate delay and allow communication
|
// Wait for a brief period to simulate delay and allow communication
|
||||||
|
@ -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
|
Loading…
x
Reference in New Issue
Block a user