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:
Kai 2025-05-22 11:31:05 -05:00
commit cc3639180b
No known key found for this signature in database
122 changed files with 3185 additions and 3421 deletions

2
.gitattributes vendored Normal file
View 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
View file

@ -94,3 +94,9 @@
[submodule "app/src/unstable/assets/sources/tedtalks"] [submodule "app/src/unstable/assets/sources/tedtalks"]
path = app/src/unstable/assets/sources/tedtalks path = app/src/unstable/assets/sources/tedtalks
url = ../plugins/tedtalks.git 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

Binary file not shown.

View file

@ -197,7 +197,7 @@ dependencies {
implementation 'org.jsoup:jsoup:1.15.3' implementation 'org.jsoup:jsoup:1.15.3'
implementation 'com.google.android.flexbox:flexbox:3.0.0' implementation 'com.google.android.flexbox:flexbox:3.0.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.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 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
implementation 'com.github.dhaval2404:imagepicker:2.1' implementation 'com.github.dhaval2404:imagepicker:2.1'
implementation 'com.google.zxing:core:3.4.1' implementation 'com.google.zxing:core:3.4.1'

View file

@ -3,19 +3,21 @@ package com.futo.platformplayer
import com.futo.platformplayer.noise.protocol.Noise import com.futo.platformplayer.noise.protocol.Noise
import com.futo.platformplayer.sync.internal.* import com.futo.platformplayer.sync.internal.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.selects.select
import org.junit.Assert.* import org.junit.Assert.*
import org.junit.Test import org.junit.Test
import java.net.Socket import java.net.Socket
import java.nio.ByteBuffer import java.nio.ByteBuffer
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class SyncServerTests { class SyncServerTests {
//private val relayHost = "relay.grayjay.app" //private val relayHost = "relay.grayjay.app"
//private val relayKey = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw=" //private val relayKey = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw="
private val relayKey = "XlUaSpIlRaCg0TGzZ7JYmPupgUHDqTZXUUBco2K7ejw=" private val relayKey = "XlUaSpIlRaCg0TGzZ7JYmPupgUHDqTZXUUBco2K7ejw="
private val relayHost = "192.168.1.175" private val relayHost = "192.168.1.138"
private val relayPort = 9000 private val relayPort = 9000
/** Creates a client connected to the live relay server. */ /** Creates a client connected to the live relay server. */
@ -23,7 +25,8 @@ class SyncServerTests {
onHandshakeComplete: ((SyncSocketSession) -> Unit)? = null, onHandshakeComplete: ((SyncSocketSession) -> Unit)? = null,
onData: ((SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit)? = null, onData: ((SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit)? = null,
onNewChannel: ((SyncSocketSession, ChannelRelayed) -> Unit)? = null, onNewChannel: ((SyncSocketSession, ChannelRelayed) -> Unit)? = null,
isHandshakeAllowed: ((SyncSocketSession, String, String?) -> Boolean)? = null isHandshakeAllowed: ((LinkType, SyncSocketSession, String, String?, UInt) -> Boolean)? = null,
onException: ((Throwable) -> Unit)? = null
): SyncSocketSession = withContext(Dispatchers.IO) { ): SyncSocketSession = withContext(Dispatchers.IO) {
val p = Noise.createDH("25519") val p = Noise.createDH("25519")
p.generateKeyPair() p.generateKeyPair()
@ -43,10 +46,14 @@ class SyncServerTests {
}, },
onData = onData ?: { _, _, _, _ -> }, onData = onData ?: { _, _, _, _ -> },
onNewChannel = onNewChannel ?: { _, _ -> }, onNewChannel = onNewChannel ?: { _, _ -> },
isHandshakeAllowed = isHandshakeAllowed ?: { _, _, _ -> true } isHandshakeAllowed = isHandshakeAllowed ?: { _, _, _, _, _ -> true }
) )
socketSession.authorizable = AlwaysAuthorized() socketSession.authorizable = AlwaysAuthorized()
try {
socketSession.startAsInitiator(relayKey) socketSession.startAsInitiator(relayKey)
} catch (e: Throwable) {
onException?.invoke(e)
}
withTimeout(5000.milliseconds) { tcs.await() } withTimeout(5000.milliseconds) { tcs.await() }
return@withContext socketSession return@withContext socketSession
} }
@ -259,6 +266,71 @@ class SyncServerTests {
clientA.stop() clientA.stop()
clientB.stop() clientB.stop()
} }
@Test
fun relayedTransport_WithValidAppId_Success() = runBlocking {
// Arrange: Set up clients
val allowedAppId = 1234u
val tcsB = CompletableDeferred<ChannelRelayed>()
// Client B requires appId 1234
val clientB = createClient(
onNewChannel = { _, c -> tcsB.complete(c) },
isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId }
)
val clientA = createClient()
// Act: Start relayed channel with valid appId
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey, appId = allowedAppId) }
val channelB = withTimeout(5.seconds) { tcsB.await() }
withTimeout(5.seconds) { channelTask.await() }
// Assert: Channel is established
assertNotNull("Channel should be created on target with valid appId", channelB)
// Clean up
clientA.stop()
clientB.stop()
}
@Test
fun relayedTransport_WithInvalidAppId_Fails() = runBlocking {
// Arrange: Set up clients
val allowedAppId = 1234u
val invalidAppId = 5678u
val tcsB = CompletableDeferred<ChannelRelayed>()
// Client B requires appId 1234
val clientB = createClient(
onNewChannel = { _, c -> tcsB.complete(c) },
isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId },
onException = { }
)
val clientA = createClient()
// Act & Assert: Attempt with invalid appId should fail
try {
withTimeout(5.seconds) {
clientA.startRelayedChannel(clientB.localPublicKey, appId = invalidAppId)
}
fail("Starting relayed channel with invalid appId should fail")
} catch (e: Throwable) {
// Expected: The channel creation should time out or fail
}
// Ensure no channel was created on client B
val completedTask = select {
tcsB.onAwait { "channel" }
async { delay(1.seconds); "timeout" }.onAwait { "timeout" }
}
assertEquals("No channel should be created with invalid appId", "timeout", completedTask)
// Clean up
clientA.stop()
clientB.stop()
}
} }
class AlwaysAuthorized : IAuthorizable { class AlwaysAuthorized : IAuthorizable {

View file

@ -0,0 +1,512 @@
package com.futo.platformplayer
import com.futo.platformplayer.noise.protocol.DHState
import com.futo.platformplayer.noise.protocol.Noise
import com.futo.platformplayer.sync.internal.*
import kotlinx.coroutines.*
import org.junit.Assert.*
import org.junit.Test
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.nio.ByteBuffer
import kotlin.random.Random
import java.io.InputStream
import java.io.OutputStream
import kotlin.time.Duration.Companion.seconds
data class PipeStreams(
val initiatorInput: LittleEndianDataInputStream,
val initiatorOutput: LittleEndianDataOutputStream,
val responderInput: LittleEndianDataInputStream,
val responderOutput: LittleEndianDataOutputStream
)
typealias OnHandshakeComplete = (SyncSocketSession) -> Unit
typealias IsHandshakeAllowed = (LinkType, SyncSocketSession, String, String?, UInt) -> Boolean
typealias OnClose = (SyncSocketSession) -> Unit
typealias OnData = (SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit
class SyncSocketTests {
private fun createPipeStreams(): PipeStreams {
val initiatorOutput = PipedOutputStream()
val responderOutput = PipedOutputStream()
val responderInput = PipedInputStream(initiatorOutput)
val initiatorInput = PipedInputStream(responderOutput)
return PipeStreams(
LittleEndianDataInputStream(initiatorInput), LittleEndianDataOutputStream(initiatorOutput),
LittleEndianDataInputStream(responderInput), LittleEndianDataOutputStream(responderOutput)
)
}
fun generateKeyPair(): DHState {
val p = Noise.createDH("25519")
p.generateKeyPair()
return p
}
private fun createSessions(
initiatorInput: LittleEndianDataInputStream,
initiatorOutput: LittleEndianDataOutputStream,
responderInput: LittleEndianDataInputStream,
responderOutput: LittleEndianDataOutputStream,
initiatorKeyPair: DHState,
responderKeyPair: DHState,
onInitiatorHandshakeComplete: OnHandshakeComplete,
onResponderHandshakeComplete: OnHandshakeComplete,
onInitiatorClose: OnClose? = null,
onResponderClose: OnClose? = null,
onClose: OnClose? = null,
isHandshakeAllowed: IsHandshakeAllowed? = null,
onDataA: OnData? = null,
onDataB: OnData? = null
): Pair<SyncSocketSession, SyncSocketSession> {
val initiatorSession = SyncSocketSession(
"", initiatorKeyPair, initiatorInput, initiatorOutput,
onClose = {
onClose?.invoke(it)
onInitiatorClose?.invoke(it)
},
onHandshakeComplete = onInitiatorHandshakeComplete,
onData = onDataA,
isHandshakeAllowed = isHandshakeAllowed
)
val responderSession = SyncSocketSession(
"", responderKeyPair, responderInput, responderOutput,
onClose = {
onClose?.invoke(it)
onResponderClose?.invoke(it)
},
onHandshakeComplete = onResponderHandshakeComplete,
onData = onDataB,
isHandshakeAllowed = isHandshakeAllowed
)
return Pair(initiatorSession, responderSession)
}
@Test
fun handshake_WithValidPairingCode_Succeeds(): Unit = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val validPairingCode = "secret"
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
)
initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = validPairingCode)
responderSession.startAsResponder()
withTimeout(5.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
}
@Test
fun handshake_WithInvalidPairingCode_Fails() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val validPairingCode = "secret"
val invalidPairingCode = "wrong"
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val initiatorClosed = CompletableDeferred<Boolean>()
val responderClosed = CompletableDeferred<Boolean>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onInitiatorClose = {
initiatorClosed.complete(true)
},
onResponderClose = {
responderClosed.complete(true)
},
isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
)
initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = invalidPairingCode)
responderSession.startAsResponder()
withTimeout(100.seconds) {
initiatorClosed.await()
responderClosed.await()
}
assertFalse(handshakeInitiatorCompleted.isCompleted)
assertFalse(handshakeResponderCompleted.isCompleted)
}
@Test
fun handshake_WithoutPairingCodeWhenRequired_Fails() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val validPairingCode = "secret"
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val initiatorClosed = CompletableDeferred<Boolean>()
val responderClosed = CompletableDeferred<Boolean>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onInitiatorClose = {
initiatorClosed.complete(true)
},
onResponderClose = {
responderClosed.complete(true)
},
isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
)
initiatorSession.startAsInitiator(responderSession.localPublicKey) // No pairing code
responderSession.startAsResponder()
withTimeout(5.seconds) {
initiatorClosed.await()
responderClosed.await()
}
assertFalse(handshakeInitiatorCompleted.isCompleted)
assertFalse(handshakeResponderCompleted.isCompleted)
}
@Test
fun handshake_WithPairingCodeWhenNotRequired_Succeeds(): Unit = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val pairingCode = "unnecessary"
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
isHandshakeAllowed = { _, _, _, _, _ -> true } // Always allow
)
initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = pairingCode)
responderSession.startAsResponder()
withTimeout(10.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
}
@Test
fun sendAndReceive_SmallDataPacket_Succeeds() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onDataB = { _, opcode, subOpcode, data ->
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
val b = ByteArray(data.remaining())
data.get(b)
tcsDataReceived.complete(b)
}
}
)
initiatorSession.startAsInitiator(responderSession.localPublicKey)
responderSession.startAsResponder()
withTimeout(10.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
// Ensure both sessions are authorized
initiatorSession.authorizable = Authorized()
responderSession.authorizable = Authorized()
val smallData = byteArrayOf(1, 2, 3)
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(smallData))
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
assertArrayEquals(smallData, receivedData)
}
@Test
fun sendAndReceive_ExactlyMaximumPacketSize_Succeeds() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onDataB = { _, opcode, subOpcode, data ->
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
val b = ByteArray(data.remaining())
data.get(b)
tcsDataReceived.complete(b)
}
}
)
initiatorSession.startAsInitiator(responderSession.localPublicKey)
responderSession.startAsResponder()
withTimeout(10.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
// Ensure both sessions are authorized
initiatorSession.authorizable = Authorized()
responderSession.authorizable = Authorized()
val maxData = ByteArray(SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE).apply { Random.nextBytes(this) }
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxData))
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
assertArrayEquals(maxData, receivedData)
}
@Test
fun stream_LargeData_Succeeds() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onDataB = { _, opcode, subOpcode, data ->
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
val b = ByteArray(data.remaining())
data.get(b)
tcsDataReceived.complete(b)
}
}
)
initiatorSession.startAsInitiator(responderSession.localPublicKey)
responderSession.startAsResponder()
withTimeout(10.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
// Ensure both sessions are authorized
initiatorSession.authorizable = Authorized()
responderSession.authorizable = Authorized()
val largeData = ByteArray(2 * (SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE)).apply { Random.nextBytes(this) }
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData))
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
assertArrayEquals(largeData, receivedData)
}
@Test
fun authorizedSession_CanSendData() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onDataB = { _, opcode, subOpcode, data ->
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
val b = ByteArray(data.remaining())
data.get(b)
tcsDataReceived.complete(b)
}
}
)
initiatorSession.startAsInitiator(responderSession.localPublicKey)
responderSession.startAsResponder()
withTimeout(10.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
// Authorize both sessions
initiatorSession.authorizable = Authorized()
responderSession.authorizable = Authorized()
val data = byteArrayOf(1, 2, 3)
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data))
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
assertArrayEquals(data, receivedData)
}
@Test
fun unauthorizedSession_CannotSendData() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onDataB = { _, _, _, _ -> }
)
initiatorSession.startAsInitiator(responderSession.localPublicKey)
responderSession.startAsResponder()
withTimeout(10.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
// Authorize initiator but not responder
initiatorSession.authorizable = Authorized()
responderSession.authorizable = Unauthorized()
val data = byteArrayOf(1, 2, 3)
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data))
delay(1.seconds)
assertFalse(tcsDataReceived.isCompleted)
}
@Test
fun directHandshake_WithValidAppId_Succeeds() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val allowedAppId = 1234u
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt ->
linkType == LinkType.Direct && appId == allowedAppId
}
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
isHandshakeAllowed = responderIsHandshakeAllowed
)
initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = allowedAppId)
responderSession.startAsResponder()
withTimeout(5.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
assertNotNull(initiatorSession.remotePublicKey)
assertNotNull(responderSession.remotePublicKey)
}
@Test
fun directHandshake_WithInvalidAppId_Fails() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val allowedAppId = 1234u
val invalidAppId = 5678u
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val initiatorClosed = CompletableDeferred<Boolean>()
val responderClosed = CompletableDeferred<Boolean>()
val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt ->
linkType == LinkType.Direct && appId == allowedAppId
}
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onInitiatorClose = {
initiatorClosed.complete(true)
},
onResponderClose = {
responderClosed.complete(true)
},
isHandshakeAllowed = responderIsHandshakeAllowed
)
initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = invalidAppId)
responderSession.startAsResponder()
withTimeout(5.seconds) {
initiatorClosed.await()
responderClosed.await()
}
assertFalse(handshakeInitiatorCompleted.isCompleted)
assertFalse(handshakeResponderCompleted.isCompleted)
}
}
class Authorized : IAuthorizable {
override val isAuthorized: Boolean = true
}
class Unauthorized : IAuthorizable {
override val isAuthorized: Boolean = false
}

View file

@ -15,6 +15,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/> <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.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/> <uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@ -55,7 +56,7 @@
<activity <activity
android:name=".activities.MainActivity" android:name=".activities.MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
android:exported="true" android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar" android:theme="@style/Theme.FutoVideo.NoActionBar"
android:launchMode="singleInstance" android:launchMode="singleInstance"

View file

@ -595,6 +595,8 @@ class PlatformComment {
this.date = obj.date ?? 0; this.date = obj.date ?? 0;
this.replyCount = obj.replyCount ?? 0; this.replyCount = obj.replyCount ?? 0;
this.context = obj.context ?? {}; this.context = obj.context ?? {};
if(obj.getReplies)
this.getReplies = obj.getReplies;
} }
} }

View file

@ -14,7 +14,6 @@ import java.text.DecimalFormat
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.math.roundToLong import kotlin.math.roundToLong
@ -376,14 +375,19 @@ private val slds = hashSetOf(".com.ac", ".net.ac", ".gov.ac", ".org.ac", ".mil.a
fun String.matchesDomain(queryDomain: String): Boolean { fun String.matchesDomain(queryDomain: String): Boolean {
if(queryDomain.startsWith(".")) { if(queryDomain.startsWith(".")) {
val parts = this.lowercase().split(".");
val parts = queryDomain.lowercase().split("."); val queryParts = queryDomain.lowercase().trimStart("."[0]).split(".");
if(parts.size < 3) if(queryParts.size < 2)
throw IllegalStateException("Illegal use of wildcards on First-Level-Domain (" + queryDomain + ")"); throw IllegalStateException("Illegal use of wildcards on First-Level-Domain (" + queryDomain + ")");
if(parts.size >= 3){ else {
val isSLD = slds.contains("." + parts[parts.size - 2] + "." + parts[parts.size - 1]); val possibleDomain = "." + queryParts.joinToString(".");
if(isSLD && parts.size <= 3) if(slds.contains(possibleDomain))
throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")"); throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")");
/*
val isSLD = slds.contains("." + queryParts[queryParts.size - 2] + "." + queryParts[queryParts.size - 1]);
if(isSLD && queryParts.size <= 3)
throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")");
*/
} }
//TODO: Should be safe, but double verify if can't be exploited //TODO: Should be safe, but double verify if can't be exploited
@ -395,9 +399,11 @@ fun String.matchesDomain(queryDomain: String): Boolean {
fun String.getSubdomainWildcardQuery(): String { fun String.getSubdomainWildcardQuery(): String {
val domainParts = this.split("."); val domainParts = this.split(".");
val sldParts = "." + domainParts[domainParts.size - 2].lowercase() + "." + domainParts[domainParts.size - 1].lowercase(); var wildcardDomain = if(domainParts.size > 2)
if(slds.contains(sldParts)) "." + domainParts.drop(1).joinToString(".")
return "." + domainParts.drop(domainParts.size - 3).joinToString(".");
else else
return "." + domainParts.drop(domainParts.size - 2).joinToString("."); "." + domainParts.joinToString(".");
if(slds.contains(wildcardDomain.lowercase()))
"." + domainParts.joinToString(".");
return wildcardDomain;
} }

View file

@ -217,6 +217,8 @@ private fun ByteArray.toInetAddress(): InetAddress {
} }
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? { fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
ensureNotMainThread()
val timeout = 2000 val timeout = 2000

View file

@ -7,6 +7,9 @@ import java.net.InetAddress
import java.net.URI import java.net.URI
import java.net.URISyntaxException import java.net.URISyntaxException
import java.net.URLEncoder import java.net.URLEncoder
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
//Syntax sugaring //Syntax sugaring
inline fun <reified T> Any.assume(): T?{ inline fun <reified T> Any.assume(): T?{
@ -33,13 +36,37 @@ fun Boolean?.toYesNo(): String {
fun InetAddress?.toUrlAddress(): String { fun InetAddress?.toUrlAddress(): String {
return when (this) { return when (this) {
is Inet6Address -> { is Inet6Address -> {
"[${hostAddress}]" val hostAddr = this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
val index = hostAddr.indexOf('%')
if (index != -1) {
val addrPart = hostAddr.substring(0, index)
val scopeId = hostAddr.substring(index + 1)
"[${addrPart}%25${scopeId}]" // %25 is URL-encoded '%'
} else {
"[$hostAddr]"
}
} }
is Inet4Address -> { is Inet4Address -> {
hostAddress this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
} }
else -> { else -> {
throw Exception("Invalid address type") throw Exception("Invalid address type")
} }
} }
} }
fun Long?.sToOffsetDateTimeUTC(): OffsetDateTime {
if (this == null || this < 0)
return OffsetDateTime.MIN
if(this > 4070912400)
return OffsetDateTime.MAX;
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(this), ZoneOffset.UTC)
}
fun Long?.msToOffsetDateTimeUTC(): OffsetDateTime {
if (this == null || this < 0)
return OffsetDateTime.MIN
if(this > 4070912400)
return OffsetDateTime.MAX;
return OffsetDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneOffset.UTC)
}

View file

@ -499,6 +499,22 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22) @FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22)
var deleteFromWatchLaterAuto: Boolean = true; 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) @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) @FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var allowIpv6: Boolean = false; var allowIpv6: Boolean = true;
/*TODO: Should we have a different casting quality? /*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@ -926,7 +942,7 @@ class Settings : FragmentedStorageFileJson() {
@Serializable @Serializable
class Synchronization { class Synchronization {
@FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1) @FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1)
var enabled: Boolean = true; var enabled: Boolean = false;
@FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1) @FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1)
var broadcast: Boolean = false; var broadcast: Boolean = false;
@ -945,6 +961,12 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.connect_through_relay, FieldForm.TOGGLE, R.string.connect_through_relay_description, 3) @FormField(R.string.connect_through_relay, FieldForm.TOGGLE, R.string.connect_through_relay_description, 3)
var connectThroughRelay: Boolean = true; var connectThroughRelay: Boolean = true;
@FormField(R.string.connect_local_direct_through_relay, FieldForm.TOGGLE, R.string.connect_local_direct_through_relay_description, 3)
var connectLocalDirectThroughRelay: Boolean = true;
@FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3)
var localConnections: Boolean = true;
} }
@FormField(R.string.info, FieldForm.GROUP, -1, 21) @FormField(R.string.info, FieldForm.GROUP, -1, 21)

View file

@ -684,6 +684,10 @@ class UISlideOverlays {
} }
} }
} }
if(!Settings.instance.downloads.shouldDownload()) {
UIDialogs.appToast("Download will start when you're back on wifi.\n" +
"(You can change this in settings)", true);
}
} }
}; };
return menu.apply { show() }; return menu.apply { show() };

View file

@ -69,7 +69,14 @@ fun warnIfMainThread(context: String) {
} }
fun ensureNotMainThread() { fun ensureNotMainThread() {
if (Looper.myLooper() == Looper.getMainLooper()) { val isMainLooper = try {
Looper.myLooper() == Looper.getMainLooper()
} catch (e: Throwable) {
//Ignore, for unit tests where its not mocked
false
}
if (isMainLooper) {
Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread") Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread")
throw IllegalStateException("Cannot run on main thread") throw IllegalStateException("Cannot run on main thread")
} }
@ -272,7 +279,7 @@ fun <T> findNewIndex(originalArr: List<T>, newArr: List<T>, item: T): Int{
} }
} }
if(newIndex < 0) if(newIndex < 0)
return originalArr.size; return newArr.size;
else else
return newIndex; return newIndex;
} }

View file

@ -1,14 +1,15 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ComponentName import android.app.AlertDialog
import android.app.UiModeManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Configuration import android.content.res.Configuration
import android.media.AudioManager
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.StrictMode import android.os.StrictMode
import android.os.StrictMode.VmPolicy import android.os.StrictMode.VmPolicy
@ -21,8 +22,10 @@ import android.widget.ImageView
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.motion.widget.MotionLayout import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isVisible 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.api.http.ManagedHttpClient
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
import com.futo.platformplayer.fragment.mainactivity.main.BuyFragment 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.SuggestionsFragment
import com.futo.platformplayer.fragment.mainactivity.main.TutorialFragment import com.futo.platformplayer.fragment.mainactivity.main.TutorialFragment
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.State
import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment 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.logging.Logger
import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.UrlVideoWithTime import com.futo.platformplayer.models.UrlVideoWithTime
import com.futo.platformplayer.receivers.MediaButtonReceiver
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup import com.futo.platformplayer.states.StateBackup
@ -185,6 +189,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private var _isVisible = true; private var _isVisible = true;
private var _wasStopped = false; private var _wasStopped = false;
private var _privateModeEnabled = false
private var _pictureInPictureEnabled = false
private var _isFullscreen = false
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data) val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
@ -262,6 +269,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateApp.instance.mainAppStarting(this); StateApp.instance.mainAppStarting(this);
super.onCreate(savedInstanceState); 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); setContentView(R.layout.activity_main);
setNavigationBarColorAndIcons(); setNavigationBarColorAndIcons();
if (Settings.instance.playback.allowVideoToGoUnderCutout) if (Settings.instance.playback.allowVideoToGoUnderCutout)
@ -354,22 +365,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragMainSubscriptionsFeed.setPreviewsEnabled(true); _fragMainSubscriptionsFeed.setPreviewsEnabled(true);
_fragContainerVideoDetail.visibility = View.INVISIBLE; _fragContainerVideoDetail.visibility = View.INVISIBLE;
updateSegmentPaddings(); updateSegmentPaddings();
updatePrivateModeVisibility()
}; };
_buttonIncognito = findViewById(R.id.incognito_button); _buttonIncognito = findViewById(R.id.incognito_button);
_buttonIncognito.elevation = -99f; updatePrivateModeVisibility()
_buttonIncognito.alpha = 0f;
StateApp.instance.privateModeChanged.subscribe { StateApp.instance.privateModeChanged.subscribe {
//Messing with visibility causes some issues with layout ordering? //Messing with visibility causes some issues with layout ordering?
if (it) { _privateModeEnabled = it
_buttonIncognito.elevation = 99f; updatePrivateModeVisibility()
_buttonIncognito.alpha = 1f;
} else {
_buttonIncognito.elevation = -99f;
_buttonIncognito.alpha = 0f;
}
} }
_buttonIncognito.setOnClickListener { _buttonIncognito.setOnClickListener {
if (!StateApp.instance.privateMode) if (!StateApp.instance.privateMode)
return@setOnClickListener; return@setOnClickListener;
@ -386,19 +393,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}; };
_fragVideoDetail.onFullscreenChanged.subscribe { _fragVideoDetail.onFullscreenChanged.subscribe {
Logger.i(TAG, "onFullscreenChanged ${it}"); Logger.i(TAG, "onFullscreenChanged ${it}");
_isFullscreen = it
updatePrivateModeVisibility()
}
if (it) { _fragVideoDetail.onMinimize.subscribe {
_buttonIncognito.elevation = -99f; updatePrivateModeVisibility()
_buttonIncognito.alpha = 0f;
} else {
if (StateApp.instance.privateMode) {
_buttonIncognito.elevation = 99f;
_buttonIncognito.alpha = 1f;
} else {
_buttonIncognito.elevation = -99f;
_buttonIncognito.alpha = 0f;
}
} }
_fragVideoDetail.onMaximized.subscribe {
updatePrivateModeVisibility()
} }
StatePlayer.instance.also { 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"); UIDialogs.toast(this, "No external file permissions\nExporting and auto backups will not work");
}*/ }*/
private var _qrCodeLoadingDialog: AlertDialog? = null
fun showUrlQrCodeScanner() { fun showUrlQrCodeScanner() {
try { try {
_qrCodeLoadingDialog = UIDialogs.showDialog(this, R.drawable.ic_loader_animated, true,
"Launching QR scanner",
"Make sure your camera is enabled", null, -2,
UIDialogs.Action("Close", {
_qrCodeLoadingDialog?.dismiss()
_qrCodeLoadingDialog = null
}));
val integrator = IntentIntegrator(this) val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt(getString(R.string.scan_a_qr_code)) integrator.setPrompt(getString(R.string.scan_a_qr_code))
@ -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() { override fun onResume() {
super.onResume(); super.onResume();
Logger.v(TAG, "onResume") Logger.v(TAG, "onResume")
@ -640,6 +666,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
super.onPause(); super.onPause();
Logger.v(TAG, "onPause") Logger.v(TAG, "onPause")
_isVisible = false; _isVisible = false;
_qrCodeLoadingDialog?.dismiss()
_qrCodeLoadingDialog = null
} }
override fun onStop() { override fun onStop() {
@ -1050,6 +1079,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.v(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop") Logger.v(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop")
_fragVideoDetail.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig); _fragVideoDetail.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig);
Logger.v(TAG, "onPictureInPictureModeChanged Ready"); Logger.v(TAG, "onPictureInPictureModeChanged Ready");
_pictureInPictureEnabled = isInPictureInPictureMode
updatePrivateModeVisibility()
} }
override fun onDestroy() { override fun onDestroy() {

View file

@ -9,6 +9,7 @@ import android.widget.LinearLayout
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateSync import com.futo.platformplayer.states.StateSync
@ -29,6 +30,16 @@ class SyncHomeActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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) setContentView(R.layout.activity_sync_home)
setNavigationBarColorAndIcons() setNavigationBarColorAndIcons()
@ -54,7 +65,6 @@ class SyncHomeActivity : AppCompatActivity() {
val view = _viewMap[publicKey] val view = _viewMap[publicKey]
if (!session.isAuthorized) { if (!session.isAuthorized) {
if (view != null) { if (view != null) {
_layoutDevices.removeView(view)
_viewMap.remove(publicKey) _viewMap.remove(publicKey)
} }
return@launch return@launch
@ -89,6 +99,14 @@ class SyncHomeActivity : AppCompatActivity() {
updateEmptyVisibility() updateEmptyVisibility()
} }
} }
StateSync.instance.confirmStarted(this, {
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity)
}, {
finish()
}, {
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity)
})
} }
override fun onDestroy() { override fun onDestroy() {
@ -100,11 +118,12 @@ class SyncHomeActivity : AppCompatActivity() {
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView { private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
val connected = session?.connected ?: false val connected = session?.connected ?: false
val authorized = session?.isAuthorized ?: false
syncDeviceView.setLinkType(session?.linkType ?: LinkType.None) syncDeviceView.setLinkType(session?.linkType ?: LinkType.None)
.setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey) .setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey)
//TODO: also display public key? //TODO: also display public key?
.setStatus(if (connected) "Connected" else "Disconnected") .setStatus(if (connected && authorized) "Connected" else "Disconnected or unauthorized")
return syncDeviceView return syncDeviceView
} }

View file

@ -83,6 +83,7 @@ class SyncPairActivity : AppCompatActivity() {
_layoutPairingSuccess.setOnClickListener { _layoutPairingSuccess.setOnClickListener {
_layoutPairingSuccess.visibility = View.GONE _layoutPairingSuccess.visibility = View.GONE
finish()
} }
_layoutPairingError.setOnClickListener { _layoutPairingError.setOnClickListener {
_layoutPairingError.visibility = View.GONE _layoutPairingError.visibility = View.GONE
@ -109,11 +110,17 @@ class SyncPairActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
StateSync.instance.connect(deviceInfo) { complete, message -> StateSync.instance.syncService?.connect(deviceInfo) { complete, message ->
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
if (complete != null && complete) { if (complete != null) {
if (complete) {
_layoutPairingSuccess.visibility = View.VISIBLE _layoutPairingSuccess.visibility = View.VISIBLE
_layoutPairing.visibility = View.GONE _layoutPairing.visibility = View.GONE
} else {
_textError.text = message
_layoutPairingError.visibility = View.VISIBLE
_layoutPairing.visibility = View.GONE
}
} else { } else {
_textPairingStatus.text = message _textPairingStatus.text = message
} }
@ -137,8 +144,6 @@ class SyncPairActivity : AppCompatActivity() {
_textError.text = e.message _textError.text = e.message
_layoutPairing.visibility = View.GONE _layoutPairing.visibility = View.GONE
Logger.e(TAG, "Failed to pair", e) Logger.e(TAG, "Failed to pair", e)
} finally {
_layoutPairing.visibility = View.GONE
} }
} }

View file

@ -67,13 +67,20 @@ class SyncShowPairingCodeActivity : AppCompatActivity() {
} }
val ips = getIPs() val ips = getIPs()
val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT, StateSync.instance.pairingCode) 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 json = Json.encodeToString(selfDeviceInfo)
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
val url = "grayjay://sync/${base64}" val url = "grayjay://sync/${base64}"
setCode(url) setCode(url)
} }
}
fun setCode(code: String?) { fun setCode(code: String?) {
_code = code _code = code

View file

@ -90,6 +90,7 @@ open class ManagedHttpClient {
} }
fun tryHead(url: String): Map<String, String>? { fun tryHead(url: String): Map<String, String>? {
ensureNotMainThread()
try { try {
val result = head(url); val result = head(url);
if(result.isOk) if(result.isOk)
@ -104,7 +105,7 @@ open class ManagedHttpClient {
} }
fun socket(url: String, headers: MutableMap<String, String> = HashMap(), listener: SocketListener): Socket { fun socket(url: String, headers: MutableMap<String, String> = HashMap(), listener: SocketListener): Socket {
ensureNotMainThread()
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder() val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
.url(url); .url(url);
if(user_agent.isNotEmpty() && !headers.any { it.key.lowercase() == "user-agent" }) if(user_agent.isNotEmpty() && !headers.any { it.key.lowercase() == "user-agent" })
@ -300,6 +301,7 @@ open class ManagedHttpClient {
} }
fun send(msg: String) { fun send(msg: String) {
ensureNotMainThread()
socket.send(msg); socket.send(msg);
} }

View file

@ -14,14 +14,16 @@ class PlatformClientPool {
private var _poolCounter = 0; private var _poolCounter = 0;
private val _poolName: String?; private val _poolName: String?;
private val _privatePool: Boolean; private val _privatePool: Boolean;
private val _isolatedInitialization: Boolean
var isDead: Boolean = false var isDead: Boolean = false
private set; private set;
val onDead = Event2<JSClient, PlatformClientPool>(); 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; _poolName = name;
_privatePool = privatePool; _privatePool = privatePool;
_isolatedInitialization = isolatedInitialization
if(parentClient !is JSClient) if(parentClient !is JSClient)
throw IllegalArgumentException("Pooling only supported for JSClients right now"); throw IllegalArgumentException("Pooling only supported for JSClients right now");
Logger.i(TAG, "Pool for ${parentClient.name} was started"); Logger.i(TAG, "Pool for ${parentClient.name} was started");
@ -53,7 +55,7 @@ class PlatformClientPool {
reserved = _pool.keys.find { !it.isBusy }; reserved = _pool.keys.find { !it.isBusy };
if(reserved == null && _pool.size < capacity) { if(reserved == null && _pool.size < capacity) {
Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${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 -> reserved?.onCaptchaException?.subscribe { client, ex ->
StateApp.instance.handleCaptchaException(client, ex); StateApp.instance.handleCaptchaException(client, ex);

View file

@ -7,13 +7,15 @@ class PlatformMultiClientPool {
private var _isFake = false; private var _isFake = false;
private var _privatePool = 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; _name = name;
_maxCap = if(maxCap > 0) _maxCap = if(maxCap > 0)
maxCap maxCap
else 99; else 99;
_privatePool = isPrivatePool; _privatePool = isPrivatePool;
_isolatedInitialization = isolatedInitialization
} }
fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient { fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient {
@ -21,7 +23,7 @@ class PlatformMultiClientPool {
return parentClient; return parentClient;
val pool = synchronized(_clientPools) { val pool = synchronized(_clientPools) {
if(!_clientPools.containsKey(parentClient)) if(!_clientPools.containsKey(parentClient))
_clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool).apply { _clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool, _isolatedInitialization).apply {
this.onDead.subscribe { _, pool -> this.onDead.subscribe { _, pool ->
synchronized(_clientPools) { synchronized(_clientPools) {
if(_clientPools[parentClient] == pool) if(_clientPools[parentClient] == pool)

View file

@ -54,8 +54,11 @@ class DevJSClient : JSClient {
return DevJSClient(context, config, _devScript, _auth, _captcha, devID, descriptor.settings); return DevJSClient(context, config, _devScript, _auth, _captcha, devID, descriptor.settings);
} }
override fun getCopy(privateCopy: Boolean): JSClient { override fun getCopy(privateCopy: Boolean, noSaveState: Boolean): JSClient {
return DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, saveState(), devID); 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() { override fun initialize() {

View file

@ -195,8 +195,11 @@ open class JSClient : IPlatformClient {
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit); _plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
} }
open fun getCopy(withoutCredentials: Boolean = false): JSClient { open fun getCopy(withoutCredentials: Boolean = false, noSaveState: Boolean = false): JSClient {
return JSClient(_context, descriptor, saveState(), _script, withoutCredentials); val client = JSClient(_context, descriptor, if (noSaveState) null else saveState(), _script, withoutCredentials);
if (noSaveState)
client.initialize()
return client
} }
fun getUnderlyingPlugin(): V8Plugin { fun getUnderlyingPlugin(): V8Plugin {
@ -211,6 +214,8 @@ open class JSClient : IPlatformClient {
} }
override fun initialize() { override fun initialize() {
if (_initialized) return
Logger.i(TAG, "Plugin [${config.name}] initializing"); Logger.i(TAG, "Plugin [${config.name}] initializing");
plugin.start(); plugin.start();
plugin.execute("plugin.config = ${Json.encodeToString(config)}"); plugin.execute("plugin.config = ${Json.encodeToString(config)}");

View file

@ -127,7 +127,7 @@ class JSHttpClient : ManagedHttpClient {
} }
if(doApplyCookies) { if(doApplyCookies) {
if (_currentCookieMap.isNotEmpty()) { if (_currentCookieMap.isNotEmpty() || _otherCookieMap.isNotEmpty()) {
val cookiesToApply = hashMapOf<String, String>(); val cookiesToApply = hashMapOf<String, String>();
synchronized(_currentCookieMap) { synchronized(_currentCookieMap) {
for(cookie in _currentCookieMap for(cookie in _currentCookieMap
@ -135,6 +135,12 @@ class JSHttpClient : ManagedHttpClient {
.flatMap { it.value.toList() }) .flatMap { it.value.toList() })
cookiesToApply[cookie.first] = cookie.second; 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) { if(cookiesToApply.size > 0) {
val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; "); val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; ");

View file

@ -149,6 +149,7 @@ class AirPlayCastingDevice : CastingDevice {
break; break;
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e) Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
delay(1000);
} }
} }

View file

@ -322,6 +322,7 @@ class ChromecastCastingDevice : CastingDevice {
break; break;
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e) Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
Thread.sleep(1000);
} }
} }

View file

@ -25,6 +25,7 @@ import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@ -289,6 +290,7 @@ class FCastCastingDevice : CastingDevice {
break; break;
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e) Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
Thread.sleep(1000);
} }
} }

View file

@ -4,10 +4,12 @@ import android.app.AlertDialog
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.Looper import android.os.Looper
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import android.util.Xml
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R import com.futo.platformplayer.R
@ -40,8 +42,6 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.mdns.DnsService
import com.futo.platformplayer.mdns.ServiceDiscoverer
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
@ -55,7 +55,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.net.InetAddress import java.net.InetAddress
import java.net.URLDecoder import java.net.URLDecoder
import java.net.URLEncoder import java.net.URLEncoder
@ -70,7 +69,6 @@ class StateCasting {
private var _started = false; private var _started = false;
var devices: HashMap<String, CastingDevice> = hashMapOf(); var devices: HashMap<String, CastingDevice> = hashMapOf();
var rememberedDevices: ArrayList<CastingDevice> = arrayListOf();
val onDeviceAdded = Event1<CastingDevice>(); val onDeviceAdded = Event1<CastingDevice>();
val onDeviceChanged = Event1<CastingDevice>(); val onDeviceChanged = Event1<CastingDevice>();
val onDeviceRemoved = Event1<CastingDevice>(); val onDeviceRemoved = Event1<CastingDevice>();
@ -84,48 +82,15 @@ class StateCasting {
private var _audioExecutor: JSRequestExecutor? = null private var _audioExecutor: JSRequestExecutor? = null
private val _client = ManagedHttpClient(); private val _client = ManagedHttpClient();
var _resumeCastingDevice: CastingDeviceInfo? = null; var _resumeCastingDevice: CastingDeviceInfo? = null;
val _serviceDiscoverer = ServiceDiscoverer(arrayOf( private var _nsdManager: NsdManager? = null
"_googlecast._tcp.local",
"_airplay._tcp.local",
"_fastcast._tcp.local",
"_fcast._tcp.local"
)) { handleServiceUpdated(it) }
val isCasting: Boolean get() = activeDevice != null; val isCasting: Boolean get() = activeDevice != null;
private fun handleServiceUpdated(services: List<DnsService>) { private val _discoveryListeners = mapOf(
for (s in services) { "_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
//TODO: Addresses IPv4 only? "_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
val addresses = s.addresses.toTypedArray() "_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
val port = s.port.toInt() "_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
var name = s.texts.firstOrNull { it.startsWith("md=") }?.substring("md=".length) )
if (s.name.endsWith("._googlecast._tcp.local")) {
if (name == null) {
name = s.name.substring(0, s.name.length - "._googlecast._tcp.local".length)
}
addOrUpdateChromeCastDevice(name, addresses, port)
} else if (s.name.endsWith("._airplay._tcp.local")) {
if (name == null) {
name = s.name.substring(0, s.name.length - "._airplay._tcp.local".length)
}
addOrUpdateAirPlayDevice(name, addresses, port)
} else if (s.name.endsWith("._fastcast._tcp.local")) {
if (name == null) {
name = s.name.substring(0, s.name.length - "._fastcast._tcp.local".length)
}
addOrUpdateFastCastDevice(name, addresses, port)
} else if (s.name.endsWith("._fcast._tcp.local")) {
if (name == null) {
name = s.name.substring(0, s.name.length - "._fcast._tcp.local".length)
}
addOrUpdateFastCastDevice(name, addresses, port)
}
}
}
fun handleUrl(context: Context, url: String) { fun handleUrl(context: Context, url: String) {
val uri = Uri.parse(url) val uri = Uri.parse(url)
@ -190,30 +155,33 @@ class StateCasting {
Logger.i(TAG, "CastingService starting..."); Logger.i(TAG, "CastingService starting...");
rememberedDevices.clear();
rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) });
_castServer.start(); _castServer.start();
enableDeveloper(true); enableDeveloper(true);
Logger.i(TAG, "CastingService started."); Logger.i(TAG, "CastingService started.");
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
} }
@Synchronized @Synchronized
fun startDiscovering() { fun startDiscovering() {
try { _nsdManager?.apply {
_serviceDiscoverer.start() _discoveryListeners.forEach {
} catch (e: Throwable) { discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
Logger.i(TAG, "Failed to start ServiceDiscoverer", e) }
} }
} }
@Synchronized @Synchronized
fun stopDiscovering() { fun stopDiscovering() {
_nsdManager?.apply {
_discoveryListeners.forEach {
try { try {
_serviceDiscoverer.stop() stopServiceDiscovery(it.value)
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.i(TAG, "Failed to stop ServiceDiscoverer", e) Logger.w(TAG, "Failed to stop service discovery", e)
}
}
} }
} }
@ -239,6 +207,85 @@ class StateCasting {
_castServer.removeAllHandlers(); _castServer.removeAllHandlers();
Logger.i(TAG, "CastingService stopped.") Logger.i(TAG, "CastingService stopped.")
_nsdManager = null
}
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
return object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(regType: String) {
Log.d(TAG, "Service discovery started for $regType")
}
override fun onDiscoveryStopped(serviceType: String) {
Log.i(TAG, "Discovery stopped: $serviceType")
}
override fun onServiceLost(service: NsdServiceInfo) {
Log.e(TAG, "service lost: $service")
// TODO: Handle service lost, e.g., remove device
}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onServiceFound(service: NsdServiceInfo) {
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
service.hostAddresses.toTypedArray()
} else {
arrayOf(service.host)
}
addOrUpdate(service.serviceName, addresses, service.port)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
_nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback {
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "onServiceUpdated: $serviceInfo")
addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port)
}
override fun onServiceLost() {
Log.v(TAG, "onServiceLost: $service")
// TODO: Handle service lost
}
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
}
override fun onServiceInfoCallbackUnregistered() {
Log.v(TAG, "onServiceInfoCallbackUnregistered")
}
})
} else {
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Log.v(TAG, "Resolve failed: $errorCode")
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port)
}
})
}
}
}
} }
private val _castingDialogLock = Any(); private val _castingDialogLock = Any();
@ -331,9 +378,6 @@ class StateCasting {
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) }; invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
}; };
addRememberedDevice(device);
Logger.i(TAG, "Device added to active discovery. Active discovery now contains ${_storage.getDevicesCount()} devices.")
try { try {
device.start(); device.start();
} catch (e: Throwable) { } catch (e: Throwable) {
@ -355,21 +399,22 @@ class StateCasting {
return addRememberedDevice(device); return addRememberedDevice(device);
} }
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo { fun getRememberedCastingDevices(): List<CastingDevice> {
val deviceInfo = device.getDeviceInfo() return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) }
val foundInfo = _storage.addDevice(deviceInfo)
if (foundInfo == deviceInfo) {
rememberedDevices.add(device);
return foundInfo;
} }
return foundInfo; fun getRememberedCastingDeviceNames(): List<String> {
return _storage.getDeviceNames()
}
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
val deviceInfo = device.getDeviceInfo()
return _storage.addDevice(deviceInfo)
} }
fun removeRememberedDevice(device: CastingDevice) { fun removeRememberedDevice(device: CastingDevice) {
val name = device.name ?: return; val name = device.name ?: return
_storage.removeDevice(name); _storage.removeDevice(name)
rememberedDevices.remove(device);
} }
private fun invokeInMainScopeIfRequired(action: () -> Unit){ private fun invokeInMainScopeIfRequired(action: () -> Unit){

View file

@ -7,7 +7,9 @@ import android.app.PendingIntent.getBroadcast
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.content.pm.PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -155,6 +157,9 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller; val packageInstaller: PackageInstaller = context.packageManager.packageInstaller;
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL); 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); val sessionId = packageInstaller.createSession(params);
session = packageInstaller.openSession(sessionId) session = packageInstaller.openSession(sessionId)

View file

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

View file

@ -633,7 +633,9 @@ class VideoDownload {
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt") val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" }) 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 { _ -> val statisticsCallback = StatisticsCallback { _ ->
//TODO: Show progress? //TODO: Show progress?

View file

@ -72,6 +72,10 @@ class PackageBridge : V8Package {
fun buildSpecVersion(): Int { fun buildSpecVersion(): Int {
return JSClientConstants.PLUGIN_SPEC_VERSION; return JSClientConstants.PLUGIN_SPEC_VERSION;
} }
@V8Property
fun buildPlatform(): String {
return "android";
}
@V8Function @V8Function
fun dispose(value: V8Value) { fun dispose(value: V8Value) {

View file

@ -70,8 +70,9 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
val lastPolycentricProfile = _lastPolycentricProfile; val lastPolycentricProfile = _lastPolycentricProfile;
var pager: IPager<IPlatformContent>? = null; var pager: IPager<IPlatformContent>? = null;
if (lastPolycentricProfile != null) if (lastPolycentricProfile != null && StatePolycentric.instance.enabled)
pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType); pager =
StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType);
if(pager == null) { if(pager == null) {
if(subType != null) if(subType != null)

View file

@ -47,6 +47,7 @@ import com.futo.platformplayer.selectHighestResolutionImage
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.adapters.ChannelTab import com.futo.platformplayer.views.adapters.ChannelTab
@ -135,6 +136,8 @@ class ChannelFragment : MainFragment() {
inflater.inflate(R.layout.fragment_channel, this) inflater.inflate(R.layout.fragment_channel, this)
_taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>({ fragment.lifecycleScope }, _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>({ fragment.lifecycleScope },
{ id -> { 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!!) 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> { }).success { setPolycentricProfile(it, animate = true) }.exception<Throwable> {
Logger.w(TAG, "Failed to load polycentric profile.", it) Logger.w(TAG, "Failed to load polycentric profile.", it)

View file

@ -2,9 +2,11 @@ package com.futo.platformplayer.fragment.mainactivity.main
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.allViews
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
@ -23,6 +25,8 @@ import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.ToggleBar
import com.futo.platformplayer.views.others.RadioGroupView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -114,6 +118,25 @@ class ContentSearchResultsFragment : MainFragment() {
} }
setPreviewsEnabled(Settings.instance.search.previewFeedItems); setPreviewsEnabled(Settings.instance.search.previewFeedItems);
initializeToolbar();
}
fun initializeToolbar(){
if(_toolbarContentView.allViews.any { it is RadioGroupView })
_toolbarContentView.removeView(_toolbarContentView.allViews.find { it is RadioGroupView });
val radioGroup = RadioGroupView(context);
radioGroup.onSelectedChange.subscribe {
if (it.size != 1)
setSearchType(SearchType.VIDEO);
else
setSearchType((it[0] ?: SearchType.VIDEO) as SearchType);
}
radioGroup?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true)
_toolbarContentView.addView(radioGroup);
} }
override fun cleanup() { override fun cleanup() {

View file

@ -14,10 +14,14 @@ import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.downloads.VideoDownload import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.services.DownloadService
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlaylists
@ -54,6 +58,15 @@ class DownloadsFragment : MainFragment() {
super.onResume() super.onResume()
_view?.reloadUI(); _view?.reloadUI();
if(StateDownloads.instance.getDownloading().any { it.state == VideoDownload.State.QUEUED } &&
!StateDownloads.instance.getDownloading().any { it.state == VideoDownload.State.DOWNLOADING } &&
Settings.instance.downloads.shouldDownload()) {
Logger.w(TAG, "Detected queued download, while not downloading, attempt recreating service");
StateApp.withContext {
DownloadService.getOrCreateService(it);
}
}
StateDownloads.instance.onDownloadsChanged.subscribe(this) { StateDownloads.instance.onDownloadsChanged.subscribe(this) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
try { try {

View file

@ -15,6 +15,8 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.* 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.IAsyncPager
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
@ -22,10 +24,14 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFrag
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer 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.HistoryListViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.others.TagsView import com.futo.platformplayer.views.others.TagsView
import com.futo.platformplayer.views.others.Toggle
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -68,6 +74,8 @@ class HistoryFragment : MainFragment() {
private var _pager: IPager<HistoryVideo>? = null; private var _pager: IPager<HistoryVideo>? = null;
private val _results = arrayListOf<HistoryVideo>(); private val _results = arrayListOf<HistoryVideo>();
private var _loading = false; private var _loading = false;
private val _toggleBar: ToggleBar
private var _togglePluginsDisabled = hashSetOf<String>()
private var _automaticNextPageCounter = 0; private var _automaticNextPageCounter = 0;
@ -79,6 +87,7 @@ class HistoryFragment : MainFragment() {
_clearSearch = findViewById(R.id.button_clear_search); _clearSearch = findViewById(R.id.button_clear_search);
_editSearch = findViewById(R.id.edit_search); _editSearch = findViewById(R.id.edit_search);
_tagsView = findViewById(R.id.tags_text); _tagsView = findViewById(R.id.tags_text);
_toggleBar = findViewById(R.id.toggle_bar)
_tagsView.setPairs(listOf( _tagsView.setPairs(listOf(
Pair(context.getString(R.string.last_hour), 60L), Pair(context.getString(R.string.last_hour), 60L),
Pair(context.getString(R.string.last_24_hours), 24L * 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) 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(), _adapter = InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
{ _results.size }, { _results.size },
{ view, _ -> { view, _ ->
@ -162,14 +187,15 @@ class HistoryFragment : MainFragment() {
else else
it.nextPage(); it.nextPage();
return@TaskHandler it.getResults(); return@TaskHandler filterResults(it.getResults());
}).success { }).success {
setLoading(false); setLoading(false);
val posBefore = _results.size; val posBefore = _results.size;
_results.addAll(it); val res = filterResults(it)
_adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), it.size); _results.addAll(res);
ensureEnoughContentVisible(it) _adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), res.size);
ensureEnoughContentVisible(res)
}.exception<Throwable> { }.exception<Throwable> {
Logger.w(TAG, "Failed to load next page.", it); Logger.w(TAG, "Failed to load next page.", it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.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() { private fun updatePager() {
val query = _editSearch.text.toString(); val query = _editSearch.text.toString();
if (_editSearch.text.isNotEmpty()) { if (_editSearch.text.isNotEmpty()) {
@ -246,11 +276,22 @@ class HistoryFragment : MainFragment() {
_adapter.setLoading(loading); _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>) { private fun loadPagerInternal(pager: IPager<HistoryVideo>) {
Logger.i(TAG, "Setting new internal pager on feed"); Logger.i(TAG, "Setting new internal pager on feed");
_results.clear(); _results.clear();
val toAdd = pager.getResults(); val toAdd = filterResults(pager.getResults())
_results.addAll(toAdd); _results.addAll(toAdd);
_adapter.notifyDataSetChanged(); _adapter.notifyDataSetChanged();
ensureEnoughContentVisible(toAdd) ensureEnoughContentVisible(toAdd)

View file

@ -168,7 +168,12 @@ class PostDetailFragment : MainFragment {
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment); UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope }; } 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) } .success { it -> setPolycentricProfile(it, animate = true) }
.exception<Throwable> { .exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it); Logger.w(TAG, "Failed to load claims.", it);
@ -327,6 +332,10 @@ class PostDetailFragment : MainFragment {
val version = _version; val version = _version;
_rating.onLikeDislikeUpdated.remove(this); _rating.onLikeDislikeUpdated.remove(this);
if (!StatePolycentric.instance.enabled)
return
_fragment.lifecycleScope.launch(Dispatchers.IO) { _fragment.lifecycleScope.launch(Dispatchers.IO) {
if (version != _version) { if (version != _version) {
return@launch; return@launch;

View file

@ -18,6 +18,7 @@ import com.futo.platformplayer.activities.AddSourceOptionsActivity
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment 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.StatePlatform
import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
@ -44,6 +45,7 @@ class SourcesFragment : MainFragment() {
if(topBar is AddTopBarFragment) { if(topBar is AddTopBarFragment) {
(topBar as AddTopBarFragment).onAdd.clear(); (topBar as AddTopBarFragment).onAdd.clear();
(topBar as AddTopBarFragment).onAdd.subscribe { (topBar as AddTopBarFragment).onAdd.subscribe {
StateApp.instance.preventPictureInPicture.emit();
startActivity(Intent(requireContext(), AddSourceOptionsActivity::class.java)); startActivity(Intent(requireContext(), AddSourceOptionsActivity::class.java));
}; };
} }
@ -93,6 +95,7 @@ class SourcesFragment : MainFragment() {
findViewById<LinearLayout>(R.id.plugin_disclaimer).isVisible = false; findViewById<LinearLayout>(R.id.plugin_disclaimer).isVisible = false;
} }
findViewById<BigButton>(R.id.button_add_sources).onClick.subscribe { findViewById<BigButton>(R.id.button_add_sources).onClick.subscribe {
StateApp.instance.preventPictureInPicture.emit();
fragment.startActivity(Intent(context, AddSourceOptionsActivity::class.java)); fragment.startActivity(Intent(context, AddSourceOptionsActivity::class.java));
}; };

View file

@ -455,6 +455,10 @@ class VideoDetailFragment() : MainFragment() {
activity?.enterPictureInPictureMode(params); activity?.enterPictureInPictureMode(params);
} }
} }
if (isFullscreen) {
viewDetail?.restoreBrightness()
}
} }
fun forcePictureInPicture() { fun forcePictureInPicture() {
@ -487,6 +491,10 @@ class VideoDetailFragment() : MainFragment() {
_isActive = true; _isActive = true;
_leavingPiP = false; _leavingPiP = false;
if (isFullscreen) {
_viewDetail?.saveBrightness()
}
_viewDetail?.let { _viewDetail?.let {
Logger.v(TAG, "onResume preventPictureInPicture=false"); Logger.v(TAG, "onResume preventPictureInPicture=false");
it.preventPictureInPicture = false; it.preventPictureInPicture = false;

View file

@ -46,6 +46,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.LiveChatManager import com.futo.platformplayer.api.media.LiveChatManager
import com.futo.platformplayer.api.media.PlatformID 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.RoundButton
import com.futo.platformplayer.views.pills.RoundButtonGroup import com.futo.platformplayer.views.pills.RoundButtonGroup
import com.futo.platformplayer.views.platform.PlatformIndicator 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.segments.CommentsList
import com.futo.platformplayer.views.subscriptions.SubscribeButton import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.platformplayer.views.video.FutoVideoPlayer import com.futo.platformplayer.views.video.FutoVideoPlayer
@ -571,7 +571,7 @@ class VideoDetailView : ConstraintLayout {
_player.setIsReplay(true); _player.setIsReplay(true);
val searchVideo = StatePlayer.instance.getCurrentQueueItem(); val searchVideo = StatePlayer.instance.getCurrentQueueItem();
if (searchVideo is SerializedPlatformVideo?) { if (searchVideo is SerializedPlatformVideo? && Settings.instance.playback.deleteFromWatchLaterAuto) {
searchVideo?.let { StatePlaylists.instance.removeFromWatchLater(it) }; searchVideo?.let { StatePlaylists.instance.removeFromWatchLater(it) };
} }
@ -688,6 +688,20 @@ class VideoDetailView : ConstraintLayout {
Logger.i(TAG, "MediaControlReceiver.onCloseReceived") Logger.i(TAG, "MediaControlReceiver.onCloseReceived")
onClose.emit() 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); }; MediaControlReceiver.onSeekToReceived.subscribe(this) { handleSeek(it); };
_container_content_description.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_description.onClose.subscribe { switchContentView(_container_content_main); };
@ -1141,6 +1155,7 @@ class VideoDetailView : ConstraintLayout {
MediaControlReceiver.onNextReceived.remove(this); MediaControlReceiver.onNextReceived.remove(this);
MediaControlReceiver.onPreviousReceived.remove(this); MediaControlReceiver.onPreviousReceived.remove(this);
MediaControlReceiver.onCloseReceived.remove(this); MediaControlReceiver.onCloseReceived.remove(this);
MediaControlReceiver.onBackgroundReceived.remove(this);
MediaControlReceiver.onSeekToReceived.remove(this); MediaControlReceiver.onSeekToReceived.remove(this);
val job = _jobHideResume; val job = _jobHideResume;
@ -1510,6 +1525,7 @@ class VideoDetailView : ConstraintLayout {
_rating.visibility = View.GONE; _rating.visibility = View.GONE;
if (StatePolycentric.instance.enabled) {
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val queryReferencesResponse = ApiMethods.getQueryReferences( val queryReferencesResponse = ApiMethods.getQueryReferences(
@ -1529,12 +1545,18 @@ class VideoDetailView : ConstraintLayout {
val likes = queryReferencesResponse.countsList[0]; val likes = queryReferencesResponse.countsList[0];
val dislikes = queryReferencesResponse.countsList[1]; val dislikes = queryReferencesResponse.countsList[1];
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/; val hasLiked =
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/; 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) { withContext(Dispatchers.Main) {
_rating.visibility = View.VISIBLE; _rating.visibility = View.VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked); _rating.setRating(
RatingLikeDislikes(likes, dislikes),
hasLiked,
hasDisliked
);
_rating.onLikeDislikeUpdated.subscribe(this) { args -> _rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (args.hasLiked) { if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like); args.processHandle.opinion(ref, Opinion.like);
@ -1566,6 +1588,7 @@ class VideoDetailView : ConstraintLayout {
_rating.visibility = View.GONE; _rating.visibility = View.GONE;
} }
} }
}
when (video.rating) { when (video.rating) {
is RatingLikeDislikes -> { is RatingLikeDislikes -> {
@ -2413,6 +2436,7 @@ class VideoDetailView : ConstraintLayout {
Logger.i(TAG, "handleFullScreen(fullscreen=$fullscreen)") Logger.i(TAG, "handleFullScreen(fullscreen=$fullscreen)")
if(fullscreen) { if(fullscreen) {
_container_content.visibility = GONE
_layoutPlayerContainer.setPadding(0, 0, 0, 0); _layoutPlayerContainer.setPadding(0, 0, 0, 0);
val lp = _container_content.layoutParams as LayoutParams; val lp = _container_content.layoutParams as LayoutParams;
@ -2426,6 +2450,7 @@ class VideoDetailView : ConstraintLayout {
setProgressBarOverlayed(null); setProgressBarOverlayed(null);
} }
else { else {
_container_content.visibility = VISIBLE
_layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt()); _layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt());
val lp = _container_content.layoutParams as LayoutParams; 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) { fun setFullscreen(fullscreen : Boolean) {
Logger.i(TAG, "setFullscreen(fullscreen=$fullscreen)") Logger.i(TAG, "setFullscreen(fullscreen=$fullscreen)")
_player.setFullScreen(fullscreen) _player.setFullScreen(fullscreen)
@ -2725,10 +2757,11 @@ class VideoDetailView : ConstraintLayout {
else 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)); 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() return PictureInPictureParams.Builder()
.setAspectRatio(Rational(videoSourceWidth, videoSourceHeight)) .setAspectRatio(Rational(videoSourceWidth, videoSourceHeight))
.setSourceRectHint(r) .setSourceRectHint(r)
.setActions(listOf(playpauseAction)) .setActions(listOf(toBackgroundAction, playpauseAction))
.build(); .build();
} }
@ -3041,7 +3074,12 @@ class VideoDetailView : ConstraintLayout {
Logger.w(TAG, "Failed to load recommendations.", it); 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) } .success { it -> setPolycentricProfile(it, animate = true) }
.exception<Throwable> { .exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it); Logger.w(TAG, "Failed to load claims.", it);
@ -3113,10 +3151,6 @@ class VideoDetailView : ConstraintLayout {
fun applyFragment(frag: VideoDetailFragment) { fun applyFragment(frag: VideoDetailFragment) {
fragment = frag; fragment = frag;
fragment.onMinimize.subscribe {
_liveChat?.stop();
_container_content_liveChat.close();
}
} }

View file

@ -1,11 +0,0 @@
package com.futo.platformplayer.mdns
data class BroadcastService(
val deviceName: String,
val serviceName: String,
val port: UShort,
val ttl: UInt,
val weight: UShort,
val priority: UShort,
val texts: List<String>? = null
)

View file

@ -1,93 +0,0 @@
package com.futo.platformplayer.mdns
import java.nio.ByteBuffer
import java.nio.ByteOrder
enum class QueryResponse(val value: Byte) {
Query(0),
Response(1)
}
enum class DnsOpcode(val value: Byte) {
StandardQuery(0),
InverseQuery(1),
ServerStatusRequest(2)
}
enum class DnsResponseCode(val value: Byte) {
NoError(0),
FormatError(1),
ServerFailure(2),
NameError(3),
NotImplemented(4),
Refused(5)
}
data class DnsPacketHeader(
val identifier: UShort,
val queryResponse: Int,
val opcode: Int,
val authoritativeAnswer: Boolean,
val truncated: Boolean,
val recursionDesired: Boolean,
val recursionAvailable: Boolean,
val answerAuthenticated: Boolean,
val nonAuthenticatedData: Boolean,
val responseCode: DnsResponseCode
)
data class DnsPacket(
val header: DnsPacketHeader,
val questions: List<DnsQuestion>,
val answers: List<DnsResourceRecord>,
val authorities: List<DnsResourceRecord>,
val additionals: List<DnsResourceRecord>
) {
companion object {
fun parse(data: ByteArray): DnsPacket {
val span = data.asUByteArray()
val flags = (span[2].toInt() shl 8 or span[3].toInt()).toUShort()
val questionCount = (span[4].toInt() shl 8 or span[5].toInt()).toUShort()
val answerCount = (span[6].toInt() shl 8 or span[7].toInt()).toUShort()
val authorityCount = (span[8].toInt() shl 8 or span[9].toInt()).toUShort()
val additionalCount = (span[10].toInt() shl 8 or span[11].toInt()).toUShort()
var position = 12
val questions = List(questionCount.toInt()) {
DnsQuestion.parse(data, position).also { position = it.second }
}.map { it.first }
val answers = List(answerCount.toInt()) {
DnsResourceRecord.parse(data, position).also { position = it.second }
}.map { it.first }
val authorities = List(authorityCount.toInt()) {
DnsResourceRecord.parse(data, position).also { position = it.second }
}.map { it.first }
val additionals = List(additionalCount.toInt()) {
DnsResourceRecord.parse(data, position).also { position = it.second }
}.map { it.first }
return DnsPacket(
header = DnsPacketHeader(
identifier = (span[0].toInt() shl 8 or span[1].toInt()).toUShort(),
queryResponse = ((flags.toUInt() shr 15) and 0b1u).toInt(),
opcode = ((flags.toUInt() shr 11) and 0b1111u).toInt(),
authoritativeAnswer = (flags.toInt() shr 10) and 0b1 != 0,
truncated = (flags.toInt() shr 9) and 0b1 != 0,
recursionDesired = (flags.toInt() shr 8) and 0b1 != 0,
recursionAvailable = (flags.toInt() shr 7) and 0b1 != 0,
answerAuthenticated = (flags.toInt() shr 5) and 0b1 != 0,
nonAuthenticatedData = (flags.toInt() shr 4) and 0b1 != 0,
responseCode = DnsResponseCode.entries[flags.toInt() and 0b1111]
),
questions = questions,
answers = answers,
authorities = authorities,
additionals = additionals
)
}
}
}

View file

@ -1,110 +0,0 @@
package com.futo.platformplayer.mdns
import com.futo.platformplayer.mdns.Extensions.readDomainName
import java.nio.ByteBuffer
import java.nio.ByteOrder
enum class QuestionType(val value: UShort) {
A(1u),
NS(2u),
MD(3u),
MF(4u),
CNAME(5u),
SOA(6u),
MB(7u),
MG(8u),
MR(9u),
NULL(10u),
WKS(11u),
PTR(12u),
HINFO(13u),
MINFO(14u),
MX(15u),
TXT(16u),
RP(17u),
AFSDB(18u),
SIG(24u),
KEY(25u),
AAAA(28u),
LOC(29u),
SRV(33u),
NAPTR(35u),
KX(36u),
CERT(37u),
DNAME(39u),
APL(42u),
DS(43u),
SSHFP(44u),
IPSECKEY(45u),
RRSIG(46u),
NSEC(47u),
DNSKEY(48u),
DHCID(49u),
NSEC3(50u),
NSEC3PARAM(51u),
TSLA(52u),
SMIMEA(53u),
HIP(55u),
CDS(59u),
CDNSKEY(60u),
OPENPGPKEY(61u),
CSYNC(62u),
ZONEMD(63u),
SVCB(64u),
HTTPS(65u),
EUI48(108u),
EUI64(109u),
TKEY(249u),
TSIG(250u),
URI(256u),
CAA(257u),
TA(32768u),
DLV(32769u),
AXFR(252u),
IXFR(251u),
OPT(41u),
MAILB(253u),
MALA(254u),
All(252u)
}
enum class QuestionClass(val value: UShort) {
IN(1u),
CS(2u),
CH(3u),
HS(4u),
All(255u)
}
data class DnsQuestion(
override val name: String,
override val type: Int,
override val clazz: Int,
val queryUnicast: Boolean
) : DnsResourceRecordBase(name, type, clazz) {
companion object {
fun parse(data: ByteArray, startPosition: Int): Pair<DnsQuestion, Int> {
val span = data.asUByteArray()
var position = startPosition
val qname = span.readDomainName(position).also { position = it.second }
val qtype = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
position += 2
val qclass = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
position += 2
return DnsQuestion(
name = qname.first,
type = qtype.toInt(),
queryUnicast = ((qclass.toInt() shr 15) and 0b1) != 0,
clazz = qclass.toInt() and 0b111111111111111
) to position
}
}
}
open class DnsResourceRecordBase(
open val name: String,
open val type: Int,
open val clazz: Int
)

View file

@ -1,514 +0,0 @@
package com.futo.platformplayer.mdns
import com.futo.platformplayer.mdns.Extensions.readDomainName
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.charset.StandardCharsets
import kotlin.math.pow
import java.net.InetAddress
data class PTRRecord(val domainName: String)
data class ARecord(val address: InetAddress)
data class AAAARecord(val address: InetAddress)
data class MXRecord(val preference: UShort, val exchange: String)
data class CNAMERecord(val cname: String)
data class TXTRecord(val texts: List<String>)
data class SOARecord(
val primaryNameServer: String,
val responsibleAuthorityMailbox: String,
val serialNumber: Int,
val refreshInterval: Int,
val retryInterval: Int,
val expiryLimit: Int,
val minimumTTL: Int
)
data class SRVRecord(val priority: UShort, val weight: UShort, val port: UShort, val target: String)
data class NSRecord(val nameServer: String)
data class CAARecord(val flags: Byte, val tag: String, val value: String)
data class HINFORecord(val cpu: String, val os: String)
data class RPRecord(val mailbox: String, val txtDomainName: String)
data class AFSDBRecord(val subtype: UShort, val hostname: String)
data class LOCRecord(
val version: Byte,
val size: Double,
val horizontalPrecision: Double,
val verticalPrecision: Double,
val latitude: Double,
val longitude: Double,
val altitude: Double
) {
companion object {
fun decodeSizeOrPrecision(coded: Byte): Double {
val baseValue = (coded.toInt() shr 4) and 0x0F
val exponent = coded.toInt() and 0x0F
return baseValue * 10.0.pow(exponent.toDouble())
}
fun decodeLatitudeOrLongitude(coded: Int): Double {
val arcSeconds = coded / 1E3
return arcSeconds / 3600.0
}
fun decodeAltitude(coded: Int): Double {
return (coded / 100.0) - 100000.0
}
}
}
data class NAPTRRecord(
val order: UShort,
val preference: UShort,
val flags: String,
val services: String,
val regexp: String,
val replacement: String
)
data class RRSIGRecord(
val typeCovered: UShort,
val algorithm: Byte,
val labels: Byte,
val originalTTL: UInt,
val signatureExpiration: UInt,
val signatureInception: UInt,
val keyTag: UShort,
val signersName: String,
val signature: ByteArray
)
data class KXRecord(val preference: UShort, val exchanger: String)
data class CERTRecord(val type: UShort, val keyTag: UShort, val algorithm: Byte, val certificate: ByteArray)
data class DNAMERecord(val target: String)
data class DSRecord(val keyTag: UShort, val algorithm: Byte, val digestType: Byte, val digest: ByteArray)
data class SSHFPRecord(val algorithm: Byte, val fingerprintType: Byte, val fingerprint: ByteArray)
data class TLSARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray)
data class SMIMEARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray)
data class URIRecord(val priority: UShort, val weight: UShort, val target: String)
data class NSECRecord(val ownerName: String, val typeBitMaps: List<Pair<Byte, ByteArray>>)
data class NSEC3Record(
val hashAlgorithm: Byte,
val flags: Byte,
val iterations: UShort,
val salt: ByteArray,
val nextHashedOwnerName: ByteArray,
val typeBitMaps: List<UShort>
)
data class NSEC3PARAMRecord(val hashAlgorithm: Byte, val flags: Byte, val iterations: UShort, val salt: ByteArray)
data class SPFRecord(val texts: List<String>)
data class TKEYRecord(
val algorithm: String,
val inception: UInt,
val expiration: UInt,
val mode: UShort,
val error: UShort,
val keyData: ByteArray,
val otherData: ByteArray
)
data class TSIGRecord(
val algorithmName: String,
val timeSigned: UInt,
val fudge: UShort,
val mac: ByteArray,
val originalID: UShort,
val error: UShort,
val otherData: ByteArray
)
data class OPTRecordOption(val code: UShort, val data: ByteArray)
data class OPTRecord(val options: List<OPTRecordOption>)
class DnsReader(private val data: ByteArray, private var position: Int = 0, private val length: Int = data.size) {
private val endPosition: Int = position + length
fun readDomainName(): String {
return data.asUByteArray().readDomainName(position).also { position = it.second }.first
}
fun readDouble(): Double {
checkRemainingBytes(Double.SIZE_BYTES)
val result = ByteBuffer.wrap(data, position, Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).double
position += Double.SIZE_BYTES
return result
}
fun readInt16(): Short {
checkRemainingBytes(Short.SIZE_BYTES)
val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short
position += Short.SIZE_BYTES
return result
}
fun readInt32(): Int {
checkRemainingBytes(Int.SIZE_BYTES)
val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int
position += Int.SIZE_BYTES
return result
}
fun readInt64(): Long {
checkRemainingBytes(Long.SIZE_BYTES)
val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long
position += Long.SIZE_BYTES
return result
}
fun readSingle(): Float {
checkRemainingBytes(Float.SIZE_BYTES)
val result = ByteBuffer.wrap(data, position, Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).float
position += Float.SIZE_BYTES
return result
}
fun readByte(): Byte {
checkRemainingBytes(Byte.SIZE_BYTES)
return data[position++]
}
fun readBytes(length: Int): ByteArray {
checkRemainingBytes(length)
return ByteArray(length).also { data.copyInto(it, startIndex = position, endIndex = position + length) }
.also { position += length }
}
fun readUInt16(): UShort {
checkRemainingBytes(Short.SIZE_BYTES)
val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short.toUShort()
position += Short.SIZE_BYTES
return result
}
fun readUInt32(): UInt {
checkRemainingBytes(Int.SIZE_BYTES)
val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int.toUInt()
position += Int.SIZE_BYTES
return result
}
fun readUInt64(): ULong {
checkRemainingBytes(Long.SIZE_BYTES)
val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long.toULong()
position += Long.SIZE_BYTES
return result
}
fun readString(): String {
val length = data[position++].toInt()
checkRemainingBytes(length)
return String(data, position, length, StandardCharsets.UTF_8).also { position += length }
}
private fun checkRemainingBytes(requiredBytes: Int) {
if (position + requiredBytes > endPosition) throw IndexOutOfBoundsException()
}
fun readRPRecord(): RPRecord {
return RPRecord(readDomainName(), readDomainName())
}
fun readKXRecord(): KXRecord {
val preference = readUInt16()
val exchanger = readDomainName()
return KXRecord(preference, exchanger)
}
fun readCERTRecord(): CERTRecord {
val type = readUInt16()
val keyTag = readUInt16()
val algorithm = readByte()
val certificateLength = readUInt16().toInt() - 5
val certificate = readBytes(certificateLength)
return CERTRecord(type, keyTag, algorithm, certificate)
}
fun readPTRRecord(): PTRRecord {
return PTRRecord(readDomainName())
}
fun readARecord(): ARecord {
val address = readBytes(4)
return ARecord(InetAddress.getByAddress(address))
}
fun readAAAARecord(): AAAARecord {
val address = readBytes(16)
return AAAARecord(InetAddress.getByAddress(address))
}
fun readMXRecord(): MXRecord {
val preference = readUInt16()
val exchange = readDomainName()
return MXRecord(preference, exchange)
}
fun readCNAMERecord(): CNAMERecord {
return CNAMERecord(readDomainName())
}
fun readTXTRecord(): TXTRecord {
val texts = mutableListOf<String>()
while (position < endPosition) {
val textLength = data[position++].toInt()
checkRemainingBytes(textLength)
val text = String(data, position, textLength, StandardCharsets.UTF_8)
texts.add(text)
position += textLength
}
return TXTRecord(texts)
}
fun readSOARecord(): SOARecord {
val primaryNameServer = readDomainName()
val responsibleAuthorityMailbox = readDomainName()
val serialNumber = readInt32()
val refreshInterval = readInt32()
val retryInterval = readInt32()
val expiryLimit = readInt32()
val minimumTTL = readInt32()
return SOARecord(primaryNameServer, responsibleAuthorityMailbox, serialNumber, refreshInterval, retryInterval, expiryLimit, minimumTTL)
}
fun readSRVRecord(): SRVRecord {
val priority = readUInt16()
val weight = readUInt16()
val port = readUInt16()
val target = readDomainName()
return SRVRecord(priority, weight, port, target)
}
fun readNSRecord(): NSRecord {
return NSRecord(readDomainName())
}
fun readCAARecord(): CAARecord {
val length = readUInt16().toInt()
val flags = readByte()
val tagLength = readByte().toInt()
val tag = String(data, position, tagLength, StandardCharsets.US_ASCII).also { position += tagLength }
val valueLength = length - 1 - 1 - tagLength
val value = String(data, position, valueLength, StandardCharsets.US_ASCII).also { position += valueLength }
return CAARecord(flags, tag, value)
}
fun readHINFORecord(): HINFORecord {
val cpuLength = readByte().toInt()
val cpu = String(data, position, cpuLength, StandardCharsets.US_ASCII).also { position += cpuLength }
val osLength = readByte().toInt()
val os = String(data, position, osLength, StandardCharsets.US_ASCII).also { position += osLength }
return HINFORecord(cpu, os)
}
fun readAFSDBRecord(): AFSDBRecord {
return AFSDBRecord(readUInt16(), readDomainName())
}
fun readLOCRecord(): LOCRecord {
val version = readByte()
val size = LOCRecord.decodeSizeOrPrecision(readByte())
val horizontalPrecision = LOCRecord.decodeSizeOrPrecision(readByte())
val verticalPrecision = LOCRecord.decodeSizeOrPrecision(readByte())
val latitudeCoded = readInt32()
val longitudeCoded = readInt32()
val altitudeCoded = readInt32()
val latitude = LOCRecord.decodeLatitudeOrLongitude(latitudeCoded)
val longitude = LOCRecord.decodeLatitudeOrLongitude(longitudeCoded)
val altitude = LOCRecord.decodeAltitude(altitudeCoded)
return LOCRecord(version, size, horizontalPrecision, verticalPrecision, latitude, longitude, altitude)
}
fun readNAPTRRecord(): NAPTRRecord {
val order = readUInt16()
val preference = readUInt16()
val flags = readString()
val services = readString()
val regexp = readString()
val replacement = readDomainName()
return NAPTRRecord(order, preference, flags, services, regexp, replacement)
}
fun readDNAMERecord(): DNAMERecord {
return DNAMERecord(readDomainName())
}
fun readDSRecord(): DSRecord {
val keyTag = readUInt16()
val algorithm = readByte()
val digestType = readByte()
val digestLength = readUInt16().toInt() - 4
val digest = readBytes(digestLength)
return DSRecord(keyTag, algorithm, digestType, digest)
}
fun readSSHFPRecord(): SSHFPRecord {
val algorithm = readByte()
val fingerprintType = readByte()
val fingerprintLength = readUInt16().toInt() - 2
val fingerprint = readBytes(fingerprintLength)
return SSHFPRecord(algorithm, fingerprintType, fingerprint)
}
fun readTLSARecord(): TLSARecord {
val usage = readByte()
val selector = readByte()
val matchingType = readByte()
val dataLength = readUInt16().toInt() - 3
val certificateAssociationData = readBytes(dataLength)
return TLSARecord(usage, selector, matchingType, certificateAssociationData)
}
fun readSMIMEARecord(): SMIMEARecord {
val usage = readByte()
val selector = readByte()
val matchingType = readByte()
val dataLength = readUInt16().toInt() - 3
val certificateAssociationData = readBytes(dataLength)
return SMIMEARecord(usage, selector, matchingType, certificateAssociationData)
}
fun readURIRecord(): URIRecord {
val priority = readUInt16()
val weight = readUInt16()
val length = readUInt16().toInt()
val target = String(data, position, length, StandardCharsets.US_ASCII).also { position += length }
return URIRecord(priority, weight, target)
}
fun readRRSIGRecord(): RRSIGRecord {
val typeCovered = readUInt16()
val algorithm = readByte()
val labels = readByte()
val originalTTL = readUInt32()
val signatureExpiration = readUInt32()
val signatureInception = readUInt32()
val keyTag = readUInt16()
val signersName = readDomainName()
val signatureLength = readUInt16().toInt()
val signature = readBytes(signatureLength)
return RRSIGRecord(
typeCovered,
algorithm,
labels,
originalTTL,
signatureExpiration,
signatureInception,
keyTag,
signersName,
signature
)
}
fun readNSECRecord(): NSECRecord {
val ownerName = readDomainName()
val typeBitMaps = mutableListOf<Pair<Byte, ByteArray>>()
while (position < endPosition) {
val windowBlock = readByte()
val bitmapLength = readByte().toInt()
val bitmap = readBytes(bitmapLength)
typeBitMaps.add(windowBlock to bitmap)
}
return NSECRecord(ownerName, typeBitMaps)
}
fun readNSEC3Record(): NSEC3Record {
val hashAlgorithm = readByte()
val flags = readByte()
val iterations = readUInt16()
val saltLength = readByte().toInt()
val salt = readBytes(saltLength)
val hashLength = readByte().toInt()
val nextHashedOwnerName = readBytes(hashLength)
val bitMapLength = readUInt16().toInt()
val typeBitMaps = mutableListOf<UShort>()
val endPos = position + bitMapLength
while (position < endPos) {
typeBitMaps.add(readUInt16())
}
return NSEC3Record(hashAlgorithm, flags, iterations, salt, nextHashedOwnerName, typeBitMaps)
}
fun readNSEC3PARAMRecord(): NSEC3PARAMRecord {
val hashAlgorithm = readByte()
val flags = readByte()
val iterations = readUInt16()
val saltLength = readByte().toInt()
val salt = readBytes(saltLength)
return NSEC3PARAMRecord(hashAlgorithm, flags, iterations, salt)
}
fun readSPFRecord(): SPFRecord {
val length = readUInt16().toInt()
val texts = mutableListOf<String>()
val endPos = position + length
while (position < endPos) {
val textLength = readByte().toInt()
val text = String(data, position, textLength, StandardCharsets.US_ASCII).also { position += textLength }
texts.add(text)
}
return SPFRecord(texts)
}
fun readTKEYRecord(): TKEYRecord {
val algorithm = readDomainName()
val inception = readUInt32()
val expiration = readUInt32()
val mode = readUInt16()
val error = readUInt16()
val keySize = readUInt16().toInt()
val keyData = readBytes(keySize)
val otherSize = readUInt16().toInt()
val otherData = readBytes(otherSize)
return TKEYRecord(algorithm, inception, expiration, mode, error, keyData, otherData)
}
fun readTSIGRecord(): TSIGRecord {
val algorithmName = readDomainName()
val timeSigned = readUInt32()
val fudge = readUInt16()
val macSize = readUInt16().toInt()
val mac = readBytes(macSize)
val originalID = readUInt16()
val error = readUInt16()
val otherSize = readUInt16().toInt()
val otherData = readBytes(otherSize)
return TSIGRecord(algorithmName, timeSigned, fudge, mac, originalID, error, otherData)
}
fun readOPTRecord(): OPTRecord {
val options = mutableListOf<OPTRecordOption>()
while (position < endPosition) {
val optionCode = readUInt16()
val optionLength = readUInt16().toInt()
val optionData = readBytes(optionLength)
options.add(OPTRecordOption(optionCode, optionData))
}
return OPTRecord(options)
}
}

View file

@ -1,117 +0,0 @@
package com.futo.platformplayer.mdns
import com.futo.platformplayer.mdns.Extensions.readDomainName
enum class ResourceRecordType(val value: UShort) {
None(0u),
A(1u),
NS(2u),
MD(3u),
MF(4u),
CNAME(5u),
SOA(6u),
MB(7u),
MG(8u),
MR(9u),
NULL(10u),
WKS(11u),
PTR(12u),
HINFO(13u),
MINFO(14u),
MX(15u),
TXT(16u),
RP(17u),
AFSDB(18u),
SIG(24u),
KEY(25u),
AAAA(28u),
LOC(29u),
SRV(33u),
NAPTR(35u),
KX(36u),
CERT(37u),
DNAME(39u),
APL(42u),
DS(43u),
SSHFP(44u),
IPSECKEY(45u),
RRSIG(46u),
NSEC(47u),
DNSKEY(48u),
DHCID(49u),
NSEC3(50u),
NSEC3PARAM(51u),
TSLA(52u),
SMIMEA(53u),
HIP(55u),
CDS(59u),
CDNSKEY(60u),
OPENPGPKEY(61u),
CSYNC(62u),
ZONEMD(63u),
SVCB(64u),
HTTPS(65u),
EUI48(108u),
EUI64(109u),
TKEY(249u),
TSIG(250u),
URI(256u),
CAA(257u),
TA(32768u),
DLV(32769u),
AXFR(252u),
IXFR(251u),
OPT(41u)
}
enum class ResourceRecordClass(val value: UShort) {
IN(1u),
CS(2u),
CH(3u),
HS(4u)
}
data class DnsResourceRecord(
override val name: String,
override val type: Int,
override val clazz: Int,
val timeToLive: UInt,
val cacheFlush: Boolean,
val dataPosition: Int = -1,
val dataLength: Int = -1,
private val data: ByteArray? = null
) : DnsResourceRecordBase(name, type, clazz) {
companion object {
fun parse(data: ByteArray, startPosition: Int): Pair<DnsResourceRecord, Int> {
val span = data.asUByteArray()
var position = startPosition
val name = span.readDomainName(position).also { position = it.second }
val type = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
position += 2
val clazz = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
position += 2
val ttl = (span[position].toInt() shl 24 or (span[position + 1].toInt() shl 16) or
(span[position + 2].toInt() shl 8) or span[position + 3].toInt()).toUInt()
position += 4
val rdlength = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
val rdposition = position + 2
position += 2 + rdlength.toInt()
return DnsResourceRecord(
name = name.first,
type = type.toInt(),
clazz = clazz.toInt() and 0b1111111_11111111,
timeToLive = ttl,
cacheFlush = ((clazz.toInt() shr 15) and 0b1) != 0,
dataPosition = rdposition,
dataLength = rdlength.toInt(),
data = data
) to position
}
}
fun getDataReader(): DnsReader {
return DnsReader(data!!, dataPosition, dataLength)
}
}

View file

@ -1,208 +0,0 @@
package com.futo.platformplayer.mdns
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.charset.StandardCharsets
class DnsWriter {
private val data = mutableListOf<Byte>()
private val namePositions = mutableMapOf<String, Int>()
fun toByteArray(): ByteArray = data.toByteArray()
fun writePacket(
header: DnsPacketHeader,
questionCount: Int? = null, questionWriter: ((DnsWriter, Int) -> Unit)? = null,
answerCount: Int? = null, answerWriter: ((DnsWriter, Int) -> Unit)? = null,
authorityCount: Int? = null, authorityWriter: ((DnsWriter, Int) -> Unit)? = null,
additionalsCount: Int? = null, additionalWriter: ((DnsWriter, Int) -> Unit)? = null
) {
if (questionCount != null && questionWriter == null || questionCount == null && questionWriter != null)
throw Exception("When question count is given, question writer should also be given.")
if (answerCount != null && answerWriter == null || answerCount == null && answerWriter != null)
throw Exception("When answer count is given, answer writer should also be given.")
if (authorityCount != null && authorityWriter == null || authorityCount == null && authorityWriter != null)
throw Exception("When authority count is given, authority writer should also be given.")
if (additionalsCount != null && additionalWriter == null || additionalsCount == null && additionalWriter != null)
throw Exception("When additionals count is given, additional writer should also be given.")
writeHeader(header, questionCount ?: 0, answerCount ?: 0, authorityCount ?: 0, additionalsCount ?: 0)
repeat(questionCount ?: 0) { questionWriter?.invoke(this, it) }
repeat(answerCount ?: 0) { answerWriter?.invoke(this, it) }
repeat(authorityCount ?: 0) { authorityWriter?.invoke(this, it) }
repeat(additionalsCount ?: 0) { additionalWriter?.invoke(this, it) }
}
fun writeHeader(header: DnsPacketHeader, questionCount: Int, answerCount: Int, authorityCount: Int, additionalsCount: Int) {
write(header.identifier)
var flags: UShort = 0u
flags = flags or ((header.queryResponse.toUInt() and 0xFFFFu) shl 15).toUShort()
flags = flags or ((header.opcode.toUInt() and 0xFFFFu) shl 11).toUShort()
flags = flags or ((if (header.authoritativeAnswer) 1u else 0u) shl 10).toUShort()
flags = flags or ((if (header.truncated) 1u else 0u) shl 9).toUShort()
flags = flags or ((if (header.recursionDesired) 1u else 0u) shl 8).toUShort()
flags = flags or ((if (header.recursionAvailable) 1u else 0u) shl 7).toUShort()
flags = flags or ((if (header.answerAuthenticated) 1u else 0u) shl 5).toUShort()
flags = flags or ((if (header.nonAuthenticatedData) 1u else 0u) shl 4).toUShort()
flags = flags or header.responseCode.value.toUShort()
write(flags)
write(questionCount.toUShort())
write(answerCount.toUShort())
write(authorityCount.toUShort())
write(additionalsCount.toUShort())
}
fun writeDomainName(name: String) {
synchronized(namePositions) {
val labels = name.split('.')
for (label in labels) {
val nameAtOffset = name.substring(name.indexOf(label))
if (namePositions.containsKey(nameAtOffset)) {
val position = namePositions[nameAtOffset]!!
val pointer = (0b11000000_00000000 or position).toUShort()
write(pointer)
return
}
if (label.isNotEmpty()) {
val labelBytes = label.toByteArray(StandardCharsets.UTF_8)
val nameStartPos = data.size
write(labelBytes.size.toByte())
write(labelBytes)
namePositions[nameAtOffset] = nameStartPos
}
}
write(0.toByte())
}
}
fun write(value: DnsResourceRecord, dataWriter: (DnsWriter) -> Unit) {
writeDomainName(value.name)
write(value.type.toUShort())
val cls = ((if (value.cacheFlush) 1u else 0u) shl 15).toUShort() or value.clazz.toUShort()
write(cls)
write(value.timeToLive)
val lengthOffset = data.size
write(0.toUShort())
dataWriter(this)
val rdLength = data.size - lengthOffset - 2
val rdLengthBytes = ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN).putShort(rdLength.toShort()).array()
data[lengthOffset] = rdLengthBytes[0]
data[lengthOffset + 1] = rdLengthBytes[1]
}
fun write(value: DnsQuestion) {
writeDomainName(value.name)
write(value.type.toUShort())
write(((if (value.queryUnicast) 1u else 0u shl 15).toUShort() or value.clazz.toUShort()))
}
fun write(value: Double) {
val bytes = ByteBuffer.allocate(Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putDouble(value).array()
write(bytes)
}
fun write(value: Short) {
val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value).array()
write(bytes)
}
fun write(value: Int) {
val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value).array()
write(bytes)
}
fun write(value: Long) {
val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value).array()
write(bytes)
}
fun write(value: Float) {
val bytes = ByteBuffer.allocate(Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putFloat(value).array()
write(bytes)
}
fun write(value: Byte) {
data.add(value)
}
fun write(value: ByteArray) {
data.addAll(value.asIterable())
}
fun write(value: ByteArray, offset: Int, length: Int) {
data.addAll(value.slice(offset until offset + length))
}
fun write(value: UShort) {
val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value.toShort()).array()
write(bytes)
}
fun write(value: UInt) {
val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value.toInt()).array()
write(bytes)
}
fun write(value: ULong) {
val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value.toLong()).array()
write(bytes)
}
fun write(value: String) {
val bytes = value.toByteArray(StandardCharsets.UTF_8)
write(bytes.size.toByte())
write(bytes)
}
fun write(value: PTRRecord) {
writeDomainName(value.domainName)
}
fun write(value: ARecord) {
val bytes = value.address.address
if (bytes.size != 4) throw Exception("Unexpected amount of address bytes.")
write(bytes)
}
fun write(value: AAAARecord) {
val bytes = value.address.address
if (bytes.size != 16) throw Exception("Unexpected amount of address bytes.")
write(bytes)
}
fun write(value: TXTRecord) {
value.texts.forEach {
val bytes = it.toByteArray(StandardCharsets.UTF_8)
write(bytes.size.toByte())
write(bytes)
}
}
fun write(value: SRVRecord) {
write(value.priority)
write(value.weight)
write(value.port)
writeDomainName(value.target)
}
fun write(value: NSECRecord) {
writeDomainName(value.ownerName)
value.typeBitMaps.forEach { (windowBlock, bitmap) ->
write(windowBlock)
write(bitmap.size.toByte())
write(bitmap)
}
}
fun write(value: OPTRecord) {
value.options.forEach { option ->
write(option.code)
write(option.data.size.toUShort())
write(option.data)
}
}
}

View file

@ -1,63 +0,0 @@
package com.futo.platformplayer.mdns
import android.util.Log
object Extensions {
fun ByteArray.toByteDump(): String {
val result = StringBuilder()
for (i in indices) {
result.append(String.format("%02X ", this[i]))
if ((i + 1) % 16 == 0 || i == size - 1) {
val padding = 3 * (16 - (i % 16 + 1))
if (i == size - 1 && (i + 1) % 16 != 0) result.append(" ".repeat(padding))
result.append("; ")
val start = i - (i % 16)
val end = minOf(i, size - 1)
for (j in start..end) {
val ch = if (this[j] in 32..127) this[j].toChar() else '.'
result.append(ch)
}
if (i != size - 1) result.appendLine()
}
}
return result.toString()
}
fun UByteArray.readDomainName(startPosition: Int): Pair<String, Int> {
var position = startPosition
return readDomainName(position, 0)
}
private fun UByteArray.readDomainName(position: Int, depth: Int = 0): Pair<String, Int> {
if (depth > 16) throw Exception("Exceeded maximum recursion depth in DNS packet. Possible circular reference.")
val domainParts = mutableListOf<String>()
var newPosition = position
while (true) {
if (newPosition < 0)
println()
val length = this[newPosition].toUByte()
if ((length and 0b11000000u).toUInt() == 0b11000000u) {
val offset = (((length and 0b00111111u).toUInt()) shl 8) or this[newPosition + 1].toUInt()
val (part, _) = this.readDomainName(offset.toInt(), depth + 1)
domainParts.add(part)
newPosition += 2
break
} else if (length.toUInt() == 0u) {
newPosition++
break
} else {
newPosition++
val part = String(this.asByteArray(), newPosition, length.toInt(), Charsets.UTF_8)
domainParts.add(part)
newPosition += length.toInt()
}
}
return domainParts.joinToString(".") to newPosition
}
}

View file

@ -1,495 +0,0 @@
package com.futo.platformplayer.mdns
import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.*
import java.net.*
import java.util.*
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
class MDNSListener {
companion object {
private val TAG = "MDNSListener"
const val MulticastPort = 5353
val MulticastAddressIPv4: InetAddress = InetAddress.getByName("224.0.0.251")
val MulticastAddressIPv6: InetAddress = InetAddress.getByName("FF02::FB")
val MdnsEndpointIPv6: InetSocketAddress = InetSocketAddress(MulticastAddressIPv6, MulticastPort)
val MdnsEndpointIPv4: InetSocketAddress = InetSocketAddress(MulticastAddressIPv4, MulticastPort)
}
private val _lockObject = ReentrantLock()
private var _receiver4: MulticastSocket? = null
private var _receiver6: MulticastSocket? = null
private val _senders = mutableListOf<MulticastSocket>()
private val _nicMonitor = NICMonitor()
private val _serviceRecordAggregator = ServiceRecordAggregator()
private var _started = false
private var _threadReceiver4: Thread? = null
private var _threadReceiver6: Thread? = null
private var _scope: CoroutineScope? = null
var onPacket: ((DnsPacket) -> Unit)? = null
var onServicesUpdated: ((List<DnsService>) -> Unit)? = null
private val _recordLockObject = ReentrantLock()
private val _recordsA = mutableListOf<Pair<DnsResourceRecord, ARecord>>()
private val _recordsAAAA = mutableListOf<Pair<DnsResourceRecord, AAAARecord>>()
private val _recordsPTR = mutableListOf<Pair<DnsResourceRecord, PTRRecord>>()
private val _recordsTXT = mutableListOf<Pair<DnsResourceRecord, TXTRecord>>()
private val _recordsSRV = mutableListOf<Pair<DnsResourceRecord, SRVRecord>>()
private val _services = mutableListOf<BroadcastService>()
init {
_nicMonitor.added = { onNicsAdded(it) }
_nicMonitor.removed = { onNicsRemoved(it) }
_serviceRecordAggregator.onServicesUpdated = { onServicesUpdated?.invoke(it) }
}
fun start() {
if (_started) {
Logger.i(TAG, "Already started.")
return
}
_started = true
_scope = CoroutineScope(Dispatchers.IO);
Logger.i(TAG, "Starting")
_lockObject.withLock {
val receiver4 = MulticastSocket(null).apply {
reuseAddress = true
bind(InetSocketAddress(InetAddress.getByName("0.0.0.0"), MulticastPort))
}
_receiver4 = receiver4
val receiver6 = MulticastSocket(null).apply {
reuseAddress = true
bind(InetSocketAddress(InetAddress.getByName("::"), MulticastPort))
}
_receiver6 = receiver6
_nicMonitor.start()
_serviceRecordAggregator.start()
onNicsAdded(_nicMonitor.current)
_threadReceiver4 = Thread {
receiveLoop(receiver4)
}.apply { start() }
_threadReceiver6 = Thread {
receiveLoop(receiver6)
}.apply { start() }
}
}
fun queryServices(names: Array<String>) {
if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.")
val writer = DnsWriter()
writer.writePacket(
DnsPacketHeader(
identifier = 0u,
queryResponse = QueryResponse.Query.value.toInt(),
opcode = DnsOpcode.StandardQuery.value.toInt(),
truncated = false,
nonAuthenticatedData = false,
recursionDesired = false,
answerAuthenticated = false,
authoritativeAnswer = false,
recursionAvailable = false,
responseCode = DnsResponseCode.NoError
),
questionCount = names.size,
questionWriter = { w, i ->
w.write(
DnsQuestion(
name = names[i],
type = QuestionType.PTR.value.toInt(),
clazz = QuestionClass.IN.value.toInt(),
queryUnicast = false
)
)
}
)
send(writer.toByteArray())
}
private fun send(data: ByteArray) {
_lockObject.withLock {
for (sender in _senders) {
try {
val endPoint = if (sender.localAddress is Inet4Address) MdnsEndpointIPv4 else MdnsEndpointIPv6
sender.send(DatagramPacket(data, data.size, endPoint))
} catch (e: Exception) {
Logger.i(TAG, "Failed to send on ${sender.localSocketAddress}: ${e.message}.")
}
}
}
}
fun queryAllQuestions(names: Array<String>) {
if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.")
val questions = names.flatMap { _serviceRecordAggregator.getAllQuestions(it) }
questions.groupBy { it.name }.forEach { (_, questionsForHost) ->
val writer = DnsWriter()
writer.writePacket(
DnsPacketHeader(
identifier = 0u,
queryResponse = QueryResponse.Query.value.toInt(),
opcode = DnsOpcode.StandardQuery.value.toInt(),
truncated = false,
nonAuthenticatedData = false,
recursionDesired = false,
answerAuthenticated = false,
authoritativeAnswer = false,
recursionAvailable = false,
responseCode = DnsResponseCode.NoError
),
questionCount = questionsForHost.size,
questionWriter = { w, i -> w.write(questionsForHost[i]) }
)
send(writer.toByteArray())
}
}
private fun onNicsAdded(nics: List<NetworkInterface>) {
_lockObject.withLock {
if (!_started) return
val addresses = nics.flatMap { nic ->
nic.interfaceAddresses.map { it.address }
.filter { it is Inet4Address || (it is Inet6Address && it.isLinkLocalAddress) }
}
addresses.forEach { address ->
Logger.i(TAG, "New address discovered $address")
try {
when (address) {
is Inet4Address -> {
_receiver4?.let { receiver4 ->
//receiver4.setOption(StandardSocketOptions.IP_MULTICAST_IF, NetworkInterface.getByInetAddress(address))
receiver4.joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address))
}
val sender = MulticastSocket(null).apply {
reuseAddress = true
bind(InetSocketAddress(address, MulticastPort))
joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address))
}
_senders.add(sender)
}
is Inet6Address -> {
_receiver6?.let { receiver6 ->
//receiver6.setOption(StandardSocketOptions.IP_MULTICAST_IF, NetworkInterface.getByInetAddress(address))
receiver6.joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), NetworkInterface.getByInetAddress(address))
}
val sender = MulticastSocket(null).apply {
reuseAddress = true
bind(InetSocketAddress(address, MulticastPort))
joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), NetworkInterface.getByInetAddress(address))
}
_senders.add(sender)
}
else -> throw UnsupportedOperationException("Address type ${address.javaClass.name} is not supported.")
}
} catch (e: Exception) {
Logger.i(TAG, "Exception occurred when processing added address $address: ${e.message}.")
// Close the socket if there was an error
(_senders.lastOrNull() as? MulticastSocket)?.close()
}
}
}
if (nics.isNotEmpty()) {
try {
updateBroadcastRecords()
broadcastRecords()
} catch (e: Exception) {
Logger.i(TAG, "Exception occurred when broadcasting records: ${e.message}.")
}
}
}
private fun onNicsRemoved(nics: List<NetworkInterface>) {
_lockObject.withLock {
if (!_started) return
//TODO: Cleanup?
}
if (nics.isNotEmpty()) {
try {
updateBroadcastRecords()
broadcastRecords()
} catch (e: Exception) {
Logger.e(TAG, "Exception occurred when broadcasting records", e)
}
}
}
private fun receiveLoop(client: DatagramSocket) {
Logger.i(TAG, "Started receive loop")
val buffer = ByteArray(8972)
val packet = DatagramPacket(buffer, buffer.size)
while (_started) {
try {
client.receive(packet)
handleResult(packet)
} catch (e: Exception) {
Logger.e(TAG, "An exception occurred while handling UDP result:", e)
}
}
Logger.i(TAG, "Stopped receive loop")
}
fun broadcastService(
deviceName: String,
serviceName: String,
port: UShort,
ttl: UInt = 120u,
weight: UShort = 0u,
priority: UShort = 0u,
texts: List<String>? = null
) {
_recordLockObject.withLock {
_services.add(
BroadcastService(
deviceName = deviceName,
port = port,
priority = priority,
serviceName = serviceName,
texts = texts,
ttl = ttl,
weight = weight
)
)
}
updateBroadcastRecords()
broadcastRecords()
}
private fun updateBroadcastRecords() {
_recordLockObject.withLock {
_recordsSRV.clear()
_recordsPTR.clear()
_recordsA.clear()
_recordsAAAA.clear()
_recordsTXT.clear()
_services.forEach { service ->
val id = UUID.randomUUID().toString()
val deviceDomainName = "${service.deviceName}.${service.serviceName}"
val addressName = "$id.local"
_recordsSRV.add(
DnsResourceRecord(
clazz = ResourceRecordClass.IN.value.toInt(),
type = ResourceRecordType.SRV.value.toInt(),
timeToLive = service.ttl,
name = deviceDomainName,
cacheFlush = false
) to SRVRecord(
target = addressName,
port = service.port,
priority = service.priority,
weight = service.weight
)
)
_recordsPTR.add(
DnsResourceRecord(
clazz = ResourceRecordClass.IN.value.toInt(),
type = ResourceRecordType.PTR.value.toInt(),
timeToLive = service.ttl,
name = service.serviceName,
cacheFlush = false
) to PTRRecord(
domainName = deviceDomainName
)
)
val addresses = _nicMonitor.current.flatMap { nic ->
nic.interfaceAddresses.map { it.address }
}
addresses.forEach { address ->
when (address) {
is Inet4Address -> _recordsA.add(
DnsResourceRecord(
clazz = ResourceRecordClass.IN.value.toInt(),
type = ResourceRecordType.A.value.toInt(),
timeToLive = service.ttl,
name = addressName,
cacheFlush = false
) to ARecord(
address = address
)
)
is Inet6Address -> _recordsAAAA.add(
DnsResourceRecord(
clazz = ResourceRecordClass.IN.value.toInt(),
type = ResourceRecordType.AAAA.value.toInt(),
timeToLive = service.ttl,
name = addressName,
cacheFlush = false
) to AAAARecord(
address = address
)
)
else -> Logger.i(TAG, "Invalid address type: $address.")
}
}
if (service.texts != null) {
_recordsTXT.add(
DnsResourceRecord(
clazz = ResourceRecordClass.IN.value.toInt(),
type = ResourceRecordType.TXT.value.toInt(),
timeToLive = service.ttl,
name = deviceDomainName,
cacheFlush = false
) to TXTRecord(
texts = service.texts
)
)
}
}
}
}
private fun broadcastRecords(questions: List<DnsQuestion>? = null) {
val writer = DnsWriter()
_recordLockObject.withLock {
val recordsA: List<Pair<DnsResourceRecord, ARecord>>
val recordsAAAA: List<Pair<DnsResourceRecord, AAAARecord>>
val recordsPTR: List<Pair<DnsResourceRecord, PTRRecord>>
val recordsTXT: List<Pair<DnsResourceRecord, TXTRecord>>
val recordsSRV: List<Pair<DnsResourceRecord, SRVRecord>>
if (questions != null) {
recordsA = _recordsA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
recordsAAAA = _recordsAAAA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
recordsPTR = _recordsPTR.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
recordsSRV = _recordsSRV.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
recordsTXT = _recordsTXT.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
} else {
recordsA = _recordsA
recordsAAAA = _recordsAAAA
recordsPTR = _recordsPTR
recordsSRV = _recordsSRV
recordsTXT = _recordsTXT
}
val answerCount = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size + recordsTXT.size
if (answerCount < 1) return
val txtOffset = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size
val srvOffset = recordsA.size + recordsAAAA.size + recordsPTR.size
val ptrOffset = recordsA.size + recordsAAAA.size
val aaaaOffset = recordsA.size
writer.writePacket(
DnsPacketHeader(
identifier = 0u,
queryResponse = QueryResponse.Response.value.toInt(),
opcode = DnsOpcode.StandardQuery.value.toInt(),
truncated = false,
nonAuthenticatedData = false,
recursionDesired = false,
answerAuthenticated = false,
authoritativeAnswer = true,
recursionAvailable = false,
responseCode = DnsResponseCode.NoError
),
answerCount = answerCount,
answerWriter = { w, i ->
when {
i >= txtOffset -> {
val record = recordsTXT[i - txtOffset]
w.write(record.first) { it.write(record.second) }
}
i >= srvOffset -> {
val record = recordsSRV[i - srvOffset]
w.write(record.first) { it.write(record.second) }
}
i >= ptrOffset -> {
val record = recordsPTR[i - ptrOffset]
w.write(record.first) { it.write(record.second) }
}
i >= aaaaOffset -> {
val record = recordsAAAA[i - aaaaOffset]
w.write(record.first) { it.write(record.second) }
}
else -> {
val record = recordsA[i]
w.write(record.first) { it.write(record.second) }
}
}
}
)
}
send(writer.toByteArray())
}
private fun handleResult(result: DatagramPacket) {
try {
val packet = DnsPacket.parse(result.data)
if (packet.questions.isNotEmpty()) {
_scope?.launch(Dispatchers.IO) {
try {
broadcastRecords(packet.questions)
} catch (e: Throwable) {
Logger.i(TAG, "Broadcasting records failed", e)
}
}
}
_serviceRecordAggregator.add(packet)
onPacket?.invoke(packet)
} catch (e: Exception) {
Logger.v(TAG, "Failed to handle packet: ${Base64.getEncoder().encodeToString(result.data.slice(IntRange(0, result.length - 1)).toByteArray())}", e)
}
}
fun stop() {
_lockObject.withLock {
_started = false
_scope?.cancel()
_scope = null
_nicMonitor.stop()
_serviceRecordAggregator.stop()
_receiver4?.close()
_receiver4 = null
_receiver6?.close()
_receiver6 = null
_senders.forEach { it.close() }
_senders.clear()
}
_threadReceiver4?.join()
_threadReceiver4 = null
_threadReceiver6?.join()
_threadReceiver6 = null
}
}

View file

@ -1,66 +0,0 @@
package com.futo.platformplayer.mdns
import kotlinx.coroutines.*
import java.net.NetworkInterface
class NICMonitor {
private val lockObject = Any()
private val nics = mutableListOf<NetworkInterface>()
private var cts: Job? = null
val current: List<NetworkInterface>
get() = synchronized(nics) { nics.toList() }
var added: ((List<NetworkInterface>) -> Unit)? = null
var removed: ((List<NetworkInterface>) -> Unit)? = null
fun start() {
synchronized(lockObject) {
if (cts != null) throw Exception("Already started.")
cts = CoroutineScope(Dispatchers.Default).launch {
loopAsync()
}
}
nics.clear()
nics.addAll(getCurrentInterfaces().toList())
}
fun stop() {
synchronized(lockObject) {
cts?.cancel()
cts = null
}
synchronized(nics) {
nics.clear()
}
}
private suspend fun loopAsync() {
while (cts?.isActive == true) {
try {
val currentNics = getCurrentInterfaces().toList()
removed?.invoke(nics.filter { k -> currentNics.none { n -> k.name == n.name } })
added?.invoke(currentNics.filter { nic -> nics.none { k -> k.name == nic.name } })
synchronized(nics) {
nics.clear()
nics.addAll(currentNics)
}
} catch (ex: Exception) {
// Ignored
}
delay(5000)
}
}
private fun getCurrentInterfaces(): List<NetworkInterface> {
val nics = NetworkInterface.getNetworkInterfaces().toList()
.filter { it.isUp && !it.isLoopback }
return if (nics.isNotEmpty()) nics else NetworkInterface.getNetworkInterfaces().toList()
.filter { it.isUp }
}
}

View file

@ -1,71 +0,0 @@
package com.futo.platformplayer.mdns
import com.futo.platformplayer.logging.Logger
import java.lang.Thread.sleep
class ServiceDiscoverer(names: Array<String>, private val _onServicesUpdated: (List<DnsService>) -> Unit) {
private val _names: Array<String>
private var _listener: MDNSListener? = null
private var _started = false
private var _thread: Thread? = null
init {
if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.")
_names = names
}
fun broadcastService(
deviceName: String,
serviceName: String,
port: UShort,
ttl: UInt = 120u,
weight: UShort = 0u,
priority: UShort = 0u,
texts: List<String>? = null
) {
_listener?.let {
it.broadcastService(deviceName, serviceName, port, ttl, weight, priority, texts)
}
}
fun stop() {
_started = false
_listener?.stop()
_listener = null
_thread?.join()
_thread = null
}
fun start() {
if (_started) {
Logger.i(TAG, "Already started.")
return
}
_started = true
val listener = MDNSListener()
_listener = listener
listener.onServicesUpdated = { _onServicesUpdated?.invoke(it) }
listener.start()
_thread = Thread {
try {
sleep(2000)
while (_started) {
listener.queryServices(_names)
sleep(2000)
listener.queryAllQuestions(_names)
sleep(2000)
}
} catch (e: Throwable) {
Logger.i(TAG, "Exception in loop thread", e)
stop()
}
}.apply { start() }
}
companion object {
private val TAG = "ServiceDiscoverer"
}
}

View file

@ -1,226 +0,0 @@
package com.futo.platformplayer.mdns
import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.net.InetAddress
import java.util.Date
data class DnsService(
var name: String,
var target: String,
var port: UShort,
val addresses: MutableList<InetAddress> = mutableListOf(),
val pointers: MutableList<String> = mutableListOf(),
val texts: MutableList<String> = mutableListOf()
)
data class CachedDnsAddressRecord(
val expirationTime: Date,
val address: InetAddress
)
data class CachedDnsTxtRecord(
val expirationTime: Date,
val texts: List<String>
)
data class CachedDnsPtrRecord(
val expirationTime: Date,
val target: String
)
data class CachedDnsSrvRecord(
val expirationTime: Date,
val service: SRVRecord
)
class ServiceRecordAggregator {
private val _lockObject = Any()
private val _cachedAddressRecords = mutableMapOf<String, MutableList<CachedDnsAddressRecord>>()
private val _cachedTxtRecords = mutableMapOf<String, CachedDnsTxtRecord>()
private val _cachedPtrRecords = mutableMapOf<String, MutableList<CachedDnsPtrRecord>>()
private val _cachedSrvRecords = mutableMapOf<String, CachedDnsSrvRecord>()
private val _currentServices = mutableListOf<DnsService>()
private var _cts: Job? = null
var onServicesUpdated: ((List<DnsService>) -> Unit)? = null
fun start() {
synchronized(_lockObject) {
if (_cts != null) throw Exception("Already started.")
_cts = CoroutineScope(Dispatchers.Default).launch {
try {
while (isActive) {
val now = Date()
synchronized(_currentServices) {
_cachedAddressRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } }
_cachedTxtRecords.entries.removeIf { now.after(it.value.expirationTime) }
_cachedSrvRecords.entries.removeIf { now.after(it.value.expirationTime) }
_cachedPtrRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } }
val newServices = getCurrentServices()
_currentServices.clear()
_currentServices.addAll(newServices)
}
onServicesUpdated?.invoke(_currentServices.toList())
delay(5000)
}
} catch (e: Throwable) {
Logger.e(TAG, "Unexpected failure in MDNS loop", e)
}
}
}
}
fun stop() {
synchronized(_lockObject) {
_cts?.cancel()
_cts = null
}
}
fun add(packet: DnsPacket) {
val currentServices: List<DnsService>
val dnsResourceRecords = packet.answers + packet.additionals + packet.authorities
val txtRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.TXT.value.toInt() }.map { it to it.getDataReader().readTXTRecord() }
val aRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.A.value.toInt() }.map { it to it.getDataReader().readARecord() }
val aaaaRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.AAAA.value.toInt() }.map { it to it.getDataReader().readAAAARecord() }
val srvRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.SRV.value.toInt() }.map { it to it.getDataReader().readSRVRecord() }
val ptrRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.PTR.value.toInt() }.map { it to it.getDataReader().readPTRRecord() }
/*val builder = StringBuilder()
builder.appendLine("Received records:")
srvRecords.forEach { builder.appendLine("SRV ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: (Port: ${it.second.port}, Target: ${it.second.target}, Priority: ${it.second.priority}, Weight: ${it.second.weight})") }
ptrRecords.forEach { builder.appendLine("PTR ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.domainName}") }
txtRecords.forEach { builder.appendLine("TXT ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.texts.joinToString(", ")}") }
aRecords.forEach { builder.appendLine("A ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") }
aaaaRecords.forEach { builder.appendLine("AAAA ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") }
Logger.i(TAG, "$builder")*/
synchronized(this._currentServices) {
ptrRecords.forEach { record ->
val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() }
val newPtrRecord = CachedDnsPtrRecord(Date(System.currentTimeMillis() + record.first.timeToLive.toLong() * 1000L), record.second.domainName)
cachedPtrRecord.replaceOrAdd(newPtrRecord) { it.target == record.second.domainName }
}
aRecords.forEach { aRecord ->
val cachedARecord = _cachedAddressRecords.getOrPut(aRecord.first.name) { mutableListOf() }
val newARecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aRecord.first.timeToLive.toLong() * 1000L), aRecord.second.address)
cachedARecord.replaceOrAdd(newARecord) { it.address == newARecord.address }
}
aaaaRecords.forEach { aaaaRecord ->
val cachedAaaaRecord = _cachedAddressRecords.getOrPut(aaaaRecord.first.name) { mutableListOf() }
val newAaaaRecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aaaaRecord.first.timeToLive.toLong() * 1000L), aaaaRecord.second.address)
cachedAaaaRecord.replaceOrAdd(newAaaaRecord) { it.address == newAaaaRecord.address }
}
txtRecords.forEach { txtRecord ->
_cachedTxtRecords[txtRecord.first.name] = CachedDnsTxtRecord(Date(System.currentTimeMillis() + txtRecord.first.timeToLive.toLong() * 1000L), txtRecord.second.texts)
}
srvRecords.forEach { srvRecord ->
_cachedSrvRecords[srvRecord.first.name] = CachedDnsSrvRecord(Date(System.currentTimeMillis() + srvRecord.first.timeToLive.toLong() * 1000L), srvRecord.second)
}
currentServices = getCurrentServices()
this._currentServices.clear()
this._currentServices.addAll(currentServices)
}
onServicesUpdated?.invoke(currentServices)
}
fun getAllQuestions(serviceName: String): List<DnsQuestion> {
val questions = mutableListOf<DnsQuestion>()
synchronized(_currentServices) {
val servicePtrRecords = _cachedPtrRecords[serviceName] ?: return emptyList()
val ptrWithoutSrvRecord = servicePtrRecords.filterNot { _cachedSrvRecords.containsKey(it.target) }.map { it.target }
questions.addAll(ptrWithoutSrvRecord.flatMap { s ->
listOf(
DnsQuestion(
name = s,
type = QuestionType.SRV.value.toInt(),
clazz = QuestionClass.IN.value.toInt(),
queryUnicast = false
)
)
})
val incompleteCurrentServices = _currentServices.filter { it.addresses.isEmpty() && it.name.endsWith(serviceName) }
questions.addAll(incompleteCurrentServices.flatMap { s ->
listOf(
DnsQuestion(
name = s.name,
type = QuestionType.TXT.value.toInt(),
clazz = QuestionClass.IN.value.toInt(),
queryUnicast = false
),
DnsQuestion(
name = s.target,
type = QuestionType.A.value.toInt(),
clazz = QuestionClass.IN.value.toInt(),
queryUnicast = false
),
DnsQuestion(
name = s.target,
type = QuestionType.AAAA.value.toInt(),
clazz = QuestionClass.IN.value.toInt(),
queryUnicast = false
)
)
})
}
return questions
}
private fun getCurrentServices(): MutableList<DnsService> {
val currentServices = _cachedSrvRecords.map { (key, value) ->
DnsService(
name = key,
target = value.service.target,
port = value.service.port
)
}.toMutableList()
currentServices.forEach { service ->
_cachedAddressRecords[service.target]?.let {
service.addresses.addAll(it.map { record -> record.address })
}
}
currentServices.forEach { service ->
service.pointers.addAll(_cachedPtrRecords.filter { it.value.any { ptr -> ptr.target == service.name } }.map { it.key })
}
currentServices.forEach { service ->
_cachedTxtRecords[service.name]?.let {
service.texts.addAll(it.texts)
}
}
return currentServices
}
private inline fun <T> MutableList<T>.replaceOrAdd(newElement: T, predicate: (T) -> Boolean) {
val index = indexOfFirst(predicate)
if (index >= 0) {
this[index] = newElement
} else {
add(newElement)
}
}
private companion object {
private const val TAG = "ServiceRecordAggregator"
}
}

View file

@ -29,7 +29,7 @@ data class ImageVariable(
Glide.with(imageView) Glide.with(imageView)
.load(bitmap) .load(bitmap)
.into(imageView) .into(imageView)
} else if(resId != null) { } else if(resId != null && resId > 0) {
Glide.with(imageView) Glide.with(imageView)
.load(resId) .load(resId)
.into(imageView) .into(imageView)

View file

@ -113,7 +113,7 @@ class LoginWebViewClient : WebViewClient {
//val domainParts = domain!!.split("."); //val domainParts = domain!!.split(".");
//val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString("."); //val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString(".");
val cookieDomain = domain!!.getSubdomainWildcardQuery(); val cookieDomain = domain!!.getSubdomainWildcardQuery();
if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) }) if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || domain.matchesDomain(it) })
_authConfig.cookiesToFind?.let { cookiesToFind -> _authConfig.cookiesToFind?.let { cookiesToFind ->
val cookies = cookieString.split(";"); val cookies = cookieString.split(";");
for(cookieStr in cookies) { for(cookieStr in cookies) {

View file

@ -67,7 +67,7 @@ class WebViewRequirementExtractor {
if(cookieString != null) { if(cookieString != null) {
//val domainParts = domain!!.split("."); //val domainParts = domain!!.split(".");
val cookieDomain = domain!!.getSubdomainWildcardQuery()//"." + domainParts.drop(domainParts.size - 2).joinToString("."); val cookieDomain = domain!!.getSubdomainWildcardQuery()//"." + domainParts.drop(domainParts.size - 2).joinToString(".");
if(allowedUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) }) if(allowedUrls.any { it == "everywhere" || domain.matchesDomain(it) })
cookiesToFind?.let { cookiesToFind -> cookiesToFind?.let { cookiesToFind ->
val cookies = cookieString.split(";"); val cookies = cookieString.split(";");
for(cookieStr in cookies) { for(cookieStr in cookies) {

View file

@ -21,6 +21,7 @@ class MediaControlReceiver : BroadcastReceiver() {
EVENT_NEXT -> onNextReceived.emit(); EVENT_NEXT -> onNextReceived.emit();
EVENT_PREV -> onPreviousReceived.emit(); EVENT_PREV -> onPreviousReceived.emit();
EVENT_CLOSE -> onCloseReceived.emit(); EVENT_CLOSE -> onCloseReceived.emit();
EVENT_BACKGROUND -> onBackgroundReceived.emit();
} }
} }
catch(ex: Throwable) { catch(ex: Throwable) {
@ -38,6 +39,7 @@ class MediaControlReceiver : BroadcastReceiver() {
const val EVENT_NEXT = "Next"; const val EVENT_NEXT = "Next";
const val EVENT_PREV = "Prev"; const val EVENT_PREV = "Prev";
const val EVENT_CLOSE = "Close"; const val EVENT_CLOSE = "Close";
const val EVENT_BACKGROUND = "Background";
val onPlayReceived = Event0(); val onPlayReceived = Event0();
val onPauseReceived = Event0(); val onPauseReceived = Event0();
@ -48,6 +50,7 @@ class MediaControlReceiver : BroadcastReceiver() {
val onLowerVolumeReceived = Event0(); val onLowerVolumeReceived = Event0();
val onCloseReceived = 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 { 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); 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 { 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); this.putExtra(EXTRA_MEDIA_ACTION, EVENT_CLOSE);
},PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT); },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);
} }
} }

View file

@ -1,5 +1,6 @@
package com.futo.platformplayer.serializers package com.futo.platformplayer.serializers
import com.futo.platformplayer.sToOffsetDateTimeUTC
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
@ -37,7 +38,7 @@ class OffsetDateTimeSerializer : KSerializer<OffsetDateTime> {
return OffsetDateTime.MAX; return OffsetDateTime.MAX;
else if(epochSecond < -9999999999) else if(epochSecond < -9999999999)
return OffsetDateTime.MIN; return OffsetDateTime.MIN;
return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC); return epochSecond.sToOffsetDateTimeUTC()
} }
} }
class OffsetDateTimeStringSerializer : KSerializer<OffsetDateTime> { class OffsetDateTimeStringSerializer : KSerializer<OffsetDateTime> {

View file

@ -29,6 +29,7 @@ import com.futo.platformplayer.activities.CaptchaActivity
import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.activities.SettingsActivity.Companion.settingsActivityClosed
import com.futo.platformplayer.api.media.platforms.js.DevJSClient import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.background.BackgroundWorker import com.futo.platformplayer.background.BackgroundWorker
@ -411,7 +412,27 @@ class StateApp {
} }
if (Settings.instance.synchronization.enabled) { if (Settings.instance.synchronization.enabled) {
StateSync.instance.start() StateSync.instance.start(context, {
try {
UIDialogs.toast("Failed to start sync, port in use")
} catch (e: Throwable) {
//Ignored
}
})
}
settingsActivityClosed.subscribe {
if (Settings.instance.synchronization.enabled) {
StateSync.instance.start(context, {
try {
UIDialogs.toast("Failed to start sync, port in use")
} catch (e: Throwable) {
//Ignored
}
})
} else {
StateSync.instance.stop()
}
} }
Logger.onLogSubmitted.subscribe { Logger.onLogSubmitted.subscribe {
@ -509,22 +530,33 @@ class StateApp {
//Migration //Migration
Logger.i(TAG, "MainApp Started: Check [Migrations]"); Logger.i(TAG, "MainApp Started: Check [Migrations]");
scopeOrNull?.launch(Dispatchers.IO) {
try {
migrateStores(context, listOf( migrateStores(context, listOf(
StateSubscriptions.instance.toMigrateCheck(), StateSubscriptions.instance.toMigrateCheck(),
StatePlaylists.instance.toMigrateCheck() StatePlaylists.instance.toMigrateCheck()
).flatten(), 0); ).flatten(), 0)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to migrate stores")
}
}
if(Settings.instance.subscriptions.fetchOnAppBoot) { if(Settings.instance.subscriptions.fetchOnAppBoot) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
Logger.i(TAG, "MainApp Started: Fetch [Subscriptions]"); Logger.i(TAG, "MainApp Started: Fetch [Subscriptions]");
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount(); val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n"); val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true }; val isBelowRateLimit = !subRequestCounts.any { clientCount ->
if (isRateLimitReached) { clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true
};
if (isBelowRateLimit) {
Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}"); Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}");
delay(5000); delay(5000);
scopeOrNull?.let {
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5) if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
StateSubscriptions.instance.updateSubscriptionFeed(scope, false); StateSubscriptions.instance.updateSubscriptionFeed(it, false);
}
} }
else else
Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}"); Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}");
@ -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) if(managedStores.size <= index)
return; return;
val store = managedStores[index]; val store = managedStores[index];
if(store.hasMissingReconstructions()) if(store.hasMissingReconstructions()) {
withContext(Dispatchers.Main) {
try {
UIDialogs.showMigrateDialog(context, store) { UIDialogs.showMigrateDialog(context, store) {
scopeOrNull?.launch(Dispatchers.IO) {
try {
migrateStores(context, managedStores, index + 1); migrateStores(context, managedStores, index + 1);
}; } catch (e: Throwable) {
else 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); migrateStores(context, managedStores, index + 1);
} }
@ -703,6 +747,7 @@ class StateApp {
StatePlayer.instance.closeMediaSession(); StatePlayer.instance.closeMediaSession();
StateCasting.instance.stop(); StateCasting.instance.stop();
StateSync.instance.stop();
StatePlayer.dispose(); StatePlayer.dispose();
Companion.dispose(); Companion.dispose();
_fileLogConsumer?.close(); _fileLogConsumer?.close();

View file

@ -94,9 +94,11 @@ class StatePlatform {
private val _trackerClientPool = PlatformMultiClientPool("Trackers", 1); //Used exclusively for playback trackers private val _trackerClientPool = PlatformMultiClientPool("Trackers", 1); //Used exclusively for playback trackers
private val _liveEventClientPool = PlatformMultiClientPool("LiveEvents", 1); //Used exclusively for live events 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 _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 _icons : HashMap<String, ImageVariable> = HashMap();
private val _iconsByName : HashMap<String, ImageVariable> = HashMap();
val hasClients: Boolean get() = _availableClients.size > 0; val hasClients: Boolean get() = _availableClients.size > 0;
@ -113,14 +115,14 @@ class StatePlatform {
Logger.i(StatePlatform::class.java.name, "Fetching video details [${url}]"); Logger.i(StatePlatform::class.java.name, "Fetching video details [${url}]");
if(!StateApp.instance.privateMode) { if(!StateApp.instance.privateMode) {
_enabledClients.find { it.isContentDetailsUrl(url) }?.let { _enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let {
_mainClientPool.getClientPooled(it).getContentDetails(url) _mainClientPool.getClientPooled(it).getContentDetails(url)
} }
?: throw NoPlatformClientException("No client enabled that supports this url ($url)"); ?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
} }
else { else {
Logger.i(TAG, "Fetching details with private client"); 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) _privateClientPool.getClientPooled(it).getContentDetails(url)
} }
?: throw NoPlatformClientException("No client enabled that supports this url ($url)"); ?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
@ -192,6 +194,7 @@ class StatePlatform {
_availableClients.clear(); _availableClients.clear();
_icons.clear(); _icons.clear();
_iconsByName.clear()
_icons[StateDeveloper.DEV_ID] = ImageVariable(null, R.drawable.ic_security_red); _icons[StateDeveloper.DEV_ID] = ImageVariable(null, R.drawable.ic_security_red);
StatePlugins.instance.updateEmbeddedPlugins(context); StatePlugins.instance.updateEmbeddedPlugins(context);
@ -200,6 +203,8 @@ class StatePlatform {
for (plugin in StatePlugins.instance.getPlugins()) { for (plugin in StatePlugins.instance.getPlugins()) {
_icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?: _icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?:
ImageVariable(plugin.config.absoluteIconUrl, null); 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); val client = JSClient(context, plugin);
client.onCaptchaException.subscribe { c, ex -> client.onCaptchaException.subscribe { c, ex ->
@ -299,6 +304,15 @@ class StatePlatform {
return null; 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>) { fun setPlatformOrder(platformOrder: List<String>) {
_platformOrderPersistent.values.clear(); _platformOrderPersistent.values.clear();
_platformOrderPersistent.values.addAll(platformOrder); _platformOrderPersistent.values.addAll(platformOrder);
@ -655,10 +669,10 @@ class StatePlatform {
//Video //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) fun getContentClient(url: String) : IPlatformClient = getContentClientOrNull(url)
?: throw NoPlatformClientException("No client enabled that supports this content url (${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> { fun getContentDetails(url: String, forceRefetch: Boolean = false): Deferred<IPlatformContentDetails> {
Logger.i(TAG, "Platform - getContentDetails (${url})"); Logger.i(TAG, "Platform - getContentDetails (${url})");
if(forceRefetch) if(forceRefetch)
@ -699,14 +713,14 @@ class StatePlatform {
return client.getContentRecommendations(url); 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) fun getChannelClient(url : String, exclude: List<String>? = null) : IPlatformClient = getChannelClientOrNull(url, exclude)
?: throw NoPlatformClientException("No client enabled that supports this channel url (${url})"); ?: throw NoPlatformClientException("No client enabled that supports this channel url (${url})");
fun getChannelClientOrNull(url : String, exclude: List<String>? = null) : IPlatformClient? = fun getChannelClientOrNull(url : String, exclude: List<String>? = null) : IPlatformClient? =
if(exclude == null) if(exclude == null)
getEnabledClients().find { it.isChannelUrl(url) } getEnabledClients().find { _instantClientPool.getClientPooled(it).isChannelUrl(url) }
else 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> { fun getChannel(url: String, updateSubscriptions: Boolean = true): Deferred<IPlatformChannel> {
Logger.i(TAG, "Platform - getChannel"); Logger.i(TAG, "Platform - getChannel");
@ -902,9 +916,9 @@ class StatePlatform {
return urls; return urls;
} }
fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { it.isPlaylistUrl(url) }; fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) };
fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { it.isPlaylistUrl(url) } fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) }
fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { 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})"); ?: throw NoPlatformClientException("No client enabled that supports this playlist url (${url})");
fun getPlaylist(url: String): IPlatformPlaylistDetails { fun getPlaylist(url: String): IPlatformPlaylistDetails {
return getPlaylistClient(url).getPlaylist(url); return getPlaylistClient(url).getPlaylist(url);

View file

@ -598,7 +598,7 @@ class StatePlayer {
} }
if(_queuePosition < _queue.size) { if(_queuePosition < _queue.size) {
return _queue[_queuePosition]; return getCurrentQueueItem();
} }
} }
return null; return null;

View file

@ -19,6 +19,7 @@ import com.futo.platformplayer.exceptions.ReconstructionException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.sToOffsetDateTimeUTC
import com.futo.platformplayer.smartMerge import com.futo.platformplayer.smartMerge
import com.futo.platformplayer.states.StateSubscriptionGroups.Companion import com.futo.platformplayer.states.StateSubscriptionGroups.Companion
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
@ -85,7 +86,7 @@ class StatePlaylists {
if(value.isEmpty()) if(value.isEmpty())
return OffsetDateTime.MIN; return OffsetDateTime.MIN;
val tryParse = value.toLongOrNull() ?: 0; val tryParse = value.toLongOrNull() ?: 0;
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(tryParse), ZoneOffset.UTC); return tryParse.sToOffsetDateTimeUTC();
} }
private fun setWatchLaterReorderTime() { private fun setWatchLaterReorderTime() {
val now = OffsetDateTime.now(ZoneOffset.UTC); val now = OffsetDateTime.now(ZoneOffset.UTC);
@ -400,12 +401,15 @@ class StatePlaylists {
companion object { companion object {
val TAG = "StatePlaylists"; val TAG = "StatePlaylists";
private var _instance : StatePlaylists? = null; private var _instance : StatePlaylists? = null;
private var _lockObject = Object()
val instance : StatePlaylists val instance : StatePlaylists
get() { get() {
synchronized(_lockObject) {
if (_instance == null) if (_instance == null)
_instance = StatePlaylists(); _instance = StatePlaylists();
return _instance!!; return _instance!!;
}; }
}
fun finish() { fun finish() {
_instance?.let { _instance?.let {

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,6 +1,7 @@
package com.futo.platformplayer.stores package com.futo.platformplayer.stores
import com.futo.platformplayer.logging.Logger 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.JsonStoreSerializer
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.stores.v2.StoreSerializer import com.futo.platformplayer.stores.v2.StoreSerializer

View file

@ -1,13 +1,18 @@
package com.futo.platformplayer.sync.internal package com.futo.platformplayer.sync.internal
import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.noise.protocol.CipherStatePair import com.futo.platformplayer.noise.protocol.CipherStatePair
import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.noise.protocol.DHState
import com.futo.platformplayer.noise.protocol.HandshakeState import com.futo.platformplayer.noise.protocol.HandshakeState
import com.futo.platformplayer.states.StateSync import com.futo.platformplayer.states.StateSync
import com.futo.polycentric.core.base64ToByteArray
import com.futo.polycentric.core.toBase64
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.util.Base64 import java.util.Base64
import java.util.zip.GZIPOutputStream
interface IChannel : AutoCloseable { interface IChannel : AutoCloseable {
val remotePublicKey: String? val remotePublicKey: String?
@ -15,7 +20,7 @@ interface IChannel : AutoCloseable {
var authorizable: IAuthorizable? var authorizable: IAuthorizable?
var syncSession: SyncSession? var syncSession: SyncSession?
fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?)
fun send(opcode: UByte, subOpcode: UByte = 0u, data: ByteBuffer? = null) fun send(opcode: UByte, subOpcode: UByte = 0u, data: ByteBuffer? = null, contentEncoding: ContentEncoding? = null)
fun setCloseHandler(onClose: ((IChannel) -> Unit)?) fun setCloseHandler(onClose: ((IChannel) -> Unit)?)
val linkType: LinkType val linkType: LinkType
} }
@ -49,9 +54,10 @@ class ChannelSocket(private val session: SyncSocketSession) : IChannel {
onData?.invoke(session, this, opcode, subOpcode, data) onData?.invoke(session, this, opcode, subOpcode, data)
} }
override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?) { override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, contentEncoding: ContentEncoding?) {
ensureNotMainThread()
if (data != null) { if (data != null) {
session.send(opcode, subOpcode, data) session.send(opcode, subOpcode, data, contentEncoding)
} else { } else {
session.send(opcode, subOpcode) session.send(opcode, subOpcode)
} }
@ -67,12 +73,12 @@ class ChannelRelayed(
private val sendLock = Object() private val sendLock = Object()
private val decryptLock = Object() private val decryptLock = Object()
private var handshakeState: HandshakeState? = if (initiator) { private var handshakeState: HandshakeState? = if (initiator) {
HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR).apply { HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR).apply {
localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair) localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair)
remotePublicKey.setPublicKey(Base64.getDecoder().decode(publicKey), 0) remotePublicKey.setPublicKey(Base64.getDecoder().decode(publicKey), 0)
} }
} else { } else {
HandshakeState(StateSync.protocolName, HandshakeState.RESPONDER).apply { HandshakeState(SyncService.protocolName, HandshakeState.RESPONDER).apply {
localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair) localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair)
} }
} }
@ -80,7 +86,7 @@ class ChannelRelayed(
override var authorizable: IAuthorizable? = null override var authorizable: IAuthorizable? = null
val isAuthorized: Boolean get() = authorizable?.isAuthorized ?: false val isAuthorized: Boolean get() = authorizable?.isAuthorized ?: false
var connectionId: Long = 0L var connectionId: Long = 0L
override var remotePublicKey: String? = publicKey override var remotePublicKey: String? = publicKey.base64ToByteArray().toBase64()
private set private set
override var remoteVersion: Int? = null override var remoteVersion: Int? = null
private set private set
@ -90,11 +96,39 @@ class ChannelRelayed(
private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null
private var onClose: ((IChannel) -> Unit)? = null private var onClose: ((IChannel) -> Unit)? = null
private var disposed = false 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 { init {
handshakeState?.start() 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)?) { override fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) {
this.onData = onData this.onData = onData
} }
@ -130,6 +164,10 @@ class ChannelRelayed(
} }
fun invokeDataHandler(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { fun invokeDataHandler(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
if (opcode == Opcode.PONG.value) {
_lastPongTime = System.currentTimeMillis()
return
}
onData?.invoke(session, this, opcode, subOpcode, data) onData?.invoke(session, this, opcode, subOpcode, data)
} }
@ -144,10 +182,12 @@ class ChannelRelayed(
handshakeState = null handshakeState = null
this.transport = transport this.transport = transport
Logger.i("ChannelRelayed", "Completed handshake for connectionId $connectionId") Logger.i("ChannelRelayed", "Completed handshake for connectionId $connectionId")
startPingLoop()
} }
private fun sendPacket(packet: ByteArray) { private fun sendPacket(packet: ByteArray) {
throwIfDisposed() throwIfDisposed()
ensureNotMainThread()
synchronized(sendLock) { synchronized(sendLock) {
val encryptedPayload = ByteArray(packet.size + 16) val encryptedPayload = ByteArray(packet.size + 16)
@ -165,6 +205,7 @@ class ChannelRelayed(
fun sendError(errorCode: SyncErrorCode) { fun sendError(errorCode: SyncErrorCode) {
throwIfDisposed() throwIfDisposed()
ensureNotMainThread()
synchronized(sendLock) { synchronized(sendLock) {
val packet = ByteArray(4) 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() 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 ENCRYPTION_OVERHEAD = 16
val CONNECTION_ID_SIZE = 8 val CONNECTION_ID_SIZE = 8
val HEADER_SIZE = 6 val HEADER_SIZE = 7
val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - HEADER_SIZE - CONNECTION_ID_SIZE - ENCRYPTION_OVERHEAD - 16 val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - HEADER_SIZE - CONNECTION_ID_SIZE - ENCRYPTION_OVERHEAD - 16
if (actualCount > MAX_DATA_PER_PACKET && data != null) { Logger.v(TAG, "Send (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.size: ${processedData?.remaining()})")
if (processedData != null && processedData.remaining() > MAX_DATA_PER_PACKET) {
val streamId = session.generateStreamId() val streamId = session.generateStreamId()
val totalSize = actualCount
var sendOffset = 0 var sendOffset = 0
while (sendOffset < totalSize) { while (sendOffset < processedData.remaining()) {
val bytesRemaining = totalSize - sendOffset val bytesRemaining = processedData.remaining() - sendOffset
val bytesToSend = minOf(MAX_DATA_PER_PACKET - 8 - 2, bytesRemaining) val bytesToSend = minOf(MAX_DATA_PER_PACKET - 8 - HEADER_SIZE + 4, bytesRemaining)
val streamData: ByteArray val streamData: ByteArray
val streamOpcode: StreamOpcode val streamOpcode: StreamOpcode
if (sendOffset == 0) { if (sendOffset == 0) {
streamOpcode = StreamOpcode.START streamOpcode = StreamOpcode.START
streamData = ByteArray(4 + 4 + 1 + 1 + bytesToSend) streamData = ByteArray(4 + HEADER_SIZE + bytesToSend)
ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply { ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(streamId) putInt(streamId)
putInt(totalSize) putInt(processedData.remaining())
put(opcode.toByte()) put(opcode.toByte())
put(subOpcode.toByte()) put(subOpcode.toByte())
put(data.array(), data.position() + sendOffset, bytesToSend) put(contentEncoding?.value?.toByte() ?: 0.toByte())
put(processedData.array(), processedData.position() + sendOffset, bytesToSend)
} }
} else { } else {
streamData = ByteArray(4 + 4 + bytesToSend) streamData = ByteArray(4 + 4 + bytesToSend)
ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply { ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(streamId) putInt(streamId)
putInt(sendOffset) putInt(sendOffset)
put(data.array(), data.position() + sendOffset, bytesToSend) put(processedData.array(), processedData.position() + sendOffset, bytesToSend)
} }
streamOpcode = if (bytesToSend < bytesRemaining) StreamOpcode.DATA else StreamOpcode.END streamOpcode = if (bytesToSend < bytesRemaining) StreamOpcode.DATA else StreamOpcode.END
} }
val fullPacket = ByteArray(HEADER_SIZE + streamData.size) val fullPacket = ByteArray(HEADER_SIZE + streamData.size)
ByteBuffer.wrap(fullPacket).order(ByteOrder.LITTLE_ENDIAN).apply { ByteBuffer.wrap(fullPacket).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(streamData.size + 2) putInt(streamData.size + HEADER_SIZE - 4)
put(Opcode.STREAM.value.toByte()) put(Opcode.STREAM.value.toByte())
put(streamOpcode.value.toByte()) put(streamOpcode.value.toByte())
put(ContentEncoding.Raw.value.toByte())
put(streamData) put(streamData)
} }
@ -235,19 +296,21 @@ class ChannelRelayed(
sendOffset += bytesToSend sendOffset += bytesToSend
} }
} else { } else {
val packet = ByteArray(HEADER_SIZE + actualCount) val packet = ByteArray(HEADER_SIZE + (processedData?.remaining() ?: 0))
ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply { ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(actualCount + 2) putInt((processedData?.remaining() ?: 0) + HEADER_SIZE - 4)
put(opcode.toByte()) put(opcode.toByte())
put(subOpcode.toByte()) put(subOpcode.toByte())
if (actualCount > 0 && data != null) put(data.array(), data.position(), actualCount) put(contentEncoding?.value?.toByte() ?: ContentEncoding.Raw.value.toByte())
if (processedData != null && processedData.remaining() > 0) put(processedData.array(), processedData.position(), processedData.remaining())
} }
sendPacket(packet) sendPacket(packet)
} }
} }
fun sendRequestTransport(requestId: Int, publicKey: String, pairingCode: String? = null) { fun sendRequestTransport(requestId: Int, publicKey: String, appId: UInt, pairingCode: String? = null) {
throwIfDisposed() throwIfDisposed()
ensureNotMainThread()
synchronized(sendLock) { synchronized(sendLock) {
val channelMessage = ByteArray(1024) val channelMessage = ByteArray(1024)
@ -270,10 +333,11 @@ class ChannelRelayed(
0 to ByteArray(0) 0 to ByteArray(0)
} }
val packetSize = 4 + 32 + 4 + pairingMessageLength + 4 + channelBytesWritten val packetSize = 4 + 4 + 32 + 4 + pairingMessageLength + 4 + channelBytesWritten
val packet = ByteArray(packetSize) val packet = ByteArray(packetSize)
ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply { ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(requestId) putInt(requestId)
putInt(appId.toInt())
put(publicKeyBytes) put(publicKeyBytes)
putInt(pairingMessageLength) putInt(pairingMessageLength)
if (pairingMessageLength > 0) put(pairingMessage) if (pairingMessageLength > 0) put(pairingMessage)
@ -287,6 +351,7 @@ class ChannelRelayed(
fun sendResponseTransport(remoteVersion: Int, requestId: Int, handshakeMessage: ByteArray) { fun sendResponseTransport(remoteVersion: Int, requestId: Int, handshakeMessage: ByteArray) {
throwIfDisposed() throwIfDisposed()
ensureNotMainThread()
synchronized(sendLock) { synchronized(sendLock) {
val message = ByteArray(1024) val message = ByteArray(1024)
@ -332,4 +397,8 @@ class ChannelRelayed(
completeHandshake(remoteVersion, transport) completeHandshake(remoteVersion, transport)
} }
} }
companion object {
private val TAG = "Channel"
}
} }

View file

@ -0,0 +1,6 @@
package com.futo.platformplayer.sync.internal
enum class ContentEncoding(val value: UByte) {
Raw(0u),
Gzip(1u)
}

View file

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

View file

@ -1,6 +1,7 @@
package com.futo.platformplayer.sync.internal package com.futo.platformplayer.sync.internal
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
@ -16,6 +17,8 @@ interface IAuthorizable {
class SyncSession : IAuthorizable { class SyncSession : IAuthorizable {
private val _channels: MutableList<IChannel> = mutableListOf() private val _channels: MutableList<IChannel> = mutableListOf()
@Volatile
private var _snapshot: Array<IChannel> = emptyArray()
private var _authorized: Boolean = false private var _authorized: Boolean = false
private var _remoteAuthorized: Boolean = false private var _remoteAuthorized: Boolean = false
private val _onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit private val _onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit
@ -82,6 +85,8 @@ class SyncSession : IAuthorizable {
synchronized(_channels) { synchronized(_channels) {
_channels.add(channel) _channels.add(channel)
_channels.sortBy { it.linkType.ordinal }
_snapshot = _channels.toTypedArray()
connected = _channels.isNotEmpty() connected = _channels.isNotEmpty()
} }
@ -123,15 +128,20 @@ class SyncSession : IAuthorizable {
fun removeChannel(channel: IChannel) { fun removeChannel(channel: IChannel) {
synchronized(_channels) { synchronized(_channels) {
_channels.remove(channel) _channels.remove(channel)
_snapshot = _channels.toTypedArray()
connected = _channels.isNotEmpty() connected = _channels.isNotEmpty()
} }
} }
fun close() { fun close() {
synchronized(_channels) { val toClose = synchronized(_channels) {
_channels.forEach { it.close() } val arr = _channels.toTypedArray()
_channels.clear() _channels.clear()
_snapshot = emptyArray()
connected = false
arr
} }
toClose.forEach { it.close() }
_onClose(this) _onClose(this)
} }
@ -192,33 +202,38 @@ class SyncSession : IAuthorizable {
} }
inline fun <reified T> sendJsonData(subOpcode: UByte, data: T) { inline fun <reified T> sendJsonData(subOpcode: UByte, data: T) {
ensureNotMainThread()
send(Opcode.DATA.value, subOpcode, Json.encodeToString(data)) send(Opcode.DATA.value, subOpcode, Json.encodeToString(data))
} }
fun sendData(subOpcode: UByte, data: String) { 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) { 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) { fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer? = null, contentEncoding: ContentEncoding? = null) {
val channels = synchronized(_channels) { _channels.sortedBy { it.linkType.ordinal }.toList() } ensureNotMainThread()
val channels = _snapshot
if (channels.isEmpty()) { if (channels.isEmpty()) {
//TODO: Should this throw? Logger.v(TAG, "Packet was not sent … no connected sockets")
Logger.v(TAG, "Packet was not sent (opcode = $opcode, subOpcode = $subOpcode) due to no connected sockets")
return return
} }
var sent = false var sent = false
for (channel in channels) { for (channel in channels) {
try { try {
channel.send(opcode, subOpcode, data) channel.send(opcode, subOpcode, data, contentEncoding)
sent = true sent = true
break break
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Packet failed to send (opcode = $opcode, subOpcode = $subOpcode)", e) Logger.w(TAG, "Packet failed to send (opcode = $opcode, subOpcode = $subOpcode), closing channel", e)
channel.close()
removeChannel(channel)
} }
} }

View file

@ -1,34 +1,46 @@
package com.futo.platformplayer.sync.internal package com.futo.platformplayer.sync.internal
import android.os.Build import android.os.Build
import com.futo.platformplayer.LittleEndianDataInputStream
import com.futo.platformplayer.LittleEndianDataOutputStream
import com.futo.platformplayer.ensureNotMainThread import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.noise.protocol.CipherStatePair import com.futo.platformplayer.noise.protocol.CipherStatePair
import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.noise.protocol.DHState
import com.futo.platformplayer.noise.protocol.HandshakeState import com.futo.platformplayer.noise.protocol.HandshakeState
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateSync 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.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.Inet4Address
import java.net.Inet6Address import java.net.Inet6Address
import java.net.InetAddress import java.net.InetAddress
import java.net.NetworkInterface import java.net.NetworkInterface
import java.net.Socket
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.util.Base64 import java.util.Base64
import java.util.Locale import java.util.Locale
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.math.min import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
import kotlin.system.measureTimeMillis
class SyncSocketSession { class SyncSocketSession {
private val _inputStream: LittleEndianDataInputStream private val _socket: Socket
private val _outputStream: LittleEndianDataOutputStream private val _inputStream: InputStream
private val _outputStream: OutputStream
private val _sendLockObject = Object() private val _sendLockObject = Object()
private val _buffer = ByteArray(MAXIMUM_PACKET_SIZE_ENCRYPTED) private val _buffer = ByteArray(MAXIMUM_PACKET_SIZE_ENCRYPTED)
private val _bufferDecrypted = ByteArray(MAXIMUM_PACKET_SIZE) private val _bufferDecrypted = ByteArray(MAXIMUM_PACKET_SIZE)
private val _sendBuffer = ByteArray(MAXIMUM_PACKET_SIZE) private val _sendBuffer = ByteArray(MAXIMUM_PACKET_SIZE)
private val _sendBufferEncrypted = ByteArray(MAXIMUM_PACKET_SIZE_ENCRYPTED) private val _sendBufferEncrypted = ByteArray(4 + MAXIMUM_PACKET_SIZE_ENCRYPTED)
private val _syncStreams = hashMapOf<Int, SyncStream>() private val _syncStreams = hashMapOf<Int, SyncStream>()
private var _streamIdGenerator = 0 private var _streamIdGenerator = 0
private val _streamIdGeneratorLock = Object() private val _streamIdGeneratorLock = Object()
@ -38,12 +50,13 @@ class SyncSocketSession {
private val _onHandshakeComplete: ((session: SyncSocketSession) -> Unit)? private val _onHandshakeComplete: ((session: SyncSocketSession) -> Unit)?
private val _onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)? private val _onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)?
private val _onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)? private val _onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)?
private val _isHandshakeAllowed: ((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 _cipherStatePair: CipherStatePair? = null
private var _remotePublicKey: String? = null private var _remotePublicKey: String? = null
val remotePublicKey: String? get() = _remotePublicKey val remotePublicKey: String? get() = _remotePublicKey
private var _started: Boolean = false private var _started: Boolean = false
private val _localKeyPair: DHState private val _localKeyPair: DHState
private var _thread: Thread? = null
private var _localPublicKey: String private var _localPublicKey: String
val localPublicKey: String get() = _localPublicKey val localPublicKey: String get() = _localPublicKey
private val _onData: ((session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit)? private val _onData: ((session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit)?
@ -65,6 +78,11 @@ class SyncSocketSession {
private val _pendingBulkGetRecordRequests = ConcurrentHashMap<Int, CompletableDeferred<Map<String, Pair<ByteArray, Long>>>>() private val _pendingBulkGetRecordRequests = ConcurrentHashMap<Int, CompletableDeferred<Map<String, Pair<ByteArray, Long>>>>()
private val _pendingBulkConnectionInfoRequests = ConcurrentHashMap<Int, CompletableDeferred<Map<String, ConnectionInfo>>>() 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( data class ConnectionInfo(
val port: UShort, val port: UShort,
val name: String, val name: String,
@ -80,17 +98,20 @@ class SyncSocketSession {
constructor( constructor(
remoteAddress: String, remoteAddress: String,
localKeyPair: DHState, localKeyPair: DHState,
inputStream: LittleEndianDataInputStream, socket: Socket,
outputStream: LittleEndianDataOutputStream,
onClose: ((session: SyncSocketSession) -> Unit)? = null, onClose: ((session: SyncSocketSession) -> Unit)? = null,
onHandshakeComplete: ((session: SyncSocketSession) -> Unit)? = null, onHandshakeComplete: ((session: SyncSocketSession) -> Unit)? = null,
onData: ((session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit)? = null, onData: ((session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit)? = null,
onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)? = null, onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)? = null,
onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)? = null, onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)? = null,
isHandshakeAllowed: ((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 _socket = socket
_outputStream = outputStream _socket.receiveBufferSize = MAXIMUM_PACKET_SIZE_ENCRYPTED
_socket.sendBufferSize = MAXIMUM_PACKET_SIZE_ENCRYPTED
_socket.tcpNoDelay = true
_inputStream = _socket.getInputStream()
_outputStream = _socket.getOutputStream()
_onClose = onClose _onClose = onClose
_onHandshakeComplete = onHandshakeComplete _onHandshakeComplete = onHandshakeComplete
_localKeyPair = localKeyPair _localKeyPair = localKeyPair
@ -105,11 +126,28 @@ class SyncSocketSession {
_localPublicKey = Base64.getEncoder().encodeToString(localPublicKey) _localPublicKey = Base64.getEncoder().encodeToString(localPublicKey)
} }
fun startAsInitiator(remotePublicKey: String, pairingCode: String? = null) { fun startAsInitiator(remotePublicKey: String, appId: UInt = 0u, pairingCode: String? = null) {
_started = true
_thread = Thread {
try {
handshakeAsInitiator(remotePublicKey, appId, pairingCode)
_onHandshakeComplete?.invoke(this)
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 _started = true
try { try {
handshakeAsInitiator(remotePublicKey, pairingCode) handshakeAsInitiator(remotePublicKey, appId, pairingCode)
_onHandshakeComplete?.invoke(this) _onHandshakeComplete?.invoke(this)
startPingLoop()
receiveLoop() receiveLoop()
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to run as initiator", e) Logger.e(TAG, "Failed to run as initiator", e)
@ -120,9 +158,11 @@ class SyncSocketSession {
fun startAsResponder() { fun startAsResponder() {
_started = true _started = true
_thread = Thread {
try { try {
if (handshakeAsResponder()) { if (handshakeAsResponder()) {
_onHandshakeComplete?.invoke(this) _onHandshakeComplete?.invoke(this)
startPingLoop()
receiveLoop() receiveLoop()
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@ -130,32 +170,48 @@ class SyncSocketSession {
} finally { } finally {
stop() 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() { private fun receiveLoop() {
while (_started) { while (_started) {
try { try {
val messageSize = _inputStream.readInt() //Logger.v(TAG, "Waiting for message size...")
readExact(_buffer, 0, 4)
val messageSize = ByteBuffer.wrap(_buffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int
//Logger.v(TAG, "Read message size ${messageSize}.")
if (messageSize > MAXIMUM_PACKET_SIZE_ENCRYPTED) { if (messageSize > MAXIMUM_PACKET_SIZE_ENCRYPTED) {
throw Exception("Message size (${messageSize}) cannot exceed MAXIMUM_PACKET_SIZE ($MAXIMUM_PACKET_SIZE_ENCRYPTED)") throw Exception("Message size (${messageSize}) cannot exceed MAXIMUM_PACKET_SIZE ($MAXIMUM_PACKET_SIZE_ENCRYPTED)")
} }
//Logger.i(TAG, "Receiving message (size = ${messageSize})") //Logger.i(TAG, "Receiving message (size = ${messageSize})")
var bytesRead = 0 readExact(_buffer, 0, messageSize)
while (bytesRead < messageSize) { //Logger.v(TAG, "Read ${messageSize}.")
val read = _inputStream.read(_buffer, bytesRead, messageSize - bytesRead)
if (read == -1)
throw Exception("Stream closed")
bytesRead += read
}
//Logger.v(TAG, "Decrypting ${messageSize} bytes.")
val plen: Int = _cipherStatePair!!.receiver.decryptWithAd(null, _buffer, 0, _bufferDecrypted, 0, messageSize) val plen: Int = _cipherStatePair!!.receiver.decryptWithAd(null, _buffer, 0, _bufferDecrypted, 0, messageSize)
//Logger.i(TAG, "Decrypted message (size = ${plen})") //Logger.i(TAG, "Decrypted message (size = ${plen})")
//Logger.v(TAG, "Decrypted ${messageSize} bytes.")
handleData(_bufferDecrypted, plen, null) handleData(_bufferDecrypted, plen, null)
//Logger.v(TAG, "Handled data ${messageSize} bytes.")
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Exception while receiving data", e) Logger.e(TAG, "Exception while receiving data, closing socket session", e)
stop()
break break
} }
} }
@ -185,17 +241,17 @@ class SyncSocketSession {
_channels.values.forEach { it.close() } _channels.values.forEach { it.close() }
_channels.clear() _channels.clear()
_onClose?.invoke(this) _onClose?.invoke(this)
_inputStream.close() _socket.close()
_outputStream.close() _thread = null
_cipherStatePair?.sender?.destroy() _cipherStatePair?.sender?.destroy()
_cipherStatePair?.receiver?.destroy() _cipherStatePair?.receiver?.destroy()
Logger.i(TAG, "Session closed") Logger.i(TAG, "Session closed")
} }
private fun handshakeAsInitiator(remotePublicKey: String, pairingCode: String?) { private fun handshakeAsInitiator(remotePublicKey: String, appId: UInt, pairingCode: String?) {
performVersionCheck() performVersionCheck()
val initiator = HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR) val initiator = HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR)
initiator.localKeyPair.copyFrom(_localKeyPair) initiator.localKeyPair.copyFrom(_localKeyPair)
initiator.remotePublicKey.setPublicKey(Base64.getDecoder().decode(remotePublicKey), 0) initiator.remotePublicKey.setPublicKey(Base64.getDecoder().decode(remotePublicKey), 0)
initiator.start() initiator.start()
@ -218,41 +274,55 @@ class SyncSocketSession {
val mainBuffer = ByteArray(512) val mainBuffer = ByteArray(512)
val mainLength = initiator.writeMessage(mainBuffer, 0, null, 0, 0) val mainLength = initiator.writeMessage(mainBuffer, 0, null, 0, 0)
val messageData = ByteBuffer.allocate(4 + pairingMessageLength + mainLength).order(ByteOrder.LITTLE_ENDIAN) val messageSize = 4 + 4 + pairingMessageLength + mainLength
val messageData = ByteBuffer.allocate(4 + messageSize).order(ByteOrder.LITTLE_ENDIAN)
messageData.putInt(messageSize)
messageData.putInt(appId.toInt())
messageData.putInt(pairingMessageLength) messageData.putInt(pairingMessageLength)
if (pairingMessageLength > 0) messageData.put(pairingMessage) if (pairingMessageLength > 0) messageData.put(pairingMessage)
messageData.put(mainBuffer, 0, mainLength) messageData.put(mainBuffer, 0, mainLength)
val messageDataArray = messageData.array() val messageDataArray = messageData.array()
_outputStream.writeInt(messageDataArray.size) _outputStream.write(messageDataArray, 0, 4 + messageSize)
_outputStream.write(messageDataArray)
readExact(_buffer, 0, 4)
val responseSize = ByteBuffer.wrap(_buffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int
if (responseSize > MAXIMUM_PACKET_SIZE_ENCRYPTED) {
throw Exception("Message size (${messageSize}) cannot exceed MAXIMUM_PACKET_SIZE ($MAXIMUM_PACKET_SIZE_ENCRYPTED)")
}
val responseSize = _inputStream.readInt()
val responseMessage = ByteArray(responseSize) val responseMessage = ByteArray(responseSize)
_inputStream.readFully(responseMessage) readExact(responseMessage, 0, responseSize)
val plaintext = ByteArray(512) // Buffer for any payload (none expected here) val plaintext = ByteArray(512) // Buffer for any payload (none expected here)
initiator.readMessage(responseMessage, 0, responseSize, plaintext, 0) initiator.readMessage(responseMessage, 0, responseSize, plaintext, 0)
_cipherStatePair = initiator.split() _cipherStatePair = initiator.split()
val remoteKeyBytes = ByteArray(initiator.remotePublicKey.publicKeyLength) val remoteKeyBytes = ByteArray(initiator.remotePublicKey.publicKeyLength)
initiator.remotePublicKey.getPublicKey(remoteKeyBytes, 0) initiator.remotePublicKey.getPublicKey(remoteKeyBytes, 0)
_remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes) _remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes).base64ToByteArray().toBase64()
} }
private fun handshakeAsResponder(): Boolean { private fun handshakeAsResponder(): Boolean {
performVersionCheck() performVersionCheck()
val responder = HandshakeState(StateSync.protocolName, HandshakeState.RESPONDER) val responder = HandshakeState(SyncService.protocolName, HandshakeState.RESPONDER)
responder.localKeyPair.copyFrom(_localKeyPair) responder.localKeyPair.copyFrom(_localKeyPair)
responder.start() responder.start()
val messageSize = _inputStream.readInt() readExact(_buffer, 0, 4)
val message = ByteArray(messageSize) val messageSize = ByteBuffer.wrap(_buffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int
_inputStream.readFully(message) if (messageSize > MAXIMUM_PACKET_SIZE_ENCRYPTED) {
val messageBuffer = ByteBuffer.wrap(message).order(ByteOrder.LITTLE_ENDIAN) throw Exception("Message size (${messageSize}) cannot exceed MAXIMUM_PACKET_SIZE ($MAXIMUM_PACKET_SIZE_ENCRYPTED)")
}
val message = ByteArray(messageSize)
readExact(message, 0, messageSize)
val messageBuffer = ByteBuffer.wrap(message).order(ByteOrder.LITTLE_ENDIAN)
val appId = messageBuffer.int.toUInt()
val pairingMessageLength = messageBuffer.int val pairingMessageLength = messageBuffer.int
val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { messageBuffer.get(it) } else byteArrayOf() val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { messageBuffer.get(it) } else byteArrayOf()
val mainLength = messageSize - 4 - pairingMessageLength val mainLength = messageBuffer.remaining()
val mainMessage = ByteArray(mainLength).also { messageBuffer.get(it) } val mainMessage = ByteArray(mainLength).also { messageBuffer.get(it) }
var pairingCode: String? = null var pairingCode: String? = null
@ -267,27 +337,36 @@ class SyncSocketSession {
val plaintext = ByteArray(512) val plaintext = ByteArray(512)
responder.readMessage(mainMessage, 0, mainLength, plaintext, 0) responder.readMessage(mainMessage, 0, mainLength, plaintext, 0)
val responseBuffer = ByteArray(512)
val responseLength = responder.writeMessage(responseBuffer, 0, null, 0, 0)
_outputStream.writeInt(responseLength)
_outputStream.write(responseBuffer, 0, responseLength)
_cipherStatePair = responder.split()
val remoteKeyBytes = ByteArray(responder.remotePublicKey.publicKeyLength) val remoteKeyBytes = ByteArray(responder.remotePublicKey.publicKeyLength)
responder.remotePublicKey.getPublicKey(remoteKeyBytes, 0) responder.remotePublicKey.getPublicKey(remoteKeyBytes, 0)
_remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes) val remotePublicKey = remoteKeyBytes.toBase64()
return (_remotePublicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Direct, this, _remotePublicKey!!, pairingCode) ?: true)).also { val isAllowedToConnect = remotePublicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Direct, this, remotePublicKey, pairingCode, appId) ?: true)
if (!it) stop() if (!isAllowedToConnect) {
stop()
return false
} }
val responseBuffer = ByteArray(4 + 512)
val responseLength = responder.writeMessage(responseBuffer, 4, null, 0, 0)
ByteBuffer.wrap(responseBuffer).order(ByteOrder.LITTLE_ENDIAN).putInt(responseLength)
_outputStream.write(responseBuffer, 0, 4 + responseLength)
_cipherStatePair = responder.split()
_remotePublicKey = remotePublicKey.base64ToByteArray().toBase64()
return true
} }
private fun performVersionCheck() { private fun performVersionCheck() {
val CURRENT_VERSION = 4 val CURRENT_VERSION = 5
val MINIMUM_VERSION = 4 val MINIMUM_VERSION = 4
_outputStream.writeInt(CURRENT_VERSION)
remoteVersion = _inputStream.readInt() val versionBytes = ByteArray(4)
ByteBuffer.wrap(versionBytes).order(ByteOrder.LITTLE_ENDIAN).putInt(CURRENT_VERSION)
_outputStream.write(versionBytes, 0, 4)
readExact(versionBytes, 0, 4)
remoteVersion = ByteBuffer.wrap(versionBytes, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int
Logger.i(TAG, "performVersionCheck (version = $remoteVersion)") Logger.i(TAG, "performVersionCheck (version = $remoteVersion)")
if (remoteVersion < MINIMUM_VERSION) if (remoteVersion < MINIMUM_VERSION)
throw Exception("Invalid version") throw Exception("Invalid version")
@ -296,25 +375,44 @@ class SyncSocketSession {
fun generateStreamId(): Int = synchronized(_streamIdGeneratorLock) { _streamIdGenerator++ } fun generateStreamId(): Int = synchronized(_streamIdGeneratorLock) { _streamIdGenerator++ }
private fun generateRequestId(): Int = synchronized(_requestIdGeneratorLock) { _requestIdGenerator++ } private fun generateRequestId(): Int = synchronized(_requestIdGeneratorLock) { _requestIdGenerator++ }
fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer, ce: ContentEncoding? = null) {
ensureNotMainThread() ensureNotMainThread()
if (data.remaining() + HEADER_SIZE > MAXIMUM_PACKET_SIZE) { Logger.v(TAG, "send (opcode: ${opcode}, subOpcode: ${subOpcode}, data.remaining(): ${data.remaining()})")
var contentEncoding: ContentEncoding? = ce
var processedData = data
if (contentEncoding == ContentEncoding.Gzip) {
val isGzipSupported = opcode == Opcode.DATA.value
if (isGzipSupported) {
val compressedStream = ByteArrayOutputStream()
GZIPOutputStream(compressedStream).use { gzipStream ->
gzipStream.write(data.array(), data.position(), data.remaining())
gzipStream.finish()
}
processedData = ByteBuffer.wrap(compressedStream.toByteArray())
} else {
Logger.w(TAG, "Gzip requested but not supported on this (opcode = ${opcode}, subOpcode = ${subOpcode}), falling back.")
contentEncoding = ContentEncoding.Raw
}
}
if (processedData.remaining() + HEADER_SIZE > MAXIMUM_PACKET_SIZE) {
val segmentSize = MAXIMUM_PACKET_SIZE - HEADER_SIZE val segmentSize = MAXIMUM_PACKET_SIZE - HEADER_SIZE
val segmentData = ByteArray(segmentSize) val segmentData = ByteArray(segmentSize)
var sendOffset = 0 var sendOffset = 0
val id = generateStreamId() val id = generateStreamId()
while (sendOffset < data.remaining()) { while (sendOffset < processedData.remaining()) {
val bytesRemaining = data.remaining() - sendOffset val bytesRemaining = processedData.remaining() - sendOffset
var bytesToSend: Int var bytesToSend: Int
var segmentPacketSize: Int var segmentPacketSize: Int
val streamOp: StreamOpcode val streamOp: StreamOpcode
if (sendOffset == 0) { if (sendOffset == 0) {
streamOp = StreamOpcode.START streamOp = StreamOpcode.START
bytesToSend = segmentSize - 4 - 4 - 1 - 1 bytesToSend = segmentSize - 4 - HEADER_SIZE
segmentPacketSize = bytesToSend + 4 + 4 + 1 + 1 segmentPacketSize = bytesToSend + 4 + HEADER_SIZE
} else { } else {
bytesToSend = minOf(segmentSize - 4 - 4, bytesRemaining) bytesToSend = minOf(segmentSize - 4 - 4, bytesRemaining)
streamOp = if (bytesToSend >= bytesRemaining) StreamOpcode.END else StreamOpcode.DATA streamOp = if (bytesToSend >= bytesRemaining) StreamOpcode.END else StreamOpcode.DATA
@ -323,12 +421,13 @@ class SyncSocketSession {
ByteBuffer.wrap(segmentData).order(ByteOrder.LITTLE_ENDIAN).apply { ByteBuffer.wrap(segmentData).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(id) putInt(id)
putInt(if (streamOp == StreamOpcode.START) data.remaining() else sendOffset) putInt(if (streamOp == StreamOpcode.START) processedData.remaining() else sendOffset)
if (streamOp == StreamOpcode.START) { if (streamOp == StreamOpcode.START) {
put(opcode.toByte()) put(opcode.toByte())
put(subOpcode.toByte()) put(subOpcode.toByte())
put(contentEncoding?.value?.toByte() ?: ContentEncoding.Raw.value.toByte())
} }
put(data.array(), data.position() + sendOffset, bytesToSend) put(processedData.array(), processedData.position() + sendOffset, bytesToSend)
} }
send(Opcode.STREAM.value, streamOp.value, ByteBuffer.wrap(segmentData, 0, segmentPacketSize)) send(Opcode.STREAM.value, streamOp.value, ByteBuffer.wrap(segmentData, 0, segmentPacketSize))
@ -337,17 +436,19 @@ class SyncSocketSession {
} else { } else {
synchronized(_sendLockObject) { synchronized(_sendLockObject) {
ByteBuffer.wrap(_sendBuffer).order(ByteOrder.LITTLE_ENDIAN).apply { ByteBuffer.wrap(_sendBuffer).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(data.remaining() + 2) putInt(processedData.remaining() + HEADER_SIZE - 4)
put(opcode.toByte()) put(opcode.toByte())
put(subOpcode.toByte()) put(subOpcode.toByte())
put(data.array(), data.position(), data.remaining()) put(contentEncoding?.value?.toByte() ?: ContentEncoding.Raw.value.toByte())
put(processedData.array(), processedData.position(), processedData.remaining())
} }
//Logger.i(TAG, "Encrypting message (size = ${data.size + HEADER_SIZE})") val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 4, processedData.remaining() + HEADER_SIZE)
val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 0, data.remaining() + HEADER_SIZE) val sendDuration = measureTimeMillis {
//Logger.i(TAG, "Sending encrypted message (size = ${len})") ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len)
_outputStream.writeInt(len) _outputStream.write(_sendBufferEncrypted, 0, 4 + len)
_outputStream.write(_sendBufferEncrypted, 0, len) }
Logger.v(TAG, "_outputStream.write (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.remaining(): ${processedData.remaining()}, sendDuration: ${sendDuration})")
} }
} }
} }
@ -357,17 +458,18 @@ class SyncSocketSession {
ensureNotMainThread() ensureNotMainThread()
synchronized(_sendLockObject) { synchronized(_sendLockObject) {
ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(2) ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(HEADER_SIZE - 4)
_sendBuffer.asUByteArray()[4] = opcode _sendBuffer.asUByteArray()[4] = opcode
_sendBuffer.asUByteArray()[5] = subOpcode _sendBuffer.asUByteArray()[5] = subOpcode
_sendBuffer.asUByteArray()[6] = ContentEncoding.Raw.value
//Logger.i(TAG, "Encrypting message (opcode = ${opcode}, subOpcode = ${subOpcode}, size = ${HEADER_SIZE})") //Logger.i(TAG, "Encrypting message (opcode = ${opcode}, subOpcode = ${subOpcode}, size = ${HEADER_SIZE})")
val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 0, HEADER_SIZE) val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 4, HEADER_SIZE)
//Logger.i(TAG, "Sending encrypted message (size = ${len})") //Logger.i(TAG, "Sending encrypted message (size = ${len})")
_outputStream.writeInt(len) ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len)
_outputStream.write(_sendBufferEncrypted, 0, len) _outputStream.write(_sendBufferEncrypted, 0, 4 + len)
} }
} }
@ -378,7 +480,7 @@ class SyncSocketSession {
private fun handleData(data: ByteBuffer, sourceChannel: ChannelRelayed?) { private fun handleData(data: ByteBuffer, sourceChannel: ChannelRelayed?) {
val length = data.remaining() val length = data.remaining()
if (length < HEADER_SIZE) if (length < HEADER_SIZE)
throw Exception("Packet must be at least 6 bytes (header size)") throw Exception("Packet must be at least ${HEADER_SIZE} bytes (header size)")
val size = data.int val size = data.int
if (size != length - 4) if (size != length - 4)
@ -386,7 +488,10 @@ class SyncSocketSession {
val opcode = data.get().toUByte() val opcode = data.get().toUByte()
val subOpcode = data.get().toUByte() val subOpcode = data.get().toUByte()
handlePacket(opcode, subOpcode, data, sourceChannel) val contentEncoding = data.get().toUByte()
//Logger.v(TAG, "handleData (opcode: ${opcode}, subOpcode: ${subOpcode}, data.size: ${data.remaining()}, sourceChannel.connectionId: ${sourceChannel?.connectionId})")
handlePacket(opcode, subOpcode, data, contentEncoding, sourceChannel)
} }
private fun handleRequest(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { private fun handleRequest(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) {
@ -400,13 +505,14 @@ class SyncSocketSession {
val remoteVersion = data.int val remoteVersion = data.int
val connectionId = data.long val connectionId = data.long
val requestId = data.int val requestId = data.int
val appId = data.int.toUInt()
val publicKeyBytes = ByteArray(32).also { data.get(it) } val publicKeyBytes = ByteArray(32).also { data.get(it) }
val pairingMessageLength = data.int val pairingMessageLength = data.int
if (pairingMessageLength > 128) throw IllegalArgumentException("Pairing message length ($pairingMessageLength) exceeds maximum (128)") if (pairingMessageLength > 128) throw IllegalArgumentException("Pairing message length ($pairingMessageLength) exceeds maximum (128) (app id: $appId)")
val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { data.get(it) } else ByteArray(0) val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { data.get(it) } else ByteArray(0)
val channelMessageLength = data.int val channelMessageLength = data.int
if (data.remaining() != channelMessageLength) { if (data.remaining() != channelMessageLength) {
Logger.e(TAG, "Invalid packet size. Expected ${52 + pairingMessageLength + 4 + channelMessageLength}, got ${data.capacity()}") Logger.e(TAG, "Invalid packet size. Expected ${52 + pairingMessageLength + 4 + channelMessageLength}, got ${data.capacity()} (app id: $appId)")
return return
} }
val channelHandshakeMessage = ByteArray(channelMessageLength).also { data.get(it) } val channelHandshakeMessage = ByteArray(channelMessageLength).also { data.get(it) }
@ -420,7 +526,7 @@ class SyncSocketSession {
val length = pairingProtocol.readMessage(pairingMessage, 0, pairingMessageLength, plaintext, 0) val length = pairingProtocol.readMessage(pairingMessage, 0, pairingMessageLength, plaintext, 0)
String(plaintext, 0, length, Charsets.UTF_8) String(plaintext, 0, length, Charsets.UTF_8)
} else null } else null
val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode) ?: true) val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode, appId) ?: true)
if (!isAllowed) { if (!isAllowed) {
val rp = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN) val rp = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN)
rp.putInt(2) // Status code for not allowed rp.putInt(2) // Status code for not allowed
@ -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})") Logger.i(TAG, "Handle packet (opcode = ${opcode}, subOpcode = ${subOpcode})")
var data = d
if (contentEncoding == ContentEncoding.Gzip.value) {
val isGzipSupported = opcode == Opcode.DATA.value
if (!isGzipSupported)
throw Exception("Failed to handle packet, gzip is not supported for this opcode (opcode = ${opcode}, subOpcode = ${subOpcode}, data.length = ${data.remaining()}).")
val compressedStream = ByteArrayInputStream(data.array(), data.position(), data.remaining())
val outputStream = ByteArrayOutputStream()
GZIPInputStream(compressedStream).use { gzipStream ->
val buffer = ByteArray(8192) // 8KB buffer
var bytesRead: Int
while (gzipStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
}
}
data = ByteBuffer.wrap(outputStream.toByteArray())
}
when (opcode) { when (opcode) {
Opcode.PING.value -> { Opcode.PING.value -> {
if (sourceChannel != null) if (sourceChannel != null)
@ -746,6 +894,11 @@ class SyncSocketSession {
return return
} }
Opcode.PONG.value -> { Opcode.PONG.value -> {
if (sourceChannel != null) {
sourceChannel.invokeDataHandler(opcode, subOpcode, data)
} else {
_lastPongTime = System.currentTimeMillis()
}
Logger.v(TAG, "Received pong") Logger.v(TAG, "Received pong")
return return
} }
@ -773,8 +926,9 @@ class SyncSocketSession {
val expectedSize = data.int val expectedSize = data.int
val op = data.get().toUByte() val op = data.get().toUByte()
val subOp = data.get().toUByte() val subOp = data.get().toUByte()
val ce = data.get().toUByte()
val syncStream = SyncStream(expectedSize, op, subOp) val syncStream = SyncStream(expectedSize, op, subOp, ce)
if (data.remaining() > 0) { if (data.remaining() > 0) {
syncStream.add(data.array(), data.position(), data.remaining()) syncStream.add(data.array(), data.position(), data.remaining())
} }
@ -819,7 +973,7 @@ class SyncSocketSession {
throw Exception("After sync stream end, the stream must be complete") throw Exception("After sync stream end, the stream must be complete")
} }
handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }, sourceChannel) handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }, syncStream.contentEncoding, sourceChannel)
} }
} }
Opcode.DATA.value -> { Opcode.DATA.value -> {
@ -876,14 +1030,14 @@ class SyncSocketSession {
return deferred.await() return deferred.await()
} }
suspend fun startRelayedChannel(publicKey: String, pairingCode: String? = null): ChannelRelayed? { suspend fun startRelayedChannel(publicKey: String, appId: UInt = 0u, pairingCode: String? = null): ChannelRelayed? {
val requestId = generateRequestId() val requestId = generateRequestId()
val deferred = CompletableDeferred<ChannelRelayed>() val deferred = CompletableDeferred<ChannelRelayed>()
val channel = ChannelRelayed(this, _localKeyPair, publicKey, true) val channel = ChannelRelayed(this, _localKeyPair, publicKey.base64ToByteArray().toBase64(), true)
_onNewChannel?.invoke(this, channel) _onNewChannel?.invoke(this, channel)
_pendingChannels[requestId] = channel to deferred _pendingChannels[requestId] = channel to deferred
try { try {
channel.sendRequestTransport(requestId, publicKey, pairingCode) channel.sendRequestTransport(requestId, publicKey, appId, pairingCode)
} catch (e: Exception) { } catch (e: Exception) {
_pendingChannels.remove(requestId)?.let { it.first.close(); it.second.completeExceptionally(e) } _pendingChannels.remove(requestId)?.let { it.first.close(); it.second.completeExceptionally(e) }
throw e throw e
@ -999,7 +1153,7 @@ class SyncSocketSession {
send(Opcode.NOTIFY.value, NotifyOpcode.CONNECTION_INFO.value, publishBytes) send(Opcode.NOTIFY.value, NotifyOpcode.CONNECTION_INFO.value, publishBytes)
} }
suspend fun publishRecords(consumerPublicKeys: List<String>, key: String, data: ByteArray): Boolean { suspend fun publishRecords(consumerPublicKeys: List<String>, key: String, data: ByteArray, contentEncoding: ContentEncoding? = null): Boolean {
val keyBytes = key.toByteArray(Charsets.UTF_8) val keyBytes = key.toByteArray(Charsets.UTF_8)
if (key.isEmpty() || keyBytes.size > 32) throw IllegalArgumentException("Key must be 1-32 bytes") if (key.isEmpty() || keyBytes.size > 32) throw IllegalArgumentException("Key must be 1-32 bytes")
if (consumerPublicKeys.isEmpty()) throw IllegalArgumentException("At least one consumer required") if (consumerPublicKeys.isEmpty()) throw IllegalArgumentException("At least one consumer required")
@ -1054,7 +1208,7 @@ class SyncSocketSession {
} }
} }
packet.rewind() packet.rewind()
send(Opcode.REQUEST.value, RequestOpcode.BULK_PUBLISH_RECORD.value, packet) send(Opcode.REQUEST.value, RequestOpcode.BULK_PUBLISH_RECORD.value, packet, ce = contentEncoding)
} catch (e: Exception) { } catch (e: Exception) {
_pendingPublishRequests.remove(requestId)?.completeExceptionally(e) _pendingPublishRequests.remove(requestId)?.completeExceptionally(e)
throw e throw e
@ -1174,6 +1328,6 @@ class SyncSocketSession {
private const val TAG = "SyncSocketSession" private const val TAG = "SyncSocketSession"
const val MAXIMUM_PACKET_SIZE = 65535 - 16 const val MAXIMUM_PACKET_SIZE = 65535 - 16
const val MAXIMUM_PACKET_SIZE_ENCRYPTED = MAXIMUM_PACKET_SIZE + 16 const val MAXIMUM_PACKET_SIZE_ENCRYPTED = MAXIMUM_PACKET_SIZE + 16
const val HEADER_SIZE = 6 const val HEADER_SIZE = 7
} }
} }

View file

@ -1,6 +1,6 @@
package com.futo.platformplayer.sync.internal package com.futo.platformplayer.sync.internal
class SyncStream(expectedSize: Int, val opcode: UByte, val subOpcode: UByte) { class SyncStream(expectedSize: Int, val opcode: UByte, val subOpcode: UByte, val contentEncoding: UByte) {
companion object { companion object {
const val MAXIMUM_SIZE = 10_000_000 const val MAXIMUM_SIZE = 10_000_000
} }

View file

@ -45,6 +45,10 @@ open class ChannelView : LinearLayout {
_buttonSubscribe = findViewById(R.id.button_subscribe); _buttonSubscribe = findViewById(R.id.button_subscribe);
_platformIndicator = findViewById(R.id.platform_indicator); _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) { if (_tiny) {
_buttonSubscribe.visibility = View.GONE; _buttonSubscribe.visibility = View.GONE;
_textMetadata.visibility = View.GONE; _textMetadata.visibility = View.GONE;
@ -66,8 +70,11 @@ open class ChannelView : LinearLayout {
open fun bind(content: IPlatformContent) { open fun bind(content: IPlatformContent) {
isClickable = true; isClickable = true;
if(content !is IPlatformChannelContent) if(content !is IPlatformChannelContent) {
return currentChannel = null;
return;
}
currentChannel = content;
_creatorThumbnail.setThumbnail(content.thumbnail, false); _creatorThumbnail.setThumbnail(content.thumbnail, false);
_textName.text = content.name; _textName.text = content.name;

View file

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

View file

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

View file

@ -14,9 +14,11 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.others.ProgressBar
import com.futo.platformplayer.views.platform.PlatformIndicator
class HistoryListViewHolder : ViewHolder { class HistoryListViewHolder : ViewHolder {
private val _root: ConstraintLayout; private val _root: ConstraintLayout;
@ -30,6 +32,7 @@ class HistoryListViewHolder : ViewHolder {
private val _imageRemove: ImageButton; private val _imageRemove: ImageButton;
private val _textHeader: TextView; private val _textHeader: TextView;
private val _timeBar: ProgressBar; private val _timeBar: ProgressBar;
private val _thumbnailPlatform: PlatformIndicator
var video: HistoryVideo? = null var video: HistoryVideo? = null
private set; private set;
@ -47,6 +50,7 @@ class HistoryListViewHolder : ViewHolder {
_textVideoDuration = itemView.findViewById(R.id.thumbnail_duration); _textVideoDuration = itemView.findViewById(R.id.thumbnail_duration);
_containerDuration = itemView.findViewById(R.id.thumbnail_duration_container); _containerDuration = itemView.findViewById(R.id.thumbnail_duration_container);
_containerLive = itemView.findViewById(R.id.thumbnail_live_container); _containerLive = itemView.findViewById(R.id.thumbnail_live_container);
_thumbnailPlatform = itemView.findViewById(R.id.thumbnail_platform)
_imageRemove = itemView.findViewById(R.id.image_trash); _imageRemove = itemView.findViewById(R.id.image_trash);
_textHeader = itemView.findViewById(R.id.text_header); _textHeader = itemView.findViewById(R.id.text_header);
_timeBar = itemView.findViewById(R.id.time_bar); _timeBar = itemView.findViewById(R.id.time_bar);
@ -73,6 +77,9 @@ class HistoryListViewHolder : ViewHolder {
_textAuthor.text = v.video.author.name; _textAuthor.text = v.video.author.name;
_textVideoDuration.text = v.video.duration.toHumanTime(false); _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) { if(v.video.isLive) {
_containerDuration.visibility = View.GONE; _containerDuration.visibility = View.GONE;
_containerLive.visibility = View.VISIBLE; _containerLive.visibility = View.VISIBLE;

View file

@ -37,9 +37,10 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
_onDatasetChanged = onDatasetChanged; _onDatasetChanged = onDatasetChanged;
StateSubscriptions.instance.onSubscriptionsChanged.subscribe { _, _ -> if(Looper.myLooper() != Looper.getMainLooper()) StateSubscriptions.instance.onSubscriptionsChanged.subscribe { _, _ -> if(Looper.myLooper() != Looper.getMainLooper())
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { updateDataset() } StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { updateDataset() }
else else
updateDataset(); } updateDataset();
}
updateDataset(); updateDataset();
} }

View file

@ -17,9 +17,11 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.toHumanNowDiffString import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.views.others.ProgressBar
import com.futo.platformplayer.views.platform.PlatformIndicator import com.futo.platformplayer.views.platform.PlatformIndicator
class VideoListEditorViewHolder : ViewHolder { class VideoListEditorViewHolder : ViewHolder {
@ -36,6 +38,7 @@ class VideoListEditorViewHolder : ViewHolder {
private val _imageDragDrop: ImageButton; private val _imageDragDrop: ImageButton;
private val _platformIndicator: PlatformIndicator; private val _platformIndicator: PlatformIndicator;
private val _layoutDownloaded: FrameLayout; private val _layoutDownloaded: FrameLayout;
private val _timeBar: ProgressBar
var video: IPlatformVideo? = null var video: IPlatformVideo? = null
private set; private set;
@ -59,6 +62,7 @@ class VideoListEditorViewHolder : ViewHolder {
_imageOptions = view.findViewById(R.id.image_settings); _imageOptions = view.findViewById(R.id.image_settings);
_imageDragDrop = view.findViewById<ImageButton>(R.id.image_drag_drop); _imageDragDrop = view.findViewById<ImageButton>(R.id.image_drag_drop);
_platformIndicator = view.findViewById(R.id.thumbnail_platform); _platformIndicator = view.findViewById(R.id.thumbnail_platform);
_timeBar = view.findViewById(R.id.time_bar);
_layoutDownloaded = view.findViewById(R.id.layout_downloaded); _layoutDownloaded = view.findViewById(R.id.layout_downloaded);
_imageDragDrop.setOnTouchListener { _, event -> _imageDragDrop.setOnTouchListener { _, event ->
@ -93,6 +97,9 @@ class VideoListEditorViewHolder : ViewHolder {
_textAuthor.text = v.author.name; _textAuthor.text = v.author.name;
_textVideoDuration.text = v.duration.toHumanTime(false); _textVideoDuration.text = v.duration.toHumanTime(false);
val historyPosition = StateHistory.instance.getHistoryPosition(v.url)
_timeBar.progress = historyPosition.toFloat() / v.duration.toFloat();
if(v.isLive) { if(v.isLive) {
_containerDuration.visibility = View.GONE; _containerDuration.visibility = View.GONE;
_containerLive.visibility = View.VISIBLE; _containerLive.visibility = View.VISIBLE;

View file

@ -628,12 +628,12 @@ class GestureControlView : LinearLayout {
private fun fastForwardTick() { private fun fastForwardTick() {
_fastForwardCounter++; _fastForwardCounter++;
val seekOffset: Long = 10000; val seekOffset: Long = Settings.instance.playback.getSeekOffset();
if (_rewinding) { 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); onSeek.emit(-seekOffset);
} else { } else {
_textFastForward.text = "${_fastForwardCounter * 10} " + context.getString(R.string.seconds); _textFastForward.text = "${_fastForwardCounter * seekOffset / 1_000} " + context.getString(R.string.seconds);
onSeek.emit(seekOffset); onSeek.emit(seekOffset);
} }
} }
@ -735,11 +735,7 @@ class GestureControlView : LinearLayout {
_animatorBrightness?.start(); _animatorBrightness?.start();
} }
fun setFullscreen(isFullScreen: Boolean) { fun saveBrightness() {
resetZoomPan()
if (isFullScreen) {
if (Settings.instance.gestureControls.useSystemBrightness) {
try { try {
_originalBrightnessMode = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE) _originalBrightnessMode = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE)
@ -754,18 +750,7 @@ class GestureControlView : LinearLayout {
UIDialogs.toast(context, "useSystemBrightness disabled due to an error") UIDialogs.toast(context, "useSystemBrightness disabled due to an error")
} }
} }
fun restoreBrightness() {
if (Settings.instance.gestureControls.useSystemVolume) {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
_soundFactor = currentVolume.toFloat() / maxVolume.toFloat()
}
onBrightnessAdjusted.emit(_brightnessFactor);
onSoundAdjusted.emit(_soundFactor);
} else {
if (Settings.instance.gestureControls.useSystemBrightness) {
if (Settings.instance.gestureControls.restoreSystemBrightness) { if (Settings.instance.gestureControls.restoreSystemBrightness) {
onBrightnessAdjusted.emit(_originalBrightnessFactor) onBrightnessAdjusted.emit(_originalBrightnessFactor)
@ -779,6 +764,28 @@ class GestureControlView : LinearLayout {
) )
} }
} }
}
fun setFullscreen(isFullScreen: Boolean) {
resetZoomPan()
if (isFullScreen) {
if (Settings.instance.gestureControls.useSystemBrightness) {
saveBrightness()
}
if (Settings.instance.gestureControls.useSystemVolume) {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
_soundFactor = currentVolume.toFloat() / maxVolume.toFloat()
}
onBrightnessAdjusted.emit(_brightnessFactor);
onSoundAdjusted.emit(_soundFactor);
} else {
if (Settings.instance.gestureControls.useSystemBrightness) {
restoreBrightness()
} else { } else {
onBrightnessAdjusted.emit(1.0f); onBrightnessAdjusted.emit(1.0f);
} }

View file

@ -13,6 +13,17 @@ class RadioGroupView : FlexboxLayout {
val selectedOptions = arrayListOf<Any?>(); val selectedOptions = arrayListOf<Any?>();
val onSelectedChange = Event1<List<Any?>>(); val onSelectedChange = Event1<List<Any?>>();
constructor(context: Context) : super(context) {
flexWrap = FlexWrap.WRAP;
_padding_px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, _padding_dp, context.resources.displayMetrics).toInt();
if (isInEditMode) {
setOptions(listOf("Example 1" to 1, "Example 2" to 2, "Example 3" to 3, "Example 4" to 4, "Example 5" to 5), listOf("Example 1", "Example 2"),
multiSelect = true,
atLeastOne = false
);
}
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
flexWrap = FlexWrap.WRAP; flexWrap = FlexWrap.WRAP;
_padding_px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, _padding_dp, context.resources.displayMetrics).toInt(); _padding_px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, _padding_dp, context.resources.displayMetrics).toInt();

View file

@ -22,4 +22,15 @@ class PlatformIndicator : androidx.appcompat.widget.AppCompatImageView {
setImageResource(0); 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);
}
}
} }

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M600,496.92L663.08,560L663.08,600L500,600L500,800L480,820L460,800L460,600L296.92,600L296.92,560L360,496.92L360,200L320,200L320,160L640,160L640,200L600,200L600,496.92Z"/>
</vector>

View file

@ -5,13 +5,19 @@
android:orientation="vertical" android:orientation="vertical"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@color/gray_1d"> android:background="#101010">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal"> android:orientation="horizontal"
android:layout_marginTop="12dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView <TextView
android:id="@+id/text_devices" android:id="@+id/text_devices"
@ -23,13 +29,30 @@
android:textColor="@color/white" android:textColor="@color/white"
android:fontFamily="@font/inter_regular" /> 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 <ImageView
android:id="@+id/image_loader" android:id="@+id/image_loader"
android:layout_width="22dp" android:layout_width="18dp"
android:layout_height="22dp" android:layout_height="18dp"
android:scaleType="fitCenter" android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_loader_animated" app:srcCompat="@drawable/ic_loader_animated"
android:layout_marginStart="5dp"/> android:layout_marginStart="5dp"/>
</LinearLayout>
</LinearLayout>
<Space android:layout_width="0dp" <Space android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="match_parent"
@ -38,7 +61,7 @@
<Button <Button
android:id="@+id/button_close" android:id="@+id/button_close"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:text="@string/close" android:text="@string/close"
android:textSize="14dp" android:textSize="14dp"
android:fontFamily="@font/inter_regular" android:fontFamily="@font/inter_regular"
@ -67,79 +90,102 @@
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_devices" android:id="@+id/recycler_devices"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="100dp" android:layout_height="200dp"
android:layout_marginStart="20dp" android:layout_marginStart="20dp"
android:layout_marginEnd="20dp" /> android:layout_marginEnd="20dp"
android:layout_marginTop="10dp"
android:layout_marginBottom="20dp"/>
</LinearLayout> </LinearLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="1px"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:background="@color/gray_ac" />
<TextView
android:id="@+id/text_remembered_devices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/unable_to_see_the_Device_youre_looking_for_try_add_the_device_manually"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:textSize="9dp"
android:ellipsize="end"
android:textColor="@color/gray_c3"
android:maxLines="3"
android:fontFamily="@font/inter_light"
android:layout_marginTop="12dp"/>
<LinearLayout <LinearLayout
android:id="@+id/layout_remembered_devices_header" android:id="@+id/layout_remembered_devices_header"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal"> android:orientation="horizontal"
android:layout_marginTop="12dp"
<TextView android:layout_marginBottom="20dp"
android:id="@+id/text_remembered_devices"
android:layout_width="0dp"
android:layout_weight="3"
android:layout_height="wrap_content"
android:text="@string/remembered_devices"
android:layout_marginStart="20dp" android:layout_marginStart="20dp"
android:layout_marginEnd="20dp" android:layout_marginEnd="20dp">
android:textSize="14dp"
android:ellipsize="end"
android:textColor="@color/white"
android:maxLines="1"
android:fontFamily="@font/inter_regular" />
<ImageButton
android:id="@+id/button_scan_qr"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_scan_qr"
android:scaleType="centerCrop"
app:srcCompat="@drawable/ic_qr"
app:tint="@color/primary" />
<Space android:layout_width="0dp" <Space android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1" />
<ImageButton <LinearLayout
android:id="@+id/button_add" android:id="@+id/button_add"
android:layout_width="40dp" android:layout_width="wrap_content"
android:layout_height="40dp" android:layout_height="wrap_content"
android:contentDescription="@string/cd_button_add" android:orientation="horizontal"
android:scaleType="centerCrop" android:background="@drawable/background_border_2e_round_6dp"
android:layout_marginEnd="20dp"
android:gravity="center">
<ImageView
android:layout_width="22dp"
android:layout_height="22dp"
app:srcCompat="@drawable/ic_add" app:srcCompat="@drawable/ic_add"
app:tint="@color/primary" android:layout_marginStart="8dp"/>
android:layout_marginEnd="20dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/add_manually"
android:textSize="12dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_medium"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="4dp"
android:paddingEnd="12dp" />
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout
android:id="@+id/layout_remembered_devices" android:id="@+id/button_qr"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/text_no_devices_remembered"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="10dp" android:orientation="horizontal"
android:text="@string/there_are_no_remembered_devices" android:background="@drawable/background_border_2e_round_6dp"
android:layout_marginTop="10dp" android:gravity="center">
android:layout_marginBottom="20dp"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:textColor="@color/gray_e0" />
<androidx.recyclerview.widget.RecyclerView <ImageView
android:id="@+id/recycler_remembered_devices" android:layout_width="22dp"
android:layout_width="match_parent" android:layout_height="22dp"
android:layout_height="100dp" app:srcCompat="@drawable/ic_qr"
android:layout_marginStart="20dp" android:layout_marginStart="8dp"/>
android:layout_marginEnd="20dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/scan_qr"
android:textSize="12dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_medium"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="4dp"
android:paddingEnd="12dp" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>

View file

@ -97,27 +97,7 @@
app:layout_constraintTop_toBottomOf="@id/text_name" app:layout_constraintTop_toBottomOf="@id/text_name"
app:layout_constraintLeft_toRightOf="@id/image_device" /> app:layout_constraintLeft_toRightOf="@id/image_device" />
<LinearLayout
android:id="@+id/button_disconnect"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_button_accent"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_light"
android:text="@string/stop" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
@ -253,4 +233,30 @@
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingBottom="15dp"> android:paddingBottom="15dp">
</LinearLayout> </LinearLayout>
<LinearLayout
android:id="@+id/button_disconnect"
android:layout_width="match_parent"
android:layout_height="35dp"
android:background="@drawable/background_button_accent"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:layout_marginBottom="20dp"
android:gravity="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_light"
android:text="@string/stop_casting" />
</LinearLayout>
</LinearLayout> </LinearLayout>

View file

@ -76,7 +76,7 @@
android:id="@+id/button_buy_text" android:id="@+id/button_buy_text"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="$9.99 + Tax" android:text="$19 + Tax"
android:textSize="14dp" android:textSize="14dp"
android:textColor="@color/white" android:textColor="@color/white"
android:fontFamily="@font/inter_regular" android:fontFamily="@font/inter_regular"

View file

@ -94,6 +94,25 @@
android:id="@+id/tags_text" android:id="@+id/tags_text"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> 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> </LinearLayout>
</androidx.appcompat.widget.Toolbar> </androidx.appcompat.widget.Toolbar>
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>

View file

@ -40,8 +40,6 @@
android:id="@+id/radio_group" android:id="@+id/radio_group"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:paddingStart="8dp" android:paddingStart="8dp"
android:paddingEnd="8dp" /> android:paddingEnd="8dp" />
</LinearLayout> </LinearLayout>

View file

@ -4,18 +4,34 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="35dp" android:layout_height="35dp"
android:clickable="true"> android:clickable="true"
android:id="@+id/layout_root">
<FrameLayout
android:id="@+id/layout_device"
android:layout_width="25dp"
android:layout_height="25dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<ImageView <ImageView
android:id="@+id/image_device" android:id="@+id/image_device"
android:layout_width="25dp" android:layout_width="match_parent"
android:layout_height="25dp" android:layout_height="match_parent"
android:contentDescription="@string/cd_image_device" android:contentDescription="@string/cd_image_device"
app:srcCompat="@drawable/ic_chromecast" app:srcCompat="@drawable/ic_chromecast"
android:scaleType="fitCenter" android:scaleType="fitCenter" />
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent" <ImageView
app:layout_constraintBottom_toBottomOf="parent"/> android:id="@+id/image_online"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_gravity="end|top"
android:contentDescription="@string/cd_image_device"
app:srcCompat="@drawable/ic_online"
android:scaleType="fitCenter" />
</FrameLayout>
<TextView <TextView
android:id="@+id/text_name" android:id="@+id/text_name"
@ -31,8 +47,8 @@
android:layout_marginStart="10dp" android:layout_marginStart="10dp"
android:layout_marginEnd="10dp" android:layout_marginEnd="10dp"
android:includeFontPadding="false" android:includeFontPadding="false"
app:layout_constraintTop_toTopOf="@id/image_device" app:layout_constraintTop_toTopOf="@id/layout_device"
app:layout_constraintLeft_toRightOf="@id/image_device" app:layout_constraintLeft_toRightOf="@id/layout_device"
app:layout_constraintRight_toLeftOf="@id/layout_button" /> app:layout_constraintRight_toLeftOf="@id/layout_button" />
<TextView <TextView
@ -43,12 +59,12 @@
tools:text="Chromecast" tools:text="Chromecast"
android:textSize="10dp" android:textSize="10dp"
android:fontFamily="@font/inter_extra_light" android:fontFamily="@font/inter_extra_light"
android:textColor="@color/white" android:textColor="@color/gray_ac"
android:includeFontPadding="false" android:includeFontPadding="false"
android:layout_marginStart="10dp" android:layout_marginStart="10dp"
android:layout_marginEnd="10dp" android:layout_marginEnd="10dp"
app:layout_constraintTop_toBottomOf="@id/text_name" app:layout_constraintTop_toBottomOf="@id/text_name"
app:layout_constraintLeft_toRightOf="@id/image_device" app:layout_constraintLeft_toRightOf="@id/layout_device"
app:layout_constraintRight_toLeftOf="@id/layout_button" /> app:layout_constraintRight_toLeftOf="@id/layout_button" />
<LinearLayout <LinearLayout
@ -68,74 +84,15 @@
app:srcCompat="@drawable/ic_loader_animated" app:srcCompat="@drawable/ic_loader_animated"
android:layout_marginEnd="8dp"/> android:layout_marginEnd="8dp"/>
<LinearLayout <ImageView
android:id="@+id/image_pin"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="25dp"
android:orientation="horizontal" android:contentDescription="@string/cd_image_loader"
app:layout_constraintRight_toRightOf="parent" app:srcCompat="@drawable/ic_pin"
app:layout_constraintTop_toTopOf="parent" android:layout_marginEnd="8dp"
app:layout_constraintBottom_toBottomOf="parent"> android:scaleType="fitEnd"
android:paddingStart="10dp" />
<LinearLayout
android:id="@+id/button_remove"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_button_accent"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:layout_marginEnd="7dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_light"
android:text="@string/remove" />
</LinearLayout>
<LinearLayout
android:id="@+id/button_disconnect"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_button_accent"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_light"
android:text="@string/stop" />
</LinearLayout>
<LinearLayout
android:id="@+id/button_connect"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_button_primary"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_light"
android:text="@string/start" />
</LinearLayout>
</LinearLayout>
<TextView <TextView
android:id="@+id/text_not_ready" android:id="@+id/text_not_ready"

View file

@ -117,6 +117,15 @@
app:radiusBottomRight="4dp" app:radiusBottomRight="4dp"
app:radiusTopLeft="0dp" app:radiusTopLeft="0dp"
app:radiusTopRight="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> </FrameLayout>
<TextView <TextView

View file

@ -41,6 +41,19 @@
app:srcCompat="@drawable/placeholder_video_thumbnail" app:srcCompat="@drawable/placeholder_video_thumbnail"
android:background="@drawable/video_thumbnail_outline" /> 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 <LinearLayout
android:id="@+id/thumbnail_live_container" android:id="@+id/thumbnail_live_container"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -49,7 +62,7 @@
android:paddingStart="2dp" android:paddingStart="2dp"
android:paddingEnd="2dp" android:paddingEnd="2dp"
android:layout_marginEnd="4dp" android:layout_marginEnd="4dp"
android:layout_marginBottom="4dp" android:layout_marginBottom="6dp"
android:paddingTop="0dp" android:paddingTop="0dp"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
@ -77,7 +90,7 @@
android:paddingStart="2dp" android:paddingStart="2dp"
android:paddingEnd="2dp" android:paddingEnd="2dp"
android:layout_marginEnd="4dp" android:layout_marginEnd="4dp"
android:layout_marginBottom="4dp" android:layout_marginBottom="6dp"
android:paddingTop="0dp" android:paddingTop="0dp"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
@ -103,7 +116,7 @@
android:layout_height="20dp" android:layout_height="20dp"
android:layout_gravity="bottom|start" android:layout_gravity="bottom|start"
android:layout_marginStart="4dp" android:layout_marginStart="4dp"
android:layout_marginBottom="4dp" /> android:layout_marginBottom="6dp" />
<FrameLayout android:id="@+id/layout_downloaded" <FrameLayout android:id="@+id/layout_downloaded"
android:layout_width="16dp" android:layout_width="16dp"

View file

@ -57,15 +57,15 @@
<ImageView <ImageView
android:id="@+id/image_clear" android:id="@+id/image_clear"
android:layout_width="16dp" android:layout_width="36dp"
android:layout_height="16dp" android:layout_height="36dp"
app:srcCompat="@drawable/ic_clear_16dp" app:srcCompat="@drawable/ic_clear_16dp"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toRightOf="parent" app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginEnd="6dp" android:layout_marginEnd="6dp"
android:layout_marginStart="6dp" android:layout_marginStart="6dp"
android:padding="2dp" /> android:padding="12dp" />
<TextView <TextView
android:id="@+id/text_name" android:id="@+id/text_name"

View file

@ -688,6 +688,14 @@
<item>Continuer la lecture</item> <item>Continuer la lecture</item>
<item>Superposition du lecteur</item> <item>Superposition du lecteur</item>
</string-array> </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"> <string-array name="resume_after_preview">
<item>Reprendre depuis le début</item> <item>Reprendre depuis le début</item>
<item>Reprendre après 10s</item> <item>Reprendre après 10s</item>

View file

@ -135,6 +135,7 @@
<string name="not_ready">Not ready</string> <string name="not_ready">Not ready</string>
<string name="connect">Connect</string> <string name="connect">Connect</string>
<string name="stop">Stop</string> <string name="stop">Stop</string>
<string name="stop_casting">Stop casting</string>
<string name="start">Start</string> <string name="start">Start</string>
<string name="storage_space">Storage Space</string> <string name="storage_space">Storage Space</string>
<string name="downloads">Downloads</string> <string name="downloads">Downloads</string>
@ -194,7 +195,9 @@
<string name="ip">IP</string> <string name="ip">IP</string>
<string name="port">Port</string> <string name="port">Port</string>
<string name="discovered_devices">Discovered Devices</string> <string name="discovered_devices">Discovered Devices</string>
<string name="available_devices">Available devices</string>
<string name="remembered_devices">Remembered Devices</string> <string name="remembered_devices">Remembered Devices</string>
<string name="unable_to_see_the_Device_youre_looking_for_try_add_the_device_manually">Unable to see the device you\'re looking for? Try to add the device manually.</string>
<string name="there_are_no_remembered_devices">There are no remembered devices</string> <string name="there_are_no_remembered_devices">There are no remembered devices</string>
<string name="connected_to">Connected to</string> <string name="connected_to">Connected to</string>
<string name="volume">Volume</string> <string name="volume">Volume</string>
@ -204,6 +207,7 @@
<string name="previous">Previous</string> <string name="previous">Previous</string>
<string name="next">Next</string> <string name="next">Next</string>
<string name="comment">Comment</string> <string name="comment">Comment</string>
<string name="add_manually">Add manually</string>
<string name="not_empty_close">Comment is not empty, close anyway?</string> <string name="not_empty_close">Comment is not empty, close anyway?</string>
<string name="str_import">Import</string> <string name="str_import">Import</string>
<string name="my_playlist_name">My Playlist Name</string> <string name="my_playlist_name">My Playlist Name</string>
@ -335,7 +339,7 @@
<string name="configure_if_background_download_should_be_used">Configure if background download should be used</string> <string name="configure_if_background_download_should_be_used">Configure if background download should be used</string>
<string name="configure_the_auto_updater">Configure the auto updater</string> <string name="configure_the_auto_updater">Configure the auto updater</string>
<string name="configure_when_updates_should_be_downloaded">Configure when updates should be downloaded</string> <string name="configure_when_updates_should_be_downloaded">Configure when updates should be downloaded</string>
<string name="configure_when_videos_should_be_downloaded">Configure when videos should be downloaded</string> <string name="configure_when_videos_should_be_downloaded">Configure when videos should be downloaded, if they should only be downloaded on unmetered networks (wifi/ethernet)</string>
<string name="creates_a_zip_file_with_your_data_which_can_be_imported_by_opening_it_with_grayjay">Creates a zip file with your data which can be imported by opening it with Grayjay</string> <string name="creates_a_zip_file_with_your_data_which_can_be_imported_by_opening_it_with_grayjay">Creates a zip file with your data which can be imported by opening it with Grayjay</string>
<string name="default_audio_quality">Default Audio Quality</string> <string name="default_audio_quality">Default Audio Quality</string>
<string name="default_playback_speed">Default Playback Speed</string> <string name="default_playback_speed">Default Playback Speed</string>
@ -366,18 +370,22 @@
<string name="networking">Networking</string> <string name="networking">Networking</string>
<string name="synchronization">Synchronization</string> <string name="synchronization">Synchronization</string>
<string name="enabled_description">Enable feature</string> <string name="enabled_description">Enable feature</string>
<string name="broadcast">Broadcast</string> <string name="broadcast">mDNS Broadcast</string>
<string name="broadcast_description">Allow device to broadcast presence</string> <string name="broadcast_description">Allow device to broadcast presence using mDNS</string>
<string name="connect_discovered">Connect discovered</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</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">Try connect last</string> <string name="connect_last">Connect Last Known</string>
<string name="connect_last_description">Allow device to automatically connect to last known</string> <string name="connect_last_description">Allow device to automatically connect to last known endpoints</string>
<string name="discover_through_relay">Discover through relay</string> <string name="discover_through_relay">Relay Enable</string>
<string name="discover_through_relay_description">Allow paired devices to be discovered and connected to through the relay</string> <string name="discover_through_relay_description">Allow device to use a relay for discovery/relaying connection</string>
<string name="pair_through_relay">Pair through relay</string> <string name="pair_through_relay">Relay Pairing</string>
<string name="pair_through_relay_description">Allow devices to be paired through the relay</string> <string name="pair_through_relay_description">Allow device to be paired through the relay</string>
<string name="connect_through_relay">Connection through relay</string> <string name="connect_through_relay">Relay Connect Relayed</string>
<string name="connect_through_relay_description">Allow devices to be connected to through the relay</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="gesture_controls">Gesture controls</string>
<string name="volume_slider">Volume slider</string> <string name="volume_slider">Volume slider</string>
<string name="volume_slider_descr">Enable slide gesture to change volume</string> <string name="volume_slider_descr">Enable slide gesture to change volume</string>
@ -416,6 +424,8 @@
<string name="allow_full_screen_portrait">Allow full-screen portrait when watching horizontal videos</string> <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">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="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">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="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> <string name="subscription_group_menu">Groups</string>
@ -1068,6 +1078,14 @@
<item>Within 30 seconds of loss</item> <item>Within 30 seconds of loss</item>
<item>Always</item> <item>Always</item>
</string-array> </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"> <string-array name="rotation_zone">
<item>15</item> <item>15</item>
<item>30</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

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