mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-08-22 02:09:32 +00:00
Merge branch 'master' into linked-channel-shorts-fix
# Conflicts: # app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt
This commit is contained in:
commit
cc3639180b
122 changed files with 3185 additions and 3421 deletions
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
aar/* filter=lfs diff=lfs merge=lfs -text
|
||||
app/aar/* filter=lfs diff=lfs merge=lfs -text
|
6
.gitmodules
vendored
6
.gitmodules
vendored
|
@ -94,3 +94,9 @@
|
|||
[submodule "app/src/unstable/assets/sources/tedtalks"]
|
||||
path = app/src/unstable/assets/sources/tedtalks
|
||||
url = ../plugins/tedtalks.git
|
||||
[submodule "app/src/stable/assets/sources/curiositystream"]
|
||||
path = app/src/stable/assets/sources/curiositystream
|
||||
url = ../plugins/curiositystream.git
|
||||
[submodule "app/src/unstable/assets/sources/curiositystream"]
|
||||
path = app/src/unstable/assets/sources/curiositystream
|
||||
url = ../plugins/curiositystream.git
|
||||
|
|
BIN
app/aar/ffmpeg-kit-full-6.0-2.LTS.aar
(Stored with Git LFS)
Normal file
BIN
app/aar/ffmpeg-kit-full-6.0-2.LTS.aar
(Stored with Git LFS)
Normal file
Binary file not shown.
|
@ -197,7 +197,7 @@ dependencies {
|
|||
implementation 'org.jsoup:jsoup:1.15.3'
|
||||
implementation 'com.google.android.flexbox:flexbox:3.0.0'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'com.arthenica:ffmpeg-kit-full:6.0-2.LTS'
|
||||
implementation fileTree(dir: 'aar', include: ['*.aar'])
|
||||
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
|
||||
implementation 'com.github.dhaval2404:imagepicker:2.1'
|
||||
implementation 'com.google.zxing:core:3.4.1'
|
||||
|
|
|
@ -3,19 +3,21 @@ 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.175"
|
||||
private val relayHost = "192.168.1.138"
|
||||
private val relayPort = 9000
|
||||
|
||||
/** Creates a client connected to the live relay server. */
|
||||
|
@ -23,7 +25,8 @@ class SyncServerTests {
|
|||
onHandshakeComplete: ((SyncSocketSession) -> Unit)? = null,
|
||||
onData: ((SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit)? = null,
|
||||
onNewChannel: ((SyncSocketSession, ChannelRelayed) -> Unit)? = null,
|
||||
isHandshakeAllowed: ((SyncSocketSession, String, String?) -> Boolean)? = null
|
||||
isHandshakeAllowed: ((LinkType, SyncSocketSession, String, String?, UInt) -> Boolean)? = null,
|
||||
onException: ((Throwable) -> Unit)? = null
|
||||
): SyncSocketSession = withContext(Dispatchers.IO) {
|
||||
val p = Noise.createDH("25519")
|
||||
p.generateKeyPair()
|
||||
|
@ -43,10 +46,14 @@ class SyncServerTests {
|
|||
},
|
||||
onData = onData ?: { _, _, _, _ -> },
|
||||
onNewChannel = onNewChannel ?: { _, _ -> },
|
||||
isHandshakeAllowed = isHandshakeAllowed ?: { _, _, _ -> true }
|
||||
isHandshakeAllowed = isHandshakeAllowed ?: { _, _, _, _, _ -> true }
|
||||
)
|
||||
socketSession.authorizable = AlwaysAuthorized()
|
||||
socketSession.startAsInitiator(relayKey)
|
||||
try {
|
||||
socketSession.startAsInitiator(relayKey)
|
||||
} catch (e: Throwable) {
|
||||
onException?.invoke(e)
|
||||
}
|
||||
withTimeout(5000.milliseconds) { tcs.await() }
|
||||
return@withContext socketSession
|
||||
}
|
||||
|
@ -259,6 +266,71 @@ class SyncServerTests {
|
|||
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 {
|
||||
|
|
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
|
||||
}
|
|
@ -15,6 +15,7 @@
|
|||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
@ -55,7 +56,7 @@
|
|||
|
||||
<activity
|
||||
android:name=".activities.MainActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||
android:launchMode="singleInstance"
|
||||
|
|
|
@ -595,6 +595,8 @@ class PlatformComment {
|
|||
this.date = obj.date ?? 0;
|
||||
this.replyCount = obj.replyCount ?? 0;
|
||||
this.context = obj.context ?? {};
|
||||
if(obj.getReplies)
|
||||
this.getReplies = obj.getReplies;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,7 +14,6 @@ import java.text.DecimalFormat
|
|||
import java.time.OffsetDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
|
||||
|
@ -376,14 +375,19 @@ private val slds = hashSetOf(".com.ac", ".net.ac", ".gov.ac", ".org.ac", ".mil.a
|
|||
fun String.matchesDomain(queryDomain: String): Boolean {
|
||||
|
||||
if(queryDomain.startsWith(".")) {
|
||||
|
||||
val parts = queryDomain.lowercase().split(".");
|
||||
if(parts.size < 3)
|
||||
val parts = this.lowercase().split(".");
|
||||
val queryParts = queryDomain.lowercase().trimStart("."[0]).split(".");
|
||||
if(queryParts.size < 2)
|
||||
throw IllegalStateException("Illegal use of wildcards on First-Level-Domain (" + queryDomain + ")");
|
||||
if(parts.size >= 3){
|
||||
val isSLD = slds.contains("." + parts[parts.size - 2] + "." + parts[parts.size - 1]);
|
||||
if(isSLD && parts.size <= 3)
|
||||
else {
|
||||
val possibleDomain = "." + queryParts.joinToString(".");
|
||||
if(slds.contains(possibleDomain))
|
||||
throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")");
|
||||
/*
|
||||
val isSLD = slds.contains("." + queryParts[queryParts.size - 2] + "." + queryParts[queryParts.size - 1]);
|
||||
if(isSLD && queryParts.size <= 3)
|
||||
throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")");
|
||||
*/
|
||||
}
|
||||
|
||||
//TODO: Should be safe, but double verify if can't be exploited
|
||||
|
@ -395,9 +399,11 @@ fun String.matchesDomain(queryDomain: String): Boolean {
|
|||
|
||||
fun String.getSubdomainWildcardQuery(): String {
|
||||
val domainParts = this.split(".");
|
||||
val sldParts = "." + domainParts[domainParts.size - 2].lowercase() + "." + domainParts[domainParts.size - 1].lowercase();
|
||||
if(slds.contains(sldParts))
|
||||
return "." + domainParts.drop(domainParts.size - 3).joinToString(".");
|
||||
var wildcardDomain = if(domainParts.size > 2)
|
||||
"." + domainParts.drop(1).joinToString(".")
|
||||
else
|
||||
return "." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||
"." + domainParts.joinToString(".");
|
||||
if(slds.contains(wildcardDomain.lowercase()))
|
||||
"." + domainParts.joinToString(".");
|
||||
return wildcardDomain;
|
||||
}
|
|
@ -217,6 +217,8 @@ private fun ByteArray.toInetAddress(): InetAddress {
|
|||
}
|
||||
|
||||
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
|
||||
ensureNotMainThread()
|
||||
|
||||
val timeout = 2000
|
||||
|
||||
|
||||
|
|
|
@ -7,6 +7,9 @@ import java.net.InetAddress
|
|||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URLEncoder
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
//Syntax sugaring
|
||||
inline fun <reified T> Any.assume(): T?{
|
||||
|
@ -33,13 +36,37 @@ fun Boolean?.toYesNo(): String {
|
|||
fun InetAddress?.toUrlAddress(): String {
|
||||
return when (this) {
|
||||
is Inet6Address -> {
|
||||
"[${hostAddress}]"
|
||||
val hostAddr = this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
|
||||
val index = hostAddr.indexOf('%')
|
||||
if (index != -1) {
|
||||
val addrPart = hostAddr.substring(0, index)
|
||||
val scopeId = hostAddr.substring(index + 1)
|
||||
"[${addrPart}%25${scopeId}]" // %25 is URL-encoded '%'
|
||||
} else {
|
||||
"[$hostAddr]"
|
||||
}
|
||||
}
|
||||
is Inet4Address -> {
|
||||
hostAddress
|
||||
this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
|
||||
}
|
||||
else -> {
|
||||
throw Exception("Invalid address type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Long?.sToOffsetDateTimeUTC(): OffsetDateTime {
|
||||
if (this == null || this < 0)
|
||||
return OffsetDateTime.MIN
|
||||
if(this > 4070912400)
|
||||
return OffsetDateTime.MAX;
|
||||
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(this), ZoneOffset.UTC)
|
||||
}
|
||||
|
||||
fun Long?.msToOffsetDateTimeUTC(): OffsetDateTime {
|
||||
if (this == null || this < 0)
|
||||
return OffsetDateTime.MIN
|
||||
if(this > 4070912400)
|
||||
return OffsetDateTime.MAX;
|
||||
return OffsetDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneOffset.UTC)
|
||||
}
|
|
@ -499,6 +499,22 @@ class Settings : FragmentedStorageFileJson() {
|
|||
|
||||
@FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22)
|
||||
var deleteFromWatchLaterAuto: Boolean = true;
|
||||
|
||||
@FormField(R.string.seek_offset, FieldForm.DROPDOWN, R.string.seek_offset_description, 23)
|
||||
@DropdownFieldOptionsId(R.array.seek_offset_duration)
|
||||
var seekOffset: Int = 2;
|
||||
|
||||
fun getSeekOffset(): Long {
|
||||
return when(seekOffset) {
|
||||
0 -> 3_000L;
|
||||
1 -> 5_000L;
|
||||
2 -> 10_000L;
|
||||
3 -> 20_000L;
|
||||
4 -> 30_000L;
|
||||
5 -> 60_000L;
|
||||
else -> 10_000L;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||
|
@ -590,7 +606,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
|
||||
@FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var allowIpv6: Boolean = false;
|
||||
var allowIpv6: Boolean = true;
|
||||
|
||||
/*TODO: Should we have a different casting quality?
|
||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||
|
@ -926,7 +942,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
@Serializable
|
||||
class Synchronization {
|
||||
@FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1)
|
||||
var enabled: Boolean = true;
|
||||
var enabled: Boolean = false;
|
||||
|
||||
@FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1)
|
||||
var broadcast: Boolean = false;
|
||||
|
@ -945,6 +961,12 @@ class Settings : FragmentedStorageFileJson() {
|
|||
|
||||
@FormField(R.string.connect_through_relay, FieldForm.TOGGLE, R.string.connect_through_relay_description, 3)
|
||||
var connectThroughRelay: Boolean = true;
|
||||
|
||||
@FormField(R.string.connect_local_direct_through_relay, FieldForm.TOGGLE, R.string.connect_local_direct_through_relay_description, 3)
|
||||
var connectLocalDirectThroughRelay: Boolean = true;
|
||||
|
||||
@FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3)
|
||||
var localConnections: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
|
||||
|
|
|
@ -684,6 +684,10 @@ class UISlideOverlays {
|
|||
}
|
||||
}
|
||||
}
|
||||
if(!Settings.instance.downloads.shouldDownload()) {
|
||||
UIDialogs.appToast("Download will start when you're back on wifi.\n" +
|
||||
"(You can change this in settings)", true);
|
||||
}
|
||||
}
|
||||
};
|
||||
return menu.apply { show() };
|
||||
|
|
|
@ -69,7 +69,14 @@ fun warnIfMainThread(context: String) {
|
|||
}
|
||||
|
||||
fun ensureNotMainThread() {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
val isMainLooper = try {
|
||||
Looper.myLooper() == Looper.getMainLooper()
|
||||
} catch (e: Throwable) {
|
||||
//Ignore, for unit tests where its not mocked
|
||||
false
|
||||
}
|
||||
|
||||
if (isMainLooper) {
|
||||
Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread")
|
||||
throw IllegalStateException("Cannot run on main thread")
|
||||
}
|
||||
|
@ -272,7 +279,7 @@ fun <T> findNewIndex(originalArr: List<T>, newArr: List<T>, item: T): Int{
|
|||
}
|
||||
}
|
||||
if(newIndex < 0)
|
||||
return originalArr.size;
|
||||
return newArr.size;
|
||||
else
|
||||
return newIndex;
|
||||
}
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ComponentName
|
||||
import android.app.AlertDialog
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.VmPolicy
|
||||
|
@ -21,8 +22,10 @@ import android.widget.ImageView
|
|||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
|
@ -38,6 +41,7 @@ import com.futo.platformplayer.UIDialogs
|
|||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.BuyFragment
|
||||
|
@ -65,6 +69,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragm
|
|||
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.TutorialFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.State
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment
|
||||
|
@ -74,7 +79,6 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
|||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImportCache
|
||||
import com.futo.platformplayer.models.UrlVideoWithTime
|
||||
import com.futo.platformplayer.receivers.MediaButtonReceiver
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateBackup
|
||||
|
@ -185,6 +189,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
|
||||
private var _isVisible = true;
|
||||
private var _wasStopped = false;
|
||||
private var _privateModeEnabled = false
|
||||
private var _pictureInPictureEnabled = false
|
||||
private var _isFullscreen = false
|
||||
|
||||
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
|
@ -262,6 +269,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
StateApp.instance.mainAppStarting(this);
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val uiMode = getSystemService(UiModeManager::class.java)
|
||||
uiMode.setApplicationNightMode(UiModeManager.MODE_NIGHT_YES)
|
||||
}
|
||||
setContentView(R.layout.activity_main);
|
||||
setNavigationBarColorAndIcons();
|
||||
if (Settings.instance.playback.allowVideoToGoUnderCutout)
|
||||
|
@ -354,22 +365,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
_fragMainSubscriptionsFeed.setPreviewsEnabled(true);
|
||||
_fragContainerVideoDetail.visibility = View.INVISIBLE;
|
||||
updateSegmentPaddings();
|
||||
updatePrivateModeVisibility()
|
||||
};
|
||||
|
||||
|
||||
_buttonIncognito = findViewById(R.id.incognito_button);
|
||||
_buttonIncognito.elevation = -99f;
|
||||
_buttonIncognito.alpha = 0f;
|
||||
updatePrivateModeVisibility()
|
||||
StateApp.instance.privateModeChanged.subscribe {
|
||||
//Messing with visibility causes some issues with layout ordering?
|
||||
if (it) {
|
||||
_buttonIncognito.elevation = 99f;
|
||||
_buttonIncognito.alpha = 1f;
|
||||
} else {
|
||||
_buttonIncognito.elevation = -99f;
|
||||
_buttonIncognito.alpha = 0f;
|
||||
}
|
||||
_privateModeEnabled = it
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
_buttonIncognito.setOnClickListener {
|
||||
if (!StateApp.instance.privateMode)
|
||||
return@setOnClickListener;
|
||||
|
@ -386,19 +393,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
};
|
||||
_fragVideoDetail.onFullscreenChanged.subscribe {
|
||||
Logger.i(TAG, "onFullscreenChanged ${it}");
|
||||
_isFullscreen = it
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
if (it) {
|
||||
_buttonIncognito.elevation = -99f;
|
||||
_buttonIncognito.alpha = 0f;
|
||||
} else {
|
||||
if (StateApp.instance.privateMode) {
|
||||
_buttonIncognito.elevation = 99f;
|
||||
_buttonIncognito.alpha = 1f;
|
||||
} else {
|
||||
_buttonIncognito.elevation = -99f;
|
||||
_buttonIncognito.alpha = 0f;
|
||||
}
|
||||
}
|
||||
_fragVideoDetail.onMinimize.subscribe {
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
_fragVideoDetail.onMaximized.subscribe {
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
StatePlayer.instance.also {
|
||||
|
@ -613,8 +617,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
UIDialogs.toast(this, "No external file permissions\nExporting and auto backups will not work");
|
||||
}*/
|
||||
|
||||
private var _qrCodeLoadingDialog: AlertDialog? = null
|
||||
|
||||
fun showUrlQrCodeScanner() {
|
||||
try {
|
||||
_qrCodeLoadingDialog = UIDialogs.showDialog(this, R.drawable.ic_loader_animated, true,
|
||||
"Launching QR scanner",
|
||||
"Make sure your camera is enabled", null, -2,
|
||||
UIDialogs.Action("Close", {
|
||||
_qrCodeLoadingDialog?.dismiss()
|
||||
_qrCodeLoadingDialog = null
|
||||
}));
|
||||
|
||||
val integrator = IntentIntegrator(this)
|
||||
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
||||
integrator.setPrompt(getString(R.string.scan_a_qr_code))
|
||||
|
@ -630,6 +644,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun updatePrivateModeVisibility() {
|
||||
if (_privateModeEnabled && (_fragVideoDetail.state == State.CLOSED || !_pictureInPictureEnabled && !_isFullscreen)) {
|
||||
_buttonIncognito.elevation = 99f;
|
||||
_buttonIncognito.alpha = 1f;
|
||||
_buttonIncognito.translationY = if (_fragVideoDetail.state == State.MINIMIZED) -60.dp(resources).toFloat() else 0f
|
||||
} else {
|
||||
_buttonIncognito.elevation = -99f;
|
||||
_buttonIncognito.alpha = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume();
|
||||
Logger.v(TAG, "onResume")
|
||||
|
@ -640,6 +666,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
super.onPause();
|
||||
Logger.v(TAG, "onPause")
|
||||
_isVisible = false;
|
||||
|
||||
_qrCodeLoadingDialog?.dismiss()
|
||||
_qrCodeLoadingDialog = null
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
|
@ -1050,6 +1079,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
Logger.v(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop")
|
||||
_fragVideoDetail.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig);
|
||||
Logger.v(TAG, "onPictureInPictureModeChanged Ready");
|
||||
|
||||
_pictureInPictureEnabled = isInPictureInPictureMode
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
|
|
@ -9,6 +9,7 @@ import android.widget.LinearLayout
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateSync
|
||||
|
@ -29,6 +30,16 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (StateApp.instance.contextOrNull == null) {
|
||||
Logger.w(TAG, "No main activity, restarting main.")
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
startActivity(intent)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
setContentView(R.layout.activity_sync_home)
|
||||
setNavigationBarColorAndIcons()
|
||||
|
||||
|
@ -54,7 +65,6 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||
val view = _viewMap[publicKey]
|
||||
if (!session.isAuthorized) {
|
||||
if (view != null) {
|
||||
_layoutDevices.removeView(view)
|
||||
_viewMap.remove(publicKey)
|
||||
}
|
||||
return@launch
|
||||
|
@ -89,6 +99,14 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||
updateEmptyVisibility()
|
||||
}
|
||||
}
|
||||
|
||||
StateSync.instance.confirmStarted(this, {
|
||||
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity)
|
||||
}, {
|
||||
finish()
|
||||
}, {
|
||||
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity)
|
||||
})
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
@ -100,11 +118,12 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||
|
||||
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
|
||||
val connected = session?.connected ?: false
|
||||
val authorized = session?.isAuthorized ?: false
|
||||
|
||||
syncDeviceView.setLinkType(session?.linkType ?: LinkType.None)
|
||||
.setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey)
|
||||
//TODO: also display public key?
|
||||
.setStatus(if (connected) "Connected" else "Disconnected")
|
||||
.setStatus(if (connected && authorized) "Connected" else "Disconnected or unauthorized")
|
||||
return syncDeviceView
|
||||
}
|
||||
|
||||
|
|
|
@ -83,6 +83,7 @@ class SyncPairActivity : AppCompatActivity() {
|
|||
|
||||
_layoutPairingSuccess.setOnClickListener {
|
||||
_layoutPairingSuccess.visibility = View.GONE
|
||||
finish()
|
||||
}
|
||||
_layoutPairingError.setOnClickListener {
|
||||
_layoutPairingError.visibility = View.GONE
|
||||
|
@ -109,11 +110,17 @@ class SyncPairActivity : AppCompatActivity() {
|
|||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
StateSync.instance.connect(deviceInfo) { complete, message ->
|
||||
StateSync.instance.syncService?.connect(deviceInfo) { complete, message ->
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
if (complete != null && complete) {
|
||||
_layoutPairingSuccess.visibility = View.VISIBLE
|
||||
_layoutPairing.visibility = View.GONE
|
||||
if (complete != null) {
|
||||
if (complete) {
|
||||
_layoutPairingSuccess.visibility = View.VISIBLE
|
||||
_layoutPairing.visibility = View.GONE
|
||||
} else {
|
||||
_textError.text = message
|
||||
_layoutPairingError.visibility = View.VISIBLE
|
||||
_layoutPairing.visibility = View.GONE
|
||||
}
|
||||
} else {
|
||||
_textPairingStatus.text = message
|
||||
}
|
||||
|
@ -137,8 +144,6 @@ class SyncPairActivity : AppCompatActivity() {
|
|||
_textError.text = e.message
|
||||
_layoutPairing.visibility = View.GONE
|
||||
Logger.e(TAG, "Failed to pair", e)
|
||||
} finally {
|
||||
_layoutPairing.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -67,11 +67,18 @@ class SyncShowPairingCodeActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
val ips = getIPs()
|
||||
val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT, StateSync.instance.pairingCode)
|
||||
val json = Json.encodeToString(selfDeviceInfo)
|
||||
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
val url = "grayjay://sync/${base64}"
|
||||
setCode(url)
|
||||
val publicKey = StateSync.instance.syncService?.publicKey
|
||||
val pairingCode = StateSync.instance.syncService?.pairingCode
|
||||
if (publicKey == null || pairingCode == null) {
|
||||
setCode("Public key or pairing code was not known, is sync enabled?")
|
||||
} else {
|
||||
val selfDeviceInfo = SyncDeviceInfo(publicKey, ips.toTypedArray(), StateSync.PORT, pairingCode)
|
||||
val json = Json.encodeToString(selfDeviceInfo)
|
||||
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
val url = "grayjay://sync/${base64}"
|
||||
setCode(url)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun setCode(code: String?) {
|
||||
|
|
|
@ -90,6 +90,7 @@ open class ManagedHttpClient {
|
|||
}
|
||||
|
||||
fun tryHead(url: String): Map<String, String>? {
|
||||
ensureNotMainThread()
|
||||
try {
|
||||
val result = head(url);
|
||||
if(result.isOk)
|
||||
|
@ -104,7 +105,7 @@ open class ManagedHttpClient {
|
|||
}
|
||||
|
||||
fun socket(url: String, headers: MutableMap<String, String> = HashMap(), listener: SocketListener): Socket {
|
||||
|
||||
ensureNotMainThread()
|
||||
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
|
||||
.url(url);
|
||||
if(user_agent.isNotEmpty() && !headers.any { it.key.lowercase() == "user-agent" })
|
||||
|
@ -300,6 +301,7 @@ open class ManagedHttpClient {
|
|||
}
|
||||
|
||||
fun send(msg: String) {
|
||||
ensureNotMainThread()
|
||||
socket.send(msg);
|
||||
}
|
||||
|
||||
|
|
|
@ -14,14 +14,16 @@ class PlatformClientPool {
|
|||
private var _poolCounter = 0;
|
||||
private val _poolName: String?;
|
||||
private val _privatePool: Boolean;
|
||||
private val _isolatedInitialization: Boolean
|
||||
|
||||
var isDead: Boolean = false
|
||||
private set;
|
||||
val onDead = Event2<JSClient, PlatformClientPool>();
|
||||
|
||||
constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false) {
|
||||
constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false, isolatedInitialization: Boolean = false) {
|
||||
_poolName = name;
|
||||
_privatePool = privatePool;
|
||||
_isolatedInitialization = isolatedInitialization
|
||||
if(parentClient !is JSClient)
|
||||
throw IllegalArgumentException("Pooling only supported for JSClients right now");
|
||||
Logger.i(TAG, "Pool for ${parentClient.name} was started");
|
||||
|
@ -53,7 +55,7 @@ class PlatformClientPool {
|
|||
reserved = _pool.keys.find { !it.isBusy };
|
||||
if(reserved == null && _pool.size < capacity) {
|
||||
Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})");
|
||||
reserved = _parent.getCopy(_privatePool);
|
||||
reserved = _parent.getCopy(_privatePool, _isolatedInitialization);
|
||||
|
||||
reserved?.onCaptchaException?.subscribe { client, ex ->
|
||||
StateApp.instance.handleCaptchaException(client, ex);
|
||||
|
|
|
@ -7,13 +7,15 @@ class PlatformMultiClientPool {
|
|||
|
||||
private var _isFake = false;
|
||||
private var _privatePool = false;
|
||||
private val _isolatedInitialization: Boolean
|
||||
|
||||
constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false) {
|
||||
constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false, isolatedInitialization: Boolean = false) {
|
||||
_name = name;
|
||||
_maxCap = if(maxCap > 0)
|
||||
maxCap
|
||||
else 99;
|
||||
_privatePool = isPrivatePool;
|
||||
_isolatedInitialization = isolatedInitialization
|
||||
}
|
||||
|
||||
fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient {
|
||||
|
@ -21,7 +23,7 @@ class PlatformMultiClientPool {
|
|||
return parentClient;
|
||||
val pool = synchronized(_clientPools) {
|
||||
if(!_clientPools.containsKey(parentClient))
|
||||
_clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool).apply {
|
||||
_clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool, _isolatedInitialization).apply {
|
||||
this.onDead.subscribe { _, pool ->
|
||||
synchronized(_clientPools) {
|
||||
if(_clientPools[parentClient] == pool)
|
||||
|
|
|
@ -54,8 +54,11 @@ class DevJSClient : JSClient {
|
|||
return DevJSClient(context, config, _devScript, _auth, _captcha, devID, descriptor.settings);
|
||||
}
|
||||
|
||||
override fun getCopy(privateCopy: Boolean): JSClient {
|
||||
return DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, saveState(), devID);
|
||||
override fun getCopy(privateCopy: Boolean, noSaveState: Boolean): JSClient {
|
||||
val client = DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, if (noSaveState) null else saveState(), devID);
|
||||
if (noSaveState)
|
||||
client.initialize()
|
||||
return client
|
||||
}
|
||||
|
||||
override fun initialize() {
|
||||
|
|
|
@ -195,8 +195,11 @@ open class JSClient : IPlatformClient {
|
|||
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
|
||||
}
|
||||
|
||||
open fun getCopy(withoutCredentials: Boolean = false): JSClient {
|
||||
return JSClient(_context, descriptor, saveState(), _script, withoutCredentials);
|
||||
open fun getCopy(withoutCredentials: Boolean = false, noSaveState: Boolean = false): JSClient {
|
||||
val client = JSClient(_context, descriptor, if (noSaveState) null else saveState(), _script, withoutCredentials);
|
||||
if (noSaveState)
|
||||
client.initialize()
|
||||
return client
|
||||
}
|
||||
|
||||
fun getUnderlyingPlugin(): V8Plugin {
|
||||
|
@ -211,6 +214,8 @@ open class JSClient : IPlatformClient {
|
|||
}
|
||||
|
||||
override fun initialize() {
|
||||
if (_initialized) return
|
||||
|
||||
Logger.i(TAG, "Plugin [${config.name}] initializing");
|
||||
plugin.start();
|
||||
plugin.execute("plugin.config = ${Json.encodeToString(config)}");
|
||||
|
|
|
@ -127,7 +127,7 @@ class JSHttpClient : ManagedHttpClient {
|
|||
}
|
||||
|
||||
if(doApplyCookies) {
|
||||
if (_currentCookieMap.isNotEmpty()) {
|
||||
if (_currentCookieMap.isNotEmpty() || _otherCookieMap.isNotEmpty()) {
|
||||
val cookiesToApply = hashMapOf<String, String>();
|
||||
synchronized(_currentCookieMap) {
|
||||
for(cookie in _currentCookieMap
|
||||
|
@ -135,6 +135,12 @@ class JSHttpClient : ManagedHttpClient {
|
|||
.flatMap { it.value.toList() })
|
||||
cookiesToApply[cookie.first] = cookie.second;
|
||||
};
|
||||
synchronized(_otherCookieMap) {
|
||||
for(cookie in _otherCookieMap
|
||||
.filter { domain.matchesDomain(it.key) }
|
||||
.flatMap { it.value.toList() })
|
||||
cookiesToApply[cookie.first] = cookie.second;
|
||||
}
|
||||
|
||||
if(cookiesToApply.size > 0) {
|
||||
val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; ");
|
||||
|
|
|
@ -149,6 +149,7 @@ class AirPlayCastingDevice : CastingDevice {
|
|||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
|
||||
delay(1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -322,6 +322,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ import com.futo.platformplayer.toInetAddress
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
@ -289,6 +290,7 @@ class FCastCastingDevice : CastingDevice {
|
|||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,10 +4,12 @@ import android.app.AlertDialog
|
|||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.util.Xml
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.R
|
||||
|
@ -40,8 +42,6 @@ import com.futo.platformplayer.constructs.Event1
|
|||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.mdns.DnsService
|
||||
import com.futo.platformplayer.mdns.ServiceDiscoverer
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.parsers.HLS
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
|
@ -55,7 +55,6 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.net.InetAddress
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
|
@ -70,7 +69,6 @@ class StateCasting {
|
|||
private var _started = false;
|
||||
|
||||
var devices: HashMap<String, CastingDevice> = hashMapOf();
|
||||
var rememberedDevices: ArrayList<CastingDevice> = arrayListOf();
|
||||
val onDeviceAdded = Event1<CastingDevice>();
|
||||
val onDeviceChanged = Event1<CastingDevice>();
|
||||
val onDeviceRemoved = Event1<CastingDevice>();
|
||||
|
@ -84,48 +82,15 @@ class StateCasting {
|
|||
private var _audioExecutor: JSRequestExecutor? = null
|
||||
private val _client = ManagedHttpClient();
|
||||
var _resumeCastingDevice: CastingDeviceInfo? = null;
|
||||
val _serviceDiscoverer = ServiceDiscoverer(arrayOf(
|
||||
"_googlecast._tcp.local",
|
||||
"_airplay._tcp.local",
|
||||
"_fastcast._tcp.local",
|
||||
"_fcast._tcp.local"
|
||||
)) { handleServiceUpdated(it) }
|
||||
|
||||
private var _nsdManager: NsdManager? = null
|
||||
val isCasting: Boolean get() = activeDevice != null;
|
||||
|
||||
private fun handleServiceUpdated(services: List<DnsService>) {
|
||||
for (s in services) {
|
||||
//TODO: Addresses IPv4 only?
|
||||
val addresses = s.addresses.toTypedArray()
|
||||
val port = s.port.toInt()
|
||||
var name = s.texts.firstOrNull { it.startsWith("md=") }?.substring("md=".length)
|
||||
if (s.name.endsWith("._googlecast._tcp.local")) {
|
||||
if (name == null) {
|
||||
name = s.name.substring(0, s.name.length - "._googlecast._tcp.local".length)
|
||||
}
|
||||
|
||||
addOrUpdateChromeCastDevice(name, addresses, port)
|
||||
} else if (s.name.endsWith("._airplay._tcp.local")) {
|
||||
if (name == null) {
|
||||
name = s.name.substring(0, s.name.length - "._airplay._tcp.local".length)
|
||||
}
|
||||
|
||||
addOrUpdateAirPlayDevice(name, addresses, port)
|
||||
} else if (s.name.endsWith("._fastcast._tcp.local")) {
|
||||
if (name == null) {
|
||||
name = s.name.substring(0, s.name.length - "._fastcast._tcp.local".length)
|
||||
}
|
||||
|
||||
addOrUpdateFastCastDevice(name, addresses, port)
|
||||
} else if (s.name.endsWith("._fcast._tcp.local")) {
|
||||
if (name == null) {
|
||||
name = s.name.substring(0, s.name.length - "._fcast._tcp.local".length)
|
||||
}
|
||||
|
||||
addOrUpdateFastCastDevice(name, addresses, port)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val _discoveryListeners = mapOf(
|
||||
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
|
||||
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
|
||||
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
|
||||
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
|
||||
)
|
||||
|
||||
fun handleUrl(context: Context, url: String) {
|
||||
val uri = Uri.parse(url)
|
||||
|
@ -190,30 +155,33 @@ class StateCasting {
|
|||
|
||||
Logger.i(TAG, "CastingService starting...");
|
||||
|
||||
rememberedDevices.clear();
|
||||
rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) });
|
||||
|
||||
_castServer.start();
|
||||
enableDeveloper(true);
|
||||
|
||||
Logger.i(TAG, "CastingService started.");
|
||||
|
||||
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun startDiscovering() {
|
||||
try {
|
||||
_serviceDiscoverer.start()
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to start ServiceDiscoverer", e)
|
||||
_nsdManager?.apply {
|
||||
_discoveryListeners.forEach {
|
||||
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stopDiscovering() {
|
||||
try {
|
||||
_serviceDiscoverer.stop()
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to stop ServiceDiscoverer", e)
|
||||
_nsdManager?.apply {
|
||||
_discoveryListeners.forEach {
|
||||
try {
|
||||
stopServiceDiscovery(it.value)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -239,6 +207,85 @@ class StateCasting {
|
|||
_castServer.removeAllHandlers();
|
||||
|
||||
Logger.i(TAG, "CastingService stopped.")
|
||||
|
||||
_nsdManager = null
|
||||
}
|
||||
|
||||
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
|
||||
return object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(regType: String) {
|
||||
Log.d(TAG, "Service discovery started for $regType")
|
||||
}
|
||||
|
||||
override fun onDiscoveryStopped(serviceType: String) {
|
||||
Log.i(TAG, "Discovery stopped: $serviceType")
|
||||
}
|
||||
|
||||
override fun onServiceLost(service: NsdServiceInfo) {
|
||||
Log.e(TAG, "service lost: $service")
|
||||
// TODO: Handle service lost, e.g., remove device
|
||||
}
|
||||
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
|
||||
try {
|
||||
_nsdManager?.stopServiceDiscovery(this)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
|
||||
try {
|
||||
_nsdManager?.stopServiceDiscovery(this)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceFound(service: NsdServiceInfo) {
|
||||
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
|
||||
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
service.hostAddresses.toTypedArray()
|
||||
} else {
|
||||
arrayOf(service.host)
|
||||
}
|
||||
addOrUpdate(service.serviceName, addresses, service.port)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
_nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback {
|
||||
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "onServiceUpdated: $serviceInfo")
|
||||
addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port)
|
||||
}
|
||||
|
||||
override fun onServiceLost() {
|
||||
Log.v(TAG, "onServiceLost: $service")
|
||||
// TODO: Handle service lost
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
||||
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackUnregistered() {
|
||||
Log.v(TAG, "onServiceInfoCallbackUnregistered")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
Log.v(TAG, "Resolve failed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
|
||||
addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _castingDialogLock = Any();
|
||||
|
@ -331,9 +378,6 @@ class StateCasting {
|
|||
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
|
||||
};
|
||||
|
||||
addRememberedDevice(device);
|
||||
Logger.i(TAG, "Device added to active discovery. Active discovery now contains ${_storage.getDevicesCount()} devices.")
|
||||
|
||||
try {
|
||||
device.start();
|
||||
} catch (e: Throwable) {
|
||||
|
@ -355,21 +399,22 @@ class StateCasting {
|
|||
return addRememberedDevice(device);
|
||||
}
|
||||
|
||||
fun getRememberedCastingDevices(): List<CastingDevice> {
|
||||
return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) }
|
||||
}
|
||||
|
||||
fun getRememberedCastingDeviceNames(): List<String> {
|
||||
return _storage.getDeviceNames()
|
||||
}
|
||||
|
||||
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
|
||||
val deviceInfo = device.getDeviceInfo()
|
||||
val foundInfo = _storage.addDevice(deviceInfo)
|
||||
if (foundInfo == deviceInfo) {
|
||||
rememberedDevices.add(device);
|
||||
return foundInfo;
|
||||
}
|
||||
|
||||
return foundInfo;
|
||||
return _storage.addDevice(deviceInfo)
|
||||
}
|
||||
|
||||
fun removeRememberedDevice(device: CastingDevice) {
|
||||
val name = device.name ?: return;
|
||||
_storage.removeDevice(name);
|
||||
rememberedDevices.remove(device);
|
||||
val name = device.name ?: return
|
||||
_storage.removeDevice(name)
|
||||
}
|
||||
|
||||
private fun invokeInMainScopeIfRequired(action: () -> Unit){
|
||||
|
|
|
@ -7,7 +7,9 @@ import android.app.PendingIntent.getBroadcast
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.content.pm.PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
|
@ -155,6 +157,9 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||
|
||||
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller;
|
||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
params.setRequireUserAction(USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
val sessionId = packageInstaller.createSession(params);
|
||||
session = packageInstaller.openSession(sessionId)
|
||||
|
||||
|
|
|
@ -9,7 +9,9 @@ import android.view.View
|
|||
import android.widget.Button
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
|
@ -21,22 +23,21 @@ import com.futo.platformplayer.casting.StateCasting
|
|||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.adapters.DeviceAdapter
|
||||
import com.futo.platformplayer.views.adapters.DeviceAdapterEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
private lateinit var _imageLoader: ImageView;
|
||||
private lateinit var _buttonClose: Button;
|
||||
private lateinit var _buttonAdd: ImageButton;
|
||||
private lateinit var _buttonScanQR: ImageButton;
|
||||
private lateinit var _buttonAdd: LinearLayout;
|
||||
private lateinit var _buttonScanQR: LinearLayout;
|
||||
private lateinit var _textNoDevicesFound: TextView;
|
||||
private lateinit var _textNoDevicesRemembered: TextView;
|
||||
private lateinit var _recyclerDevices: RecyclerView;
|
||||
private lateinit var _recyclerRememberedDevices: RecyclerView;
|
||||
private lateinit var _adapter: DeviceAdapter;
|
||||
private lateinit var _rememberedAdapter: DeviceAdapter;
|
||||
private val _devices: ArrayList<CastingDevice> = arrayListOf();
|
||||
private val _rememberedDevices: ArrayList<CastingDevice> = arrayListOf();
|
||||
private val _devices: MutableSet<String> = mutableSetOf()
|
||||
private val _rememberedDevices: MutableSet<String> = mutableSetOf()
|
||||
private val _unifiedDevices: MutableList<DeviceAdapterEntry> = mutableListOf()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
@ -45,42 +46,40 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
|||
_imageLoader = findViewById(R.id.image_loader);
|
||||
_buttonClose = findViewById(R.id.button_close);
|
||||
_buttonAdd = findViewById(R.id.button_add);
|
||||
_buttonScanQR = findViewById(R.id.button_scan_qr);
|
||||
_buttonScanQR = findViewById(R.id.button_qr);
|
||||
_recyclerDevices = findViewById(R.id.recycler_devices);
|
||||
_recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices);
|
||||
_textNoDevicesFound = findViewById(R.id.text_no_devices_found);
|
||||
_textNoDevicesRemembered = findViewById(R.id.text_no_devices_remembered);
|
||||
|
||||
_adapter = DeviceAdapter(_devices, false);
|
||||
_adapter = DeviceAdapter(_unifiedDevices)
|
||||
_recyclerDevices.adapter = _adapter;
|
||||
_recyclerDevices.layoutManager = LinearLayoutManager(context);
|
||||
|
||||
_rememberedAdapter = DeviceAdapter(_rememberedDevices, true);
|
||||
_rememberedAdapter.onRemove.subscribe { d ->
|
||||
if (StateCasting.instance.activeDevice == d) {
|
||||
d.stopCasting();
|
||||
_adapter.onPin.subscribe { d ->
|
||||
val isRemembered = _rememberedDevices.contains(d.name)
|
||||
val newIsRemembered = !isRemembered
|
||||
if (newIsRemembered) {
|
||||
StateCasting.instance.addRememberedDevice(d)
|
||||
val name = d.name
|
||||
if (name != null) {
|
||||
_rememberedDevices.add(name)
|
||||
}
|
||||
} else {
|
||||
StateCasting.instance.removeRememberedDevice(d)
|
||||
_rememberedDevices.remove(d.name)
|
||||
}
|
||||
|
||||
StateCasting.instance.removeRememberedDevice(d);
|
||||
val index = _rememberedDevices.indexOf(d);
|
||||
if (index != -1) {
|
||||
_rememberedDevices.removeAt(index);
|
||||
_rememberedAdapter.notifyItemRemoved(index);
|
||||
}
|
||||
|
||||
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
|
||||
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
|
||||
};
|
||||
_rememberedAdapter.onConnect.subscribe { _ ->
|
||||
dismiss()
|
||||
//UIDialogs.showCastingDialog(context)
|
||||
updateUnifiedList()
|
||||
}
|
||||
|
||||
//TODO: Integrate remembered into the main list
|
||||
//TODO: Add green indicator to indicate a device is oneline
|
||||
//TODO: Add pinning
|
||||
//TODO: Implement QR code as an option in add manually
|
||||
//TODO: Remove start button
|
||||
|
||||
_adapter.onConnect.subscribe { _ ->
|
||||
dismiss()
|
||||
//UIDialogs.showCastingDialog(context)
|
||||
}
|
||||
_recyclerRememberedDevices.adapter = _rememberedAdapter;
|
||||
_recyclerRememberedDevices.layoutManager = LinearLayoutManager(context);
|
||||
|
||||
_buttonClose.setOnClickListener { dismiss(); };
|
||||
_buttonAdd.setOnClickListener {
|
||||
|
@ -105,77 +104,112 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
|||
Logger.i(TAG, "Dialog shown.");
|
||||
|
||||
StateCasting.instance.startDiscovering()
|
||||
|
||||
(_imageLoader.drawable as Animatable?)?.start();
|
||||
|
||||
_devices.clear();
|
||||
synchronized (StateCasting.instance.devices) {
|
||||
_devices.addAll(StateCasting.instance.devices.values);
|
||||
synchronized(StateCasting.instance.devices) {
|
||||
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
|
||||
}
|
||||
|
||||
_rememberedDevices.clear();
|
||||
synchronized (StateCasting.instance.rememberedDevices) {
|
||||
_rememberedDevices.addAll(StateCasting.instance.rememberedDevices);
|
||||
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
|
||||
updateUnifiedList()
|
||||
|
||||
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
|
||||
val name = d.name
|
||||
if (name != null)
|
||||
_devices.add(name)
|
||||
updateUnifiedList()
|
||||
}
|
||||
|
||||
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
|
||||
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name }
|
||||
if (index != -1) {
|
||||
_unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice)
|
||||
_adapter.notifyItemChanged(index)
|
||||
}
|
||||
}
|
||||
|
||||
StateCasting.instance.onDeviceRemoved.subscribe(this) { d ->
|
||||
_devices.remove(d.name)
|
||||
updateUnifiedList()
|
||||
}
|
||||
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
|
||||
if (connectionState == CastConnectionState.CONNECTED) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
|
||||
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
|
||||
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
|
||||
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
|
||||
|
||||
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
|
||||
_devices.add(d);
|
||||
_adapter.notifyItemInserted(_devices.size - 1);
|
||||
_textNoDevicesFound.visibility = View.GONE;
|
||||
_recyclerDevices.visibility = View.VISIBLE;
|
||||
};
|
||||
|
||||
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
|
||||
val index = _devices.indexOf(d);
|
||||
if (index == -1) {
|
||||
return@subscribe;
|
||||
}
|
||||
|
||||
_devices[index] = d;
|
||||
_adapter.notifyItemChanged(index);
|
||||
};
|
||||
|
||||
StateCasting.instance.onDeviceRemoved.subscribe(this) { d ->
|
||||
val index = _devices.indexOf(d);
|
||||
if (index == -1) {
|
||||
return@subscribe;
|
||||
}
|
||||
|
||||
_devices.removeAt(index);
|
||||
_adapter.notifyItemRemoved(index);
|
||||
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
|
||||
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
|
||||
};
|
||||
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
|
||||
if (connectionState != CastConnectionState.CONNECTED) {
|
||||
return@subscribe;
|
||||
}
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
dismiss();
|
||||
};
|
||||
};
|
||||
|
||||
_adapter.notifyDataSetChanged();
|
||||
_rememberedAdapter.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
super.dismiss();
|
||||
|
||||
(_imageLoader.drawable as Animatable?)?.stop();
|
||||
|
||||
super.dismiss()
|
||||
(_imageLoader.drawable as Animatable?)?.stop()
|
||||
StateCasting.instance.stopDiscovering()
|
||||
StateCasting.instance.onDeviceAdded.remove(this);
|
||||
StateCasting.instance.onDeviceChanged.remove(this);
|
||||
StateCasting.instance.onDeviceRemoved.remove(this);
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
|
||||
StateCasting.instance.onDeviceAdded.remove(this)
|
||||
StateCasting.instance.onDeviceChanged.remove(this)
|
||||
StateCasting.instance.onDeviceRemoved.remove(this)
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this)
|
||||
}
|
||||
|
||||
private fun updateUnifiedList() {
|
||||
val oldList = ArrayList(_unifiedDevices)
|
||||
val newList = buildUnifiedList()
|
||||
|
||||
val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||
override fun getOldListSize() = oldList.size
|
||||
override fun getNewListSize() = newList.size
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldItem = oldList[oldItemPosition]
|
||||
val newItem = newList[newItemPosition]
|
||||
return oldItem.castingDevice.name == newItem.castingDevice.name
|
||||
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
|
||||
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
|
||||
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
|
||||
}
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldItem = oldList[oldItemPosition]
|
||||
val newItem = newList[newItemPosition]
|
||||
return oldItem.castingDevice.name == newItem.castingDevice.name
|
||||
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
|
||||
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
|
||||
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
|
||||
}
|
||||
})
|
||||
|
||||
_unifiedDevices.clear()
|
||||
_unifiedDevices.addAll(newList)
|
||||
diffResult.dispatchUpdatesTo(_adapter)
|
||||
|
||||
_textNoDevicesFound.visibility = if (_unifiedDevices.isEmpty()) View.VISIBLE else View.GONE
|
||||
_recyclerDevices.visibility = if (_unifiedDevices.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun buildUnifiedList(): List<DeviceAdapterEntry> {
|
||||
val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name }
|
||||
val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name }
|
||||
|
||||
val unifiedList = mutableListOf<DeviceAdapterEntry>()
|
||||
|
||||
val intersectionNames = _devices.intersect(_rememberedDevices)
|
||||
for (name in intersectionNames) {
|
||||
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, true)) }
|
||||
}
|
||||
|
||||
val onlineOnlyNames = _devices - _rememberedDevices
|
||||
for (name in onlineOnlyNames) {
|
||||
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, false, true)) }
|
||||
}
|
||||
|
||||
val rememberedOnlyNames = _rememberedDevices - _devices
|
||||
for (name in rememberedOnlyNames) {
|
||||
rememberedDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, false)) }
|
||||
}
|
||||
|
||||
return unifiedList
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -633,7 +633,9 @@ class VideoDownload {
|
|||
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
|
||||
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
|
||||
|
||||
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
|
||||
// 8 second analyze duration is needed for some Rumble HLS downloads
|
||||
val cmd = "-analyzeduration 8M -f concat -safe 0 -i \"${fileList.absolutePath}\"" +
|
||||
" -c copy \"${targetFile.absolutePath}\""
|
||||
|
||||
val statisticsCallback = StatisticsCallback { _ ->
|
||||
//TODO: Show progress?
|
||||
|
|
|
@ -72,6 +72,10 @@ class PackageBridge : V8Package {
|
|||
fun buildSpecVersion(): Int {
|
||||
return JSClientConstants.PLUGIN_SPEC_VERSION;
|
||||
}
|
||||
@V8Property
|
||||
fun buildPlatform(): String {
|
||||
return "android";
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun dispose(value: V8Value) {
|
||||
|
|
|
@ -70,8 +70,9 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
|
||||
val lastPolycentricProfile = _lastPolycentricProfile;
|
||||
var pager: IPager<IPlatformContent>? = null;
|
||||
if (lastPolycentricProfile != null)
|
||||
pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType);
|
||||
if (lastPolycentricProfile != null && StatePolycentric.instance.enabled)
|
||||
pager =
|
||||
StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType);
|
||||
|
||||
if(pager == null) {
|
||||
if(subType != null)
|
||||
|
|
|
@ -47,6 +47,7 @@ import com.futo.platformplayer.selectHighestResolutionImage
|
|||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.toHumanNumber
|
||||
import com.futo.platformplayer.views.adapters.ChannelTab
|
||||
|
@ -135,6 +136,8 @@ class ChannelFragment : MainFragment() {
|
|||
inflater.inflate(R.layout.fragment_channel, this)
|
||||
_taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>({ fragment.lifecycleScope },
|
||||
{ id ->
|
||||
if (!StatePolycentric.instance.enabled)
|
||||
return@TaskHandler null
|
||||
return@TaskHandler ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, id.claimFieldType.toLong(), id.claimType.toLong(), id.value!!)
|
||||
}).success { setPolycentricProfile(it, animate = true) }.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load polycentric profile.", it)
|
||||
|
|
|
@ -2,9 +2,11 @@ package com.futo.platformplayer.fragment.mainactivity.main
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.allViews
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
|
@ -23,6 +25,8 @@ import com.futo.platformplayer.models.SearchType
|
|||
import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.ToggleBar
|
||||
import com.futo.platformplayer.views.others.RadioGroupView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
@ -114,6 +118,25 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
}
|
||||
|
||||
setPreviewsEnabled(Settings.instance.search.previewFeedItems);
|
||||
|
||||
initializeToolbar();
|
||||
}
|
||||
|
||||
fun initializeToolbar(){
|
||||
if(_toolbarContentView.allViews.any { it is RadioGroupView })
|
||||
_toolbarContentView.removeView(_toolbarContentView.allViews.find { it is RadioGroupView });
|
||||
|
||||
val radioGroup = RadioGroupView(context);
|
||||
radioGroup.onSelectedChange.subscribe {
|
||||
|
||||
if (it.size != 1)
|
||||
setSearchType(SearchType.VIDEO);
|
||||
else
|
||||
setSearchType((it[0] ?: SearchType.VIDEO) as SearchType);
|
||||
}
|
||||
radioGroup?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true)
|
||||
|
||||
_toolbarContentView.addView(radioGroup);
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
|
|
|
@ -14,10 +14,14 @@ import androidx.core.widget.addTextChangedListener
|
|||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.downloads.VideoDownload
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.services.DownloadService
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
|
@ -54,6 +58,15 @@ class DownloadsFragment : MainFragment() {
|
|||
super.onResume()
|
||||
_view?.reloadUI();
|
||||
|
||||
if(StateDownloads.instance.getDownloading().any { it.state == VideoDownload.State.QUEUED } &&
|
||||
!StateDownloads.instance.getDownloading().any { it.state == VideoDownload.State.DOWNLOADING } &&
|
||||
Settings.instance.downloads.shouldDownload()) {
|
||||
Logger.w(TAG, "Detected queued download, while not downloading, attempt recreating service");
|
||||
StateApp.withContext {
|
||||
DownloadService.getOrCreateService(it);
|
||||
}
|
||||
}
|
||||
|
||||
StateDownloads.instance.onDownloadsChanged.subscribe(this) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
|
|
|
@ -15,6 +15,8 @@ import androidx.lifecycle.lifecycleScope
|
|||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.structures.IAsyncPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
|
@ -22,10 +24,14 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFrag
|
|||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.HistoryVideo
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.views.ToggleBar
|
||||
import com.futo.platformplayer.views.adapters.HistoryListViewHolder
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.others.TagsView
|
||||
import com.futo.platformplayer.views.others.Toggle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -68,6 +74,8 @@ class HistoryFragment : MainFragment() {
|
|||
private var _pager: IPager<HistoryVideo>? = null;
|
||||
private val _results = arrayListOf<HistoryVideo>();
|
||||
private var _loading = false;
|
||||
private val _toggleBar: ToggleBar
|
||||
private var _togglePluginsDisabled = hashSetOf<String>()
|
||||
|
||||
private var _automaticNextPageCounter = 0;
|
||||
|
||||
|
@ -79,6 +87,7 @@ class HistoryFragment : MainFragment() {
|
|||
_clearSearch = findViewById(R.id.button_clear_search);
|
||||
_editSearch = findViewById(R.id.edit_search);
|
||||
_tagsView = findViewById(R.id.tags_text);
|
||||
_toggleBar = findViewById(R.id.toggle_bar)
|
||||
_tagsView.setPairs(listOf(
|
||||
Pair(context.getString(R.string.last_hour), 60L),
|
||||
Pair(context.getString(R.string.last_24_hours), 24L * 60L),
|
||||
|
@ -88,6 +97,22 @@ class HistoryFragment : MainFragment() {
|
|||
Pair(context.getString(R.string.all_time), -1L)
|
||||
));
|
||||
|
||||
val toggles = StatePlatform.instance.getEnabledClients()
|
||||
.filter { it is JSClient }
|
||||
.map { plugin ->
|
||||
val pluginName = plugin.name.lowercase()
|
||||
ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) pluginName else "", plugin.icon, !_togglePluginsDisabled.contains(plugin.id), { view, active ->
|
||||
if (active) {
|
||||
_togglePluginsDisabled.remove(plugin.id)
|
||||
} else {
|
||||
_togglePluginsDisabled.add(plugin.id)
|
||||
}
|
||||
|
||||
filtersChanged()
|
||||
}).withTag("plugins")
|
||||
}.toTypedArray()
|
||||
_toggleBar.setToggles(*toggles)
|
||||
|
||||
_adapter = InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
|
||||
{ _results.size },
|
||||
{ view, _ ->
|
||||
|
@ -162,14 +187,15 @@ class HistoryFragment : MainFragment() {
|
|||
else
|
||||
it.nextPage();
|
||||
|
||||
return@TaskHandler it.getResults();
|
||||
return@TaskHandler filterResults(it.getResults());
|
||||
}).success {
|
||||
setLoading(false);
|
||||
|
||||
val posBefore = _results.size;
|
||||
_results.addAll(it);
|
||||
_adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), it.size);
|
||||
ensureEnoughContentVisible(it)
|
||||
val res = filterResults(it)
|
||||
_results.addAll(res);
|
||||
_adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), res.size);
|
||||
ensureEnoughContentVisible(res)
|
||||
}.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load next page.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
|
||||
|
@ -178,6 +204,10 @@ class HistoryFragment : MainFragment() {
|
|||
};
|
||||
}
|
||||
|
||||
private fun filtersChanged() {
|
||||
updatePager()
|
||||
}
|
||||
|
||||
private fun updatePager() {
|
||||
val query = _editSearch.text.toString();
|
||||
if (_editSearch.text.isNotEmpty()) {
|
||||
|
@ -246,11 +276,22 @@ class HistoryFragment : MainFragment() {
|
|||
_adapter.setLoading(loading);
|
||||
}
|
||||
|
||||
private fun filterResults(a: List<HistoryVideo>): List<HistoryVideo> {
|
||||
val enabledPluginIds = StatePlatform.instance.getEnabledClients().map { it.id }.toHashSet()
|
||||
val disabledPluginIds = _togglePluginsDisabled.toHashSet()
|
||||
return a.filter {
|
||||
val pluginId = it.video.id.pluginId ?: StatePlatform.instance.getContentClientOrNull(it.video.url)?.id ?: return@filter false
|
||||
if (!enabledPluginIds.contains(pluginId))
|
||||
return@filter false
|
||||
return@filter !disabledPluginIds.contains(pluginId)
|
||||
};
|
||||
}
|
||||
|
||||
private fun loadPagerInternal(pager: IPager<HistoryVideo>) {
|
||||
Logger.i(TAG, "Setting new internal pager on feed");
|
||||
|
||||
_results.clear();
|
||||
val toAdd = pager.getResults();
|
||||
val toAdd = filterResults(pager.getResults())
|
||||
_results.addAll(toAdd);
|
||||
_adapter.notifyDataSetChanged();
|
||||
ensureEnoughContentVisible(toAdd)
|
||||
|
|
|
@ -168,7 +168,12 @@ class PostDetailFragment : MainFragment {
|
|||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
|
||||
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
|
||||
|
||||
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) })
|
||||
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, {
|
||||
if (!StatePolycentric.instance.enabled)
|
||||
return@TaskHandler null
|
||||
|
||||
ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!)
|
||||
})
|
||||
.success { it -> setPolycentricProfile(it, animate = true) }
|
||||
.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load claims.", it);
|
||||
|
@ -327,6 +332,10 @@ class PostDetailFragment : MainFragment {
|
|||
val version = _version;
|
||||
|
||||
_rating.onLikeDislikeUpdated.remove(this);
|
||||
|
||||
if (!StatePolycentric.instance.enabled)
|
||||
return
|
||||
|
||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
if (version != _version) {
|
||||
return@launch;
|
||||
|
|
|
@ -18,6 +18,7 @@ import com.futo.platformplayer.activities.AddSourceOptionsActivity
|
|||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
|
@ -44,6 +45,7 @@ class SourcesFragment : MainFragment() {
|
|||
if(topBar is AddTopBarFragment) {
|
||||
(topBar as AddTopBarFragment).onAdd.clear();
|
||||
(topBar as AddTopBarFragment).onAdd.subscribe {
|
||||
StateApp.instance.preventPictureInPicture.emit();
|
||||
startActivity(Intent(requireContext(), AddSourceOptionsActivity::class.java));
|
||||
};
|
||||
}
|
||||
|
@ -93,6 +95,7 @@ class SourcesFragment : MainFragment() {
|
|||
findViewById<LinearLayout>(R.id.plugin_disclaimer).isVisible = false;
|
||||
}
|
||||
findViewById<BigButton>(R.id.button_add_sources).onClick.subscribe {
|
||||
StateApp.instance.preventPictureInPicture.emit();
|
||||
fragment.startActivity(Intent(context, AddSourceOptionsActivity::class.java));
|
||||
};
|
||||
|
||||
|
|
|
@ -455,6 +455,10 @@ class VideoDetailFragment() : MainFragment() {
|
|||
activity?.enterPictureInPictureMode(params);
|
||||
}
|
||||
}
|
||||
|
||||
if (isFullscreen) {
|
||||
viewDetail?.restoreBrightness()
|
||||
}
|
||||
}
|
||||
|
||||
fun forcePictureInPicture() {
|
||||
|
@ -487,6 +491,10 @@ class VideoDetailFragment() : MainFragment() {
|
|||
_isActive = true;
|
||||
_leavingPiP = false;
|
||||
|
||||
if (isFullscreen) {
|
||||
_viewDetail?.saveBrightness()
|
||||
}
|
||||
|
||||
_viewDetail?.let {
|
||||
Logger.v(TAG, "onResume preventPictureInPicture=false");
|
||||
it.preventPictureInPicture = false;
|
||||
|
|
|
@ -46,6 +46,7 @@ import com.futo.platformplayer.R
|
|||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.LiveChatManager
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
|
@ -148,7 +149,6 @@ import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
|
|||
import com.futo.platformplayer.views.pills.RoundButton
|
||||
import com.futo.platformplayer.views.pills.RoundButtonGroup
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
import com.futo.platformplayer.views.segments.ChaptersList
|
||||
import com.futo.platformplayer.views.segments.CommentsList
|
||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||
import com.futo.platformplayer.views.video.FutoVideoPlayer
|
||||
|
@ -571,7 +571,7 @@ class VideoDetailView : ConstraintLayout {
|
|||
_player.setIsReplay(true);
|
||||
|
||||
val searchVideo = StatePlayer.instance.getCurrentQueueItem();
|
||||
if (searchVideo is SerializedPlatformVideo?) {
|
||||
if (searchVideo is SerializedPlatformVideo? && Settings.instance.playback.deleteFromWatchLaterAuto) {
|
||||
searchVideo?.let { StatePlaylists.instance.removeFromWatchLater(it) };
|
||||
}
|
||||
|
||||
|
@ -688,6 +688,20 @@ class VideoDetailView : ConstraintLayout {
|
|||
Logger.i(TAG, "MediaControlReceiver.onCloseReceived")
|
||||
onClose.emit()
|
||||
};
|
||||
MediaControlReceiver.onBackgroundReceived.subscribe(this) {
|
||||
Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived")
|
||||
_player.switchToAudioMode();
|
||||
allowBackground = true;
|
||||
StateApp.instance.contextOrNull?.let {
|
||||
try {
|
||||
if (it is MainActivity) {
|
||||
it.moveTaskToBack(true)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to move task to back", e)
|
||||
}
|
||||
}
|
||||
};
|
||||
MediaControlReceiver.onSeekToReceived.subscribe(this) { handleSeek(it); };
|
||||
|
||||
_container_content_description.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
|
@ -1141,6 +1155,7 @@ class VideoDetailView : ConstraintLayout {
|
|||
MediaControlReceiver.onNextReceived.remove(this);
|
||||
MediaControlReceiver.onPreviousReceived.remove(this);
|
||||
MediaControlReceiver.onCloseReceived.remove(this);
|
||||
MediaControlReceiver.onBackgroundReceived.remove(this);
|
||||
MediaControlReceiver.onSeekToReceived.remove(this);
|
||||
|
||||
val job = _jobHideResume;
|
||||
|
@ -1510,60 +1525,68 @@ class VideoDetailView : ConstraintLayout {
|
|||
|
||||
_rating.visibility = View.GONE;
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val queryReferencesResponse = ApiMethods.getQueryReferences(
|
||||
ApiMethods.SERVER, ref, null, null,
|
||||
arrayListOf(
|
||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||
.setFromType(ContentType.OPINION.value).setValue(
|
||||
ByteString.copyFrom(Opinion.like.data)
|
||||
).build(),
|
||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||
.setFromType(ContentType.OPINION.value).setValue(
|
||||
ByteString.copyFrom(Opinion.dislike.data)
|
||||
).build()
|
||||
),
|
||||
extraByteReferences = listOfNotNull(extraBytesRef)
|
||||
);
|
||||
if (StatePolycentric.instance.enabled) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val queryReferencesResponse = ApiMethods.getQueryReferences(
|
||||
ApiMethods.SERVER, ref, null, null,
|
||||
arrayListOf(
|
||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||
.setFromType(ContentType.OPINION.value).setValue(
|
||||
ByteString.copyFrom(Opinion.like.data)
|
||||
).build(),
|
||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||
.setFromType(ContentType.OPINION.value).setValue(
|
||||
ByteString.copyFrom(Opinion.dislike.data)
|
||||
).build()
|
||||
),
|
||||
extraByteReferences = listOfNotNull(extraBytesRef)
|
||||
);
|
||||
|
||||
val likes = queryReferencesResponse.countsList[0];
|
||||
val dislikes = queryReferencesResponse.countsList[1];
|
||||
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
|
||||
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
|
||||
val likes = queryReferencesResponse.countsList[0];
|
||||
val dislikes = queryReferencesResponse.countsList[1];
|
||||
val hasLiked =
|
||||
StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
|
||||
val hasDisliked =
|
||||
StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
_rating.visibility = View.VISIBLE;
|
||||
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
|
||||
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
|
||||
if (args.hasLiked) {
|
||||
args.processHandle.opinion(ref, Opinion.like);
|
||||
} else if (args.hasDisliked) {
|
||||
args.processHandle.opinion(ref, Opinion.dislike);
|
||||
} else {
|
||||
args.processHandle.opinion(ref, Opinion.neutral);
|
||||
}
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
_rating.visibility = View.VISIBLE;
|
||||
_rating.setRating(
|
||||
RatingLikeDislikes(likes, dislikes),
|
||||
hasLiked,
|
||||
hasDisliked
|
||||
);
|
||||
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
|
||||
if (args.hasLiked) {
|
||||
args.processHandle.opinion(ref, Opinion.like);
|
||||
} else if (args.hasDisliked) {
|
||||
args.processHandle.opinion(ref, Opinion.dislike);
|
||||
} else {
|
||||
args.processHandle.opinion(ref, Opinion.neutral);
|
||||
}
|
||||
}
|
||||
|
||||
StatePolycentric.instance.updateLikeMap(
|
||||
ref,
|
||||
args.hasLiked,
|
||||
args.hasDisliked
|
||||
)
|
||||
};
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers", e)
|
||||
}
|
||||
}
|
||||
|
||||
StatePolycentric.instance.updateLikeMap(
|
||||
ref,
|
||||
args.hasLiked,
|
||||
args.hasDisliked
|
||||
)
|
||||
};
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
|
||||
_rating.visibility = View.GONE;
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
|
||||
_rating.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2413,6 +2436,7 @@ class VideoDetailView : ConstraintLayout {
|
|||
Logger.i(TAG, "handleFullScreen(fullscreen=$fullscreen)")
|
||||
|
||||
if(fullscreen) {
|
||||
_container_content.visibility = GONE
|
||||
_layoutPlayerContainer.setPadding(0, 0, 0, 0);
|
||||
|
||||
val lp = _container_content.layoutParams as LayoutParams;
|
||||
|
@ -2426,6 +2450,7 @@ class VideoDetailView : ConstraintLayout {
|
|||
setProgressBarOverlayed(null);
|
||||
}
|
||||
else {
|
||||
_container_content.visibility = VISIBLE
|
||||
_layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt());
|
||||
|
||||
val lp = _container_content.layoutParams as LayoutParams;
|
||||
|
@ -2485,6 +2510,13 @@ class VideoDetailView : ConstraintLayout {
|
|||
}
|
||||
}
|
||||
|
||||
fun saveBrightness() {
|
||||
_player.gestureControl.saveBrightness()
|
||||
}
|
||||
fun restoreBrightness() {
|
||||
_player.gestureControl.restoreBrightness()
|
||||
}
|
||||
|
||||
fun setFullscreen(fullscreen : Boolean) {
|
||||
Logger.i(TAG, "setFullscreen(fullscreen=$fullscreen)")
|
||||
_player.setFullScreen(fullscreen)
|
||||
|
@ -2725,10 +2757,11 @@ class VideoDetailView : ConstraintLayout {
|
|||
else
|
||||
RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 6));
|
||||
|
||||
val toBackgroundAction = RemoteAction(Icon.createWithResource(context, R.drawable.ic_screen_share), context.getString(R.string.background), context.getString(R.string.background_switch_audio), MediaControlReceiver.getToBackgroundIntent(context, 7));
|
||||
return PictureInPictureParams.Builder()
|
||||
.setAspectRatio(Rational(videoSourceWidth, videoSourceHeight))
|
||||
.setSourceRectHint(r)
|
||||
.setActions(listOf(playpauseAction))
|
||||
.setActions(listOf(toBackgroundAction, playpauseAction))
|
||||
.build();
|
||||
}
|
||||
|
||||
|
@ -3041,7 +3074,12 @@ class VideoDetailView : ConstraintLayout {
|
|||
Logger.w(TAG, "Failed to load recommendations.", it);
|
||||
};
|
||||
|
||||
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) })
|
||||
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, {
|
||||
if (!StatePolycentric.instance.enabled)
|
||||
return@TaskHandler null
|
||||
|
||||
ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!)
|
||||
})
|
||||
.success { it -> setPolycentricProfile(it, animate = true) }
|
||||
.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load claims.", it);
|
||||
|
@ -3113,10 +3151,6 @@ class VideoDetailView : ConstraintLayout {
|
|||
|
||||
fun applyFragment(frag: VideoDetailFragment) {
|
||||
fragment = frag;
|
||||
fragment.onMinimize.subscribe {
|
||||
_liveChat?.stop();
|
||||
_container_content_liveChat.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
data class BroadcastService(
|
||||
val deviceName: String,
|
||||
val serviceName: String,
|
||||
val port: UShort,
|
||||
val ttl: UInt,
|
||||
val weight: UShort,
|
||||
val priority: UShort,
|
||||
val texts: List<String>? = null
|
||||
)
|
|
@ -1,93 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
enum class QueryResponse(val value: Byte) {
|
||||
Query(0),
|
||||
Response(1)
|
||||
}
|
||||
|
||||
enum class DnsOpcode(val value: Byte) {
|
||||
StandardQuery(0),
|
||||
InverseQuery(1),
|
||||
ServerStatusRequest(2)
|
||||
}
|
||||
|
||||
enum class DnsResponseCode(val value: Byte) {
|
||||
NoError(0),
|
||||
FormatError(1),
|
||||
ServerFailure(2),
|
||||
NameError(3),
|
||||
NotImplemented(4),
|
||||
Refused(5)
|
||||
}
|
||||
|
||||
data class DnsPacketHeader(
|
||||
val identifier: UShort,
|
||||
val queryResponse: Int,
|
||||
val opcode: Int,
|
||||
val authoritativeAnswer: Boolean,
|
||||
val truncated: Boolean,
|
||||
val recursionDesired: Boolean,
|
||||
val recursionAvailable: Boolean,
|
||||
val answerAuthenticated: Boolean,
|
||||
val nonAuthenticatedData: Boolean,
|
||||
val responseCode: DnsResponseCode
|
||||
)
|
||||
|
||||
data class DnsPacket(
|
||||
val header: DnsPacketHeader,
|
||||
val questions: List<DnsQuestion>,
|
||||
val answers: List<DnsResourceRecord>,
|
||||
val authorities: List<DnsResourceRecord>,
|
||||
val additionals: List<DnsResourceRecord>
|
||||
) {
|
||||
companion object {
|
||||
fun parse(data: ByteArray): DnsPacket {
|
||||
val span = data.asUByteArray()
|
||||
val flags = (span[2].toInt() shl 8 or span[3].toInt()).toUShort()
|
||||
val questionCount = (span[4].toInt() shl 8 or span[5].toInt()).toUShort()
|
||||
val answerCount = (span[6].toInt() shl 8 or span[7].toInt()).toUShort()
|
||||
val authorityCount = (span[8].toInt() shl 8 or span[9].toInt()).toUShort()
|
||||
val additionalCount = (span[10].toInt() shl 8 or span[11].toInt()).toUShort()
|
||||
|
||||
var position = 12
|
||||
|
||||
val questions = List(questionCount.toInt()) {
|
||||
DnsQuestion.parse(data, position).also { position = it.second }
|
||||
}.map { it.first }
|
||||
|
||||
val answers = List(answerCount.toInt()) {
|
||||
DnsResourceRecord.parse(data, position).also { position = it.second }
|
||||
}.map { it.first }
|
||||
|
||||
val authorities = List(authorityCount.toInt()) {
|
||||
DnsResourceRecord.parse(data, position).also { position = it.second }
|
||||
}.map { it.first }
|
||||
|
||||
val additionals = List(additionalCount.toInt()) {
|
||||
DnsResourceRecord.parse(data, position).also { position = it.second }
|
||||
}.map { it.first }
|
||||
|
||||
return DnsPacket(
|
||||
header = DnsPacketHeader(
|
||||
identifier = (span[0].toInt() shl 8 or span[1].toInt()).toUShort(),
|
||||
queryResponse = ((flags.toUInt() shr 15) and 0b1u).toInt(),
|
||||
opcode = ((flags.toUInt() shr 11) and 0b1111u).toInt(),
|
||||
authoritativeAnswer = (flags.toInt() shr 10) and 0b1 != 0,
|
||||
truncated = (flags.toInt() shr 9) and 0b1 != 0,
|
||||
recursionDesired = (flags.toInt() shr 8) and 0b1 != 0,
|
||||
recursionAvailable = (flags.toInt() shr 7) and 0b1 != 0,
|
||||
answerAuthenticated = (flags.toInt() shr 5) and 0b1 != 0,
|
||||
nonAuthenticatedData = (flags.toInt() shr 4) and 0b1 != 0,
|
||||
responseCode = DnsResponseCode.entries[flags.toInt() and 0b1111]
|
||||
),
|
||||
questions = questions,
|
||||
answers = answers,
|
||||
authorities = authorities,
|
||||
additionals = additionals
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,110 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import com.futo.platformplayer.mdns.Extensions.readDomainName
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
|
||||
enum class QuestionType(val value: UShort) {
|
||||
A(1u),
|
||||
NS(2u),
|
||||
MD(3u),
|
||||
MF(4u),
|
||||
CNAME(5u),
|
||||
SOA(6u),
|
||||
MB(7u),
|
||||
MG(8u),
|
||||
MR(9u),
|
||||
NULL(10u),
|
||||
WKS(11u),
|
||||
PTR(12u),
|
||||
HINFO(13u),
|
||||
MINFO(14u),
|
||||
MX(15u),
|
||||
TXT(16u),
|
||||
RP(17u),
|
||||
AFSDB(18u),
|
||||
SIG(24u),
|
||||
KEY(25u),
|
||||
AAAA(28u),
|
||||
LOC(29u),
|
||||
SRV(33u),
|
||||
NAPTR(35u),
|
||||
KX(36u),
|
||||
CERT(37u),
|
||||
DNAME(39u),
|
||||
APL(42u),
|
||||
DS(43u),
|
||||
SSHFP(44u),
|
||||
IPSECKEY(45u),
|
||||
RRSIG(46u),
|
||||
NSEC(47u),
|
||||
DNSKEY(48u),
|
||||
DHCID(49u),
|
||||
NSEC3(50u),
|
||||
NSEC3PARAM(51u),
|
||||
TSLA(52u),
|
||||
SMIMEA(53u),
|
||||
HIP(55u),
|
||||
CDS(59u),
|
||||
CDNSKEY(60u),
|
||||
OPENPGPKEY(61u),
|
||||
CSYNC(62u),
|
||||
ZONEMD(63u),
|
||||
SVCB(64u),
|
||||
HTTPS(65u),
|
||||
EUI48(108u),
|
||||
EUI64(109u),
|
||||
TKEY(249u),
|
||||
TSIG(250u),
|
||||
URI(256u),
|
||||
CAA(257u),
|
||||
TA(32768u),
|
||||
DLV(32769u),
|
||||
AXFR(252u),
|
||||
IXFR(251u),
|
||||
OPT(41u),
|
||||
MAILB(253u),
|
||||
MALA(254u),
|
||||
All(252u)
|
||||
}
|
||||
|
||||
enum class QuestionClass(val value: UShort) {
|
||||
IN(1u),
|
||||
CS(2u),
|
||||
CH(3u),
|
||||
HS(4u),
|
||||
All(255u)
|
||||
}
|
||||
|
||||
data class DnsQuestion(
|
||||
override val name: String,
|
||||
override val type: Int,
|
||||
override val clazz: Int,
|
||||
val queryUnicast: Boolean
|
||||
) : DnsResourceRecordBase(name, type, clazz) {
|
||||
companion object {
|
||||
fun parse(data: ByteArray, startPosition: Int): Pair<DnsQuestion, Int> {
|
||||
val span = data.asUByteArray()
|
||||
var position = startPosition
|
||||
val qname = span.readDomainName(position).also { position = it.second }
|
||||
val qtype = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
||||
position += 2
|
||||
val qclass = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
||||
position += 2
|
||||
|
||||
return DnsQuestion(
|
||||
name = qname.first,
|
||||
type = qtype.toInt(),
|
||||
queryUnicast = ((qclass.toInt() shr 15) and 0b1) != 0,
|
||||
clazz = qclass.toInt() and 0b111111111111111
|
||||
) to position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open class DnsResourceRecordBase(
|
||||
open val name: String,
|
||||
open val type: Int,
|
||||
open val clazz: Int
|
||||
)
|
|
@ -1,514 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import com.futo.platformplayer.mdns.Extensions.readDomainName
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlin.math.pow
|
||||
import java.net.InetAddress
|
||||
|
||||
data class PTRRecord(val domainName: String)
|
||||
|
||||
data class ARecord(val address: InetAddress)
|
||||
|
||||
data class AAAARecord(val address: InetAddress)
|
||||
|
||||
data class MXRecord(val preference: UShort, val exchange: String)
|
||||
|
||||
data class CNAMERecord(val cname: String)
|
||||
|
||||
data class TXTRecord(val texts: List<String>)
|
||||
|
||||
data class SOARecord(
|
||||
val primaryNameServer: String,
|
||||
val responsibleAuthorityMailbox: String,
|
||||
val serialNumber: Int,
|
||||
val refreshInterval: Int,
|
||||
val retryInterval: Int,
|
||||
val expiryLimit: Int,
|
||||
val minimumTTL: Int
|
||||
)
|
||||
|
||||
data class SRVRecord(val priority: UShort, val weight: UShort, val port: UShort, val target: String)
|
||||
|
||||
data class NSRecord(val nameServer: String)
|
||||
|
||||
data class CAARecord(val flags: Byte, val tag: String, val value: String)
|
||||
|
||||
data class HINFORecord(val cpu: String, val os: String)
|
||||
|
||||
data class RPRecord(val mailbox: String, val txtDomainName: String)
|
||||
|
||||
|
||||
data class AFSDBRecord(val subtype: UShort, val hostname: String)
|
||||
data class LOCRecord(
|
||||
val version: Byte,
|
||||
val size: Double,
|
||||
val horizontalPrecision: Double,
|
||||
val verticalPrecision: Double,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val altitude: Double
|
||||
) {
|
||||
companion object {
|
||||
fun decodeSizeOrPrecision(coded: Byte): Double {
|
||||
val baseValue = (coded.toInt() shr 4) and 0x0F
|
||||
val exponent = coded.toInt() and 0x0F
|
||||
return baseValue * 10.0.pow(exponent.toDouble())
|
||||
}
|
||||
|
||||
fun decodeLatitudeOrLongitude(coded: Int): Double {
|
||||
val arcSeconds = coded / 1E3
|
||||
return arcSeconds / 3600.0
|
||||
}
|
||||
|
||||
fun decodeAltitude(coded: Int): Double {
|
||||
return (coded / 100.0) - 100000.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class NAPTRRecord(
|
||||
val order: UShort,
|
||||
val preference: UShort,
|
||||
val flags: String,
|
||||
val services: String,
|
||||
val regexp: String,
|
||||
val replacement: String
|
||||
)
|
||||
|
||||
data class RRSIGRecord(
|
||||
val typeCovered: UShort,
|
||||
val algorithm: Byte,
|
||||
val labels: Byte,
|
||||
val originalTTL: UInt,
|
||||
val signatureExpiration: UInt,
|
||||
val signatureInception: UInt,
|
||||
val keyTag: UShort,
|
||||
val signersName: String,
|
||||
val signature: ByteArray
|
||||
)
|
||||
|
||||
data class KXRecord(val preference: UShort, val exchanger: String)
|
||||
|
||||
data class CERTRecord(val type: UShort, val keyTag: UShort, val algorithm: Byte, val certificate: ByteArray)
|
||||
|
||||
|
||||
|
||||
data class DNAMERecord(val target: String)
|
||||
|
||||
data class DSRecord(val keyTag: UShort, val algorithm: Byte, val digestType: Byte, val digest: ByteArray)
|
||||
|
||||
data class SSHFPRecord(val algorithm: Byte, val fingerprintType: Byte, val fingerprint: ByteArray)
|
||||
|
||||
data class TLSARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray)
|
||||
|
||||
data class SMIMEARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray)
|
||||
|
||||
data class URIRecord(val priority: UShort, val weight: UShort, val target: String)
|
||||
|
||||
data class NSECRecord(val ownerName: String, val typeBitMaps: List<Pair<Byte, ByteArray>>)
|
||||
data class NSEC3Record(
|
||||
val hashAlgorithm: Byte,
|
||||
val flags: Byte,
|
||||
val iterations: UShort,
|
||||
val salt: ByteArray,
|
||||
val nextHashedOwnerName: ByteArray,
|
||||
val typeBitMaps: List<UShort>
|
||||
)
|
||||
|
||||
data class NSEC3PARAMRecord(val hashAlgorithm: Byte, val flags: Byte, val iterations: UShort, val salt: ByteArray)
|
||||
data class SPFRecord(val texts: List<String>)
|
||||
data class TKEYRecord(
|
||||
val algorithm: String,
|
||||
val inception: UInt,
|
||||
val expiration: UInt,
|
||||
val mode: UShort,
|
||||
val error: UShort,
|
||||
val keyData: ByteArray,
|
||||
val otherData: ByteArray
|
||||
)
|
||||
|
||||
data class TSIGRecord(
|
||||
val algorithmName: String,
|
||||
val timeSigned: UInt,
|
||||
val fudge: UShort,
|
||||
val mac: ByteArray,
|
||||
val originalID: UShort,
|
||||
val error: UShort,
|
||||
val otherData: ByteArray
|
||||
)
|
||||
|
||||
data class OPTRecordOption(val code: UShort, val data: ByteArray)
|
||||
data class OPTRecord(val options: List<OPTRecordOption>)
|
||||
|
||||
class DnsReader(private val data: ByteArray, private var position: Int = 0, private val length: Int = data.size) {
|
||||
|
||||
private val endPosition: Int = position + length
|
||||
|
||||
fun readDomainName(): String {
|
||||
return data.asUByteArray().readDomainName(position).also { position = it.second }.first
|
||||
}
|
||||
|
||||
fun readDouble(): Double {
|
||||
checkRemainingBytes(Double.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).double
|
||||
position += Double.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readInt16(): Short {
|
||||
checkRemainingBytes(Short.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short
|
||||
position += Short.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readInt32(): Int {
|
||||
checkRemainingBytes(Int.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int
|
||||
position += Int.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readInt64(): Long {
|
||||
checkRemainingBytes(Long.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long
|
||||
position += Long.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readSingle(): Float {
|
||||
checkRemainingBytes(Float.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).float
|
||||
position += Float.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readByte(): Byte {
|
||||
checkRemainingBytes(Byte.SIZE_BYTES)
|
||||
return data[position++]
|
||||
}
|
||||
|
||||
fun readBytes(length: Int): ByteArray {
|
||||
checkRemainingBytes(length)
|
||||
return ByteArray(length).also { data.copyInto(it, startIndex = position, endIndex = position + length) }
|
||||
.also { position += length }
|
||||
}
|
||||
|
||||
fun readUInt16(): UShort {
|
||||
checkRemainingBytes(Short.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short.toUShort()
|
||||
position += Short.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readUInt32(): UInt {
|
||||
checkRemainingBytes(Int.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int.toUInt()
|
||||
position += Int.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readUInt64(): ULong {
|
||||
checkRemainingBytes(Long.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long.toULong()
|
||||
position += Long.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readString(): String {
|
||||
val length = data[position++].toInt()
|
||||
checkRemainingBytes(length)
|
||||
return String(data, position, length, StandardCharsets.UTF_8).also { position += length }
|
||||
}
|
||||
|
||||
private fun checkRemainingBytes(requiredBytes: Int) {
|
||||
if (position + requiredBytes > endPosition) throw IndexOutOfBoundsException()
|
||||
}
|
||||
|
||||
fun readRPRecord(): RPRecord {
|
||||
return RPRecord(readDomainName(), readDomainName())
|
||||
}
|
||||
|
||||
fun readKXRecord(): KXRecord {
|
||||
val preference = readUInt16()
|
||||
val exchanger = readDomainName()
|
||||
return KXRecord(preference, exchanger)
|
||||
}
|
||||
|
||||
fun readCERTRecord(): CERTRecord {
|
||||
val type = readUInt16()
|
||||
val keyTag = readUInt16()
|
||||
val algorithm = readByte()
|
||||
val certificateLength = readUInt16().toInt() - 5
|
||||
val certificate = readBytes(certificateLength)
|
||||
return CERTRecord(type, keyTag, algorithm, certificate)
|
||||
}
|
||||
|
||||
fun readPTRRecord(): PTRRecord {
|
||||
return PTRRecord(readDomainName())
|
||||
}
|
||||
|
||||
fun readARecord(): ARecord {
|
||||
val address = readBytes(4)
|
||||
return ARecord(InetAddress.getByAddress(address))
|
||||
}
|
||||
|
||||
fun readAAAARecord(): AAAARecord {
|
||||
val address = readBytes(16)
|
||||
return AAAARecord(InetAddress.getByAddress(address))
|
||||
}
|
||||
|
||||
fun readMXRecord(): MXRecord {
|
||||
val preference = readUInt16()
|
||||
val exchange = readDomainName()
|
||||
return MXRecord(preference, exchange)
|
||||
}
|
||||
|
||||
fun readCNAMERecord(): CNAMERecord {
|
||||
return CNAMERecord(readDomainName())
|
||||
}
|
||||
|
||||
fun readTXTRecord(): TXTRecord {
|
||||
val texts = mutableListOf<String>()
|
||||
while (position < endPosition) {
|
||||
val textLength = data[position++].toInt()
|
||||
checkRemainingBytes(textLength)
|
||||
val text = String(data, position, textLength, StandardCharsets.UTF_8)
|
||||
texts.add(text)
|
||||
position += textLength
|
||||
}
|
||||
return TXTRecord(texts)
|
||||
}
|
||||
|
||||
fun readSOARecord(): SOARecord {
|
||||
val primaryNameServer = readDomainName()
|
||||
val responsibleAuthorityMailbox = readDomainName()
|
||||
val serialNumber = readInt32()
|
||||
val refreshInterval = readInt32()
|
||||
val retryInterval = readInt32()
|
||||
val expiryLimit = readInt32()
|
||||
val minimumTTL = readInt32()
|
||||
return SOARecord(primaryNameServer, responsibleAuthorityMailbox, serialNumber, refreshInterval, retryInterval, expiryLimit, minimumTTL)
|
||||
}
|
||||
|
||||
fun readSRVRecord(): SRVRecord {
|
||||
val priority = readUInt16()
|
||||
val weight = readUInt16()
|
||||
val port = readUInt16()
|
||||
val target = readDomainName()
|
||||
return SRVRecord(priority, weight, port, target)
|
||||
}
|
||||
|
||||
fun readNSRecord(): NSRecord {
|
||||
return NSRecord(readDomainName())
|
||||
}
|
||||
|
||||
fun readCAARecord(): CAARecord {
|
||||
val length = readUInt16().toInt()
|
||||
val flags = readByte()
|
||||
val tagLength = readByte().toInt()
|
||||
val tag = String(data, position, tagLength, StandardCharsets.US_ASCII).also { position += tagLength }
|
||||
val valueLength = length - 1 - 1 - tagLength
|
||||
val value = String(data, position, valueLength, StandardCharsets.US_ASCII).also { position += valueLength }
|
||||
return CAARecord(flags, tag, value)
|
||||
}
|
||||
|
||||
fun readHINFORecord(): HINFORecord {
|
||||
val cpuLength = readByte().toInt()
|
||||
val cpu = String(data, position, cpuLength, StandardCharsets.US_ASCII).also { position += cpuLength }
|
||||
val osLength = readByte().toInt()
|
||||
val os = String(data, position, osLength, StandardCharsets.US_ASCII).also { position += osLength }
|
||||
return HINFORecord(cpu, os)
|
||||
}
|
||||
|
||||
fun readAFSDBRecord(): AFSDBRecord {
|
||||
return AFSDBRecord(readUInt16(), readDomainName())
|
||||
}
|
||||
|
||||
fun readLOCRecord(): LOCRecord {
|
||||
val version = readByte()
|
||||
val size = LOCRecord.decodeSizeOrPrecision(readByte())
|
||||
val horizontalPrecision = LOCRecord.decodeSizeOrPrecision(readByte())
|
||||
val verticalPrecision = LOCRecord.decodeSizeOrPrecision(readByte())
|
||||
val latitudeCoded = readInt32()
|
||||
val longitudeCoded = readInt32()
|
||||
val altitudeCoded = readInt32()
|
||||
val latitude = LOCRecord.decodeLatitudeOrLongitude(latitudeCoded)
|
||||
val longitude = LOCRecord.decodeLatitudeOrLongitude(longitudeCoded)
|
||||
val altitude = LOCRecord.decodeAltitude(altitudeCoded)
|
||||
return LOCRecord(version, size, horizontalPrecision, verticalPrecision, latitude, longitude, altitude)
|
||||
}
|
||||
|
||||
fun readNAPTRRecord(): NAPTRRecord {
|
||||
val order = readUInt16()
|
||||
val preference = readUInt16()
|
||||
val flags = readString()
|
||||
val services = readString()
|
||||
val regexp = readString()
|
||||
val replacement = readDomainName()
|
||||
return NAPTRRecord(order, preference, flags, services, regexp, replacement)
|
||||
}
|
||||
|
||||
fun readDNAMERecord(): DNAMERecord {
|
||||
return DNAMERecord(readDomainName())
|
||||
}
|
||||
|
||||
fun readDSRecord(): DSRecord {
|
||||
val keyTag = readUInt16()
|
||||
val algorithm = readByte()
|
||||
val digestType = readByte()
|
||||
val digestLength = readUInt16().toInt() - 4
|
||||
val digest = readBytes(digestLength)
|
||||
return DSRecord(keyTag, algorithm, digestType, digest)
|
||||
}
|
||||
|
||||
fun readSSHFPRecord(): SSHFPRecord {
|
||||
val algorithm = readByte()
|
||||
val fingerprintType = readByte()
|
||||
val fingerprintLength = readUInt16().toInt() - 2
|
||||
val fingerprint = readBytes(fingerprintLength)
|
||||
return SSHFPRecord(algorithm, fingerprintType, fingerprint)
|
||||
}
|
||||
|
||||
fun readTLSARecord(): TLSARecord {
|
||||
val usage = readByte()
|
||||
val selector = readByte()
|
||||
val matchingType = readByte()
|
||||
val dataLength = readUInt16().toInt() - 3
|
||||
val certificateAssociationData = readBytes(dataLength)
|
||||
return TLSARecord(usage, selector, matchingType, certificateAssociationData)
|
||||
}
|
||||
|
||||
fun readSMIMEARecord(): SMIMEARecord {
|
||||
val usage = readByte()
|
||||
val selector = readByte()
|
||||
val matchingType = readByte()
|
||||
val dataLength = readUInt16().toInt() - 3
|
||||
val certificateAssociationData = readBytes(dataLength)
|
||||
return SMIMEARecord(usage, selector, matchingType, certificateAssociationData)
|
||||
}
|
||||
|
||||
fun readURIRecord(): URIRecord {
|
||||
val priority = readUInt16()
|
||||
val weight = readUInt16()
|
||||
val length = readUInt16().toInt()
|
||||
val target = String(data, position, length, StandardCharsets.US_ASCII).also { position += length }
|
||||
return URIRecord(priority, weight, target)
|
||||
}
|
||||
|
||||
fun readRRSIGRecord(): RRSIGRecord {
|
||||
val typeCovered = readUInt16()
|
||||
val algorithm = readByte()
|
||||
val labels = readByte()
|
||||
val originalTTL = readUInt32()
|
||||
val signatureExpiration = readUInt32()
|
||||
val signatureInception = readUInt32()
|
||||
val keyTag = readUInt16()
|
||||
val signersName = readDomainName()
|
||||
val signatureLength = readUInt16().toInt()
|
||||
val signature = readBytes(signatureLength)
|
||||
return RRSIGRecord(
|
||||
typeCovered,
|
||||
algorithm,
|
||||
labels,
|
||||
originalTTL,
|
||||
signatureExpiration,
|
||||
signatureInception,
|
||||
keyTag,
|
||||
signersName,
|
||||
signature
|
||||
)
|
||||
}
|
||||
|
||||
fun readNSECRecord(): NSECRecord {
|
||||
val ownerName = readDomainName()
|
||||
val typeBitMaps = mutableListOf<Pair<Byte, ByteArray>>()
|
||||
while (position < endPosition) {
|
||||
val windowBlock = readByte()
|
||||
val bitmapLength = readByte().toInt()
|
||||
val bitmap = readBytes(bitmapLength)
|
||||
typeBitMaps.add(windowBlock to bitmap)
|
||||
}
|
||||
return NSECRecord(ownerName, typeBitMaps)
|
||||
}
|
||||
|
||||
fun readNSEC3Record(): NSEC3Record {
|
||||
val hashAlgorithm = readByte()
|
||||
val flags = readByte()
|
||||
val iterations = readUInt16()
|
||||
val saltLength = readByte().toInt()
|
||||
val salt = readBytes(saltLength)
|
||||
val hashLength = readByte().toInt()
|
||||
val nextHashedOwnerName = readBytes(hashLength)
|
||||
val bitMapLength = readUInt16().toInt()
|
||||
val typeBitMaps = mutableListOf<UShort>()
|
||||
val endPos = position + bitMapLength
|
||||
while (position < endPos) {
|
||||
typeBitMaps.add(readUInt16())
|
||||
}
|
||||
return NSEC3Record(hashAlgorithm, flags, iterations, salt, nextHashedOwnerName, typeBitMaps)
|
||||
}
|
||||
|
||||
fun readNSEC3PARAMRecord(): NSEC3PARAMRecord {
|
||||
val hashAlgorithm = readByte()
|
||||
val flags = readByte()
|
||||
val iterations = readUInt16()
|
||||
val saltLength = readByte().toInt()
|
||||
val salt = readBytes(saltLength)
|
||||
return NSEC3PARAMRecord(hashAlgorithm, flags, iterations, salt)
|
||||
}
|
||||
|
||||
|
||||
fun readSPFRecord(): SPFRecord {
|
||||
val length = readUInt16().toInt()
|
||||
val texts = mutableListOf<String>()
|
||||
val endPos = position + length
|
||||
while (position < endPos) {
|
||||
val textLength = readByte().toInt()
|
||||
val text = String(data, position, textLength, StandardCharsets.US_ASCII).also { position += textLength }
|
||||
texts.add(text)
|
||||
}
|
||||
return SPFRecord(texts)
|
||||
}
|
||||
|
||||
fun readTKEYRecord(): TKEYRecord {
|
||||
val algorithm = readDomainName()
|
||||
val inception = readUInt32()
|
||||
val expiration = readUInt32()
|
||||
val mode = readUInt16()
|
||||
val error = readUInt16()
|
||||
val keySize = readUInt16().toInt()
|
||||
val keyData = readBytes(keySize)
|
||||
val otherSize = readUInt16().toInt()
|
||||
val otherData = readBytes(otherSize)
|
||||
return TKEYRecord(algorithm, inception, expiration, mode, error, keyData, otherData)
|
||||
}
|
||||
|
||||
fun readTSIGRecord(): TSIGRecord {
|
||||
val algorithmName = readDomainName()
|
||||
val timeSigned = readUInt32()
|
||||
val fudge = readUInt16()
|
||||
val macSize = readUInt16().toInt()
|
||||
val mac = readBytes(macSize)
|
||||
val originalID = readUInt16()
|
||||
val error = readUInt16()
|
||||
val otherSize = readUInt16().toInt()
|
||||
val otherData = readBytes(otherSize)
|
||||
return TSIGRecord(algorithmName, timeSigned, fudge, mac, originalID, error, otherData)
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun readOPTRecord(): OPTRecord {
|
||||
val options = mutableListOf<OPTRecordOption>()
|
||||
while (position < endPosition) {
|
||||
val optionCode = readUInt16()
|
||||
val optionLength = readUInt16().toInt()
|
||||
val optionData = readBytes(optionLength)
|
||||
options.add(OPTRecordOption(optionCode, optionData))
|
||||
}
|
||||
return OPTRecord(options)
|
||||
}
|
||||
}
|
|
@ -1,117 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import com.futo.platformplayer.mdns.Extensions.readDomainName
|
||||
|
||||
enum class ResourceRecordType(val value: UShort) {
|
||||
None(0u),
|
||||
A(1u),
|
||||
NS(2u),
|
||||
MD(3u),
|
||||
MF(4u),
|
||||
CNAME(5u),
|
||||
SOA(6u),
|
||||
MB(7u),
|
||||
MG(8u),
|
||||
MR(9u),
|
||||
NULL(10u),
|
||||
WKS(11u),
|
||||
PTR(12u),
|
||||
HINFO(13u),
|
||||
MINFO(14u),
|
||||
MX(15u),
|
||||
TXT(16u),
|
||||
RP(17u),
|
||||
AFSDB(18u),
|
||||
SIG(24u),
|
||||
KEY(25u),
|
||||
AAAA(28u),
|
||||
LOC(29u),
|
||||
SRV(33u),
|
||||
NAPTR(35u),
|
||||
KX(36u),
|
||||
CERT(37u),
|
||||
DNAME(39u),
|
||||
APL(42u),
|
||||
DS(43u),
|
||||
SSHFP(44u),
|
||||
IPSECKEY(45u),
|
||||
RRSIG(46u),
|
||||
NSEC(47u),
|
||||
DNSKEY(48u),
|
||||
DHCID(49u),
|
||||
NSEC3(50u),
|
||||
NSEC3PARAM(51u),
|
||||
TSLA(52u),
|
||||
SMIMEA(53u),
|
||||
HIP(55u),
|
||||
CDS(59u),
|
||||
CDNSKEY(60u),
|
||||
OPENPGPKEY(61u),
|
||||
CSYNC(62u),
|
||||
ZONEMD(63u),
|
||||
SVCB(64u),
|
||||
HTTPS(65u),
|
||||
EUI48(108u),
|
||||
EUI64(109u),
|
||||
TKEY(249u),
|
||||
TSIG(250u),
|
||||
URI(256u),
|
||||
CAA(257u),
|
||||
TA(32768u),
|
||||
DLV(32769u),
|
||||
AXFR(252u),
|
||||
IXFR(251u),
|
||||
OPT(41u)
|
||||
}
|
||||
|
||||
enum class ResourceRecordClass(val value: UShort) {
|
||||
IN(1u),
|
||||
CS(2u),
|
||||
CH(3u),
|
||||
HS(4u)
|
||||
}
|
||||
|
||||
data class DnsResourceRecord(
|
||||
override val name: String,
|
||||
override val type: Int,
|
||||
override val clazz: Int,
|
||||
val timeToLive: UInt,
|
||||
val cacheFlush: Boolean,
|
||||
val dataPosition: Int = -1,
|
||||
val dataLength: Int = -1,
|
||||
private val data: ByteArray? = null
|
||||
) : DnsResourceRecordBase(name, type, clazz) {
|
||||
|
||||
companion object {
|
||||
fun parse(data: ByteArray, startPosition: Int): Pair<DnsResourceRecord, Int> {
|
||||
val span = data.asUByteArray()
|
||||
var position = startPosition
|
||||
val name = span.readDomainName(position).also { position = it.second }
|
||||
val type = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
||||
position += 2
|
||||
val clazz = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
||||
position += 2
|
||||
val ttl = (span[position].toInt() shl 24 or (span[position + 1].toInt() shl 16) or
|
||||
(span[position + 2].toInt() shl 8) or span[position + 3].toInt()).toUInt()
|
||||
position += 4
|
||||
val rdlength = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
||||
val rdposition = position + 2
|
||||
position += 2 + rdlength.toInt()
|
||||
|
||||
return DnsResourceRecord(
|
||||
name = name.first,
|
||||
type = type.toInt(),
|
||||
clazz = clazz.toInt() and 0b1111111_11111111,
|
||||
timeToLive = ttl,
|
||||
cacheFlush = ((clazz.toInt() shr 15) and 0b1) != 0,
|
||||
dataPosition = rdposition,
|
||||
dataLength = rdlength.toInt(),
|
||||
data = data
|
||||
) to position
|
||||
}
|
||||
}
|
||||
|
||||
fun getDataReader(): DnsReader {
|
||||
return DnsReader(data!!, dataPosition, dataLength)
|
||||
}
|
||||
}
|
|
@ -1,208 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class DnsWriter {
|
||||
private val data = mutableListOf<Byte>()
|
||||
private val namePositions = mutableMapOf<String, Int>()
|
||||
|
||||
fun toByteArray(): ByteArray = data.toByteArray()
|
||||
|
||||
fun writePacket(
|
||||
header: DnsPacketHeader,
|
||||
questionCount: Int? = null, questionWriter: ((DnsWriter, Int) -> Unit)? = null,
|
||||
answerCount: Int? = null, answerWriter: ((DnsWriter, Int) -> Unit)? = null,
|
||||
authorityCount: Int? = null, authorityWriter: ((DnsWriter, Int) -> Unit)? = null,
|
||||
additionalsCount: Int? = null, additionalWriter: ((DnsWriter, Int) -> Unit)? = null
|
||||
) {
|
||||
if (questionCount != null && questionWriter == null || questionCount == null && questionWriter != null)
|
||||
throw Exception("When question count is given, question writer should also be given.")
|
||||
if (answerCount != null && answerWriter == null || answerCount == null && answerWriter != null)
|
||||
throw Exception("When answer count is given, answer writer should also be given.")
|
||||
if (authorityCount != null && authorityWriter == null || authorityCount == null && authorityWriter != null)
|
||||
throw Exception("When authority count is given, authority writer should also be given.")
|
||||
if (additionalsCount != null && additionalWriter == null || additionalsCount == null && additionalWriter != null)
|
||||
throw Exception("When additionals count is given, additional writer should also be given.")
|
||||
|
||||
writeHeader(header, questionCount ?: 0, answerCount ?: 0, authorityCount ?: 0, additionalsCount ?: 0)
|
||||
|
||||
repeat(questionCount ?: 0) { questionWriter?.invoke(this, it) }
|
||||
repeat(answerCount ?: 0) { answerWriter?.invoke(this, it) }
|
||||
repeat(authorityCount ?: 0) { authorityWriter?.invoke(this, it) }
|
||||
repeat(additionalsCount ?: 0) { additionalWriter?.invoke(this, it) }
|
||||
}
|
||||
|
||||
fun writeHeader(header: DnsPacketHeader, questionCount: Int, answerCount: Int, authorityCount: Int, additionalsCount: Int) {
|
||||
write(header.identifier)
|
||||
|
||||
var flags: UShort = 0u
|
||||
flags = flags or ((header.queryResponse.toUInt() and 0xFFFFu) shl 15).toUShort()
|
||||
flags = flags or ((header.opcode.toUInt() and 0xFFFFu) shl 11).toUShort()
|
||||
flags = flags or ((if (header.authoritativeAnswer) 1u else 0u) shl 10).toUShort()
|
||||
flags = flags or ((if (header.truncated) 1u else 0u) shl 9).toUShort()
|
||||
flags = flags or ((if (header.recursionDesired) 1u else 0u) shl 8).toUShort()
|
||||
flags = flags or ((if (header.recursionAvailable) 1u else 0u) shl 7).toUShort()
|
||||
flags = flags or ((if (header.answerAuthenticated) 1u else 0u) shl 5).toUShort()
|
||||
flags = flags or ((if (header.nonAuthenticatedData) 1u else 0u) shl 4).toUShort()
|
||||
flags = flags or header.responseCode.value.toUShort()
|
||||
write(flags)
|
||||
|
||||
write(questionCount.toUShort())
|
||||
write(answerCount.toUShort())
|
||||
write(authorityCount.toUShort())
|
||||
write(additionalsCount.toUShort())
|
||||
}
|
||||
|
||||
fun writeDomainName(name: String) {
|
||||
synchronized(namePositions) {
|
||||
val labels = name.split('.')
|
||||
for (label in labels) {
|
||||
val nameAtOffset = name.substring(name.indexOf(label))
|
||||
if (namePositions.containsKey(nameAtOffset)) {
|
||||
val position = namePositions[nameAtOffset]!!
|
||||
val pointer = (0b11000000_00000000 or position).toUShort()
|
||||
write(pointer)
|
||||
return
|
||||
}
|
||||
if (label.isNotEmpty()) {
|
||||
val labelBytes = label.toByteArray(StandardCharsets.UTF_8)
|
||||
val nameStartPos = data.size
|
||||
write(labelBytes.size.toByte())
|
||||
write(labelBytes)
|
||||
namePositions[nameAtOffset] = nameStartPos
|
||||
}
|
||||
}
|
||||
write(0.toByte())
|
||||
}
|
||||
}
|
||||
|
||||
fun write(value: DnsResourceRecord, dataWriter: (DnsWriter) -> Unit) {
|
||||
writeDomainName(value.name)
|
||||
write(value.type.toUShort())
|
||||
val cls = ((if (value.cacheFlush) 1u else 0u) shl 15).toUShort() or value.clazz.toUShort()
|
||||
write(cls)
|
||||
write(value.timeToLive)
|
||||
|
||||
val lengthOffset = data.size
|
||||
write(0.toUShort())
|
||||
dataWriter(this)
|
||||
val rdLength = data.size - lengthOffset - 2
|
||||
val rdLengthBytes = ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN).putShort(rdLength.toShort()).array()
|
||||
data[lengthOffset] = rdLengthBytes[0]
|
||||
data[lengthOffset + 1] = rdLengthBytes[1]
|
||||
}
|
||||
|
||||
fun write(value: DnsQuestion) {
|
||||
writeDomainName(value.name)
|
||||
write(value.type.toUShort())
|
||||
write(((if (value.queryUnicast) 1u else 0u shl 15).toUShort() or value.clazz.toUShort()))
|
||||
}
|
||||
|
||||
fun write(value: Double) {
|
||||
val bytes = ByteBuffer.allocate(Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putDouble(value).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: Short) {
|
||||
val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: Int) {
|
||||
val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: Long) {
|
||||
val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: Float) {
|
||||
val bytes = ByteBuffer.allocate(Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putFloat(value).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: Byte) {
|
||||
data.add(value)
|
||||
}
|
||||
|
||||
fun write(value: ByteArray) {
|
||||
data.addAll(value.asIterable())
|
||||
}
|
||||
|
||||
fun write(value: ByteArray, offset: Int, length: Int) {
|
||||
data.addAll(value.slice(offset until offset + length))
|
||||
}
|
||||
|
||||
fun write(value: UShort) {
|
||||
val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value.toShort()).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: UInt) {
|
||||
val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value.toInt()).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: ULong) {
|
||||
val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value.toLong()).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: String) {
|
||||
val bytes = value.toByteArray(StandardCharsets.UTF_8)
|
||||
write(bytes.size.toByte())
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: PTRRecord) {
|
||||
writeDomainName(value.domainName)
|
||||
}
|
||||
|
||||
fun write(value: ARecord) {
|
||||
val bytes = value.address.address
|
||||
if (bytes.size != 4) throw Exception("Unexpected amount of address bytes.")
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: AAAARecord) {
|
||||
val bytes = value.address.address
|
||||
if (bytes.size != 16) throw Exception("Unexpected amount of address bytes.")
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: TXTRecord) {
|
||||
value.texts.forEach {
|
||||
val bytes = it.toByteArray(StandardCharsets.UTF_8)
|
||||
write(bytes.size.toByte())
|
||||
write(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fun write(value: SRVRecord) {
|
||||
write(value.priority)
|
||||
write(value.weight)
|
||||
write(value.port)
|
||||
writeDomainName(value.target)
|
||||
}
|
||||
|
||||
fun write(value: NSECRecord) {
|
||||
writeDomainName(value.ownerName)
|
||||
value.typeBitMaps.forEach { (windowBlock, bitmap) ->
|
||||
write(windowBlock)
|
||||
write(bitmap.size.toByte())
|
||||
write(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
fun write(value: OPTRecord) {
|
||||
value.options.forEach { option ->
|
||||
write(option.code)
|
||||
write(option.data.size.toUShort())
|
||||
write(option.data)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import android.util.Log
|
||||
|
||||
object Extensions {
|
||||
fun ByteArray.toByteDump(): String {
|
||||
val result = StringBuilder()
|
||||
for (i in indices) {
|
||||
result.append(String.format("%02X ", this[i]))
|
||||
|
||||
if ((i + 1) % 16 == 0 || i == size - 1) {
|
||||
val padding = 3 * (16 - (i % 16 + 1))
|
||||
if (i == size - 1 && (i + 1) % 16 != 0) result.append(" ".repeat(padding))
|
||||
|
||||
result.append("; ")
|
||||
val start = i - (i % 16)
|
||||
val end = minOf(i, size - 1)
|
||||
for (j in start..end) {
|
||||
val ch = if (this[j] in 32..127) this[j].toChar() else '.'
|
||||
result.append(ch)
|
||||
}
|
||||
if (i != size - 1) result.appendLine()
|
||||
}
|
||||
}
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
fun UByteArray.readDomainName(startPosition: Int): Pair<String, Int> {
|
||||
var position = startPosition
|
||||
return readDomainName(position, 0)
|
||||
}
|
||||
|
||||
private fun UByteArray.readDomainName(position: Int, depth: Int = 0): Pair<String, Int> {
|
||||
if (depth > 16) throw Exception("Exceeded maximum recursion depth in DNS packet. Possible circular reference.")
|
||||
|
||||
val domainParts = mutableListOf<String>()
|
||||
var newPosition = position
|
||||
|
||||
while (true) {
|
||||
if (newPosition < 0)
|
||||
println()
|
||||
|
||||
val length = this[newPosition].toUByte()
|
||||
if ((length and 0b11000000u).toUInt() == 0b11000000u) {
|
||||
val offset = (((length and 0b00111111u).toUInt()) shl 8) or this[newPosition + 1].toUInt()
|
||||
val (part, _) = this.readDomainName(offset.toInt(), depth + 1)
|
||||
domainParts.add(part)
|
||||
newPosition += 2
|
||||
break
|
||||
} else if (length.toUInt() == 0u) {
|
||||
newPosition++
|
||||
break
|
||||
} else {
|
||||
newPosition++
|
||||
val part = String(this.asByteArray(), newPosition, length.toInt(), Charsets.UTF_8)
|
||||
domainParts.add(part)
|
||||
newPosition += length.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
return domainParts.joinToString(".") to newPosition
|
||||
}
|
||||
}
|
|
@ -1,495 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.coroutines.*
|
||||
import java.net.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
class MDNSListener {
|
||||
companion object {
|
||||
private val TAG = "MDNSListener"
|
||||
const val MulticastPort = 5353
|
||||
val MulticastAddressIPv4: InetAddress = InetAddress.getByName("224.0.0.251")
|
||||
val MulticastAddressIPv6: InetAddress = InetAddress.getByName("FF02::FB")
|
||||
val MdnsEndpointIPv6: InetSocketAddress = InetSocketAddress(MulticastAddressIPv6, MulticastPort)
|
||||
val MdnsEndpointIPv4: InetSocketAddress = InetSocketAddress(MulticastAddressIPv4, MulticastPort)
|
||||
}
|
||||
|
||||
private val _lockObject = ReentrantLock()
|
||||
private var _receiver4: MulticastSocket? = null
|
||||
private var _receiver6: MulticastSocket? = null
|
||||
private val _senders = mutableListOf<MulticastSocket>()
|
||||
private val _nicMonitor = NICMonitor()
|
||||
private val _serviceRecordAggregator = ServiceRecordAggregator()
|
||||
private var _started = false
|
||||
private var _threadReceiver4: Thread? = null
|
||||
private var _threadReceiver6: Thread? = null
|
||||
private var _scope: CoroutineScope? = null
|
||||
|
||||
var onPacket: ((DnsPacket) -> Unit)? = null
|
||||
var onServicesUpdated: ((List<DnsService>) -> Unit)? = null
|
||||
|
||||
private val _recordLockObject = ReentrantLock()
|
||||
private val _recordsA = mutableListOf<Pair<DnsResourceRecord, ARecord>>()
|
||||
private val _recordsAAAA = mutableListOf<Pair<DnsResourceRecord, AAAARecord>>()
|
||||
private val _recordsPTR = mutableListOf<Pair<DnsResourceRecord, PTRRecord>>()
|
||||
private val _recordsTXT = mutableListOf<Pair<DnsResourceRecord, TXTRecord>>()
|
||||
private val _recordsSRV = mutableListOf<Pair<DnsResourceRecord, SRVRecord>>()
|
||||
private val _services = mutableListOf<BroadcastService>()
|
||||
|
||||
init {
|
||||
_nicMonitor.added = { onNicsAdded(it) }
|
||||
_nicMonitor.removed = { onNicsRemoved(it) }
|
||||
_serviceRecordAggregator.onServicesUpdated = { onServicesUpdated?.invoke(it) }
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (_started) {
|
||||
Logger.i(TAG, "Already started.")
|
||||
return
|
||||
}
|
||||
_started = true
|
||||
|
||||
_scope = CoroutineScope(Dispatchers.IO);
|
||||
|
||||
Logger.i(TAG, "Starting")
|
||||
_lockObject.withLock {
|
||||
val receiver4 = MulticastSocket(null).apply {
|
||||
reuseAddress = true
|
||||
bind(InetSocketAddress(InetAddress.getByName("0.0.0.0"), MulticastPort))
|
||||
}
|
||||
_receiver4 = receiver4
|
||||
|
||||
val receiver6 = MulticastSocket(null).apply {
|
||||
reuseAddress = true
|
||||
bind(InetSocketAddress(InetAddress.getByName("::"), MulticastPort))
|
||||
}
|
||||
_receiver6 = receiver6
|
||||
|
||||
_nicMonitor.start()
|
||||
_serviceRecordAggregator.start()
|
||||
onNicsAdded(_nicMonitor.current)
|
||||
|
||||
_threadReceiver4 = Thread {
|
||||
receiveLoop(receiver4)
|
||||
}.apply { start() }
|
||||
|
||||
_threadReceiver6 = Thread {
|
||||
receiveLoop(receiver6)
|
||||
}.apply { start() }
|
||||
}
|
||||
}
|
||||
|
||||
fun queryServices(names: Array<String>) {
|
||||
if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.")
|
||||
|
||||
val writer = DnsWriter()
|
||||
writer.writePacket(
|
||||
DnsPacketHeader(
|
||||
identifier = 0u,
|
||||
queryResponse = QueryResponse.Query.value.toInt(),
|
||||
opcode = DnsOpcode.StandardQuery.value.toInt(),
|
||||
truncated = false,
|
||||
nonAuthenticatedData = false,
|
||||
recursionDesired = false,
|
||||
answerAuthenticated = false,
|
||||
authoritativeAnswer = false,
|
||||
recursionAvailable = false,
|
||||
responseCode = DnsResponseCode.NoError
|
||||
),
|
||||
questionCount = names.size,
|
||||
questionWriter = { w, i ->
|
||||
w.write(
|
||||
DnsQuestion(
|
||||
name = names[i],
|
||||
type = QuestionType.PTR.value.toInt(),
|
||||
clazz = QuestionClass.IN.value.toInt(),
|
||||
queryUnicast = false
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
send(writer.toByteArray())
|
||||
}
|
||||
|
||||
private fun send(data: ByteArray) {
|
||||
_lockObject.withLock {
|
||||
for (sender in _senders) {
|
||||
try {
|
||||
val endPoint = if (sender.localAddress is Inet4Address) MdnsEndpointIPv4 else MdnsEndpointIPv6
|
||||
sender.send(DatagramPacket(data, data.size, endPoint))
|
||||
} catch (e: Exception) {
|
||||
Logger.i(TAG, "Failed to send on ${sender.localSocketAddress}: ${e.message}.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun queryAllQuestions(names: Array<String>) {
|
||||
if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.")
|
||||
|
||||
val questions = names.flatMap { _serviceRecordAggregator.getAllQuestions(it) }
|
||||
questions.groupBy { it.name }.forEach { (_, questionsForHost) ->
|
||||
val writer = DnsWriter()
|
||||
writer.writePacket(
|
||||
DnsPacketHeader(
|
||||
identifier = 0u,
|
||||
queryResponse = QueryResponse.Query.value.toInt(),
|
||||
opcode = DnsOpcode.StandardQuery.value.toInt(),
|
||||
truncated = false,
|
||||
nonAuthenticatedData = false,
|
||||
recursionDesired = false,
|
||||
answerAuthenticated = false,
|
||||
authoritativeAnswer = false,
|
||||
recursionAvailable = false,
|
||||
responseCode = DnsResponseCode.NoError
|
||||
),
|
||||
questionCount = questionsForHost.size,
|
||||
questionWriter = { w, i -> w.write(questionsForHost[i]) }
|
||||
)
|
||||
send(writer.toByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNicsAdded(nics: List<NetworkInterface>) {
|
||||
_lockObject.withLock {
|
||||
if (!_started) return
|
||||
|
||||
val addresses = nics.flatMap { nic ->
|
||||
nic.interfaceAddresses.map { it.address }
|
||||
.filter { it is Inet4Address || (it is Inet6Address && it.isLinkLocalAddress) }
|
||||
}
|
||||
|
||||
addresses.forEach { address ->
|
||||
Logger.i(TAG, "New address discovered $address")
|
||||
|
||||
try {
|
||||
when (address) {
|
||||
is Inet4Address -> {
|
||||
_receiver4?.let { receiver4 ->
|
||||
//receiver4.setOption(StandardSocketOptions.IP_MULTICAST_IF, NetworkInterface.getByInetAddress(address))
|
||||
receiver4.joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address))
|
||||
}
|
||||
|
||||
val sender = MulticastSocket(null).apply {
|
||||
reuseAddress = true
|
||||
bind(InetSocketAddress(address, MulticastPort))
|
||||
joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address))
|
||||
}
|
||||
_senders.add(sender)
|
||||
}
|
||||
|
||||
is Inet6Address -> {
|
||||
_receiver6?.let { receiver6 ->
|
||||
//receiver6.setOption(StandardSocketOptions.IP_MULTICAST_IF, NetworkInterface.getByInetAddress(address))
|
||||
receiver6.joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), NetworkInterface.getByInetAddress(address))
|
||||
}
|
||||
|
||||
val sender = MulticastSocket(null).apply {
|
||||
reuseAddress = true
|
||||
bind(InetSocketAddress(address, MulticastPort))
|
||||
joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), NetworkInterface.getByInetAddress(address))
|
||||
}
|
||||
_senders.add(sender)
|
||||
}
|
||||
|
||||
else -> throw UnsupportedOperationException("Address type ${address.javaClass.name} is not supported.")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.i(TAG, "Exception occurred when processing added address $address: ${e.message}.")
|
||||
// Close the socket if there was an error
|
||||
(_senders.lastOrNull() as? MulticastSocket)?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nics.isNotEmpty()) {
|
||||
try {
|
||||
updateBroadcastRecords()
|
||||
broadcastRecords()
|
||||
} catch (e: Exception) {
|
||||
Logger.i(TAG, "Exception occurred when broadcasting records: ${e.message}.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNicsRemoved(nics: List<NetworkInterface>) {
|
||||
_lockObject.withLock {
|
||||
if (!_started) return
|
||||
//TODO: Cleanup?
|
||||
}
|
||||
|
||||
if (nics.isNotEmpty()) {
|
||||
try {
|
||||
updateBroadcastRecords()
|
||||
broadcastRecords()
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Exception occurred when broadcasting records", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun receiveLoop(client: DatagramSocket) {
|
||||
Logger.i(TAG, "Started receive loop")
|
||||
|
||||
val buffer = ByteArray(8972)
|
||||
val packet = DatagramPacket(buffer, buffer.size)
|
||||
while (_started) {
|
||||
try {
|
||||
client.receive(packet)
|
||||
handleResult(packet)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "An exception occurred while handling UDP result:", e)
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped receive loop")
|
||||
}
|
||||
|
||||
fun broadcastService(
|
||||
deviceName: String,
|
||||
serviceName: String,
|
||||
port: UShort,
|
||||
ttl: UInt = 120u,
|
||||
weight: UShort = 0u,
|
||||
priority: UShort = 0u,
|
||||
texts: List<String>? = null
|
||||
) {
|
||||
_recordLockObject.withLock {
|
||||
_services.add(
|
||||
BroadcastService(
|
||||
deviceName = deviceName,
|
||||
port = port,
|
||||
priority = priority,
|
||||
serviceName = serviceName,
|
||||
texts = texts,
|
||||
ttl = ttl,
|
||||
weight = weight
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
updateBroadcastRecords()
|
||||
broadcastRecords()
|
||||
}
|
||||
|
||||
private fun updateBroadcastRecords() {
|
||||
_recordLockObject.withLock {
|
||||
_recordsSRV.clear()
|
||||
_recordsPTR.clear()
|
||||
_recordsA.clear()
|
||||
_recordsAAAA.clear()
|
||||
_recordsTXT.clear()
|
||||
|
||||
_services.forEach { service ->
|
||||
val id = UUID.randomUUID().toString()
|
||||
val deviceDomainName = "${service.deviceName}.${service.serviceName}"
|
||||
val addressName = "$id.local"
|
||||
|
||||
_recordsSRV.add(
|
||||
DnsResourceRecord(
|
||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
||||
type = ResourceRecordType.SRV.value.toInt(),
|
||||
timeToLive = service.ttl,
|
||||
name = deviceDomainName,
|
||||
cacheFlush = false
|
||||
) to SRVRecord(
|
||||
target = addressName,
|
||||
port = service.port,
|
||||
priority = service.priority,
|
||||
weight = service.weight
|
||||
)
|
||||
)
|
||||
|
||||
_recordsPTR.add(
|
||||
DnsResourceRecord(
|
||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
||||
type = ResourceRecordType.PTR.value.toInt(),
|
||||
timeToLive = service.ttl,
|
||||
name = service.serviceName,
|
||||
cacheFlush = false
|
||||
) to PTRRecord(
|
||||
domainName = deviceDomainName
|
||||
)
|
||||
)
|
||||
|
||||
val addresses = _nicMonitor.current.flatMap { nic ->
|
||||
nic.interfaceAddresses.map { it.address }
|
||||
}
|
||||
|
||||
addresses.forEach { address ->
|
||||
when (address) {
|
||||
is Inet4Address -> _recordsA.add(
|
||||
DnsResourceRecord(
|
||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
||||
type = ResourceRecordType.A.value.toInt(),
|
||||
timeToLive = service.ttl,
|
||||
name = addressName,
|
||||
cacheFlush = false
|
||||
) to ARecord(
|
||||
address = address
|
||||
)
|
||||
)
|
||||
|
||||
is Inet6Address -> _recordsAAAA.add(
|
||||
DnsResourceRecord(
|
||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
||||
type = ResourceRecordType.AAAA.value.toInt(),
|
||||
timeToLive = service.ttl,
|
||||
name = addressName,
|
||||
cacheFlush = false
|
||||
) to AAAARecord(
|
||||
address = address
|
||||
)
|
||||
)
|
||||
|
||||
else -> Logger.i(TAG, "Invalid address type: $address.")
|
||||
}
|
||||
}
|
||||
|
||||
if (service.texts != null) {
|
||||
_recordsTXT.add(
|
||||
DnsResourceRecord(
|
||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
||||
type = ResourceRecordType.TXT.value.toInt(),
|
||||
timeToLive = service.ttl,
|
||||
name = deviceDomainName,
|
||||
cacheFlush = false
|
||||
) to TXTRecord(
|
||||
texts = service.texts
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun broadcastRecords(questions: List<DnsQuestion>? = null) {
|
||||
val writer = DnsWriter()
|
||||
_recordLockObject.withLock {
|
||||
val recordsA: List<Pair<DnsResourceRecord, ARecord>>
|
||||
val recordsAAAA: List<Pair<DnsResourceRecord, AAAARecord>>
|
||||
val recordsPTR: List<Pair<DnsResourceRecord, PTRRecord>>
|
||||
val recordsTXT: List<Pair<DnsResourceRecord, TXTRecord>>
|
||||
val recordsSRV: List<Pair<DnsResourceRecord, SRVRecord>>
|
||||
|
||||
if (questions != null) {
|
||||
recordsA = _recordsA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
||||
recordsAAAA = _recordsAAAA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
||||
recordsPTR = _recordsPTR.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
||||
recordsSRV = _recordsSRV.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
||||
recordsTXT = _recordsTXT.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
||||
} else {
|
||||
recordsA = _recordsA
|
||||
recordsAAAA = _recordsAAAA
|
||||
recordsPTR = _recordsPTR
|
||||
recordsSRV = _recordsSRV
|
||||
recordsTXT = _recordsTXT
|
||||
}
|
||||
|
||||
val answerCount = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size + recordsTXT.size
|
||||
if (answerCount < 1) return
|
||||
|
||||
val txtOffset = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size
|
||||
val srvOffset = recordsA.size + recordsAAAA.size + recordsPTR.size
|
||||
val ptrOffset = recordsA.size + recordsAAAA.size
|
||||
val aaaaOffset = recordsA.size
|
||||
|
||||
writer.writePacket(
|
||||
DnsPacketHeader(
|
||||
identifier = 0u,
|
||||
queryResponse = QueryResponse.Response.value.toInt(),
|
||||
opcode = DnsOpcode.StandardQuery.value.toInt(),
|
||||
truncated = false,
|
||||
nonAuthenticatedData = false,
|
||||
recursionDesired = false,
|
||||
answerAuthenticated = false,
|
||||
authoritativeAnswer = true,
|
||||
recursionAvailable = false,
|
||||
responseCode = DnsResponseCode.NoError
|
||||
),
|
||||
answerCount = answerCount,
|
||||
answerWriter = { w, i ->
|
||||
when {
|
||||
i >= txtOffset -> {
|
||||
val record = recordsTXT[i - txtOffset]
|
||||
w.write(record.first) { it.write(record.second) }
|
||||
}
|
||||
|
||||
i >= srvOffset -> {
|
||||
val record = recordsSRV[i - srvOffset]
|
||||
w.write(record.first) { it.write(record.second) }
|
||||
}
|
||||
|
||||
i >= ptrOffset -> {
|
||||
val record = recordsPTR[i - ptrOffset]
|
||||
w.write(record.first) { it.write(record.second) }
|
||||
}
|
||||
|
||||
i >= aaaaOffset -> {
|
||||
val record = recordsAAAA[i - aaaaOffset]
|
||||
w.write(record.first) { it.write(record.second) }
|
||||
}
|
||||
|
||||
else -> {
|
||||
val record = recordsA[i]
|
||||
w.write(record.first) { it.write(record.second) }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
send(writer.toByteArray())
|
||||
}
|
||||
|
||||
private fun handleResult(result: DatagramPacket) {
|
||||
try {
|
||||
val packet = DnsPacket.parse(result.data)
|
||||
if (packet.questions.isNotEmpty()) {
|
||||
_scope?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
broadcastRecords(packet.questions)
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Broadcasting records failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
_serviceRecordAggregator.add(packet)
|
||||
onPacket?.invoke(packet)
|
||||
} catch (e: Exception) {
|
||||
Logger.v(TAG, "Failed to handle packet: ${Base64.getEncoder().encodeToString(result.data.slice(IntRange(0, result.length - 1)).toByteArray())}", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
_lockObject.withLock {
|
||||
_started = false
|
||||
|
||||
_scope?.cancel()
|
||||
_scope = null
|
||||
|
||||
_nicMonitor.stop()
|
||||
_serviceRecordAggregator.stop()
|
||||
|
||||
_receiver4?.close()
|
||||
_receiver4 = null
|
||||
|
||||
_receiver6?.close()
|
||||
_receiver6 = null
|
||||
|
||||
_senders.forEach { it.close() }
|
||||
_senders.clear()
|
||||
}
|
||||
|
||||
_threadReceiver4?.join()
|
||||
_threadReceiver4 = null
|
||||
|
||||
_threadReceiver6?.join()
|
||||
_threadReceiver6 = null
|
||||
}
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import java.net.NetworkInterface
|
||||
|
||||
class NICMonitor {
|
||||
private val lockObject = Any()
|
||||
private val nics = mutableListOf<NetworkInterface>()
|
||||
private var cts: Job? = null
|
||||
|
||||
val current: List<NetworkInterface>
|
||||
get() = synchronized(nics) { nics.toList() }
|
||||
|
||||
var added: ((List<NetworkInterface>) -> Unit)? = null
|
||||
var removed: ((List<NetworkInterface>) -> Unit)? = null
|
||||
|
||||
fun start() {
|
||||
synchronized(lockObject) {
|
||||
if (cts != null) throw Exception("Already started.")
|
||||
|
||||
cts = CoroutineScope(Dispatchers.Default).launch {
|
||||
loopAsync()
|
||||
}
|
||||
}
|
||||
|
||||
nics.clear()
|
||||
nics.addAll(getCurrentInterfaces().toList())
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
synchronized(lockObject) {
|
||||
cts?.cancel()
|
||||
cts = null
|
||||
}
|
||||
|
||||
synchronized(nics) {
|
||||
nics.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loopAsync() {
|
||||
while (cts?.isActive == true) {
|
||||
try {
|
||||
val currentNics = getCurrentInterfaces().toList()
|
||||
removed?.invoke(nics.filter { k -> currentNics.none { n -> k.name == n.name } })
|
||||
added?.invoke(currentNics.filter { nic -> nics.none { k -> k.name == nic.name } })
|
||||
|
||||
synchronized(nics) {
|
||||
nics.clear()
|
||||
nics.addAll(currentNics)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
// Ignored
|
||||
}
|
||||
delay(5000)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCurrentInterfaces(): List<NetworkInterface> {
|
||||
val nics = NetworkInterface.getNetworkInterfaces().toList()
|
||||
.filter { it.isUp && !it.isLoopback }
|
||||
|
||||
return if (nics.isNotEmpty()) nics else NetworkInterface.getNetworkInterfaces().toList()
|
||||
.filter { it.isUp }
|
||||
}
|
||||
}
|
|
@ -1,71 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import java.lang.Thread.sleep
|
||||
|
||||
class ServiceDiscoverer(names: Array<String>, private val _onServicesUpdated: (List<DnsService>) -> Unit) {
|
||||
private val _names: Array<String>
|
||||
private var _listener: MDNSListener? = null
|
||||
private var _started = false
|
||||
private var _thread: Thread? = null
|
||||
|
||||
init {
|
||||
if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.")
|
||||
_names = names
|
||||
}
|
||||
|
||||
fun broadcastService(
|
||||
deviceName: String,
|
||||
serviceName: String,
|
||||
port: UShort,
|
||||
ttl: UInt = 120u,
|
||||
weight: UShort = 0u,
|
||||
priority: UShort = 0u,
|
||||
texts: List<String>? = null
|
||||
) {
|
||||
_listener?.let {
|
||||
it.broadcastService(deviceName, serviceName, port, ttl, weight, priority, texts)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
_started = false
|
||||
_listener?.stop()
|
||||
_listener = null
|
||||
_thread?.join()
|
||||
_thread = null
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (_started) {
|
||||
Logger.i(TAG, "Already started.")
|
||||
return
|
||||
}
|
||||
_started = true
|
||||
|
||||
val listener = MDNSListener()
|
||||
_listener = listener
|
||||
listener.onServicesUpdated = { _onServicesUpdated?.invoke(it) }
|
||||
listener.start()
|
||||
|
||||
_thread = Thread {
|
||||
try {
|
||||
sleep(2000)
|
||||
|
||||
while (_started) {
|
||||
listener.queryServices(_names)
|
||||
sleep(2000)
|
||||
listener.queryAllQuestions(_names)
|
||||
sleep(2000)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Exception in loop thread", e)
|
||||
stop()
|
||||
}
|
||||
}.apply { start() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "ServiceDiscoverer"
|
||||
}
|
||||
}
|
|
@ -1,226 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.InetAddress
|
||||
import java.util.Date
|
||||
|
||||
data class DnsService(
|
||||
var name: String,
|
||||
var target: String,
|
||||
var port: UShort,
|
||||
val addresses: MutableList<InetAddress> = mutableListOf(),
|
||||
val pointers: MutableList<String> = mutableListOf(),
|
||||
val texts: MutableList<String> = mutableListOf()
|
||||
)
|
||||
|
||||
data class CachedDnsAddressRecord(
|
||||
val expirationTime: Date,
|
||||
val address: InetAddress
|
||||
)
|
||||
|
||||
data class CachedDnsTxtRecord(
|
||||
val expirationTime: Date,
|
||||
val texts: List<String>
|
||||
)
|
||||
|
||||
data class CachedDnsPtrRecord(
|
||||
val expirationTime: Date,
|
||||
val target: String
|
||||
)
|
||||
|
||||
data class CachedDnsSrvRecord(
|
||||
val expirationTime: Date,
|
||||
val service: SRVRecord
|
||||
)
|
||||
|
||||
class ServiceRecordAggregator {
|
||||
private val _lockObject = Any()
|
||||
private val _cachedAddressRecords = mutableMapOf<String, MutableList<CachedDnsAddressRecord>>()
|
||||
private val _cachedTxtRecords = mutableMapOf<String, CachedDnsTxtRecord>()
|
||||
private val _cachedPtrRecords = mutableMapOf<String, MutableList<CachedDnsPtrRecord>>()
|
||||
private val _cachedSrvRecords = mutableMapOf<String, CachedDnsSrvRecord>()
|
||||
private val _currentServices = mutableListOf<DnsService>()
|
||||
private var _cts: Job? = null
|
||||
|
||||
var onServicesUpdated: ((List<DnsService>) -> Unit)? = null
|
||||
|
||||
fun start() {
|
||||
synchronized(_lockObject) {
|
||||
if (_cts != null) throw Exception("Already started.")
|
||||
|
||||
_cts = CoroutineScope(Dispatchers.Default).launch {
|
||||
try {
|
||||
while (isActive) {
|
||||
val now = Date()
|
||||
synchronized(_currentServices) {
|
||||
_cachedAddressRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } }
|
||||
_cachedTxtRecords.entries.removeIf { now.after(it.value.expirationTime) }
|
||||
_cachedSrvRecords.entries.removeIf { now.after(it.value.expirationTime) }
|
||||
_cachedPtrRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } }
|
||||
|
||||
val newServices = getCurrentServices()
|
||||
_currentServices.clear()
|
||||
_currentServices.addAll(newServices)
|
||||
}
|
||||
|
||||
onServicesUpdated?.invoke(_currentServices.toList())
|
||||
delay(5000)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unexpected failure in MDNS loop", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
synchronized(_lockObject) {
|
||||
_cts?.cancel()
|
||||
_cts = null
|
||||
}
|
||||
}
|
||||
|
||||
fun add(packet: DnsPacket) {
|
||||
val currentServices: List<DnsService>
|
||||
val dnsResourceRecords = packet.answers + packet.additionals + packet.authorities
|
||||
val txtRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.TXT.value.toInt() }.map { it to it.getDataReader().readTXTRecord() }
|
||||
val aRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.A.value.toInt() }.map { it to it.getDataReader().readARecord() }
|
||||
val aaaaRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.AAAA.value.toInt() }.map { it to it.getDataReader().readAAAARecord() }
|
||||
val srvRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.SRV.value.toInt() }.map { it to it.getDataReader().readSRVRecord() }
|
||||
val ptrRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.PTR.value.toInt() }.map { it to it.getDataReader().readPTRRecord() }
|
||||
|
||||
/*val builder = StringBuilder()
|
||||
builder.appendLine("Received records:")
|
||||
srvRecords.forEach { builder.appendLine("SRV ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: (Port: ${it.second.port}, Target: ${it.second.target}, Priority: ${it.second.priority}, Weight: ${it.second.weight})") }
|
||||
ptrRecords.forEach { builder.appendLine("PTR ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.domainName}") }
|
||||
txtRecords.forEach { builder.appendLine("TXT ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.texts.joinToString(", ")}") }
|
||||
aRecords.forEach { builder.appendLine("A ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") }
|
||||
aaaaRecords.forEach { builder.appendLine("AAAA ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") }
|
||||
Logger.i(TAG, "$builder")*/
|
||||
|
||||
synchronized(this._currentServices) {
|
||||
ptrRecords.forEach { record ->
|
||||
val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() }
|
||||
val newPtrRecord = CachedDnsPtrRecord(Date(System.currentTimeMillis() + record.first.timeToLive.toLong() * 1000L), record.second.domainName)
|
||||
cachedPtrRecord.replaceOrAdd(newPtrRecord) { it.target == record.second.domainName }
|
||||
}
|
||||
|
||||
aRecords.forEach { aRecord ->
|
||||
val cachedARecord = _cachedAddressRecords.getOrPut(aRecord.first.name) { mutableListOf() }
|
||||
val newARecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aRecord.first.timeToLive.toLong() * 1000L), aRecord.second.address)
|
||||
cachedARecord.replaceOrAdd(newARecord) { it.address == newARecord.address }
|
||||
}
|
||||
|
||||
aaaaRecords.forEach { aaaaRecord ->
|
||||
val cachedAaaaRecord = _cachedAddressRecords.getOrPut(aaaaRecord.first.name) { mutableListOf() }
|
||||
val newAaaaRecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aaaaRecord.first.timeToLive.toLong() * 1000L), aaaaRecord.second.address)
|
||||
cachedAaaaRecord.replaceOrAdd(newAaaaRecord) { it.address == newAaaaRecord.address }
|
||||
}
|
||||
|
||||
txtRecords.forEach { txtRecord ->
|
||||
_cachedTxtRecords[txtRecord.first.name] = CachedDnsTxtRecord(Date(System.currentTimeMillis() + txtRecord.first.timeToLive.toLong() * 1000L), txtRecord.second.texts)
|
||||
}
|
||||
|
||||
srvRecords.forEach { srvRecord ->
|
||||
_cachedSrvRecords[srvRecord.first.name] = CachedDnsSrvRecord(Date(System.currentTimeMillis() + srvRecord.first.timeToLive.toLong() * 1000L), srvRecord.second)
|
||||
}
|
||||
|
||||
currentServices = getCurrentServices()
|
||||
this._currentServices.clear()
|
||||
this._currentServices.addAll(currentServices)
|
||||
}
|
||||
|
||||
onServicesUpdated?.invoke(currentServices)
|
||||
}
|
||||
|
||||
fun getAllQuestions(serviceName: String): List<DnsQuestion> {
|
||||
val questions = mutableListOf<DnsQuestion>()
|
||||
synchronized(_currentServices) {
|
||||
val servicePtrRecords = _cachedPtrRecords[serviceName] ?: return emptyList()
|
||||
|
||||
val ptrWithoutSrvRecord = servicePtrRecords.filterNot { _cachedSrvRecords.containsKey(it.target) }.map { it.target }
|
||||
questions.addAll(ptrWithoutSrvRecord.flatMap { s ->
|
||||
listOf(
|
||||
DnsQuestion(
|
||||
name = s,
|
||||
type = QuestionType.SRV.value.toInt(),
|
||||
clazz = QuestionClass.IN.value.toInt(),
|
||||
queryUnicast = false
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
val incompleteCurrentServices = _currentServices.filter { it.addresses.isEmpty() && it.name.endsWith(serviceName) }
|
||||
questions.addAll(incompleteCurrentServices.flatMap { s ->
|
||||
listOf(
|
||||
DnsQuestion(
|
||||
name = s.name,
|
||||
type = QuestionType.TXT.value.toInt(),
|
||||
clazz = QuestionClass.IN.value.toInt(),
|
||||
queryUnicast = false
|
||||
),
|
||||
DnsQuestion(
|
||||
name = s.target,
|
||||
type = QuestionType.A.value.toInt(),
|
||||
clazz = QuestionClass.IN.value.toInt(),
|
||||
queryUnicast = false
|
||||
),
|
||||
DnsQuestion(
|
||||
name = s.target,
|
||||
type = QuestionType.AAAA.value.toInt(),
|
||||
clazz = QuestionClass.IN.value.toInt(),
|
||||
queryUnicast = false
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
return questions
|
||||
}
|
||||
|
||||
private fun getCurrentServices(): MutableList<DnsService> {
|
||||
val currentServices = _cachedSrvRecords.map { (key, value) ->
|
||||
DnsService(
|
||||
name = key,
|
||||
target = value.service.target,
|
||||
port = value.service.port
|
||||
)
|
||||
}.toMutableList()
|
||||
|
||||
currentServices.forEach { service ->
|
||||
_cachedAddressRecords[service.target]?.let {
|
||||
service.addresses.addAll(it.map { record -> record.address })
|
||||
}
|
||||
}
|
||||
|
||||
currentServices.forEach { service ->
|
||||
service.pointers.addAll(_cachedPtrRecords.filter { it.value.any { ptr -> ptr.target == service.name } }.map { it.key })
|
||||
}
|
||||
|
||||
currentServices.forEach { service ->
|
||||
_cachedTxtRecords[service.name]?.let {
|
||||
service.texts.addAll(it.texts)
|
||||
}
|
||||
}
|
||||
|
||||
return currentServices
|
||||
}
|
||||
|
||||
private inline fun <T> MutableList<T>.replaceOrAdd(newElement: T, predicate: (T) -> Boolean) {
|
||||
val index = indexOfFirst(predicate)
|
||||
if (index >= 0) {
|
||||
this[index] = newElement
|
||||
} else {
|
||||
add(newElement)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "ServiceRecordAggregator"
|
||||
}
|
||||
}
|
|
@ -29,7 +29,7 @@ data class ImageVariable(
|
|||
Glide.with(imageView)
|
||||
.load(bitmap)
|
||||
.into(imageView)
|
||||
} else if(resId != null) {
|
||||
} else if(resId != null && resId > 0) {
|
||||
Glide.with(imageView)
|
||||
.load(resId)
|
||||
.into(imageView)
|
||||
|
|
|
@ -113,7 +113,7 @@ class LoginWebViewClient : WebViewClient {
|
|||
//val domainParts = domain!!.split(".");
|
||||
//val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||
val cookieDomain = domain!!.getSubdomainWildcardQuery();
|
||||
if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) })
|
||||
if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || domain.matchesDomain(it) })
|
||||
_authConfig.cookiesToFind?.let { cookiesToFind ->
|
||||
val cookies = cookieString.split(";");
|
||||
for(cookieStr in cookies) {
|
||||
|
|
|
@ -67,7 +67,7 @@ class WebViewRequirementExtractor {
|
|||
if(cookieString != null) {
|
||||
//val domainParts = domain!!.split(".");
|
||||
val cookieDomain = domain!!.getSubdomainWildcardQuery()//"." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||
if(allowedUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) })
|
||||
if(allowedUrls.any { it == "everywhere" || domain.matchesDomain(it) })
|
||||
cookiesToFind?.let { cookiesToFind ->
|
||||
val cookies = cookieString.split(";");
|
||||
for(cookieStr in cookies) {
|
||||
|
|
|
@ -21,6 +21,7 @@ class MediaControlReceiver : BroadcastReceiver() {
|
|||
EVENT_NEXT -> onNextReceived.emit();
|
||||
EVENT_PREV -> onPreviousReceived.emit();
|
||||
EVENT_CLOSE -> onCloseReceived.emit();
|
||||
EVENT_BACKGROUND -> onBackgroundReceived.emit();
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
|
@ -38,6 +39,7 @@ class MediaControlReceiver : BroadcastReceiver() {
|
|||
const val EVENT_NEXT = "Next";
|
||||
const val EVENT_PREV = "Prev";
|
||||
const val EVENT_CLOSE = "Close";
|
||||
const val EVENT_BACKGROUND = "Background";
|
||||
|
||||
val onPlayReceived = Event0();
|
||||
val onPauseReceived = Event0();
|
||||
|
@ -48,6 +50,7 @@ class MediaControlReceiver : BroadcastReceiver() {
|
|||
val onLowerVolumeReceived = Event0();
|
||||
|
||||
val onCloseReceived = Event0()
|
||||
val onBackgroundReceived = Event0()
|
||||
|
||||
fun getPlayIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply {
|
||||
this.putExtra(EXTRA_MEDIA_ACTION, EVENT_PLAY);
|
||||
|
@ -64,5 +67,8 @@ class MediaControlReceiver : BroadcastReceiver() {
|
|||
fun getCloseIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply {
|
||||
this.putExtra(EXTRA_MEDIA_ACTION, EVENT_CLOSE);
|
||||
},PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
fun getToBackgroundIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply {
|
||||
this.putExtra(EXTRA_MEDIA_ACTION, EVENT_BACKGROUND);
|
||||
},PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package com.futo.platformplayer.serializers
|
||||
|
||||
import com.futo.platformplayer.sToOffsetDateTimeUTC
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
|
@ -37,7 +38,7 @@ class OffsetDateTimeSerializer : KSerializer<OffsetDateTime> {
|
|||
return OffsetDateTime.MAX;
|
||||
else if(epochSecond < -9999999999)
|
||||
return OffsetDateTime.MIN;
|
||||
return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
||||
return epochSecond.sToOffsetDateTimeUTC()
|
||||
}
|
||||
}
|
||||
class OffsetDateTimeStringSerializer : KSerializer<OffsetDateTime> {
|
||||
|
|
|
@ -29,6 +29,7 @@ import com.futo.platformplayer.activities.CaptchaActivity
|
|||
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity.Companion.settingsActivityClosed
|
||||
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.background.BackgroundWorker
|
||||
|
@ -411,7 +412,27 @@ class StateApp {
|
|||
}
|
||||
|
||||
if (Settings.instance.synchronization.enabled) {
|
||||
StateSync.instance.start()
|
||||
StateSync.instance.start(context, {
|
||||
try {
|
||||
UIDialogs.toast("Failed to start sync, port in use")
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
settingsActivityClosed.subscribe {
|
||||
if (Settings.instance.synchronization.enabled) {
|
||||
StateSync.instance.start(context, {
|
||||
try {
|
||||
UIDialogs.toast("Failed to start sync, port in use")
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
})
|
||||
} else {
|
||||
StateSync.instance.stop()
|
||||
}
|
||||
}
|
||||
|
||||
Logger.onLogSubmitted.subscribe {
|
||||
|
@ -509,22 +530,33 @@ class StateApp {
|
|||
|
||||
//Migration
|
||||
Logger.i(TAG, "MainApp Started: Check [Migrations]");
|
||||
migrateStores(context, listOf(
|
||||
StateSubscriptions.instance.toMigrateCheck(),
|
||||
StatePlaylists.instance.toMigrateCheck()
|
||||
).flatten(), 0);
|
||||
|
||||
scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
migrateStores(context, listOf(
|
||||
StateSubscriptions.instance.toMigrateCheck(),
|
||||
StatePlaylists.instance.toMigrateCheck()
|
||||
).flatten(), 0)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to migrate stores")
|
||||
}
|
||||
}
|
||||
|
||||
if(Settings.instance.subscriptions.fetchOnAppBoot) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Logger.i(TAG, "MainApp Started: Fetch [Subscriptions]");
|
||||
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
|
||||
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
|
||||
val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true };
|
||||
if (isRateLimitReached) {
|
||||
val isBelowRateLimit = !subRequestCounts.any { clientCount ->
|
||||
clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true
|
||||
};
|
||||
if (isBelowRateLimit) {
|
||||
Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}");
|
||||
delay(5000);
|
||||
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
|
||||
StateSubscriptions.instance.updateSubscriptionFeed(scope, false);
|
||||
scopeOrNull?.let {
|
||||
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
|
||||
StateSubscriptions.instance.updateSubscriptionFeed(it, false);
|
||||
}
|
||||
}
|
||||
else
|
||||
Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}");
|
||||
|
@ -675,15 +707,27 @@ class StateApp {
|
|||
}
|
||||
|
||||
|
||||
private fun migrateStores(context: Context, managedStores: List<ManagedStore<*>>, index: Int) {
|
||||
private suspend fun migrateStores(context: Context, managedStores: List<ManagedStore<*>>, index: Int) {
|
||||
if(managedStores.size <= index)
|
||||
return;
|
||||
val store = managedStores[index];
|
||||
if(store.hasMissingReconstructions())
|
||||
UIDialogs.showMigrateDialog(context, store) {
|
||||
migrateStores(context, managedStores, index + 1);
|
||||
};
|
||||
else
|
||||
if(store.hasMissingReconstructions()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
try {
|
||||
UIDialogs.showMigrateDialog(context, store) {
|
||||
scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
migrateStores(context, managedStores, index + 1);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to migrate store", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to migrate stores", e)
|
||||
}
|
||||
}
|
||||
} else
|
||||
migrateStores(context, managedStores, index + 1);
|
||||
}
|
||||
|
||||
|
@ -703,6 +747,7 @@ class StateApp {
|
|||
|
||||
StatePlayer.instance.closeMediaSession();
|
||||
StateCasting.instance.stop();
|
||||
StateSync.instance.stop();
|
||||
StatePlayer.dispose();
|
||||
Companion.dispose();
|
||||
_fileLogConsumer?.close();
|
||||
|
|
|
@ -94,9 +94,11 @@ class StatePlatform {
|
|||
private val _trackerClientPool = PlatformMultiClientPool("Trackers", 1); //Used exclusively for playback trackers
|
||||
private val _liveEventClientPool = PlatformMultiClientPool("LiveEvents", 1); //Used exclusively for live events
|
||||
private val _privateClientPool = PlatformMultiClientPool("Private", 2, true); //Used primarily for calls if in incognito mode
|
||||
private val _instantClientPool = PlatformMultiClientPool("Instant", 1, false, true); //Used for all instant calls
|
||||
|
||||
|
||||
private val _icons : HashMap<String, ImageVariable> = HashMap();
|
||||
private val _iconsByName : HashMap<String, ImageVariable> = HashMap();
|
||||
|
||||
val hasClients: Boolean get() = _availableClients.size > 0;
|
||||
|
||||
|
@ -113,14 +115,14 @@ class StatePlatform {
|
|||
|
||||
Logger.i(StatePlatform::class.java.name, "Fetching video details [${url}]");
|
||||
if(!StateApp.instance.privateMode) {
|
||||
_enabledClients.find { it.isContentDetailsUrl(url) }?.let {
|
||||
_enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let {
|
||||
_mainClientPool.getClientPooled(it).getContentDetails(url)
|
||||
}
|
||||
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
|
||||
}
|
||||
else {
|
||||
Logger.i(TAG, "Fetching details with private client");
|
||||
_enabledClients.find { it.isContentDetailsUrl(url) }?.let {
|
||||
_enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let {
|
||||
_privateClientPool.getClientPooled(it).getContentDetails(url)
|
||||
}
|
||||
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
|
||||
|
@ -192,6 +194,7 @@ class StatePlatform {
|
|||
_availableClients.clear();
|
||||
|
||||
_icons.clear();
|
||||
_iconsByName.clear()
|
||||
_icons[StateDeveloper.DEV_ID] = ImageVariable(null, R.drawable.ic_security_red);
|
||||
|
||||
StatePlugins.instance.updateEmbeddedPlugins(context);
|
||||
|
@ -200,6 +203,8 @@ class StatePlatform {
|
|||
for (plugin in StatePlugins.instance.getPlugins()) {
|
||||
_icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?:
|
||||
ImageVariable(plugin.config.absoluteIconUrl, null);
|
||||
_iconsByName[plugin.config.name.lowercase()] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?:
|
||||
ImageVariable(plugin.config.absoluteIconUrl, null);
|
||||
|
||||
val client = JSClient(context, plugin);
|
||||
client.onCaptchaException.subscribe { c, ex ->
|
||||
|
@ -299,6 +304,15 @@ class StatePlatform {
|
|||
return null;
|
||||
}
|
||||
|
||||
fun getPlatformIconByName(name: String?) : ImageVariable? {
|
||||
if(name == null)
|
||||
return null;
|
||||
val nameLower = name.lowercase()
|
||||
if(_iconsByName.containsKey(nameLower))
|
||||
return _iconsByName[nameLower];
|
||||
return null;
|
||||
}
|
||||
|
||||
fun setPlatformOrder(platformOrder: List<String>) {
|
||||
_platformOrderPersistent.values.clear();
|
||||
_platformOrderPersistent.values.addAll(platformOrder);
|
||||
|
@ -655,10 +669,10 @@ class StatePlatform {
|
|||
|
||||
|
||||
//Video
|
||||
fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { it.isContentDetailsUrl(url) };
|
||||
fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) };
|
||||
fun getContentClient(url: String) : IPlatformClient = getContentClientOrNull(url)
|
||||
?: throw NoPlatformClientException("No client enabled that supports this content url (${url})");
|
||||
fun getContentClientOrNull(url: String) : IPlatformClient? = getEnabledClients().find { it.isContentDetailsUrl(url) };
|
||||
fun getContentClientOrNull(url: String) : IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) };
|
||||
fun getContentDetails(url: String, forceRefetch: Boolean = false): Deferred<IPlatformContentDetails> {
|
||||
Logger.i(TAG, "Platform - getContentDetails (${url})");
|
||||
if(forceRefetch)
|
||||
|
@ -699,14 +713,14 @@ class StatePlatform {
|
|||
return client.getContentRecommendations(url);
|
||||
}
|
||||
|
||||
fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { it.isChannelUrl(url) };
|
||||
fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isChannelUrl(url) };
|
||||
fun getChannelClient(url : String, exclude: List<String>? = null) : IPlatformClient = getChannelClientOrNull(url, exclude)
|
||||
?: throw NoPlatformClientException("No client enabled that supports this channel url (${url})");
|
||||
fun getChannelClientOrNull(url : String, exclude: List<String>? = null) : IPlatformClient? =
|
||||
if(exclude == null)
|
||||
getEnabledClients().find { it.isChannelUrl(url) }
|
||||
getEnabledClients().find { _instantClientPool.getClientPooled(it).isChannelUrl(url) }
|
||||
else
|
||||
getEnabledClients().find { !exclude.contains(it.id) && it.isChannelUrl(url) };
|
||||
getEnabledClients().find { !exclude.contains(it.id) && _instantClientPool.getClientPooled(it).isChannelUrl(url) };
|
||||
|
||||
fun getChannel(url: String, updateSubscriptions: Boolean = true): Deferred<IPlatformChannel> {
|
||||
Logger.i(TAG, "Platform - getChannel");
|
||||
|
@ -902,9 +916,9 @@ class StatePlatform {
|
|||
return urls;
|
||||
}
|
||||
|
||||
fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { it.isPlaylistUrl(url) };
|
||||
fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { it.isPlaylistUrl(url) }
|
||||
fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { it.isPlaylistUrl(url) }
|
||||
fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) };
|
||||
fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) }
|
||||
fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) }
|
||||
?: throw NoPlatformClientException("No client enabled that supports this playlist url (${url})");
|
||||
fun getPlaylist(url: String): IPlatformPlaylistDetails {
|
||||
return getPlaylistClient(url).getPlaylist(url);
|
||||
|
|
|
@ -598,7 +598,7 @@ class StatePlayer {
|
|||
}
|
||||
|
||||
if(_queuePosition < _queue.size) {
|
||||
return _queue[_queuePosition];
|
||||
return getCurrentQueueItem();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
|
|
@ -19,6 +19,7 @@ import com.futo.platformplayer.exceptions.ReconstructionException
|
|||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImportCache
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.sToOffsetDateTimeUTC
|
||||
import com.futo.platformplayer.smartMerge
|
||||
import com.futo.platformplayer.states.StateSubscriptionGroups.Companion
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
|
@ -85,7 +86,7 @@ class StatePlaylists {
|
|||
if(value.isEmpty())
|
||||
return OffsetDateTime.MIN;
|
||||
val tryParse = value.toLongOrNull() ?: 0;
|
||||
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(tryParse), ZoneOffset.UTC);
|
||||
return tryParse.sToOffsetDateTimeUTC();
|
||||
}
|
||||
private fun setWatchLaterReorderTime() {
|
||||
val now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
|
@ -400,12 +401,15 @@ class StatePlaylists {
|
|||
companion object {
|
||||
val TAG = "StatePlaylists";
|
||||
private var _instance : StatePlaylists? = null;
|
||||
private var _lockObject = Object()
|
||||
val instance : StatePlaylists
|
||||
get(){
|
||||
if(_instance == null)
|
||||
_instance = StatePlaylists();
|
||||
return _instance!!;
|
||||
};
|
||||
get() {
|
||||
synchronized(_lockObject) {
|
||||
if (_instance == null)
|
||||
_instance = StatePlaylists();
|
||||
return _instance!!;
|
||||
}
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
_instance?.let {
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -19,6 +19,11 @@ class CastingDeviceInfoStorage : FragmentedStorageFileJson() {
|
|||
return deviceInfos.toList();
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getDeviceNames() : List<String> {
|
||||
return deviceInfos.map { it.name }.toList();
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addDevice(castingDeviceInfo: CastingDeviceInfo): CastingDeviceInfo {
|
||||
val foundDeviceInfo = deviceInfos.firstOrNull { d -> d.name == castingDeviceInfo.name }
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.futo.platformplayer.stores
|
||||
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.stores.v2.JsonStoreSerializer
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.stores.v2.StoreSerializer
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
package com.futo.platformplayer.sync.internal
|
||||
|
||||
import com.futo.platformplayer.ensureNotMainThread
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.noise.protocol.CipherStatePair
|
||||
import com.futo.platformplayer.noise.protocol.DHState
|
||||
import com.futo.platformplayer.noise.protocol.HandshakeState
|
||||
import com.futo.platformplayer.states.StateSync
|
||||
import com.futo.polycentric.core.base64ToByteArray
|
||||
import com.futo.polycentric.core.toBase64
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.Base64
|
||||
import java.util.zip.GZIPOutputStream
|
||||
|
||||
interface IChannel : AutoCloseable {
|
||||
val remotePublicKey: String?
|
||||
|
@ -15,7 +20,7 @@ interface IChannel : AutoCloseable {
|
|||
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 send(opcode: UByte, subOpcode: UByte = 0u, data: ByteBuffer? = null, contentEncoding: ContentEncoding? = null)
|
||||
fun setCloseHandler(onClose: ((IChannel) -> Unit)?)
|
||||
val linkType: LinkType
|
||||
}
|
||||
|
@ -49,9 +54,10 @@ class ChannelSocket(private val session: SyncSocketSession) : IChannel {
|
|||
onData?.invoke(session, this, opcode, subOpcode, data)
|
||||
}
|
||||
|
||||
override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?) {
|
||||
override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, contentEncoding: ContentEncoding?) {
|
||||
ensureNotMainThread()
|
||||
if (data != null) {
|
||||
session.send(opcode, subOpcode, data)
|
||||
session.send(opcode, subOpcode, data, contentEncoding)
|
||||
} else {
|
||||
session.send(opcode, subOpcode)
|
||||
}
|
||||
|
@ -67,12 +73,12 @@ class ChannelRelayed(
|
|||
private val sendLock = Object()
|
||||
private val decryptLock = Object()
|
||||
private var handshakeState: HandshakeState? = if (initiator) {
|
||||
HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR).apply {
|
||||
HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR).apply {
|
||||
localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair)
|
||||
remotePublicKey.setPublicKey(Base64.getDecoder().decode(publicKey), 0)
|
||||
}
|
||||
} else {
|
||||
HandshakeState(StateSync.protocolName, HandshakeState.RESPONDER).apply {
|
||||
HandshakeState(SyncService.protocolName, HandshakeState.RESPONDER).apply {
|
||||
localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair)
|
||||
}
|
||||
}
|
||||
|
@ -80,7 +86,7 @@ class ChannelRelayed(
|
|||
override var authorizable: IAuthorizable? = null
|
||||
val isAuthorized: Boolean get() = authorizable?.isAuthorized ?: false
|
||||
var connectionId: Long = 0L
|
||||
override var remotePublicKey: String? = publicKey
|
||||
override var remotePublicKey: String? = publicKey.base64ToByteArray().toBase64()
|
||||
private set
|
||||
override var remoteVersion: Int? = null
|
||||
private set
|
||||
|
@ -90,11 +96,39 @@ class ChannelRelayed(
|
|||
private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null
|
||||
private var onClose: ((IChannel) -> Unit)? = null
|
||||
private var disposed = false
|
||||
private var _lastPongTime: Long = 0
|
||||
private val _pingInterval: Long = 5000 // 5 seconds in milliseconds
|
||||
private val _disconnectTimeout: Long = 30000 // 30 seconds in milliseconds
|
||||
|
||||
init {
|
||||
handshakeState?.start()
|
||||
}
|
||||
|
||||
private fun startPingLoop() {
|
||||
if (remoteVersion!! < 5) {
|
||||
return
|
||||
}
|
||||
|
||||
_lastPongTime = System.currentTimeMillis()
|
||||
|
||||
Thread {
|
||||
try {
|
||||
while (!disposed) {
|
||||
Thread.sleep(_pingInterval)
|
||||
if (System.currentTimeMillis() - _lastPongTime > _disconnectTimeout) {
|
||||
Logger.e("ChannelRelayed", "Channel timed out waiting for PONG; closing.")
|
||||
close()
|
||||
break
|
||||
}
|
||||
send(Opcode.PING.value, 0u)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("ChannelRelayed", "Ping loop failed", e)
|
||||
close()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
override fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) {
|
||||
this.onData = onData
|
||||
}
|
||||
|
@ -130,6 +164,10 @@ class ChannelRelayed(
|
|||
}
|
||||
|
||||
fun invokeDataHandler(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
|
||||
if (opcode == Opcode.PONG.value) {
|
||||
_lastPongTime = System.currentTimeMillis()
|
||||
return
|
||||
}
|
||||
onData?.invoke(session, this, opcode, subOpcode, data)
|
||||
}
|
||||
|
||||
|
@ -144,10 +182,12 @@ class ChannelRelayed(
|
|||
handshakeState = null
|
||||
this.transport = transport
|
||||
Logger.i("ChannelRelayed", "Completed handshake for connectionId $connectionId")
|
||||
startPingLoop()
|
||||
}
|
||||
|
||||
private fun sendPacket(packet: ByteArray) {
|
||||
throwIfDisposed()
|
||||
ensureNotMainThread()
|
||||
|
||||
synchronized(sendLock) {
|
||||
val encryptedPayload = ByteArray(packet.size + 16)
|
||||
|
@ -165,6 +205,7 @@ class ChannelRelayed(
|
|||
|
||||
fun sendError(errorCode: SyncErrorCode) {
|
||||
throwIfDisposed()
|
||||
ensureNotMainThread()
|
||||
|
||||
synchronized(sendLock) {
|
||||
val packet = ByteArray(4)
|
||||
|
@ -183,51 +224,71 @@ class ChannelRelayed(
|
|||
}
|
||||
}
|
||||
|
||||
override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?) {
|
||||
override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, ce: ContentEncoding?) {
|
||||
throwIfDisposed()
|
||||
ensureNotMainThread()
|
||||
|
||||
var contentEncoding: ContentEncoding? = ce
|
||||
var processedData = data
|
||||
if (data != null && contentEncoding == ContentEncoding.Gzip) {
|
||||
val isGzipSupported = opcode == Opcode.DATA.value
|
||||
if (isGzipSupported) {
|
||||
val compressedStream = ByteArrayOutputStream()
|
||||
GZIPOutputStream(compressedStream).use { gzipStream ->
|
||||
gzipStream.write(data.array(), data.position(), data.remaining())
|
||||
gzipStream.finish()
|
||||
}
|
||||
processedData = ByteBuffer.wrap(compressedStream.toByteArray())
|
||||
} else {
|
||||
Logger.w(TAG, "Gzip requested but not supported on this (opcode = ${opcode}, subOpcode = ${subOpcode}), falling back.")
|
||||
contentEncoding = ContentEncoding.Raw
|
||||
}
|
||||
}
|
||||
|
||||
val actualCount = data?.remaining() ?: 0
|
||||
val ENCRYPTION_OVERHEAD = 16
|
||||
val CONNECTION_ID_SIZE = 8
|
||||
val HEADER_SIZE = 6
|
||||
val HEADER_SIZE = 7
|
||||
val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - HEADER_SIZE - CONNECTION_ID_SIZE - ENCRYPTION_OVERHEAD - 16
|
||||
|
||||
if (actualCount > MAX_DATA_PER_PACKET && data != null) {
|
||||
Logger.v(TAG, "Send (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.size: ${processedData?.remaining()})")
|
||||
|
||||
if (processedData != null && processedData.remaining() > MAX_DATA_PER_PACKET) {
|
||||
val streamId = session.generateStreamId()
|
||||
val totalSize = actualCount
|
||||
var sendOffset = 0
|
||||
|
||||
while (sendOffset < totalSize) {
|
||||
val bytesRemaining = totalSize - sendOffset
|
||||
val bytesToSend = minOf(MAX_DATA_PER_PACKET - 8 - 2, bytesRemaining)
|
||||
while (sendOffset < processedData.remaining()) {
|
||||
val bytesRemaining = processedData.remaining() - sendOffset
|
||||
val bytesToSend = minOf(MAX_DATA_PER_PACKET - 8 - HEADER_SIZE + 4, bytesRemaining)
|
||||
|
||||
val streamData: ByteArray
|
||||
val streamOpcode: StreamOpcode
|
||||
if (sendOffset == 0) {
|
||||
streamOpcode = StreamOpcode.START
|
||||
streamData = ByteArray(4 + 4 + 1 + 1 + bytesToSend)
|
||||
streamData = ByteArray(4 + HEADER_SIZE + bytesToSend)
|
||||
ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply {
|
||||
putInt(streamId)
|
||||
putInt(totalSize)
|
||||
putInt(processedData.remaining())
|
||||
put(opcode.toByte())
|
||||
put(subOpcode.toByte())
|
||||
put(data.array(), data.position() + sendOffset, bytesToSend)
|
||||
put(contentEncoding?.value?.toByte() ?: 0.toByte())
|
||||
put(processedData.array(), processedData.position() + sendOffset, bytesToSend)
|
||||
}
|
||||
} else {
|
||||
streamData = ByteArray(4 + 4 + bytesToSend)
|
||||
ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply {
|
||||
putInt(streamId)
|
||||
putInt(sendOffset)
|
||||
put(data.array(), data.position() + sendOffset, bytesToSend)
|
||||
put(processedData.array(), processedData.position() + sendOffset, bytesToSend)
|
||||
}
|
||||
streamOpcode = if (bytesToSend < bytesRemaining) StreamOpcode.DATA else StreamOpcode.END
|
||||
}
|
||||
|
||||
val fullPacket = ByteArray(HEADER_SIZE + streamData.size)
|
||||
ByteBuffer.wrap(fullPacket).order(ByteOrder.LITTLE_ENDIAN).apply {
|
||||
putInt(streamData.size + 2)
|
||||
putInt(streamData.size + HEADER_SIZE - 4)
|
||||
put(Opcode.STREAM.value.toByte())
|
||||
put(streamOpcode.value.toByte())
|
||||
put(ContentEncoding.Raw.value.toByte())
|
||||
put(streamData)
|
||||
}
|
||||
|
||||
|
@ -235,19 +296,21 @@ class ChannelRelayed(
|
|||
sendOffset += bytesToSend
|
||||
}
|
||||
} else {
|
||||
val packet = ByteArray(HEADER_SIZE + actualCount)
|
||||
val packet = ByteArray(HEADER_SIZE + (processedData?.remaining() ?: 0))
|
||||
ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply {
|
||||
putInt(actualCount + 2)
|
||||
putInt((processedData?.remaining() ?: 0) + HEADER_SIZE - 4)
|
||||
put(opcode.toByte())
|
||||
put(subOpcode.toByte())
|
||||
if (actualCount > 0 && data != null) put(data.array(), data.position(), actualCount)
|
||||
put(contentEncoding?.value?.toByte() ?: ContentEncoding.Raw.value.toByte())
|
||||
if (processedData != null && processedData.remaining() > 0) put(processedData.array(), processedData.position(), processedData.remaining())
|
||||
}
|
||||
sendPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendRequestTransport(requestId: Int, publicKey: String, pairingCode: String? = null) {
|
||||
fun sendRequestTransport(requestId: Int, publicKey: String, appId: UInt, pairingCode: String? = null) {
|
||||
throwIfDisposed()
|
||||
ensureNotMainThread()
|
||||
|
||||
synchronized(sendLock) {
|
||||
val channelMessage = ByteArray(1024)
|
||||
|
@ -270,10 +333,11 @@ class ChannelRelayed(
|
|||
0 to ByteArray(0)
|
||||
}
|
||||
|
||||
val packetSize = 4 + 32 + 4 + pairingMessageLength + 4 + channelBytesWritten
|
||||
val packetSize = 4 + 4 + 32 + 4 + pairingMessageLength + 4 + channelBytesWritten
|
||||
val packet = ByteArray(packetSize)
|
||||
ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply {
|
||||
putInt(requestId)
|
||||
putInt(appId.toInt())
|
||||
put(publicKeyBytes)
|
||||
putInt(pairingMessageLength)
|
||||
if (pairingMessageLength > 0) put(pairingMessage)
|
||||
|
@ -287,6 +351,7 @@ class ChannelRelayed(
|
|||
|
||||
fun sendResponseTransport(remoteVersion: Int, requestId: Int, handshakeMessage: ByteArray) {
|
||||
throwIfDisposed()
|
||||
ensureNotMainThread()
|
||||
|
||||
synchronized(sendLock) {
|
||||
val message = ByteArray(1024)
|
||||
|
@ -332,4 +397,8 @@ class ChannelRelayed(
|
|||
completeHandshake(remoteVersion, transport)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "Channel"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package com.futo.platformplayer.sync.internal
|
||||
|
||||
enum class ContentEncoding(val value: UByte) {
|
||||
Raw(0u),
|
||||
Gzip(1u)
|
||||
}
|
|
@ -0,0 +1,746 @@
|
|||
package com.futo.platformplayer.sync.internal
|
||||
|
||||
import android.content.Context
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.generateReadablePassword
|
||||
import com.futo.platformplayer.getConnectedSocket
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.noise.protocol.DHState
|
||||
import com.futo.platformplayer.noise.protocol.Noise
|
||||
import com.futo.platformplayer.states.StateSync
|
||||
import com.futo.polycentric.core.base64ToByteArray
|
||||
import com.futo.polycentric.core.toBase64
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
import kotlin.math.min
|
||||
|
||||
public data class SyncServiceSettings(
|
||||
val listenerPort: Int = 12315,
|
||||
val mdnsBroadcast: Boolean = true,
|
||||
val mdnsConnectDiscovered: Boolean = true,
|
||||
val bindListener: Boolean = true,
|
||||
val connectLastKnown: Boolean = true,
|
||||
val relayHandshakeAllowed: Boolean = true,
|
||||
val relayPairAllowed: Boolean = true,
|
||||
val relayEnabled: Boolean = true,
|
||||
val relayConnectDirect: Boolean = true,
|
||||
val relayConnectRelayed: Boolean = true
|
||||
)
|
||||
|
||||
interface ISyncDatabaseProvider {
|
||||
fun isAuthorized(publicKey: String): Boolean
|
||||
fun addAuthorizedDevice(publicKey: String)
|
||||
fun removeAuthorizedDevice(publicKey: String)
|
||||
fun getAllAuthorizedDevices(): Array<String>?
|
||||
fun getAuthorizedDeviceCount(): Int
|
||||
fun getSyncKeyPair(): SyncKeyPair?
|
||||
fun setSyncKeyPair(value: SyncKeyPair)
|
||||
fun getLastAddress(publicKey: String): String?
|
||||
fun setLastAddress(publicKey: String, address: String)
|
||||
fun getDeviceName(publicKey: String): String?
|
||||
fun setDeviceName(publicKey: String, name: String)
|
||||
}
|
||||
|
||||
class SyncService(
|
||||
private val serviceName: String,
|
||||
private val relayServer: String,
|
||||
private val relayPublicKey: String,
|
||||
private val appId: UInt,
|
||||
private val database: ISyncDatabaseProvider,
|
||||
private val settings: SyncServiceSettings = SyncServiceSettings()
|
||||
) {
|
||||
private var _serverSocket: ServerSocket? = null
|
||||
private var _thread: Thread? = null
|
||||
private var _connectThread: Thread? = null
|
||||
private var _mdnsThread: Thread? = null
|
||||
@Volatile private var _started = false
|
||||
private val _sessions: MutableMap<String, SyncSession> = mutableMapOf()
|
||||
private val _lastConnectTimesMdns: MutableMap<String, Long> = mutableMapOf()
|
||||
private val _lastConnectTimesIp: MutableMap<String, Long> = mutableMapOf()
|
||||
var serverSocketFailedToStart = false
|
||||
//TODO: Should sync mdns and casting mdns be merged?
|
||||
//TODO: Decrease interval that devices are updated
|
||||
//TODO: Send less data
|
||||
|
||||
private val _pairingCode: String? = generateReadablePassword(8)
|
||||
val pairingCode: String? get() = _pairingCode
|
||||
private var _relaySession: SyncSocketSession? = null
|
||||
private var _threadRelay: Thread? = null
|
||||
private val _remotePendingStatusUpdate = mutableMapOf<String, (complete: Boolean?, message: String) -> Unit>()
|
||||
private var _nsdManager: NsdManager? = null
|
||||
private var _scope: CoroutineScope? = null
|
||||
private val _mdnsCache = mutableMapOf<String, SyncDeviceInfo>()
|
||||
private var _discoveryListener: NsdManager.DiscoveryListener = object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(regType: String) {
|
||||
Log.d(TAG, "Service discovery started for $regType")
|
||||
}
|
||||
|
||||
override fun onDiscoveryStopped(serviceType: String) {
|
||||
Log.i(TAG, "Discovery stopped: $serviceType")
|
||||
}
|
||||
|
||||
override fun onServiceLost(service: NsdServiceInfo) {
|
||||
Log.e(TAG, "service lost: $service")
|
||||
val urlSafePkey = service.attributes["pk"]?.decodeToString() ?: return
|
||||
val pkey = Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')).toBase64()
|
||||
synchronized(_mdnsCache) {
|
||||
_mdnsCache.remove(pkey)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
|
||||
try {
|
||||
_nsdManager?.stopServiceDiscovery(this)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
|
||||
try {
|
||||
_nsdManager?.stopServiceDiscovery(this)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun addOrUpdate(name: String, adrs: Array<InetAddress>, port: Int, attributes: Map<String, ByteArray>) {
|
||||
if (!Settings.instance.synchronization.connectDiscovered) {
|
||||
return
|
||||
}
|
||||
|
||||
val urlSafePkey = attributes.get("pk")?.decodeToString() ?: return
|
||||
val pkey = Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')).toBase64()
|
||||
val syncDeviceInfo = SyncDeviceInfo(pkey, adrs.map { it.hostAddress }.toTypedArray(), port, null)
|
||||
|
||||
synchronized(_mdnsCache) {
|
||||
_mdnsCache[pkey] = syncDeviceInfo
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceFound(service: NsdServiceInfo) {
|
||||
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
|
||||
addOrUpdate(service.serviceName, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
service.hostAddresses.toTypedArray()
|
||||
} else {
|
||||
if(service.host != null)
|
||||
arrayOf(service.host);
|
||||
else
|
||||
arrayOf();
|
||||
}, service.port, service.attributes)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
_nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback {
|
||||
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "onServiceUpdated: $serviceInfo")
|
||||
addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port, serviceInfo.attributes)
|
||||
}
|
||||
|
||||
override fun onServiceLost() {
|
||||
Log.v(TAG, "onServiceLost: $service")
|
||||
val urlSafePkey = service.attributes["pk"]?.decodeToString() ?: return
|
||||
val pkey = Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')).toBase64()
|
||||
synchronized(_mdnsCache) {
|
||||
_mdnsCache.remove(pkey)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
||||
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackUnregistered() {
|
||||
Log.v(TAG, "onServiceInfoCallbackUnregistered")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
Log.v(TAG, "Resolve failed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
|
||||
addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port, serviceInfo.attributes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _registrationListener = object : NsdManager.RegistrationListener {
|
||||
override fun onServiceRegistered(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "onServiceRegistered: ${serviceInfo.serviceName}")
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
Log.v(TAG, "onRegistrationFailed: ${serviceInfo.serviceName} (error code: $errorCode)")
|
||||
}
|
||||
|
||||
override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "onServiceUnregistered: ${serviceInfo.serviceName}")
|
||||
}
|
||||
|
||||
override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
Log.v(TAG, "onUnregistrationFailed: ${serviceInfo.serviceName} (error code: $errorCode)")
|
||||
}
|
||||
}
|
||||
|
||||
var keyPair: DHState? = null
|
||||
var publicKey: String? = null
|
||||
|
||||
var onAuthorized: ((SyncSession, Boolean, Boolean) -> Unit)? = null
|
||||
var onUnauthorized: ((SyncSession) -> Unit)? = null
|
||||
var onConnectedChanged: ((SyncSession, Boolean) -> Unit)? = null
|
||||
var onClose: ((SyncSession) -> Unit)? = null
|
||||
var onData: ((SyncSession, UByte, UByte, ByteBuffer) -> Unit)? = null
|
||||
var authorizePrompt: ((String, (Boolean) -> Unit) -> Unit)? = null
|
||||
|
||||
fun start(context: Context, onServerBindFail: (() -> Unit)? = null) {
|
||||
if (_started) {
|
||||
Logger.i(TAG, "Already started.")
|
||||
return
|
||||
}
|
||||
_started = true
|
||||
_scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
try {
|
||||
val syncKeyPair = database.getSyncKeyPair() ?: throw Exception("SyncKeyPair not found")
|
||||
val p = Noise.createDH(dh)
|
||||
p.setPublicKey(syncKeyPair.publicKey.base64ToByteArray(), 0)
|
||||
p.setPrivateKey(syncKeyPair.privateKey.base64ToByteArray(), 0)
|
||||
keyPair = p
|
||||
} catch (e: Throwable) {
|
||||
//Sync key pair non-existing, invalid or lost
|
||||
val p = Noise.createDH(dh)
|
||||
p.generateKeyPair()
|
||||
|
||||
val publicKey = ByteArray(p.publicKeyLength)
|
||||
p.getPublicKey(publicKey, 0)
|
||||
val privateKey = ByteArray(p.privateKeyLength)
|
||||
p.getPrivateKey(privateKey, 0)
|
||||
|
||||
val syncKeyPair = SyncKeyPair(1, publicKey.toBase64(), privateKey.toBase64())
|
||||
database.setSyncKeyPair(syncKeyPair)
|
||||
|
||||
Logger.e(TAG, "Failed to load existing key pair", e)
|
||||
keyPair = p
|
||||
}
|
||||
|
||||
publicKey = keyPair?.let {
|
||||
val pkey = ByteArray(it.publicKeyLength)
|
||||
it.getPublicKey(pkey, 0)
|
||||
return@let pkey.toBase64()
|
||||
}
|
||||
|
||||
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||
if (settings.mdnsConnectDiscovered) {
|
||||
startMdnsRetryLoop()
|
||||
}
|
||||
|
||||
if (settings.mdnsBroadcast) {
|
||||
val pk = publicKey
|
||||
val nsdManager = _nsdManager
|
||||
|
||||
if (pk != null && nsdManager != null) {
|
||||
val sn = serviceName
|
||||
val serviceInfo = NsdServiceInfo().apply {
|
||||
serviceName = getDeviceName()
|
||||
serviceType = sn
|
||||
port = settings.listenerPort
|
||||
setAttribute("pk", pk.replace('+', '-').replace('/', '_').replace("=", ""))
|
||||
}
|
||||
|
||||
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, _registrationListener)
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Sync key pair initialized (public key = $publicKey)")
|
||||
|
||||
if (settings.bindListener) {
|
||||
startListener(onServerBindFail)
|
||||
}
|
||||
|
||||
if (settings.relayEnabled) {
|
||||
startRelayLoop()
|
||||
}
|
||||
|
||||
if (settings.connectLastKnown) {
|
||||
startConnectLastLoop()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startListener(onServerBindFail: (() -> Unit)? = null) {
|
||||
serverSocketFailedToStart = false
|
||||
_thread = Thread {
|
||||
try {
|
||||
val serverSocket = ServerSocket(settings.listenerPort)
|
||||
_serverSocket = serverSocket
|
||||
|
||||
Log.i(TAG, "Running on port ${settings.listenerPort} (TCP)")
|
||||
|
||||
while (_started) {
|
||||
val socket = serverSocket.accept()
|
||||
val session = createSocketSession(socket, true)
|
||||
session.startAsResponder()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to bind server socket to port ${settings.listenerPort}", e)
|
||||
serverSocketFailedToStart = true
|
||||
onServerBindFail?.invoke()
|
||||
}
|
||||
}.apply { start() }
|
||||
}
|
||||
|
||||
private fun startMdnsRetryLoop() {
|
||||
_nsdManager?.apply {
|
||||
discoverServices(serviceName, NsdManager.PROTOCOL_DNS_SD, _discoveryListener)
|
||||
}
|
||||
|
||||
_mdnsThread = Thread {
|
||||
while (_started) {
|
||||
try {
|
||||
val now = System.currentTimeMillis()
|
||||
synchronized(_mdnsCache) {
|
||||
for ((pkey, info) in _mdnsCache) {
|
||||
if (!database.isAuthorized(pkey) || isConnected(pkey)) continue
|
||||
|
||||
val last = synchronized(_lastConnectTimesMdns) {
|
||||
_lastConnectTimesMdns[pkey] ?: 0L
|
||||
}
|
||||
if (now - last > 30_000L) {
|
||||
_lastConnectTimesMdns[pkey] = now
|
||||
try {
|
||||
Logger.i(TAG, "MDNS-retry: connecting to $pkey")
|
||||
connect(info)
|
||||
} catch (ex: Throwable) {
|
||||
Logger.w(TAG, "MDNS retry failed for $pkey", ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Error in MDNS retry loop", ex)
|
||||
}
|
||||
Thread.sleep(5000)
|
||||
}
|
||||
}.apply { start() }
|
||||
}
|
||||
|
||||
|
||||
private fun startConnectLastLoop() {
|
||||
_connectThread = Thread {
|
||||
Log.i(TAG, "Running auto reconnector")
|
||||
|
||||
while (_started) {
|
||||
val authorizedDevices = database.getAllAuthorizedDevices() ?: arrayOf()
|
||||
val addressesToConnect = authorizedDevices.mapNotNull {
|
||||
val connected = isConnected(it)
|
||||
if (connected) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
val lastKnownAddress = database.getLastAddress(it) ?: return@mapNotNull null
|
||||
return@mapNotNull Pair(it, lastKnownAddress)
|
||||
}
|
||||
|
||||
for (connectPair in addressesToConnect) {
|
||||
try {
|
||||
val now = System.currentTimeMillis()
|
||||
val lastConnectTime = synchronized(_lastConnectTimesIp) {
|
||||
_lastConnectTimesIp[connectPair.first] ?: 0
|
||||
}
|
||||
|
||||
//Connect once every 30 seconds, max
|
||||
if (now - lastConnectTime > 30000) {
|
||||
synchronized(_lastConnectTimesIp) {
|
||||
_lastConnectTimesIp[connectPair.first] = now
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Attempting to connect to authorized device by last known IP '${connectPair.first}' with pkey=${connectPair.first}")
|
||||
connect(arrayOf(connectPair.second), settings.listenerPort, connectPair.first, null)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to connect to " + connectPair.first, e)
|
||||
}
|
||||
}
|
||||
Thread.sleep(5000)
|
||||
}
|
||||
}.apply { start() }
|
||||
}
|
||||
|
||||
private fun startRelayLoop() {
|
||||
_threadRelay = Thread {
|
||||
var backoffs: Array<Long> = arrayOf(1000, 5000, 10000, 20000)
|
||||
var backoffIndex = 0;
|
||||
|
||||
while (_started) {
|
||||
try {
|
||||
Log.i(TAG, "Starting relay session...")
|
||||
|
||||
var socketClosed = false;
|
||||
val socket = Socket(relayServer, 9000)
|
||||
_relaySession = SyncSocketSession(
|
||||
(socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!,
|
||||
keyPair!!,
|
||||
socket,
|
||||
isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) },
|
||||
onNewChannel = { _, c ->
|
||||
val remotePublicKey = c.remotePublicKey
|
||||
if (remotePublicKey == null) {
|
||||
Log.e(TAG, "Remote public key should never be null in onNewChannel.")
|
||||
return@SyncSocketSession
|
||||
}
|
||||
|
||||
Log.i(TAG, "New channel established from relay (pk: '$remotePublicKey').")
|
||||
|
||||
var session: SyncSession?
|
||||
synchronized(_sessions) {
|
||||
session = _sessions[remotePublicKey]
|
||||
if (session == null) {
|
||||
val remoteDeviceName = database.getDeviceName(remotePublicKey)
|
||||
session = createNewSyncSession(remotePublicKey, remoteDeviceName)
|
||||
_sessions[remotePublicKey] = session!!
|
||||
}
|
||||
session!!.addChannel(c)
|
||||
}
|
||||
|
||||
c.setDataHandler { _, channel, opcode, subOpcode, data ->
|
||||
session?.handlePacket(opcode, subOpcode, data)
|
||||
}
|
||||
c.setCloseHandler { channel ->
|
||||
session?.removeChannel(channel)
|
||||
}
|
||||
},
|
||||
onChannelEstablished = { _, channel, isResponder ->
|
||||
handleAuthorization(channel, isResponder)
|
||||
},
|
||||
onClose = { socketClosed = true },
|
||||
onHandshakeComplete = { relaySession ->
|
||||
backoffIndex = 0
|
||||
|
||||
Thread {
|
||||
try {
|
||||
while (_started && !socketClosed) {
|
||||
val unconnectedAuthorizedDevices = database.getAllAuthorizedDevices()?.filter { !isConnected(it) }?.toTypedArray() ?: arrayOf()
|
||||
relaySession.publishConnectionInformation(unconnectedAuthorizedDevices, settings.listenerPort, settings.relayConnectDirect, false, false, settings.relayConnectRelayed)
|
||||
|
||||
Logger.v(TAG, "Requesting ${unconnectedAuthorizedDevices.size} devices connection information")
|
||||
val connectionInfos = runBlocking { relaySession.requestBulkConnectionInfo(unconnectedAuthorizedDevices) }
|
||||
Logger.v(TAG, "Received ${connectionInfos.size} devices connection information")
|
||||
|
||||
for ((targetKey, connectionInfo) in connectionInfos) {
|
||||
val potentialLocalAddresses = connectionInfo.ipv4Addresses
|
||||
.filter { it != connectionInfo.remoteIp }
|
||||
if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) {
|
||||
Thread {
|
||||
try {
|
||||
Log.v(TAG, "Attempting to connect directly, locally to '$targetKey'.")
|
||||
connect(potentialLocalAddresses.map { it }.toTypedArray(), settings.listenerPort, targetKey, null)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to start direct connection using connection info with $targetKey.", e)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
if (connectionInfo.allowRemoteDirect) {
|
||||
// TODO: Implement direct remote connection if needed
|
||||
}
|
||||
|
||||
if (connectionInfo.allowRemoteHolePunched) {
|
||||
// TODO: Implement hole punching if needed
|
||||
}
|
||||
|
||||
if (connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) {
|
||||
try {
|
||||
Logger.v(TAG, "Attempting relayed connection with '$targetKey'.")
|
||||
runBlocking { relaySession.startRelayedChannel(targetKey, appId, null) }
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to start relayed channel with $targetKey.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Thread.sleep(15000)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in relay session.", e)
|
||||
relaySession.stop()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
)
|
||||
|
||||
_relaySession!!.authorizable = object : IAuthorizable {
|
||||
override val isAuthorized: Boolean get() = true
|
||||
}
|
||||
|
||||
_relaySession!!.runAsInitiator(relayPublicKey, appId, null)
|
||||
|
||||
Log.i(TAG, "Started relay session.")
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Relay session failed.", e)
|
||||
} finally {
|
||||
_relaySession?.stop()
|
||||
_relaySession = null
|
||||
Thread.sleep(backoffs[min(backoffs.size - 1, backoffIndex++)])
|
||||
}
|
||||
}
|
||||
}.apply { start() }
|
||||
}
|
||||
|
||||
private fun createSocketSession(socket: Socket, isResponder: Boolean): SyncSocketSession {
|
||||
var session: SyncSession? = null
|
||||
var channelSocket: ChannelSocket? = null
|
||||
return SyncSocketSession(
|
||||
(socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!,
|
||||
keyPair!!,
|
||||
socket,
|
||||
onClose = { s ->
|
||||
if (channelSocket != null)
|
||||
session?.removeChannel(channelSocket!!)
|
||||
},
|
||||
isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) },
|
||||
onHandshakeComplete = { s ->
|
||||
val remotePublicKey = s.remotePublicKey
|
||||
if (remotePublicKey == null) {
|
||||
s.stop()
|
||||
return@SyncSocketSession
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Handshake complete with (LocalPublicKey = ${s.localPublicKey}, RemotePublicKey = ${s.remotePublicKey})")
|
||||
|
||||
channelSocket = ChannelSocket(s)
|
||||
|
||||
synchronized(_sessions) {
|
||||
session = _sessions[s.remotePublicKey]
|
||||
if (session == null) {
|
||||
val remoteDeviceName = database.getDeviceName(remotePublicKey)
|
||||
database.setLastAddress(remotePublicKey, s.remoteAddress)
|
||||
session = createNewSyncSession(remotePublicKey, remoteDeviceName)
|
||||
_sessions[remotePublicKey] = session!!
|
||||
}
|
||||
session!!.addChannel(channelSocket!!)
|
||||
}
|
||||
|
||||
handleAuthorization(channelSocket!!, isResponder)
|
||||
},
|
||||
onData = { s, opcode, subOpcode, data ->
|
||||
session?.handlePacket(opcode, subOpcode, data)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleAuthorization(channel: IChannel, isResponder: Boolean) {
|
||||
val syncSession = channel.syncSession!!
|
||||
val remotePublicKey = channel.remotePublicKey!!
|
||||
|
||||
if (isResponder) {
|
||||
val isAuthorized = database.isAuthorized(remotePublicKey)
|
||||
if (!isAuthorized) {
|
||||
val ap = this.authorizePrompt
|
||||
if (ap == null) {
|
||||
try {
|
||||
Logger.i(TAG, "$remotePublicKey unauthorized because AuthorizePrompt is null")
|
||||
syncSession.unauthorize()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to send authorize result.", e)
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ap.invoke(remotePublicKey) {
|
||||
try {
|
||||
_scope?.launch(Dispatchers.IO) {
|
||||
if (it) {
|
||||
Logger.i(TAG, "$remotePublicKey manually authorized")
|
||||
syncSession.authorize()
|
||||
} else {
|
||||
Logger.i(TAG, "$remotePublicKey manually unauthorized")
|
||||
syncSession.unauthorize()
|
||||
syncSession.close()
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to send authorize result.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
//Responder does not need to check because already approved
|
||||
syncSession.authorize()
|
||||
Logger.i(TAG, "Connection authorized for $remotePublicKey because already authorized")
|
||||
}
|
||||
} else {
|
||||
//Initiator does not need to check because the manual action of scanning the QR counts as approval
|
||||
syncSession.authorize()
|
||||
Logger.i(TAG, "Connection authorized for $remotePublicKey because initiator")
|
||||
}
|
||||
}
|
||||
|
||||
private fun isHandshakeAllowed(linkType: LinkType, syncSocketSession: SyncSocketSession, publicKey: String, pairingCode: String?, appId: UInt): Boolean {
|
||||
Log.v(TAG, "Check if handshake allowed from '$publicKey' (app id: $appId).")
|
||||
if (publicKey == StateSync.RELAY_PUBLIC_KEY)
|
||||
return true
|
||||
|
||||
if (database.isAuthorized(publicKey)) {
|
||||
if (linkType == LinkType.Relayed && !settings.relayHandshakeAllowed)
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
Log.v(TAG, "Check if handshake allowed with pairing code '$pairingCode' with active pairing code '$_pairingCode' (app id: $appId).")
|
||||
if (_pairingCode == null || pairingCode.isNullOrEmpty())
|
||||
return false
|
||||
|
||||
if (linkType == LinkType.Relayed && !settings.relayPairAllowed)
|
||||
return false
|
||||
|
||||
return _pairingCode == pairingCode
|
||||
}
|
||||
|
||||
private fun createNewSyncSession(rpk: String, remoteDeviceName: String?): SyncSession {
|
||||
val remotePublicKey = rpk.base64ToByteArray().toBase64()
|
||||
return SyncSession(
|
||||
remotePublicKey,
|
||||
onAuthorized = { it, isNewlyAuthorized, isNewSession ->
|
||||
synchronized(_remotePendingStatusUpdate) {
|
||||
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(true, "Authorized")
|
||||
}
|
||||
|
||||
if (isNewSession) {
|
||||
it.remoteDeviceName?.let { remoteDeviceName ->
|
||||
database.setDeviceName(remotePublicKey, remoteDeviceName)
|
||||
}
|
||||
|
||||
database.addAuthorizedDevice(remotePublicKey)
|
||||
}
|
||||
|
||||
onAuthorized?.invoke(it, isNewlyAuthorized, isNewSession)
|
||||
},
|
||||
onUnauthorized = {
|
||||
synchronized(_remotePendingStatusUpdate) {
|
||||
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Unauthorized")
|
||||
}
|
||||
|
||||
onUnauthorized?.invoke(it)
|
||||
},
|
||||
onConnectedChanged = { it, connected ->
|
||||
Logger.i(TAG, "$remotePublicKey connected: $connected")
|
||||
onConnectedChanged?.invoke(it, connected)
|
||||
},
|
||||
onClose = {
|
||||
Logger.i(TAG, "$remotePublicKey closed")
|
||||
|
||||
removeSession(it.remotePublicKey)
|
||||
synchronized(_remotePendingStatusUpdate) {
|
||||
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Connection closed")
|
||||
}
|
||||
|
||||
onClose?.invoke(it)
|
||||
},
|
||||
dataHandler = { it, opcode, subOpcode, data ->
|
||||
onData?.invoke(it, opcode, subOpcode, data)
|
||||
},
|
||||
remoteDeviceName
|
||||
)
|
||||
}
|
||||
|
||||
fun isConnected(publicKey: String): Boolean = synchronized(_sessions) { _sessions[publicKey]?.connected ?: false }
|
||||
fun isAuthorized(publicKey: String): Boolean = database.isAuthorized(publicKey)
|
||||
fun getSession(publicKey: String): SyncSession? = synchronized(_sessions) { _sessions[publicKey] }
|
||||
fun getSessions(): List<SyncSession> = synchronized(_sessions) { _sessions.values.toList() }
|
||||
fun removeSession(publicKey: String) = synchronized(_sessions) { _sessions.remove(publicKey) }
|
||||
fun getCachedName(publicKey: String): String? = database.getDeviceName(publicKey)
|
||||
fun getAuthorizedDeviceCount(): Int = database.getAuthorizedDeviceCount()
|
||||
fun getAllAuthorizedDevices(): Array<String>? = database.getAllAuthorizedDevices()
|
||||
fun removeAuthorizedDevice(publicKey: String) = database.removeAuthorizedDevice(publicKey)
|
||||
|
||||
fun connect(deviceInfo: SyncDeviceInfo, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null) {
|
||||
try {
|
||||
connect(deviceInfo.addresses, deviceInfo.port, deviceInfo.publicKey, deviceInfo.pairingCode, onStatusUpdate)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to connect directly", e)
|
||||
val relaySession = _relaySession
|
||||
if (relaySession != null && Settings.instance.synchronization.pairThroughRelay) {
|
||||
onStatusUpdate?.invoke(null, "Connecting via relay...")
|
||||
|
||||
runBlocking {
|
||||
if (onStatusUpdate != null) {
|
||||
synchronized(_remotePendingStatusUpdate) {
|
||||
_remotePendingStatusUpdate[deviceInfo.publicKey.base64ToByteArray().toBase64()] = onStatusUpdate
|
||||
}
|
||||
}
|
||||
relaySession.startRelayedChannel(deviceInfo.publicKey.base64ToByteArray().toBase64(), appId, deviceInfo.pairingCode)
|
||||
}
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun connect(addresses: Array<String>, port: Int, publicKey: String, pairingCode: String?, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null): SyncSocketSession {
|
||||
onStatusUpdate?.invoke(null, "Connecting directly...")
|
||||
val socket = getConnectedSocket(addresses.map { InetAddress.getByName(it) }, port) ?: throw Exception("Failed to connect")
|
||||
onStatusUpdate?.invoke(null, "Handshaking...")
|
||||
|
||||
val session = createSocketSession(socket, false)
|
||||
if (onStatusUpdate != null) {
|
||||
synchronized(_remotePendingStatusUpdate) {
|
||||
_remotePendingStatusUpdate[publicKey.base64ToByteArray().toBase64()] = onStatusUpdate
|
||||
}
|
||||
}
|
||||
|
||||
session.startAsInitiator(publicKey, appId, pairingCode)
|
||||
return session
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
_scope?.cancel()
|
||||
_scope = null
|
||||
_relaySession?.stop()
|
||||
_relaySession = null
|
||||
_serverSocket?.close()
|
||||
_serverSocket = null
|
||||
synchronized(_sessions) {
|
||||
_sessions.values.forEach { it.close() }
|
||||
_sessions.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDeviceName(): String {
|
||||
val manufacturer = Build.MANUFACTURER.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
|
||||
val model = Build.MODEL
|
||||
|
||||
return if (model.startsWith(manufacturer, ignoreCase = true)) {
|
||||
model.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
|
||||
} else {
|
||||
"$manufacturer $model".replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val dh = "25519"
|
||||
val pattern = "IK"
|
||||
val cipher = "ChaChaPoly"
|
||||
val hash = "BLAKE2b"
|
||||
var protocolName = "Noise_${pattern}_${dh}_${cipher}_${hash}"
|
||||
|
||||
private const val TAG = "SyncService"
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package com.futo.platformplayer.sync.internal
|
||||
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.ensureNotMainThread
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
|
@ -16,6 +17,8 @@ interface IAuthorizable {
|
|||
|
||||
class SyncSession : IAuthorizable {
|
||||
private val _channels: MutableList<IChannel> = mutableListOf()
|
||||
@Volatile
|
||||
private var _snapshot: Array<IChannel> = emptyArray()
|
||||
private var _authorized: Boolean = false
|
||||
private var _remoteAuthorized: Boolean = false
|
||||
private val _onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit
|
||||
|
@ -82,6 +85,8 @@ class SyncSession : IAuthorizable {
|
|||
|
||||
synchronized(_channels) {
|
||||
_channels.add(channel)
|
||||
_channels.sortBy { it.linkType.ordinal }
|
||||
_snapshot = _channels.toTypedArray()
|
||||
connected = _channels.isNotEmpty()
|
||||
}
|
||||
|
||||
|
@ -123,15 +128,20 @@ class SyncSession : IAuthorizable {
|
|||
fun removeChannel(channel: IChannel) {
|
||||
synchronized(_channels) {
|
||||
_channels.remove(channel)
|
||||
_snapshot = _channels.toTypedArray()
|
||||
connected = _channels.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
synchronized(_channels) {
|
||||
_channels.forEach { it.close() }
|
||||
val toClose = synchronized(_channels) {
|
||||
val arr = _channels.toTypedArray()
|
||||
_channels.clear()
|
||||
_snapshot = emptyArray()
|
||||
connected = false
|
||||
arr
|
||||
}
|
||||
toClose.forEach { it.close() }
|
||||
_onClose(this)
|
||||
}
|
||||
|
||||
|
@ -192,33 +202,38 @@ class SyncSession : IAuthorizable {
|
|||
}
|
||||
|
||||
inline fun <reified T> sendJsonData(subOpcode: UByte, data: T) {
|
||||
ensureNotMainThread()
|
||||
send(Opcode.DATA.value, subOpcode, Json.encodeToString(data))
|
||||
}
|
||||
|
||||
fun sendData(subOpcode: UByte, data: String) {
|
||||
send(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)))
|
||||
ensureNotMainThread()
|
||||
send(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip)
|
||||
}
|
||||
|
||||
fun send(opcode: UByte, subOpcode: UByte, data: String) {
|
||||
send(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)))
|
||||
ensureNotMainThread()
|
||||
send(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip)
|
||||
}
|
||||
|
||||
fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer? = null) {
|
||||
val channels = synchronized(_channels) { _channels.sortedBy { it.linkType.ordinal }.toList() }
|
||||
fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer? = null, contentEncoding: ContentEncoding? = null) {
|
||||
ensureNotMainThread()
|
||||
val channels = _snapshot
|
||||
if (channels.isEmpty()) {
|
||||
//TODO: Should this throw?
|
||||
Logger.v(TAG, "Packet was not sent (opcode = $opcode, subOpcode = $subOpcode) due to no connected sockets")
|
||||
Logger.v(TAG, "Packet was not sent … no connected sockets")
|
||||
return
|
||||
}
|
||||
|
||||
var sent = false
|
||||
for (channel in channels) {
|
||||
try {
|
||||
channel.send(opcode, subOpcode, data)
|
||||
channel.send(opcode, subOpcode, data, contentEncoding)
|
||||
sent = true
|
||||
break
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Packet failed to send (opcode = $opcode, subOpcode = $subOpcode)", e)
|
||||
Logger.w(TAG, "Packet failed to send (opcode = $opcode, subOpcode = $subOpcode), closing channel", e)
|
||||
channel.close()
|
||||
removeChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,34 +1,46 @@
|
|||
package com.futo.platformplayer.sync.internal
|
||||
|
||||
import android.os.Build
|
||||
import com.futo.platformplayer.LittleEndianDataInputStream
|
||||
import com.futo.platformplayer.LittleEndianDataOutputStream
|
||||
import com.futo.platformplayer.ensureNotMainThread
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.noise.protocol.CipherStatePair
|
||||
import com.futo.platformplayer.noise.protocol.DHState
|
||||
import com.futo.platformplayer.noise.protocol.HandshakeState
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateSync
|
||||
import com.futo.polycentric.core.base64ToByteArray
|
||||
import com.futo.polycentric.core.toBase64
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.Socket
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.min
|
||||
import java.util.zip.GZIPInputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
class SyncSocketSession {
|
||||
private val _inputStream: LittleEndianDataInputStream
|
||||
private val _outputStream: LittleEndianDataOutputStream
|
||||
private val _socket: Socket
|
||||
private val _inputStream: InputStream
|
||||
private val _outputStream: OutputStream
|
||||
private val _sendLockObject = Object()
|
||||
private val _buffer = ByteArray(MAXIMUM_PACKET_SIZE_ENCRYPTED)
|
||||
private val _bufferDecrypted = ByteArray(MAXIMUM_PACKET_SIZE)
|
||||
private val _sendBuffer = ByteArray(MAXIMUM_PACKET_SIZE)
|
||||
private val _sendBufferEncrypted = ByteArray(MAXIMUM_PACKET_SIZE_ENCRYPTED)
|
||||
private val _sendBufferEncrypted = ByteArray(4 + MAXIMUM_PACKET_SIZE_ENCRYPTED)
|
||||
private val _syncStreams = hashMapOf<Int, SyncStream>()
|
||||
private var _streamIdGenerator = 0
|
||||
private val _streamIdGeneratorLock = Object()
|
||||
|
@ -38,12 +50,13 @@ class SyncSocketSession {
|
|||
private val _onHandshakeComplete: ((session: SyncSocketSession) -> Unit)?
|
||||
private val _onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)?
|
||||
private val _onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)?
|
||||
private val _isHandshakeAllowed: ((linkType: LinkType, session: SyncSocketSession, remotePublicKey: String, pairingCode: String?) -> Boolean)?
|
||||
private val _isHandshakeAllowed: ((linkType: LinkType, session: SyncSocketSession, remotePublicKey: String, pairingCode: String?, appId: UInt) -> Boolean)?
|
||||
private var _cipherStatePair: CipherStatePair? = null
|
||||
private var _remotePublicKey: String? = null
|
||||
val remotePublicKey: String? get() = _remotePublicKey
|
||||
private var _started: Boolean = false
|
||||
private val _localKeyPair: DHState
|
||||
private var _thread: Thread? = null
|
||||
private var _localPublicKey: String
|
||||
val localPublicKey: String get() = _localPublicKey
|
||||
private val _onData: ((session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit)?
|
||||
|
@ -65,6 +78,11 @@ class SyncSocketSession {
|
|||
private val _pendingBulkGetRecordRequests = ConcurrentHashMap<Int, CompletableDeferred<Map<String, Pair<ByteArray, Long>>>>()
|
||||
private val _pendingBulkConnectionInfoRequests = ConcurrentHashMap<Int, CompletableDeferred<Map<String, ConnectionInfo>>>()
|
||||
|
||||
@Volatile
|
||||
private var _lastPongTime: Long = System.currentTimeMillis()
|
||||
private val _pingInterval: Long = 5000 // 5 seconds in milliseconds
|
||||
private val _disconnectTimeout: Long = 30000 // 30 seconds in milliseconds
|
||||
|
||||
data class ConnectionInfo(
|
||||
val port: UShort,
|
||||
val name: String,
|
||||
|
@ -80,17 +98,20 @@ class SyncSocketSession {
|
|||
constructor(
|
||||
remoteAddress: String,
|
||||
localKeyPair: DHState,
|
||||
inputStream: LittleEndianDataInputStream,
|
||||
outputStream: LittleEndianDataOutputStream,
|
||||
socket: Socket,
|
||||
onClose: ((session: SyncSocketSession) -> Unit)? = null,
|
||||
onHandshakeComplete: ((session: SyncSocketSession) -> Unit)? = null,
|
||||
onData: ((session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit)? = null,
|
||||
onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)? = null,
|
||||
onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)? = null,
|
||||
isHandshakeAllowed: ((linkType: LinkType, session: SyncSocketSession, remotePublicKey: String, pairingCode: String?) -> Boolean)? = null
|
||||
isHandshakeAllowed: ((linkType: LinkType, session: SyncSocketSession, remotePublicKey: String, pairingCode: String?, appId: UInt) -> Boolean)? = null
|
||||
) {
|
||||
_inputStream = inputStream
|
||||
_outputStream = outputStream
|
||||
_socket = socket
|
||||
_socket.receiveBufferSize = MAXIMUM_PACKET_SIZE_ENCRYPTED
|
||||
_socket.sendBufferSize = MAXIMUM_PACKET_SIZE_ENCRYPTED
|
||||
_socket.tcpNoDelay = true
|
||||
_inputStream = _socket.getInputStream()
|
||||
_outputStream = _socket.getOutputStream()
|
||||
_onClose = onClose
|
||||
_onHandshakeComplete = onHandshakeComplete
|
||||
_localKeyPair = localKeyPair
|
||||
|
@ -105,11 +126,28 @@ class SyncSocketSession {
|
|||
_localPublicKey = Base64.getEncoder().encodeToString(localPublicKey)
|
||||
}
|
||||
|
||||
fun startAsInitiator(remotePublicKey: String, pairingCode: String? = null) {
|
||||
fun startAsInitiator(remotePublicKey: String, appId: UInt = 0u, pairingCode: String? = null) {
|
||||
_started = true
|
||||
_thread = Thread {
|
||||
try {
|
||||
handshakeAsInitiator(remotePublicKey, appId, pairingCode)
|
||||
_onHandshakeComplete?.invoke(this)
|
||||
startPingLoop()
|
||||
receiveLoop()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to run as initiator", e)
|
||||
} finally {
|
||||
stop()
|
||||
}
|
||||
}.apply { start() }
|
||||
}
|
||||
|
||||
fun runAsInitiator(remotePublicKey: String, appId: UInt = 0u, pairingCode: String? = null) {
|
||||
_started = true
|
||||
try {
|
||||
handshakeAsInitiator(remotePublicKey, pairingCode)
|
||||
handshakeAsInitiator(remotePublicKey, appId, pairingCode)
|
||||
_onHandshakeComplete?.invoke(this)
|
||||
startPingLoop()
|
||||
receiveLoop()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to run as initiator", e)
|
||||
|
@ -120,42 +158,60 @@ class SyncSocketSession {
|
|||
|
||||
fun startAsResponder() {
|
||||
_started = true
|
||||
try {
|
||||
if (handshakeAsResponder()) {
|
||||
_onHandshakeComplete?.invoke(this)
|
||||
receiveLoop()
|
||||
_thread = Thread {
|
||||
try {
|
||||
if (handshakeAsResponder()) {
|
||||
_onHandshakeComplete?.invoke(this)
|
||||
startPingLoop()
|
||||
receiveLoop()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to run as responder", e)
|
||||
} finally {
|
||||
stop()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to run as responder", e)
|
||||
} finally {
|
||||
stop()
|
||||
}.apply { start() }
|
||||
}
|
||||
|
||||
private fun readExact(buffer: ByteArray, offset: Int, size: Int) {
|
||||
var totalBytesReceived: Int = 0
|
||||
while (totalBytesReceived < size) {
|
||||
val bytesReceived = _inputStream.read(buffer, offset + totalBytesReceived, size - totalBytesReceived)
|
||||
if (bytesReceived <= 0)
|
||||
throw Exception("Socket disconnected")
|
||||
totalBytesReceived += bytesReceived
|
||||
}
|
||||
}
|
||||
|
||||
private fun receiveLoop() {
|
||||
while (_started) {
|
||||
try {
|
||||
val messageSize = _inputStream.readInt()
|
||||
//Logger.v(TAG, "Waiting for message size...")
|
||||
|
||||
readExact(_buffer, 0, 4)
|
||||
val messageSize = ByteBuffer.wrap(_buffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int
|
||||
|
||||
//Logger.v(TAG, "Read message size ${messageSize}.")
|
||||
|
||||
if (messageSize > MAXIMUM_PACKET_SIZE_ENCRYPTED) {
|
||||
throw Exception("Message size (${messageSize}) cannot exceed MAXIMUM_PACKET_SIZE ($MAXIMUM_PACKET_SIZE_ENCRYPTED)")
|
||||
}
|
||||
|
||||
//Logger.i(TAG, "Receiving message (size = ${messageSize})")
|
||||
|
||||
var bytesRead = 0
|
||||
while (bytesRead < messageSize) {
|
||||
val read = _inputStream.read(_buffer, bytesRead, messageSize - bytesRead)
|
||||
if (read == -1)
|
||||
throw Exception("Stream closed")
|
||||
bytesRead += read
|
||||
}
|
||||
readExact(_buffer, 0, messageSize)
|
||||
//Logger.v(TAG, "Read ${messageSize}.")
|
||||
|
||||
//Logger.v(TAG, "Decrypting ${messageSize} bytes.")
|
||||
val plen: Int = _cipherStatePair!!.receiver.decryptWithAd(null, _buffer, 0, _bufferDecrypted, 0, messageSize)
|
||||
//Logger.i(TAG, "Decrypted message (size = ${plen})")
|
||||
|
||||
//Logger.v(TAG, "Decrypted ${messageSize} bytes.")
|
||||
handleData(_bufferDecrypted, plen, null)
|
||||
//Logger.v(TAG, "Handled data ${messageSize} bytes.")
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Exception while receiving data", e)
|
||||
Logger.e(TAG, "Exception while receiving data, closing socket session", e)
|
||||
stop()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
@ -185,17 +241,17 @@ class SyncSocketSession {
|
|||
_channels.values.forEach { it.close() }
|
||||
_channels.clear()
|
||||
_onClose?.invoke(this)
|
||||
_inputStream.close()
|
||||
_outputStream.close()
|
||||
_socket.close()
|
||||
_thread = null
|
||||
_cipherStatePair?.sender?.destroy()
|
||||
_cipherStatePair?.receiver?.destroy()
|
||||
Logger.i(TAG, "Session closed")
|
||||
}
|
||||
|
||||
private fun handshakeAsInitiator(remotePublicKey: String, pairingCode: String?) {
|
||||
private fun handshakeAsInitiator(remotePublicKey: String, appId: UInt, pairingCode: String?) {
|
||||
performVersionCheck()
|
||||
|
||||
val initiator = HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR)
|
||||
val initiator = HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR)
|
||||
initiator.localKeyPair.copyFrom(_localKeyPair)
|
||||
initiator.remotePublicKey.setPublicKey(Base64.getDecoder().decode(remotePublicKey), 0)
|
||||
initiator.start()
|
||||
|
@ -218,41 +274,55 @@ class SyncSocketSession {
|
|||
val mainBuffer = ByteArray(512)
|
||||
val mainLength = initiator.writeMessage(mainBuffer, 0, null, 0, 0)
|
||||
|
||||
val messageData = ByteBuffer.allocate(4 + pairingMessageLength + mainLength).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val messageSize = 4 + 4 + pairingMessageLength + mainLength
|
||||
val messageData = ByteBuffer.allocate(4 + messageSize).order(ByteOrder.LITTLE_ENDIAN)
|
||||
messageData.putInt(messageSize)
|
||||
messageData.putInt(appId.toInt())
|
||||
messageData.putInt(pairingMessageLength)
|
||||
if (pairingMessageLength > 0) messageData.put(pairingMessage)
|
||||
messageData.put(mainBuffer, 0, mainLength)
|
||||
val messageDataArray = messageData.array()
|
||||
_outputStream.writeInt(messageDataArray.size)
|
||||
_outputStream.write(messageDataArray)
|
||||
_outputStream.write(messageDataArray, 0, 4 + messageSize)
|
||||
|
||||
readExact(_buffer, 0, 4)
|
||||
val responseSize = ByteBuffer.wrap(_buffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int
|
||||
if (responseSize > MAXIMUM_PACKET_SIZE_ENCRYPTED) {
|
||||
throw Exception("Message size (${messageSize}) cannot exceed MAXIMUM_PACKET_SIZE ($MAXIMUM_PACKET_SIZE_ENCRYPTED)")
|
||||
}
|
||||
|
||||
val responseSize = _inputStream.readInt()
|
||||
val responseMessage = ByteArray(responseSize)
|
||||
_inputStream.readFully(responseMessage)
|
||||
readExact(responseMessage, 0, responseSize)
|
||||
|
||||
val plaintext = ByteArray(512) // Buffer for any payload (none expected here)
|
||||
initiator.readMessage(responseMessage, 0, responseSize, plaintext, 0)
|
||||
|
||||
_cipherStatePair = initiator.split()
|
||||
val remoteKeyBytes = ByteArray(initiator.remotePublicKey.publicKeyLength)
|
||||
initiator.remotePublicKey.getPublicKey(remoteKeyBytes, 0)
|
||||
_remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes)
|
||||
_remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes).base64ToByteArray().toBase64()
|
||||
}
|
||||
|
||||
private fun handshakeAsResponder(): Boolean {
|
||||
performVersionCheck()
|
||||
|
||||
val responder = HandshakeState(StateSync.protocolName, HandshakeState.RESPONDER)
|
||||
val responder = HandshakeState(SyncService.protocolName, HandshakeState.RESPONDER)
|
||||
responder.localKeyPair.copyFrom(_localKeyPair)
|
||||
responder.start()
|
||||
|
||||
val messageSize = _inputStream.readInt()
|
||||
val message = ByteArray(messageSize)
|
||||
_inputStream.readFully(message)
|
||||
val messageBuffer = ByteBuffer.wrap(message).order(ByteOrder.LITTLE_ENDIAN)
|
||||
readExact(_buffer, 0, 4)
|
||||
val messageSize = ByteBuffer.wrap(_buffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int
|
||||
if (messageSize > MAXIMUM_PACKET_SIZE_ENCRYPTED) {
|
||||
throw Exception("Message size (${messageSize}) cannot exceed MAXIMUM_PACKET_SIZE ($MAXIMUM_PACKET_SIZE_ENCRYPTED)")
|
||||
}
|
||||
|
||||
val message = ByteArray(messageSize)
|
||||
readExact(message, 0, messageSize)
|
||||
|
||||
val messageBuffer = ByteBuffer.wrap(message).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val appId = messageBuffer.int.toUInt()
|
||||
val pairingMessageLength = messageBuffer.int
|
||||
val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { messageBuffer.get(it) } else byteArrayOf()
|
||||
val mainLength = messageSize - 4 - pairingMessageLength
|
||||
val mainLength = messageBuffer.remaining()
|
||||
val mainMessage = ByteArray(mainLength).also { messageBuffer.get(it) }
|
||||
|
||||
var pairingCode: String? = null
|
||||
|
@ -267,27 +337,36 @@ class SyncSocketSession {
|
|||
|
||||
val plaintext = ByteArray(512)
|
||||
responder.readMessage(mainMessage, 0, mainLength, plaintext, 0)
|
||||
|
||||
val responseBuffer = ByteArray(512)
|
||||
val responseLength = responder.writeMessage(responseBuffer, 0, null, 0, 0)
|
||||
_outputStream.writeInt(responseLength)
|
||||
_outputStream.write(responseBuffer, 0, responseLength)
|
||||
|
||||
_cipherStatePair = responder.split()
|
||||
val remoteKeyBytes = ByteArray(responder.remotePublicKey.publicKeyLength)
|
||||
responder.remotePublicKey.getPublicKey(remoteKeyBytes, 0)
|
||||
_remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes)
|
||||
val remotePublicKey = remoteKeyBytes.toBase64()
|
||||
|
||||
return (_remotePublicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Direct, this, _remotePublicKey!!, pairingCode) ?: true)).also {
|
||||
if (!it) stop()
|
||||
val isAllowedToConnect = remotePublicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Direct, this, remotePublicKey, pairingCode, appId) ?: true)
|
||||
if (!isAllowedToConnect) {
|
||||
stop()
|
||||
return false
|
||||
}
|
||||
|
||||
val responseBuffer = ByteArray(4 + 512)
|
||||
val responseLength = responder.writeMessage(responseBuffer, 4, null, 0, 0)
|
||||
ByteBuffer.wrap(responseBuffer).order(ByteOrder.LITTLE_ENDIAN).putInt(responseLength)
|
||||
_outputStream.write(responseBuffer, 0, 4 + responseLength)
|
||||
|
||||
_cipherStatePair = responder.split()
|
||||
_remotePublicKey = remotePublicKey.base64ToByteArray().toBase64()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun performVersionCheck() {
|
||||
val CURRENT_VERSION = 4
|
||||
val CURRENT_VERSION = 5
|
||||
val MINIMUM_VERSION = 4
|
||||
_outputStream.writeInt(CURRENT_VERSION)
|
||||
remoteVersion = _inputStream.readInt()
|
||||
|
||||
val versionBytes = ByteArray(4)
|
||||
ByteBuffer.wrap(versionBytes).order(ByteOrder.LITTLE_ENDIAN).putInt(CURRENT_VERSION)
|
||||
_outputStream.write(versionBytes, 0, 4)
|
||||
|
||||
readExact(versionBytes, 0, 4)
|
||||
remoteVersion = ByteBuffer.wrap(versionBytes, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int
|
||||
Logger.i(TAG, "performVersionCheck (version = $remoteVersion)")
|
||||
if (remoteVersion < MINIMUM_VERSION)
|
||||
throw Exception("Invalid version")
|
||||
|
@ -296,25 +375,44 @@ class SyncSocketSession {
|
|||
fun generateStreamId(): Int = synchronized(_streamIdGeneratorLock) { _streamIdGenerator++ }
|
||||
private fun generateRequestId(): Int = synchronized(_requestIdGeneratorLock) { _requestIdGenerator++ }
|
||||
|
||||
fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
|
||||
fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer, ce: ContentEncoding? = null) {
|
||||
ensureNotMainThread()
|
||||
|
||||
if (data.remaining() + HEADER_SIZE > MAXIMUM_PACKET_SIZE) {
|
||||
Logger.v(TAG, "send (opcode: ${opcode}, subOpcode: ${subOpcode}, data.remaining(): ${data.remaining()})")
|
||||
|
||||
var contentEncoding: ContentEncoding? = ce
|
||||
var processedData = data
|
||||
if (contentEncoding == ContentEncoding.Gzip) {
|
||||
val isGzipSupported = opcode == Opcode.DATA.value
|
||||
if (isGzipSupported) {
|
||||
val compressedStream = ByteArrayOutputStream()
|
||||
GZIPOutputStream(compressedStream).use { gzipStream ->
|
||||
gzipStream.write(data.array(), data.position(), data.remaining())
|
||||
gzipStream.finish()
|
||||
}
|
||||
processedData = ByteBuffer.wrap(compressedStream.toByteArray())
|
||||
} else {
|
||||
Logger.w(TAG, "Gzip requested but not supported on this (opcode = ${opcode}, subOpcode = ${subOpcode}), falling back.")
|
||||
contentEncoding = ContentEncoding.Raw
|
||||
}
|
||||
}
|
||||
|
||||
if (processedData.remaining() + HEADER_SIZE > MAXIMUM_PACKET_SIZE) {
|
||||
val segmentSize = MAXIMUM_PACKET_SIZE - HEADER_SIZE
|
||||
val segmentData = ByteArray(segmentSize)
|
||||
var sendOffset = 0
|
||||
val id = generateStreamId()
|
||||
|
||||
while (sendOffset < data.remaining()) {
|
||||
val bytesRemaining = data.remaining() - sendOffset
|
||||
while (sendOffset < processedData.remaining()) {
|
||||
val bytesRemaining = processedData.remaining() - sendOffset
|
||||
var bytesToSend: Int
|
||||
var segmentPacketSize: Int
|
||||
val streamOp: StreamOpcode
|
||||
|
||||
if (sendOffset == 0) {
|
||||
streamOp = StreamOpcode.START
|
||||
bytesToSend = segmentSize - 4 - 4 - 1 - 1
|
||||
segmentPacketSize = bytesToSend + 4 + 4 + 1 + 1
|
||||
bytesToSend = segmentSize - 4 - HEADER_SIZE
|
||||
segmentPacketSize = bytesToSend + 4 + HEADER_SIZE
|
||||
} else {
|
||||
bytesToSend = minOf(segmentSize - 4 - 4, bytesRemaining)
|
||||
streamOp = if (bytesToSend >= bytesRemaining) StreamOpcode.END else StreamOpcode.DATA
|
||||
|
@ -323,12 +421,13 @@ class SyncSocketSession {
|
|||
|
||||
ByteBuffer.wrap(segmentData).order(ByteOrder.LITTLE_ENDIAN).apply {
|
||||
putInt(id)
|
||||
putInt(if (streamOp == StreamOpcode.START) data.remaining() else sendOffset)
|
||||
putInt(if (streamOp == StreamOpcode.START) processedData.remaining() else sendOffset)
|
||||
if (streamOp == StreamOpcode.START) {
|
||||
put(opcode.toByte())
|
||||
put(subOpcode.toByte())
|
||||
put(contentEncoding?.value?.toByte() ?: ContentEncoding.Raw.value.toByte())
|
||||
}
|
||||
put(data.array(), data.position() + sendOffset, bytesToSend)
|
||||
put(processedData.array(), processedData.position() + sendOffset, bytesToSend)
|
||||
}
|
||||
|
||||
send(Opcode.STREAM.value, streamOp.value, ByteBuffer.wrap(segmentData, 0, segmentPacketSize))
|
||||
|
@ -337,17 +436,19 @@ class SyncSocketSession {
|
|||
} else {
|
||||
synchronized(_sendLockObject) {
|
||||
ByteBuffer.wrap(_sendBuffer).order(ByteOrder.LITTLE_ENDIAN).apply {
|
||||
putInt(data.remaining() + 2)
|
||||
putInt(processedData.remaining() + HEADER_SIZE - 4)
|
||||
put(opcode.toByte())
|
||||
put(subOpcode.toByte())
|
||||
put(data.array(), data.position(), data.remaining())
|
||||
put(contentEncoding?.value?.toByte() ?: ContentEncoding.Raw.value.toByte())
|
||||
put(processedData.array(), processedData.position(), processedData.remaining())
|
||||
}
|
||||
|
||||
//Logger.i(TAG, "Encrypting message (size = ${data.size + HEADER_SIZE})")
|
||||
val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 0, data.remaining() + HEADER_SIZE)
|
||||
//Logger.i(TAG, "Sending encrypted message (size = ${len})")
|
||||
_outputStream.writeInt(len)
|
||||
_outputStream.write(_sendBufferEncrypted, 0, len)
|
||||
val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 4, processedData.remaining() + HEADER_SIZE)
|
||||
val sendDuration = measureTimeMillis {
|
||||
ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len)
|
||||
_outputStream.write(_sendBufferEncrypted, 0, 4 + len)
|
||||
}
|
||||
Logger.v(TAG, "_outputStream.write (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.remaining(): ${processedData.remaining()}, sendDuration: ${sendDuration})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -357,17 +458,18 @@ class SyncSocketSession {
|
|||
ensureNotMainThread()
|
||||
|
||||
synchronized(_sendLockObject) {
|
||||
ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(2)
|
||||
ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(HEADER_SIZE - 4)
|
||||
_sendBuffer.asUByteArray()[4] = opcode
|
||||
_sendBuffer.asUByteArray()[5] = subOpcode
|
||||
_sendBuffer.asUByteArray()[6] = ContentEncoding.Raw.value
|
||||
|
||||
//Logger.i(TAG, "Encrypting message (opcode = ${opcode}, subOpcode = ${subOpcode}, size = ${HEADER_SIZE})")
|
||||
|
||||
val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 0, HEADER_SIZE)
|
||||
val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 4, HEADER_SIZE)
|
||||
//Logger.i(TAG, "Sending encrypted message (size = ${len})")
|
||||
|
||||
_outputStream.writeInt(len)
|
||||
_outputStream.write(_sendBufferEncrypted, 0, len)
|
||||
ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len)
|
||||
_outputStream.write(_sendBufferEncrypted, 0, 4 + len)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -378,7 +480,7 @@ class SyncSocketSession {
|
|||
private fun handleData(data: ByteBuffer, sourceChannel: ChannelRelayed?) {
|
||||
val length = data.remaining()
|
||||
if (length < HEADER_SIZE)
|
||||
throw Exception("Packet must be at least 6 bytes (header size)")
|
||||
throw Exception("Packet must be at least ${HEADER_SIZE} bytes (header size)")
|
||||
|
||||
val size = data.int
|
||||
if (size != length - 4)
|
||||
|
@ -386,7 +488,10 @@ class SyncSocketSession {
|
|||
|
||||
val opcode = data.get().toUByte()
|
||||
val subOpcode = data.get().toUByte()
|
||||
handlePacket(opcode, subOpcode, data, sourceChannel)
|
||||
val contentEncoding = data.get().toUByte()
|
||||
|
||||
//Logger.v(TAG, "handleData (opcode: ${opcode}, subOpcode: ${subOpcode}, data.size: ${data.remaining()}, sourceChannel.connectionId: ${sourceChannel?.connectionId})")
|
||||
handlePacket(opcode, subOpcode, data, contentEncoding, sourceChannel)
|
||||
}
|
||||
|
||||
private fun handleRequest(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) {
|
||||
|
@ -400,13 +505,14 @@ class SyncSocketSession {
|
|||
val remoteVersion = data.int
|
||||
val connectionId = data.long
|
||||
val requestId = data.int
|
||||
val appId = data.int.toUInt()
|
||||
val publicKeyBytes = ByteArray(32).also { data.get(it) }
|
||||
val pairingMessageLength = data.int
|
||||
if (pairingMessageLength > 128) throw IllegalArgumentException("Pairing message length ($pairingMessageLength) exceeds maximum (128)")
|
||||
if (pairingMessageLength > 128) throw IllegalArgumentException("Pairing message length ($pairingMessageLength) exceeds maximum (128) (app id: $appId)")
|
||||
val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { data.get(it) } else ByteArray(0)
|
||||
val channelMessageLength = data.int
|
||||
if (data.remaining() != channelMessageLength) {
|
||||
Logger.e(TAG, "Invalid packet size. Expected ${52 + pairingMessageLength + 4 + channelMessageLength}, got ${data.capacity()}")
|
||||
Logger.e(TAG, "Invalid packet size. Expected ${52 + pairingMessageLength + 4 + channelMessageLength}, got ${data.capacity()} (app id: $appId)")
|
||||
return
|
||||
}
|
||||
val channelHandshakeMessage = ByteArray(channelMessageLength).also { data.get(it) }
|
||||
|
@ -420,7 +526,7 @@ class SyncSocketSession {
|
|||
val length = pairingProtocol.readMessage(pairingMessage, 0, pairingMessageLength, plaintext, 0)
|
||||
String(plaintext, 0, length, Charsets.UTF_8)
|
||||
} else null
|
||||
val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode) ?: true)
|
||||
val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode, appId) ?: true)
|
||||
if (!isAllowed) {
|
||||
val rp = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN)
|
||||
rp.putInt(2) // Status code for not allowed
|
||||
|
@ -733,9 +839,51 @@ class SyncSocketSession {
|
|||
}
|
||||
}
|
||||
|
||||
private fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) {
|
||||
private fun startPingLoop() {
|
||||
if (remoteVersion < 5) return
|
||||
|
||||
_lastPongTime = System.currentTimeMillis()
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
while (_started) {
|
||||
delay(_pingInterval)
|
||||
|
||||
if (System.currentTimeMillis() - _lastPongTime > _disconnectTimeout) {
|
||||
Logger.e(TAG, "Session timed out waiting for PONG; closing.")
|
||||
stop()
|
||||
break
|
||||
}
|
||||
send(Opcode.PING.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Ping loop failed", e)
|
||||
stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePacket(opcode: UByte, subOpcode: UByte, d: ByteBuffer, contentEncoding: UByte, sourceChannel: ChannelRelayed?) {
|
||||
Logger.i(TAG, "Handle packet (opcode = ${opcode}, subOpcode = ${subOpcode})")
|
||||
|
||||
var data = d
|
||||
if (contentEncoding == ContentEncoding.Gzip.value) {
|
||||
val isGzipSupported = opcode == Opcode.DATA.value
|
||||
if (!isGzipSupported)
|
||||
throw Exception("Failed to handle packet, gzip is not supported for this opcode (opcode = ${opcode}, subOpcode = ${subOpcode}, data.length = ${data.remaining()}).")
|
||||
|
||||
val compressedStream = ByteArrayInputStream(data.array(), data.position(), data.remaining())
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
GZIPInputStream(compressedStream).use { gzipStream ->
|
||||
val buffer = ByteArray(8192) // 8KB buffer
|
||||
var bytesRead: Int
|
||||
while (gzipStream.read(buffer).also { bytesRead = it } != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
}
|
||||
}
|
||||
data = ByteBuffer.wrap(outputStream.toByteArray())
|
||||
}
|
||||
|
||||
when (opcode) {
|
||||
Opcode.PING.value -> {
|
||||
if (sourceChannel != null)
|
||||
|
@ -746,6 +894,11 @@ class SyncSocketSession {
|
|||
return
|
||||
}
|
||||
Opcode.PONG.value -> {
|
||||
if (sourceChannel != null) {
|
||||
sourceChannel.invokeDataHandler(opcode, subOpcode, data)
|
||||
} else {
|
||||
_lastPongTime = System.currentTimeMillis()
|
||||
}
|
||||
Logger.v(TAG, "Received pong")
|
||||
return
|
||||
}
|
||||
|
@ -773,8 +926,9 @@ class SyncSocketSession {
|
|||
val expectedSize = data.int
|
||||
val op = data.get().toUByte()
|
||||
val subOp = data.get().toUByte()
|
||||
val ce = data.get().toUByte()
|
||||
|
||||
val syncStream = SyncStream(expectedSize, op, subOp)
|
||||
val syncStream = SyncStream(expectedSize, op, subOp, ce)
|
||||
if (data.remaining() > 0) {
|
||||
syncStream.add(data.array(), data.position(), data.remaining())
|
||||
}
|
||||
|
@ -819,7 +973,7 @@ class SyncSocketSession {
|
|||
throw Exception("After sync stream end, the stream must be complete")
|
||||
}
|
||||
|
||||
handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }, sourceChannel)
|
||||
handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }, syncStream.contentEncoding, sourceChannel)
|
||||
}
|
||||
}
|
||||
Opcode.DATA.value -> {
|
||||
|
@ -876,14 +1030,14 @@ class SyncSocketSession {
|
|||
return deferred.await()
|
||||
}
|
||||
|
||||
suspend fun startRelayedChannel(publicKey: String, pairingCode: String? = null): ChannelRelayed? {
|
||||
suspend fun startRelayedChannel(publicKey: String, appId: UInt = 0u, pairingCode: String? = null): ChannelRelayed? {
|
||||
val requestId = generateRequestId()
|
||||
val deferred = CompletableDeferred<ChannelRelayed>()
|
||||
val channel = ChannelRelayed(this, _localKeyPair, publicKey, true)
|
||||
val channel = ChannelRelayed(this, _localKeyPair, publicKey.base64ToByteArray().toBase64(), true)
|
||||
_onNewChannel?.invoke(this, channel)
|
||||
_pendingChannels[requestId] = channel to deferred
|
||||
try {
|
||||
channel.sendRequestTransport(requestId, publicKey, pairingCode)
|
||||
channel.sendRequestTransport(requestId, publicKey, appId, pairingCode)
|
||||
} catch (e: Exception) {
|
||||
_pendingChannels.remove(requestId)?.let { it.first.close(); it.second.completeExceptionally(e) }
|
||||
throw e
|
||||
|
@ -999,7 +1153,7 @@ class SyncSocketSession {
|
|||
send(Opcode.NOTIFY.value, NotifyOpcode.CONNECTION_INFO.value, publishBytes)
|
||||
}
|
||||
|
||||
suspend fun publishRecords(consumerPublicKeys: List<String>, key: String, data: ByteArray): Boolean {
|
||||
suspend fun publishRecords(consumerPublicKeys: List<String>, key: String, data: ByteArray, contentEncoding: ContentEncoding? = null): Boolean {
|
||||
val keyBytes = key.toByteArray(Charsets.UTF_8)
|
||||
if (key.isEmpty() || keyBytes.size > 32) throw IllegalArgumentException("Key must be 1-32 bytes")
|
||||
if (consumerPublicKeys.isEmpty()) throw IllegalArgumentException("At least one consumer required")
|
||||
|
@ -1054,7 +1208,7 @@ class SyncSocketSession {
|
|||
}
|
||||
}
|
||||
packet.rewind()
|
||||
send(Opcode.REQUEST.value, RequestOpcode.BULK_PUBLISH_RECORD.value, packet)
|
||||
send(Opcode.REQUEST.value, RequestOpcode.BULK_PUBLISH_RECORD.value, packet, ce = contentEncoding)
|
||||
} catch (e: Exception) {
|
||||
_pendingPublishRequests.remove(requestId)?.completeExceptionally(e)
|
||||
throw e
|
||||
|
@ -1174,6 +1328,6 @@ class SyncSocketSession {
|
|||
private const val TAG = "SyncSocketSession"
|
||||
const val MAXIMUM_PACKET_SIZE = 65535 - 16
|
||||
const val MAXIMUM_PACKET_SIZE_ENCRYPTED = MAXIMUM_PACKET_SIZE + 16
|
||||
const val HEADER_SIZE = 6
|
||||
const val HEADER_SIZE = 7
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
package com.futo.platformplayer.sync.internal
|
||||
|
||||
class SyncStream(expectedSize: Int, val opcode: UByte, val subOpcode: UByte) {
|
||||
class SyncStream(expectedSize: Int, val opcode: UByte, val subOpcode: UByte, val contentEncoding: UByte) {
|
||||
companion object {
|
||||
const val MAXIMUM_SIZE = 10_000_000
|
||||
}
|
||||
|
|
|
@ -45,6 +45,10 @@ open class ChannelView : LinearLayout {
|
|||
_buttonSubscribe = findViewById(R.id.button_subscribe);
|
||||
_platformIndicator = findViewById(R.id.platform_indicator);
|
||||
|
||||
//_textName.setOnClickListener { currentChannel?.let { onClick.emit(it) }; }
|
||||
//_creatorThumbnail.setOnClickListener { currentChannel?.let { onClick.emit(it) }; }
|
||||
//_textMetadata.setOnClickListener { currentChannel?.let { onClick.emit(it) }; }
|
||||
|
||||
if (_tiny) {
|
||||
_buttonSubscribe.visibility = View.GONE;
|
||||
_textMetadata.visibility = View.GONE;
|
||||
|
@ -66,8 +70,11 @@ open class ChannelView : LinearLayout {
|
|||
open fun bind(content: IPlatformContent) {
|
||||
isClickable = true;
|
||||
|
||||
if(content !is IPlatformChannelContent)
|
||||
return
|
||||
if(content !is IPlatformChannelContent) {
|
||||
currentChannel = null;
|
||||
return;
|
||||
}
|
||||
currentChannel = content;
|
||||
|
||||
_creatorThumbnail.setThumbnail(content.thumbnail, false);
|
||||
_textName.text = content.name;
|
||||
|
|
|
@ -7,16 +7,16 @@ import com.futo.platformplayer.R
|
|||
import com.futo.platformplayer.casting.CastingDevice
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
|
||||
class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
|
||||
private val _devices: ArrayList<CastingDevice>;
|
||||
private val _isRememberedDevice: Boolean;
|
||||
data class DeviceAdapterEntry(val castingDevice: CastingDevice, val isPinnedDevice: Boolean, val isOnlineDevice: Boolean)
|
||||
|
||||
var onRemove = Event1<CastingDevice>();
|
||||
class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
|
||||
private val _devices: List<DeviceAdapterEntry>;
|
||||
|
||||
var onPin = Event1<CastingDevice>();
|
||||
var onConnect = Event1<CastingDevice>();
|
||||
|
||||
constructor(devices: ArrayList<CastingDevice>, isRememberedDevice: Boolean) : super() {
|
||||
constructor(devices: List<DeviceAdapterEntry>) : super() {
|
||||
_devices = devices;
|
||||
_isRememberedDevice = isRememberedDevice;
|
||||
}
|
||||
|
||||
override fun getItemCount() = _devices.size;
|
||||
|
@ -24,13 +24,13 @@ class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
|
|||
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): DeviceViewHolder {
|
||||
val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_device, viewGroup, false);
|
||||
val holder = DeviceViewHolder(view);
|
||||
holder.setIsRememberedDevice(_isRememberedDevice);
|
||||
holder.onRemove.subscribe { d -> onRemove.emit(d); };
|
||||
holder.onPin.subscribe { d -> onPin.emit(d); };
|
||||
holder.onConnect.subscribe { d -> onConnect.emit(d); }
|
||||
return holder;
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(viewHolder: DeviceViewHolder, position: Int) {
|
||||
viewHolder.bind(_devices[position]);
|
||||
val p = _devices[position];
|
||||
viewHolder.bind(p.castingDevice, p.isOnlineDevice, p.isPinnedDevice);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,9 +2,11 @@ package com.futo.platformplayer.views.adapters
|
|||
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.casting.AirPlayCastingDevice
|
||||
|
@ -14,70 +16,71 @@ import com.futo.platformplayer.casting.ChromecastCastingDevice
|
|||
import com.futo.platformplayer.casting.FCastCastingDevice
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import androidx.core.view.isVisible
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
|
||||
class DeviceViewHolder : ViewHolder {
|
||||
private val _layoutDevice: FrameLayout;
|
||||
private val _imageDevice: ImageView;
|
||||
private val _textName: TextView;
|
||||
private val _textType: TextView;
|
||||
private val _textNotReady: TextView;
|
||||
private val _buttonDisconnect: LinearLayout;
|
||||
private val _buttonConnect: LinearLayout;
|
||||
private val _buttonRemove: LinearLayout;
|
||||
private val _imageLoader: ImageView;
|
||||
private val _imageOnline: ImageView;
|
||||
private val _root: ConstraintLayout;
|
||||
private var _animatableLoader: Animatable? = null;
|
||||
private var _isRememberedDevice: Boolean = false;
|
||||
private var _imagePin: ImageView;
|
||||
|
||||
var device: CastingDevice? = null
|
||||
private set
|
||||
|
||||
var onRemove = Event1<CastingDevice>();
|
||||
var onPin = Event1<CastingDevice>();
|
||||
val onConnect = Event1<CastingDevice>();
|
||||
|
||||
constructor(view: View) : super(view) {
|
||||
_root = view.findViewById(R.id.layout_root);
|
||||
_layoutDevice = view.findViewById(R.id.layout_device);
|
||||
_imageDevice = view.findViewById(R.id.image_device);
|
||||
_textName = view.findViewById(R.id.text_name);
|
||||
_textType = view.findViewById(R.id.text_type);
|
||||
_textNotReady = view.findViewById(R.id.text_not_ready);
|
||||
_buttonDisconnect = view.findViewById(R.id.button_disconnect);
|
||||
_buttonConnect = view.findViewById(R.id.button_connect);
|
||||
_buttonRemove = view.findViewById(R.id.button_remove);
|
||||
_imageLoader = view.findViewById(R.id.image_loader);
|
||||
_imageOnline = view.findViewById(R.id.image_online);
|
||||
_imagePin = view.findViewById(R.id.image_pin);
|
||||
|
||||
val d = _imageLoader.drawable;
|
||||
if (d is Animatable) {
|
||||
_animatableLoader = d;
|
||||
}
|
||||
|
||||
_buttonDisconnect.setOnClickListener {
|
||||
StateCasting.instance.activeDevice?.stopCasting();
|
||||
updateButton();
|
||||
};
|
||||
|
||||
_buttonConnect.setOnClickListener {
|
||||
val dev = device ?: return@setOnClickListener;
|
||||
StateCasting.instance.activeDevice?.stopCasting();
|
||||
StateCasting.instance.connectDevice(dev);
|
||||
onConnect.emit(dev);
|
||||
};
|
||||
|
||||
_buttonRemove.setOnClickListener {
|
||||
val dev = device ?: return@setOnClickListener;
|
||||
onRemove.emit(dev);
|
||||
};
|
||||
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ ->
|
||||
updateButton();
|
||||
val connect = {
|
||||
device?.let { dev ->
|
||||
if (dev.isReady) {
|
||||
StateCasting.instance.activeDevice?.stopCasting()
|
||||
StateCasting.instance.connectDevice(dev)
|
||||
onConnect.emit(dev)
|
||||
} else {
|
||||
try {
|
||||
view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") }
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsRememberedDevice(false);
|
||||
_textName.setOnClickListener { connect() };
|
||||
_textType.setOnClickListener { connect() };
|
||||
_layoutDevice.setOnClickListener { connect() };
|
||||
|
||||
_imagePin.setOnClickListener {
|
||||
val dev = device ?: return@setOnClickListener;
|
||||
onPin.emit(dev);
|
||||
}
|
||||
}
|
||||
|
||||
fun setIsRememberedDevice(isRememberedDevice: Boolean) {
|
||||
_isRememberedDevice = isRememberedDevice;
|
||||
_buttonRemove.visibility = if (isRememberedDevice) View.VISIBLE else View.GONE;
|
||||
}
|
||||
|
||||
fun bind(d: CastingDevice) {
|
||||
fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) {
|
||||
if (d is ChromecastCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
||||
_textType.text = "Chromecast";
|
||||
|
@ -90,54 +93,47 @@ class DeviceViewHolder : ViewHolder {
|
|||
}
|
||||
|
||||
_textName.text = d.name;
|
||||
device = d;
|
||||
updateButton();
|
||||
}
|
||||
|
||||
private fun updateButton() {
|
||||
val d = device ?: return;
|
||||
_imageOnline.visibility = if (isOnlineDevice && d.isReady) View.VISIBLE else View.GONE
|
||||
|
||||
if (!d.isReady) {
|
||||
_buttonConnect.visibility = View.GONE;
|
||||
_buttonDisconnect.visibility = View.GONE;
|
||||
_imageLoader.visibility = View.GONE;
|
||||
_textNotReady.visibility = View.VISIBLE;
|
||||
return;
|
||||
}
|
||||
|
||||
_textNotReady.visibility = View.GONE;
|
||||
|
||||
val dev = StateCasting.instance.activeDevice;
|
||||
if (dev == d) {
|
||||
if (dev.connectionState == CastConnectionState.CONNECTED) {
|
||||
_buttonConnect.visibility = View.GONE;
|
||||
_buttonDisconnect.visibility = View.VISIBLE;
|
||||
_imageLoader.visibility = View.GONE;
|
||||
_textNotReady.visibility = View.GONE;
|
||||
} else {
|
||||
_buttonConnect.visibility = View.GONE;
|
||||
_buttonDisconnect.visibility = View.VISIBLE;
|
||||
_imageLoader.visibility = View.VISIBLE;
|
||||
_textNotReady.visibility = View.GONE;
|
||||
}
|
||||
_imagePin.visibility = View.GONE;
|
||||
} else {
|
||||
if (d.isReady) {
|
||||
_buttonConnect.visibility = View.VISIBLE;
|
||||
_buttonDisconnect.visibility = View.GONE;
|
||||
_imageLoader.visibility = View.GONE;
|
||||
_textNotReady.visibility = View.GONE;
|
||||
_textNotReady.visibility = View.GONE;
|
||||
|
||||
val dev = StateCasting.instance.activeDevice;
|
||||
if (dev == d) {
|
||||
if (dev.connectionState == CastConnectionState.CONNECTED) {
|
||||
_imageLoader.visibility = View.GONE;
|
||||
_textNotReady.visibility = View.GONE;
|
||||
_imagePin.visibility = View.VISIBLE;
|
||||
} else {
|
||||
_imageLoader.visibility = View.VISIBLE;
|
||||
_textNotReady.visibility = View.GONE;
|
||||
_imagePin.visibility = View.VISIBLE;
|
||||
}
|
||||
} else {
|
||||
_buttonConnect.visibility = View.GONE;
|
||||
_buttonDisconnect.visibility = View.GONE;
|
||||
_imageLoader.visibility = View.GONE;
|
||||
_textNotReady.visibility = View.VISIBLE;
|
||||
if (d.isReady) {
|
||||
_imageLoader.visibility = View.GONE;
|
||||
_textNotReady.visibility = View.GONE;
|
||||
_imagePin.visibility = View.VISIBLE;
|
||||
} else {
|
||||
_imageLoader.visibility = View.GONE;
|
||||
_textNotReady.visibility = View.VISIBLE;
|
||||
_imagePin.visibility = View.VISIBLE;
|
||||
}
|
||||
}
|
||||
|
||||
_imagePin.setImageResource(if (isPinnedDevice) R.drawable.keep_24px else R.drawable.ic_pin)
|
||||
|
||||
if (_imageLoader.isVisible) {
|
||||
_animatableLoader?.start();
|
||||
} else {
|
||||
_animatableLoader?.stop();
|
||||
}
|
||||
}
|
||||
|
||||
if (_imageLoader.visibility == View.VISIBLE) {
|
||||
_animatableLoader?.start();
|
||||
} else {
|
||||
_animatableLoader?.stop();
|
||||
}
|
||||
device = d;
|
||||
}
|
||||
}
|
|
@ -14,9 +14,11 @@ import com.futo.platformplayer.R
|
|||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.models.HistoryVideo
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.toHumanNumber
|
||||
import com.futo.platformplayer.toHumanTime
|
||||
import com.futo.platformplayer.views.others.ProgressBar
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
|
||||
class HistoryListViewHolder : ViewHolder {
|
||||
private val _root: ConstraintLayout;
|
||||
|
@ -30,6 +32,7 @@ class HistoryListViewHolder : ViewHolder {
|
|||
private val _imageRemove: ImageButton;
|
||||
private val _textHeader: TextView;
|
||||
private val _timeBar: ProgressBar;
|
||||
private val _thumbnailPlatform: PlatformIndicator
|
||||
|
||||
var video: HistoryVideo? = null
|
||||
private set;
|
||||
|
@ -47,6 +50,7 @@ class HistoryListViewHolder : ViewHolder {
|
|||
_textVideoDuration = itemView.findViewById(R.id.thumbnail_duration);
|
||||
_containerDuration = itemView.findViewById(R.id.thumbnail_duration_container);
|
||||
_containerLive = itemView.findViewById(R.id.thumbnail_live_container);
|
||||
_thumbnailPlatform = itemView.findViewById(R.id.thumbnail_platform)
|
||||
_imageRemove = itemView.findViewById(R.id.image_trash);
|
||||
_textHeader = itemView.findViewById(R.id.text_header);
|
||||
_timeBar = itemView.findViewById(R.id.time_bar);
|
||||
|
@ -73,6 +77,9 @@ class HistoryListViewHolder : ViewHolder {
|
|||
_textAuthor.text = v.video.author.name;
|
||||
_textVideoDuration.text = v.video.duration.toHumanTime(false);
|
||||
|
||||
val pluginId = v.video.id.pluginId ?: StatePlatform.instance.getContentClientOrNull(v.video.url)?.id
|
||||
_thumbnailPlatform.setPlatformFromClientID(pluginId)
|
||||
|
||||
if(v.video.isLive) {
|
||||
_containerDuration.visibility = View.GONE;
|
||||
_containerLive.visibility = View.VISIBLE;
|
||||
|
|
|
@ -37,9 +37,10 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
|
|||
_onDatasetChanged = onDatasetChanged;
|
||||
|
||||
StateSubscriptions.instance.onSubscriptionsChanged.subscribe { _, _ -> if(Looper.myLooper() != Looper.getMainLooper())
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { updateDataset() }
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { updateDataset() }
|
||||
else
|
||||
updateDataset(); }
|
||||
updateDataset();
|
||||
}
|
||||
updateDataset();
|
||||
}
|
||||
|
||||
|
|
|
@ -17,9 +17,11 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
|||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.toHumanNowDiffString
|
||||
import com.futo.platformplayer.toHumanNumber
|
||||
import com.futo.platformplayer.toHumanTime
|
||||
import com.futo.platformplayer.views.others.ProgressBar
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
|
||||
class VideoListEditorViewHolder : ViewHolder {
|
||||
|
@ -36,6 +38,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
|||
private val _imageDragDrop: ImageButton;
|
||||
private val _platformIndicator: PlatformIndicator;
|
||||
private val _layoutDownloaded: FrameLayout;
|
||||
private val _timeBar: ProgressBar
|
||||
|
||||
var video: IPlatformVideo? = null
|
||||
private set;
|
||||
|
@ -59,6 +62,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
|||
_imageOptions = view.findViewById(R.id.image_settings);
|
||||
_imageDragDrop = view.findViewById<ImageButton>(R.id.image_drag_drop);
|
||||
_platformIndicator = view.findViewById(R.id.thumbnail_platform);
|
||||
_timeBar = view.findViewById(R.id.time_bar);
|
||||
_layoutDownloaded = view.findViewById(R.id.layout_downloaded);
|
||||
|
||||
_imageDragDrop.setOnTouchListener { _, event ->
|
||||
|
@ -93,6 +97,9 @@ class VideoListEditorViewHolder : ViewHolder {
|
|||
_textAuthor.text = v.author.name;
|
||||
_textVideoDuration.text = v.duration.toHumanTime(false);
|
||||
|
||||
val historyPosition = StateHistory.instance.getHistoryPosition(v.url)
|
||||
_timeBar.progress = historyPosition.toFloat() / v.duration.toFloat();
|
||||
|
||||
if(v.isLive) {
|
||||
_containerDuration.visibility = View.GONE;
|
||||
_containerLive.visibility = View.VISIBLE;
|
||||
|
|
|
@ -628,12 +628,12 @@ class GestureControlView : LinearLayout {
|
|||
private fun fastForwardTick() {
|
||||
_fastForwardCounter++;
|
||||
|
||||
val seekOffset: Long = 10000;
|
||||
val seekOffset: Long = Settings.instance.playback.getSeekOffset();
|
||||
if (_rewinding) {
|
||||
_textRewind.text = "${_fastForwardCounter * 10} " + context.getString(R.string.seconds);
|
||||
_textRewind.text = "${_fastForwardCounter * seekOffset / 1_000} " + context.getString(R.string.seconds);
|
||||
onSeek.emit(-seekOffset);
|
||||
} else {
|
||||
_textFastForward.text = "${_fastForwardCounter * 10} " + context.getString(R.string.seconds);
|
||||
_textFastForward.text = "${_fastForwardCounter * seekOffset / 1_000} " + context.getString(R.string.seconds);
|
||||
onSeek.emit(seekOffset);
|
||||
}
|
||||
}
|
||||
|
@ -735,24 +735,43 @@ class GestureControlView : LinearLayout {
|
|||
_animatorBrightness?.start();
|
||||
}
|
||||
|
||||
fun saveBrightness() {
|
||||
try {
|
||||
_originalBrightnessMode = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE)
|
||||
|
||||
val brightness = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS)
|
||||
_brightnessFactor = brightness / 255.0f;
|
||||
Log.i(TAG, "Starting brightness brightness: $brightness, _brightnessFactor: $_brightnessFactor, _originalBrightnessMode: $_originalBrightnessMode")
|
||||
|
||||
_originalBrightnessFactor = _brightnessFactor
|
||||
} catch (e: Throwable) {
|
||||
Settings.instance.gestureControls.useSystemBrightness = false
|
||||
Settings.instance.save()
|
||||
UIDialogs.toast(context, "useSystemBrightness disabled due to an error")
|
||||
}
|
||||
}
|
||||
fun restoreBrightness() {
|
||||
if (Settings.instance.gestureControls.restoreSystemBrightness) {
|
||||
onBrightnessAdjusted.emit(_originalBrightnessFactor)
|
||||
|
||||
if (android.provider.Settings.System.canWrite(context)) {
|
||||
Log.i(TAG, "Restoring system brightness mode _originalBrightnessMode: $_originalBrightnessMode")
|
||||
|
||||
android.provider.Settings.System.putInt(
|
||||
context.contentResolver,
|
||||
android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE,
|
||||
_originalBrightnessMode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setFullscreen(isFullScreen: Boolean) {
|
||||
resetZoomPan()
|
||||
|
||||
if (isFullScreen) {
|
||||
if (Settings.instance.gestureControls.useSystemBrightness) {
|
||||
try {
|
||||
_originalBrightnessMode = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE)
|
||||
|
||||
val brightness = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS)
|
||||
_brightnessFactor = brightness / 255.0f;
|
||||
Log.i(TAG, "Starting brightness brightness: $brightness, _brightnessFactor: $_brightnessFactor, _originalBrightnessMode: $_originalBrightnessMode")
|
||||
|
||||
_originalBrightnessFactor = _brightnessFactor
|
||||
} catch (e: Throwable) {
|
||||
Settings.instance.gestureControls.useSystemBrightness = false
|
||||
Settings.instance.save()
|
||||
UIDialogs.toast(context, "useSystemBrightness disabled due to an error")
|
||||
}
|
||||
saveBrightness()
|
||||
}
|
||||
|
||||
if (Settings.instance.gestureControls.useSystemVolume) {
|
||||
|
@ -766,19 +785,7 @@ class GestureControlView : LinearLayout {
|
|||
onSoundAdjusted.emit(_soundFactor);
|
||||
} else {
|
||||
if (Settings.instance.gestureControls.useSystemBrightness) {
|
||||
if (Settings.instance.gestureControls.restoreSystemBrightness) {
|
||||
onBrightnessAdjusted.emit(_originalBrightnessFactor)
|
||||
|
||||
if (android.provider.Settings.System.canWrite(context)) {
|
||||
Log.i(TAG, "Restoring system brightness mode _originalBrightnessMode: $_originalBrightnessMode")
|
||||
|
||||
android.provider.Settings.System.putInt(
|
||||
context.contentResolver,
|
||||
android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE,
|
||||
_originalBrightnessMode
|
||||
)
|
||||
}
|
||||
}
|
||||
restoreBrightness()
|
||||
} else {
|
||||
onBrightnessAdjusted.emit(1.0f);
|
||||
}
|
||||
|
|
|
@ -13,6 +13,17 @@ class RadioGroupView : FlexboxLayout {
|
|||
|
||||
val selectedOptions = arrayListOf<Any?>();
|
||||
val onSelectedChange = Event1<List<Any?>>();
|
||||
constructor(context: Context) : super(context) {
|
||||
flexWrap = FlexWrap.WRAP;
|
||||
_padding_px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, _padding_dp, context.resources.displayMetrics).toInt();
|
||||
|
||||
if (isInEditMode) {
|
||||
setOptions(listOf("Example 1" to 1, "Example 2" to 2, "Example 3" to 3, "Example 4" to 4, "Example 5" to 5), listOf("Example 1", "Example 2"),
|
||||
multiSelect = true,
|
||||
atLeastOne = false
|
||||
);
|
||||
}
|
||||
}
|
||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
|
||||
flexWrap = FlexWrap.WRAP;
|
||||
_padding_px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, _padding_dp, context.resources.displayMetrics).toInt();
|
||||
|
|
|
@ -22,4 +22,15 @@ class PlatformIndicator : androidx.appcompat.widget.AppCompatImageView {
|
|||
setImageResource(0);
|
||||
}
|
||||
}
|
||||
fun setPlatformFromClientName(name: String?) {
|
||||
if(name == null)
|
||||
setImageResource(0);
|
||||
else {
|
||||
val result = StatePlatform.instance.getPlatformIconByName(name);
|
||||
if (result != null)
|
||||
result.setImageView(this);
|
||||
else
|
||||
setImageResource(0);
|
||||
}
|
||||
}
|
||||
}
|
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>
|
|
@ -5,31 +5,54 @@
|
|||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/gray_1d">
|
||||
android:background="#101010">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_devices"
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/discovered_devices"
|
||||
android:layout_marginStart="20dp"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular" />
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_loader"
|
||||
android:layout_width="22dp"
|
||||
android:layout_height="22dp"
|
||||
android:scaleType="fitCenter"
|
||||
app:srcCompat="@drawable/ic_loader_animated"
|
||||
android:layout_marginStart="5dp"/>
|
||||
<TextView
|
||||
android:id="@+id/text_devices"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/discovered_devices"
|
||||
android:layout_marginStart="20dp"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/available_devices"
|
||||
android:layout_marginStart="20dp"
|
||||
android:textSize="11dp"
|
||||
android:textColor="@color/gray_ac"
|
||||
android:fontFamily="@font/inter_medium" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_loader"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:scaleType="fitCenter"
|
||||
app:srcCompat="@drawable/ic_loader_animated"
|
||||
android:layout_marginStart="5dp"/>
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<Space android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
|
@ -38,7 +61,7 @@
|
|||
<Button
|
||||
android:id="@+id/button_close"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:text="@string/close"
|
||||
android:textSize="14dp"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
|
@ -67,79 +90,102 @@
|
|||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_devices"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="100dp"
|
||||
android:layout_height="200dp"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp" />
|
||||
android:layout_marginEnd="20dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginBottom="20dp"/>
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1px"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:background="@color/gray_ac" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_remembered_devices"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/unable_to_see_the_Device_youre_looking_for_try_add_the_device_manually"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:textSize="9dp"
|
||||
android:ellipsize="end"
|
||||
android:textColor="@color/gray_c3"
|
||||
android:maxLines="3"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:layout_marginTop="12dp"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_remembered_devices_header"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_remembered_devices"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="3"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/remembered_devices"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:textSize="14dp"
|
||||
android:ellipsize="end"
|
||||
android:textColor="@color/white"
|
||||
android:maxLines="1"
|
||||
android:fontFamily="@font/inter_regular" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_scan_qr"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@string/cd_button_scan_qr"
|
||||
android:scaleType="centerCrop"
|
||||
app:srcCompat="@drawable/ic_qr"
|
||||
app:tint="@color/primary" />
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp">
|
||||
|
||||
<Space android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<ImageButton
|
||||
<LinearLayout
|
||||
android:id="@+id/button_add"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@string/cd_button_add"
|
||||
android:scaleType="centerCrop"
|
||||
app:srcCompat="@drawable/ic_add"
|
||||
app:tint="@color/primary"
|
||||
android:layout_marginEnd="20dp"/>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_remembered_devices"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_no_devices_remembered"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="10dp"
|
||||
android:text="@string/there_are_no_remembered_devices"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:layout_marginStart="20dp"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/background_border_2e_round_6dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:textColor="@color/gray_e0" />
|
||||
android:gravity="center">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_remembered_devices"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="100dp"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp" />
|
||||
<ImageView
|
||||
android:layout_width="22dp"
|
||||
android:layout_height="22dp"
|
||||
app:srcCompat="@drawable/ic_add"
|
||||
android:layout_marginStart="8dp"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/add_manually"
|
||||
android:textSize="12dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_medium"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="12dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/button_qr"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/background_border_2e_round_6dp"
|
||||
android:gravity="center">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="22dp"
|
||||
android:layout_height="22dp"
|
||||
app:srcCompat="@drawable/ic_qr"
|
||||
android:layout_marginStart="8dp"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/scan_qr"
|
||||
android:textSize="12dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_medium"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="12dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
|
@ -97,27 +97,7 @@
|
|||
app:layout_constraintTop_toBottomOf="@id/text_name"
|
||||
app:layout_constraintLeft_toRightOf="@id/image_device" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/button_disconnect"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/background_button_accent"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:text="@string/stop" />
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
|
@ -253,4 +233,30 @@
|
|||
android:gravity="center_vertical"
|
||||
android:paddingBottom="15dp">
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/button_disconnect"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="35dp"
|
||||
android:background="@drawable/background_button_accent"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:text="@string/stop_casting" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
|
@ -76,7 +76,7 @@
|
|||
android:id="@+id/button_buy_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="$9.99 + Tax"
|
||||
android:text="$19 + Tax"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
|
|
|
@ -94,6 +94,25 @@
|
|||
android:id="@+id/tags_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_filters"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/filters"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:textSize="16dp"
|
||||
android:textColor="@color/white"
|
||||
android:paddingStart="5dp"
|
||||
android:paddingTop="15dp"
|
||||
android:paddingBottom="8dp" />
|
||||
|
||||
<com.futo.platformplayer.views.ToggleBar
|
||||
android:id="@+id/toggle_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
</LinearLayout>
|
||||
</androidx.appcompat.widget.Toolbar>
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
|
|
@ -40,8 +40,6 @@
|
|||
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>
|
||||
|
|
|
@ -4,18 +4,34 @@
|
|||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="35dp"
|
||||
android:clickable="true">
|
||||
android:clickable="true"
|
||||
android:id="@+id/layout_root">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_device"
|
||||
<FrameLayout
|
||||
android:id="@+id/layout_device"
|
||||
android:layout_width="25dp"
|
||||
android:layout_height="25dp"
|
||||
android:contentDescription="@string/cd_image_device"
|
||||
app:srcCompat="@drawable/ic_chromecast"
|
||||
android:scaleType="fitCenter"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"/>
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_device"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/cd_image_device"
|
||||
app:srcCompat="@drawable/ic_chromecast"
|
||||
android:scaleType="fitCenter" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_online"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:layout_gravity="end|top"
|
||||
android:contentDescription="@string/cd_image_device"
|
||||
app:srcCompat="@drawable/ic_online"
|
||||
android:scaleType="fitCenter" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_name"
|
||||
|
@ -31,8 +47,8 @@
|
|||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:includeFontPadding="false"
|
||||
app:layout_constraintTop_toTopOf="@id/image_device"
|
||||
app:layout_constraintLeft_toRightOf="@id/image_device"
|
||||
app:layout_constraintTop_toTopOf="@id/layout_device"
|
||||
app:layout_constraintLeft_toRightOf="@id/layout_device"
|
||||
app:layout_constraintRight_toLeftOf="@id/layout_button" />
|
||||
|
||||
<TextView
|
||||
|
@ -43,12 +59,12 @@
|
|||
tools:text="Chromecast"
|
||||
android:textSize="10dp"
|
||||
android:fontFamily="@font/inter_extra_light"
|
||||
android:textColor="@color/white"
|
||||
android:textColor="@color/gray_ac"
|
||||
android:includeFontPadding="false"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="10dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_name"
|
||||
app:layout_constraintLeft_toRightOf="@id/image_device"
|
||||
app:layout_constraintLeft_toRightOf="@id/layout_device"
|
||||
app:layout_constraintRight_toLeftOf="@id/layout_button" />
|
||||
|
||||
<LinearLayout
|
||||
|
@ -68,74 +84,15 @@
|
|||
app:srcCompat="@drawable/ic_loader_animated"
|
||||
android:layout_marginEnd="8dp"/>
|
||||
|
||||
<LinearLayout
|
||||
<ImageView
|
||||
android:id="@+id/image_pin"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/button_remove"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/background_button_accent"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:layout_marginEnd="7dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:text="@string/remove" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/button_disconnect"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/background_button_accent"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:text="@string/stop" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/button_connect"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/background_button_primary"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:text="@string/start" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
android:layout_height="25dp"
|
||||
android:contentDescription="@string/cd_image_loader"
|
||||
app:srcCompat="@drawable/ic_pin"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:scaleType="fitEnd"
|
||||
android:paddingStart="10dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_not_ready"
|
||||
|
|
|
@ -117,6 +117,15 @@
|
|||
app:radiusBottomRight="4dp"
|
||||
app:radiusTopLeft="0dp"
|
||||
app:radiusTopRight="0dp" />
|
||||
|
||||
<com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
android:id="@+id/thumbnail_platform"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:contentDescription="@string/cd_platform_indicator"
|
||||
android:layout_gravity="bottom|start"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginBottom="4dp" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
|
|
|
@ -41,6 +41,19 @@
|
|||
app:srcCompat="@drawable/placeholder_video_thumbnail"
|
||||
android:background="@drawable/video_thumbnail_outline" />
|
||||
|
||||
<com.futo.platformplayer.views.others.ProgressBar
|
||||
android:id="@+id/time_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="2dp"
|
||||
android:layout_gravity="bottom"
|
||||
app:progress="60%"
|
||||
app:inactiveColor="#55EEEEEE"
|
||||
android:layout_marginBottom="0dp"
|
||||
app:radiusBottomLeft="4dp"
|
||||
app:radiusBottomRight="4dp"
|
||||
app:radiusTopLeft="0dp"
|
||||
app:radiusTopRight="0dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/thumbnail_live_container"
|
||||
android:layout_width="wrap_content"
|
||||
|
@ -49,7 +62,7 @@
|
|||
android:paddingStart="2dp"
|
||||
android:paddingEnd="2dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:layout_marginBottom="6dp"
|
||||
android:paddingTop="0dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
|
@ -77,7 +90,7 @@
|
|||
android:paddingStart="2dp"
|
||||
android:paddingEnd="2dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:layout_marginBottom="6dp"
|
||||
android:paddingTop="0dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
|
@ -103,7 +116,7 @@
|
|||
android:layout_height="20dp"
|
||||
android:layout_gravity="bottom|start"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginBottom="4dp" />
|
||||
android:layout_marginBottom="6dp" />
|
||||
|
||||
<FrameLayout android:id="@+id/layout_downloaded"
|
||||
android:layout_width="16dp"
|
||||
|
|
|
@ -57,15 +57,15 @@
|
|||
|
||||
<ImageView
|
||||
android:id="@+id/image_clear"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
app:srcCompat="@drawable/ic_clear_16dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:layout_marginStart="6dp"
|
||||
android:padding="2dp" />
|
||||
android:padding="12dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_name"
|
||||
|
|
|
@ -688,6 +688,14 @@
|
|||
<item>Continuer la lecture</item>
|
||||
<item>Superposition du lecteur</item>
|
||||
</string-array>
|
||||
<string-array name="seek_offset_duration">
|
||||
<item>3 secondes</item>
|
||||
<item>5 secondes</item>
|
||||
<item>10 secondes</item>
|
||||
<item>20 secondes</item>
|
||||
<item>30 secondes</item>
|
||||
<item>60 secondes</item>
|
||||
</string-array>
|
||||
<string-array name="resume_after_preview">
|
||||
<item>Reprendre depuis le début</item>
|
||||
<item>Reprendre après 10s</item>
|
||||
|
|
|
@ -135,6 +135,7 @@
|
|||
<string name="not_ready">Not ready</string>
|
||||
<string name="connect">Connect</string>
|
||||
<string name="stop">Stop</string>
|
||||
<string name="stop_casting">Stop casting</string>
|
||||
<string name="start">Start</string>
|
||||
<string name="storage_space">Storage Space</string>
|
||||
<string name="downloads">Downloads</string>
|
||||
|
@ -194,7 +195,9 @@
|
|||
<string name="ip">IP</string>
|
||||
<string name="port">Port</string>
|
||||
<string name="discovered_devices">Discovered Devices</string>
|
||||
<string name="available_devices">Available devices</string>
|
||||
<string name="remembered_devices">Remembered Devices</string>
|
||||
<string name="unable_to_see_the_Device_youre_looking_for_try_add_the_device_manually">Unable to see the device you\'re looking for? Try to add the device manually.</string>
|
||||
<string name="there_are_no_remembered_devices">There are no remembered devices</string>
|
||||
<string name="connected_to">Connected to</string>
|
||||
<string name="volume">Volume</string>
|
||||
|
@ -204,6 +207,7 @@
|
|||
<string name="previous">Previous</string>
|
||||
<string name="next">Next</string>
|
||||
<string name="comment">Comment</string>
|
||||
<string name="add_manually">Add manually</string>
|
||||
<string name="not_empty_close">Comment is not empty, close anyway?</string>
|
||||
<string name="str_import">Import</string>
|
||||
<string name="my_playlist_name">My Playlist Name</string>
|
||||
|
@ -335,7 +339,7 @@
|
|||
<string name="configure_if_background_download_should_be_used">Configure if background download should be used</string>
|
||||
<string name="configure_the_auto_updater">Configure the auto updater</string>
|
||||
<string name="configure_when_updates_should_be_downloaded">Configure when updates should be downloaded</string>
|
||||
<string name="configure_when_videos_should_be_downloaded">Configure when videos should be downloaded</string>
|
||||
<string name="configure_when_videos_should_be_downloaded">Configure when videos should be downloaded, if they should only be downloaded on unmetered networks (wifi/ethernet)</string>
|
||||
<string name="creates_a_zip_file_with_your_data_which_can_be_imported_by_opening_it_with_grayjay">Creates a zip file with your data which can be imported by opening it with Grayjay</string>
|
||||
<string name="default_audio_quality">Default Audio Quality</string>
|
||||
<string name="default_playback_speed">Default Playback Speed</string>
|
||||
|
@ -366,18 +370,22 @@
|
|||
<string name="networking">Networking</string>
|
||||
<string name="synchronization">Synchronization</string>
|
||||
<string name="enabled_description">Enable feature</string>
|
||||
<string name="broadcast">Broadcast</string>
|
||||
<string name="broadcast_description">Allow device to broadcast presence</string>
|
||||
<string name="connect_discovered">Connect discovered</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_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="broadcast">mDNS Broadcast</string>
|
||||
<string name="broadcast_description">Allow device to broadcast presence using mDNS</string>
|
||||
<string name="connect_discovered">mDNS Connect</string>
|
||||
<string name="connect_discovered_description">Allow device to search for and initiate connection with known paired devices using mDNS</string>
|
||||
<string name="connect_last">Connect Last Known</string>
|
||||
<string name="connect_last_description">Allow device to automatically connect to last known endpoints</string>
|
||||
<string name="discover_through_relay">Relay Enable</string>
|
||||
<string name="discover_through_relay_description">Allow device to use a relay for discovery/relaying connection</string>
|
||||
<string name="pair_through_relay">Relay Pairing</string>
|
||||
<string name="pair_through_relay_description">Allow device to be paired through the relay</string>
|
||||
<string name="connect_through_relay">Relay Connect Relayed</string>
|
||||
<string name="connect_through_relay_description">Allow device to be connected to using a relayed connection</string>
|
||||
<string name="connect_local_direct_through_relay">Relay Connect Direct</string>
|
||||
<string name="connect_local_direct_through_relay_description">Allow device to be directly connected to using relay published information</string>
|
||||
<string name="local_connections">Bind Listener</string>
|
||||
<string name="local_connections_description">Allow device to be directly connected to</string>
|
||||
<string name="gesture_controls">Gesture controls</string>
|
||||
<string name="volume_slider">Volume slider</string>
|
||||
<string name="volume_slider_descr">Enable slide gesture to change volume</string>
|
||||
|
@ -416,6 +424,8 @@
|
|||
<string name="allow_full_screen_portrait">Allow full-screen portrait when watching horizontal videos</string>
|
||||
<string name="delete_watchlist_on_finish">Delete from WatchLater when watched</string>
|
||||
<string name="delete_watchlist_on_finish_description">After you leave a video that you mostly watched, it will be removed from watch later.</string>
|
||||
<string name="seek_offset">Seek duration</string>
|
||||
<string name="seek_offset_description">Fast-Forward / Fast-Rewind duration</string>
|
||||
<string name="background_switch_audio">Switch to Audio in Background</string>
|
||||
<string name="background_switch_audio_description">Optimize bandwidth usage by switching to audio-only stream in background if available, may cause stutter</string>
|
||||
<string name="subscription_group_menu">Groups</string>
|
||||
|
@ -1068,6 +1078,14 @@
|
|||
<item>Within 30 seconds of loss</item>
|
||||
<item>Always</item>
|
||||
</string-array>
|
||||
<string-array name="seek_offset_duration">
|
||||
<item>3 seconds</item>
|
||||
<item>5 seconds</item>
|
||||
<item>10 seconds</item>
|
||||
<item>20 seconds</item>
|
||||
<item>30 seconds</item>
|
||||
<item>60 seconds</item>
|
||||
</string-array>
|
||||
<string-array name="rotation_zone">
|
||||
<item>15</item>
|
||||
<item>30</item>
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 07e39f9df71b1937adf5bfb718a115fc232aa6f8
|
||||
Subproject commit 9aa31c5e87c7957a6e7ef07b6a8f38b775c88d9a
|
|
@ -1 +1 @@
|
|||
Subproject commit ce0571bdeaed4e341351ef477ef4b6599aa4d0fb
|
||||
Subproject commit 12226380428664a1de75abd2886ae12e00ec691f
|
|
@ -1 +1 @@
|
|||
Subproject commit 3fbd872ad8bd7df62c5fbec7437e1200d82b74e1
|
||||
Subproject commit b31ced36b9faaa535fb13a5873cdeb1c89d55859
|
1
app/src/stable/assets/sources/curiositystream
Submodule
1
app/src/stable/assets/sources/curiositystream
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit f6eb2463f5de4d0dc4e5f921967babf2b5bd806f
|
|
@ -1 +1 @@
|
|||
Subproject commit b34134ca2dbb1662b060b4a67f14e7c5d077889d
|
||||
Subproject commit ffd40f2006b9048690944e55688951a849f5a13a
|
|
@ -1 +1 @@
|
|||
Subproject commit 3a0efd1fc4db63c15334a190ab69a8fb4498ae23
|
||||
Subproject commit ffdf4cda380e5e4e9e370412f014e704bd14c09e
|
|
@ -1 +1 @@
|
|||
Subproject commit f30a3bfc0f6a894d816ab7fa732b8f63eb54b84e
|
||||
Subproject commit 97a5ad5a37c40ed68cccbab05ba16926a0aaee41
|
|
@ -1 +1 @@
|
|||
Subproject commit 2bcab14d01a564aa8ab9218de54042fc68b9ee76
|
||||
Subproject commit 6e7f943b0ba56181ee503e1f2cb8349db1351553
|
|
@ -1 +1 @@
|
|||
Subproject commit a32dbb626aacfc6264e505cd5c7f34dd8a60edfc
|
||||
Subproject commit 932fdf78dec23a132bedc8838185af9911452af5
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue