Merge branch 'master' into shorts-tab

# Conflicts:
#	app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt
#	app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt
This commit is contained in:
Kai 2025-06-04 13:03:40 -05:00
commit 0653f88c49
No known key found for this signature in database
196 changed files with 8046 additions and 4016 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

12
.gitmodules vendored
View file

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

BIN
app/aar/ffmpeg-kit-full-6.0-2.LTS.aar (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -198,7 +198,8 @@ 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 'com.arthenica:smart-exception-java:0.2.1'
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

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

View file

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

View file

@ -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|uiMode"
android:exported="true" android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar" android:theme="@style/Theme.FutoVideo.NoActionBar"
android:launchMode="singleInstance" android:launchMode="singleInstance"
@ -239,4 +240,4 @@
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBar" />
</application> </application>
</manifest> </manifest>

View file

@ -32,7 +32,8 @@ let Type = {
Text: { Text: {
RAW: 0, RAW: 0,
HTML: 1, HTML: 1,
MARKUP: 2 MARKUP: 2,
CODE: 3
}, },
Chapter: { Chapter: {
NORMAL: 0, NORMAL: 0,
@ -291,15 +292,39 @@ class PlatformPostDetails extends PlatformPost {
} }
} }
class PlatformArticleDetails extends PlatformContent { class PlatformWeb extends PlatformContent {
constructor(obj) {
super(obj, 7);
obj = obj ?? {};
this.plugin_type = "PlatformWeb";
}
}
class PlatformWebDetails extends PlatformWeb {
constructor(obj) {
super(obj, 7);
obj = obj ?? {};
this.plugin_type = "PlatformWebDetails";
this.html = obj.html;
}
}
class PlatformArticle extends PlatformContent {
constructor(obj) {
super(obj, 3);
obj = obj ?? {};
this.plugin_type = "PlatformArticle";
this.rating = obj.rating ?? new RatingLikes(-1);
this.summary = obj.summary ?? "";
this.thumbnails = obj.thumbnails ?? new Thumbnails([]);
}
}
class PlatformArticleDetails extends PlatformArticle {
constructor(obj) { constructor(obj) {
super(obj, 3); super(obj, 3);
obj = obj ?? {}; obj = obj ?? {};
this.plugin_type = "PlatformArticleDetails"; this.plugin_type = "PlatformArticleDetails";
this.rating = obj.rating ?? new RatingLikes(-1); this.rating = obj.rating ?? new RatingLikes(-1);
this.summary = obj.summary ?? "";
this.segments = obj.segments ?? []; this.segments = obj.segments ?? [];
this.thumbnails = obj.thumbnails ?? new Thumbnails([]);
} }
} }
class ArticleSegment { class ArticleSegment {
@ -315,9 +340,17 @@ class ArticleTextSegment extends ArticleSegment {
} }
} }
class ArticleImagesSegment extends ArticleSegment { class ArticleImagesSegment extends ArticleSegment {
constructor(images) { constructor(images, caption) {
super(2); super(2);
this.images = images; this.images = images;
this.caption = caption;
}
}
class ArticleHeaderSegment extends ArticleSegment {
constructor(content, level) {
super(3);
this.level = level;
this.content = content;
} }
} }
class ArticleNestedSegment extends ArticleSegment { class ArticleNestedSegment extends ArticleSegment {
@ -595,6 +628,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;
@ -936,6 +952,21 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.connect_last, FieldForm.TOGGLE, R.string.connect_last_description, 3) @FormField(R.string.connect_last, FieldForm.TOGGLE, R.string.connect_last_description, 3)
var connectLast: Boolean = true; var connectLast: Boolean = true;
@FormField(R.string.discover_through_relay, FieldForm.TOGGLE, R.string.discover_through_relay_description, 3)
var discoverThroughRelay: Boolean = true;
@FormField(R.string.pair_through_relay, FieldForm.TOGGLE, R.string.pair_through_relay_description, 3)
var pairThroughRelay: Boolean = true;
@FormField(R.string.connect_through_relay, FieldForm.TOGGLE, R.string.connect_through_relay_description, 3)
var connectThroughRelay: Boolean = true;
@FormField(R.string.connect_local_direct_through_relay, FieldForm.TOGGLE, R.string.connect_local_direct_through_relay_description, 3)
var connectLocalDirectThroughRelay: Boolean = true;
@FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3)
var localConnections: Boolean = true;
} }
@FormField(R.string.info, FieldForm.GROUP, -1, 21) @FormField(R.string.info, FieldForm.GROUP, -1, 21)
@ -1004,4 +1035,4 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
//endregion //endregion
} }

View file

@ -319,7 +319,11 @@ class UIDialogs {
closeAction?.invoke() closeAction?.invoke()
}, UIDialogs.ActionStyle.NONE), }, UIDialogs.ActionStyle.NONE),
UIDialogs.Action(context.getString(R.string.retry), { UIDialogs.Action(context.getString(R.string.retry), {
retryAction?.invoke(); try {
retryAction?.invoke();
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception retrying", e)
}
}, UIDialogs.ActionStyle.PRIMARY) }, UIDialogs.ActionStyle.PRIMARY)
); );
else else
@ -333,7 +337,11 @@ class UIDialogs {
closeAction?.invoke() closeAction?.invoke()
}, UIDialogs.ActionStyle.NONE), }, UIDialogs.ActionStyle.NONE),
UIDialogs.Action(context.getString(R.string.retry), { UIDialogs.Action(context.getString(R.string.retry), {
retryAction?.invoke(); try {
retryAction?.invoke();
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception retrying", e)
}
}, UIDialogs.ActionStyle.PRIMARY) }, UIDialogs.ActionStyle.PRIMARY)
); );
} }

View file

@ -4,8 +4,14 @@ import android.app.NotificationManager
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity import com.futo.platformplayer.activities.SettingsActivity
@ -37,6 +43,9 @@ import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.parsers.HLS.MediaRendition
import com.futo.platformplayer.parsers.HLS.StreamInfo
import com.futo.platformplayer.parsers.HLS.VariantPlaylistReference
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StateHistory
@ -63,6 +72,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
import androidx.core.net.toUri
class UISlideOverlays { class UISlideOverlays {
companion object { companion object {
@ -299,6 +310,7 @@ class UISlideOverlays {
} }
@OptIn(UnstableApi::class)
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay { fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
val items = arrayListOf<View>(LoaderView(container.context)) val items = arrayListOf<View>(LoaderView(container.context))
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items) val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
@ -310,6 +322,8 @@ class UISlideOverlays {
val masterPlaylistContent = masterPlaylistResponse.body?.string() val masterPlaylistContent = masterPlaylistResponse.body?.string()
?: throw Exception("Master playlist content is empty") ?: throw Exception("Master playlist content is empty")
val resolvedPlaylistUrl = masterPlaylistResponse.url
val videoButtons = arrayListOf<SlideUpMenuItem>() val videoButtons = arrayListOf<SlideUpMenuItem>()
val audioButtons = arrayListOf<SlideUpMenuItem>() val audioButtons = arrayListOf<SlideUpMenuItem>()
//TODO: Implement subtitles //TODO: Implement subtitles
@ -322,55 +336,103 @@ class UISlideOverlays {
val masterPlaylist: HLS.MasterPlaylist val masterPlaylist: HLS.MasterPlaylist
try { try {
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray())
val playlist = DefaultHlsPlaylistParserFactory().createPlaylistParser()
.parse(sourceUrl.toUri(), inputStream)
masterPlaylist.getAudioSources().forEach { it -> if (playlist is HlsMediaPlaylist) {
if (source is IHLSManifestAudioSource) {
val variant = HLS.mediaRenditionToVariant(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null))!!
val estSize = VideoHelper.estimateSourceSize(it); val estSize = VideoHelper.estimateSourceSize(variant);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
audioButtons.add(SlideUpMenuItem( audioButtons.add(SlideUpMenuItem(
container.context, container.context,
R.drawable.ic_music, R.drawable.ic_music,
it.name, variant.name,
listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), listOf(variant.language, variant.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
(prefix + it.codec).trim(), (prefix + variant.codec).trim(),
tag = it, tag = variant,
call = { call = {
selectedAudioVariant = it selectedAudioVariant = variant
slideUpMenuOverlay.selectOption(audioButtons, it) slideUpMenuOverlay.selectOption(audioButtons, variant)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
},
invokeParent = false
))
}
/*masterPlaylist.getSubtitleSources().forEach { it ->
subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
selectedSubtitleVariant = it
slideUpMenuOverlay.selectOption(subtitleButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}, false))
}*/
masterPlaylist.getVideoSources().forEach {
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
videoButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.name,
"${it.width}x${it.height}",
(prefix + it.codec).trim(),
tag = it,
call = {
selectedVideoVariant = it
slideUpMenuOverlay.selectOption(videoButtons, it)
if (audioButtons.isEmpty()){
slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
} },
}, invokeParent = false
invokeParent = false ))
)) } else {
val variant = HLS.variantReferenceToVariant(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null)))
val estSize = VideoHelper.estimateSourceSize(variant);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
videoButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
variant.name,
"${variant.width}x${variant.height}",
(prefix + variant.codec).trim(),
tag = variant,
call = {
selectedVideoVariant = variant
slideUpMenuOverlay.selectOption(videoButtons, variant)
if (audioButtons.isEmpty()){
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}
},
invokeParent = false
))
}
} else if (playlist is HlsMultivariantPlaylist) {
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, resolvedPlaylistUrl)
masterPlaylist.getAudioSources().forEach { it ->
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
audioButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_music,
it.name,
listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
(prefix + it.codec).trim(),
tag = it,
call = {
selectedAudioVariant = it
slideUpMenuOverlay.selectOption(audioButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
},
invokeParent = false
))
}
/*masterPlaylist.getSubtitleSources().forEach { it ->
subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
selectedSubtitleVariant = it
slideUpMenuOverlay.selectOption(subtitleButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}, false))
}*/
masterPlaylist.getVideoSources().forEach {
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
videoButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.name,
"${it.width}x${it.height}",
(prefix + it.codec).trim(),
tag = it,
call = {
selectedVideoVariant = it
slideUpMenuOverlay.selectOption(videoButtons, it)
if (audioButtons.isEmpty()){
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}
},
invokeParent = false
))
}
} }
val newItems = arrayListOf<View>() val newItems = arrayListOf<View>()
@ -398,11 +460,11 @@ class UISlideOverlays {
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (source is IHLSManifestSource) { if (source is IHLSManifestSource) {
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, sourceUrl), null, null) StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null)
UIDialogs.toast(container.context, "Variant video HLS playlist download started") UIDialogs.toast(container.context, "Variant video HLS playlist download started")
slideUpMenuOverlay.hide() slideUpMenuOverlay.hide()
} else if (source is IHLSManifestAudioSource) { } else if (source is IHLSManifestAudioSource) {
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, sourceUrl), null) StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null)
UIDialogs.toast(container.context, "Variant audio HLS playlist download started") UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
slideUpMenuOverlay.hide() slideUpMenuOverlay.hide()
} else { } else {
@ -684,6 +746,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() };
@ -980,26 +1046,30 @@ class UISlideOverlays {
+ actions).filterNotNull() + actions).filterNotNull()
)); ));
items.add( items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto", SlideUpMenuGroup(
SlideUpMenuItem(container.context, container.context, container.context.getString(R.string.add_to), "addto",
SlideUpMenuItem(
container.context,
R.drawable.ic_queue_add, R.drawable.ic_queue_add,
container.context.getString(R.string.add_to_queue), container.context.getString(R.string.add_to_queue),
"${queue.size} " + container.context.getString(R.string.videos), "${queue.size} " + container.context.getString(R.string.videos),
tag = "queue", tag = "queue",
call = { StatePlayer.instance.addToQueue(video); }), call = { StatePlayer.instance.addToQueue(video); }),
SlideUpMenuItem(container.context, SlideUpMenuItem(
container.context,
R.drawable.ic_watchlist_add, R.drawable.ic_watchlist_add,
"${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "", "${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "",
"${watchLater.size} " + container.context.getString(R.string.videos), "${watchLater.size} " + container.context.getString(R.string.videos),
tag = "watch later", tag = "watch later",
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }), call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }),
SlideUpMenuItem(container.context, SlideUpMenuItem(
container.context,
R.drawable.ic_history, R.drawable.ic_history,
container.context.getString(R.string.add_to_history), container.context.getString(R.string.add_to_history),
"Mark as watched", "Mark as watched",
tag = "history", tag = "history",
call = { StateHistory.instance.markAsWatched(video); }), call = { StateHistory.instance.markAsWatched(video); }),
)); ));
val playlistItems = arrayListOf<SlideUpMenuItem>(); val playlistItems = arrayListOf<SlideUpMenuItem>();
playlistItems.add(SlideUpMenuItem( playlistItems.add(SlideUpMenuItem(
@ -1063,14 +1133,17 @@ class UISlideOverlays {
val queue = StatePlayer.instance.getQueue(); val queue = StatePlayer.instance.getQueue();
val watchLater = StatePlaylists.instance.getWatchLater(); val watchLater = StatePlaylists.instance.getWatchLater();
items.add( items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other", SlideUpMenuGroup(
SlideUpMenuItem(container.context, container.context, container.context.getString(R.string.other), "other",
SlideUpMenuItem(
container.context,
R.drawable.ic_queue_add, R.drawable.ic_queue_add,
container.context.getString(R.string.queue), container.context.getString(R.string.queue),
"${queue.size} " + container.context.getString(R.string.videos), "${queue.size} " + container.context.getString(R.string.videos),
tag = "queue", tag = "queue",
call = { StatePlayer.instance.addToQueue(video); }), call = { StatePlayer.instance.addToQueue(video); }),
SlideUpMenuItem(container.context, SlideUpMenuItem(
container.context,
R.drawable.ic_watchlist_add, R.drawable.ic_watchlist_add,
StatePlayer.TYPE_WATCHLATER, StatePlayer.TYPE_WATCHLATER,
"${watchLater.size} " + container.context.getString(R.string.videos), "${watchLater.size} " + container.context.getString(R.string.videos),
@ -1079,7 +1152,7 @@ class UISlideOverlays {
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true)) if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true))
UIDialogs.appToast("Added to watch later", false); UIDialogs.appToast("Added to watch later", false);
}), }),
) )
); );
val playlistItems = arrayListOf<SlideUpMenuItem>(); val playlistItems = arrayListOf<SlideUpMenuItem>();
@ -1117,8 +1190,8 @@ class UISlideOverlays {
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() }; return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() };
} }
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>, isChannelSearch: Boolean = false): SlideUpMenuFilters { fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>): SlideUpMenuFilters {
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues, isChannelSearch); val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues);
overlay.show(); overlay.show();
return overlay; return overlay;
} }

View file

@ -28,12 +28,17 @@ import com.futo.platformplayer.models.PlatformVideoWithTime
import com.futo.platformplayer.others.PlatformLinkMovementMethod import com.futo.platformplayer.others.PlatformLinkMovementMethod
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
import java.net.InterfaceAddress
import java.net.NetworkInterface
import java.net.SocketException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.security.SecureRandom
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.* import java.util.*
import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.ThreadLocalRandom
@ -70,7 +75,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")
} }
@ -273,7 +285,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;
} }
@ -284,6 +296,18 @@ fun ByteBuffer.toUtf8String(): String {
return String(remainingBytes, Charsets.UTF_8) return String(remainingBytes, Charsets.UTF_8)
} }
fun generateReadablePassword(length: Int): String {
val validChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"
val secureRandom = SecureRandom()
val randomBytes = ByteArray(length)
secureRandom.nextBytes(randomBytes)
val sb = StringBuilder(length)
for (byte in randomBytes) {
val index = (byte.toInt() and 0xFF) % validChars.length
sb.append(validChars[index])
}
return sb.toString()
}
fun ByteArray.toGzip(): ByteArray { fun ByteArray.toGzip(): ByteArray {
if (this == null || this.isEmpty()) return ByteArray(0) if (this == null || this.isEmpty()) return ByteArray(0)
@ -313,4 +337,98 @@ fun ByteArray.fromGzip(): ByteArray {
} }
} }
return outputStream.toByteArray() return outputStream.toByteArray()
} }
fun findPreferredAddress(): InetAddress? {
val candidates = NetworkInterface.getNetworkInterfaces()
.toList()
.asSequence()
.filter(::isUsableInterface)
.flatMap { nif ->
nif.interfaceAddresses
.asSequence()
.mapNotNull { ia ->
ia.address.takeIf(::isUsableAddress)?.let { addr ->
nif to ia
}
}
}
.toList()
return candidates
.minWithOrNull(
compareBy<Pair<NetworkInterface, InterfaceAddress>>(
{ addressScore(it.second.address) },
{ interfaceScore(it.first) },
{ -it.second.networkPrefixLength.toInt() },
{ -it.first.mtu }
)
)?.second?.address
}
private fun isUsableInterface(nif: NetworkInterface): Boolean {
val name = nif.name.lowercase()
return try {
// must be up, not loopback/virtual/PtP, have a MAC, not Docker/tun/etc.
nif.isUp
&& !nif.isLoopback
&& !nif.isPointToPoint
&& !nif.isVirtual
&& !name.startsWith("docker")
&& !name.startsWith("veth")
&& !name.startsWith("br-")
&& !name.startsWith("virbr")
&& !name.startsWith("vmnet")
&& !name.startsWith("tun")
&& !name.startsWith("tap")
} catch (e: SocketException) {
false
}
}
private fun isUsableAddress(addr: InetAddress): Boolean {
return when {
addr.isAnyLocalAddress -> false // 0.0.0.0 / ::
addr.isLoopbackAddress -> false
addr.isLinkLocalAddress -> false // 169.254.x.x or fe80::/10
addr.isMulticastAddress -> false
else -> true
}
}
private fun interfaceScore(nif: NetworkInterface): Int {
val name = nif.name.lowercase()
return when {
name.matches(Regex("^(eth|enp|eno|ens|em)\\d+")) -> 0
name.startsWith("eth") || name.contains("ethernet") -> 0
name.matches(Regex("^(wlan|wlp)\\d+")) -> 1
name.contains("wi-fi") || name.contains("wifi") -> 1
else -> 2
}
}
private fun addressScore(addr: InetAddress): Int {
return when (addr) {
is Inet4Address -> {
val octets = addr.address.map { it.toInt() and 0xFF }
when {
octets[0] == 10 -> 0 // 10/8
octets[0] == 192 && octets[1] == 168 -> 0 // 192.168/16
octets[0] == 172 && octets[1] in 16..31 -> 0 // 172.1631/12
else -> 1 // public IPv4
}
}
is Inet6Address -> {
// ULA (fc00::/7) vs global vs others
val b0 = addr.address[0].toInt() and 0xFF
when {
(b0 and 0xFE) == 0xFC -> 2 // ULA
(b0 and 0xE0) == 0x20 -> 3 // global
else -> 4
}
}
else -> Int.MAX_VALUE
}
}
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)

View file

@ -1,12 +1,15 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.annotation.SuppressLint import android.annotation.SuppressLint
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.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
@ -19,6 +22,7 @@ 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.core.app.ActivityCompat import androidx.core.app.ActivityCompat
@ -28,6 +32,8 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.whenStateAtLeast
import androidx.lifecycle.withStateAtLeast
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R import com.futo.platformplayer.R
@ -36,7 +42,9 @@ 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.ArticleDetailFragment
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
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
@ -64,7 +72,9 @@ 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.main.WebDetailFragment
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
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
@ -144,6 +154,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//Frags Main //Frags Main
lateinit var _fragMainHome: HomeFragment; lateinit var _fragMainHome: HomeFragment;
lateinit var _fragPostDetail: PostDetailFragment; lateinit var _fragPostDetail: PostDetailFragment;
lateinit var _fragArticleDetail: ArticleDetailFragment;
lateinit var _fragWebDetail: WebDetailFragment;
lateinit var _fragMainVideoSearchResults: ContentSearchResultsFragment; lateinit var _fragMainVideoSearchResults: ContentSearchResultsFragment;
lateinit var _fragMainCreatorSearchResults: CreatorSearchResultsFragment; lateinit var _fragMainCreatorSearchResults: CreatorSearchResultsFragment;
lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment; lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment;
@ -184,6 +196,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)
@ -195,7 +210,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
try { try {
runBlocking { lifecycleScope.launch {
handleUrlAll(content) handleUrlAll(content)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@ -261,6 +276,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)
@ -268,7 +287,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
runBlocking { runBlocking {
StatePlatform.instance.updateAvailableClients(this@MainActivity); try {
StatePlatform.instance.updateAvailableClients(this@MainActivity);
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in updateAvailableClients", e)
}
} }
//Preload common files to memory //Preload common files to memory
@ -312,6 +335,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragMainPlaylist = PlaylistFragment.newInstance(); _fragMainPlaylist = PlaylistFragment.newInstance();
_fragMainRemotePlaylist = RemotePlaylistFragment.newInstance(); _fragMainRemotePlaylist = RemotePlaylistFragment.newInstance();
_fragPostDetail = PostDetailFragment.newInstance(); _fragPostDetail = PostDetailFragment.newInstance();
_fragArticleDetail = ArticleDetailFragment.newInstance();
_fragWebDetail = WebDetailFragment.newInstance();
_fragWatchlist = WatchLaterFragment.newInstance(); _fragWatchlist = WatchLaterFragment.newInstance();
_fragHistory = HistoryFragment.newInstance(); _fragHistory = HistoryFragment.newInstance();
_fragShorts = ShortsFragment.newInstance(); _fragShorts = ShortsFragment.newInstance();
@ -358,22 +383,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;
@ -390,19 +411,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) { _fragVideoDetail.onMaximized.subscribe {
_buttonIncognito.elevation = 99f; updatePrivateModeVisibility()
_buttonIncognito.alpha = 1f;
} else {
_buttonIncognito.elevation = -99f;
_buttonIncognito.alpha = 0f;
}
}
} }
StatePlayer.instance.also { StatePlayer.instance.also {
@ -450,6 +468,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragMainPlaylist.topBar = _fragTopBarNavigation; _fragMainPlaylist.topBar = _fragTopBarNavigation;
_fragMainRemotePlaylist.topBar = _fragTopBarNavigation; _fragMainRemotePlaylist.topBar = _fragTopBarNavigation;
_fragPostDetail.topBar = _fragTopBarNavigation; _fragPostDetail.topBar = _fragTopBarNavigation;
_fragArticleDetail.topBar = _fragTopBarNavigation;
_fragWebDetail.topBar = _fragTopBarNavigation;
_fragWatchlist.topBar = _fragTopBarNavigation; _fragWatchlist.topBar = _fragTopBarNavigation;
_fragHistory.topBar = _fragTopBarNavigation; _fragHistory.topBar = _fragTopBarNavigation;
_fragSourceDetail.topBar = _fragTopBarNavigation; _fragSourceDetail.topBar = _fragTopBarNavigation;
@ -617,8 +637,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))
@ -634,6 +664,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")
@ -644,6 +686,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() {
@ -682,7 +727,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
"VIDEO" -> { "VIDEO" -> {
val url = intent.getStringExtra("VIDEO"); val url = intent.getStringExtra("VIDEO");
navigate(_fragVideoDetail, url); navigateWhenReady(_fragVideoDetail, url);
} }
"IMPORT_OPTIONS" -> { "IMPORT_OPTIONS" -> {
@ -700,11 +745,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
"Sources" -> { "Sources" -> {
runBlocking { runBlocking {
StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed.. StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed..
navigate(_fragMainSources); navigateWhenReady(_fragMainSources);
} }
}; };
"BROWSE_PLUGINS" -> { "BROWSE_PLUGINS" -> {
navigate(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf( navigateWhenReady(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
Pair("grayjay") { req -> Pair("grayjay") { req ->
StateApp.instance.contextOrNull?.let { StateApp.instance.contextOrNull?.let {
if (it is MainActivity) { if (it is MainActivity) {
@ -722,8 +767,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
try { try {
if (targetData != null) { if (targetData != null) {
runBlocking { lifecycleScope.launch(Dispatchers.Main) {
handleUrlAll(targetData) try {
handleUrlAll(targetData)
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
}
} }
} }
} catch (ex: Throwable) { } catch (ex: Throwable) {
@ -751,10 +800,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
startActivity(intent); startActivity(intent);
} else if (url.startsWith("grayjay://video/")) { } else if (url.startsWith("grayjay://video/")) {
val videoUrl = url.substring("grayjay://video/".length); val videoUrl = url.substring("grayjay://video/".length);
navigate(_fragVideoDetail, videoUrl); navigateWhenReady(_fragVideoDetail, videoUrl);
} else if (url.startsWith("grayjay://channel/")) { } else if (url.startsWith("grayjay://channel/")) {
val channelUrl = url.substring("grayjay://channel/".length); val channelUrl = url.substring("grayjay://channel/".length);
navigate(_fragMainChannel, channelUrl); navigateWhenReady(_fragMainChannel, channelUrl);
} }
} }
@ -820,29 +869,29 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
Logger.i(TAG, "handleUrl(url=$url) on IO"); Logger.i(TAG, "handleUrl(url=$url) on IO");
if (StatePlatform.instance.hasEnabledVideoClient(url)) { if (StatePlatform.instance.hasEnabledContentClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found video client"); Logger.i(TAG, "handleUrl(url=$url) found video client");
lifecycleScope.launch(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (position > 0) if (position > 0)
navigate(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true)); navigateWhenReady(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
else else
navigate(_fragVideoDetail, url); navigateWhenReady(_fragVideoDetail, url);
_fragVideoDetail.maximizeVideoDetail(true); _fragVideoDetail.maximizeVideoDetail(true);
} }
return@withContext true; return@withContext true;
} else if (StatePlatform.instance.hasEnabledChannelClient(url)) { } else if (StatePlatform.instance.hasEnabledChannelClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found channel client"); Logger.i(TAG, "handleUrl(url=$url) found channel client");
lifecycleScope.launch(Dispatchers.Main) { withContext(Dispatchers.Main) {
navigate(_fragMainChannel, url); navigateWhenReady(_fragMainChannel, url);
delay(100); delay(100);
_fragVideoDetail.minimizeVideoDetail(); _fragVideoDetail.minimizeVideoDetail();
}; };
return@withContext true; return@withContext true;
} else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) { } else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found playlist client"); Logger.i(TAG, "handleUrl(url=$url) found playlist client");
lifecycleScope.launch(Dispatchers.Main) { withContext(Dispatchers.Main) {
navigate(_fragMainRemotePlaylist, url); navigateWhenReady(_fragMainRemotePlaylist, url);
delay(100); delay(100);
_fragVideoDetail.minimizeVideoDetail(); _fragVideoDetail.minimizeVideoDetail();
}; };
@ -1054,6 +1103,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() {
@ -1066,6 +1118,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return fragCurrent is T; return fragCurrent is T;
} }
fun navigateWhenReady(segment: MainFragment, parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
navigate(segment, parameter, withHistory, isBack)
} else {
lifecycleScope.launch {
lifecycle.withStateAtLeast(Lifecycle.State.RESUMED) {
navigate(segment, parameter, withHistory, isBack)
}
}
}
}
/** /**
* Navigate takes a MainFragment, and makes them the current main visible view * Navigate takes a MainFragment, and makes them the current main visible view
* A parameter can be provided which becomes available in the onShow of said fragment * A parameter can be provided which becomes available in the onShow of said fragment
@ -1191,6 +1255,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
PlaylistFragment::class -> _fragMainPlaylist as T; PlaylistFragment::class -> _fragMainPlaylist as T;
RemotePlaylistFragment::class -> _fragMainRemotePlaylist as T; RemotePlaylistFragment::class -> _fragMainRemotePlaylist as T;
PostDetailFragment::class -> _fragPostDetail as T; PostDetailFragment::class -> _fragPostDetail as T;
ArticleDetailFragment::class -> _fragArticleDetail as T;
WebDetailFragment::class -> _fragWebDetail as T;
WatchLaterFragment::class -> _fragWatchlist as T; WatchLaterFragment::class -> _fragWatchlist as T;
HistoryFragment::class -> _fragHistory as T; HistoryFragment::class -> _fragHistory as T;
ShortsFragment::class -> _fragShorts as T; ShortsFragment::class -> _fragShorts as T;

View file

@ -9,6 +9,8 @@ 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.UIDialogs
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 +31,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 +66,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 +100,20 @@ class SyncHomeActivity : AppCompatActivity() {
updateEmptyVisibility() updateEmptyVisibility()
} }
} }
StateSync.instance.confirmStarted(this, onStarted = {
if (StateSync.instance.syncService?.serverSocketFailedToStart == true) {
UIDialogs.toast(this, "Server socket failed to start, is the port in use?", true)
}
if (StateSync.instance.syncService?.relayConnected == false) {
UIDialogs.toast(this, "Not connected to relay, remote connections will work.", false)
}
if (StateSync.instance.syncService?.serverSocketStarted == false) {
UIDialogs.toast(this, "Listener not started, local connections will not work.", false)
}
}, onNotStarted = {
finish()
})
} }
override fun onDestroy() { override fun onDestroy() {
@ -100,10 +125,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
syncDeviceView.setLinkType(if (connected) LinkType.Local else LinkType.None) val authorized = session?.isAuthorized ?: false
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) { session, complete, message -> StateSync.instance.syncService?.connect(deviceInfo) { complete, message ->
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
if (complete) { if (complete != null) {
_layoutPairingSuccess.visibility = View.VISIBLE if (complete) {
_layoutPairing.visibility = View.GONE _layoutPairingSuccess.visibility = View.VISIBLE
_layoutPairing.visibility = View.GONE
} else {
_textError.text = message
_layoutPairingError.visibility = View.VISIBLE
_layoutPairing.visibility = View.GONE
}
} else { } else {
_textPairingStatus.text = message _textPairingStatus.text = message
} }
@ -137,8 +144,6 @@ class SyncPairActivity : AppCompatActivity() {
_textError.text = e.message _textError.text = e.message
_layoutPairing.visibility = View.GONE _layoutPairing.visibility = View.GONE
Logger.e(TAG, "Failed to pair", e) Logger.e(TAG, "Failed to pair", e)
} finally {
_layoutPairing.visibility = View.GONE
} }
} }

View file

@ -67,11 +67,18 @@ class SyncShowPairingCodeActivity : AppCompatActivity() {
} }
val ips = getIPs() val ips = getIPs()
val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT) val publicKey = StateSync.instance.syncService?.publicKey
val json = Json.encodeToString(selfDeviceInfo) val pairingCode = StateSync.instance.syncService?.pairingCode
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) if (publicKey == null || pairingCode == null) {
val url = "grayjay://sync/${base64}" setCode("Public key or pairing code was not known, is sync enabled?")
setCode(url) } else {
val selfDeviceInfo = SyncDeviceInfo(publicKey, ips.toTypedArray(), StateSync.PORT, pairingCode)
val json = Json.encodeToString(selfDeviceInfo)
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
val url = "grayjay://sync/${base64}"
setCode(url)
}
} }
fun setCode(code: String?) { fun setCode(code: String?) {

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

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

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

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

View file

@ -0,0 +1,9 @@
package com.futo.platformplayer.api.media.models.article
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
interface IPlatformArticle: IPlatformContent {
val summary: String?;
val thumbnails: Thumbnails?;
}

View file

@ -0,0 +1,12 @@
package com.futo.platformplayer.api.media.models.article
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.platforms.js.models.IJSArticleSegment
interface IPlatformArticleDetails: IPlatformContent, IPlatformArticle, IPlatformContentDetails {
val segments: List<IJSArticleSegment>;
val rating : IRating;
}

View file

@ -8,10 +8,12 @@ enum class ContentType(val value: Int) {
POST(2), POST(2),
ARTICLE(3), ARTICLE(3),
PLAYLIST(4), PLAYLIST(4),
WEB(7),
URL(9), URL(9),
NESTED_VIDEO(11), NESTED_VIDEO(11),
CHANNEL(60),
LOCKED(70), LOCKED(70),

View file

@ -5,7 +5,8 @@ import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException
enum class TextType(val value: Int) { enum class TextType(val value: Int) {
RAW(0), RAW(0),
HTML(1), HTML(1),
MARKUP(2); MARKUP(2),
CODE(3);
companion object { companion object {
fun fromInt(value: Int): TextType fun fromInt(value: Int): TextType

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

@ -10,6 +10,7 @@ import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformClientCapabilities import com.futo.platformplayer.api.media.PlatformClientCapabilities
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
@ -32,6 +33,7 @@ import com.futo.platformplayer.api.media.platforms.js.internal.JSParameterDocs
import com.futo.platformplayer.api.media.platforms.js.models.IJSContent import com.futo.platformplayer.api.media.platforms.js.models.IJSContent
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSChannel import com.futo.platformplayer.api.media.platforms.js.models.JSChannel
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelContentPager
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager
import com.futo.platformplayer.api.media.platforms.js.models.JSChapter import com.futo.platformplayer.api.media.platforms.js.models.JSChapter
import com.futo.platformplayer.api.media.platforms.js.models.JSComment import com.futo.platformplayer.api.media.platforms.js.models.JSComment
@ -196,8 +198,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 {
@ -212,6 +217,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)}");
@ -371,6 +378,10 @@ open class JSClient : IPlatformClient {
return@isBusyWith JSChannelPager(config, this, return@isBusyWith JSChannelPager(config, this,
plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})")); plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"));
} }
override fun searchChannelsAsContent(query: String): IPager<IPlatformContent> = isBusyWith("searchChannels") {
ensureEnabled();
return@isBusyWith JSChannelContentPager(config, this, plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"), );
}
@JSDocs(6, "source.isChannelUrl(url)", "Validates if an channel url is for this platform") @JSDocs(6, "source.isChannelUrl(url)", "Validates if an channel url is for this platform")
@JSDocsParameter("url", "A channel url (May not be your platform)") @JSDocsParameter("url", "A channel url (May not be your platform)")

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

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

View file

@ -17,6 +17,7 @@ interface IJSContentDetails: IPlatformContent {
ContentType.MEDIA -> JSVideoDetails(plugin, obj); ContentType.MEDIA -> JSVideoDetails(plugin, obj);
ContentType.POST -> JSPostDetails(plugin.config, obj); ContentType.POST -> JSPostDetails(plugin.config, obj);
ContentType.ARTICLE -> JSArticleDetails(plugin, obj); ContentType.ARTICLE -> JSArticleDetails(plugin, obj);
ContentType.WEB -> JSWebDetails(plugin, obj);
else -> throw NotImplementedError("Unknown content type ${type}"); else -> throw NotImplementedError("Unknown content type ${type}");
} }
} }

View file

@ -0,0 +1,39 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.post.TextType
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
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.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.states.StateDeveloper
open class JSArticle : JSContent, IPlatformArticle, IPluginSourced {
final override val contentType: ContentType get() = ContentType.ARTICLE;
override val summary: String;
override val thumbnails: Thumbnails?;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformArticle";
summary = _content.getOrDefault(config, "summary", contextName, "") ?: "";
thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName));
}
}

View file

@ -4,6 +4,8 @@ import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.models.Thumbnails import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails
import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
@ -21,20 +23,20 @@ import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails { open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
final override val contentType: ContentType get() = ContentType.ARTICLE; final override val contentType: ContentType get() = ContentType.ARTICLE;
private val _hasGetComments: Boolean; private val _hasGetComments: Boolean;
private val _hasGetContentRecommendations: Boolean; private val _hasGetContentRecommendations: Boolean;
val rating: IRating; override val rating: IRating;
val summary: String; override val summary: String;
val thumbnails: Thumbnails?; override val thumbnails: Thumbnails?;
val segments: List<IJSArticleSegment>; override val segments: List<IJSArticleSegment>;
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) { constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
val contextName = "PlatformPost"; val contextName = "PlatformArticle";
rating = obj.getOrDefault<V8ValueObject>(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0); rating = obj.getOrDefault<V8ValueObject>(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0);
summary = _content.getOrThrow(client.config, "summary", contextName); summary = _content.getOrThrow(client.config, "summary", contextName);
@ -99,6 +101,7 @@ open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails
return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) { return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
SegmentType.TEXT -> JSTextSegment(client, obj); SegmentType.TEXT -> JSTextSegment(client, obj);
SegmentType.IMAGES -> JSImagesSegment(client, obj); SegmentType.IMAGES -> JSImagesSegment(client, obj);
SegmentType.HEADER -> JSHeaderSegment(client, obj);
SegmentType.NESTED -> JSNestedSegment(client, obj); SegmentType.NESTED -> JSNestedSegment(client, obj);
else -> null; else -> null;
} }
@ -110,6 +113,7 @@ enum class SegmentType(val value: Int) {
UNKNOWN(0), UNKNOWN(0),
TEXT(1), TEXT(1),
IMAGES(2), IMAGES(2),
HEADER(3),
NESTED(9); NESTED(9);
@ -150,6 +154,17 @@ class JSImagesSegment: IJSArticleSegment {
caption = obj.getOrDefault(client.config, "caption", contextName, "") ?: ""; caption = obj.getOrDefault(client.config, "caption", contextName, "") ?: "";
} }
} }
class JSHeaderSegment: IJSArticleSegment {
override val type = SegmentType.HEADER;
val content: String;
val level: Int;
constructor(client: JSClient, obj: V8ValueObject) {
val contextName = "JSHeaderSegment";
content = obj.getOrDefault(client.config, "content", contextName, "") ?: "";
level = obj.getOrDefault(client.config, "level", contextName, 1) ?: 1;
}
}
class JSNestedSegment: IJSArticleSegment { class JSNestedSegment: IJSArticleSegment {
override val type = SegmentType.NESTED; override val type = SegmentType.NESTED;
val nested: IPlatformContent; val nested: IPlatformContent;

View file

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

View file

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

View file

@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.models.JSChannelContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@ -15,4 +16,14 @@ class JSContentPager : JSPager<IPlatformContent>, IPluginSourced {
override fun convertResult(obj: V8ValueObject): IPlatformContent { override fun convertResult(obj: V8ValueObject): IPlatformContent {
return IJSContent.fromV8(plugin, obj); return IJSContent.fromV8(plugin, obj);
} }
}
class JSChannelContentPager : JSPager<IPlatformContent>, IPluginSourced {
override val sourceConfig: SourcePluginConfig get() = config;
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {}
override fun convertResult(obj: V8ValueObject): IPlatformContent {
return JSChannelContent(config, obj);
}
} }

View file

@ -0,0 +1,31 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.post.TextType
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
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.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.states.StateDeveloper
open class JSWeb : JSContent, IPluginSourced {
final override val contentType: ContentType get() = ContentType.WEB;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformWeb";
}
}

View file

@ -0,0 +1,41 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.post.TextType
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
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.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.states.StateDeveloper
open class JSWebDetails : JSContent, IPluginSourced, IPlatformContentDetails {
final override val contentType: ContentType get() = ContentType.WEB;
val html: String?;
//TODO: Options?
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
val contextName = "PlatformWeb";
html = obj.getOrDefault(client.config, "html", contextName, null);
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? = null;
}

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

@ -108,7 +108,7 @@ abstract class CastingDevice {
val expectedCurrentTime: Double val expectedCurrentTime: Double
get() { get() {
val diff = (System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0; val diff = if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff; return time + diff;
}; };
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED

View file

@ -10,7 +10,9 @@ import com.futo.platformplayer.toHexString
import com.futo.platformplayer.toInetAddress import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
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 org.json.JSONObject import org.json.JSONObject
@ -56,6 +58,10 @@ class ChromecastCastingDevice : CastingDevice {
private var _mediaSessionId: Int? = null; private var _mediaSessionId: Int? = null;
private var _thread: Thread? = null; private var _thread: Thread? = null;
private var _pingThread: Thread? = null; private var _pingThread: Thread? = null;
private var _launchRetries = 0
private val MAX_LAUNCH_RETRIES = 3
private var _lastLaunchTime_ms = 0L
private var _retryJob: Job? = null
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() { constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name; this.name = name;
@ -229,6 +235,7 @@ class ChromecastCastingDevice : CastingDevice {
launchObject.put("appId", "CC1AD845"); launchObject.put("appId", "CC1AD845");
launchObject.put("requestId", _requestId++); launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString()); sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
_lastLaunchTime_ms = System.currentTimeMillis()
} }
private fun getStatus() { private fun getStatus() {
@ -268,6 +275,7 @@ class ChromecastCastingDevice : CastingDevice {
_contentType = null; _contentType = null;
_streamType = null; _streamType = null;
_sessionId = null; _sessionId = null;
_launchRetries = 0
_transportId = null; _transportId = null;
} }
@ -282,6 +290,7 @@ class ChromecastCastingDevice : CastingDevice {
_started = true; _started = true;
_sessionId = null; _sessionId = null;
_launchRetries = 0
_mediaSessionId = null; _mediaSessionId = null;
Logger.i(TAG, "Starting..."); Logger.i(TAG, "Starting...");
@ -322,6 +331,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);
} }
} }
@ -334,6 +344,10 @@ class ChromecastCastingDevice : CastingDevice {
//Connection loop //Connection loop
while (_scopeIO?.isActive == true) { while (_scopeIO?.isActive == true) {
_sessionId = null;
_launchRetries = 0
_mediaSessionId = null;
Logger.i(TAG, "Connecting to Chromecast."); Logger.i(TAG, "Connecting to Chromecast.");
connectionState = CastConnectionState.CONNECTING; connectionState = CastConnectionState.CONNECTING;
@ -392,7 +406,7 @@ class ChromecastCastingDevice : CastingDevice {
try { try {
val inputStream = _inputStream ?: break; val inputStream = _inputStream ?: break;
synchronized(_inputStreamLock) val message = synchronized(_inputStreamLock)
{ {
Log.d(TAG, "Receiving next packet..."); Log.d(TAG, "Receiving next packet...");
val b1 = inputStream.readUnsignedByte(); val b1 = inputStream.readUnsignedByte();
@ -404,7 +418,7 @@ class ChromecastCastingDevice : CastingDevice {
if (size > buffer.size) { if (size > buffer.size) {
Logger.w(TAG, "Skipping packet that is too large $size bytes.") Logger.w(TAG, "Skipping packet that is too large $size bytes.")
inputStream.skip(size.toLong()); inputStream.skip(size.toLong());
return@synchronized return@synchronized null
} }
Log.d(TAG, "Received header indicating $size bytes. Waiting for message."); Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
@ -413,15 +427,19 @@ class ChromecastCastingDevice : CastingDevice {
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end? //TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
val messageBytes = buffer.sliceArray(IntRange(0, size - 1)); val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}."); Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val message = ChromeCast.CastMessage.parseFrom(messageBytes); val msg = ChromeCast.CastMessage.parseFrom(messageBytes);
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") { if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $message"); Logger.i(TAG, "Received message: $msg");
} }
return@synchronized msg
}
if (message != null) {
try { try {
handleMessage(message); handleMessage(message);
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to handle message.", e); Logger.w(TAG, "Failed to handle message.", e);
break
} }
} }
} catch (e: java.net.SocketException) { } catch (e: java.net.SocketException) {
@ -485,6 +503,10 @@ class ChromecastCastingDevice : CastingDevice {
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e); Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e);
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
connectionState = CastConnectionState.CONNECTING;
} }
} }
@ -511,6 +533,7 @@ class ChromecastCastingDevice : CastingDevice {
if (_sessionId == null) { if (_sessionId == null) {
connectionState = CastConnectionState.CONNECTED; connectionState = CastConnectionState.CONNECTED;
_sessionId = applicationUpdate.getString("sessionId"); _sessionId = applicationUpdate.getString("sessionId");
_launchRetries = 0
val transportId = applicationUpdate.getString("transportId"); val transportId = applicationUpdate.getString("transportId");
connectMediaChannel(transportId); connectMediaChannel(transportId);
@ -525,21 +548,40 @@ class ChromecastCastingDevice : CastingDevice {
} }
if (!sessionIsRunning) { if (!sessionIsRunning) {
_sessionId = null; if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
_mediaSessionId = null; _sessionId = null
setTime(0.0); _mediaSessionId = null
_transportId = null; setTime(0.0)
Logger.w(TAG, "Session not found."); _transportId = null
if (_launching) { if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
Logger.i(TAG, "Player not found, launching."); Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
launchPlayer(); _launchRetries++
launchPlayer()
} else if (!_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
// Maybe the first GET_STATUS came back empty; still try launching
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
_launching = true
_launchRetries++
launchPlayer()
} else {
Logger.e(TAG, "Player not found after $_launchRetries attempts; giving up.")
Logger.i(TAG, "Unable to start media receiver on device")
stop()
}
} else { } else {
Logger.i(TAG, "Player not found, disconnecting."); if (_retryJob == null) {
stop(); Logger.i(TAG, "Scheduled retry job over 5 seconds")
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
delay(5000)
getStatus()
_retryJob = null
}
}
} }
} else { } else {
_launching = false; _launching = false
_launchRetries = 0
} }
val volume = status.getJSONObject("volume"); val volume = status.getJSONObject("volume");
@ -566,7 +608,7 @@ class ChromecastCastingDevice : CastingDevice {
} }
isPlaying = playerState == "PLAYING"; isPlaying = playerState == "PLAYING";
if (isPlaying) { if (isPlaying || playerState == "PAUSED") {
setTime(currentTime); setTime(currentTime);
} }
@ -581,6 +623,8 @@ class ChromecastCastingDevice : CastingDevice {
if (message.sourceId == "receiver-0") { if (message.sourceId == "receiver-0") {
Logger.i(TAG, "Close received."); Logger.i(TAG, "Close received.");
stop(); stop();
} else if (_transportId == message.sourceId) {
throw Exception("Transport id closed.")
} }
} }
} else { } else {
@ -615,6 +659,9 @@ class ChromecastCastingDevice : CastingDevice {
localAddress = null; localAddress = null;
_started = false; _started = false;
_retryJob?.cancel()
_retryJob = null
val socket = _socket; val socket = _socket;
val scopeIO = _scopeIO; val scopeIO = _scopeIO;

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
@ -92,7 +93,7 @@ class FCastCastingDevice : CastingDevice {
private var _version: Long = 1; private var _version: Long = 1;
private var _thread: Thread? = null private var _thread: Thread? = null
private var _pingThread: Thread? = null private var _pingThread: Thread? = null
private var _lastPongTime = -1L @Volatile private var _lastPongTime = System.currentTimeMillis()
private var _outputStreamLock = Object() private var _outputStreamLock = Object()
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() { constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
@ -289,6 +290,7 @@ class FCastCastingDevice : CastingDevice {
break; break;
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e) Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
Thread.sleep(1000);
} }
} }
@ -326,9 +328,9 @@ class FCastCastingDevice : CastingDevice {
continue; continue;
} }
localAddress = _socket?.localAddress; localAddress = _socket?.localAddress
connectionState = CastConnectionState.CONNECTED; _lastPongTime = System.currentTimeMillis()
_lastPongTime = -1L connectionState = CastConnectionState.CONNECTED
val buffer = ByteArray(4096); val buffer = ByteArray(4096);
@ -404,36 +406,32 @@ class FCastCastingDevice : CastingDevice {
_pingThread = Thread { _pingThread = Thread {
Logger.i(TAG, "Started ping loop.") Logger.i(TAG, "Started ping loop.")
while (_scopeIO?.isActive == true) { while (_scopeIO?.isActive == true) {
try { if (connectionState == CastConnectionState.CONNECTED) {
send(Opcode.Ping)
} catch (e: Throwable) {
Log.w(TAG, "Failed to send ping.")
try { try {
_socket?.close() send(Opcode.Ping)
_inputStream?.close() if (System.currentTimeMillis() - _lastPongTime > 15000) {
_outputStream?.close() Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.")
try {
_socket?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}
} catch (e: Throwable) { } catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e) Log.w(TAG, "Failed to send ping.")
try {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
} }
} }
Thread.sleep(5000)
/*if (_lastPongTime != -1L && System.currentTimeMillis() - _lastPongTime > 6000) {
Logger.w(TAG, "Closing socket due to last pong time being larger than 6 seconds.")
try {
_socket?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}*/
Thread.sleep(2000)
} }
Logger.i(TAG, "Stopped ping loop.")
Logger.i(TAG, "Stopped ping loop.");
}.apply { start() } }.apply { start() }
} else { } else {
Log.i(TAG, "Thread was still alive, not restarted") Log.i(TAG, "Thread was still alive, not restarted")

View file

@ -4,10 +4,14 @@ 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 java.net.NetworkInterface
import java.net.Inet4Address
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
@ -39,9 +43,8 @@ import com.futo.platformplayer.builders.DashBuilder
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.findPreferredAddress
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,10 +58,11 @@ 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.Inet6Address
import java.net.InetAddress import java.net.InetAddress
import java.net.URLDecoder import java.net.URLDecoder
import java.net.URLEncoder import java.net.URLEncoder
import java.util.Collections
import java.util.UUID import java.util.UUID
class StateCasting { class StateCasting {
@ -70,7 +74,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 +87,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 +160,33 @@ class StateCasting {
Logger.i(TAG, "CastingService starting..."); Logger.i(TAG, "CastingService starting...");
rememberedDevices.clear();
rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) });
_castServer.start(); _castServer.start();
enableDeveloper(true); enableDeveloper(true);
Logger.i(TAG, "CastingService started."); Logger.i(TAG, "CastingService started.");
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
} }
@Synchronized @Synchronized
fun startDiscovering() { fun startDiscovering() {
try { _nsdManager?.apply {
_serviceDiscoverer.start() _discoveryListeners.forEach {
} catch (e: Throwable) { discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
Logger.i(TAG, "Failed to start ServiceDiscoverer", e) }
} }
} }
@Synchronized @Synchronized
fun stopDiscovering() { fun stopDiscovering() {
try { _nsdManager?.apply {
_serviceDiscoverer.stop() _discoveryListeners.forEach {
} catch (e: Throwable) { try {
Logger.i(TAG, "Failed to stop ServiceDiscoverer", e) stopServiceDiscovery(it.value)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
} }
} }
@ -239,6 +212,85 @@ class StateCasting {
_castServer.removeAllHandlers(); _castServer.removeAllHandlers();
Logger.i(TAG, "CastingService stopped.") Logger.i(TAG, "CastingService stopped.")
_nsdManager = null
}
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
return object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(regType: String) {
Log.d(TAG, "Service discovery started for $regType")
}
override fun onDiscoveryStopped(serviceType: String) {
Log.i(TAG, "Discovery stopped: $serviceType")
}
override fun onServiceLost(service: NsdServiceInfo) {
Log.e(TAG, "service lost: $service")
// TODO: Handle service lost, e.g., remove device
}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onServiceFound(service: NsdServiceInfo) {
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
service.hostAddresses.toTypedArray()
} else {
arrayOf(service.host)
}
addOrUpdate(service.serviceName, addresses, service.port)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
_nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback {
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "onServiceUpdated: $serviceInfo")
addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port)
}
override fun onServiceLost() {
Log.v(TAG, "onServiceLost: $service")
// TODO: Handle service lost
}
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
}
override fun onServiceInfoCallbackUnregistered() {
Log.v(TAG, "onServiceInfoCallbackUnregistered")
}
})
} else {
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Log.v(TAG, "Resolve failed: $errorCode")
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port)
}
})
}
}
}
} }
private val _castingDialogLock = Any(); private val _castingDialogLock = Any();
@ -294,7 +346,9 @@ class StateCasting {
UIDialogs.toast(it, "Connecting to device...") UIDialogs.toast(it, "Connecting to device...")
synchronized(_castingDialogLock) { synchronized(_castingDialogLock) {
if(_currentDialog == null) { if(_currentDialog == null) {
_currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, "Connecting to [${device.name}]", "Make sure you are on the same network", null, -2, _currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true,
"Connecting to [${device.name}]",
"Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2,
UIDialogs.Action("Disconnect", { UIDialogs.Action("Disconnect", {
device.stop(); device.stop();
})); }));
@ -329,9 +383,6 @@ class StateCasting {
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) }; invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
}; };
addRememberedDevice(device);
Logger.i(TAG, "Device added to active discovery. Active discovery now contains ${_storage.getDevicesCount()} devices.")
try { try {
device.start(); device.start();
} catch (e: Throwable) { } catch (e: Throwable) {
@ -353,21 +404,22 @@ class StateCasting {
return addRememberedDevice(device); return addRememberedDevice(device);
} }
fun getRememberedCastingDevices(): List<CastingDevice> {
return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) }
}
fun getRememberedCastingDeviceNames(): List<String> {
return _storage.getDeviceNames()
}
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo { fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
val deviceInfo = device.getDeviceInfo() val deviceInfo = device.getDeviceInfo()
val foundInfo = _storage.addDevice(deviceInfo) return _storage.addDevice(deviceInfo)
if (foundInfo == deviceInfo) {
rememberedDevices.add(device);
return foundInfo;
}
return foundInfo;
} }
fun removeRememberedDevice(device: CastingDevice) { fun removeRememberedDevice(device: CastingDevice) {
val name = device.name ?: return; val name = device.name ?: return
_storage.removeDevice(name); _storage.removeDevice(name)
rememberedDevices.remove(device);
} }
private fun invokeInMainScopeIfRequired(action: () -> Unit){ private fun invokeInMainScopeIfRequired(action: () -> Unit){
@ -436,7 +488,7 @@ class StateCasting {
} }
} else { } else {
val proxyStreams = Settings.instance.casting.alwaysProxyRequests; val proxyStreams = Settings.instance.casting.alwaysProxyRequests;
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
if (videoSource is IVideoUrlSource) { if (videoSource is IVideoUrlSource) {
@ -531,7 +583,7 @@ class StateCasting {
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> { private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val videoPath = "/video-${id}" val videoPath = "/video-${id}"
val videoUrl = url + videoPath; val videoUrl = url + videoPath;
@ -550,7 +602,7 @@ class StateCasting {
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> { private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val audioPath = "/audio-${id}" val audioPath = "/audio-${id}"
val audioUrl = url + audioPath; val audioUrl = url + audioPath;
@ -569,7 +621,7 @@ class StateCasting {
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List<String> { private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List<String> {
val ad = activeDevice ?: return listOf() val ad = activeDevice ?: return listOf()
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}" val url = getLocalUrl(ad)
val id = UUID.randomUUID() val id = UUID.randomUUID()
val hlsPath = "/hls-${id}" val hlsPath = "/hls-${id}"
@ -665,7 +717,7 @@ class StateCasting {
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List<String> { private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val dashPath = "/dash-${id}" val dashPath = "/dash-${id}"
@ -715,7 +767,7 @@ class StateCasting {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val videoPath = "/video-${id}" val videoPath = "/video-${id}"
@ -780,7 +832,7 @@ class StateCasting {
_castServer.removeAllHandlers("castProxiedHlsMaster") _castServer.removeAllHandlers("castProxiedHlsMaster")
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val hlsPath = "/hls-${id}" val hlsPath = "/hls-${id}"
@ -950,7 +1002,7 @@ class StateCasting {
private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> { private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val hlsPath = "/hls-${id}" val hlsPath = "/hls-${id}"
@ -1080,7 +1132,7 @@ class StateCasting {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val dashPath = "/dash-${id}" val dashPath = "/dash-${id}"
@ -1166,6 +1218,15 @@ class StateCasting {
} }
} }
private fun getLocalUrl(ad: CastingDevice): String {
var address = ad.localAddress!!
if (address.isLinkLocalAddress) {
address = findPreferredAddress() ?: address
Logger.i(TAG, "Selected casting address: $address")
}
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
}
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> { private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
@ -1173,7 +1234,7 @@ class StateCasting {
cleanExecutors() cleanExecutors()
_castServer.removeAllHandlers("castDashRaw") _castServer.removeAllHandlers("castDashRaw")
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val dashPath = "/dash-${id}" val dashPath = "/dash-${id}"

View file

@ -82,7 +82,11 @@ class TaskHandler<TParameter, TResult> {
handled = true; handled = true;
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Handled exception in TaskHandler onSuccess.", e); Logger.w(TAG, "Handled exception in TaskHandler onSuccess.", e);
onError.emit(e, parameter); try {
onError.emit(e, parameter);
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in .exception handler 1", e)
}
handled = true; handled = true;
} }
} }
@ -99,10 +103,14 @@ class TaskHandler<TParameter, TResult> {
if (id != _idGenerator) if (id != _idGenerator)
return@withContext; return@withContext;
if (!onError.emit(e, parameter)) { try {
Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e); if (!onError.emit(e, parameter)) {
} else { Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e);
//Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); (Prevents duplicate logs) } else {
//Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); (Prevents duplicate logs)
}
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in .exception handler 2", e)
} }
} }
} }

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);
val index = _rememberedDevices.indexOf(d);
if (index != -1) {
_rememberedDevices.removeAt(index);
_rememberedAdapter.notifyItemRemoved(index);
}
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
};
_rememberedAdapter.onConnect.subscribe { _ ->
dismiss()
//UIDialogs.showCastingDialog(context)
} }
//TODO: Integrate remembered into the main list
//TODO: Add green indicator to indicate a device is oneline
//TODO: Add pinning
//TODO: Implement QR code as an option in add manually
//TODO: Remove start button
_adapter.onConnect.subscribe { _ -> _adapter.onConnect.subscribe { _ ->
dismiss() dismiss()
//UIDialogs.showCastingDialog(context) //UIDialogs.showCastingDialog(context)
} }
_recyclerRememberedDevices.adapter = _rememberedAdapter;
_recyclerRememberedDevices.layoutManager = LinearLayoutManager(context);
_buttonClose.setOnClickListener { dismiss(); }; _buttonClose.setOnClickListener { dismiss(); };
_buttonAdd.setOnClickListener { _buttonAdd.setOnClickListener {
@ -105,77 +104,112 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
Logger.i(TAG, "Dialog shown."); Logger.i(TAG, "Dialog shown.");
StateCasting.instance.startDiscovering() StateCasting.instance.startDiscovering()
(_imageLoader.drawable as Animatable?)?.start(); (_imageLoader.drawable as Animatable?)?.start();
_devices.clear(); synchronized(StateCasting.instance.devices) {
synchronized (StateCasting.instance.devices) { _devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
_devices.addAll(StateCasting.instance.devices.values);
} }
_rememberedDevices.clear(); _rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
synchronized (StateCasting.instance.rememberedDevices) { updateUnifiedList()
_rememberedDevices.addAll(StateCasting.instance.rememberedDevices);
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
val name = d.name
if (name != null)
_devices.add(name)
updateUnifiedList()
}
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name }
if (index != -1) {
_unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice)
_adapter.notifyItemChanged(index)
}
}
StateCasting.instance.onDeviceRemoved.subscribe(this) { d ->
_devices.remove(d.name)
updateUnifiedList()
}
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
if (connectionState == CastConnectionState.CONNECTED) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
dismiss()
}
}
} }
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE; _textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE; _recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
_devices.add(d);
_adapter.notifyItemInserted(_devices.size - 1);
_textNoDevicesFound.visibility = View.GONE;
_recyclerDevices.visibility = View.VISIBLE;
};
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
val index = _devices.indexOf(d);
if (index == -1) {
return@subscribe;
}
_devices[index] = d;
_adapter.notifyItemChanged(index);
};
StateCasting.instance.onDeviceRemoved.subscribe(this) { d ->
val index = _devices.indexOf(d);
if (index == -1) {
return@subscribe;
}
_devices.removeAt(index);
_adapter.notifyItemRemoved(index);
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
};
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
if (connectionState != CastConnectionState.CONNECTED) {
return@subscribe;
}
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
dismiss();
};
};
_adapter.notifyDataSetChanged();
_rememberedAdapter.notifyDataSetChanged();
} }
override fun dismiss() { override fun dismiss() {
super.dismiss(); super.dismiss()
(_imageLoader.drawable as Animatable?)?.stop()
(_imageLoader.drawable as Animatable?)?.stop();
StateCasting.instance.stopDiscovering() StateCasting.instance.stopDiscovering()
StateCasting.instance.onDeviceAdded.remove(this); StateCasting.instance.onDeviceAdded.remove(this)
StateCasting.instance.onDeviceChanged.remove(this); StateCasting.instance.onDeviceChanged.remove(this)
StateCasting.instance.onDeviceRemoved.remove(this); StateCasting.instance.onDeviceRemoved.remove(this)
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this)
}
private fun updateUnifiedList() {
val oldList = ArrayList(_unifiedDevices)
val newList = buildUnifiedList()
val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun getOldListSize() = oldList.size
override fun getNewListSize() = newList.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return oldItem.castingDevice.name == newItem.castingDevice.name
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return oldItem.castingDevice.name == newItem.castingDevice.name
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
}
})
_unifiedDevices.clear()
_unifiedDevices.addAll(newList)
diffResult.dispatchUpdatesTo(_adapter)
_textNoDevicesFound.visibility = if (_unifiedDevices.isEmpty()) View.VISIBLE else View.GONE
_recyclerDevices.visibility = if (_unifiedDevices.isNotEmpty()) View.VISIBLE else View.GONE
}
private fun buildUnifiedList(): List<DeviceAdapterEntry> {
val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name }
val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name }
val unifiedList = mutableListOf<DeviceAdapterEntry>()
val intersectionNames = _devices.intersect(_rememberedDevices)
for (name in intersectionNames) {
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, true)) }
}
val onlineOnlyNames = _devices - _rememberedDevices
for (name in onlineOnlyNames) {
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, false, true)) }
}
val rememberedOnlyNames = _rememberedDevices - _devices
for (name in rememberedOnlyNames) {
rememberedDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, false)) }
}
return unifiedList
} }
companion object { companion object {

View file

@ -47,6 +47,7 @@ import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSpeed import com.futo.platformplayer.toHumanBytesSpeed
import com.futo.polycentric.core.hexStringToByteArray
import hasAnySource import hasAnySource
import isDownloadable import isDownloadable
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@ -59,16 +60,21 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Contextual import kotlinx.serialization.Contextual
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.lang.Thread.sleep import java.lang.Thread.sleep
import java.nio.ByteBuffer
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.UUID import java.util.UUID
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask import java.util.concurrent.ForkJoinTask
import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.ThreadLocalRandom
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlin.time.times import kotlin.time.times
@ -564,6 +570,14 @@ class VideoDownload {
} }
} }
private fun decryptSegment(encryptedSegment: ByteArray, key: ByteArray, iv: ByteArray): ByteArray {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val secretKey = SecretKeySpec(key, "AES")
val ivSpec = IvParameterSpec(iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
return cipher.doFinal(encryptedSegment)
}
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists()) if(targetFile.exists())
targetFile.delete(); targetFile.delete();
@ -579,6 +593,14 @@ class VideoDownload {
?: throw Exception("Variant playlist content is empty") ?: throw Exception("Variant playlist content is empty")
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl) val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) {
val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl)
check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" }
DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv?.hexStringToByteArray())
} else {
null
}
variantPlaylist.segments.forEachIndexed { index, segment -> variantPlaylist.segments.forEachIndexed { index, segment ->
if (segment !is HLS.MediaSegment) { if (segment !is HLS.MediaSegment) {
return@forEachIndexed return@forEachIndexed
@ -590,7 +612,7 @@ class VideoDownload {
try { try {
segmentFiles.add(segmentFile) segmentFiles.add(segmentFile)
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri) { segmentLength, totalRead, lastSpeed -> val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo, index) { segmentLength, totalRead, lastSpeed ->
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed) onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
@ -630,10 +652,8 @@ class VideoDownload {
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) { private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt") val cmd =
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" }) "-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
val statisticsCallback = StatisticsCallback { _ -> val statisticsCallback = StatisticsCallback { _ ->
//TODO: Show progress? //TODO: Show progress?
@ -643,7 +663,6 @@ class VideoDownload {
val session = FFmpegKit.executeAsync(cmd, val session = FFmpegKit.executeAsync(cmd,
{ session -> { session ->
if (ReturnCode.isSuccess(session.returnCode)) { if (ReturnCode.isSuccess(session.returnCode)) {
fileList.delete()
continuation.resumeWith(Result.success(Unit)) continuation.resumeWith(Result.success(Unit))
} else { } else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) { val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
@ -651,7 +670,6 @@ class VideoDownload {
} else { } else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}" "Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
} }
fileList.delete()
continuation.resumeWithException(RuntimeException(errorMessage)) continuation.resumeWithException(RuntimeException(errorMessage))
} }
}, },
@ -771,7 +789,7 @@ class VideoDownload {
else { else {
Logger.i(TAG, "Download $name Sequential"); Logger.i(TAG, "Download $name Sequential");
try { try {
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress); sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress);
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)") Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
throw e throw e
@ -798,7 +816,31 @@ class VideoDownload {
} }
return sourceLength!!; return sourceLength!!;
} }
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, onProgress: (Long, Long, Long) -> Unit): Long {
data class DecryptionInfo(
val key: ByteArray,
val iv: ByteArray?
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DecryptionInfo
if (!key.contentEquals(other.key)) return false
if (!iv.contentEquals(other.iv)) return false
return true
}
override fun hashCode(): Int {
var result = key.contentHashCode()
result = 31 * result + iv.contentHashCode()
return result
}
}
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long {
val progressRate: Int = 4096 * 5; val progressRate: Int = 4096 * 5;
var lastProgressCount: Int = 0; var lastProgressCount: Int = 0;
val speedRate: Int = 4096 * 5; val speedRate: Int = 4096 * 5;
@ -818,6 +860,8 @@ class VideoDownload {
val sourceLength = result.body.contentLength(); val sourceLength = result.body.contentLength();
val sourceStream = result.body.byteStream(); val sourceStream = result.body.byteStream();
val segmentBuffer = ByteArrayOutputStream()
var totalRead: Long = 0; var totalRead: Long = 0;
try { try {
var read: Int; var read: Int;
@ -828,7 +872,7 @@ class VideoDownload {
if (read < 0) if (read < 0)
break; break;
fileStream.write(buffer, 0, read); segmentBuffer.write(buffer, 0, read);
totalRead += read; totalRead += read;
@ -854,6 +898,21 @@ class VideoDownload {
result.body.close() result.body.close()
} }
if (decryptionInfo != null) {
var iv = decryptionInfo.iv
if (iv == null) {
iv = ByteBuffer.allocate(16)
.putLong(0L)
.putLong(index.toLong())
.array()
}
val decryptedData = decryptSegment(segmentBuffer.toByteArray(), decryptionInfo.key, iv!!)
fileStream.write(decryptedData)
} else {
fileStream.write(segmentBuffer.toByteArray())
}
onProgress(sourceLength, totalRead, 0); onProgress(sourceLength, totalRead, 0);
return sourceLength; return sourceLength;
} }
@ -1160,6 +1219,8 @@ class VideoDownload {
fun audioContainerToExtension(container: String): String { fun audioContainerToExtension(container: String): String {
if (container.contains("audio/mp4")) if (container.contains("audio/mp4"))
return "mp4a"; return "mp4a";
else if (container.contains("video/mp4"))
return "mp4";
else if (container.contains("audio/mpeg")) else if (container.contains("audio/mpeg"))
return "mpga"; return "mpga";
else if (container.contains("audio/mp3")) else if (container.contains("audio/mp3"))
@ -1167,7 +1228,7 @@ class VideoDownload {
else if (container.contains("audio/webm")) else if (container.contains("audio/webm"))
return "webm"; return "webm";
else if (container == "application/vnd.apple.mpegurl") else if (container == "application/vnd.apple.mpegurl")
return "mp4a"; return "m4a";
else else
return "audio";// throw IllegalStateException("Unknown container: " + container) return "audio";// throw IllegalStateException("Unknown container: " + container)
} }

View file

@ -69,7 +69,7 @@ class VideoExport {
outputFile = f; outputFile = f;
} else if (v != null) { } else if (v != null) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container); val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container);
val f = downloadRoot.createFile(v.container, outputFileName) val f = downloadRoot.createFile(if (v.container == "application/vnd.apple.mpegurl") "video/mp4" else v.container, outputFileName)
?: throw Exception("Failed to create file in external directory."); ?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying video."); Logger.i(TAG, "Copying video.");
@ -81,8 +81,8 @@ class VideoExport {
outputFile = f; outputFile = f;
} else if (a != null) { } else if (a != null) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container); val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container);
val f = downloadRoot.createFile(a.container, outputFileName) val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "video/mp4" else a.container, outputFileName)
?: throw Exception("Failed to create file in external directory."); ?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying audio."); Logger.i(TAG, "Copying audio.");

View file

@ -188,6 +188,14 @@ class V8Plugin {
whenNotBusy { whenNotBusy {
synchronized(_runtimeLock) { synchronized(_runtimeLock) {
isStopped = true; isStopped = true;
//Cleanup http
for(pack in _depsPackages) {
if(pack is PackageHttp) {
pack.cleanup();
}
}
_runtime?.let { _runtime?.let {
_runtime = null; _runtime = null;
if(!it.isClosed && !it.isDead) { if(!it.isClosed && !it.isDead) {

View file

@ -12,6 +12,7 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.JSClientConstants import com.futo.platformplayer.api.media.platforms.js.JSClientConstants
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@ -72,6 +73,26 @@ 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";
}
@V8Property
fun supportedContent(): Array<Int> {
return arrayOf(
ContentType.MEDIA.value,
ContentType.POST.value,
ContentType.PLAYLIST.value,
ContentType.WEB.value,
ContentType.URL.value,
ContentType.NESTED_VIDEO.value,
ContentType.CHANNEL.value,
ContentType.LOCKED.value,
ContentType.PLACEHOLDER.value,
ContentType.DEFERRED.value
)
}
@V8Function @V8Function
fun dispose(value: V8Value) { fun dispose(value: V8Value) {

View file

@ -8,9 +8,7 @@ import com.caoccao.javet.enums.V8ProxyMode
import com.caoccao.javet.interop.V8Runtime import com.caoccao.javet.interop.V8Runtime
import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.primitive.V8ValueString import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.V8ValueArrayBuffer
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.caoccao.javet.values.reference.V8ValueSharedArrayBuffer
import com.caoccao.javet.values.reference.V8ValueTypedArray import com.caoccao.javet.values.reference.V8ValueTypedArray
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@ -20,15 +18,9 @@ import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.engine.internal.IV8Convertable import com.futo.platformplayer.engine.internal.IV8Convertable
import com.futo.platformplayer.engine.internal.V8BindObject import com.futo.platformplayer.engine.internal.V8BindObject
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask import java.util.concurrent.ForkJoinTask
import kotlin.concurrent.thread
import kotlin.streams.asSequence
class PackageHttp: V8Package { class PackageHttp: V8Package {
@Transient @Transient
@ -49,6 +41,9 @@ class PackageHttp: V8Package {
private var _batchPoolLock: Any = Any(); private var _batchPoolLock: Any = Any();
private var _batchPool: ForkJoinPool? = null; private var _batchPool: ForkJoinPool? = null;
private val aliveSockets = mutableListOf<SocketResult>();
private var _cleanedUp = false;
constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) { constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) {
_config = config; _config = config;
@ -58,6 +53,27 @@ class PackageHttp: V8Package {
_packageClientAuth = PackageHttpClient(this, _clientAuth); _packageClientAuth = PackageHttpClient(this, _clientAuth);
} }
fun cleanup(){
Logger.w(TAG, "PackageHttp Cleaning up")
val sockets = synchronized(aliveSockets) { aliveSockets.toList() }
_cleanedUp = true;
for(socket in sockets){
try {
Logger.w(TAG, "PackageHttp Socket Cleaned Up");
socket.close(1001, "Cleanup");
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to close socket", ex);
}
}
if(sockets.size > 0) {
//Thread.sleep(100); //Give sockets a bit
}
synchronized(aliveSockets) {
aliveSockets.clear();
}
}
/* /*
Automatically adjusting threadpool dedicated per PackageHttp for batch requests. Automatically adjusting threadpool dedicated per PackageHttp for batch requests.
@ -111,24 +127,24 @@ class PackageHttp: V8Package {
@V8Function @V8Function
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse { fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
return if(useAuth) return if(useAuth)
_packageClientAuth.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING) _packageClientAuth.requestInternal(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
else else
_packageClient.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING); _packageClient.requestInternal(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
} }
@V8Function @V8Function
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse { fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
return if(useAuth) return if(useAuth)
_packageClientAuth.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING) _packageClientAuth.requestWithBodyInternal(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
else else
_packageClient.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING); _packageClient.requestWithBodyInternal(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
} }
@V8Function @V8Function
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
return if(useAuth) return if(useAuth)
_packageClientAuth.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING) _packageClientAuth.GETInternal(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING)
else else
_packageClient.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); _packageClient.GETInternal(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
} }
@V8Function @V8Function
fun POST(url: String, body: Any, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { fun POST(url: String, body: Any, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
@ -136,15 +152,15 @@ class PackageHttp: V8Package {
val client = if(useAuth) _packageClientAuth else _packageClient; val client = if(useAuth) _packageClientAuth else _packageClient;
if(body is V8ValueString) if(body is V8ValueString)
return client.POST(url, body.value, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); return client.POSTInternal(url, body.value, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is String) else if(body is String)
return client.POST(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); return client.POSTInternal(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is V8ValueTypedArray) else if(body is V8ValueTypedArray)
return client.POST(url, body.toBytes(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); return client.POSTInternal(url, body.toBytes(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is ByteArray) else if(body is ByteArray)
return client.POST(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); return client.POSTInternal(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is ArrayList<*>) //Avoid this case, used purely for testing else if(body is ArrayList<*>) //Avoid this case, used purely for testing
return client.POST(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); return client.POSTInternal(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else else
throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST"); throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST");
} }
@ -276,9 +292,9 @@ class PackageHttp: V8Package {
if(it.second.method == "DUMMY") if(it.second.method == "DUMMY")
return@autoParallelPool null; return@autoParallelPool null;
if(it.second.body != null) if(it.second.body != null)
return@autoParallelPool it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType); return@autoParallelPool it.first.requestWithBodyInternal(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType);
else else
return@autoParallelPool it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType); return@autoParallelPool it.first.requestInternal(it.second.method, it.second.url, it.second.headers, it.second.respType);
}.map { }.map {
if(it.second != null) if(it.second != null)
throw it.second!!; throw it.second!!;
@ -345,7 +361,9 @@ class PackageHttp: V8Package {
} }
@V8Function @V8Function
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse { fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= requestInternal(method, url, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING);
fun requestInternal(method: String, url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
applyDefaultHeaders(headers); applyDefaultHeaders(headers);
return logExceptions { return logExceptions {
return@logExceptions catchHttp { return@logExceptions catchHttp {
@ -364,7 +382,9 @@ class PackageHttp: V8Package {
}; };
} }
@V8Function @V8Function
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse { fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= requestWithBodyInternal(method, url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
fun requestWithBodyInternal(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
applyDefaultHeaders(headers); applyDefaultHeaders(headers);
return logExceptions { return logExceptions {
catchHttp { catchHttp {
@ -385,7 +405,9 @@ class PackageHttp: V8Package {
} }
@V8Function @V8Function
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= GETInternal(url, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
fun GETInternal(url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
applyDefaultHeaders(headers); applyDefaultHeaders(headers);
return logExceptions { return logExceptions {
catchHttp { catchHttp {
@ -407,7 +429,9 @@ class PackageHttp: V8Package {
}; };
} }
@V8Function @V8Function
fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
fun POSTInternal(url: String, body: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
applyDefaultHeaders(headers); applyDefaultHeaders(headers);
return logExceptions { return logExceptions {
catchHttp { catchHttp {
@ -429,7 +453,9 @@ class PackageHttp: V8Package {
}; };
} }
@V8Function @V8Function
fun POST(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { fun POST(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
fun POSTInternal(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
applyDefaultHeaders(headers); applyDefaultHeaders(headers);
return logExceptions { return logExceptions {
catchHttp { catchHttp {
@ -453,9 +479,16 @@ class PackageHttp: V8Package {
@V8Function @V8Function
fun socket(url: String, headers: Map<String, String>? = null): SocketResult { fun socket(url: String, headers: Map<String, String>? = null): SocketResult {
if(_package._cleanedUp)
throw IllegalStateException("Plugin shutdown");
val socketHeaders = headers?.toMutableMap() ?: HashMap(); val socketHeaders = headers?.toMutableMap() ?: HashMap();
applyDefaultHeaders(socketHeaders); applyDefaultHeaders(socketHeaders);
return SocketResult(this, _client, url, socketHeaders); val socket = SocketResult(_package, this, _client, url, socketHeaders);
Logger.w(TAG, "PackageHttp Socket opened");
synchronized(_package.aliveSockets) {
_package.aliveSockets.add(socket);
}
return socket;
} }
private fun applyDefaultHeaders(headerMap: MutableMap<String, String>) { private fun applyDefaultHeaders(headerMap: MutableMap<String, String>) {
@ -561,13 +594,15 @@ class PackageHttp: V8Package {
private var _listeners: V8ValueObject? = null; private var _listeners: V8ValueObject? = null;
private val _package: PackageHttp;
private val _packageClient: PackageHttpClient; private val _packageClient: PackageHttpClient;
private val _client: ManagedHttpClient; private val _client: ManagedHttpClient;
private val _url: String; private val _url: String;
private val _headers: Map<String, String>; private val _headers: Map<String, String>;
constructor(pack: PackageHttpClient, client: ManagedHttpClient, url: String, headers: Map<String,String>) { constructor(parent: PackageHttp, pack: PackageHttpClient, client: ManagedHttpClient, url: String, headers: Map<String,String>) {
_packageClient = pack; _packageClient = pack;
_package = parent;
_client = client; _client = client;
_url = url; _url = url;
_headers = headers; _headers = headers;
@ -593,7 +628,7 @@ class PackageHttp: V8Package {
override fun open() { override fun open() {
Logger.i(TAG, "Websocket opened: " + _url); Logger.i(TAG, "Websocket opened: " + _url);
_isOpen = true; _isOpen = true;
if(hasOpen) { if(hasOpen && _listeners?.isClosed != true) {
try { try {
_listeners?.invokeVoid("open", arrayOf<Any>()); _listeners?.invokeVoid("open", arrayOf<Any>());
} }
@ -603,7 +638,7 @@ class PackageHttp: V8Package {
} }
} }
override fun message(msg: String) { override fun message(msg: String) {
if(hasMessage) { if(hasMessage && _listeners?.isClosed != true) {
try { try {
_listeners?.invokeVoid("message", msg); _listeners?.invokeVoid("message", msg);
} }
@ -611,7 +646,7 @@ class PackageHttp: V8Package {
} }
} }
override fun closing(code: Int, reason: String) { override fun closing(code: Int, reason: String) {
if(hasClosing) if(hasClosing && _listeners?.isClosed != true)
{ {
try { try {
_listeners?.invokeVoid("closing", code, reason); _listeners?.invokeVoid("closing", code, reason);
@ -623,7 +658,7 @@ class PackageHttp: V8Package {
} }
override fun closed(code: Int, reason: String) { override fun closed(code: Int, reason: String) {
_isOpen = false; _isOpen = false;
if(hasClosed) { if(hasClosed && _listeners?.isClosed != true) {
try { try {
_listeners?.invokeVoid("closed", code, reason); _listeners?.invokeVoid("closed", code, reason);
} }
@ -631,11 +666,15 @@ class PackageHttp: V8Package {
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex); Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
} }
} }
Logger.w(TAG, "PackageHttp Socket removed");
synchronized(_package.aliveSockets) {
_package.aliveSockets.remove(this@SocketResult);
}
} }
override fun failure(exception: Throwable) { override fun failure(exception: Throwable) {
_isOpen = false; _isOpen = false;
Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception); Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception);
if(hasFailure) { if(hasFailure && _listeners?.isClosed != true) {
try { try {
_listeners?.invokeVoid("failure", exception.message); _listeners?.invokeVoid("failure", exception.message);
} }

View file

@ -5,6 +5,7 @@ import android.os.Bundle
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 android.widget.FrameLayout
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
@ -25,6 +26,7 @@ import com.futo.platformplayer.api.media.structures.MultiPager
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.exceptions.ChannelException
@ -32,9 +34,11 @@ import com.futo.platformplayer.fragment.mainactivity.main.FeedView
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.SearchView
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
@ -54,6 +58,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
private var _results: ArrayList<IPlatformContent> = arrayListOf(); private var _results: ArrayList<IPlatformContent> = arrayListOf();
private var _adapterResults: InsertedViewAdapterWithLoader<ContentPreviewViewHolder>? = null; private var _adapterResults: InsertedViewAdapterWithLoader<ContentPreviewViewHolder>? = null;
private var _lastPolycentricProfile: PolycentricProfile? = null; private var _lastPolycentricProfile: PolycentricProfile? = null;
private var _query: String? = null
private var _searchView: SearchView? = null
val onContentClicked = Event2<IPlatformContent, Long>(); val onContentClicked = Event2<IPlatformContent, Long>();
val onContentUrlClicked = Event2<String, ContentType>(); val onContentUrlClicked = Event2<String, ContentType>();
@ -68,16 +74,32 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> { private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
Logger.i(TAG, "getContentPager"); Logger.i(TAG, "getContentPager");
val lastPolycentricProfile = _lastPolycentricProfile; var pager: IPager<IPlatformContent>? = null
var pager: IPager<IPlatformContent>? = null; val query = _query
if (lastPolycentricProfile != null) if (!query.isNullOrBlank()) {
pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile); if(subType != null) {
Logger.i(TAG, "StatePlatform.instance.searchChannel(channel.url = ${channel.url}, query = ${query}, subType = ${subType})")
pager = StatePlatform.instance.searchChannel(channel.url, query, subType);
} else {
Logger.i(TAG, "StatePlatform.instance.searchChannel(channel.url = ${channel.url}, query = ${query})")
pager = StatePlatform.instance.searchChannel(channel.url, query);
}
} else {
val lastPolycentricProfile = _lastPolycentricProfile;
if (lastPolycentricProfile != null && StatePolycentric.instance.enabled) {
pager = StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType);
Logger.i(TAG, "StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = ${subType})")
}
if(pager == null) { if(pager == null) {
if(subType != null) if(subType != null) {
pager = StatePlatform.instance.getChannelContent(channel.url, subType); pager = StatePlatform.instance.getChannelContent(channel.url, subType);
else Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url}, subType = ${subType})")
pager = StatePlatform.instance.getChannelContent(channel.url); } else {
pager = StatePlatform.instance.getChannelContent(channel.url);
Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url})")
}
}
} }
return pager; return pager;
} }
@ -144,19 +166,49 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
_taskLoadVideos.cancel(); _taskLoadVideos.cancel();
_query = null
_channel = channel; _channel = channel;
updateSearchViewVisibility()
_results.clear(); _results.clear();
_adapterResults?.notifyDataSetChanged(); _adapterResults?.notifyDataSetChanged();
loadInitial(); loadInitial();
} }
private fun updateSearchViewVisibility() {
if (subType != null) {
_searchView?.visibility = View.GONE
return
}
val client = _channel?.id?.pluginId?.let { StatePlatform.instance.getClientOrNull(it) }
Logger.i(TAG, "_searchView.visible = ${client?.capabilities?.hasSearchChannelContents == true}")
_searchView?.visibility = if (client?.capabilities?.hasSearchChannelContents == true) View.VISIBLE else View.GONE
}
fun setQuery(query: String) {
_query = query
_taskLoadVideos.cancel()
_results.clear()
_adapterResults?.notifyDataSetChanged()
loadInitial()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_channel_videos, container, false); val view = inflater.inflate(R.layout.fragment_channel_videos, container, false);
_query = null
_recyclerResults = view.findViewById(R.id.recycler_videos); _recyclerResults = view.findViewById(R.id.recycler_videos);
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar).apply { val searchView = SearchView(requireContext()).apply { layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT) }.apply {
onEnter.subscribe {
setQuery(it)
}
}
_searchView = searchView
updateSearchViewVisibility()
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply {
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit); this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit); this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit); this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
@ -173,6 +225,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
_recyclerResults?.layoutManager = _glmVideo; _recyclerResults?.layoutManager = _glmVideo;
_recyclerResults?.addOnScrollListener(_scrollListener); _recyclerResults?.addOnScrollListener(_scrollListener);
return view; return view;
} }
@ -181,6 +234,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
_recyclerResults?.removeOnScrollListener(_scrollListener); _recyclerResults?.removeOnScrollListener(_scrollListener);
_recyclerResults = null; _recyclerResults = null;
_pager = null; _pager = null;
_query = null
_searchView = null
_taskLoadVideos.cancel(); _taskLoadVideos.cancel();
_nextPageHandler.cancel(); _nextPageHandler.cancel();
@ -303,6 +358,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
} }
private fun loadInitial() { private fun loadInitial() {
Logger.i(TAG, "loadInitial")
val channel: IPlatformChannel = _channel ?: return; val channel: IPlatformChannel = _channel ?: return;
setLoading(true); setLoading(true);
_taskLoadVideos.run(channel); _taskLoadVideos.run(channel);

View file

@ -331,7 +331,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
} }
if (!StatePayment.instance.hasPaid) { if (!StatePayment.instance.hasPaid) {
newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid_filled, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>() })) newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid_filled, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>(withHistory = false) }))
} }
//Add conditional buttons here, when you add a conditional button, be sure to add the register and unregister events for when the button needs to be updated //Add conditional buttons here, when you add a conditional button, be sure to add the register and unregister events for when the button needs to be updated
@ -385,19 +385,19 @@ class MenuBottomBarFragment : MainActivityFragment() {
currentMain.scrollToTop(false) currentMain.scrollToTop(false)
currentMain.reloadFeed() currentMain.reloadFeed()
} else { } else {
it.navigate<HomeFragment>() it.navigate<HomeFragment>(withHistory = false)
} }
}), }),
ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate<SubscriptionsFeedFragment>() }), ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate<SubscriptionsFeedFragment>(withHistory = false) }),
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>() }), ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>(withHistory = false) }),
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>() }), ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>(withHistory = false) }),
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>() }), ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>(withHistory = false) }),
ButtonDefinition(5, R.drawable.ic_smart_display, R.drawable.ic_smart_display_filled, R.string.shorts, canToggle = true, { it.currentMain is ShortsFragment }, { it.navigate<ShortsFragment>() }), ButtonDefinition(5, R.drawable.ic_smart_display, R.drawable.ic_smart_display_filled, R.string.shorts, canToggle = true, { it.currentMain is ShortsFragment }, { it.navigate<ShortsFragment>(withHistory = false) }),
ButtonDefinition(6, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>() }), ButtonDefinition(6, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>(withHistory = false) }),
ButtonDefinition(7, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>() }), ButtonDefinition(7, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>(withHistory = false) }),
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>() }), ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>() }), ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>() }), ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
ButtonDefinition(11, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, { ButtonDefinition(11, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
val c = it.context ?: return@ButtonDefinition; val c = it.context ?: return@ButtonDefinition;
Logger.i(TAG, "settings preventPictureInPicture()"); Logger.i(TAG, "settings preventPictureInPicture()");
@ -419,7 +419,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
}, UIDialogs.ActionStyle.PRIMARY)); }, UIDialogs.ActionStyle.PRIMARY));
}), }),
ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, { ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
it.navigate<BrowserFragment>(Settings.URL_FAQ); it.navigate<BrowserFragment>(Settings.URL_FAQ, withHistory = false);
}) })
//96 is reserved for privacy button //96 is reserved for privacy button
//98 is reserved for buy button //98 is reserved for buy button

View file

@ -0,0 +1,814 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.Animatable
import android.os.Bundle
import android.text.Html
import android.text.method.ScrollingMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewPropertyAnimator
import android.widget.Button
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.children
import androidx.core.view.isVisible
import androidx.core.view.setPadding
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails
import com.futo.platformplayer.api.media.models.post.TextType
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.models.JSArticleDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSHeaderSegment
import com.futo.platformplayer.api.media.platforms.js.models.JSImagesSegment
import com.futo.platformplayer.api.media.platforms.js.models.JSNestedSegment
import com.futo.platformplayer.api.media.platforms.js.models.JSTextSegment
import com.futo.platformplayer.api.media.platforms.js.models.SegmentType
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.fixHtmlWhitespace
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
import com.futo.platformplayer.sp
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.feedtypes.PreviewLockedView
import com.futo.platformplayer.views.adapters.feedtypes.PreviewNestedVideoView
import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView
import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoView
import com.futo.platformplayer.views.comments.AddCommentView
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.segments.CommentsList
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models
import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import com.google.android.flexbox.FlexboxLayout
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.protobuf.ByteString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import userpackage.Protocol
import java.lang.Integer.min
class ArticleDetailFragment : MainFragment {
override val isMainView: Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _viewDetail: ArticleDetailView? = null;
constructor() : super() { }
override fun onBackPressed(): Boolean {
return false;
}
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = ArticleDetailView(inflater.context).applyFragment(this);
_viewDetail = view;
return view;
}
override fun onDestroyMainView() {
super.onDestroyMainView();
_viewDetail?.onDestroy();
_viewDetail = null;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
if (parameter is IPlatformArticleDetails) {
_viewDetail?.clear();
_viewDetail?.setArticleDetails(parameter);
} else if (parameter is IPlatformArticle) {
_viewDetail?.setArticleOverview(parameter);
} else if(parameter is String) {
_viewDetail?.setPostUrl(parameter);
}
}
private class ArticleDetailView : ConstraintLayout {
private lateinit var _fragment: ArticleDetailFragment;
private var _url: String? = null;
private var _isLoading = false;
private var _article: IPlatformArticleDetails? = null;
private var _articleOverview: IPlatformArticle? = null;
private var _polycentricProfile: PolycentricProfile? = null;
private var _version = 0;
private var _isRepliesVisible: Boolean = false;
private var _repliesAnimator: ViewPropertyAnimator? = null;
private val _creatorThumbnail: CreatorThumbnail;
private val _buttonSubscribe: SubscribeButton;
private val _channelName: TextView;
private val _channelMeta: TextView;
private val _textTitle: TextView;
private val _textMeta: TextView;
private val _textSummary: TextView;
private val _containerSegments: LinearLayout;
private val _platformIndicator: PlatformIndicator;
private val _buttonShare: ImageButton;
private val _layoutRating: LinearLayout;
private val _imageLikeIcon: ImageView;
private val _textLikes: TextView;
private val _imageDislikeIcon: ImageView;
private val _textDislikes: TextView;
private val _addCommentView: AddCommentView;
private val _rating: PillRatingLikesDislikes;
private val _layoutLoadingOverlay: FrameLayout;
private val _imageLoader: ImageView;
private var _overlayContainer: FrameLayout
private val _repliesOverlay: RepliesOverlay;
private val _commentsList: CommentsList;
private var _commentType: Boolean? = null;
private val _buttonPolycentric: Button
private val _buttonPlatform: Button
private val _taskLoadPost = if(!isInEditMode) TaskHandler<String, IPlatformArticleDetails>(
StateApp.instance.scopeGetter,
{
val result = StatePlatform.instance.getContentDetails(it).await();
if(result !is IPlatformArticleDetails)
throw IllegalStateException(context.getString(R.string.expected_media_content_found) + " ${result.contentType}");
return@TaskHandler result;
})
.success { setArticleDetails(it) }
.exception<Throwable> {
Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, {
if (!StatePolycentric.instance.enabled)
return@TaskHandler null
ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!)
})
.success { it -> setPolycentricProfile(it, animate = true) }
.exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it);
};
constructor(context: Context) : super(context) {
inflate(context, R.layout.fragview_article_detail, this);
val root = findViewById<FrameLayout>(R.id.root);
_creatorThumbnail = findViewById(R.id.creator_thumbnail);
_buttonSubscribe = findViewById(R.id.button_subscribe);
_channelName = findViewById(R.id.text_channel_name);
_channelMeta = findViewById(R.id.text_channel_meta);
_textTitle = findViewById(R.id.text_title);
_textMeta = findViewById(R.id.text_meta);
_textSummary = findViewById(R.id.text_summary);
_containerSegments = findViewById(R.id.container_segments);
_platformIndicator = findViewById(R.id.platform_indicator);
_buttonShare = findViewById(R.id.button_share);
_overlayContainer = findViewById(R.id.overlay_container);
_layoutRating = findViewById(R.id.layout_rating);
_imageLikeIcon = findViewById(R.id.image_like_icon);
_textLikes = findViewById(R.id.text_likes);
_imageDislikeIcon = findViewById(R.id.image_dislike_icon);
_textDislikes = findViewById(R.id.text_dislikes);
_commentsList = findViewById(R.id.comments_list);
_addCommentView = findViewById(R.id.add_comment_view);
_rating = findViewById(R.id.rating);
_layoutLoadingOverlay = findViewById(R.id.layout_loading_overlay);
_imageLoader = findViewById(R.id.image_loader);
_repliesOverlay = findViewById(R.id.replies_overlay);
_buttonPolycentric = findViewById(R.id.button_polycentric)
_buttonPlatform = findViewById(R.id.button_platform)
_buttonSubscribe.onSubscribed.subscribe {
//TODO: add overlay to layout
//UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
};
val layoutTop: LinearLayout = findViewById(R.id.layout_top);
root.removeView(layoutTop);
_commentsList.setPrependedView(layoutTop);
/*TODO: Why is this here?
_commentsList.onCommentsLoaded.subscribe {
updateCommentType(false);
};*/
_commentsList.onRepliesClick.subscribe { c ->
val replyCount = c.replyCount ?: 0;
var metadata = "";
if (replyCount > 0) {
metadata += "$replyCount " + context.getString(R.string.replies);
}
if (c is PolycentricPlatformComment) {
var parentComment: PolycentricPlatformComment = c;
_repliesOverlay.load(_commentType!!, metadata, c.contextUrl, c.reference, c,
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
{
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
_commentsList.replaceComment(parentComment, newComment);
parentComment = newComment;
});
} else {
_repliesOverlay.load(_commentType!!, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
}
setRepliesOverlayVisible(isVisible = true, animate = true);
};
if (StatePolycentric.instance.enabled) {
_buttonPolycentric.setOnClickListener {
updateCommentType(false)
}
} else {
_buttonPolycentric.visibility = View.GONE
}
_buttonPlatform.setOnClickListener {
updateCommentType(true)
}
_addCommentView.onCommentAdded.subscribe {
_commentsList.addComment(it);
};
_repliesOverlay.onClose.subscribe { setRepliesOverlayVisible(isVisible = false, animate = true); };
_buttonShare.setOnClickListener { share() };
_creatorThumbnail.onClick.subscribe { openChannel() };
_channelName.setOnClickListener { openChannel() };
_channelMeta.setOnClickListener { openChannel() };
}
private fun openChannel() {
val author = _article?.author ?: _articleOverview?.author ?: return;
_fragment.navigate<ChannelFragment>(author);
}
private fun share() {
try {
Logger.i(PreviewPostView.TAG, "sharePost")
val url = _article?.shareUrl ?: _articleOverview?.shareUrl ?: _url;
_fragment.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND;
putExtra(Intent.EXTRA_TEXT, url);
type = "text/plain"; //TODO: Determine alt types?
}, null));
} catch (e: Throwable) {
//Ignored
Logger.e(PreviewPostView.TAG, "Failed to share.", e);
}
}
private fun updatePolycentricRating() {
_rating.visibility = View.GONE;
val ref = Models.referenceFromBuffer((_article?.url ?: _articleOverview?.url)?.toByteArray() ?: return)
val extraBytesRef = (_article?.id?.value ?: _articleOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null }
val version = _version;
_rating.onLikeDislikeUpdated.remove(this);
if (!StatePolycentric.instance.enabled)
return
_fragment.lifecycleScope.launch(Dispatchers.IO) {
if (version != _version) {
return@launch;
}
try {
val queryReferencesResponse = ApiMethods.getQueryReferences(ApiMethods.SERVER, ref, null,null,
arrayListOf(
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(
ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.like.data)).build(),
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(
ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.dislike.data)).build()
),
extraByteReferences = listOfNotNull(extraBytesRef)
);
if (version != _version) {
return@launch;
}
val likes = queryReferencesResponse.countsList[0];
val dislikes = queryReferencesResponse.countsList[1];
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
withContext(Dispatchers.Main) {
if (version != _version) {
return@withContext;
}
_rating.visibility = VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) {
args.processHandle.opinion(ref, Opinion.dislike);
} else {
args.processHandle.opinion(ref, Opinion.neutral);
}
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
}
}
StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked)
};
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
_rating.visibility = View.GONE;
}
}
}
private fun setPlatformRating(rating: IRating?) {
if (rating == null) {
_layoutRating.visibility = View.GONE;
return;
}
_layoutRating.visibility = View.VISIBLE;
when (rating) {
is RatingLikeDislikes -> {
_textLikes.visibility = View.VISIBLE;
_imageLikeIcon.visibility = View.VISIBLE;
_textLikes.text = rating.likes.toHumanNumber();
_imageDislikeIcon.visibility = View.VISIBLE;
_textDislikes.visibility = View.VISIBLE;
_textDislikes.text = rating.dislikes.toHumanNumber();
}
is RatingLikes -> {
_textLikes.visibility = View.VISIBLE;
_imageLikeIcon.visibility = View.VISIBLE;
_textLikes.text = rating.likes.toHumanNumber();
_imageDislikeIcon.visibility = View.GONE;
_textDislikes.visibility = View.GONE;
}
else -> {
_textLikes.visibility = View.GONE;
_imageLikeIcon.visibility = View.GONE;
_imageDislikeIcon.visibility = View.GONE;
_textDislikes.visibility = View.GONE;
}
}
}
fun applyFragment(frag: ArticleDetailFragment): ArticleDetailView {
_fragment = frag;
return this;
}
fun clear() {
_commentsList.cancel();
_taskLoadPost.cancel();
_taskLoadPolycentricProfile.cancel();
_version++;
updateCommentType(null)
_url = null;
_article = null;
_articleOverview = null;
_creatorThumbnail.clear();
//_buttonSubscribe.setSubscribeChannel(null); TODO: clear button
_channelName.text = "";
setChannelMeta(null);
_textTitle.text = "";
_textMeta.text = "";
setPlatformRating(null);
_polycentricProfile = null;
_rating.visibility = View.GONE;
updatePolycentricRating();
setRepliesOverlayVisible(isVisible = false, animate = false);
_containerSegments.removeAllViews();
_addCommentView.setContext(null, null);
_platformIndicator.clearPlatform();
}
fun setArticleDetails(value: IPlatformArticleDetails) {
_url = value.url;
_article = value;
_creatorThumbnail.setThumbnail(value.author.thumbnail, false);
_buttonSubscribe.setSubscribeChannel(value.author.url);
_channelName.text = value.author.name;
setChannelMeta(value);
_textTitle.text = value.name;
_textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count?
_textSummary.text = value.summary
_textSummary.isVisible = !value.summary.isNullOrEmpty()
_platformIndicator.setPlatformFromClientID(value.id.pluginId);
setPlatformRating(value.rating);
for(seg in value.segments) {
when(seg.type) {
SegmentType.HEADER -> {
if(seg is JSHeaderSegment) {
_containerSegments.addView(ArticleHeaderBlock(context, seg.content, seg.level))
}
}
SegmentType.TEXT -> {
if(seg is JSTextSegment) {
_containerSegments.addView(ArticleTextBlock(context, seg.content, seg.textType))
}
}
SegmentType.IMAGES -> {
if(seg is JSImagesSegment) {
if(seg.images.size > 0)
_containerSegments.addView(ArticleImageBlock(context, seg.images[0], seg.caption))
}
}
SegmentType.NESTED -> {
if(seg is JSNestedSegment) {
_containerSegments.addView(ArticleContentBlock(context, seg.nested, _fragment, _overlayContainer));
}
}
else ->{}
}
}
//Fetch only when not already called in setPostOverview
if (_articleOverview == null) {
fetchPolycentricProfile();
updatePolycentricRating();
_addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray()));
}
val commentType = !Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1
updateCommentType(commentType, true);
setLoading(false);
}
fun setArticleOverview(value: IPlatformArticle) {
clear();
_url = value.url;
_articleOverview = value;
_creatorThumbnail.setThumbnail(value.author.thumbnail, false);
_buttonSubscribe.setSubscribeChannel(value.author.url);
_channelName.text = value.author.name;
setChannelMeta(value);
_textTitle.text = value.name;
_textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count?
_platformIndicator.setPlatformFromClientID(value.id.pluginId);
_addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray()));
updatePolycentricRating();
fetchPolycentricProfile();
fetchPost();
}
private fun setRepliesOverlayVisible(isVisible: Boolean, animate: Boolean) {
if (_isRepliesVisible == isVisible) {
return;
}
_isRepliesVisible = isVisible;
_repliesAnimator?.cancel();
if (isVisible) {
_repliesOverlay.visibility = View.VISIBLE;
if (animate) {
_repliesOverlay.translationY = _repliesOverlay.height.toFloat();
_repliesAnimator = _repliesOverlay.animate()
.setDuration(300)
.translationY(0f)
.withEndAction {
_repliesAnimator = null;
}.apply { start() };
}
} else {
if (animate) {
_repliesOverlay.translationY = 0f;
_repliesAnimator = _repliesOverlay.animate()
.setDuration(300)
.translationY(_repliesOverlay.height.toFloat())
.withEndAction {
_repliesOverlay.visibility = GONE;
_repliesAnimator = null;
}.apply { start(); }
} else {
_repliesOverlay.visibility = View.GONE;
_repliesOverlay.translationY = _repliesOverlay.height.toFloat();
}
}
}
private fun fetchPolycentricProfile() {
val author = _article?.author ?: _articleOverview?.author ?: return;
setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(author.id);
}
private fun setChannelMeta(value: IPlatformArticle?) {
val subscribers = value?.author?.subscribers;
if(subscribers != null && subscribers > 0) {
_channelMeta.visibility = View.VISIBLE;
_channelMeta.text = if((value.author.subscribers ?: 0) > 0) value.author.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else "";
} else {
_channelMeta.visibility = View.GONE;
_channelMeta.text = "";
}
}
fun setPostUrl(url: String) {
clear();
_url = url;
fetchPost();
}
fun onDestroy() {
_commentsList.cancel();
_taskLoadPost.cancel();
_repliesOverlay.cleanup();
}
private fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) {
_polycentricProfile = polycentricProfile;
val pp = _polycentricProfile;
if (pp == null) {
_creatorThumbnail.setHarborAvailable(false, animate, null);
return;
}
_creatorThumbnail.setHarborAvailable(true, animate, pp.system.toProto());
}
private fun fetchPost() {
Logger.i(TAG, "fetchVideo")
_article = null;
val url = _url;
if (!url.isNullOrBlank()) {
setLoading(true);
_taskLoadPost.run(url);
}
}
private fun fetchComments() {
Logger.i(TAG, "fetchComments")
_article?.let {
_commentsList.load(true) { StatePlatform.instance.getComments(it); };
}
}
private fun fetchPolycentricComments() {
Logger.i(TAG, "fetchPolycentricComments")
val post = _article;
val ref = (_article?.url ?: _articleOverview?.url)?.toByteArray()?.let { Models.referenceFromBuffer(it) }
val extraBytesRef = (_article?.id?.value ?: _articleOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null }
if (ref == null) {
Logger.w(TAG, "Failed to fetch polycentric comments because url was not set null")
_commentsList.clear();
return
}
_commentsList.load(false) { StatePolycentric.instance.getCommentPager(post!!.url, ref, listOfNotNull(extraBytesRef)); };
}
private fun updateCommentType(commentType: Boolean?, forceReload: Boolean = false) {
val changed = commentType != _commentType
_commentType = commentType
if (commentType == null) {
_buttonPlatform.setTextColor(resources.getColor(R.color.gray_ac))
_buttonPolycentric.setTextColor(resources.getColor(R.color.gray_ac))
} else {
_buttonPlatform.setTextColor(resources.getColor(if (commentType) R.color.white else R.color.gray_ac))
_buttonPolycentric.setTextColor(resources.getColor(if (!commentType) R.color.white else R.color.gray_ac))
if (commentType) {
_addCommentView.visibility = View.GONE;
if (forceReload || changed) {
fetchComments();
}
} else {
_addCommentView.visibility = View.VISIBLE;
if (forceReload || changed) {
fetchPolycentricComments()
}
}
}
}
private fun setLoading(isLoading : Boolean) {
if (_isLoading == isLoading) {
return;
}
_isLoading = isLoading;
if(isLoading) {
(_imageLoader.drawable as Animatable?)?.start()
_layoutLoadingOverlay.visibility = View.VISIBLE;
}
else {
_layoutLoadingOverlay.visibility = View.GONE;
(_imageLoader.drawable as Animatable?)?.stop()
}
}
class ArticleHeaderBlock : LinearLayout {
constructor(context: Context?, content: String, level: Int) : super(context){
inflate(context, R.layout.view_segment_text, this);
findViewById<TextView>(R.id.text_content)?.let {
it.text = content;
val sp = when(level) {
1 -> 6.sp(resources);
2 -> 8.sp(resources);
3 -> 10.sp(resources);
4 -> 12.sp(resources);
5 -> 14.sp(resources);
else -> 6.sp(resources);
}
it.setTextColor(Color.WHITE);
it.setTypeface(Typeface.create(null, 600, false));
it.textSize = sp.toFloat();
}
}
}
class ArticleTextBlock : LinearLayout {
constructor(context: Context?, content: String, textType: TextType) : super(context){
inflate(context, R.layout.view_segment_text, this);
findViewById<TextView>(R.id.text_content)?.let {
if(textType == TextType.HTML)
it.text = Html.fromHtml(content, Html.FROM_HTML_MODE_COMPACT);
else if(textType == TextType.CODE) {
it.text = content;
it.setPadding(15.dp(resources));
it.setHorizontallyScrolling(true);
it.movementMethod = ScrollingMovementMethod();
it.setTypeface(Typeface.MONOSPACE);
it.setBackgroundResource(R.drawable.background_videodetail_description)
}
else
it.text = content;
}
}
}
class ArticleImageBlock: LinearLayout {
constructor(context: Context?, image: String, caption: String? = null) : super(context){
inflate(context, R.layout.view_segment_image, this);
findViewById<ImageView>(R.id.image_content)?.let {
Glide.with(it)
.load(image)
.crossfade()
.into(it);
}
findViewById<TextView>(R.id.text_content)?.let {
if(caption?.isNullOrEmpty() == true)
it.isVisible = false;
else
it.text = caption;
}
}
}
class ArticleContentBlock: LinearLayout {
constructor(context: Context, content: IPlatformContent?, fragment: ArticleDetailFragment? = null, overlayContainer: FrameLayout? = null): super(context) {
if(content != null) {
var view: View? = null;
if(content is IPlatformNestedContent) {
view = PreviewNestedVideoView(context, FeedStyle.THUMBNAIL, null);
view.bind(content);
view.onContentUrlClicked.subscribe { a,b -> }
}
else if(content is IPlatformVideo) {
view = PreviewVideoView(context, FeedStyle.THUMBNAIL, null, true);
view.bind(content);
view.onVideoClicked.subscribe { a,b -> fragment?.navigate<VideoDetailFragment>(a) }
view.onChannelClicked.subscribe { a -> fragment?.navigate<ChannelFragment>(a) }
if(overlayContainer != null) {
view.onAddToClicked.subscribe { a -> UISlideOverlays.showVideoOptionsOverlay(a, overlayContainer) };
}
view.onAddToQueueClicked.subscribe { a -> StatePlayer.instance.addToQueue(a) }
view.onAddToWatchLaterClicked.subscribe { a ->
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true))
UIDialogs.toast("Added to watch later\n[${content.name}]")
}
}
else if(content is IPlatformPost) {
view = PreviewPostView(context, FeedStyle.THUMBNAIL);
view.bind(content);
view.onContentClicked.subscribe { a -> fragment?.navigate<PostDetailFragment>(a) }
view.onChannelClicked.subscribe { a -> fragment?.navigate<ChannelFragment>(a) }
}
else if(content is IPlatformArticle) {
view = PreviewPostView(context, FeedStyle.THUMBNAIL);
view.bind(content);
view.onContentClicked.subscribe { a -> fragment?.navigate<ArticleDetailFragment>(a) }
view.onChannelClicked.subscribe { a -> fragment?.navigate<ChannelFragment>(a) }
}
else if(content is IPlatformLockedContent) {
view = PreviewLockedView(context, FeedStyle.THUMBNAIL);
view.bind(content);
}
if(view != null)
addView(view);
}
}
}
companion object {
const val TAG = "PostDetailFragment"
}
}
companion object {
fun newInstance() = ArticleDetailFragment().apply {}
}
}

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)
@ -422,17 +425,15 @@ class ChannelFragment : MainFragment() {
_fragment.lifecycleScope.launch(Dispatchers.IO) { _fragment.lifecycleScope.launch(Dispatchers.IO) {
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url) val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (plugin != null && plugin.capabilities.hasSearchChannelContents) { buttons.add(Pair(R.drawable.ic_search) {
buttons.add(Pair(R.drawable.ic_search) { _fragment.navigate<SuggestionsFragment>(
_fragment.navigate<SuggestionsFragment>( SuggestionsFragmentData(
SuggestionsFragmentData( "", SearchType.VIDEO
"", SearchType.VIDEO, channel.url
)
) )
}) )
})
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
}
if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) { if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) {
if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false && if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false &&
!(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) { !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) {

View file

@ -10,12 +10,14 @@ 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.api.media.models.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.post.IPlatformPost import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StateMeta
@ -196,16 +198,24 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
fragment.navigate<RemotePlaylistFragment>(content); fragment.navigate<RemotePlaylistFragment>(content);
} else if (content is IPlatformPost) { } else if (content is IPlatformPost) {
fragment.navigate<PostDetailFragment>(content); fragment.navigate<PostDetailFragment>(content);
} else if(content is IPlatformArticle) {
fragment.navigate<ArticleDetailFragment>(content);
} }
else if(content is JSWeb) {
fragment.navigate<WebDetailFragment>(content);
}
else
UIDialogs.appToast("Unknown content type [" + content.contentType.name + "]");
} }
protected open fun onContentUrlClicked(url: String, contentType: ContentType) { protected open fun onContentUrlClicked(url: String, contentType: ContentType) {
when(contentType) { when(contentType) {
ContentType.MEDIA -> { ContentType.MEDIA -> {
StatePlayer.instance.clearQueue(); StatePlayer.instance.clearQueue()
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail(); fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
}; }
ContentType.PLAYLIST -> fragment.navigate<RemotePlaylistFragment>(url); ContentType.PLAYLIST -> fragment.navigate<RemotePlaylistFragment>(url)
ContentType.URL -> fragment.navigate<BrowserFragment>(url); ContentType.URL -> fragment.navigate<BrowserFragment>(url)
ContentType.CHANNEL -> fragment.navigate<ChannelFragment>(url)
else -> {}; else -> {};
} }
} }

View file

@ -2,15 +2,18 @@ package com.futo.platformplayer.fragment.mainactivity.main
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.allViews
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
@ -18,9 +21,12 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.isHttpUrl import com.futo.platformplayer.isHttpUrl
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.ToggleBar
import com.futo.platformplayer.views.others.RadioGroupView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -83,7 +89,7 @@ class ContentSearchResultsFragment : MainFragment() {
private var _sortBy: String? = null; private var _sortBy: String? = null;
private var _filterValues: HashMap<String, List<String>> = hashMapOf(); private var _filterValues: HashMap<String, List<String>> = hashMapOf();
private var _enabledClientIds: List<String>? = null; private var _enabledClientIds: List<String>? = null;
private var _channelUrl: String? = null; private var _searchType: SearchType? = null;
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>; private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar
@ -91,11 +97,12 @@ class ContentSearchResultsFragment : MainFragment() {
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) { constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query -> _taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query ->
Logger.i(TAG, "Searching for: $query") Logger.i(TAG, "Searching for: $query")
val channelUrl = _channelUrl; when (_searchType)
if (channelUrl != null) { {
StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds) SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
} else { SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query)
StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds) SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query)
else -> throw Exception("Search type must be specified")
} }
}) })
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { } .success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
@ -105,6 +112,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() {
@ -115,7 +141,7 @@ class ContentSearchResultsFragment : MainFragment() {
fun onShown(parameter: Any?) { fun onShown(parameter: Any?) {
if(parameter is SuggestionsFragmentData) { if(parameter is SuggestionsFragmentData) {
setQuery(parameter.query, false); setQuery(parameter.query, false);
setChannelUrl(parameter.channelUrl, false); setSearchType(parameter.searchType, false)
fragment.topBar?.apply { fragment.topBar?.apply {
if (this is SearchTopBarFragment) { if (this is SearchTopBarFragment) {
@ -131,7 +157,7 @@ class ContentSearchResultsFragment : MainFragment() {
onFilterClick.subscribe(this) { onFilterClick.subscribe(this) {
_overlayContainer.let { _overlayContainer.let {
val filterValuesCopy = HashMap(_filterValues); val filterValuesCopy = HashMap(_filterValues);
val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy, _channelUrl != null); val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy);
filtersOverlay.onOK.subscribe { enabledClientIds, changed -> filtersOverlay.onOK.subscribe { enabledClientIds, changed ->
if (changed) { if (changed) {
setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy); setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy);
@ -178,11 +204,7 @@ class ContentSearchResultsFragment : MainFragment() {
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val commonCapabilities = val commonCapabilities = StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
if(_channelUrl == null)
StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
else
StatePlatform.instance.getCommonSearchChannelContentsCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
val sorts = commonCapabilities?.sorts ?: listOf(); val sorts = commonCapabilities?.sorts ?: listOf();
if (sorts.size > 1) { if (sorts.size > 1) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -249,8 +271,8 @@ class ContentSearchResultsFragment : MainFragment() {
} }
} }
private fun setChannelUrl(channelUrl: String?, updateResults: Boolean = true) { private fun setSearchType(searchType: SearchType, updateResults: Boolean = true) {
_channelUrl = channelUrl; _searchType = searchType
if (updateResults) { if (updateResults) {
clearResults(); clearResults();

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

@ -136,7 +136,6 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_toolbarContentView = findViewById(R.id.container_toolbar_content); _toolbarContentView = findViewById(R.id.container_toolbar_content);
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, { _nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
if (it is IAsyncPager<*>) if (it is IAsyncPager<*>)
it.nextPageAsync(); it.nextPageAsync();
else else

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

@ -217,7 +217,7 @@ class PlaylistsFragment : MainFragment() {
var playlistsToReturn = pls; var playlistsToReturn = pls;
if(!_listPlaylistsSearch.text.isNullOrEmpty()) if(!_listPlaylistsSearch.text.isNullOrEmpty())
playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) }; playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) };
if(!_ordering.value.isNullOrEmpty()){ if(!_ordering.value.isNullOrEmpty()) {
playlistsToReturn = when(_ordering.value){ playlistsToReturn = when(_ordering.value){
"nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() } "nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() }
"nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() }; "nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() };

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

@ -290,8 +290,8 @@ class SubscriptionGroupFragment : MainFragment() {
image.setImageView(_imageGroup); image.setImageView(_imageGroup);
} }
else { else {
_imageGroupBackground.setImageResource(0); _imageGroupBackground.setImageDrawable(null);
_imageGroup.setImageResource(0); _imageGroup.setImageDrawable(null);
} }
updateMeta(); updateMeta();
reloadCreators(group); reloadCreators(group);

View file

@ -18,8 +18,10 @@ import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.SearchHistoryStorage import com.futo.platformplayer.stores.SearchHistoryStorage
import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter
import com.futo.platformplayer.views.others.RadioGroupView
import com.futo.platformplayer.views.others.TagsView
data class SuggestionsFragmentData(val query: String, val searchType: SearchType, val channelUrl: String? = null); data class SuggestionsFragmentData(val query: String, val searchType: SearchType);
class SuggestionsFragment : MainFragment { class SuggestionsFragment : MainFragment {
override val isMainView : Boolean = true; override val isMainView : Boolean = true;
@ -28,10 +30,10 @@ class SuggestionsFragment : MainFragment {
private var _recyclerSuggestions: RecyclerView? = null; private var _recyclerSuggestions: RecyclerView? = null;
private var _llmSuggestions: LinearLayoutManager? = null; private var _llmSuggestions: LinearLayoutManager? = null;
private var _radioGroupView: RadioGroupView? = null;
private val _suggestions: ArrayList<String> = ArrayList(); private val _suggestions: ArrayList<String> = ArrayList();
private var _query: String? = null; private var _query: String? = null;
private var _searchType: SearchType = SearchType.VIDEO; private var _searchType: SearchType = SearchType.VIDEO;
private var _channelUrl: String? = null;
private val _adapterSuggestions = SearchSuggestionAdapter(_suggestions); private val _adapterSuggestions = SearchSuggestionAdapter(_suggestions);
@ -49,14 +51,7 @@ class SuggestionsFragment : MainFragment {
_adapterSuggestions.onClicked.subscribe { suggestion -> _adapterSuggestions.onClicked.subscribe { suggestion ->
val storage = FragmentedStorage.get<SearchHistoryStorage>(); val storage = FragmentedStorage.get<SearchHistoryStorage>();
storage.add(suggestion); storage.add(suggestion);
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType));
if (_searchType == SearchType.CREATOR) {
navigate<CreatorSearchResultsFragment>(suggestion);
} else if (_searchType == SearchType.PLAYLIST) {
navigate<PlaylistSearchResultsFragment>(suggestion);
} else {
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, SearchType.VIDEO, _channelUrl));
}
} }
_adapterSuggestions.onRemove.subscribe { suggestion -> _adapterSuggestions.onRemove.subscribe { suggestion ->
val index = _suggestions.indexOf(suggestion); val index = _suggestions.indexOf(suggestion);
@ -80,6 +75,15 @@ class SuggestionsFragment : MainFragment {
recyclerSuggestions.adapter = _adapterSuggestions; recyclerSuggestions.adapter = _adapterSuggestions;
_recyclerSuggestions = recyclerSuggestions; _recyclerSuggestions = recyclerSuggestions;
_radioGroupView = view.findViewById<RadioGroupView>(R.id.radio_group).apply {
onSelectedChange.subscribe {
if (it.size != 1)
_searchType = SearchType.VIDEO
else
_searchType = (it[0] ?: SearchType.VIDEO) as SearchType
}
}
loadSuggestions(); loadSuggestions();
return view; return view;
} }
@ -104,37 +108,31 @@ class SuggestionsFragment : MainFragment {
if (parameter is SuggestionsFragmentData) { if (parameter is SuggestionsFragmentData) {
_searchType = parameter.searchType; _searchType = parameter.searchType;
_channelUrl = parameter.channelUrl;
} else if (parameter is SearchType) { } else if (parameter is SearchType) {
_searchType = parameter; _searchType = parameter;
_channelUrl = null;
} }
_radioGroupView?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true)
topBar?.apply { topBar?.apply {
if (this is SearchTopBarFragment) { if (this is SearchTopBarFragment) {
onSearch.subscribe(this) { onSearch.subscribe(this) {
if (_searchType == SearchType.CREATOR) { if(it.isHttpUrl()) {
navigate<CreatorSearchResultsFragment>(it); if(StatePlatform.instance.hasEnabledPlaylistClient(it))
} else if (_searchType == SearchType.PLAYLIST) { navigate<RemotePlaylistFragment>(it);
navigate<PlaylistSearchResultsFragment>(it); else if(StatePlatform.instance.hasEnabledChannelClient(it))
} else { navigate<ChannelFragment>(it);
if(it.isHttpUrl()) { else {
if(StatePlatform.instance.hasEnabledPlaylistClient(it)) val url = it;
navigate<RemotePlaylistFragment>(it); activity?.let {
else if(StatePlatform.instance.hasEnabledChannelClient(it)) close()
navigate<ChannelFragment>(it); if(it is MainActivity)
else { it.navigate(it.getFragment<VideoDetailFragment>(), url);
val url = it;
activity?.let {
close()
if(it is MainActivity)
it.navigate(it.getFragment<VideoDetailFragment>(), url);
}
} }
} }
else
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
} }
else
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType));
}; };
onTextChange.subscribe(this) { onTextChange.subscribe(this) {
@ -196,6 +194,7 @@ class SuggestionsFragment : MainFragment {
super.onDestroyMainView(); super.onDestroyMainView();
_getSuggestions.onError.clear(); _getSuggestions.onError.clear();
_recyclerSuggestions = null; _recyclerSuggestions = null;
_radioGroupView = null
} }
override fun onDestroy() { override fun onDestroy() {

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) };
} }
@ -619,6 +619,7 @@ class VideoDetailView : ConstraintLayout {
loadCurrentVideo(lastPositionMilliseconds); loadCurrentVideo(lastPositionMilliseconds);
updatePillButtonVisibilities(); updatePillButtonVisibilities();
setCastEnabled(false); setCastEnabled(false);
} }
else -> {} else -> {}
} }
@ -647,6 +648,15 @@ class VideoDetailView : ConstraintLayout {
_timeBar.setDuration(video?.duration ?: 0); _timeBar.setDuration(video?.duration ?: 0);
} }
}; };
_cast.onTimeJobTimeChanged_s.subscribe {
if (_isCasting) {
setLastPositionMilliseconds((it * 1000.0).toLong(), true);
_timeBar.setPosition(it);
_timeBar.setBufferedPosition(0);
_timeBar.setDuration(video?.duration ?: 0);
}
}
} }
_playerProgress.player = _player.exoPlayer?.player; _playerProgress.player = _player.exoPlayer?.player;
@ -688,6 +698,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); };
@ -767,6 +791,7 @@ class VideoDetailView : ConstraintLayout {
_lastAudioSource = null; _lastAudioSource = null;
_lastSubtitleSource = null; _lastSubtitleSource = null;
video = null; video = null;
_container_content_liveChat?.close();
_player.clear(); _player.clear();
cleanupPlaybackTracker(); cleanupPlaybackTracker();
Logger.i(TAG, "Keep screen on unset onClose") Logger.i(TAG, "Keep screen on unset onClose")
@ -1141,6 +1166,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,60 +1536,68 @@ class VideoDetailView : ConstraintLayout {
_rating.visibility = View.GONE; _rating.visibility = View.GONE;
fragment.lifecycleScope.launch(Dispatchers.IO) { if (StatePolycentric.instance.enabled) {
try { fragment.lifecycleScope.launch(Dispatchers.IO) {
val queryReferencesResponse = ApiMethods.getQueryReferences( try {
ApiMethods.SERVER, ref, null, null, val queryReferencesResponse = ApiMethods.getQueryReferences(
arrayListOf( ApiMethods.SERVER, ref, null, null,
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() arrayListOf(
.setFromType(ContentType.OPINION.value).setValue( Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
ByteString.copyFrom(Opinion.like.data) .setFromType(ContentType.OPINION.value).setValue(
).build(), ByteString.copyFrom(Opinion.like.data)
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() ).build(),
.setFromType(ContentType.OPINION.value).setValue( Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
ByteString.copyFrom(Opinion.dislike.data) .setFromType(ContentType.OPINION.value).setValue(
).build() ByteString.copyFrom(Opinion.dislike.data)
), ).build()
extraByteReferences = listOfNotNull(extraBytesRef) ),
); extraByteReferences = listOfNotNull(extraBytesRef)
);
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(
_rating.onLikeDislikeUpdated.subscribe(this) { args -> RatingLikeDislikes(likes, dislikes),
if (args.hasLiked) { hasLiked,
args.processHandle.opinion(ref, Opinion.like); hasDisliked
} else if (args.hasDisliked) { );
args.processHandle.opinion(ref, Opinion.dislike); _rating.onLikeDislikeUpdated.subscribe(this) { args ->
} else { if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.neutral); args.processHandle.opinion(ref, Opinion.like);
} } else if (args.hasDisliked) {
args.processHandle.opinion(ref, Opinion.dislike);
fragment.lifecycleScope.launch(Dispatchers.IO) { } else {
try { args.processHandle.opinion(ref, Opinion.neutral);
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
} }
}
StatePolycentric.instance.updateLikeMap( fragment.lifecycleScope.launch(Dispatchers.IO) {
ref, try {
args.hasLiked, Logger.i(TAG, "Started backfill");
args.hasDisliked args.processHandle.fullyBackfillServersAnnounceExceptions();
) Logger.i(TAG, "Finished backfill");
}; } catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
}
}
StatePolycentric.instance.updateLikeMap(
ref,
args.hasLiked,
args.hasDisliked
)
};
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
_rating.visibility = View.GONE;
} }
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
_rating.visibility = View.GONE;
} }
} }
@ -2413,6 +2447,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 +2461,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 +2521,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)
@ -2648,9 +2691,10 @@ class VideoDetailView : ConstraintLayout {
} }
onChannelClicked.subscribe { onChannelClicked.subscribe {
if(it.url.isNotBlank()) if(it.url.isNotBlank()) {
fragment.minimizeVideoDetail()
fragment.navigate<ChannelFragment>(it) fragment.navigate<ChannelFragment>(it)
else } else
UIDialogs.appToast("No author url present"); UIDialogs.appToast("No author url present");
} }
@ -2725,10 +2769,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 +3086,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 +3163,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,9 +1,11 @@
package com.futo.platformplayer.fragment.mainactivity.main package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.util.TypedValue import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
@ -48,6 +50,11 @@ abstract class VideoListEditorView : LinearLayout {
private var _loadedVideos: List<IPlatformVideo>? = null; private var _loadedVideos: List<IPlatformVideo>? = null;
private var _loadedVideosCanEdit: Boolean = false; private var _loadedVideosCanEdit: Boolean = false;
fun hideSearchKeyboard() {
(context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager)?.hideSoftInputFromWindow(_search.textSearch.windowToken, 0)
_search.textSearch.clearFocus();
}
constructor(inflater: LayoutInflater) : super(inflater.context) { constructor(inflater: LayoutInflater) : super(inflater.context) {
inflater.inflate(R.layout.fragment_video_list_editor, this); inflater.inflate(R.layout.fragment_video_list_editor, this);
@ -79,6 +86,7 @@ abstract class VideoListEditorView : LinearLayout {
_search.textSearch.text = ""; _search.textSearch.text = "";
updateVideoFilters(); updateVideoFilters();
_buttonSearch.setImageResource(R.drawable.ic_search); _buttonSearch.setImageResource(R.drawable.ic_search);
hideSearchKeyboard();
} }
else { else {
_search.visibility = View.VISIBLE; _search.visibility = View.VISIBLE;
@ -89,23 +97,23 @@ abstract class VideoListEditorView : LinearLayout {
_buttonShare = findViewById(R.id.button_share); _buttonShare = findViewById(R.id.button_share);
val onShare = _onShare; val onShare = _onShare;
if(onShare != null) { if(onShare != null) {
_buttonShare.setOnClickListener { onShare.invoke() }; _buttonShare.setOnClickListener { hideSearchKeyboard(); onShare.invoke() };
_buttonShare.visibility = View.VISIBLE; _buttonShare.visibility = View.VISIBLE;
} }
else else
_buttonShare.visibility = View.GONE; _buttonShare.visibility = View.GONE;
buttonPlayAll.setOnClickListener { onPlayAllClick(); }; buttonPlayAll.setOnClickListener { hideSearchKeyboard();onPlayAllClick(); hideSearchKeyboard(); };
buttonShuffle.setOnClickListener { onShuffleClick(); }; buttonShuffle.setOnClickListener { hideSearchKeyboard();onShuffleClick(); hideSearchKeyboard(); };
_buttonEdit.setOnClickListener { onEditClick(); }; _buttonEdit.setOnClickListener { hideSearchKeyboard(); onEditClick(); };
setButtonExportVisible(false); setButtonExportVisible(false);
setButtonDownloadVisible(canEdit()); setButtonDownloadVisible(canEdit());
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged); videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved); videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved);
videoListEditorView.onVideoOptions.subscribe(::onVideoOptions); videoListEditorView.onVideoOptions.subscribe(::onVideoOptions);
videoListEditorView.onVideoClicked.subscribe(::onVideoClicked); videoListEditorView.onVideoClicked.subscribe { hideSearchKeyboard(); onVideoClicked(it)};
_videoListEditorView = videoListEditorView; _videoListEditorView = videoListEditorView;
} }
@ -113,6 +121,7 @@ abstract class VideoListEditorView : LinearLayout {
fun setOnShare(onShare: (()-> Unit)? = null) { fun setOnShare(onShare: (()-> Unit)? = null) {
_onShare = onShare; _onShare = onShare;
_buttonShare.setOnClickListener { _buttonShare.setOnClickListener {
hideSearchKeyboard();
onShare?.invoke(); onShare?.invoke();
}; };
_buttonShare.visibility = View.VISIBLE; _buttonShare.visibility = View.VISIBLE;
@ -145,7 +154,7 @@ abstract class VideoListEditorView : LinearLayout {
setButtonExportVisible(false); setButtonExportVisible(false);
_buttonDownload.setImageResource(R.drawable.ic_loader_animated); _buttonDownload.setImageResource(R.drawable.ic_loader_animated);
_buttonDownload.drawable.assume<Animatable, Unit> { it.start() }; _buttonDownload.drawable.assume<Animatable, Unit> { it.start() };
_buttonDownload.setOnClickListener { _buttonDownload.setOnClickListener { hideSearchKeyboard();
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), { UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
StateDownloads.instance.deleteCachedPlaylist(playlistId); StateDownloads.instance.deleteCachedPlaylist(playlistId);
}); });
@ -154,7 +163,7 @@ abstract class VideoListEditorView : LinearLayout {
else if(isDownloaded) { else if(isDownloaded) {
setButtonExportVisible(true) setButtonExportVisible(true)
_buttonDownload.setImageResource(R.drawable.ic_download_off); _buttonDownload.setImageResource(R.drawable.ic_download_off);
_buttonDownload.setOnClickListener { _buttonDownload.setOnClickListener { hideSearchKeyboard();
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), { UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
StateDownloads.instance.deleteCachedPlaylist(playlistId); StateDownloads.instance.deleteCachedPlaylist(playlistId);
}); });
@ -163,7 +172,7 @@ abstract class VideoListEditorView : LinearLayout {
else { else {
setButtonExportVisible(false); setButtonExportVisible(false);
_buttonDownload.setImageResource(R.drawable.ic_download); _buttonDownload.setImageResource(R.drawable.ic_download);
_buttonDownload.setOnClickListener { _buttonDownload.setOnClickListener { hideSearchKeyboard();
onDownload(); onDownload();
//UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer); //UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer);
} }
@ -215,7 +224,8 @@ abstract class VideoListEditorView : LinearLayout {
fun updateVideoFilters() { fun updateVideoFilters() {
val videos = _loadedVideos ?: return; val videos = _loadedVideos ?: return;
_videoListEditorView.setVideos(filterVideos(videos), _loadedVideosCanEdit); val filteredVideos = filterVideos(videos)
_videoListEditorView.setVideos(filteredVideos, _loadedVideosCanEdit && filteredVideos.size == videos.size);
} }
protected fun setButtonDownloadVisible(isVisible: Boolean) { protected fun setButtonDownloadVisible(isVisible: Boolean) {

View file

@ -0,0 +1,223 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Animatable
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewPropertyAnimator
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.children
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
import com.futo.platformplayer.api.media.platforms.js.models.JSWebDetails
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.fixHtmlWhitespace
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.adapters.ChannelTab
import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView
import com.futo.platformplayer.views.comments.AddCommentView
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.segments.CommentsList
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models
import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import com.google.android.flexbox.FlexboxLayout
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.protobuf.ByteString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import userpackage.Protocol
import java.lang.Integer.min
class WebDetailFragment : MainFragment {
override val isMainView: Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _viewDetail: WebDetailView? = null;
constructor() : super() { }
override fun onBackPressed(): Boolean {
return false;
}
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = WebDetailView(inflater.context).applyFragment(this);
_viewDetail = view;
return view;
}
override fun onDestroyMainView() {
super.onDestroyMainView();
_viewDetail?.onDestroy();
_viewDetail = null;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
if (parameter is JSWeb) {
_viewDetail?.clear();
_viewDetail?.setWeb(parameter);
}
if (parameter is JSWebDetails) {
_viewDetail?.clear();
_viewDetail?.setWebDetails(parameter);
}
}
private class WebDetailView : ConstraintLayout {
private lateinit var _fragment: WebDetailFragment;
private var _url: String? = null;
private var _isLoading = false;
private var _web: JSWebDetails? = null;
private val _layoutLoadingOverlay: FrameLayout;
private val _imageLoader: ImageView;
private val _webview: WebView;
private val _taskLoadPost = if(!isInEditMode) TaskHandler<String, JSWebDetails>(
StateApp.instance.scopeGetter,
{
val result = StatePlatform.instance.getContentDetails(it).await();
if(result !is JSWebDetails)
throw IllegalStateException(context.getString(R.string.expected_media_content_found) + " ${result.contentType}");
return@TaskHandler result;
})
.success { setWebDetails(it) }
.exception<Throwable> {
Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
constructor(context: Context) : super(context) {
inflate(context, R.layout.fragview_web_detail, this);
val root = findViewById<FrameLayout>(R.id.root);
_layoutLoadingOverlay = findViewById(R.id.layout_loading_overlay);
_imageLoader = findViewById(R.id.image_loader);
_webview = findViewById(R.id.webview);
_webview.webViewClient = object: WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url);
if(url != "about:blank")
setLoading(false);
}
}
}
fun applyFragment(frag: WebDetailFragment): WebDetailView {
_fragment = frag;
return this;
}
fun clear() {
_webview.loadUrl("about:blank");
}
fun setWeb(value: JSWeb) {
_url = value.url;
setLoading(true);
clear();
fetchPost();
}
fun setWebDetails(value: JSWebDetails) {
_web = value;
setLoading(true);
_webview.loadUrl("about:blank");
if(!value.html.isNullOrEmpty())
_webview.loadData(value.html, "text/html", null);
else
_webview.loadUrl(value.url ?: "about:blank");
}
private fun fetchPost() {
Logger.i(WebDetailView.TAG, "fetchWeb")
_web = null;
val url = _url;
if (!url.isNullOrBlank()) {
setLoading(true);
_taskLoadPost.run(url);
}
}
fun onDestroy() {
_webview.loadUrl("about:blank");
}
private fun setLoading(isLoading : Boolean) {
if (_isLoading == isLoading) {
return;
}
_isLoading = isLoading;
if(isLoading) {
(_imageLoader.drawable as Animatable?)?.start()
_layoutLoadingOverlay.visibility = View.VISIBLE;
}
else {
_layoutLoadingOverlay.visibility = View.GONE;
(_imageLoader.drawable as Animatable?)?.stop()
}
}
companion object {
const val TAG = "WebDetailFragment"
}
}
companion object {
fun newInstance() = WebDetailFragment().apply {}
}
}

View file

@ -88,7 +88,6 @@ class SearchTopBarFragment : TopFragment() {
} else if (parameter is SuggestionsFragmentData) { } else if (parameter is SuggestionsFragmentData) {
this.setText(parameter.query); this.setText(parameter.query);
_searchType = parameter.searchType; _searchType = parameter.searchType;
_channelUrl = parameter.channelUrl;
} }
if(currentMain is SuggestionsFragment) if(currentMain is SuggestionsFragment)
@ -114,7 +113,7 @@ class SearchTopBarFragment : TopFragment() {
fun clear() { fun clear() {
_editSearch?.text?.clear(); _editSearch?.text?.clear();
if (currentMain !is SuggestionsFragment) { if (currentMain !is SuggestionsFragment) {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType, _channelUrl), false); navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType), false);
} else { } else {
onSearch.emit(""); onSearch.emit("");
} }

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

@ -1,5 +1,11 @@
package com.futo.platformplayer.parsers package com.futo.platformplayer.parsers
import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
@ -7,12 +13,15 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudi
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.toYesNo import com.futo.platformplayer.toYesNo
import com.futo.platformplayer.yesNoToBoolean import com.futo.platformplayer.yesNoToBoolean
import java.io.ByteArrayInputStream
import java.net.URI import java.net.URI
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.text.ifEmpty
class HLS { class HLS {
companion object { companion object {
@OptIn(UnstableApi::class)
fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist { fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist {
val baseUrl = URI(sourceUrl).resolve("./").toString() val baseUrl = URI(sourceUrl).resolve("./").toString()
@ -49,6 +58,31 @@ class HLS {
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments) return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments)
} }
fun mediaRenditionToVariant(rendition: MediaRendition): HLSVariantAudioUrlSource? {
if (rendition.uri == null) {
return null
}
val suffix = listOf(rendition.language, rendition.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
return when (rendition.type) {
"AUDIO" -> HLSVariantAudioUrlSource(rendition.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", rendition.language ?: "", null, false, false, rendition.uri)
else -> null
}
}
fun variantReferenceToVariant(reference: VariantPlaylistReference): HLSVariantVideoUrlSource {
var width: Int? = null
var height: Int? = null
val resolutionTokens = reference.streamInfo.resolution?.split('x')
if (resolutionTokens?.isNotEmpty() == true) {
width = resolutionTokens[0].toIntOrNull()
height = resolutionTokens[1].toIntOrNull()
}
val suffix = listOf(reference.streamInfo.video, reference.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
return HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", reference.streamInfo.codecs ?: "", reference.streamInfo.bandwidth, 0, false, reference.url)
}
fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist { fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist {
val lines = content.lines() val lines = content.lines()
val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull() val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull()
@ -61,7 +95,25 @@ class HLS {
val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":") val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) } val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) }
val keyInfo =
lines.find { it.startsWith("#EXT-X-KEY:") }?.substringAfter(":")?.split(",")
val key = keyInfo?.find { it.startsWith("URI=") }?.substringAfter("=")?.trim('"')
val iv =
keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x")
val decryptionInfo: DecryptionInfo? = key?.let { k ->
DecryptionInfo(k, iv)
}
val initSegment =
lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0)
?.substringAfter("=")?.trim('"')
val segments = mutableListOf<Segment>() val segments = mutableListOf<Segment>()
if (initSegment != null) {
segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
}
var currentSegment: MediaSegment? = null var currentSegment: MediaSegment? = null
lines.forEach { line -> lines.forEach { line ->
when { when {
@ -86,7 +138,7 @@ class HLS {
} }
} }
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments) return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments, decryptionInfo)
} }
fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> { fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
@ -270,7 +322,7 @@ class HLS {
val name: String?, val name: String?,
val isDefault: Boolean?, val isDefault: Boolean?,
val isAutoSelect: Boolean?, val isAutoSelect: Boolean?,
val isForced: Boolean? val isForced: Boolean?,
) { ) {
fun toM3U8Line(): String = buildString { fun toM3U8Line(): String = buildString {
append("#EXT-X-MEDIA:") append("#EXT-X-MEDIA:")
@ -319,30 +371,13 @@ class HLS {
fun getVideoSources(): List<HLSVariantVideoUrlSource> { fun getVideoSources(): List<HLSVariantVideoUrlSource> {
return variantPlaylistsRefs.map { return variantPlaylistsRefs.map {
var width: Int? = null variantReferenceToVariant(it)
var height: Int? = null
val resolutionTokens = it.streamInfo.resolution?.split('x')
if (resolutionTokens?.isNotEmpty() == true) {
width = resolutionTokens[0].toIntOrNull()
height = resolutionTokens[1].toIntOrNull()
}
val suffix = listOf(it.streamInfo.video, it.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url)
} }
} }
fun getAudioSources(): List<HLSVariantAudioUrlSource> { fun getAudioSources(): List<HLSVariantAudioUrlSource> {
return mediaRenditions.mapNotNull { return mediaRenditions.mapNotNull {
if (it.uri == null) { return@mapNotNull mediaRenditionToVariant(it)
return@mapNotNull null
}
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
return@mapNotNull when (it.type) {
"AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, false, it.uri)
else -> null
}
} }
} }
@ -368,6 +403,11 @@ class HLS {
} }
} }
data class DecryptionInfo(
val keyUrl: String,
val iv: String?
)
data class VariantPlaylist( data class VariantPlaylist(
val version: Int?, val version: Int?,
val targetDuration: Int?, val targetDuration: Int?,
@ -376,7 +416,8 @@ class HLS {
val programDateTime: ZonedDateTime?, val programDateTime: ZonedDateTime?,
val playlistType: String?, val playlistType: String?,
val streamInfo: StreamInfo?, val streamInfo: StreamInfo?,
val segments: List<Segment> val segments: List<Segment>,
val decryptionInfo: DecryptionInfo? = null
) { ) {
fun buildM3U8(): String = buildString { fun buildM3U8(): String = buildString {
append("#EXTM3U\n") append("#EXTM3U\n")

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,15 @@ class StateApp {
} }
if (Settings.instance.synchronization.enabled) { if (Settings.instance.synchronization.enabled) {
StateSync.instance.start() StateSync.instance.start(context)
}
settingsActivityClosed.subscribe {
if (Settings.instance.synchronization.enabled) {
StateSync.instance.start(context)
} else {
StateSync.instance.stop()
}
} }
Logger.onLogSubmitted.subscribe { Logger.onLogSubmitted.subscribe {
@ -509,22 +518,33 @@ class StateApp {
//Migration //Migration
Logger.i(TAG, "MainApp Started: Check [Migrations]"); Logger.i(TAG, "MainApp Started: Check [Migrations]");
migrateStores(context, listOf(
StateSubscriptions.instance.toMigrateCheck(), scopeOrNull?.launch(Dispatchers.IO) {
StatePlaylists.instance.toMigrateCheck() try {
).flatten(), 0); migrateStores(context, listOf(
StateSubscriptions.instance.toMigrateCheck(),
StatePlaylists.instance.toMigrateCheck()
).flatten(), 0)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to migrate stores")
}
}
if(Settings.instance.subscriptions.fetchOnAppBoot) { 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);
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5) scopeOrNull?.let {
StateSubscriptions.instance.updateSubscriptionFeed(scope, false); if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
StateSubscriptions.instance.updateSubscriptionFeed(it, false);
}
} }
else else
Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}"); Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}");
@ -675,15 +695,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()) {
UIDialogs.showMigrateDialog(context, store) { withContext(Dispatchers.Main) {
migrateStores(context, managedStores, index + 1); try {
}; UIDialogs.showMigrateDialog(context, store) {
else scopeOrNull?.launch(Dispatchers.IO) {
try {
migrateStores(context, managedStores, index + 1);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to migrate store", e)
}
}
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to migrate stores", e)
}
}
} else
migrateStores(context, managedStores, index + 1); migrateStores(context, managedStores, index + 1);
} }
@ -703,6 +735,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

@ -383,7 +383,7 @@ class StateDownloads {
} }
private fun validateDownload(videoState: VideoDownload) { private fun validateDownload(videoState: VideoDownload) {
if(_downloading.hasItem { it.videoEither.url == videoState.videoEither.url }) if(_downloading.hasItem { it.videoEither.url == videoState.videoEither.url })
throw IllegalStateException("Video [${videoState.name}] is already queued for dowload"); throw IllegalStateException("Video [${videoState.name}] is already queued for download");
val existing = getCachedVideo(videoState.id); val existing = getCachedVideo(videoState.id);
if(existing != null) { if(existing != null) {

View file

@ -5,7 +5,6 @@ import androidx.collection.LruCache
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.PlatformMultiClientPool import com.futo.platformplayer.api.media.PlatformMultiClientPool
@ -46,7 +45,6 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.ToastView import com.futo.platformplayer.views.ToastView
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
@ -56,7 +54,6 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.internal.concat
import java.lang.Thread.sleep import java.lang.Thread.sleep
import java.time.OffsetDateTime import java.time.OffsetDateTime
import kotlin.streams.asSequence import kotlin.streams.asSequence
@ -94,9 +91,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 +112,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 +191,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 +200,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 +301,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);
@ -673,12 +684,33 @@ class StatePlatform {
return pager; return pager;
} }
fun searchChannelsAsContent(query: String): IPager<IPlatformContent> {
Logger.i(TAG, "Platform - searchChannels");
val pagers = mutableMapOf<IPager<IPlatformContent>, Float>();
getSortedEnabledClient().parallelStream().forEach {
try {
if (it.capabilities.hasChannelSearch)
pagers.put(it.searchChannelsAsContent(query), 1f);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed search channels", ex)
UIDialogs.toast("Failed search channels on [${it.name}]\n(${ex.message})");
}
};
if(pagers.isEmpty())
return EmptyPager<IPlatformContent>();
val pager = MultiDistributionContentPager(pagers);
pager.initialize();
return pager;
}
//Video //Video
fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { it.isContentDetailsUrl(url) }; fun hasEnabledContentClient(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)
@ -719,14 +751,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");
@ -738,7 +770,7 @@ class StatePlatform {
} }
} }
fun getChannelContent(baseClient: IPlatformClient, channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0): IPager<IPlatformContent> { fun getChannelContent(baseClient: IPlatformClient, channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, type: String? = null): IPager<IPlatformContent> {
val clientCapabilities = baseClient.getChannelCapabilities(); val clientCapabilities = baseClient.getChannelCapabilities();
val client = if(usePooledClients > 1) val client = if(usePooledClients > 1)
_channelClientPool.getClientPooled(baseClient, usePooledClients); _channelClientPool.getClientPooled(baseClient, usePooledClients);
@ -747,66 +779,75 @@ class StatePlatform {
var lastStream: OffsetDateTime? = null; var lastStream: OffsetDateTime? = null;
val pagerResult: IPager<IPlatformContent>; val pagerResult: IPager<IPlatformContent>;
if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) && if (type == null) {
( clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) || if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) &&
clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS) || ( clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) ||
clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE) || clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS) ||
clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS) clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE) ||
)) { clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS)
val toQuery = mutableListOf<String>(); )) {
if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) val toQuery = mutableListOf<String>();
toQuery.add(ResultCapabilities.TYPE_VIDEOS); if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
if(clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS)) toQuery.add(ResultCapabilities.TYPE_VIDEOS);
toQuery.add(ResultCapabilities.TYPE_STREAMS); if(clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS))
if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE)) toQuery.add(ResultCapabilities.TYPE_STREAMS);
toQuery.add(ResultCapabilities.TYPE_LIVE); if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE))
if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS)) toQuery.add(ResultCapabilities.TYPE_LIVE);
toQuery.add(ResultCapabilities.TYPE_POSTS); if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS))
toQuery.add(ResultCapabilities.TYPE_POSTS);
if(isSubscriptionOptimized) { if(isSubscriptionOptimized) {
val sub = StateSubscriptions.instance.getSubscription(channelUrl); val sub = StateSubscriptions.instance.getSubscription(channelUrl);
if(sub != null) { if(sub != null) {
if(!sub.shouldFetchStreams()) { if(!sub.shouldFetchStreams()) {
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 7 days, skipping live streams [${sub.lastLiveStream.getNowDiffDays()} days ago]"); Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 7 days, skipping live streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
toQuery.remove(ResultCapabilities.TYPE_LIVE); toQuery.remove(ResultCapabilities.TYPE_LIVE);
} }
if(!sub.shouldFetchLiveStreams()) { if(!sub.shouldFetchLiveStreams()) {
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]"); Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
toQuery.remove(ResultCapabilities.TYPE_STREAMS); toQuery.remove(ResultCapabilities.TYPE_STREAMS);
} }
if(!sub.shouldFetchPosts()) { if(!sub.shouldFetchPosts()) {
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 5 days, skipping posts [${sub.lastPost.getNowDiffDays()} days ago]"); Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 5 days, skipping posts [${sub.lastPost.getNowDiffDays()} days ago]");
toQuery.remove(ResultCapabilities.TYPE_POSTS); toQuery.remove(ResultCapabilities.TYPE_POSTS);
}
}
}
//Merged pager
val pagers = toQuery
.parallelStream()
.map {
val results = client.getChannelContents(channelUrl, it, ResultCapabilities.ORDER_CHONOLOGICAL) ;
when(it) {
ResultCapabilities.TYPE_STREAMS -> {
val streamResults = results.getResults();
if(streamResults.size == 0)
lastStream = OffsetDateTime.MIN;
else
lastStream = results.getResults().firstOrNull()?.datetime;
} }
} }
return@map results;
} }
.asSequence()
.toList();
val pager = MultiChronoContentPager(pagers.toTypedArray()); //Merged pager
pager.initialize(); val pagers = toQuery
pagerResult = pager; .parallelStream()
.map {
val results = client.getChannelContents(channelUrl, it, ResultCapabilities.ORDER_CHONOLOGICAL) ;
when(it) {
ResultCapabilities.TYPE_STREAMS -> {
val streamResults = results.getResults();
if(streamResults.size == 0)
lastStream = OffsetDateTime.MIN;
else
lastStream = results.getResults().firstOrNull()?.datetime;
}
}
return@map results;
}
.asSequence()
.toList();
val pager = MultiChronoContentPager(pagers.toTypedArray());
pager.initialize();
pagerResult = pager;
}
else {
pagerResult = client.getChannelContents(channelUrl, ResultCapabilities.TYPE_MIXED, ResultCapabilities.ORDER_CHONOLOGICAL);
}
} else {
pagerResult = if (type == ResultCapabilities.TYPE_SHORTS && clientCapabilities.hasType(ResultCapabilities.TYPE_SHORTS)) {
client.getChannelContents(channelUrl, ResultCapabilities.TYPE_SHORTS, ResultCapabilities.ORDER_CHONOLOGICAL);
} else {
EmptyPager()
}
} }
else
pagerResult = client.getChannelContents(channelUrl, ResultCapabilities.TYPE_MIXED, ResultCapabilities.ORDER_CHONOLOGICAL);
//Subscription optimization //Subscription optimization
val sub = StateSubscriptions.instance.getSubscription(channelUrl); val sub = StateSubscriptions.instance.getSubscription(channelUrl);
@ -858,10 +899,10 @@ class StatePlatform {
return pagerResult; return pagerResult;
} }
fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List<String>? = null): IPager<IPlatformContent> { fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List<String>? = null, type: String? = null): IPager<IPlatformContent> {
Logger.i(TAG, "Platform - getChannelVideos"); Logger.i(TAG, "Platform - getChannelVideos");
val baseClient = getChannelClient(channelUrl, ignorePlugins); val baseClient = getChannelClient(channelUrl, ignorePlugins);
return getChannelContent(baseClient, channelUrl, isSubscriptionOptimized, usePooledClients); return getChannelContent(baseClient, channelUrl, isSubscriptionOptimized, usePooledClients, type);
} }
fun getChannelContent(channelUrl: String, type: String?, ordering: String = ResultCapabilities.ORDER_CHONOLOGICAL): IPager<IPlatformContent> { fun getChannelContent(channelUrl: String, type: String?, ordering: String = ResultCapabilities.ORDER_CHONOLOGICAL): IPager<IPlatformContent> {
val client = getChannelClient(channelUrl); val client = getChannelClient(channelUrl);
@ -913,9 +954,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

@ -599,7 +599,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() {
if(_instance == null) synchronized(_lockObject) {
_instance = StatePlaylists(); if (_instance == null)
return _instance!!; _instance = StatePlaylists();
}; return _instance!!;
}
}
fun finish() { fun finish() {
_instance?.let { _instance?.let {

View file

@ -236,7 +236,7 @@ class StatePolycentric {
return Pair(didUpdate, listOf(url)); return Pair(didUpdate, listOf(url));
} }
fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager<IPlatformContent>? { fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1, type: String? = null): IPager<IPlatformContent>? {
ensureEnabled() ensureEnabled()
//TODO: Currently abusing subscription concurrency for parallelism //TODO: Currently abusing subscription concurrency for parallelism
@ -248,7 +248,11 @@ class StatePolycentric {
return@mapNotNull Pair(client, scope.async(Dispatchers.IO) { return@mapNotNull Pair(client, scope.async(Dispatchers.IO) {
try { try {
return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency); if (type == null) {
return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency);
} else {
return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency, type = type);
}
} catch (ex: Throwable) { } catch (ex: Throwable) {
Logger.e(TAG, "getChannelContent", ex); Logger.e(TAG, "getChannelContent", ex);
return@async null; return@async null;

View file

@ -1,232 +1,187 @@
package com.futo.platformplayer.states package com.futo.platformplayer.states
import android.os.Build import android.content.Context
import android.util.Log import com.futo.platformplayer.R
import com.futo.platformplayer.LittleEndianDataInputStream
import com.futo.platformplayer.LittleEndianDataOutputStream
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SyncShowPairingCodeActivity import com.futo.platformplayer.activities.SyncShowPairingCodeActivity
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.encryption.GEncryptionProvider import com.futo.platformplayer.encryption.GEncryptionProvider
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.mdns.DnsService import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.mdns.ServiceDiscoverer import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.sToOffsetDateTimeUTC
import com.futo.platformplayer.noise.protocol.Noise import com.futo.platformplayer.smartMerge
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStringMapStorage
import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.stores.StringStringMapStorage
import com.futo.platformplayer.stores.StringTMapStorage import com.futo.platformplayer.stores.StringTMapStorage
import com.futo.platformplayer.sync.SyncSessionData import com.futo.platformplayer.sync.SyncSessionData
import com.futo.platformplayer.sync.internal.GJSyncOpcodes import com.futo.platformplayer.sync.internal.GJSyncOpcodes
import com.futo.platformplayer.sync.internal.SyncDeviceInfo import com.futo.platformplayer.sync.internal.ISyncDatabaseProvider
import com.futo.platformplayer.sync.internal.Opcode
import com.futo.platformplayer.sync.internal.SyncKeyPair import com.futo.platformplayer.sync.internal.SyncKeyPair
import com.futo.platformplayer.sync.internal.SyncService
import com.futo.platformplayer.sync.internal.SyncServiceSettings
import com.futo.platformplayer.sync.internal.SyncSession import com.futo.platformplayer.sync.internal.SyncSession
import com.futo.platformplayer.sync.internal.SyncSocketSession import com.futo.platformplayer.sync.models.SendToDevicePackage
import com.futo.polycentric.core.base64ToByteArray import com.futo.platformplayer.sync.models.SyncPlaylistsPackage
import com.futo.polycentric.core.toBase64 import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
import com.futo.platformplayer.sync.models.SyncWatchLaterPackage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.net.InetAddress import java.io.ByteArrayInputStream
import java.net.InetSocketAddress import java.nio.ByteBuffer
import java.net.ServerSocket import java.time.OffsetDateTime
import java.net.Socket
import java.util.Base64
import java.util.Locale
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
class StateSync { class StateSync {
private val _authorizedDevices = FragmentedStorage.get<StringArrayStorage>("authorized_devices")
private val _nameStorage = FragmentedStorage.get<StringStringMapStorage>("sync_remembered_name_storage")
private val _syncKeyPair = FragmentedStorage.get<StringStorage>("sync_key_pair")
private val _lastAddressStorage = FragmentedStorage.get<StringStringMapStorage>("sync_last_address_storage")
private val _syncSessionData = FragmentedStorage.get<StringTMapStorage<SyncSessionData>>("syncSessionData") private val _syncSessionData = FragmentedStorage.get<StringTMapStorage<SyncSessionData>>("syncSessionData")
private var _serverSocket: ServerSocket? = null var syncService: SyncService? = null
private var _thread: Thread? = null private set
private var _connectThread: Thread? = null
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()
//TODO: Should sync mdns and casting mdns be merged?
//TODO: Decrease interval that devices are updated
//TODO: Send less data
val _serviceDiscoverer = ServiceDiscoverer(arrayOf("_gsync._tcp.local")) { handleServiceUpdated(it) }
var keyPair: DHState? = null
var publicKey: String? = null
val deviceRemoved: Event1<String> = Event1() val deviceRemoved: Event1<String> = Event1()
val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2() val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2()
fun hasAuthorizedDevice(): Boolean { fun start(context: Context) {
synchronized(_sessions) { if (syncService != null) {
return _sessions.any{ it.value.connected && it.value.isAuthorized };
}
}
fun start() {
if (_started) {
Logger.i(TAG, "Already started.") Logger.i(TAG, "Already started.")
return return
} }
_started = true
if (Settings.instance.synchronization.broadcast || Settings.instance.synchronization.connectDiscovered) { syncService = SyncService(
_serviceDiscoverer.start() SERVICE_NAME,
} RELAY_SERVER,
RELAY_PUBLIC_KEY,
try { APP_ID,
val syncKeyPair = Json.decodeFromString<SyncKeyPair>(GEncryptionProvider.instance.decrypt(_syncKeyPair.value)) StoreBasedSyncDatabaseProvider(),
val p = Noise.createDH(dh) SyncServiceSettings(
p.setPublicKey(syncKeyPair.publicKey.base64ToByteArray(), 0) mdnsBroadcast = Settings.instance.synchronization.broadcast,
p.setPrivateKey(syncKeyPair.privateKey.base64ToByteArray(), 0) mdnsConnectDiscovered = Settings.instance.synchronization.connectDiscovered,
keyPair = p bindListener = Settings.instance.synchronization.localConnections,
} catch (e: Throwable) { connectLastKnown = Settings.instance.synchronization.connectLast,
//Sync key pair non-existing, invalid or lost relayHandshakeAllowed = Settings.instance.synchronization.connectThroughRelay,
val p = Noise.createDH(dh) relayPairAllowed = Settings.instance.synchronization.pairThroughRelay,
p.generateKeyPair() relayEnabled = Settings.instance.synchronization.discoverThroughRelay,
relayConnectDirect = Settings.instance.synchronization.connectLocalDirectThroughRelay,
val publicKey = ByteArray(p.publicKeyLength) relayConnectRelayed = Settings.instance.synchronization.connectThroughRelay
p.getPublicKey(publicKey, 0) )
val privateKey = ByteArray(p.privateKeyLength) ).apply {
p.getPrivateKey(privateKey, 0) onAuthorized = { sess, isNewlyAuthorized, isNewSession ->
if (isNewSession) {
val syncKeyPair = SyncKeyPair(1, publicKey.toBase64(), privateKey.toBase64()) deviceUpdatedOrAdded.emit(sess.remotePublicKey, sess)
_syncKeyPair.setAndSave(GEncryptionProvider.instance.encrypt(Json.encodeToString(syncKeyPair))) StateApp.instance.scope.launch(Dispatchers.IO) { checkForSync(sess) }
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()
}
if (Settings.instance.synchronization.broadcast) {
publicKey?.let { _serviceDiscoverer.broadcastService(getDeviceName(), "_gsync._tcp.local", PORT.toUShort(), texts = arrayListOf("pk=${it.replace('+', '-').replace('/', '_').replace("=", "")}")) }
}
Logger.i(TAG, "Sync key pair initialized (public key = ${publicKey})")
_thread = Thread {
try {
val serverSocket = ServerSocket(PORT)
_serverSocket = serverSocket
Log.i(TAG, "Running on port ${PORT} (TCP)")
while (_started) {
val socket = serverSocket.accept()
val session = createSocketSession(socket, true) { session, socketSession ->
}
session.startAsResponder()
} }
} catch (e: Throwable) {
Logger.e(TAG, "Failed to bind server socket to port ${PORT}", e)
UIDialogs.toast("Failed to start sync, port in use")
} }
}.apply { start() }
if (Settings.instance.synchronization.connectLast) { onUnauthorized = { sess ->
_connectThread = Thread { StateApp.instance.scope.launch(Dispatchers.Main) {
Log.i(TAG, "Running auto reconnector") UIDialogs.showConfirmationDialog(
context,
while (_started) { "Device Unauthorized: ${sess.displayName}",
val authorizedDevices = synchronized(_authorizedDevices) { action = {
return@synchronized _authorizedDevices.values Logger.i(TAG, "${sess.remotePublicKey} unauthorized received")
} removeAuthorizedDevice(sess.remotePublicKey)
deviceRemoved.emit(sess.remotePublicKey)
val lastKnownMap = synchronized(_lastAddressStorage) { },
return@synchronized _lastAddressStorage.map.toMap() cancelAction = {}
} )
val addressesToConnect = authorizedDevices.mapNotNull {
val connected = isConnected(it)
if (connected) {
return@mapNotNull null
}
val lastKnownAddress = lastKnownMap[it] ?: return@mapNotNull null
return@mapNotNull Pair(it, lastKnownAddress)
}
for (connectPair in addressesToConnect) {
try {
val syncDeviceInfo = SyncDeviceInfo(connectPair.first, arrayOf(connectPair.second), PORT)
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(syncDeviceInfo)
}
} catch (e: Throwable) {
Logger.i(TAG, "Failed to connect to " + connectPair.first, e)
}
}
Thread.sleep(5000)
} }
}.apply { start() } }
onConnectedChanged = { sess, _ -> deviceUpdatedOrAdded.emit(sess.remotePublicKey, sess) }
onClose = { sess -> deviceRemoved.emit(sess.remotePublicKey) }
onData = { it, opcode, subOpcode, data ->
val dataCopy = ByteArray(data.remaining())
data.get(dataCopy)
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
handleData(it, opcode, subOpcode, ByteBuffer.wrap(dataCopy))
} catch (e: Throwable) {
Logger.e(TAG, "Exception occurred while handling data, closing session", e)
it.close()
}
}
}
authorizePrompt = { remotePublicKey, callback ->
val scope = StateApp.instance.scopeOrNull
val activity = SyncShowPairingCodeActivity.activity
if (scope != null && activity != null) {
scope.launch(Dispatchers.Main) {
UIDialogs.showConfirmationDialog(activity, "Allow connection from $remotePublicKey?",
action = {
scope.launch(Dispatchers.IO) {
try {
callback(true)
Logger.i(TAG, "Connection authorized for $remotePublicKey by confirmation")
activity.finish()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to send authorize", e)
}
}
},
cancelAction = {
scope.launch(Dispatchers.IO) {
try {
callback(false)
Logger.i(TAG, "$remotePublicKey unauthorized received")
} catch (e: Throwable) {
Logger.w(TAG, "Failed to send unauthorize", e)
}
}
}
)
}
} else {
callback(false)
Logger.i(TAG, "Connection unauthorized for $remotePublicKey because not authorized and not on pairing activity to ask")
}
}
} }
syncService?.start(context)
} }
private fun getDeviceName(): String { fun confirmStarted(context: Context, onStarted: () -> Unit, onNotStarted: () -> Unit) {
val manufacturer = Build.MANUFACTURER.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } if (syncService == null) {
val model = Build.MODEL UIDialogs.showConfirmationDialog(context, "Sync has not been enabled yet, would you like to enable sync?", {
Settings.instance.synchronization.enabled = true
return if (model.startsWith(manufacturer, ignoreCase = true)) { start(context)
model.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } Settings.instance.save()
onStarted.invoke()
}, {
onNotStarted.invoke()
})
} else { } else {
"$manufacturer $model".replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } onStarted.invoke()
} }
} }
fun isConnected(publicKey: String): Boolean { fun hasAuthorizedDevice(): Boolean {
return synchronized(_sessions) { return (syncService?.getAuthorizedDeviceCount() ?: 0) > 0
_sessions[publicKey]?.connected ?: false
}
} }
fun isAuthorized(publicKey: String): Boolean { fun isAuthorized(publicKey: String): Boolean {
return synchronized(_authorizedDevices) { return syncService?.isAuthorized(publicKey) ?: false
_authorizedDevices.values.contains(publicKey)
}
} }
fun getSession(publicKey: String): SyncSession? { fun getSession(publicKey: String): SyncSession? {
return synchronized(_sessions) { return syncService?.getSession(publicKey)
_sessions[publicKey]
}
}
fun getSessions(): List<SyncSession> {
return synchronized(_sessions) {
return _sessions.values.toList()
};
} }
fun getAuthorizedSessions(): List<SyncSession> { fun getAuthorizedSessions(): List<SyncSession> {
return synchronized(_sessions) { return syncService?.getSessions()?.filter { it.isAuthorized }?.toList() ?: listOf()
return _sessions.values.filter { it.isAuthorized }.toList()
};
} }
fun getSyncSessionData(key: String): SyncSessionData { fun getSyncSessionData(key: String): SyncSessionData {
@ -239,197 +194,267 @@ class StateSync {
_syncSessionData.setAndSave(data.publicKey, data); _syncSessionData.setAndSave(data.publicKey, data);
} }
private fun handleServiceUpdated(services: List<DnsService>) { private fun handleSyncSubscriptionPackage(origin: SyncSession, pack: SyncSubscriptionsPackage) {
if (!Settings.instance.synchronization.connectDiscovered) { val added = mutableListOf<Subscription>()
return for(sub in pack.subscriptions) {
if(!StateSubscriptions.instance.isSubscribed(sub.channel)) {
val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url);
if(sub.creationTime > removalTime) {
val newSub = StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime);
added.add(newSub);
}
}
} }
if(added.size > 3)
UIDialogs.appToast("${added.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}");
else if(added.size > 0)
UIDialogs.appToast("Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" +
added.map { it.channel.name }.joinToString("\n"));
for (s in services) {
//TODO: Addresses IPv4 only?
val addresses = s.addresses.mapNotNull { it.hostAddress }.toTypedArray()
val port = s.port.toInt()
if (s.name.endsWith("._gsync._tcp.local")) {
val name = s.name.substring(0, s.name.length - "._gsync._tcp.local".length)
val urlSafePkey = s.texts.firstOrNull { it.startsWith("pk=") }?.substring("pk=".length) ?: continue
val pkey = Base64.getEncoder().encodeToString(Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')))
val syncDeviceInfo = SyncDeviceInfo(pkey, addresses, port) if(pack.subscriptionRemovals.isNotEmpty()) {
val authorized = isAuthorized(pkey) for (subRemoved in pack.subscriptionRemovals) {
val removed = StateSubscriptions.instance.applySubscriptionRemovals(pack.subscriptionRemovals);
if(removed.size > 3) {
UIDialogs.appToast("Removed ${removed.size} Subscriptions from ${origin.remotePublicKey.substring(0, 8.coerceAtMost(origin.remotePublicKey.length))}");
} else if(removed.isNotEmpty()) {
UIDialogs.appToast("Subscriptions removed from ${origin.remotePublicKey.substring(0, 8.coerceAtMost(origin.remotePublicKey.length))}:\n" + removed.map { it.channel.name }.joinToString("\n"));
}
}
}
}
if (authorized && !isConnected(pkey)) { private fun handleData(session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
val now = System.currentTimeMillis() val remotePublicKey = session.remotePublicKey
val lastConnectTime = synchronized(_lastConnectTimesMdns) { when (subOpcode) {
_lastConnectTimesMdns[pkey] ?: 0 GJSyncOpcodes.sendToDevices -> {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
val context = StateApp.instance.contextOrNull;
if (context != null && context is MainActivity) {
val dataBody = ByteArray(data.remaining());
val remainder = data.remaining();
data.get(dataBody, 0, remainder);
val json = String(dataBody, Charsets.UTF_8);
val obj = Json.decodeFromString<SendToDevicePackage>(json);
UIDialogs.appToast("Received url from device [${session.remotePublicKey}]:\n{${obj.url}");
context.handleUrl(obj.url, obj.position);
} }
};
}
//Connect once every 30 seconds, max GJSyncOpcodes.syncStateExchange -> {
if (now - lastConnectTime > 30000) { val dataBody = ByteArray(data.remaining());
synchronized(_lastConnectTimesMdns) { data.get(dataBody);
_lastConnectTimesMdns[pkey] = now val json = String(dataBody, Charsets.UTF_8);
val syncSessionData = Serializer.json.decodeFromString<SyncSessionData>(json);
Logger.i(TAG, "Received SyncSessionData from $remotePublicKey");
val subscriptionPackageString = StateSubscriptions.instance.getSyncSubscriptionsPackageString()
Logger.i(TAG, "syncStateExchange syncSubscriptions b (size: ${subscriptionPackageString.length})")
session.sendData(GJSyncOpcodes.syncSubscriptions, subscriptionPackageString);
Logger.i(TAG, "syncStateExchange syncSubscriptions (size: ${subscriptionPackageString.length})")
val subscriptionGroupPackageString = StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString()
Logger.i(TAG, "syncStateExchange syncSubscriptionGroups b (size: ${subscriptionGroupPackageString.length})")
session.sendData(GJSyncOpcodes.syncSubscriptionGroups, subscriptionGroupPackageString);
Logger.i(TAG, "syncStateExchange syncSubscriptionGroups (size: ${subscriptionGroupPackageString.length})")
val syncPlaylistPackageString = StatePlaylists.instance.getSyncPlaylistsPackageString()
Logger.i(TAG, "syncStateExchange syncPlaylists b (size: ${syncPlaylistPackageString.length})")
session.sendData(GJSyncOpcodes.syncPlaylists, syncPlaylistPackageString)
Logger.i(TAG, "syncStateExchange syncPlaylists (size: ${syncPlaylistPackageString.length})")
val watchLaterPackageString = Json.encodeToString(StatePlaylists.instance.getWatchLaterSyncPacket(false))
Logger.i(TAG, "syncStateExchange syncWatchLater b (size: ${watchLaterPackageString.length})")
session.sendData(GJSyncOpcodes.syncWatchLater, watchLaterPackageString);
Logger.i(TAG, "syncStateExchange syncWatchLater (size: ${watchLaterPackageString.length})")
val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory);
Logger.i(TAG, "syncStateExchange syncHistory b (size: ${recentHistory.size})")
if(recentHistory.isNotEmpty())
session.sendJsonData(GJSyncOpcodes.syncHistory, recentHistory);
Logger.i(TAG, "syncStateExchange syncHistory (size: ${recentHistory.size})")
}
GJSyncOpcodes.syncExport -> {
val dataBody = ByteArray(data.remaining());
val bytesStr = ByteArrayInputStream(data.array(), data.position(), data.remaining());
bytesStr.use { bytesStrBytes ->
val exportStruct = StateBackup.ExportStructure.fromZipBytes(bytesStrBytes);
for (store in exportStruct.stores) {
if (store.key.equals("subscriptions", true)) {
val subStore =
StateSubscriptions.instance.getUnderlyingSubscriptionsStore();
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val pack = SyncSubscriptionsPackage(
store.value.map {
subStore.fromReconstruction(it, exportStruct.cache)
},
StateSubscriptions.instance.getSubscriptionRemovals()
);
handleSyncSubscriptionPackage(session, pack);
}
} }
}
}
}
Logger.i(TAG, "Found device authorized device '${name}' with pkey=$pkey, attempting to connect") GJSyncOpcodes.syncSubscriptions -> {
val dataBody = ByteArray(data.remaining());
data.get(dataBody);
val json = String(dataBody, Charsets.UTF_8);
val subPackage = Serializer.json.decodeFromString<SyncSubscriptionsPackage>(json);
handleSyncSubscriptionPackage(session, subPackage);
try { if(subPackage.subscriptions.size > 0) {
connect(syncDeviceInfo) val newestSub = subPackage.subscriptions.maxOf { it.creationTime };
} catch (e: Throwable) {
Logger.i(TAG, "Failed to connect to $pkey", e) val sesData = getSyncSessionData(remotePublicKey);
} if (newestSub > sesData.lastSubscription) {
sesData.lastSubscription = newestSub;
saveSyncSessionData(sesData);
}
}
}
GJSyncOpcodes.syncSubscriptionGroups -> {
val dataBody = ByteArray(data.remaining());
data.get(dataBody);
val json = String(dataBody, Charsets.UTF_8);
val pack = Serializer.json.decodeFromString<SyncSubscriptionGroupsPackage>(json);
var lastSubgroupChange = OffsetDateTime.MIN;
for(group in pack.groups){
if(group.lastChange > lastSubgroupChange)
lastSubgroupChange = group.lastChange;
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
if(existing == null)
StateSubscriptionGroups.instance.updateSubscriptionGroup(group, false, true);
else if(existing.lastChange < group.lastChange) {
existing.name = group.name;
existing.urls = group.urls;
existing.image = group.image;
existing.priority = group.priority;
existing.lastChange = group.lastChange;
StateSubscriptionGroups.instance.updateSubscriptionGroup(existing, false, true);
}
}
for(removal in pack.groupRemovals) {
val creation = StateSubscriptionGroups.instance.getSubscriptionGroup(removal.key);
val removalTime = removal.value.sToOffsetDateTimeUTC();
if(creation != null && creation.creationTime < removalTime)
StateSubscriptionGroups.instance.deleteSubscriptionGroup(removal.key, false);
}
}
GJSyncOpcodes.syncPlaylists -> {
val dataBody = ByteArray(data.remaining());
data.get(dataBody);
val json = String(dataBody, Charsets.UTF_8);
val pack = Serializer.json.decodeFromString<SyncPlaylistsPackage>(json);
for(playlist in pack.playlists) {
val existing = StatePlaylists.instance.getPlaylist(playlist.id);
if(existing == null)
StatePlaylists.instance.createOrUpdatePlaylist(playlist, false);
else if(existing.dateUpdate < playlist.dateUpdate) {
existing.dateUpdate = playlist.dateUpdate;
existing.name = playlist.name;
existing.videos = playlist.videos;
existing.dateCreation = playlist.dateCreation;
existing.datePlayed = playlist.datePlayed;
StatePlaylists.instance.createOrUpdatePlaylist(existing, false);
}
}
for(removal in pack.playlistRemovals) {
val creation = StatePlaylists.instance.getPlaylist(removal.key);
val removalTime = removal.value.sToOffsetDateTimeUTC();
if(creation != null && creation.dateCreation < removalTime)
StatePlaylists.instance.removePlaylist(creation, false);
}
}
GJSyncOpcodes.syncWatchLater -> {
val dataBody = ByteArray(data.remaining());
data.get(dataBody);
val json = String(dataBody, Charsets.UTF_8);
val pack = Serializer.json.decodeFromString<SyncWatchLaterPackage>(json);
Logger.i(TAG, "SyncWatchLater received ${pack.videos.size} (${pack.videoAdds?.size}, ${pack.videoRemovals?.size})");
val allExisting = StatePlaylists.instance.getWatchLater();
for(video in pack.videos) {
val existing = allExisting.firstOrNull { it.url == video.url };
val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) (pack.videoAdds[video.url] ?: 0).sToOffsetDateTimeUTC() else OffsetDateTime.MIN;
val removalTime = StatePlaylists.instance.getWatchLaterRemovalTime(video.url) ?: OffsetDateTime.MIN;
if(existing == null && time > removalTime) {
StatePlaylists.instance.addToWatchLater(video, false);
if(time > OffsetDateTime.MIN)
StatePlaylists.instance.setWatchLaterAddTime(video.url, time);
}
}
for(removal in pack.videoRemovals) {
val watchLater = allExisting.firstOrNull { it.url == removal.key } ?: continue;
val creation = StatePlaylists.instance.getWatchLaterRemovalTime(watchLater.url) ?: OffsetDateTime.MIN;
val removalTime = removal.value.sToOffsetDateTimeUTC()
if(creation < removalTime)
StatePlaylists.instance.removeFromWatchLater(watchLater, false, removalTime);
}
val packReorderTime = pack.reorderTime.sToOffsetDateTimeUTC()
val localReorderTime = StatePlaylists.instance.getWatchLaterLastReorderTime();
if(localReorderTime < packReorderTime && pack.ordering != null) {
StatePlaylists.instance.updateWatchLaterOrdering(smartMerge(pack.ordering!!, StatePlaylists.instance.getWatchLaterOrdering()), true);
}
}
GJSyncOpcodes.syncHistory -> {
val dataBody = ByteArray(data.remaining());
data.get(dataBody);
val json = String(dataBody, Charsets.UTF_8);
val history = Serializer.json.decodeFromString<List<HistoryVideo>>(json);
Logger.i(TAG, "SyncHistory received ${history.size} videos from ${remotePublicKey}");
if (history.size == 1) {
Logger.i(TAG, "SyncHistory received update video '${history[0].video.name}' (url: ${history[0].video.url}) at timestamp ${history[0].position}");
}
var lastHistory = OffsetDateTime.MIN;
for(video in history){
val hist = StateHistory.instance.getHistoryByVideo(video.video, true, video.date);
if(hist != null)
StateHistory.instance.updateHistoryPosition(video.video, hist, true, video.position, video.date)
if(lastHistory < video.date)
lastHistory = video.date;
}
if(lastHistory != OffsetDateTime.MIN && history.size > 1) {
val sesData = getSyncSessionData(remotePublicKey);
if (lastHistory > sesData.lastHistory) {
sesData.lastHistory = lastHistory;
saveSyncSessionData(sesData);
} }
} }
} }
} }
} }
private fun unauthorize(remotePublicKey: String) {
Logger.i(TAG, "${remotePublicKey} unauthorized received")
_authorizedDevices.remove(remotePublicKey)
_authorizedDevices.save()
deviceRemoved.emit(remotePublicKey)
}
private fun createSocketSession(socket: Socket, isResponder: Boolean, onAuthorized: (session: SyncSession, socketSession: SyncSocketSession) -> Unit): SyncSocketSession {
var session: SyncSession? = null
return SyncSocketSession((socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, keyPair!!, LittleEndianDataInputStream(socket.getInputStream()), LittleEndianDataOutputStream(socket.getOutputStream()),
onClose = { s ->
session?.removeSocketSession(s)
},
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})")
synchronized(_sessions) {
session = _sessions[s.remotePublicKey]
if (session == null) {
val remoteDeviceName = synchronized(_nameStorage) {
_nameStorage.get(remotePublicKey)
}
session = SyncSession(remotePublicKey, onAuthorized = { it, isNewlyAuthorized, isNewSession ->
if (!isNewSession) {
return@SyncSession
}
it.remoteDeviceName?.let { remoteDeviceName ->
synchronized(_nameStorage) {
_nameStorage.setAndSave(remotePublicKey, remoteDeviceName)
}
}
Logger.i(TAG, "${s.remotePublicKey} authorized (name: ${it.displayName})")
synchronized(_lastAddressStorage) {
_lastAddressStorage.setAndSave(remotePublicKey, s.remoteAddress)
}
onAuthorized(it, s)
_authorizedDevices.addDistinct(remotePublicKey)
_authorizedDevices.save()
deviceUpdatedOrAdded.emit(it.remotePublicKey, session!!)
checkForSync(it);
}, onUnauthorized = {
unauthorize(remotePublicKey)
synchronized(_sessions) {
session?.close()
_sessions.remove(remotePublicKey)
}
}, onConnectedChanged = { it, connected ->
Logger.i(TAG, "${s.remotePublicKey} connected: " + connected)
deviceUpdatedOrAdded.emit(it.remotePublicKey, session!!)
}, onClose = {
Logger.i(TAG, "${s.remotePublicKey} closed")
synchronized(_sessions)
{
_sessions.remove(it.remotePublicKey)
}
deviceRemoved.emit(it.remotePublicKey)
}, remoteDeviceName)
_sessions[remotePublicKey] = session!!
}
session!!.addSocketSession(s)
}
if (isResponder) {
val isAuthorized = synchronized(_authorizedDevices) {
_authorizedDevices.values.contains(remotePublicKey)
}
if (!isAuthorized) {
val scope = StateApp.instance.scopeOrNull
val activity = SyncShowPairingCodeActivity.activity
if (scope != null && activity != null) {
scope.launch(Dispatchers.Main) {
UIDialogs.showConfirmationDialog(activity, "Allow connection from ${remotePublicKey}?", action = {
scope.launch(Dispatchers.IO) {
try {
session!!.authorize(s)
Logger.i(TAG, "Connection authorized for $remotePublicKey by confirmation")
} catch (e: Throwable) {
Logger.e(TAG, "Failed to send authorize", e)
}
}
}, cancelAction = {
scope.launch(Dispatchers.IO) {
try {
unauthorize(remotePublicKey)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to send unauthorize", e)
}
synchronized(_sessions) {
session?.close()
_sessions.remove(remotePublicKey)
}
}
})
}
} else {
val publicKey = session!!.remotePublicKey
session!!.unauthorize(s)
session!!.close()
synchronized(_sessions) {
_sessions.remove(publicKey)
}
Logger.i(TAG, "Connection unauthorized for ${remotePublicKey} because not authorized and not on pairing activity to ask")
}
} else {
//Responder does not need to check because already approved
session!!.authorize(s)
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
session!!.authorize(s)
Logger.i(TAG, "Connection authorized for ${remotePublicKey} because initiator")
}
},
onData = { s, opcode, subOpcode, data ->
session?.handlePacket(s, opcode, subOpcode, data)
})
}
inline fun <reified T> broadcastJsonData(subOpcode: UByte, data: T) { inline fun <reified T> broadcastJsonData(subOpcode: UByte, data: T) {
broadcast(SyncSocketSession.Opcode.DATA.value, subOpcode, Json.encodeToString(data)); broadcast(Opcode.DATA.value, subOpcode, Json.encodeToString(data));
} }
fun broadcastData(subOpcode: UByte, data: String) { fun broadcastData(subOpcode: UByte, data: String) {
broadcast(SyncSocketSession.Opcode.DATA.value, subOpcode, data.toByteArray(Charsets.UTF_8)); broadcast(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)));
} }
fun broadcast(opcode: UByte, subOpcode: UByte, data: String) { fun broadcast(opcode: UByte, subOpcode: UByte, data: String) {
broadcast(opcode, subOpcode, data.toByteArray(Charsets.UTF_8)); broadcast(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)));
} }
fun broadcast(opcode: UByte, subOpcode: UByte, data: ByteArray) { fun broadcast(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
for(session in getAuthorizedSessions()) { for(session in getAuthorizedSessions()) {
try { try {
session.send(opcode, subOpcode, data); session.send(opcode, subOpcode, data);
@ -450,40 +475,17 @@ class StateSync {
} }
fun stop() { fun stop() {
_started = false syncService?.stop()
_serviceDiscoverer.stop() syncService = null
_serverSocket?.close()
_serverSocket = null
//_thread?.join()
_thread = null
_connectThread = null
} }
fun connect(deviceInfo: SyncDeviceInfo, onStatusUpdate: ((session: SyncSocketSession?, complete: Boolean, message: String) -> Unit)? = null): SyncSocketSession {
onStatusUpdate?.invoke(null, false, "Connecting...")
val socket = getConnectedSocket(deviceInfo.addresses.map { InetAddress.getByName(it) }, deviceInfo.port) ?: throw Exception("Failed to connect")
onStatusUpdate?.invoke(null, false, "Handshaking...")
val session = createSocketSession(socket, false) { _, ss ->
onStatusUpdate?.invoke(ss, true, "Handshake complete")
}
session.startAsInitiator(deviceInfo.publicKey)
return session
}
fun getAll(): List<String> { fun getAll(): List<String> {
synchronized(_authorizedDevices) { return syncService?.getAllAuthorizedDevices()?.toList() ?: listOf()
return _authorizedDevices.values.toList()
}
} }
fun getCachedName(publicKey: String): String? { fun getCachedName(publicKey: String): String? {
return synchronized(_nameStorage) { return syncService?.getCachedName(publicKey)
_nameStorage.get(publicKey)
}
} }
suspend fun delete(publicKey: String) { suspend fun delete(publicKey: String) {
@ -500,14 +502,8 @@ class StateSync {
session.close() session.close()
} }
synchronized(_sessions) { syncService?.removeSession(publicKey)
_sessions.remove(publicKey) syncService?.removeAuthorizedDevice(publicKey)
}
synchronized(_authorizedDevices) {
_authorizedDevices.remove(publicKey)
}
_authorizedDevices.save()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
deviceRemoved.emit(publicKey) deviceRemoved.emit(publicKey)
@ -516,16 +512,47 @@ class StateSync {
Logger.w(TAG, "Failed to delete", e) Logger.w(TAG, "Failed to delete", e)
} }
} }
}
class StoreBasedSyncDatabaseProvider : ISyncDatabaseProvider {
private val _authorizedDevices = FragmentedStorage.get<StringArrayStorage>("authorized_devices")
private val _nameStorage = FragmentedStorage.get<StringStringMapStorage>("sync_remembered_name_storage")
private val _syncKeyPair = FragmentedStorage.get<StringStorage>("sync_key_pair")
private val _lastAddressStorage = FragmentedStorage.get<StringStringMapStorage>("sync_last_address_storage")
override fun isAuthorized(publicKey: String): Boolean = synchronized(_authorizedDevices) { _authorizedDevices.values.contains(publicKey) }
override fun addAuthorizedDevice(publicKey: String) = synchronized(_authorizedDevices) {
_authorizedDevices.addDistinct(publicKey)
_authorizedDevices.save()
}
override fun removeAuthorizedDevice(publicKey: String) = synchronized(_authorizedDevices) {
_authorizedDevices.remove(publicKey)
_authorizedDevices.save()
}
override fun getAllAuthorizedDevices(): Array<String> = synchronized(_authorizedDevices) { _authorizedDevices.values.toTypedArray() }
override fun getAuthorizedDeviceCount(): Int = synchronized(_authorizedDevices) { _authorizedDevices.values.size }
override fun getSyncKeyPair(): SyncKeyPair? = try {
Json.decodeFromString<SyncKeyPair>(GEncryptionProvider.instance.decrypt(_syncKeyPair.value))
} catch (e: Throwable) { null }
override fun setSyncKeyPair(value: SyncKeyPair) { _syncKeyPair.setAndSave(GEncryptionProvider.instance.encrypt(Json.encodeToString(value))) }
override fun getLastAddress(publicKey: String): String? = synchronized(_lastAddressStorage) { _lastAddressStorage.map[publicKey] }
override fun setLastAddress(publicKey: String, address: String) = synchronized(_lastAddressStorage) {
_lastAddressStorage.map[publicKey] = address
_lastAddressStorage.save()
}
override fun getDeviceName(publicKey: String): String? = synchronized(_nameStorage) { _nameStorage.map[publicKey] }
override fun setDeviceName(publicKey: String, name: String) = synchronized(_nameStorage) {
_nameStorage.map[publicKey] = name
_nameStorage.save()
}
} }
companion object { companion object {
val dh = "25519"
val pattern = "IK"
val cipher = "ChaChaPoly"
val hash = "BLAKE2b"
var protocolName = "Noise_${pattern}_${dh}_${cipher}_${hash}"
val version = 1 val version = 1
val RELAY_SERVER = "relay.grayjay.app"
val SERVICE_NAME = "_gsync._tcp"
val RELAY_PUBLIC_KEY = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw="
val APP_ID = 0x534A5247u //GRayJaySync (GRJS)
private const val TAG = "StateSync" private const val TAG = "StateSync"
const val PORT = 12315 const val PORT = 12315

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

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

Some files were not shown because too many files have changed in this diff Show more