mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-08-13 11:39:49 +00:00
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:
commit
0653f88c49
196 changed files with 8046 additions and 4016 deletions
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
aar/* filter=lfs diff=lfs merge=lfs -text
|
||||
app/aar/* filter=lfs diff=lfs merge=lfs -text
|
12
.gitmodules
vendored
12
.gitmodules
vendored
|
@ -94,3 +94,15 @@
|
|||
[submodule "app/src/unstable/assets/sources/tedtalks"]
|
||||
path = app/src/unstable/assets/sources/tedtalks
|
||||
url = ../plugins/tedtalks.git
|
||||
[submodule "app/src/stable/assets/sources/curiositystream"]
|
||||
path = app/src/stable/assets/sources/curiositystream
|
||||
url = ../plugins/curiositystream.git
|
||||
[submodule "app/src/unstable/assets/sources/curiositystream"]
|
||||
path = app/src/unstable/assets/sources/curiositystream
|
||||
url = ../plugins/curiositystream.git
|
||||
[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
BIN
app/aar/ffmpeg-kit-full-6.0-2.LTS.aar
(Stored with Git LFS)
Normal file
Binary file not shown.
|
@ -198,7 +198,8 @@ dependencies {
|
|||
implementation 'org.jsoup:jsoup:1.15.3'
|
||||
implementation 'com.google.android.flexbox:flexbox:3.0.0'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'com.arthenica:ffmpeg-kit-full:6.0-2.LTS'
|
||||
implementation fileTree(dir: 'aar', include: ['*.aar'])
|
||||
implementation 'com.arthenica:smart-exception-java:0.2.1'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
|
||||
implementation 'com.github.dhaval2404:imagepicker:2.1'
|
||||
implementation 'com.google.zxing:core:3.4.1'
|
||||
|
|
|
@ -0,0 +1,338 @@
|
|||
package com.futo.platformplayer
|
||||
|
||||
import com.futo.platformplayer.noise.protocol.Noise
|
||||
import com.futo.platformplayer.sync.internal.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.selects.select
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.net.Socket
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class SyncServerTests {
|
||||
|
||||
//private val relayHost = "relay.grayjay.app"
|
||||
//private val relayKey = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw="
|
||||
private val relayKey = "XlUaSpIlRaCg0TGzZ7JYmPupgUHDqTZXUUBco2K7ejw="
|
||||
private val relayHost = "192.168.1.138"
|
||||
private val relayPort = 9000
|
||||
|
||||
/** Creates a client connected to the live relay server. */
|
||||
private suspend fun createClient(
|
||||
onHandshakeComplete: ((SyncSocketSession) -> Unit)? = null,
|
||||
onData: ((SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit)? = null,
|
||||
onNewChannel: ((SyncSocketSession, ChannelRelayed) -> Unit)? = null,
|
||||
isHandshakeAllowed: ((LinkType, SyncSocketSession, String, String?, UInt) -> Boolean)? = null,
|
||||
onException: ((Throwable) -> Unit)? = null
|
||||
): SyncSocketSession = withContext(Dispatchers.IO) {
|
||||
val p = Noise.createDH("25519")
|
||||
p.generateKeyPair()
|
||||
val socket = Socket(relayHost, relayPort)
|
||||
val inputStream = LittleEndianDataInputStream(socket.getInputStream())
|
||||
val outputStream = LittleEndianDataOutputStream(socket.getOutputStream())
|
||||
val tcs = CompletableDeferred<Boolean>()
|
||||
val socketSession = SyncSocketSession(
|
||||
relayHost,
|
||||
p,
|
||||
inputStream,
|
||||
outputStream,
|
||||
onClose = { socket.close() },
|
||||
onHandshakeComplete = { s ->
|
||||
onHandshakeComplete?.invoke(s)
|
||||
tcs.complete(true)
|
||||
},
|
||||
onData = onData ?: { _, _, _, _ -> },
|
||||
onNewChannel = onNewChannel ?: { _, _ -> },
|
||||
isHandshakeAllowed = isHandshakeAllowed ?: { _, _, _, _, _ -> true }
|
||||
)
|
||||
socketSession.authorizable = AlwaysAuthorized()
|
||||
try {
|
||||
socketSession.startAsInitiator(relayKey)
|
||||
} catch (e: Throwable) {
|
||||
onException?.invoke(e)
|
||||
}
|
||||
withTimeout(5000.milliseconds) { tcs.await() }
|
||||
return@withContext socketSession
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleClientsHandshake_Success() = runBlocking {
|
||||
val client1 = createClient()
|
||||
val client2 = createClient()
|
||||
assertNotNull(client1.remotePublicKey, "Client 1 handshake failed")
|
||||
assertNotNull(client2.remotePublicKey, "Client 2 handshake failed")
|
||||
client1.stop()
|
||||
client2.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publishAndRequestConnectionInfo_Authorized_Success() = runBlocking {
|
||||
val clientA = createClient()
|
||||
val clientB = createClient()
|
||||
val clientC = createClient()
|
||||
clientA.publishConnectionInformation(arrayOf(clientB.localPublicKey), 12345, true, true, true, true)
|
||||
delay(100.milliseconds)
|
||||
val infoB = clientB.requestConnectionInfo(clientA.localPublicKey)
|
||||
val infoC = clientC.requestConnectionInfo(clientA.localPublicKey)
|
||||
assertNotNull("Client B should receive connection info", infoB)
|
||||
assertEquals(12345.toUShort(), infoB!!.port)
|
||||
assertNull("Client C should not receive connection info (unauthorized)", infoC)
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
clientC.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayedTransport_Bidirectional_Success() = runBlocking {
|
||||
val tcsA = CompletableDeferred<ChannelRelayed>()
|
||||
val tcsB = CompletableDeferred<ChannelRelayed>()
|
||||
val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
|
||||
val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
|
||||
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
|
||||
val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
|
||||
channelA.authorizable = AlwaysAuthorized()
|
||||
val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
|
||||
channelB.authorizable = AlwaysAuthorized()
|
||||
channelTask.await()
|
||||
|
||||
val tcsDataB = CompletableDeferred<ByteArray>()
|
||||
channelB.setDataHandler { _, _, o, so, d ->
|
||||
val b = ByteArray(d.remaining())
|
||||
d.get(b)
|
||||
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
|
||||
}
|
||||
channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(1, 2, 3)))
|
||||
|
||||
val tcsDataA = CompletableDeferred<ByteArray>()
|
||||
channelA.setDataHandler { _, _, o, so, d ->
|
||||
val b = ByteArray(d.remaining())
|
||||
d.get(b)
|
||||
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataA.complete(b)
|
||||
}
|
||||
channelB.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(4, 5, 6)))
|
||||
|
||||
val receivedB = withTimeout(5000.milliseconds) { tcsDataB.await() }
|
||||
val receivedA = withTimeout(5000.milliseconds) { tcsDataA.await() }
|
||||
assertArrayEquals(byteArrayOf(1, 2, 3), receivedB)
|
||||
assertArrayEquals(byteArrayOf(4, 5, 6), receivedA)
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayedTransport_MaximumMessageSize_Success() = runBlocking {
|
||||
val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE - 8 - 16 - 16
|
||||
val maxSizeData = ByteArray(MAX_DATA_PER_PACKET).apply { Random.nextBytes(this) }
|
||||
val tcsA = CompletableDeferred<ChannelRelayed>()
|
||||
val tcsB = CompletableDeferred<ChannelRelayed>()
|
||||
val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
|
||||
val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
|
||||
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
|
||||
val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
|
||||
channelA.authorizable = AlwaysAuthorized()
|
||||
val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
|
||||
channelB.authorizable = AlwaysAuthorized()
|
||||
channelTask.await()
|
||||
|
||||
val tcsDataB = CompletableDeferred<ByteArray>()
|
||||
channelB.setDataHandler { _, _, o, so, d ->
|
||||
val b = ByteArray(d.remaining())
|
||||
d.get(b)
|
||||
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
|
||||
}
|
||||
channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxSizeData))
|
||||
val receivedData = withTimeout(5000.milliseconds) { tcsDataB.await() }
|
||||
assertArrayEquals(maxSizeData, receivedData)
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publishAndGetRecord_Success() = runBlocking {
|
||||
val clientA = createClient()
|
||||
val clientB = createClient()
|
||||
val clientC = createClient()
|
||||
val data = byteArrayOf(1, 2, 3)
|
||||
val success = clientA.publishRecords(listOf(clientB.localPublicKey), "testKey", data)
|
||||
val recordB = clientB.getRecord(clientA.localPublicKey, "testKey")
|
||||
val recordC = clientC.getRecord(clientA.localPublicKey, "testKey")
|
||||
assertTrue(success)
|
||||
assertNotNull(recordB)
|
||||
assertArrayEquals(data, recordB!!.first)
|
||||
assertNull("Unauthorized client should not access record", recordC)
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
clientC.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getNonExistentRecord_ReturnsNull() = runBlocking {
|
||||
val clientA = createClient()
|
||||
val clientB = createClient()
|
||||
val record = clientB.getRecord(clientA.localPublicKey, "nonExistentKey")
|
||||
assertNull("Getting non-existent record should return null", record)
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateRecord_TimestampUpdated() = runBlocking {
|
||||
val clientA = createClient()
|
||||
val clientB = createClient()
|
||||
val key = "updateKey"
|
||||
val data1 = byteArrayOf(1)
|
||||
val data2 = byteArrayOf(2)
|
||||
clientA.publishRecords(listOf(clientB.localPublicKey), key, data1)
|
||||
val record1 = clientB.getRecord(clientA.localPublicKey, key)
|
||||
delay(1000.milliseconds)
|
||||
clientA.publishRecords(listOf(clientB.localPublicKey), key, data2)
|
||||
val record2 = clientB.getRecord(clientA.localPublicKey, key)
|
||||
assertNotNull(record1)
|
||||
assertNotNull(record2)
|
||||
assertTrue(record2!!.second > record1!!.second)
|
||||
assertArrayEquals(data2, record2.first)
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteRecord_Success() = runBlocking {
|
||||
val clientA = createClient()
|
||||
val clientB = createClient()
|
||||
val data = byteArrayOf(1, 2, 3)
|
||||
clientA.publishRecords(listOf(clientB.localPublicKey), "toDelete", data)
|
||||
val success = clientB.deleteRecords(clientA.localPublicKey, clientB.localPublicKey, listOf("toDelete"))
|
||||
val record = clientB.getRecord(clientA.localPublicKey, "toDelete")
|
||||
assertTrue(success)
|
||||
assertNull(record)
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listRecordKeys_Success() = runBlocking {
|
||||
val clientA = createClient()
|
||||
val clientB = createClient()
|
||||
val keys = arrayOf("key1", "key2", "key3")
|
||||
keys.forEach { key ->
|
||||
clientA.publishRecords(listOf(clientB.localPublicKey), key, byteArrayOf(1))
|
||||
}
|
||||
val listedKeys = clientB.listRecordKeys(clientA.localPublicKey, clientB.localPublicKey)
|
||||
assertArrayEquals(keys, listedKeys.map { it.first }.toTypedArray())
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleLargeMessageViaRelayedChannel_Success() = runBlocking {
|
||||
val largeData = ByteArray(100000).apply { Random.nextBytes(this) }
|
||||
val tcsA = CompletableDeferred<ChannelRelayed>()
|
||||
val tcsB = CompletableDeferred<ChannelRelayed>()
|
||||
val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
|
||||
val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
|
||||
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
|
||||
val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
|
||||
channelA.authorizable = AlwaysAuthorized()
|
||||
val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
|
||||
channelB.authorizable = AlwaysAuthorized()
|
||||
channelTask.await()
|
||||
|
||||
val tcsDataB = CompletableDeferred<ByteArray>()
|
||||
channelB.setDataHandler { _, _, o, so, d ->
|
||||
val b = ByteArray(d.remaining())
|
||||
d.get(b)
|
||||
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
|
||||
}
|
||||
channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData))
|
||||
val receivedData = withTimeout(10000.milliseconds) { tcsDataB.await() }
|
||||
assertArrayEquals(largeData, receivedData)
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publishAndGetLargeRecord_Success() = runBlocking {
|
||||
val largeData = ByteArray(1000000).apply { Random.nextBytes(this) }
|
||||
val clientA = createClient()
|
||||
val clientB = createClient()
|
||||
val success = clientA.publishRecords(listOf(clientB.localPublicKey), "largeRecord", largeData)
|
||||
val record = clientB.getRecord(clientA.localPublicKey, "largeRecord")
|
||||
assertTrue(success)
|
||||
assertNotNull(record)
|
||||
assertArrayEquals(largeData, record!!.first)
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayedTransport_WithValidAppId_Success() = runBlocking {
|
||||
// Arrange: Set up clients
|
||||
val allowedAppId = 1234u
|
||||
val tcsB = CompletableDeferred<ChannelRelayed>()
|
||||
|
||||
// Client B requires appId 1234
|
||||
val clientB = createClient(
|
||||
onNewChannel = { _, c -> tcsB.complete(c) },
|
||||
isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId }
|
||||
)
|
||||
|
||||
val clientA = createClient()
|
||||
|
||||
// Act: Start relayed channel with valid appId
|
||||
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey, appId = allowedAppId) }
|
||||
val channelB = withTimeout(5.seconds) { tcsB.await() }
|
||||
withTimeout(5.seconds) { channelTask.await() }
|
||||
|
||||
// Assert: Channel is established
|
||||
assertNotNull("Channel should be created on target with valid appId", channelB)
|
||||
|
||||
// Clean up
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayedTransport_WithInvalidAppId_Fails() = runBlocking {
|
||||
// Arrange: Set up clients
|
||||
val allowedAppId = 1234u
|
||||
val invalidAppId = 5678u
|
||||
val tcsB = CompletableDeferred<ChannelRelayed>()
|
||||
|
||||
// Client B requires appId 1234
|
||||
val clientB = createClient(
|
||||
onNewChannel = { _, c -> tcsB.complete(c) },
|
||||
isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId },
|
||||
onException = { }
|
||||
)
|
||||
|
||||
val clientA = createClient()
|
||||
|
||||
// Act & Assert: Attempt with invalid appId should fail
|
||||
try {
|
||||
withTimeout(5.seconds) {
|
||||
clientA.startRelayedChannel(clientB.localPublicKey, appId = invalidAppId)
|
||||
}
|
||||
fail("Starting relayed channel with invalid appId should fail")
|
||||
} catch (e: Throwable) {
|
||||
// Expected: The channel creation should time out or fail
|
||||
}
|
||||
|
||||
// Ensure no channel was created on client B
|
||||
val completedTask = select {
|
||||
tcsB.onAwait { "channel" }
|
||||
async { delay(1.seconds); "timeout" }.onAwait { "timeout" }
|
||||
}
|
||||
assertEquals("No channel should be created with invalid appId", "timeout", completedTask)
|
||||
|
||||
// Clean up
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
}
|
||||
|
||||
class AlwaysAuthorized : IAuthorizable {
|
||||
override val isAuthorized: Boolean get() = true
|
||||
}
|
512
app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt
Normal file
512
app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt
Normal file
|
@ -0,0 +1,512 @@
|
|||
package com.futo.platformplayer
|
||||
|
||||
import com.futo.platformplayer.noise.protocol.DHState
|
||||
import com.futo.platformplayer.noise.protocol.Noise
|
||||
import com.futo.platformplayer.sync.internal.*
|
||||
import kotlinx.coroutines.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.io.PipedInputStream
|
||||
import java.io.PipedOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.random.Random
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
data class PipeStreams(
|
||||
val initiatorInput: LittleEndianDataInputStream,
|
||||
val initiatorOutput: LittleEndianDataOutputStream,
|
||||
val responderInput: LittleEndianDataInputStream,
|
||||
val responderOutput: LittleEndianDataOutputStream
|
||||
)
|
||||
|
||||
typealias OnHandshakeComplete = (SyncSocketSession) -> Unit
|
||||
typealias IsHandshakeAllowed = (LinkType, SyncSocketSession, String, String?, UInt) -> Boolean
|
||||
typealias OnClose = (SyncSocketSession) -> Unit
|
||||
typealias OnData = (SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit
|
||||
|
||||
class SyncSocketTests {
|
||||
private fun createPipeStreams(): PipeStreams {
|
||||
val initiatorOutput = PipedOutputStream()
|
||||
val responderOutput = PipedOutputStream()
|
||||
val responderInput = PipedInputStream(initiatorOutput)
|
||||
val initiatorInput = PipedInputStream(responderOutput)
|
||||
return PipeStreams(
|
||||
LittleEndianDataInputStream(initiatorInput), LittleEndianDataOutputStream(initiatorOutput),
|
||||
LittleEndianDataInputStream(responderInput), LittleEndianDataOutputStream(responderOutput)
|
||||
)
|
||||
}
|
||||
|
||||
fun generateKeyPair(): DHState {
|
||||
val p = Noise.createDH("25519")
|
||||
p.generateKeyPair()
|
||||
return p
|
||||
}
|
||||
|
||||
private fun createSessions(
|
||||
initiatorInput: LittleEndianDataInputStream,
|
||||
initiatorOutput: LittleEndianDataOutputStream,
|
||||
responderInput: LittleEndianDataInputStream,
|
||||
responderOutput: LittleEndianDataOutputStream,
|
||||
initiatorKeyPair: DHState,
|
||||
responderKeyPair: DHState,
|
||||
onInitiatorHandshakeComplete: OnHandshakeComplete,
|
||||
onResponderHandshakeComplete: OnHandshakeComplete,
|
||||
onInitiatorClose: OnClose? = null,
|
||||
onResponderClose: OnClose? = null,
|
||||
onClose: OnClose? = null,
|
||||
isHandshakeAllowed: IsHandshakeAllowed? = null,
|
||||
onDataA: OnData? = null,
|
||||
onDataB: OnData? = null
|
||||
): Pair<SyncSocketSession, SyncSocketSession> {
|
||||
val initiatorSession = SyncSocketSession(
|
||||
"", initiatorKeyPair, initiatorInput, initiatorOutput,
|
||||
onClose = {
|
||||
onClose?.invoke(it)
|
||||
onInitiatorClose?.invoke(it)
|
||||
},
|
||||
onHandshakeComplete = onInitiatorHandshakeComplete,
|
||||
onData = onDataA,
|
||||
isHandshakeAllowed = isHandshakeAllowed
|
||||
)
|
||||
|
||||
val responderSession = SyncSocketSession(
|
||||
"", responderKeyPair, responderInput, responderOutput,
|
||||
onClose = {
|
||||
onClose?.invoke(it)
|
||||
onResponderClose?.invoke(it)
|
||||
},
|
||||
onHandshakeComplete = onResponderHandshakeComplete,
|
||||
onData = onDataB,
|
||||
isHandshakeAllowed = isHandshakeAllowed
|
||||
)
|
||||
|
||||
return Pair(initiatorSession, responderSession)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handshake_WithValidPairingCode_Succeeds(): Unit = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
val validPairingCode = "secret"
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = validPairingCode)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(5.seconds) {
|
||||
handshakeInitiatorCompleted.await()
|
||||
handshakeResponderCompleted.await()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handshake_WithInvalidPairingCode_Fails() = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
val validPairingCode = "secret"
|
||||
val invalidPairingCode = "wrong"
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
val initiatorClosed = CompletableDeferred<Boolean>()
|
||||
val responderClosed = CompletableDeferred<Boolean>()
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
onInitiatorClose = {
|
||||
initiatorClosed.complete(true)
|
||||
},
|
||||
onResponderClose = {
|
||||
responderClosed.complete(true)
|
||||
},
|
||||
isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = invalidPairingCode)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(100.seconds) {
|
||||
initiatorClosed.await()
|
||||
responderClosed.await()
|
||||
}
|
||||
|
||||
assertFalse(handshakeInitiatorCompleted.isCompleted)
|
||||
assertFalse(handshakeResponderCompleted.isCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handshake_WithoutPairingCodeWhenRequired_Fails() = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
val validPairingCode = "secret"
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
val initiatorClosed = CompletableDeferred<Boolean>()
|
||||
val responderClosed = CompletableDeferred<Boolean>()
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
onInitiatorClose = {
|
||||
initiatorClosed.complete(true)
|
||||
},
|
||||
onResponderClose = {
|
||||
responderClosed.complete(true)
|
||||
},
|
||||
isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey) // No pairing code
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(5.seconds) {
|
||||
initiatorClosed.await()
|
||||
responderClosed.await()
|
||||
}
|
||||
|
||||
assertFalse(handshakeInitiatorCompleted.isCompleted)
|
||||
assertFalse(handshakeResponderCompleted.isCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handshake_WithPairingCodeWhenNotRequired_Succeeds(): Unit = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
val pairingCode = "unnecessary"
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
isHandshakeAllowed = { _, _, _, _, _ -> true } // Always allow
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = pairingCode)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(10.seconds) {
|
||||
handshakeInitiatorCompleted.await()
|
||||
handshakeResponderCompleted.await()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendAndReceive_SmallDataPacket_Succeeds() = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
val tcsDataReceived = CompletableDeferred<ByteArray>()
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
onDataB = { _, opcode, subOpcode, data ->
|
||||
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
|
||||
val b = ByteArray(data.remaining())
|
||||
data.get(b)
|
||||
tcsDataReceived.complete(b)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(10.seconds) {
|
||||
handshakeInitiatorCompleted.await()
|
||||
handshakeResponderCompleted.await()
|
||||
}
|
||||
|
||||
// Ensure both sessions are authorized
|
||||
initiatorSession.authorizable = Authorized()
|
||||
responderSession.authorizable = Authorized()
|
||||
|
||||
val smallData = byteArrayOf(1, 2, 3)
|
||||
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(smallData))
|
||||
|
||||
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
|
||||
assertArrayEquals(smallData, receivedData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendAndReceive_ExactlyMaximumPacketSize_Succeeds() = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
val tcsDataReceived = CompletableDeferred<ByteArray>()
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
onDataB = { _, opcode, subOpcode, data ->
|
||||
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
|
||||
val b = ByteArray(data.remaining())
|
||||
data.get(b)
|
||||
tcsDataReceived.complete(b)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(10.seconds) {
|
||||
handshakeInitiatorCompleted.await()
|
||||
handshakeResponderCompleted.await()
|
||||
}
|
||||
|
||||
// Ensure both sessions are authorized
|
||||
initiatorSession.authorizable = Authorized()
|
||||
responderSession.authorizable = Authorized()
|
||||
|
||||
val maxData = ByteArray(SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE).apply { Random.nextBytes(this) }
|
||||
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxData))
|
||||
|
||||
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
|
||||
assertArrayEquals(maxData, receivedData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stream_LargeData_Succeeds() = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
val tcsDataReceived = CompletableDeferred<ByteArray>()
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
onDataB = { _, opcode, subOpcode, data ->
|
||||
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
|
||||
val b = ByteArray(data.remaining())
|
||||
data.get(b)
|
||||
tcsDataReceived.complete(b)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(10.seconds) {
|
||||
handshakeInitiatorCompleted.await()
|
||||
handshakeResponderCompleted.await()
|
||||
}
|
||||
|
||||
// Ensure both sessions are authorized
|
||||
initiatorSession.authorizable = Authorized()
|
||||
responderSession.authorizable = Authorized()
|
||||
|
||||
val largeData = ByteArray(2 * (SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE)).apply { Random.nextBytes(this) }
|
||||
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData))
|
||||
|
||||
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
|
||||
assertArrayEquals(largeData, receivedData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authorizedSession_CanSendData() = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
val tcsDataReceived = CompletableDeferred<ByteArray>()
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
onDataB = { _, opcode, subOpcode, data ->
|
||||
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
|
||||
val b = ByteArray(data.remaining())
|
||||
data.get(b)
|
||||
tcsDataReceived.complete(b)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(10.seconds) {
|
||||
handshakeInitiatorCompleted.await()
|
||||
handshakeResponderCompleted.await()
|
||||
}
|
||||
|
||||
// Authorize both sessions
|
||||
initiatorSession.authorizable = Authorized()
|
||||
responderSession.authorizable = Authorized()
|
||||
|
||||
val data = byteArrayOf(1, 2, 3)
|
||||
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data))
|
||||
|
||||
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
|
||||
assertArrayEquals(data, receivedData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unauthorizedSession_CannotSendData() = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
val tcsDataReceived = CompletableDeferred<ByteArray>()
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
onDataB = { _, _, _, _ -> }
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(10.seconds) {
|
||||
handshakeInitiatorCompleted.await()
|
||||
handshakeResponderCompleted.await()
|
||||
}
|
||||
|
||||
// Authorize initiator but not responder
|
||||
initiatorSession.authorizable = Authorized()
|
||||
responderSession.authorizable = Unauthorized()
|
||||
|
||||
val data = byteArrayOf(1, 2, 3)
|
||||
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data))
|
||||
|
||||
delay(1.seconds)
|
||||
assertFalse(tcsDataReceived.isCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun directHandshake_WithValidAppId_Succeeds() = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
val allowedAppId = 1234u
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
|
||||
val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt ->
|
||||
linkType == LinkType.Direct && appId == allowedAppId
|
||||
}
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
isHandshakeAllowed = responderIsHandshakeAllowed
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = allowedAppId)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(5.seconds) {
|
||||
handshakeInitiatorCompleted.await()
|
||||
handshakeResponderCompleted.await()
|
||||
}
|
||||
|
||||
assertNotNull(initiatorSession.remotePublicKey)
|
||||
assertNotNull(responderSession.remotePublicKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun directHandshake_WithInvalidAppId_Fails() = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
val allowedAppId = 1234u
|
||||
val invalidAppId = 5678u
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
val initiatorClosed = CompletableDeferred<Boolean>()
|
||||
val responderClosed = CompletableDeferred<Boolean>()
|
||||
|
||||
val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt ->
|
||||
linkType == LinkType.Direct && appId == allowedAppId
|
||||
}
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
onInitiatorClose = {
|
||||
initiatorClosed.complete(true)
|
||||
},
|
||||
onResponderClose = {
|
||||
responderClosed.complete(true)
|
||||
},
|
||||
isHandshakeAllowed = responderIsHandshakeAllowed
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = invalidAppId)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(5.seconds) {
|
||||
initiatorClosed.await()
|
||||
responderClosed.await()
|
||||
}
|
||||
|
||||
assertFalse(handshakeInitiatorCompleted.isCompleted)
|
||||
assertFalse(handshakeResponderCompleted.isCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
class Authorized : IAuthorizable {
|
||||
override val isAuthorized: Boolean = true
|
||||
}
|
||||
|
||||
class Unauthorized : IAuthorizable {
|
||||
override val isAuthorized: Boolean = false
|
||||
}
|
|
@ -15,6 +15,7 @@
|
|||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
@ -55,7 +56,7 @@
|
|||
|
||||
<activity
|
||||
android:name=".activities.MainActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||
android:launchMode="singleInstance"
|
||||
|
@ -239,4 +240,4 @@
|
|||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
</application>
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
|
|
@ -32,7 +32,8 @@ let Type = {
|
|||
Text: {
|
||||
RAW: 0,
|
||||
HTML: 1,
|
||||
MARKUP: 2
|
||||
MARKUP: 2,
|
||||
CODE: 3
|
||||
},
|
||||
Chapter: {
|
||||
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) {
|
||||
super(obj, 3);
|
||||
obj = obj ?? {};
|
||||
this.plugin_type = "PlatformArticleDetails";
|
||||
this.rating = obj.rating ?? new RatingLikes(-1);
|
||||
this.summary = obj.summary ?? "";
|
||||
this.segments = obj.segments ?? [];
|
||||
this.thumbnails = obj.thumbnails ?? new Thumbnails([]);
|
||||
}
|
||||
}
|
||||
class ArticleSegment {
|
||||
|
@ -315,9 +340,17 @@ class ArticleTextSegment extends ArticleSegment {
|
|||
}
|
||||
}
|
||||
class ArticleImagesSegment extends ArticleSegment {
|
||||
constructor(images) {
|
||||
constructor(images, caption) {
|
||||
super(2);
|
||||
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 {
|
||||
|
@ -595,6 +628,8 @@ class PlatformComment {
|
|||
this.date = obj.date ?? 0;
|
||||
this.replyCount = obj.replyCount ?? 0;
|
||||
this.context = obj.context ?? {};
|
||||
if(obj.getReplies)
|
||||
this.getReplies = obj.getReplies;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,7 +14,6 @@ import java.text.DecimalFormat
|
|||
import java.time.OffsetDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
|
||||
|
@ -376,14 +375,19 @@ private val slds = hashSetOf(".com.ac", ".net.ac", ".gov.ac", ".org.ac", ".mil.a
|
|||
fun String.matchesDomain(queryDomain: String): Boolean {
|
||||
|
||||
if(queryDomain.startsWith(".")) {
|
||||
|
||||
val parts = queryDomain.lowercase().split(".");
|
||||
if(parts.size < 3)
|
||||
val parts = this.lowercase().split(".");
|
||||
val queryParts = queryDomain.lowercase().trimStart("."[0]).split(".");
|
||||
if(queryParts.size < 2)
|
||||
throw IllegalStateException("Illegal use of wildcards on First-Level-Domain (" + queryDomain + ")");
|
||||
if(parts.size >= 3){
|
||||
val isSLD = slds.contains("." + parts[parts.size - 2] + "." + parts[parts.size - 1]);
|
||||
if(isSLD && parts.size <= 3)
|
||||
else {
|
||||
val possibleDomain = "." + queryParts.joinToString(".");
|
||||
if(slds.contains(possibleDomain))
|
||||
throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")");
|
||||
/*
|
||||
val isSLD = slds.contains("." + queryParts[queryParts.size - 2] + "." + queryParts[queryParts.size - 1]);
|
||||
if(isSLD && queryParts.size <= 3)
|
||||
throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")");
|
||||
*/
|
||||
}
|
||||
|
||||
//TODO: Should be safe, but double verify if can't be exploited
|
||||
|
@ -395,9 +399,11 @@ fun String.matchesDomain(queryDomain: String): Boolean {
|
|||
|
||||
fun String.getSubdomainWildcardQuery(): String {
|
||||
val domainParts = this.split(".");
|
||||
val sldParts = "." + domainParts[domainParts.size - 2].lowercase() + "." + domainParts[domainParts.size - 1].lowercase();
|
||||
if(slds.contains(sldParts))
|
||||
return "." + domainParts.drop(domainParts.size - 3).joinToString(".");
|
||||
var wildcardDomain = if(domainParts.size > 2)
|
||||
"." + domainParts.drop(1).joinToString(".")
|
||||
else
|
||||
return "." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||
"." + domainParts.joinToString(".");
|
||||
if(slds.contains(wildcardDomain.lowercase()))
|
||||
"." + domainParts.joinToString(".");
|
||||
return wildcardDomain;
|
||||
}
|
|
@ -217,6 +217,8 @@ private fun ByteArray.toInetAddress(): InetAddress {
|
|||
}
|
||||
|
||||
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
|
||||
ensureNotMainThread()
|
||||
|
||||
val timeout = 2000
|
||||
|
||||
|
||||
|
|
|
@ -7,6 +7,9 @@ import java.net.InetAddress
|
|||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URLEncoder
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
//Syntax sugaring
|
||||
inline fun <reified T> Any.assume(): T?{
|
||||
|
@ -33,13 +36,37 @@ fun Boolean?.toYesNo(): String {
|
|||
fun InetAddress?.toUrlAddress(): String {
|
||||
return when (this) {
|
||||
is Inet6Address -> {
|
||||
"[${hostAddress}]"
|
||||
val hostAddr = this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
|
||||
val index = hostAddr.indexOf('%')
|
||||
if (index != -1) {
|
||||
val addrPart = hostAddr.substring(0, index)
|
||||
val scopeId = hostAddr.substring(index + 1)
|
||||
"[${addrPart}%25${scopeId}]" // %25 is URL-encoded '%'
|
||||
} else {
|
||||
"[$hostAddr]"
|
||||
}
|
||||
}
|
||||
is Inet4Address -> {
|
||||
hostAddress
|
||||
this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
|
||||
}
|
||||
else -> {
|
||||
throw Exception("Invalid address type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Long?.sToOffsetDateTimeUTC(): OffsetDateTime {
|
||||
if (this == null || this < 0)
|
||||
return OffsetDateTime.MIN
|
||||
if(this > 4070912400)
|
||||
return OffsetDateTime.MAX;
|
||||
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(this), ZoneOffset.UTC)
|
||||
}
|
||||
|
||||
fun Long?.msToOffsetDateTimeUTC(): OffsetDateTime {
|
||||
if (this == null || this < 0)
|
||||
return OffsetDateTime.MIN
|
||||
if(this > 4070912400)
|
||||
return OffsetDateTime.MAX;
|
||||
return OffsetDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneOffset.UTC)
|
||||
}
|
|
@ -499,6 +499,22 @@ class Settings : FragmentedStorageFileJson() {
|
|||
|
||||
@FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22)
|
||||
var deleteFromWatchLaterAuto: Boolean = true;
|
||||
|
||||
@FormField(R.string.seek_offset, FieldForm.DROPDOWN, R.string.seek_offset_description, 23)
|
||||
@DropdownFieldOptionsId(R.array.seek_offset_duration)
|
||||
var seekOffset: Int = 2;
|
||||
|
||||
fun getSeekOffset(): Long {
|
||||
return when(seekOffset) {
|
||||
0 -> 3_000L;
|
||||
1 -> 5_000L;
|
||||
2 -> 10_000L;
|
||||
3 -> 20_000L;
|
||||
4 -> 30_000L;
|
||||
5 -> 60_000L;
|
||||
else -> 10_000L;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||
|
@ -590,7 +606,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
|
||||
@FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var allowIpv6: Boolean = false;
|
||||
var allowIpv6: Boolean = true;
|
||||
|
||||
/*TODO: Should we have a different casting quality?
|
||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||
|
@ -926,7 +942,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
@Serializable
|
||||
class Synchronization {
|
||||
@FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1)
|
||||
var enabled: Boolean = true;
|
||||
var enabled: Boolean = false;
|
||||
|
||||
@FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1)
|
||||
var broadcast: Boolean = false;
|
||||
|
@ -936,6 +952,21 @@ class Settings : FragmentedStorageFileJson() {
|
|||
|
||||
@FormField(R.string.connect_last, FieldForm.TOGGLE, R.string.connect_last_description, 3)
|
||||
var connectLast: Boolean = true;
|
||||
|
||||
@FormField(R.string.discover_through_relay, FieldForm.TOGGLE, R.string.discover_through_relay_description, 3)
|
||||
var discoverThroughRelay: Boolean = true;
|
||||
|
||||
@FormField(R.string.pair_through_relay, FieldForm.TOGGLE, R.string.pair_through_relay_description, 3)
|
||||
var pairThroughRelay: Boolean = true;
|
||||
|
||||
@FormField(R.string.connect_through_relay, FieldForm.TOGGLE, R.string.connect_through_relay_description, 3)
|
||||
var connectThroughRelay: Boolean = true;
|
||||
|
||||
@FormField(R.string.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)
|
||||
|
@ -1004,4 +1035,4 @@ class Settings : FragmentedStorageFileJson() {
|
|||
}
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
}
|
||||
|
|
|
@ -319,7 +319,11 @@ class UIDialogs {
|
|||
closeAction?.invoke()
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
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)
|
||||
);
|
||||
else
|
||||
|
@ -333,7 +337,11 @@ class UIDialogs {
|
|||
closeAction?.invoke()
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,8 +4,14 @@ import android.app.NotificationManager
|
|||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
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 com.futo.platformplayer.activities.MainActivity
|
||||
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.SubscriptionGroup
|
||||
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.StateDownloads
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
|
@ -63,6 +72,8 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayInputStream
|
||||
import androidx.core.net.toUri
|
||||
|
||||
class UISlideOverlays {
|
||||
companion object {
|
||||
|
@ -299,6 +310,7 @@ class UISlideOverlays {
|
|||
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
|
||||
val items = arrayListOf<View>(LoaderView(container.context))
|
||||
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()
|
||||
?: throw Exception("Master playlist content is empty")
|
||||
|
||||
val resolvedPlaylistUrl = masterPlaylistResponse.url
|
||||
|
||||
val videoButtons = arrayListOf<SlideUpMenuItem>()
|
||||
val audioButtons = arrayListOf<SlideUpMenuItem>()
|
||||
//TODO: Implement subtitles
|
||||
|
@ -322,55 +336,103 @@ class UISlideOverlays {
|
|||
|
||||
val masterPlaylist: HLS.MasterPlaylist
|
||||
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 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()){
|
||||
val estSize = VideoHelper.estimateSourceSize(variant);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
audioButtons.add(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_music,
|
||||
variant.name,
|
||||
listOf(variant.language, variant.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
|
||||
(prefix + variant.codec).trim(),
|
||||
tag = variant,
|
||||
call = {
|
||||
selectedAudioVariant = variant
|
||||
slideUpMenuOverlay.selectOption(audioButtons, variant)
|
||||
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>()
|
||||
|
@ -398,11 +460,11 @@ class UISlideOverlays {
|
|||
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
|
||||
withContext(Dispatchers.Main) {
|
||||
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")
|
||||
slideUpMenuOverlay.hide()
|
||||
} 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")
|
||||
slideUpMenuOverlay.hide()
|
||||
} 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() };
|
||||
|
@ -980,26 +1046,30 @@ class UISlideOverlays {
|
|||
+ actions).filterNotNull()
|
||||
));
|
||||
items.add(
|
||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuGroup(
|
||||
container.context, container.context.getString(R.string.add_to), "addto",
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_queue_add,
|
||||
container.context.getString(R.string.add_to_queue),
|
||||
"${queue.size} " + container.context.getString(R.string.videos),
|
||||
tag = "queue",
|
||||
call = { StatePlayer.instance.addToQueue(video); }),
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_watchlist_add,
|
||||
"${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "",
|
||||
"${watchLater.size} " + container.context.getString(R.string.videos),
|
||||
tag = "watch later",
|
||||
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }),
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_history,
|
||||
container.context.getString(R.string.add_to_history),
|
||||
"Mark as watched",
|
||||
tag = "history",
|
||||
call = { StateHistory.instance.markAsWatched(video); }),
|
||||
));
|
||||
));
|
||||
|
||||
val playlistItems = arrayListOf<SlideUpMenuItem>();
|
||||
playlistItems.add(SlideUpMenuItem(
|
||||
|
@ -1063,14 +1133,17 @@ class UISlideOverlays {
|
|||
val queue = StatePlayer.instance.getQueue();
|
||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||
items.add(
|
||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other",
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuGroup(
|
||||
container.context, container.context.getString(R.string.other), "other",
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_queue_add,
|
||||
container.context.getString(R.string.queue),
|
||||
"${queue.size} " + container.context.getString(R.string.videos),
|
||||
tag = "queue",
|
||||
call = { StatePlayer.instance.addToQueue(video); }),
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_watchlist_add,
|
||||
StatePlayer.TYPE_WATCHLATER,
|
||||
"${watchLater.size} " + container.context.getString(R.string.videos),
|
||||
|
@ -1079,7 +1152,7 @@ class UISlideOverlays {
|
|||
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true))
|
||||
UIDialogs.appToast("Added to watch later", false);
|
||||
}),
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
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() };
|
||||
}
|
||||
|
||||
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>, isChannelSearch: Boolean = false): SlideUpMenuFilters {
|
||||
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues, isChannelSearch);
|
||||
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>): SlideUpMenuFilters {
|
||||
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues);
|
||||
overlay.show();
|
||||
return overlay;
|
||||
}
|
||||
|
|
|
@ -28,12 +28,17 @@ import com.futo.platformplayer.models.PlatformVideoWithTime
|
|||
import com.futo.platformplayer.others.PlatformLinkMovementMethod
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
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.ByteOrder
|
||||
import java.security.SecureRandom
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.*
|
||||
import java.util.concurrent.ThreadLocalRandom
|
||||
|
@ -70,7 +75,14 @@ fun warnIfMainThread(context: String) {
|
|||
}
|
||||
|
||||
fun ensureNotMainThread() {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
val isMainLooper = try {
|
||||
Looper.myLooper() == Looper.getMainLooper()
|
||||
} catch (e: Throwable) {
|
||||
//Ignore, for unit tests where its not mocked
|
||||
false
|
||||
}
|
||||
|
||||
if (isMainLooper) {
|
||||
Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread")
|
||||
throw IllegalStateException("Cannot run on main thread")
|
||||
}
|
||||
|
@ -273,7 +285,7 @@ fun <T> findNewIndex(originalArr: List<T>, newArr: List<T>, item: T): Int{
|
|||
}
|
||||
}
|
||||
if(newIndex < 0)
|
||||
return originalArr.size;
|
||||
return newArr.size;
|
||||
else
|
||||
return newIndex;
|
||||
}
|
||||
|
@ -284,6 +296,18 @@ fun ByteBuffer.toUtf8String(): String {
|
|||
return String(remainingBytes, Charsets.UTF_8)
|
||||
}
|
||||
|
||||
fun generateReadablePassword(length: Int): String {
|
||||
val validChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"
|
||||
val secureRandom = SecureRandom()
|
||||
val randomBytes = ByteArray(length)
|
||||
secureRandom.nextBytes(randomBytes)
|
||||
val sb = StringBuilder(length)
|
||||
for (byte in randomBytes) {
|
||||
val index = (byte.toInt() and 0xFF) % validChars.length
|
||||
sb.append(validChars[index])
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
fun ByteArray.toGzip(): ByteArray {
|
||||
if (this == null || this.isEmpty()) return ByteArray(0)
|
||||
|
@ -313,4 +337,98 @@ fun ByteArray.fromGzip(): ByteArray {
|
|||
}
|
||||
}
|
||||
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.16–31/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)
|
|
@ -1,12 +1,15 @@
|
|||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.AlertDialog
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.VmPolicy
|
||||
|
@ -19,6 +22,7 @@ import android.widget.ImageView
|
|||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
|
@ -28,6 +32,8 @@ import androidx.fragment.app.Fragment
|
|||
import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.whenStateAtLeast
|
||||
import androidx.lifecycle.withStateAtLeast
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
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.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ArticleDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.BuyFragment
|
||||
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.TutorialFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.State
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.WebDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
|
||||
|
@ -144,6 +154,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
//Frags Main
|
||||
lateinit var _fragMainHome: HomeFragment;
|
||||
lateinit var _fragPostDetail: PostDetailFragment;
|
||||
lateinit var _fragArticleDetail: ArticleDetailFragment;
|
||||
lateinit var _fragWebDetail: WebDetailFragment;
|
||||
lateinit var _fragMainVideoSearchResults: ContentSearchResultsFragment;
|
||||
lateinit var _fragMainCreatorSearchResults: CreatorSearchResultsFragment;
|
||||
lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment;
|
||||
|
@ -184,6 +196,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
|
||||
private var _isVisible = true;
|
||||
private var _wasStopped = false;
|
||||
private var _privateModeEnabled = false
|
||||
private var _pictureInPictureEnabled = false
|
||||
private var _isFullscreen = false
|
||||
|
||||
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
|
@ -195,7 +210,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
}
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
lifecycleScope.launch {
|
||||
handleUrlAll(content)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
|
@ -261,6 +276,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
StateApp.instance.mainAppStarting(this);
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val uiMode = getSystemService(UiModeManager::class.java)
|
||||
uiMode.setApplicationNightMode(UiModeManager.MODE_NIGHT_YES)
|
||||
}
|
||||
setContentView(R.layout.activity_main);
|
||||
setNavigationBarColorAndIcons();
|
||||
if (Settings.instance.playback.allowVideoToGoUnderCutout)
|
||||
|
@ -268,7 +287,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
|
||||
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
|
||||
|
@ -312,6 +335,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
_fragMainPlaylist = PlaylistFragment.newInstance();
|
||||
_fragMainRemotePlaylist = RemotePlaylistFragment.newInstance();
|
||||
_fragPostDetail = PostDetailFragment.newInstance();
|
||||
_fragArticleDetail = ArticleDetailFragment.newInstance();
|
||||
_fragWebDetail = WebDetailFragment.newInstance();
|
||||
_fragWatchlist = WatchLaterFragment.newInstance();
|
||||
_fragHistory = HistoryFragment.newInstance();
|
||||
_fragShorts = ShortsFragment.newInstance();
|
||||
|
@ -358,22 +383,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
_fragMainSubscriptionsFeed.setPreviewsEnabled(true);
|
||||
_fragContainerVideoDetail.visibility = View.INVISIBLE;
|
||||
updateSegmentPaddings();
|
||||
updatePrivateModeVisibility()
|
||||
};
|
||||
|
||||
|
||||
_buttonIncognito = findViewById(R.id.incognito_button);
|
||||
_buttonIncognito.elevation = -99f;
|
||||
_buttonIncognito.alpha = 0f;
|
||||
updatePrivateModeVisibility()
|
||||
StateApp.instance.privateModeChanged.subscribe {
|
||||
//Messing with visibility causes some issues with layout ordering?
|
||||
if (it) {
|
||||
_buttonIncognito.elevation = 99f;
|
||||
_buttonIncognito.alpha = 1f;
|
||||
} else {
|
||||
_buttonIncognito.elevation = -99f;
|
||||
_buttonIncognito.alpha = 0f;
|
||||
}
|
||||
_privateModeEnabled = it
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
_buttonIncognito.setOnClickListener {
|
||||
if (!StateApp.instance.privateMode)
|
||||
return@setOnClickListener;
|
||||
|
@ -390,19 +411,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
};
|
||||
_fragVideoDetail.onFullscreenChanged.subscribe {
|
||||
Logger.i(TAG, "onFullscreenChanged ${it}");
|
||||
_isFullscreen = it
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
if (it) {
|
||||
_buttonIncognito.elevation = -99f;
|
||||
_buttonIncognito.alpha = 0f;
|
||||
} else {
|
||||
if (StateApp.instance.privateMode) {
|
||||
_buttonIncognito.elevation = 99f;
|
||||
_buttonIncognito.alpha = 1f;
|
||||
} else {
|
||||
_buttonIncognito.elevation = -99f;
|
||||
_buttonIncognito.alpha = 0f;
|
||||
}
|
||||
}
|
||||
_fragVideoDetail.onMinimize.subscribe {
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
_fragVideoDetail.onMaximized.subscribe {
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
StatePlayer.instance.also {
|
||||
|
@ -450,6 +468,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
_fragMainPlaylist.topBar = _fragTopBarNavigation;
|
||||
_fragMainRemotePlaylist.topBar = _fragTopBarNavigation;
|
||||
_fragPostDetail.topBar = _fragTopBarNavigation;
|
||||
_fragArticleDetail.topBar = _fragTopBarNavigation;
|
||||
_fragWebDetail.topBar = _fragTopBarNavigation;
|
||||
_fragWatchlist.topBar = _fragTopBarNavigation;
|
||||
_fragHistory.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");
|
||||
}*/
|
||||
|
||||
private var _qrCodeLoadingDialog: AlertDialog? = null
|
||||
|
||||
fun showUrlQrCodeScanner() {
|
||||
try {
|
||||
_qrCodeLoadingDialog = UIDialogs.showDialog(this, R.drawable.ic_loader_animated, true,
|
||||
"Launching QR scanner",
|
||||
"Make sure your camera is enabled", null, -2,
|
||||
UIDialogs.Action("Close", {
|
||||
_qrCodeLoadingDialog?.dismiss()
|
||||
_qrCodeLoadingDialog = null
|
||||
}));
|
||||
|
||||
val integrator = IntentIntegrator(this)
|
||||
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
||||
integrator.setPrompt(getString(R.string.scan_a_qr_code))
|
||||
|
@ -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() {
|
||||
super.onResume();
|
||||
Logger.v(TAG, "onResume")
|
||||
|
@ -644,6 +686,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
super.onPause();
|
||||
Logger.v(TAG, "onPause")
|
||||
_isVisible = false;
|
||||
|
||||
_qrCodeLoadingDialog?.dismiss()
|
||||
_qrCodeLoadingDialog = null
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
|
@ -682,7 +727,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
|
||||
"VIDEO" -> {
|
||||
val url = intent.getStringExtra("VIDEO");
|
||||
navigate(_fragVideoDetail, url);
|
||||
navigateWhenReady(_fragVideoDetail, url);
|
||||
}
|
||||
|
||||
"IMPORT_OPTIONS" -> {
|
||||
|
@ -700,11 +745,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
"Sources" -> {
|
||||
runBlocking {
|
||||
StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed..
|
||||
navigate(_fragMainSources);
|
||||
navigateWhenReady(_fragMainSources);
|
||||
}
|
||||
};
|
||||
"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 ->
|
||||
StateApp.instance.contextOrNull?.let {
|
||||
if (it is MainActivity) {
|
||||
|
@ -722,8 +767,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
|
||||
try {
|
||||
if (targetData != null) {
|
||||
runBlocking {
|
||||
handleUrlAll(targetData)
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
handleUrlAll(targetData)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
|
@ -751,10 +800,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
startActivity(intent);
|
||||
} else if (url.startsWith("grayjay://video/")) {
|
||||
val videoUrl = url.substring("grayjay://video/".length);
|
||||
navigate(_fragVideoDetail, videoUrl);
|
||||
navigateWhenReady(_fragVideoDetail, videoUrl);
|
||||
} else if (url.startsWith("grayjay://channel/")) {
|
||||
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) {
|
||||
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");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (position > 0)
|
||||
navigate(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
|
||||
navigateWhenReady(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
|
||||
else
|
||||
navigate(_fragVideoDetail, url);
|
||||
navigateWhenReady(_fragVideoDetail, url);
|
||||
|
||||
_fragVideoDetail.maximizeVideoDetail(true);
|
||||
}
|
||||
return@withContext true;
|
||||
} else if (StatePlatform.instance.hasEnabledChannelClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found channel client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
navigate(_fragMainChannel, url);
|
||||
withContext(Dispatchers.Main) {
|
||||
navigateWhenReady(_fragMainChannel, url);
|
||||
delay(100);
|
||||
_fragVideoDetail.minimizeVideoDetail();
|
||||
};
|
||||
return@withContext true;
|
||||
} else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found playlist client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
navigate(_fragMainRemotePlaylist, url);
|
||||
withContext(Dispatchers.Main) {
|
||||
navigateWhenReady(_fragMainRemotePlaylist, url);
|
||||
delay(100);
|
||||
_fragVideoDetail.minimizeVideoDetail();
|
||||
};
|
||||
|
@ -1054,6 +1103,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
Logger.v(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop")
|
||||
_fragVideoDetail.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig);
|
||||
Logger.v(TAG, "onPictureInPictureModeChanged Ready");
|
||||
|
||||
_pictureInPictureEnabled = isInPictureInPictureMode
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
@ -1066,6 +1118,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
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
|
||||
* 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;
|
||||
RemotePlaylistFragment::class -> _fragMainRemotePlaylist as T;
|
||||
PostDetailFragment::class -> _fragPostDetail as T;
|
||||
ArticleDetailFragment::class -> _fragArticleDetail as T;
|
||||
WebDetailFragment::class -> _fragWebDetail as T;
|
||||
WatchLaterFragment::class -> _fragWatchlist as T;
|
||||
HistoryFragment::class -> _fragHistory as T;
|
||||
ShortsFragment::class -> _fragShorts as T;
|
||||
|
|
|
@ -9,6 +9,8 @@ import android.widget.LinearLayout
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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.states.StateApp
|
||||
import com.futo.platformplayer.states.StateSync
|
||||
|
@ -29,6 +31,16 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (StateApp.instance.contextOrNull == null) {
|
||||
Logger.w(TAG, "No main activity, restarting main.")
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
startActivity(intent)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
setContentView(R.layout.activity_sync_home)
|
||||
setNavigationBarColorAndIcons()
|
||||
|
||||
|
@ -54,7 +66,6 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||
val view = _viewMap[publicKey]
|
||||
if (!session.isAuthorized) {
|
||||
if (view != null) {
|
||||
_layoutDevices.removeView(view)
|
||||
_viewMap.remove(publicKey)
|
||||
}
|
||||
return@launch
|
||||
|
@ -89,6 +100,20 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||
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() {
|
||||
|
@ -100,10 +125,12 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||
|
||||
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
|
||||
val connected = session?.connected ?: false
|
||||
syncDeviceView.setLinkType(if (connected) LinkType.Local else LinkType.None)
|
||||
val authorized = session?.isAuthorized ?: false
|
||||
|
||||
syncDeviceView.setLinkType(session?.linkType ?: LinkType.None)
|
||||
.setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey)
|
||||
//TODO: also display public key?
|
||||
.setStatus(if (connected) "Connected" else "Disconnected")
|
||||
.setStatus(if (connected && authorized) "Connected" else "Disconnected or unauthorized")
|
||||
return syncDeviceView
|
||||
}
|
||||
|
||||
|
|
|
@ -83,6 +83,7 @@ class SyncPairActivity : AppCompatActivity() {
|
|||
|
||||
_layoutPairingSuccess.setOnClickListener {
|
||||
_layoutPairingSuccess.visibility = View.GONE
|
||||
finish()
|
||||
}
|
||||
_layoutPairingError.setOnClickListener {
|
||||
_layoutPairingError.visibility = View.GONE
|
||||
|
@ -109,11 +110,17 @@ class SyncPairActivity : AppCompatActivity() {
|
|||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
StateSync.instance.connect(deviceInfo) { session, complete, message ->
|
||||
StateSync.instance.syncService?.connect(deviceInfo) { complete, message ->
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
if (complete) {
|
||||
_layoutPairingSuccess.visibility = View.VISIBLE
|
||||
_layoutPairing.visibility = View.GONE
|
||||
if (complete != null) {
|
||||
if (complete) {
|
||||
_layoutPairingSuccess.visibility = View.VISIBLE
|
||||
_layoutPairing.visibility = View.GONE
|
||||
} else {
|
||||
_textError.text = message
|
||||
_layoutPairingError.visibility = View.VISIBLE
|
||||
_layoutPairing.visibility = View.GONE
|
||||
}
|
||||
} else {
|
||||
_textPairingStatus.text = message
|
||||
}
|
||||
|
@ -137,8 +144,6 @@ class SyncPairActivity : AppCompatActivity() {
|
|||
_textError.text = e.message
|
||||
_layoutPairing.visibility = View.GONE
|
||||
Logger.e(TAG, "Failed to pair", e)
|
||||
} finally {
|
||||
_layoutPairing.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -67,11 +67,18 @@ class SyncShowPairingCodeActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
val ips = getIPs()
|
||||
val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT)
|
||||
val json = Json.encodeToString(selfDeviceInfo)
|
||||
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
val url = "grayjay://sync/${base64}"
|
||||
setCode(url)
|
||||
val publicKey = StateSync.instance.syncService?.publicKey
|
||||
val pairingCode = StateSync.instance.syncService?.pairingCode
|
||||
if (publicKey == null || pairingCode == null) {
|
||||
setCode("Public key or pairing code was not known, is sync enabled?")
|
||||
} else {
|
||||
val selfDeviceInfo = SyncDeviceInfo(publicKey, ips.toTypedArray(), StateSync.PORT, pairingCode)
|
||||
val json = Json.encodeToString(selfDeviceInfo)
|
||||
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
val url = "grayjay://sync/${base64}"
|
||||
setCode(url)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun setCode(code: String?) {
|
||||
|
|
|
@ -90,6 +90,7 @@ open class ManagedHttpClient {
|
|||
}
|
||||
|
||||
fun tryHead(url: String): Map<String, String>? {
|
||||
ensureNotMainThread()
|
||||
try {
|
||||
val result = head(url);
|
||||
if(result.isOk)
|
||||
|
@ -104,7 +105,7 @@ open class ManagedHttpClient {
|
|||
}
|
||||
|
||||
fun socket(url: String, headers: MutableMap<String, String> = HashMap(), listener: SocketListener): Socket {
|
||||
|
||||
ensureNotMainThread()
|
||||
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
|
||||
.url(url);
|
||||
if(user_agent.isNotEmpty() && !headers.any { it.key.lowercase() == "user-agent" })
|
||||
|
@ -300,6 +301,7 @@ open class ManagedHttpClient {
|
|||
}
|
||||
|
||||
fun send(msg: String) {
|
||||
ensureNotMainThread()
|
||||
socket.send(msg);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.futo.platformplayer.api.media
|
||||
|
||||
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
|
@ -72,6 +73,11 @@ interface IPlatformClient {
|
|||
*/
|
||||
fun searchChannels(query: String): IPager<PlatformAuthorLink>;
|
||||
|
||||
/**
|
||||
* Searches for channels and returns a content pager
|
||||
*/
|
||||
fun searchChannelsAsContent(query: String): IPager<IPlatformContent>;
|
||||
|
||||
|
||||
//Video Pages
|
||||
/**
|
||||
|
|
|
@ -14,14 +14,16 @@ class PlatformClientPool {
|
|||
private var _poolCounter = 0;
|
||||
private val _poolName: String?;
|
||||
private val _privatePool: Boolean;
|
||||
private val _isolatedInitialization: Boolean
|
||||
|
||||
var isDead: Boolean = false
|
||||
private set;
|
||||
val onDead = Event2<JSClient, PlatformClientPool>();
|
||||
|
||||
constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false) {
|
||||
constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false, isolatedInitialization: Boolean = false) {
|
||||
_poolName = name;
|
||||
_privatePool = privatePool;
|
||||
_isolatedInitialization = isolatedInitialization
|
||||
if(parentClient !is JSClient)
|
||||
throw IllegalArgumentException("Pooling only supported for JSClients right now");
|
||||
Logger.i(TAG, "Pool for ${parentClient.name} was started");
|
||||
|
@ -53,7 +55,7 @@ class PlatformClientPool {
|
|||
reserved = _pool.keys.find { !it.isBusy };
|
||||
if(reserved == null && _pool.size < capacity) {
|
||||
Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})");
|
||||
reserved = _parent.getCopy(_privatePool);
|
||||
reserved = _parent.getCopy(_privatePool, _isolatedInitialization);
|
||||
|
||||
reserved?.onCaptchaException?.subscribe { client, ex ->
|
||||
StateApp.instance.handleCaptchaException(client, ex);
|
||||
|
|
|
@ -7,13 +7,15 @@ class PlatformMultiClientPool {
|
|||
|
||||
private var _isFake = false;
|
||||
private var _privatePool = false;
|
||||
private val _isolatedInitialization: Boolean
|
||||
|
||||
constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false) {
|
||||
constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false, isolatedInitialization: Boolean = false) {
|
||||
_name = name;
|
||||
_maxCap = if(maxCap > 0)
|
||||
maxCap
|
||||
else 99;
|
||||
_privatePool = isPrivatePool;
|
||||
_isolatedInitialization = isolatedInitialization
|
||||
}
|
||||
|
||||
fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient {
|
||||
|
@ -21,7 +23,7 @@ class PlatformMultiClientPool {
|
|||
return parentClient;
|
||||
val pool = synchronized(_clientPools) {
|
||||
if(!_clientPools.containsKey(parentClient))
|
||||
_clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool).apply {
|
||||
_clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool, _isolatedInitialization).apply {
|
||||
this.onDead.subscribe { _, pool ->
|
||||
synchronized(_clientPools) {
|
||||
if(_clientPools[parentClient] == pool)
|
||||
|
|
|
@ -2,7 +2,10 @@ package com.futo.platformplayer.api.media.models
|
|||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSContent
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
|
@ -42,4 +45,21 @@ open class PlatformAuthorLink {
|
|||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface IPlatformChannelContent : IPlatformContent {
|
||||
val thumbnail: String?
|
||||
val subscribers: Long?
|
||||
}
|
||||
|
||||
open class JSChannelContent : JSContent, IPlatformChannelContent {
|
||||
override val contentType: ContentType get() = ContentType.CHANNEL
|
||||
override val thumbnail: String?
|
||||
override val subscribers: Long?
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
|
||||
val contextName = "Channel";
|
||||
thumbnail = obj.getOrDefault<String>(config, "thumbnail", contextName, null)
|
||||
subscribers = if(obj.has("subscribers")) obj.getOrThrow(config,"subscribers", contextName) else null
|
||||
}
|
||||
}
|
|
@ -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?;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -8,10 +8,12 @@ enum class ContentType(val value: Int) {
|
|||
POST(2),
|
||||
ARTICLE(3),
|
||||
PLAYLIST(4),
|
||||
WEB(7),
|
||||
|
||||
URL(9),
|
||||
|
||||
NESTED_VIDEO(11),
|
||||
CHANNEL(60),
|
||||
|
||||
LOCKED(70),
|
||||
|
||||
|
|
|
@ -5,7 +5,8 @@ import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException
|
|||
enum class TextType(val value: Int) {
|
||||
RAW(0),
|
||||
HTML(1),
|
||||
MARKUP(2);
|
||||
MARKUP(2),
|
||||
CODE(3);
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int): TextType
|
||||
|
|
|
@ -54,8 +54,11 @@ class DevJSClient : JSClient {
|
|||
return DevJSClient(context, config, _devScript, _auth, _captcha, devID, descriptor.settings);
|
||||
}
|
||||
|
||||
override fun getCopy(privateCopy: Boolean): JSClient {
|
||||
return DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, saveState(), devID);
|
||||
override fun getCopy(privateCopy: Boolean, noSaveState: Boolean): JSClient {
|
||||
val client = DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, if (noSaveState) null else saveState(), devID);
|
||||
if (noSaveState)
|
||||
client.initialize()
|
||||
return client
|
||||
}
|
||||
|
||||
override fun initialize() {
|
||||
|
|
|
@ -10,6 +10,7 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
|||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.PlatformClientCapabilities
|
||||
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
|
@ -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.IJSContentDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChannel
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelContentPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChapter
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSComment
|
||||
|
@ -196,8 +198,11 @@ open class JSClient : IPlatformClient {
|
|||
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
|
||||
}
|
||||
|
||||
open fun getCopy(withoutCredentials: Boolean = false): JSClient {
|
||||
return JSClient(_context, descriptor, saveState(), _script, withoutCredentials);
|
||||
open fun getCopy(withoutCredentials: Boolean = false, noSaveState: Boolean = false): JSClient {
|
||||
val client = JSClient(_context, descriptor, if (noSaveState) null else saveState(), _script, withoutCredentials);
|
||||
if (noSaveState)
|
||||
client.initialize()
|
||||
return client
|
||||
}
|
||||
|
||||
fun getUnderlyingPlugin(): V8Plugin {
|
||||
|
@ -212,6 +217,8 @@ open class JSClient : IPlatformClient {
|
|||
}
|
||||
|
||||
override fun initialize() {
|
||||
if (_initialized) return
|
||||
|
||||
Logger.i(TAG, "Plugin [${config.name}] initializing");
|
||||
plugin.start();
|
||||
plugin.execute("plugin.config = ${Json.encodeToString(config)}");
|
||||
|
@ -371,6 +378,10 @@ open class JSClient : IPlatformClient {
|
|||
return@isBusyWith JSChannelPager(config, this,
|
||||
plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"));
|
||||
}
|
||||
override fun searchChannelsAsContent(query: String): IPager<IPlatformContent> = isBusyWith("searchChannels") {
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSChannelContentPager(config, this, plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"), );
|
||||
}
|
||||
|
||||
@JSDocs(6, "source.isChannelUrl(url)", "Validates if an channel url is for this platform")
|
||||
@JSDocsParameter("url", "A channel url (May not be your platform)")
|
||||
|
|
|
@ -127,7 +127,7 @@ class JSHttpClient : ManagedHttpClient {
|
|||
}
|
||||
|
||||
if(doApplyCookies) {
|
||||
if (_currentCookieMap.isNotEmpty()) {
|
||||
if (_currentCookieMap.isNotEmpty() || _otherCookieMap.isNotEmpty()) {
|
||||
val cookiesToApply = hashMapOf<String, String>();
|
||||
synchronized(_currentCookieMap) {
|
||||
for(cookie in _currentCookieMap
|
||||
|
@ -135,6 +135,12 @@ class JSHttpClient : ManagedHttpClient {
|
|||
.flatMap { it.value.toList() })
|
||||
cookiesToApply[cookie.first] = cookie.second;
|
||||
};
|
||||
synchronized(_otherCookieMap) {
|
||||
for(cookie in _otherCookieMap
|
||||
.filter { domain.matchesDomain(it.key) }
|
||||
.flatMap { it.value.toList() })
|
||||
cookiesToApply[cookie.first] = cookie.second;
|
||||
}
|
||||
|
||||
if(cookiesToApply.size > 0) {
|
||||
val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; ");
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.JSChannelContent
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
|
@ -26,6 +27,9 @@ interface IJSContent: IPlatformContent {
|
|||
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
|
||||
ContentType.PLAYLIST -> JSPlaylist(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}");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ interface IJSContentDetails: IPlatformContent {
|
|||
ContentType.MEDIA -> JSVideoDetails(plugin, obj);
|
||||
ContentType.POST -> JSPostDetails(plugin.config, obj);
|
||||
ContentType.ARTICLE -> JSArticleDetails(plugin, obj);
|
||||
ContentType.WEB -> JSWebDetails(plugin, obj);
|
||||
else -> throw NotImplementedError("Unknown content type ${type}");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
||||
}
|
||||
}
|
|
@ -4,6 +4,8 @@ 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.article.IPlatformArticleDetails
|
||||
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
|
||||
|
@ -21,20 +23,20 @@ import com.futo.platformplayer.getOrThrow
|
|||
import com.futo.platformplayer.getOrThrowNullableList
|
||||
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;
|
||||
|
||||
private val _hasGetComments: Boolean;
|
||||
private val _hasGetContentRecommendations: Boolean;
|
||||
|
||||
val rating: IRating;
|
||||
override val rating: IRating;
|
||||
|
||||
val summary: String;
|
||||
val thumbnails: Thumbnails?;
|
||||
val segments: List<IJSArticleSegment>;
|
||||
override val summary: String;
|
||||
override val thumbnails: Thumbnails?;
|
||||
override val segments: List<IJSArticleSegment>;
|
||||
|
||||
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);
|
||||
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"))) {
|
||||
SegmentType.TEXT -> JSTextSegment(client, obj);
|
||||
SegmentType.IMAGES -> JSImagesSegment(client, obj);
|
||||
SegmentType.HEADER -> JSHeaderSegment(client, obj);
|
||||
SegmentType.NESTED -> JSNestedSegment(client, obj);
|
||||
else -> null;
|
||||
}
|
||||
|
@ -110,6 +113,7 @@ enum class SegmentType(val value: Int) {
|
|||
UNKNOWN(0),
|
||||
TEXT(1),
|
||||
IMAGES(2),
|
||||
HEADER(3),
|
||||
|
||||
NESTED(9);
|
||||
|
||||
|
@ -150,6 +154,17 @@ class JSImagesSegment: IJSArticleSegment {
|
|||
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 {
|
||||
override val type = SegmentType.NESTED;
|
||||
val nested: IPlatformContent;
|
||||
|
|
|
@ -5,7 +5,6 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
|||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
|
||||
class JSChannelPager : JSPager<PlatformAuthorLink>, IPager<PlatformAuthorLink> {
|
||||
|
||||
|
|
|
@ -49,8 +49,8 @@ open class JSContent : IPlatformContent, IPluginSourced {
|
|||
else
|
||||
author = PlatformAuthorLink.UNKNOWN;
|
||||
|
||||
val datetimeInt = _content.getOrThrow<Int>(config, "datetime", contextName).toLong();
|
||||
if(datetimeInt == 0.toLong())
|
||||
val datetimeInt = _content.getOrDefault<Int>(config, "datetime", contextName, null)?.toLong();
|
||||
if(datetimeInt == null || datetimeInt == 0.toLong())
|
||||
datetime = null;
|
||||
else
|
||||
datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js.models
|
|||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.models.JSChannelContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
|
@ -15,4 +16,14 @@ class JSContentPager : JSPager<IPlatformContent>, IPluginSourced {
|
|||
override fun convertResult(obj: V8ValueObject): IPlatformContent {
|
||||
return IJSContent.fromV8(plugin, obj);
|
||||
}
|
||||
}
|
||||
|
||||
class JSChannelContentPager : JSPager<IPlatformContent>, IPluginSourced {
|
||||
override val sourceConfig: SourcePluginConfig get() = config;
|
||||
|
||||
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {}
|
||||
|
||||
override fun convertResult(obj: V8ValueObject): IPlatformContent {
|
||||
return JSChannelContent(config, obj);
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
}
|
|
@ -149,6 +149,7 @@ class AirPlayCastingDevice : CastingDevice {
|
|||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
|
||||
delay(1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -108,7 +108,7 @@ abstract class CastingDevice {
|
|||
|
||||
val expectedCurrentTime: Double
|
||||
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;
|
||||
};
|
||||
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
|
||||
|
|
|
@ -10,7 +10,9 @@ import com.futo.platformplayer.toHexString
|
|||
import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
|
@ -56,6 +58,10 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
private var _mediaSessionId: Int? = null;
|
||||
private var _thread: 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() {
|
||||
this.name = name;
|
||||
|
@ -229,6 +235,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
launchObject.put("appId", "CC1AD845");
|
||||
launchObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
||||
_lastLaunchTime_ms = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
private fun getStatus() {
|
||||
|
@ -268,6 +275,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
_contentType = null;
|
||||
_streamType = null;
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_transportId = null;
|
||||
}
|
||||
|
||||
|
@ -282,6 +290,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
|
||||
_started = true;
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_mediaSessionId = null;
|
||||
|
||||
Logger.i(TAG, "Starting...");
|
||||
|
@ -322,6 +331,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
break;
|
||||
} catch (e: Throwable) {
|
||||
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
|
||||
while (_scopeIO?.isActive == true) {
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_mediaSessionId = null;
|
||||
|
||||
Logger.i(TAG, "Connecting to Chromecast.");
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
|
@ -392,7 +406,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
|
||||
synchronized(_inputStreamLock)
|
||||
val message = synchronized(_inputStreamLock)
|
||||
{
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
val b1 = inputStream.readUnsignedByte();
|
||||
|
@ -404,7 +418,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||
inputStream.skip(size.toLong());
|
||||
return@synchronized
|
||||
return@synchronized null
|
||||
}
|
||||
|
||||
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?
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
Logger.i(TAG, "Received message: $message");
|
||||
val msg = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||
if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
Logger.i(TAG, "Received message: $msg");
|
||||
}
|
||||
return@synchronized msg
|
||||
}
|
||||
|
||||
if (message != null) {
|
||||
try {
|
||||
handleMessage(message);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e);
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e: java.net.SocketException) {
|
||||
|
@ -485,6 +503,10 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
}
|
||||
} catch (e: Throwable) {
|
||||
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) {
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
_sessionId = applicationUpdate.getString("sessionId");
|
||||
_launchRetries = 0
|
||||
|
||||
val transportId = applicationUpdate.getString("transportId");
|
||||
connectMediaChannel(transportId);
|
||||
|
@ -525,21 +548,40 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
}
|
||||
|
||||
if (!sessionIsRunning) {
|
||||
_sessionId = null;
|
||||
_mediaSessionId = null;
|
||||
setTime(0.0);
|
||||
_transportId = null;
|
||||
Logger.w(TAG, "Session not found.");
|
||||
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
|
||||
_sessionId = null
|
||||
_mediaSessionId = null
|
||||
setTime(0.0)
|
||||
_transportId = null
|
||||
|
||||
if (_launching) {
|
||||
Logger.i(TAG, "Player not found, launching.");
|
||||
launchPlayer();
|
||||
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
|
||||
_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 {
|
||||
Logger.i(TAG, "Player not found, disconnecting.");
|
||||
stop();
|
||||
if (_retryJob == null) {
|
||||
Logger.i(TAG, "Scheduled retry job over 5 seconds")
|
||||
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
|
||||
delay(5000)
|
||||
getStatus()
|
||||
_retryJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_launching = false;
|
||||
_launching = false
|
||||
_launchRetries = 0
|
||||
}
|
||||
|
||||
val volume = status.getJSONObject("volume");
|
||||
|
@ -566,7 +608,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
}
|
||||
|
||||
isPlaying = playerState == "PLAYING";
|
||||
if (isPlaying) {
|
||||
if (isPlaying || playerState == "PAUSED") {
|
||||
setTime(currentTime);
|
||||
}
|
||||
|
||||
|
@ -581,6 +623,8 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
if (message.sourceId == "receiver-0") {
|
||||
Logger.i(TAG, "Close received.");
|
||||
stop();
|
||||
} else if (_transportId == message.sourceId) {
|
||||
throw Exception("Transport id closed.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -615,6 +659,9 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
localAddress = null;
|
||||
_started = false;
|
||||
|
||||
_retryJob?.cancel()
|
||||
_retryJob = null
|
||||
|
||||
val socket = _socket;
|
||||
val scopeIO = _scopeIO;
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ import com.futo.platformplayer.toInetAddress
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
@ -92,7 +93,7 @@ class FCastCastingDevice : CastingDevice {
|
|||
private var _version: Long = 1;
|
||||
private var _thread: Thread? = null
|
||||
private var _pingThread: Thread? = null
|
||||
private var _lastPongTime = -1L
|
||||
@Volatile private var _lastPongTime = System.currentTimeMillis()
|
||||
private var _outputStreamLock = Object()
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
|
@ -289,6 +290,7 @@ class FCastCastingDevice : CastingDevice {
|
|||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -326,9 +328,9 @@ class FCastCastingDevice : CastingDevice {
|
|||
continue;
|
||||
}
|
||||
|
||||
localAddress = _socket?.localAddress;
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
_lastPongTime = -1L
|
||||
localAddress = _socket?.localAddress
|
||||
_lastPongTime = System.currentTimeMillis()
|
||||
connectionState = CastConnectionState.CONNECTED
|
||||
|
||||
val buffer = ByteArray(4096);
|
||||
|
||||
|
@ -404,36 +406,32 @@ class FCastCastingDevice : CastingDevice {
|
|||
|
||||
_pingThread = Thread {
|
||||
Logger.i(TAG, "Started ping loop.")
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
send(Opcode.Ping)
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to send ping.")
|
||||
|
||||
if (connectionState == CastConnectionState.CONNECTED) {
|
||||
try {
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
send(Opcode.Ping)
|
||||
if (System.currentTimeMillis() - _lastPongTime > 15000) {
|
||||
Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.")
|
||||
try {
|
||||
_socket?.close()
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to close socket.", e)
|
||||
Log.w(TAG, "Failed to send ping.")
|
||||
try {
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*if (_lastPongTime != -1L && System.currentTimeMillis() - _lastPongTime > 6000) {
|
||||
Logger.w(TAG, "Closing socket due to last pong time being larger than 6 seconds.")
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
}*/
|
||||
|
||||
Thread.sleep(2000)
|
||||
Thread.sleep(5000)
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped ping loop.");
|
||||
Logger.i(TAG, "Stopped ping loop.")
|
||||
}.apply { start() }
|
||||
} else {
|
||||
Log.i(TAG, "Thread was still alive, not restarted")
|
||||
|
|
|
@ -4,10 +4,14 @@ import android.app.AlertDialog
|
|||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.util.Xml
|
||||
import java.net.NetworkInterface
|
||||
import java.net.Inet4Address
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
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.Event2
|
||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||
import com.futo.platformplayer.findPreferredAddress
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.mdns.DnsService
|
||||
import com.futo.platformplayer.mdns.ServiceDiscoverer
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.parsers.HLS
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
|
@ -55,10 +58,11 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import java.util.Collections
|
||||
import java.util.UUID
|
||||
|
||||
class StateCasting {
|
||||
|
@ -70,7 +74,6 @@ class StateCasting {
|
|||
private var _started = false;
|
||||
|
||||
var devices: HashMap<String, CastingDevice> = hashMapOf();
|
||||
var rememberedDevices: ArrayList<CastingDevice> = arrayListOf();
|
||||
val onDeviceAdded = Event1<CastingDevice>();
|
||||
val onDeviceChanged = Event1<CastingDevice>();
|
||||
val onDeviceRemoved = Event1<CastingDevice>();
|
||||
|
@ -84,48 +87,15 @@ class StateCasting {
|
|||
private var _audioExecutor: JSRequestExecutor? = null
|
||||
private val _client = ManagedHttpClient();
|
||||
var _resumeCastingDevice: CastingDeviceInfo? = null;
|
||||
val _serviceDiscoverer = ServiceDiscoverer(arrayOf(
|
||||
"_googlecast._tcp.local",
|
||||
"_airplay._tcp.local",
|
||||
"_fastcast._tcp.local",
|
||||
"_fcast._tcp.local"
|
||||
)) { handleServiceUpdated(it) }
|
||||
|
||||
private var _nsdManager: NsdManager? = null
|
||||
val isCasting: Boolean get() = activeDevice != null;
|
||||
|
||||
private fun handleServiceUpdated(services: List<DnsService>) {
|
||||
for (s in services) {
|
||||
//TODO: Addresses IPv4 only?
|
||||
val addresses = s.addresses.toTypedArray()
|
||||
val port = s.port.toInt()
|
||||
var name = s.texts.firstOrNull { it.startsWith("md=") }?.substring("md=".length)
|
||||
if (s.name.endsWith("._googlecast._tcp.local")) {
|
||||
if (name == null) {
|
||||
name = s.name.substring(0, s.name.length - "._googlecast._tcp.local".length)
|
||||
}
|
||||
|
||||
addOrUpdateChromeCastDevice(name, addresses, port)
|
||||
} else if (s.name.endsWith("._airplay._tcp.local")) {
|
||||
if (name == null) {
|
||||
name = s.name.substring(0, s.name.length - "._airplay._tcp.local".length)
|
||||
}
|
||||
|
||||
addOrUpdateAirPlayDevice(name, addresses, port)
|
||||
} else if (s.name.endsWith("._fastcast._tcp.local")) {
|
||||
if (name == null) {
|
||||
name = s.name.substring(0, s.name.length - "._fastcast._tcp.local".length)
|
||||
}
|
||||
|
||||
addOrUpdateFastCastDevice(name, addresses, port)
|
||||
} else if (s.name.endsWith("._fcast._tcp.local")) {
|
||||
if (name == null) {
|
||||
name = s.name.substring(0, s.name.length - "._fcast._tcp.local".length)
|
||||
}
|
||||
|
||||
addOrUpdateFastCastDevice(name, addresses, port)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val _discoveryListeners = mapOf(
|
||||
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
|
||||
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
|
||||
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
|
||||
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
|
||||
)
|
||||
|
||||
fun handleUrl(context: Context, url: String) {
|
||||
val uri = Uri.parse(url)
|
||||
|
@ -190,30 +160,33 @@ class StateCasting {
|
|||
|
||||
Logger.i(TAG, "CastingService starting...");
|
||||
|
||||
rememberedDevices.clear();
|
||||
rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) });
|
||||
|
||||
_castServer.start();
|
||||
enableDeveloper(true);
|
||||
|
||||
Logger.i(TAG, "CastingService started.");
|
||||
|
||||
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun startDiscovering() {
|
||||
try {
|
||||
_serviceDiscoverer.start()
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to start ServiceDiscoverer", e)
|
||||
_nsdManager?.apply {
|
||||
_discoveryListeners.forEach {
|
||||
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stopDiscovering() {
|
||||
try {
|
||||
_serviceDiscoverer.stop()
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to stop ServiceDiscoverer", e)
|
||||
_nsdManager?.apply {
|
||||
_discoveryListeners.forEach {
|
||||
try {
|
||||
stopServiceDiscovery(it.value)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -239,6 +212,85 @@ class StateCasting {
|
|||
_castServer.removeAllHandlers();
|
||||
|
||||
Logger.i(TAG, "CastingService stopped.")
|
||||
|
||||
_nsdManager = null
|
||||
}
|
||||
|
||||
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
|
||||
return object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(regType: String) {
|
||||
Log.d(TAG, "Service discovery started for $regType")
|
||||
}
|
||||
|
||||
override fun onDiscoveryStopped(serviceType: String) {
|
||||
Log.i(TAG, "Discovery stopped: $serviceType")
|
||||
}
|
||||
|
||||
override fun onServiceLost(service: NsdServiceInfo) {
|
||||
Log.e(TAG, "service lost: $service")
|
||||
// TODO: Handle service lost, e.g., remove device
|
||||
}
|
||||
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
|
||||
try {
|
||||
_nsdManager?.stopServiceDiscovery(this)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
|
||||
try {
|
||||
_nsdManager?.stopServiceDiscovery(this)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceFound(service: NsdServiceInfo) {
|
||||
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
|
||||
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
service.hostAddresses.toTypedArray()
|
||||
} else {
|
||||
arrayOf(service.host)
|
||||
}
|
||||
addOrUpdate(service.serviceName, addresses, service.port)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
_nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback {
|
||||
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "onServiceUpdated: $serviceInfo")
|
||||
addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port)
|
||||
}
|
||||
|
||||
override fun onServiceLost() {
|
||||
Log.v(TAG, "onServiceLost: $service")
|
||||
// TODO: Handle service lost
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
||||
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackUnregistered() {
|
||||
Log.v(TAG, "onServiceInfoCallbackUnregistered")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
Log.v(TAG, "Resolve failed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
|
||||
addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _castingDialogLock = Any();
|
||||
|
@ -294,7 +346,9 @@ class StateCasting {
|
|||
UIDialogs.toast(it, "Connecting to device...")
|
||||
synchronized(_castingDialogLock) {
|
||||
if(_currentDialog == null) {
|
||||
_currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, "Connecting to [${device.name}]", "Make sure you are on the same network", 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", {
|
||||
device.stop();
|
||||
}));
|
||||
|
@ -329,9 +383,6 @@ class StateCasting {
|
|||
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
|
||||
};
|
||||
|
||||
addRememberedDevice(device);
|
||||
Logger.i(TAG, "Device added to active discovery. Active discovery now contains ${_storage.getDevicesCount()} devices.")
|
||||
|
||||
try {
|
||||
device.start();
|
||||
} catch (e: Throwable) {
|
||||
|
@ -353,21 +404,22 @@ class StateCasting {
|
|||
return addRememberedDevice(device);
|
||||
}
|
||||
|
||||
fun getRememberedCastingDevices(): List<CastingDevice> {
|
||||
return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) }
|
||||
}
|
||||
|
||||
fun getRememberedCastingDeviceNames(): List<String> {
|
||||
return _storage.getDeviceNames()
|
||||
}
|
||||
|
||||
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
|
||||
val deviceInfo = device.getDeviceInfo()
|
||||
val foundInfo = _storage.addDevice(deviceInfo)
|
||||
if (foundInfo == deviceInfo) {
|
||||
rememberedDevices.add(device);
|
||||
return foundInfo;
|
||||
}
|
||||
|
||||
return foundInfo;
|
||||
return _storage.addDevice(deviceInfo)
|
||||
}
|
||||
|
||||
fun removeRememberedDevice(device: CastingDevice) {
|
||||
val name = device.name ?: return;
|
||||
_storage.removeDevice(name);
|
||||
rememberedDevices.remove(device);
|
||||
val name = device.name ?: return
|
||||
_storage.removeDevice(name)
|
||||
}
|
||||
|
||||
private fun invokeInMainScopeIfRequired(action: () -> Unit){
|
||||
|
@ -436,7 +488,7 @@ class StateCasting {
|
|||
}
|
||||
} else {
|
||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests;
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
if (videoSource is IVideoUrlSource) {
|
||||
|
@ -531,7 +583,7 @@ class StateCasting {
|
|||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = url + videoPath;
|
||||
|
@ -550,7 +602,7 @@ class StateCasting {
|
|||
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
val audioPath = "/audio-${id}"
|
||||
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> {
|
||||
val ad = activeDevice ?: return listOf()
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"
|
||||
val url = getLocalUrl(ad)
|
||||
val id = UUID.randomUUID()
|
||||
|
||||
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> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
|
@ -715,7 +767,7 @@ class StateCasting {
|
|||
val ad = activeDevice ?: return listOf();
|
||||
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 videoPath = "/video-${id}"
|
||||
|
@ -780,7 +832,7 @@ class StateCasting {
|
|||
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
||||
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
|
||||
val id = UUID.randomUUID();
|
||||
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> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val hlsPath = "/hls-${id}"
|
||||
|
@ -1080,7 +1132,7 @@ class StateCasting {
|
|||
val ad = activeDevice ?: return listOf();
|
||||
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 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)
|
||||
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();
|
||||
|
@ -1173,7 +1234,7 @@ class StateCasting {
|
|||
cleanExecutors()
|
||||
_castServer.removeAllHandlers("castDashRaw")
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
|
|
|
@ -82,7 +82,11 @@ class TaskHandler<TParameter, TResult> {
|
|||
handled = true;
|
||||
} catch (e: Throwable) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -99,10 +103,14 @@ class TaskHandler<TParameter, TResult> {
|
|||
if (id != _idGenerator)
|
||||
return@withContext;
|
||||
|
||||
if (!onError.emit(e, parameter)) {
|
||||
Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e);
|
||||
} else {
|
||||
//Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); (Prevents duplicate logs)
|
||||
try {
|
||||
if (!onError.emit(e, parameter)) {
|
||||
Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e);
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,9 @@ import android.app.PendingIntent.getBroadcast
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.content.pm.PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
|
@ -155,6 +157,9 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||
|
||||
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller;
|
||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
params.setRequireUserAction(USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
val sessionId = packageInstaller.createSession(params);
|
||||
session = packageInstaller.openSession(sessionId)
|
||||
|
||||
|
|
|
@ -9,7 +9,9 @@ import android.view.View
|
|||
import android.widget.Button
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
|
@ -21,22 +23,21 @@ import com.futo.platformplayer.casting.StateCasting
|
|||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.adapters.DeviceAdapter
|
||||
import com.futo.platformplayer.views.adapters.DeviceAdapterEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
private lateinit var _imageLoader: ImageView;
|
||||
private lateinit var _buttonClose: Button;
|
||||
private lateinit var _buttonAdd: ImageButton;
|
||||
private lateinit var _buttonScanQR: ImageButton;
|
||||
private lateinit var _buttonAdd: LinearLayout;
|
||||
private lateinit var _buttonScanQR: LinearLayout;
|
||||
private lateinit var _textNoDevicesFound: TextView;
|
||||
private lateinit var _textNoDevicesRemembered: TextView;
|
||||
private lateinit var _recyclerDevices: RecyclerView;
|
||||
private lateinit var _recyclerRememberedDevices: RecyclerView;
|
||||
private lateinit var _adapter: DeviceAdapter;
|
||||
private lateinit var _rememberedAdapter: DeviceAdapter;
|
||||
private val _devices: ArrayList<CastingDevice> = arrayListOf();
|
||||
private val _rememberedDevices: ArrayList<CastingDevice> = arrayListOf();
|
||||
private val _devices: MutableSet<String> = mutableSetOf()
|
||||
private val _rememberedDevices: MutableSet<String> = mutableSetOf()
|
||||
private val _unifiedDevices: MutableList<DeviceAdapterEntry> = mutableListOf()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
@ -45,42 +46,40 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
|||
_imageLoader = findViewById(R.id.image_loader);
|
||||
_buttonClose = findViewById(R.id.button_close);
|
||||
_buttonAdd = findViewById(R.id.button_add);
|
||||
_buttonScanQR = findViewById(R.id.button_scan_qr);
|
||||
_buttonScanQR = findViewById(R.id.button_qr);
|
||||
_recyclerDevices = findViewById(R.id.recycler_devices);
|
||||
_recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices);
|
||||
_textNoDevicesFound = findViewById(R.id.text_no_devices_found);
|
||||
_textNoDevicesRemembered = findViewById(R.id.text_no_devices_remembered);
|
||||
|
||||
_adapter = DeviceAdapter(_devices, false);
|
||||
_adapter = DeviceAdapter(_unifiedDevices)
|
||||
_recyclerDevices.adapter = _adapter;
|
||||
_recyclerDevices.layoutManager = LinearLayoutManager(context);
|
||||
|
||||
_rememberedAdapter = DeviceAdapter(_rememberedDevices, true);
|
||||
_rememberedAdapter.onRemove.subscribe { d ->
|
||||
if (StateCasting.instance.activeDevice == d) {
|
||||
d.stopCasting();
|
||||
_adapter.onPin.subscribe { d ->
|
||||
val isRemembered = _rememberedDevices.contains(d.name)
|
||||
val newIsRemembered = !isRemembered
|
||||
if (newIsRemembered) {
|
||||
StateCasting.instance.addRememberedDevice(d)
|
||||
val name = d.name
|
||||
if (name != null) {
|
||||
_rememberedDevices.add(name)
|
||||
}
|
||||
} else {
|
||||
StateCasting.instance.removeRememberedDevice(d)
|
||||
_rememberedDevices.remove(d.name)
|
||||
}
|
||||
|
||||
StateCasting.instance.removeRememberedDevice(d);
|
||||
val index = _rememberedDevices.indexOf(d);
|
||||
if (index != -1) {
|
||||
_rememberedDevices.removeAt(index);
|
||||
_rememberedAdapter.notifyItemRemoved(index);
|
||||
}
|
||||
|
||||
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
|
||||
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
|
||||
};
|
||||
_rememberedAdapter.onConnect.subscribe { _ ->
|
||||
dismiss()
|
||||
//UIDialogs.showCastingDialog(context)
|
||||
updateUnifiedList()
|
||||
}
|
||||
|
||||
//TODO: Integrate remembered into the main list
|
||||
//TODO: Add green indicator to indicate a device is oneline
|
||||
//TODO: Add pinning
|
||||
//TODO: Implement QR code as an option in add manually
|
||||
//TODO: Remove start button
|
||||
|
||||
_adapter.onConnect.subscribe { _ ->
|
||||
dismiss()
|
||||
//UIDialogs.showCastingDialog(context)
|
||||
}
|
||||
_recyclerRememberedDevices.adapter = _rememberedAdapter;
|
||||
_recyclerRememberedDevices.layoutManager = LinearLayoutManager(context);
|
||||
|
||||
_buttonClose.setOnClickListener { dismiss(); };
|
||||
_buttonAdd.setOnClickListener {
|
||||
|
@ -105,77 +104,112 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
|||
Logger.i(TAG, "Dialog shown.");
|
||||
|
||||
StateCasting.instance.startDiscovering()
|
||||
|
||||
(_imageLoader.drawable as Animatable?)?.start();
|
||||
|
||||
_devices.clear();
|
||||
synchronized (StateCasting.instance.devices) {
|
||||
_devices.addAll(StateCasting.instance.devices.values);
|
||||
synchronized(StateCasting.instance.devices) {
|
||||
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
|
||||
}
|
||||
|
||||
_rememberedDevices.clear();
|
||||
synchronized (StateCasting.instance.rememberedDevices) {
|
||||
_rememberedDevices.addAll(StateCasting.instance.rememberedDevices);
|
||||
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
|
||||
updateUnifiedList()
|
||||
|
||||
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
|
||||
val name = d.name
|
||||
if (name != null)
|
||||
_devices.add(name)
|
||||
updateUnifiedList()
|
||||
}
|
||||
|
||||
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
|
||||
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name }
|
||||
if (index != -1) {
|
||||
_unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice)
|
||||
_adapter.notifyItemChanged(index)
|
||||
}
|
||||
}
|
||||
|
||||
StateCasting.instance.onDeviceRemoved.subscribe(this) { d ->
|
||||
_devices.remove(d.name)
|
||||
updateUnifiedList()
|
||||
}
|
||||
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
|
||||
if (connectionState == CastConnectionState.CONNECTED) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
|
||||
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
|
||||
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
|
||||
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
|
||||
|
||||
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
|
||||
_devices.add(d);
|
||||
_adapter.notifyItemInserted(_devices.size - 1);
|
||||
_textNoDevicesFound.visibility = View.GONE;
|
||||
_recyclerDevices.visibility = View.VISIBLE;
|
||||
};
|
||||
|
||||
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
|
||||
val index = _devices.indexOf(d);
|
||||
if (index == -1) {
|
||||
return@subscribe;
|
||||
}
|
||||
|
||||
_devices[index] = d;
|
||||
_adapter.notifyItemChanged(index);
|
||||
};
|
||||
|
||||
StateCasting.instance.onDeviceRemoved.subscribe(this) { d ->
|
||||
val index = _devices.indexOf(d);
|
||||
if (index == -1) {
|
||||
return@subscribe;
|
||||
}
|
||||
|
||||
_devices.removeAt(index);
|
||||
_adapter.notifyItemRemoved(index);
|
||||
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
|
||||
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
|
||||
};
|
||||
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
|
||||
if (connectionState != CastConnectionState.CONNECTED) {
|
||||
return@subscribe;
|
||||
}
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
dismiss();
|
||||
};
|
||||
};
|
||||
|
||||
_adapter.notifyDataSetChanged();
|
||||
_rememberedAdapter.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
super.dismiss();
|
||||
|
||||
(_imageLoader.drawable as Animatable?)?.stop();
|
||||
|
||||
super.dismiss()
|
||||
(_imageLoader.drawable as Animatable?)?.stop()
|
||||
StateCasting.instance.stopDiscovering()
|
||||
StateCasting.instance.onDeviceAdded.remove(this);
|
||||
StateCasting.instance.onDeviceChanged.remove(this);
|
||||
StateCasting.instance.onDeviceRemoved.remove(this);
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
|
||||
StateCasting.instance.onDeviceAdded.remove(this)
|
||||
StateCasting.instance.onDeviceChanged.remove(this)
|
||||
StateCasting.instance.onDeviceRemoved.remove(this)
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this)
|
||||
}
|
||||
|
||||
private fun updateUnifiedList() {
|
||||
val oldList = ArrayList(_unifiedDevices)
|
||||
val newList = buildUnifiedList()
|
||||
|
||||
val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||
override fun getOldListSize() = oldList.size
|
||||
override fun getNewListSize() = newList.size
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldItem = oldList[oldItemPosition]
|
||||
val newItem = newList[newItemPosition]
|
||||
return oldItem.castingDevice.name == newItem.castingDevice.name
|
||||
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
|
||||
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
|
||||
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
|
||||
}
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldItem = oldList[oldItemPosition]
|
||||
val newItem = newList[newItemPosition]
|
||||
return oldItem.castingDevice.name == newItem.castingDevice.name
|
||||
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
|
||||
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
|
||||
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
|
||||
}
|
||||
})
|
||||
|
||||
_unifiedDevices.clear()
|
||||
_unifiedDevices.addAll(newList)
|
||||
diffResult.dispatchUpdatesTo(_adapter)
|
||||
|
||||
_textNoDevicesFound.visibility = if (_unifiedDevices.isEmpty()) View.VISIBLE else View.GONE
|
||||
_recyclerDevices.visibility = if (_unifiedDevices.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun buildUnifiedList(): List<DeviceAdapterEntry> {
|
||||
val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name }
|
||||
val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name }
|
||||
|
||||
val unifiedList = mutableListOf<DeviceAdapterEntry>()
|
||||
|
||||
val intersectionNames = _devices.intersect(_rememberedDevices)
|
||||
for (name in intersectionNames) {
|
||||
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, true)) }
|
||||
}
|
||||
|
||||
val onlineOnlyNames = _devices - _rememberedDevices
|
||||
for (name in onlineOnlyNames) {
|
||||
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, false, true)) }
|
||||
}
|
||||
|
||||
val rememberedOnlyNames = _rememberedDevices - _devices
|
||||
for (name in rememberedOnlyNames) {
|
||||
rememberedDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, false)) }
|
||||
}
|
||||
|
||||
return unifiedList
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -47,6 +47,7 @@ import com.futo.platformplayer.states.StatePlatform
|
|||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.toHumanBitrate
|
||||
import com.futo.platformplayer.toHumanBytesSpeed
|
||||
import com.futo.polycentric.core.hexStringToByteArray
|
||||
import hasAnySource
|
||||
import isDownloadable
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
@ -59,16 +60,21 @@ import kotlinx.coroutines.suspendCancellableCoroutine
|
|||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Transient
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.lang.Thread.sleep
|
||||
import java.nio.ByteBuffer
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ForkJoinPool
|
||||
import java.util.concurrent.ForkJoinTask
|
||||
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.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 {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
|
@ -579,6 +593,14 @@ class VideoDownload {
|
|||
?: throw Exception("Variant playlist content is empty")
|
||||
|
||||
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 ->
|
||||
if (segment !is HLS.MediaSegment) {
|
||||
return@forEachIndexed
|
||||
|
@ -590,7 +612,7 @@ class VideoDownload {
|
|||
try {
|
||||
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 expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
||||
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) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
|
||||
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
|
||||
|
||||
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
|
||||
val cmd =
|
||||
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
|
||||
|
||||
val statisticsCallback = StatisticsCallback { _ ->
|
||||
//TODO: Show progress?
|
||||
|
@ -643,7 +663,6 @@ class VideoDownload {
|
|||
val session = FFmpegKit.executeAsync(cmd,
|
||||
{ session ->
|
||||
if (ReturnCode.isSuccess(session.returnCode)) {
|
||||
fileList.delete()
|
||||
continuation.resumeWith(Result.success(Unit))
|
||||
} else {
|
||||
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
||||
|
@ -651,7 +670,6 @@ class VideoDownload {
|
|||
} else {
|
||||
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
||||
}
|
||||
fileList.delete()
|
||||
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||
}
|
||||
},
|
||||
|
@ -771,7 +789,7 @@ class VideoDownload {
|
|||
else {
|
||||
Logger.i(TAG, "Download $name Sequential");
|
||||
try {
|
||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress);
|
||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
|
||||
throw e
|
||||
|
@ -798,7 +816,31 @@ class VideoDownload {
|
|||
}
|
||||
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;
|
||||
var lastProgressCount: Int = 0;
|
||||
val speedRate: Int = 4096 * 5;
|
||||
|
@ -818,6 +860,8 @@ class VideoDownload {
|
|||
val sourceLength = result.body.contentLength();
|
||||
val sourceStream = result.body.byteStream();
|
||||
|
||||
val segmentBuffer = ByteArrayOutputStream()
|
||||
|
||||
var totalRead: Long = 0;
|
||||
try {
|
||||
var read: Int;
|
||||
|
@ -828,7 +872,7 @@ class VideoDownload {
|
|||
if (read < 0)
|
||||
break;
|
||||
|
||||
fileStream.write(buffer, 0, read);
|
||||
segmentBuffer.write(buffer, 0, read);
|
||||
|
||||
totalRead += read;
|
||||
|
||||
|
@ -854,6 +898,21 @@ class VideoDownload {
|
|||
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);
|
||||
return sourceLength;
|
||||
}
|
||||
|
@ -1160,6 +1219,8 @@ class VideoDownload {
|
|||
fun audioContainerToExtension(container: String): String {
|
||||
if (container.contains("audio/mp4"))
|
||||
return "mp4a";
|
||||
else if (container.contains("video/mp4"))
|
||||
return "mp4";
|
||||
else if (container.contains("audio/mpeg"))
|
||||
return "mpga";
|
||||
else if (container.contains("audio/mp3"))
|
||||
|
@ -1167,7 +1228,7 @@ class VideoDownload {
|
|||
else if (container.contains("audio/webm"))
|
||||
return "webm";
|
||||
else if (container == "application/vnd.apple.mpegurl")
|
||||
return "mp4a";
|
||||
return "m4a";
|
||||
else
|
||||
return "audio";// throw IllegalStateException("Unknown container: " + container)
|
||||
}
|
||||
|
|
|
@ -69,7 +69,7 @@ class VideoExport {
|
|||
outputFile = f;
|
||||
} else if (v != null) {
|
||||
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.");
|
||||
|
||||
Logger.i(TAG, "Copying video.");
|
||||
|
@ -81,8 +81,8 @@ class VideoExport {
|
|||
outputFile = f;
|
||||
} else if (a != null) {
|
||||
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container);
|
||||
val f = downloadRoot.createFile(a.container, outputFileName)
|
||||
?: throw Exception("Failed to create file in external directory.");
|
||||
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.");
|
||||
|
||||
Logger.i(TAG, "Copying audio.");
|
||||
|
||||
|
|
|
@ -188,6 +188,14 @@ class V8Plugin {
|
|||
whenNotBusy {
|
||||
synchronized(_runtimeLock) {
|
||||
isStopped = true;
|
||||
|
||||
//Cleanup http
|
||||
for(pack in _depsPackages) {
|
||||
if(pack is PackageHttp) {
|
||||
pack.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
_runtime?.let {
|
||||
_runtime = null;
|
||||
if(!it.isClosed && !it.isDead) {
|
||||
|
|
|
@ -12,6 +12,7 @@ import com.futo.platformplayer.logging.Logger
|
|||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
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.JSClientConstants
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
|
@ -72,6 +73,26 @@ class PackageBridge : V8Package {
|
|||
fun buildSpecVersion(): Int {
|
||||
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
|
||||
fun dispose(value: V8Value) {
|
||||
|
|
|
@ -8,9 +8,7 @@ import com.caoccao.javet.enums.V8ProxyMode
|
|||
import com.caoccao.javet.interop.V8Runtime
|
||||
import com.caoccao.javet.values.V8Value
|
||||
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.V8ValueSharedArrayBuffer
|
||||
import com.caoccao.javet.values.reference.V8ValueTypedArray
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
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.V8BindObject
|
||||
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.util.concurrent.ForkJoinPool
|
||||
import java.util.concurrent.ForkJoinTask
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.streams.asSequence
|
||||
|
||||
class PackageHttp: V8Package {
|
||||
@Transient
|
||||
|
@ -49,6 +41,9 @@ class PackageHttp: V8Package {
|
|||
private var _batchPoolLock: Any = Any();
|
||||
private var _batchPool: ForkJoinPool? = null;
|
||||
|
||||
private val aliveSockets = mutableListOf<SocketResult>();
|
||||
private var _cleanedUp = false;
|
||||
|
||||
|
||||
constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) {
|
||||
_config = config;
|
||||
|
@ -58,6 +53,27 @@ class PackageHttp: V8Package {
|
|||
_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.
|
||||
|
@ -111,24 +127,24 @@ class PackageHttp: V8Package {
|
|||
@V8Function
|
||||
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
|
||||
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
|
||||
_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
|
||||
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
|
||||
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
|
||||
_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
|
||||
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
|
||||
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
|
||||
_packageClient.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
|
||||
_packageClient.GETInternal(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
|
||||
}
|
||||
@V8Function
|
||||
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;
|
||||
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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
|
||||
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
|
||||
throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST");
|
||||
}
|
||||
|
@ -276,9 +292,9 @@ class PackageHttp: V8Package {
|
|||
if(it.second.method == "DUMMY")
|
||||
return@autoParallelPool 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
|
||||
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 {
|
||||
if(it.second != null)
|
||||
throw it.second!!;
|
||||
|
@ -345,7 +361,9 @@ class PackageHttp: V8Package {
|
|||
}
|
||||
|
||||
@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);
|
||||
return logExceptions {
|
||||
return@logExceptions catchHttp {
|
||||
|
@ -364,7 +382,9 @@ class PackageHttp: V8Package {
|
|||
};
|
||||
}
|
||||
@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);
|
||||
return logExceptions {
|
||||
catchHttp {
|
||||
|
@ -385,7 +405,9 @@ class PackageHttp: V8Package {
|
|||
}
|
||||
|
||||
@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);
|
||||
return logExceptions {
|
||||
catchHttp {
|
||||
|
@ -407,7 +429,9 @@ class PackageHttp: V8Package {
|
|||
};
|
||||
}
|
||||
@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);
|
||||
return logExceptions {
|
||||
catchHttp {
|
||||
|
@ -429,7 +453,9 @@ class PackageHttp: V8Package {
|
|||
};
|
||||
}
|
||||
@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);
|
||||
return logExceptions {
|
||||
catchHttp {
|
||||
|
@ -453,9 +479,16 @@ class PackageHttp: V8Package {
|
|||
|
||||
@V8Function
|
||||
fun socket(url: String, headers: Map<String, String>? = null): SocketResult {
|
||||
if(_package._cleanedUp)
|
||||
throw IllegalStateException("Plugin shutdown");
|
||||
val socketHeaders = headers?.toMutableMap() ?: HashMap();
|
||||
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>) {
|
||||
|
@ -561,13 +594,15 @@ class PackageHttp: V8Package {
|
|||
|
||||
private var _listeners: V8ValueObject? = null;
|
||||
|
||||
private val _package: PackageHttp;
|
||||
private val _packageClient: PackageHttpClient;
|
||||
private val _client: ManagedHttpClient;
|
||||
private val _url: 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;
|
||||
_package = parent;
|
||||
_client = client;
|
||||
_url = url;
|
||||
_headers = headers;
|
||||
|
@ -593,7 +628,7 @@ class PackageHttp: V8Package {
|
|||
override fun open() {
|
||||
Logger.i(TAG, "Websocket opened: " + _url);
|
||||
_isOpen = true;
|
||||
if(hasOpen) {
|
||||
if(hasOpen && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_listeners?.invokeVoid("open", arrayOf<Any>());
|
||||
}
|
||||
|
@ -603,7 +638,7 @@ class PackageHttp: V8Package {
|
|||
}
|
||||
}
|
||||
override fun message(msg: String) {
|
||||
if(hasMessage) {
|
||||
if(hasMessage && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_listeners?.invokeVoid("message", msg);
|
||||
}
|
||||
|
@ -611,7 +646,7 @@ class PackageHttp: V8Package {
|
|||
}
|
||||
}
|
||||
override fun closing(code: Int, reason: String) {
|
||||
if(hasClosing)
|
||||
if(hasClosing && _listeners?.isClosed != true)
|
||||
{
|
||||
try {
|
||||
_listeners?.invokeVoid("closing", code, reason);
|
||||
|
@ -623,7 +658,7 @@ class PackageHttp: V8Package {
|
|||
}
|
||||
override fun closed(code: Int, reason: String) {
|
||||
_isOpen = false;
|
||||
if(hasClosed) {
|
||||
if(hasClosed && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_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.w(TAG, "PackageHttp Socket removed");
|
||||
synchronized(_package.aliveSockets) {
|
||||
_package.aliveSockets.remove(this@SocketResult);
|
||||
}
|
||||
}
|
||||
override fun failure(exception: Throwable) {
|
||||
_isOpen = false;
|
||||
Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception);
|
||||
if(hasFailure) {
|
||||
if(hasFailure && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_listeners?.invokeVoid("failure", exception.message);
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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.Event2
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
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.states.StateCache
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
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.InsertedViewAdapterWithLoader
|
||||
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 _adapterResults: InsertedViewAdapterWithLoader<ContentPreviewViewHolder>? = null;
|
||||
private var _lastPolycentricProfile: PolycentricProfile? = null;
|
||||
private var _query: String? = null
|
||||
private var _searchView: SearchView? = null
|
||||
|
||||
val onContentClicked = Event2<IPlatformContent, Long>();
|
||||
val onContentUrlClicked = Event2<String, ContentType>();
|
||||
|
@ -68,16 +74,32 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
|
||||
Logger.i(TAG, "getContentPager");
|
||||
|
||||
val lastPolycentricProfile = _lastPolycentricProfile;
|
||||
var pager: IPager<IPlatformContent>? = null;
|
||||
if (lastPolycentricProfile != null)
|
||||
pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile);
|
||||
var pager: IPager<IPlatformContent>? = null
|
||||
val query = _query
|
||||
if (!query.isNullOrBlank()) {
|
||||
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(subType != null)
|
||||
pager = StatePlatform.instance.getChannelContent(channel.url, subType);
|
||||
else
|
||||
pager = StatePlatform.instance.getChannelContent(channel.url);
|
||||
if(pager == null) {
|
||||
if(subType != null) {
|
||||
pager = StatePlatform.instance.getChannelContent(channel.url, subType);
|
||||
Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url}, subType = ${subType})")
|
||||
} else {
|
||||
pager = StatePlatform.instance.getChannelContent(channel.url);
|
||||
Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url})")
|
||||
}
|
||||
}
|
||||
}
|
||||
return pager;
|
||||
}
|
||||
|
@ -144,19 +166,49 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
|
||||
_taskLoadVideos.cancel();
|
||||
|
||||
_query = null
|
||||
_channel = channel;
|
||||
updateSearchViewVisibility()
|
||||
_results.clear();
|
||||
_adapterResults?.notifyDataSetChanged();
|
||||
|
||||
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? {
|
||||
val view = inflater.inflate(R.layout.fragment_channel_videos, container, false);
|
||||
|
||||
_query = null
|
||||
_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.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
|
||||
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
|
||||
|
@ -173,6 +225,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
_recyclerResults?.layoutManager = _glmVideo;
|
||||
_recyclerResults?.addOnScrollListener(_scrollListener);
|
||||
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
|
@ -181,6 +234,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
_recyclerResults?.removeOnScrollListener(_scrollListener);
|
||||
_recyclerResults = null;
|
||||
_pager = null;
|
||||
_query = null
|
||||
_searchView = null
|
||||
|
||||
_taskLoadVideos.cancel();
|
||||
_nextPageHandler.cancel();
|
||||
|
@ -303,6 +358,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
}
|
||||
|
||||
private fun loadInitial() {
|
||||
Logger.i(TAG, "loadInitial")
|
||||
val channel: IPlatformChannel = _channel ?: return;
|
||||
setLoading(true);
|
||||
_taskLoadVideos.run(channel);
|
||||
|
|
|
@ -331,7 +331,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||
}
|
||||
|
||||
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
|
||||
|
@ -385,19 +385,19 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||
currentMain.scrollToTop(false)
|
||||
currentMain.reloadFeed()
|
||||
} 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(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>() }),
|
||||
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>() }),
|
||||
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>() }),
|
||||
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(6, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>() }),
|
||||
ButtonDefinition(7, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>() }),
|
||||
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>() }),
|
||||
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(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(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>(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>(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>(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>(withHistory = false) }),
|
||||
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>(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>(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>(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>(withHistory = false) }),
|
||||
ButtonDefinition(11, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
|
||||
val c = it.context ?: return@ButtonDefinition;
|
||||
Logger.i(TAG, "settings preventPictureInPicture()");
|
||||
|
@ -419,7 +419,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}),
|
||||
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
|
||||
//98 is reserved for buy button
|
||||
|
|
|
@ -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 {}
|
||||
}
|
||||
}
|
|
@ -47,6 +47,7 @@ import com.futo.platformplayer.selectHighestResolutionImage
|
|||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.toHumanNumber
|
||||
import com.futo.platformplayer.views.adapters.ChannelTab
|
||||
|
@ -135,6 +136,8 @@ class ChannelFragment : MainFragment() {
|
|||
inflater.inflate(R.layout.fragment_channel, this)
|
||||
_taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>({ fragment.lifecycleScope },
|
||||
{ id ->
|
||||
if (!StatePolycentric.instance.enabled)
|
||||
return@TaskHandler null
|
||||
return@TaskHandler ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, id.claimFieldType.toLong(), id.claimType.toLong(), id.value!!)
|
||||
}).success { setPolycentricProfile(it, animate = true) }.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load polycentric profile.", it)
|
||||
|
@ -422,17 +425,15 @@ class ChannelFragment : MainFragment() {
|
|||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (plugin != null && plugin.capabilities.hasSearchChannelContents) {
|
||||
buttons.add(Pair(R.drawable.ic_search) {
|
||||
_fragment.navigate<SuggestionsFragment>(
|
||||
SuggestionsFragmentData(
|
||||
"", SearchType.VIDEO, channel.url
|
||||
)
|
||||
buttons.add(Pair(R.drawable.ic_search) {
|
||||
_fragment.navigate<SuggestionsFragment>(
|
||||
SuggestionsFragmentData(
|
||||
"", SearchType.VIDEO
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
|
||||
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
|
||||
}
|
||||
if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) {
|
||||
if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false &&
|
||||
!(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) {
|
||||
|
|
|
@ -10,12 +10,14 @@ 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.models.article.IPlatformArticle
|
||||
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.playlists.IPlatformPlaylist
|
||||
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.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
|
@ -196,16 +198,24 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
|||
fragment.navigate<RemotePlaylistFragment>(content);
|
||||
} else if (content is IPlatformPost) {
|
||||
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) {
|
||||
when(contentType) {
|
||||
ContentType.MEDIA -> {
|
||||
StatePlayer.instance.clearQueue();
|
||||
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail();
|
||||
};
|
||||
ContentType.PLAYLIST -> fragment.navigate<RemotePlaylistFragment>(url);
|
||||
ContentType.URL -> fragment.navigate<BrowserFragment>(url);
|
||||
StatePlayer.instance.clearQueue()
|
||||
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
|
||||
}
|
||||
ContentType.PLAYLIST -> fragment.navigate<RemotePlaylistFragment>(url)
|
||||
ContentType.URL -> fragment.navigate<BrowserFragment>(url)
|
||||
ContentType.CHANNEL -> fragment.navigate<ChannelFragment>(url)
|
||||
else -> {};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,15 +2,18 @@ package com.futo.platformplayer.fragment.mainactivity.main
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.allViews
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
|
@ -18,9 +21,12 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
|||
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
||||
import com.futo.platformplayer.isHttpUrl
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.SearchType
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.ToggleBar
|
||||
import com.futo.platformplayer.views.others.RadioGroupView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
@ -83,7 +89,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
private var _sortBy: String? = null;
|
||||
private var _filterValues: HashMap<String, List<String>> = hashMapOf();
|
||||
private var _enabledClientIds: List<String>? = null;
|
||||
private var _channelUrl: String? = null;
|
||||
private var _searchType: SearchType? = null;
|
||||
|
||||
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
|
||||
override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar
|
||||
|
@ -91,11 +97,12 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
||||
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query ->
|
||||
Logger.i(TAG, "Searching for: $query")
|
||||
val channelUrl = _channelUrl;
|
||||
if (channelUrl != null) {
|
||||
StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds)
|
||||
} else {
|
||||
StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
|
||||
when (_searchType)
|
||||
{
|
||||
SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
|
||||
SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query)
|
||||
SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query)
|
||||
else -> throw Exception("Search type must be specified")
|
||||
}
|
||||
})
|
||||
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
|
||||
|
@ -105,6 +112,25 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
}
|
||||
|
||||
setPreviewsEnabled(Settings.instance.search.previewFeedItems);
|
||||
|
||||
initializeToolbar();
|
||||
}
|
||||
|
||||
fun initializeToolbar(){
|
||||
if(_toolbarContentView.allViews.any { it is RadioGroupView })
|
||||
_toolbarContentView.removeView(_toolbarContentView.allViews.find { it is RadioGroupView });
|
||||
|
||||
val radioGroup = RadioGroupView(context);
|
||||
radioGroup.onSelectedChange.subscribe {
|
||||
|
||||
if (it.size != 1)
|
||||
setSearchType(SearchType.VIDEO);
|
||||
else
|
||||
setSearchType((it[0] ?: SearchType.VIDEO) as SearchType);
|
||||
}
|
||||
radioGroup?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true)
|
||||
|
||||
_toolbarContentView.addView(radioGroup);
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
|
@ -115,7 +141,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
fun onShown(parameter: Any?) {
|
||||
if(parameter is SuggestionsFragmentData) {
|
||||
setQuery(parameter.query, false);
|
||||
setChannelUrl(parameter.channelUrl, false);
|
||||
setSearchType(parameter.searchType, false)
|
||||
|
||||
fragment.topBar?.apply {
|
||||
if (this is SearchTopBarFragment) {
|
||||
|
@ -131,7 +157,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
onFilterClick.subscribe(this) {
|
||||
_overlayContainer.let {
|
||||
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 ->
|
||||
if (changed) {
|
||||
setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy);
|
||||
|
@ -178,11 +204,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val commonCapabilities =
|
||||
if(_channelUrl == null)
|
||||
StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
else
|
||||
StatePlatform.instance.getCommonSearchChannelContentsCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
val commonCapabilities = StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
val sorts = commonCapabilities?.sorts ?: listOf();
|
||||
if (sorts.size > 1) {
|
||||
withContext(Dispatchers.Main) {
|
||||
|
@ -249,8 +271,8 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun setChannelUrl(channelUrl: String?, updateResults: Boolean = true) {
|
||||
_channelUrl = channelUrl;
|
||||
private fun setSearchType(searchType: SearchType, updateResults: Boolean = true) {
|
||||
_searchType = searchType
|
||||
|
||||
if (updateResults) {
|
||||
clearResults();
|
||||
|
|
|
@ -14,10 +14,14 @@ import androidx.core.widget.addTextChangedListener
|
|||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.downloads.VideoDownload
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.services.DownloadService
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
|
@ -54,6 +58,15 @@ class DownloadsFragment : MainFragment() {
|
|||
super.onResume()
|
||||
_view?.reloadUI();
|
||||
|
||||
if(StateDownloads.instance.getDownloading().any { it.state == VideoDownload.State.QUEUED } &&
|
||||
!StateDownloads.instance.getDownloading().any { it.state == VideoDownload.State.DOWNLOADING } &&
|
||||
Settings.instance.downloads.shouldDownload()) {
|
||||
Logger.w(TAG, "Detected queued download, while not downloading, attempt recreating service");
|
||||
StateApp.withContext {
|
||||
DownloadService.getOrCreateService(it);
|
||||
}
|
||||
}
|
||||
|
||||
StateDownloads.instance.onDownloadsChanged.subscribe(this) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
|
|
|
@ -136,7 +136,6 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||
_toolbarContentView = findViewById(R.id.container_toolbar_content);
|
||||
|
||||
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
|
||||
|
||||
if (it is IAsyncPager<*>)
|
||||
it.nextPageAsync();
|
||||
else
|
||||
|
|
|
@ -15,6 +15,8 @@ import androidx.lifecycle.lifecycleScope
|
|||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.structures.IAsyncPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
|
@ -22,10 +24,14 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFrag
|
|||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.HistoryVideo
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.views.ToggleBar
|
||||
import com.futo.platformplayer.views.adapters.HistoryListViewHolder
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.others.TagsView
|
||||
import com.futo.platformplayer.views.others.Toggle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -68,6 +74,8 @@ class HistoryFragment : MainFragment() {
|
|||
private var _pager: IPager<HistoryVideo>? = null;
|
||||
private val _results = arrayListOf<HistoryVideo>();
|
||||
private var _loading = false;
|
||||
private val _toggleBar: ToggleBar
|
||||
private var _togglePluginsDisabled = hashSetOf<String>()
|
||||
|
||||
private var _automaticNextPageCounter = 0;
|
||||
|
||||
|
@ -79,6 +87,7 @@ class HistoryFragment : MainFragment() {
|
|||
_clearSearch = findViewById(R.id.button_clear_search);
|
||||
_editSearch = findViewById(R.id.edit_search);
|
||||
_tagsView = findViewById(R.id.tags_text);
|
||||
_toggleBar = findViewById(R.id.toggle_bar)
|
||||
_tagsView.setPairs(listOf(
|
||||
Pair(context.getString(R.string.last_hour), 60L),
|
||||
Pair(context.getString(R.string.last_24_hours), 24L * 60L),
|
||||
|
@ -88,6 +97,22 @@ class HistoryFragment : MainFragment() {
|
|||
Pair(context.getString(R.string.all_time), -1L)
|
||||
));
|
||||
|
||||
val toggles = StatePlatform.instance.getEnabledClients()
|
||||
.filter { it is JSClient }
|
||||
.map { plugin ->
|
||||
val pluginName = plugin.name.lowercase()
|
||||
ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) pluginName else "", plugin.icon, !_togglePluginsDisabled.contains(plugin.id), { view, active ->
|
||||
if (active) {
|
||||
_togglePluginsDisabled.remove(plugin.id)
|
||||
} else {
|
||||
_togglePluginsDisabled.add(plugin.id)
|
||||
}
|
||||
|
||||
filtersChanged()
|
||||
}).withTag("plugins")
|
||||
}.toTypedArray()
|
||||
_toggleBar.setToggles(*toggles)
|
||||
|
||||
_adapter = InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
|
||||
{ _results.size },
|
||||
{ view, _ ->
|
||||
|
@ -162,14 +187,15 @@ class HistoryFragment : MainFragment() {
|
|||
else
|
||||
it.nextPage();
|
||||
|
||||
return@TaskHandler it.getResults();
|
||||
return@TaskHandler filterResults(it.getResults());
|
||||
}).success {
|
||||
setLoading(false);
|
||||
|
||||
val posBefore = _results.size;
|
||||
_results.addAll(it);
|
||||
_adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), it.size);
|
||||
ensureEnoughContentVisible(it)
|
||||
val res = filterResults(it)
|
||||
_results.addAll(res);
|
||||
_adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), res.size);
|
||||
ensureEnoughContentVisible(res)
|
||||
}.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load next page.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
|
||||
|
@ -178,6 +204,10 @@ class HistoryFragment : MainFragment() {
|
|||
};
|
||||
}
|
||||
|
||||
private fun filtersChanged() {
|
||||
updatePager()
|
||||
}
|
||||
|
||||
private fun updatePager() {
|
||||
val query = _editSearch.text.toString();
|
||||
if (_editSearch.text.isNotEmpty()) {
|
||||
|
@ -246,11 +276,22 @@ class HistoryFragment : MainFragment() {
|
|||
_adapter.setLoading(loading);
|
||||
}
|
||||
|
||||
private fun filterResults(a: List<HistoryVideo>): List<HistoryVideo> {
|
||||
val enabledPluginIds = StatePlatform.instance.getEnabledClients().map { it.id }.toHashSet()
|
||||
val disabledPluginIds = _togglePluginsDisabled.toHashSet()
|
||||
return a.filter {
|
||||
val pluginId = it.video.id.pluginId ?: StatePlatform.instance.getContentClientOrNull(it.video.url)?.id ?: return@filter false
|
||||
if (!enabledPluginIds.contains(pluginId))
|
||||
return@filter false
|
||||
return@filter !disabledPluginIds.contains(pluginId)
|
||||
};
|
||||
}
|
||||
|
||||
private fun loadPagerInternal(pager: IPager<HistoryVideo>) {
|
||||
Logger.i(TAG, "Setting new internal pager on feed");
|
||||
|
||||
_results.clear();
|
||||
val toAdd = pager.getResults();
|
||||
val toAdd = filterResults(pager.getResults())
|
||||
_results.addAll(toAdd);
|
||||
_adapter.notifyDataSetChanged();
|
||||
ensureEnoughContentVisible(toAdd)
|
||||
|
|
|
@ -217,7 +217,7 @@ class PlaylistsFragment : MainFragment() {
|
|||
var playlistsToReturn = pls;
|
||||
if(!_listPlaylistsSearch.text.isNullOrEmpty())
|
||||
playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) };
|
||||
if(!_ordering.value.isNullOrEmpty()){
|
||||
if(!_ordering.value.isNullOrEmpty()) {
|
||||
playlistsToReturn = when(_ordering.value){
|
||||
"nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() }
|
||||
"nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() };
|
||||
|
|
|
@ -168,7 +168,12 @@ class PostDetailFragment : MainFragment {
|
|||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
|
||||
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
|
||||
|
||||
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) })
|
||||
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, {
|
||||
if (!StatePolycentric.instance.enabled)
|
||||
return@TaskHandler null
|
||||
|
||||
ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!)
|
||||
})
|
||||
.success { it -> setPolycentricProfile(it, animate = true) }
|
||||
.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load claims.", it);
|
||||
|
@ -327,6 +332,10 @@ class PostDetailFragment : MainFragment {
|
|||
val version = _version;
|
||||
|
||||
_rating.onLikeDislikeUpdated.remove(this);
|
||||
|
||||
if (!StatePolycentric.instance.enabled)
|
||||
return
|
||||
|
||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
if (version != _version) {
|
||||
return@launch;
|
||||
|
|
|
@ -18,6 +18,7 @@ import com.futo.platformplayer.activities.AddSourceOptionsActivity
|
|||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
|
@ -44,6 +45,7 @@ class SourcesFragment : MainFragment() {
|
|||
if(topBar is AddTopBarFragment) {
|
||||
(topBar as AddTopBarFragment).onAdd.clear();
|
||||
(topBar as AddTopBarFragment).onAdd.subscribe {
|
||||
StateApp.instance.preventPictureInPicture.emit();
|
||||
startActivity(Intent(requireContext(), AddSourceOptionsActivity::class.java));
|
||||
};
|
||||
}
|
||||
|
@ -93,6 +95,7 @@ class SourcesFragment : MainFragment() {
|
|||
findViewById<LinearLayout>(R.id.plugin_disclaimer).isVisible = false;
|
||||
}
|
||||
findViewById<BigButton>(R.id.button_add_sources).onClick.subscribe {
|
||||
StateApp.instance.preventPictureInPicture.emit();
|
||||
fragment.startActivity(Intent(context, AddSourceOptionsActivity::class.java));
|
||||
};
|
||||
|
||||
|
|
|
@ -290,8 +290,8 @@ class SubscriptionGroupFragment : MainFragment() {
|
|||
image.setImageView(_imageGroup);
|
||||
}
|
||||
else {
|
||||
_imageGroupBackground.setImageResource(0);
|
||||
_imageGroup.setImageResource(0);
|
||||
_imageGroupBackground.setImageDrawable(null);
|
||||
_imageGroup.setImageDrawable(null);
|
||||
}
|
||||
updateMeta();
|
||||
reloadCreators(group);
|
||||
|
|
|
@ -18,8 +18,10 @@ import com.futo.platformplayer.states.StatePlatform
|
|||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.SearchHistoryStorage
|
||||
import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter
|
||||
import com.futo.platformplayer.views.others.RadioGroupView
|
||||
import com.futo.platformplayer.views.others.TagsView
|
||||
|
||||
data class SuggestionsFragmentData(val query: String, val searchType: SearchType, val channelUrl: String? = null);
|
||||
data class SuggestionsFragmentData(val query: String, val searchType: SearchType);
|
||||
|
||||
class SuggestionsFragment : MainFragment {
|
||||
override val isMainView : Boolean = true;
|
||||
|
@ -28,10 +30,10 @@ class SuggestionsFragment : MainFragment {
|
|||
|
||||
private var _recyclerSuggestions: RecyclerView? = null;
|
||||
private var _llmSuggestions: LinearLayoutManager? = null;
|
||||
private var _radioGroupView: RadioGroupView? = null;
|
||||
private val _suggestions: ArrayList<String> = ArrayList();
|
||||
private var _query: String? = null;
|
||||
private var _searchType: SearchType = SearchType.VIDEO;
|
||||
private var _channelUrl: String? = null;
|
||||
|
||||
private val _adapterSuggestions = SearchSuggestionAdapter(_suggestions);
|
||||
|
||||
|
@ -49,14 +51,7 @@ class SuggestionsFragment : MainFragment {
|
|||
_adapterSuggestions.onClicked.subscribe { suggestion ->
|
||||
val storage = FragmentedStorage.get<SearchHistoryStorage>();
|
||||
storage.add(suggestion);
|
||||
|
||||
if (_searchType == SearchType.CREATOR) {
|
||||
navigate<CreatorSearchResultsFragment>(suggestion);
|
||||
} else if (_searchType == SearchType.PLAYLIST) {
|
||||
navigate<PlaylistSearchResultsFragment>(suggestion);
|
||||
} else {
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, SearchType.VIDEO, _channelUrl));
|
||||
}
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType));
|
||||
}
|
||||
_adapterSuggestions.onRemove.subscribe { suggestion ->
|
||||
val index = _suggestions.indexOf(suggestion);
|
||||
|
@ -80,6 +75,15 @@ class SuggestionsFragment : MainFragment {
|
|||
recyclerSuggestions.adapter = _adapterSuggestions;
|
||||
_recyclerSuggestions = recyclerSuggestions;
|
||||
|
||||
_radioGroupView = view.findViewById<RadioGroupView>(R.id.radio_group).apply {
|
||||
onSelectedChange.subscribe {
|
||||
if (it.size != 1)
|
||||
_searchType = SearchType.VIDEO
|
||||
else
|
||||
_searchType = (it[0] ?: SearchType.VIDEO) as SearchType
|
||||
}
|
||||
}
|
||||
|
||||
loadSuggestions();
|
||||
return view;
|
||||
}
|
||||
|
@ -104,37 +108,31 @@ class SuggestionsFragment : MainFragment {
|
|||
|
||||
if (parameter is SuggestionsFragmentData) {
|
||||
_searchType = parameter.searchType;
|
||||
_channelUrl = parameter.channelUrl;
|
||||
} else if (parameter is SearchType) {
|
||||
_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 {
|
||||
if (this is SearchTopBarFragment) {
|
||||
onSearch.subscribe(this) {
|
||||
if (_searchType == SearchType.CREATOR) {
|
||||
navigate<CreatorSearchResultsFragment>(it);
|
||||
} else if (_searchType == SearchType.PLAYLIST) {
|
||||
navigate<PlaylistSearchResultsFragment>(it);
|
||||
} else {
|
||||
if(it.isHttpUrl()) {
|
||||
if(StatePlatform.instance.hasEnabledPlaylistClient(it))
|
||||
navigate<RemotePlaylistFragment>(it);
|
||||
else if(StatePlatform.instance.hasEnabledChannelClient(it))
|
||||
navigate<ChannelFragment>(it);
|
||||
else {
|
||||
val url = it;
|
||||
activity?.let {
|
||||
close()
|
||||
if(it is MainActivity)
|
||||
it.navigate(it.getFragment<VideoDetailFragment>(), url);
|
||||
}
|
||||
if(it.isHttpUrl()) {
|
||||
if(StatePlatform.instance.hasEnabledPlaylistClient(it))
|
||||
navigate<RemotePlaylistFragment>(it);
|
||||
else if(StatePlatform.instance.hasEnabledChannelClient(it))
|
||||
navigate<ChannelFragment>(it);
|
||||
else {
|
||||
val url = it;
|
||||
activity?.let {
|
||||
close()
|
||||
if(it is MainActivity)
|
||||
it.navigate(it.getFragment<VideoDetailFragment>(), url);
|
||||
}
|
||||
}
|
||||
else
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
|
||||
}
|
||||
else
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType));
|
||||
};
|
||||
|
||||
onTextChange.subscribe(this) {
|
||||
|
@ -196,6 +194,7 @@ class SuggestionsFragment : MainFragment {
|
|||
super.onDestroyMainView();
|
||||
_getSuggestions.onError.clear();
|
||||
_recyclerSuggestions = null;
|
||||
_radioGroupView = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
|
|
@ -455,6 +455,10 @@ class VideoDetailFragment() : MainFragment() {
|
|||
activity?.enterPictureInPictureMode(params);
|
||||
}
|
||||
}
|
||||
|
||||
if (isFullscreen) {
|
||||
viewDetail?.restoreBrightness()
|
||||
}
|
||||
}
|
||||
|
||||
fun forcePictureInPicture() {
|
||||
|
@ -487,6 +491,10 @@ class VideoDetailFragment() : MainFragment() {
|
|||
_isActive = true;
|
||||
_leavingPiP = false;
|
||||
|
||||
if (isFullscreen) {
|
||||
_viewDetail?.saveBrightness()
|
||||
}
|
||||
|
||||
_viewDetail?.let {
|
||||
Logger.v(TAG, "onResume preventPictureInPicture=false");
|
||||
it.preventPictureInPicture = false;
|
||||
|
|
|
@ -46,6 +46,7 @@ import com.futo.platformplayer.R
|
|||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.LiveChatManager
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
|
@ -148,7 +149,6 @@ import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
|
|||
import com.futo.platformplayer.views.pills.RoundButton
|
||||
import com.futo.platformplayer.views.pills.RoundButtonGroup
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
import com.futo.platformplayer.views.segments.ChaptersList
|
||||
import com.futo.platformplayer.views.segments.CommentsList
|
||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||
import com.futo.platformplayer.views.video.FutoVideoPlayer
|
||||
|
@ -571,7 +571,7 @@ class VideoDetailView : ConstraintLayout {
|
|||
_player.setIsReplay(true);
|
||||
|
||||
val searchVideo = StatePlayer.instance.getCurrentQueueItem();
|
||||
if (searchVideo is SerializedPlatformVideo?) {
|
||||
if (searchVideo is SerializedPlatformVideo? && Settings.instance.playback.deleteFromWatchLaterAuto) {
|
||||
searchVideo?.let { StatePlaylists.instance.removeFromWatchLater(it) };
|
||||
}
|
||||
|
||||
|
@ -619,6 +619,7 @@ class VideoDetailView : ConstraintLayout {
|
|||
loadCurrentVideo(lastPositionMilliseconds);
|
||||
updatePillButtonVisibilities();
|
||||
setCastEnabled(false);
|
||||
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
@ -647,6 +648,15 @@ class VideoDetailView : ConstraintLayout {
|
|||
_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;
|
||||
|
@ -688,6 +698,20 @@ class VideoDetailView : ConstraintLayout {
|
|||
Logger.i(TAG, "MediaControlReceiver.onCloseReceived")
|
||||
onClose.emit()
|
||||
};
|
||||
MediaControlReceiver.onBackgroundReceived.subscribe(this) {
|
||||
Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived")
|
||||
_player.switchToAudioMode();
|
||||
allowBackground = true;
|
||||
StateApp.instance.contextOrNull?.let {
|
||||
try {
|
||||
if (it is MainActivity) {
|
||||
it.moveTaskToBack(true)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to move task to back", e)
|
||||
}
|
||||
}
|
||||
};
|
||||
MediaControlReceiver.onSeekToReceived.subscribe(this) { handleSeek(it); };
|
||||
|
||||
_container_content_description.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
|
@ -767,6 +791,7 @@ class VideoDetailView : ConstraintLayout {
|
|||
_lastAudioSource = null;
|
||||
_lastSubtitleSource = null;
|
||||
video = null;
|
||||
_container_content_liveChat?.close();
|
||||
_player.clear();
|
||||
cleanupPlaybackTracker();
|
||||
Logger.i(TAG, "Keep screen on unset onClose")
|
||||
|
@ -1141,6 +1166,7 @@ class VideoDetailView : ConstraintLayout {
|
|||
MediaControlReceiver.onNextReceived.remove(this);
|
||||
MediaControlReceiver.onPreviousReceived.remove(this);
|
||||
MediaControlReceiver.onCloseReceived.remove(this);
|
||||
MediaControlReceiver.onBackgroundReceived.remove(this);
|
||||
MediaControlReceiver.onSeekToReceived.remove(this);
|
||||
|
||||
val job = _jobHideResume;
|
||||
|
@ -1510,60 +1536,68 @@ class VideoDetailView : ConstraintLayout {
|
|||
|
||||
_rating.visibility = View.GONE;
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val queryReferencesResponse = ApiMethods.getQueryReferences(
|
||||
ApiMethods.SERVER, ref, null, null,
|
||||
arrayListOf(
|
||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||
.setFromType(ContentType.OPINION.value).setValue(
|
||||
ByteString.copyFrom(Opinion.like.data)
|
||||
).build(),
|
||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||
.setFromType(ContentType.OPINION.value).setValue(
|
||||
ByteString.copyFrom(Opinion.dislike.data)
|
||||
).build()
|
||||
),
|
||||
extraByteReferences = listOfNotNull(extraBytesRef)
|
||||
);
|
||||
if (StatePolycentric.instance.enabled) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val queryReferencesResponse = ApiMethods.getQueryReferences(
|
||||
ApiMethods.SERVER, ref, null, null,
|
||||
arrayListOf(
|
||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||
.setFromType(ContentType.OPINION.value).setValue(
|
||||
ByteString.copyFrom(Opinion.like.data)
|
||||
).build(),
|
||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||
.setFromType(ContentType.OPINION.value).setValue(
|
||||
ByteString.copyFrom(Opinion.dislike.data)
|
||||
).build()
|
||||
),
|
||||
extraByteReferences = listOfNotNull(extraBytesRef)
|
||||
);
|
||||
|
||||
val likes = queryReferencesResponse.countsList[0];
|
||||
val dislikes = queryReferencesResponse.countsList[1];
|
||||
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
|
||||
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
|
||||
val likes = queryReferencesResponse.countsList[0];
|
||||
val dislikes = queryReferencesResponse.countsList[1];
|
||||
val hasLiked =
|
||||
StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
|
||||
val hasDisliked =
|
||||
StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
_rating.visibility = View.VISIBLE;
|
||||
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
|
||||
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
|
||||
if (args.hasLiked) {
|
||||
args.processHandle.opinion(ref, Opinion.like);
|
||||
} else if (args.hasDisliked) {
|
||||
args.processHandle.opinion(ref, Opinion.dislike);
|
||||
} else {
|
||||
args.processHandle.opinion(ref, Opinion.neutral);
|
||||
}
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
_rating.visibility = View.VISIBLE;
|
||||
_rating.setRating(
|
||||
RatingLikeDislikes(likes, dislikes),
|
||||
hasLiked,
|
||||
hasDisliked
|
||||
);
|
||||
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
|
||||
if (args.hasLiked) {
|
||||
args.processHandle.opinion(ref, Opinion.like);
|
||||
} else if (args.hasDisliked) {
|
||||
args.processHandle.opinion(ref, Opinion.dislike);
|
||||
} else {
|
||||
args.processHandle.opinion(ref, Opinion.neutral);
|
||||
}
|
||||
}
|
||||
|
||||
StatePolycentric.instance.updateLikeMap(
|
||||
ref,
|
||||
args.hasLiked,
|
||||
args.hasDisliked
|
||||
)
|
||||
};
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers", e)
|
||||
}
|
||||
}
|
||||
|
||||
StatePolycentric.instance.updateLikeMap(
|
||||
ref,
|
||||
args.hasLiked,
|
||||
args.hasDisliked
|
||||
)
|
||||
};
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
|
||||
_rating.visibility = View.GONE;
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
|
||||
_rating.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2413,6 +2447,7 @@ class VideoDetailView : ConstraintLayout {
|
|||
Logger.i(TAG, "handleFullScreen(fullscreen=$fullscreen)")
|
||||
|
||||
if(fullscreen) {
|
||||
_container_content.visibility = GONE
|
||||
_layoutPlayerContainer.setPadding(0, 0, 0, 0);
|
||||
|
||||
val lp = _container_content.layoutParams as LayoutParams;
|
||||
|
@ -2426,6 +2461,7 @@ class VideoDetailView : ConstraintLayout {
|
|||
setProgressBarOverlayed(null);
|
||||
}
|
||||
else {
|
||||
_container_content.visibility = VISIBLE
|
||||
_layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt());
|
||||
|
||||
val lp = _container_content.layoutParams as LayoutParams;
|
||||
|
@ -2485,6 +2521,13 @@ class VideoDetailView : ConstraintLayout {
|
|||
}
|
||||
}
|
||||
|
||||
fun saveBrightness() {
|
||||
_player.gestureControl.saveBrightness()
|
||||
}
|
||||
fun restoreBrightness() {
|
||||
_player.gestureControl.restoreBrightness()
|
||||
}
|
||||
|
||||
fun setFullscreen(fullscreen : Boolean) {
|
||||
Logger.i(TAG, "setFullscreen(fullscreen=$fullscreen)")
|
||||
_player.setFullScreen(fullscreen)
|
||||
|
@ -2648,9 +2691,10 @@ class VideoDetailView : ConstraintLayout {
|
|||
}
|
||||
|
||||
onChannelClicked.subscribe {
|
||||
if(it.url.isNotBlank())
|
||||
if(it.url.isNotBlank()) {
|
||||
fragment.minimizeVideoDetail()
|
||||
fragment.navigate<ChannelFragment>(it)
|
||||
else
|
||||
} else
|
||||
UIDialogs.appToast("No author url present");
|
||||
}
|
||||
|
||||
|
@ -2725,10 +2769,11 @@ class VideoDetailView : ConstraintLayout {
|
|||
else
|
||||
RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 6));
|
||||
|
||||
val toBackgroundAction = RemoteAction(Icon.createWithResource(context, R.drawable.ic_screen_share), context.getString(R.string.background), context.getString(R.string.background_switch_audio), MediaControlReceiver.getToBackgroundIntent(context, 7));
|
||||
return PictureInPictureParams.Builder()
|
||||
.setAspectRatio(Rational(videoSourceWidth, videoSourceHeight))
|
||||
.setSourceRectHint(r)
|
||||
.setActions(listOf(playpauseAction))
|
||||
.setActions(listOf(toBackgroundAction, playpauseAction))
|
||||
.build();
|
||||
}
|
||||
|
||||
|
@ -3041,7 +3086,12 @@ class VideoDetailView : ConstraintLayout {
|
|||
Logger.w(TAG, "Failed to load recommendations.", it);
|
||||
};
|
||||
|
||||
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) })
|
||||
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, {
|
||||
if (!StatePolycentric.instance.enabled)
|
||||
return@TaskHandler null
|
||||
|
||||
ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!)
|
||||
})
|
||||
.success { it -> setPolycentricProfile(it, animate = true) }
|
||||
.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load claims.", it);
|
||||
|
@ -3113,10 +3163,6 @@ class VideoDetailView : ConstraintLayout {
|
|||
|
||||
fun applyFragment(frag: VideoDetailFragment) {
|
||||
fragment = frag;
|
||||
fragment.onMinimize.subscribe {
|
||||
_liveChat?.stop();
|
||||
_container_content_liveChat.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
|
@ -48,6 +50,11 @@ abstract class VideoListEditorView : LinearLayout {
|
|||
private var _loadedVideos: List<IPlatformVideo>? = null;
|
||||
private var _loadedVideosCanEdit: Boolean = false;
|
||||
|
||||
fun hideSearchKeyboard() {
|
||||
(context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager)?.hideSoftInputFromWindow(_search.textSearch.windowToken, 0)
|
||||
_search.textSearch.clearFocus();
|
||||
}
|
||||
|
||||
constructor(inflater: LayoutInflater) : super(inflater.context) {
|
||||
inflater.inflate(R.layout.fragment_video_list_editor, this);
|
||||
|
||||
|
@ -79,6 +86,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||
_search.textSearch.text = "";
|
||||
updateVideoFilters();
|
||||
_buttonSearch.setImageResource(R.drawable.ic_search);
|
||||
hideSearchKeyboard();
|
||||
}
|
||||
else {
|
||||
_search.visibility = View.VISIBLE;
|
||||
|
@ -89,23 +97,23 @@ abstract class VideoListEditorView : LinearLayout {
|
|||
_buttonShare = findViewById(R.id.button_share);
|
||||
val onShare = _onShare;
|
||||
if(onShare != null) {
|
||||
_buttonShare.setOnClickListener { onShare.invoke() };
|
||||
_buttonShare.setOnClickListener { hideSearchKeyboard(); onShare.invoke() };
|
||||
_buttonShare.visibility = View.VISIBLE;
|
||||
}
|
||||
else
|
||||
_buttonShare.visibility = View.GONE;
|
||||
|
||||
buttonPlayAll.setOnClickListener { onPlayAllClick(); };
|
||||
buttonShuffle.setOnClickListener { onShuffleClick(); };
|
||||
buttonPlayAll.setOnClickListener { hideSearchKeyboard();onPlayAllClick(); hideSearchKeyboard(); };
|
||||
buttonShuffle.setOnClickListener { hideSearchKeyboard();onShuffleClick(); hideSearchKeyboard(); };
|
||||
|
||||
_buttonEdit.setOnClickListener { onEditClick(); };
|
||||
_buttonEdit.setOnClickListener { hideSearchKeyboard(); onEditClick(); };
|
||||
setButtonExportVisible(false);
|
||||
setButtonDownloadVisible(canEdit());
|
||||
|
||||
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
|
||||
videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved);
|
||||
videoListEditorView.onVideoOptions.subscribe(::onVideoOptions);
|
||||
videoListEditorView.onVideoClicked.subscribe(::onVideoClicked);
|
||||
videoListEditorView.onVideoClicked.subscribe { hideSearchKeyboard(); onVideoClicked(it)};
|
||||
|
||||
_videoListEditorView = videoListEditorView;
|
||||
}
|
||||
|
@ -113,6 +121,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||
fun setOnShare(onShare: (()-> Unit)? = null) {
|
||||
_onShare = onShare;
|
||||
_buttonShare.setOnClickListener {
|
||||
hideSearchKeyboard();
|
||||
onShare?.invoke();
|
||||
};
|
||||
_buttonShare.visibility = View.VISIBLE;
|
||||
|
@ -145,7 +154,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||
setButtonExportVisible(false);
|
||||
_buttonDownload.setImageResource(R.drawable.ic_loader_animated);
|
||||
_buttonDownload.drawable.assume<Animatable, Unit> { it.start() };
|
||||
_buttonDownload.setOnClickListener {
|
||||
_buttonDownload.setOnClickListener { hideSearchKeyboard();
|
||||
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
|
||||
StateDownloads.instance.deleteCachedPlaylist(playlistId);
|
||||
});
|
||||
|
@ -154,7 +163,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||
else if(isDownloaded) {
|
||||
setButtonExportVisible(true)
|
||||
_buttonDownload.setImageResource(R.drawable.ic_download_off);
|
||||
_buttonDownload.setOnClickListener {
|
||||
_buttonDownload.setOnClickListener { hideSearchKeyboard();
|
||||
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
|
||||
StateDownloads.instance.deleteCachedPlaylist(playlistId);
|
||||
});
|
||||
|
@ -163,7 +172,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||
else {
|
||||
setButtonExportVisible(false);
|
||||
_buttonDownload.setImageResource(R.drawable.ic_download);
|
||||
_buttonDownload.setOnClickListener {
|
||||
_buttonDownload.setOnClickListener { hideSearchKeyboard();
|
||||
onDownload();
|
||||
//UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer);
|
||||
}
|
||||
|
@ -215,7 +224,8 @@ abstract class VideoListEditorView : LinearLayout {
|
|||
|
||||
fun updateVideoFilters() {
|
||||
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) {
|
||||
|
|
|
@ -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 {}
|
||||
}
|
||||
}
|
|
@ -88,7 +88,6 @@ class SearchTopBarFragment : TopFragment() {
|
|||
} else if (parameter is SuggestionsFragmentData) {
|
||||
this.setText(parameter.query);
|
||||
_searchType = parameter.searchType;
|
||||
_channelUrl = parameter.channelUrl;
|
||||
}
|
||||
|
||||
if(currentMain is SuggestionsFragment)
|
||||
|
@ -114,7 +113,7 @@ class SearchTopBarFragment : TopFragment() {
|
|||
fun clear() {
|
||||
_editSearch?.text?.clear();
|
||||
if (currentMain !is SuggestionsFragment) {
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType, _channelUrl), false);
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType), false);
|
||||
} else {
|
||||
onSearch.emit("");
|
||||
}
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
data class BroadcastService(
|
||||
val deviceName: String,
|
||||
val serviceName: String,
|
||||
val port: UShort,
|
||||
val ttl: UInt,
|
||||
val weight: UShort,
|
||||
val priority: UShort,
|
||||
val texts: List<String>? = null
|
||||
)
|
|
@ -1,93 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
enum class QueryResponse(val value: Byte) {
|
||||
Query(0),
|
||||
Response(1)
|
||||
}
|
||||
|
||||
enum class DnsOpcode(val value: Byte) {
|
||||
StandardQuery(0),
|
||||
InverseQuery(1),
|
||||
ServerStatusRequest(2)
|
||||
}
|
||||
|
||||
enum class DnsResponseCode(val value: Byte) {
|
||||
NoError(0),
|
||||
FormatError(1),
|
||||
ServerFailure(2),
|
||||
NameError(3),
|
||||
NotImplemented(4),
|
||||
Refused(5)
|
||||
}
|
||||
|
||||
data class DnsPacketHeader(
|
||||
val identifier: UShort,
|
||||
val queryResponse: Int,
|
||||
val opcode: Int,
|
||||
val authoritativeAnswer: Boolean,
|
||||
val truncated: Boolean,
|
||||
val recursionDesired: Boolean,
|
||||
val recursionAvailable: Boolean,
|
||||
val answerAuthenticated: Boolean,
|
||||
val nonAuthenticatedData: Boolean,
|
||||
val responseCode: DnsResponseCode
|
||||
)
|
||||
|
||||
data class DnsPacket(
|
||||
val header: DnsPacketHeader,
|
||||
val questions: List<DnsQuestion>,
|
||||
val answers: List<DnsResourceRecord>,
|
||||
val authorities: List<DnsResourceRecord>,
|
||||
val additionals: List<DnsResourceRecord>
|
||||
) {
|
||||
companion object {
|
||||
fun parse(data: ByteArray): DnsPacket {
|
||||
val span = data.asUByteArray()
|
||||
val flags = (span[2].toInt() shl 8 or span[3].toInt()).toUShort()
|
||||
val questionCount = (span[4].toInt() shl 8 or span[5].toInt()).toUShort()
|
||||
val answerCount = (span[6].toInt() shl 8 or span[7].toInt()).toUShort()
|
||||
val authorityCount = (span[8].toInt() shl 8 or span[9].toInt()).toUShort()
|
||||
val additionalCount = (span[10].toInt() shl 8 or span[11].toInt()).toUShort()
|
||||
|
||||
var position = 12
|
||||
|
||||
val questions = List(questionCount.toInt()) {
|
||||
DnsQuestion.parse(data, position).also { position = it.second }
|
||||
}.map { it.first }
|
||||
|
||||
val answers = List(answerCount.toInt()) {
|
||||
DnsResourceRecord.parse(data, position).also { position = it.second }
|
||||
}.map { it.first }
|
||||
|
||||
val authorities = List(authorityCount.toInt()) {
|
||||
DnsResourceRecord.parse(data, position).also { position = it.second }
|
||||
}.map { it.first }
|
||||
|
||||
val additionals = List(additionalCount.toInt()) {
|
||||
DnsResourceRecord.parse(data, position).also { position = it.second }
|
||||
}.map { it.first }
|
||||
|
||||
return DnsPacket(
|
||||
header = DnsPacketHeader(
|
||||
identifier = (span[0].toInt() shl 8 or span[1].toInt()).toUShort(),
|
||||
queryResponse = ((flags.toUInt() shr 15) and 0b1u).toInt(),
|
||||
opcode = ((flags.toUInt() shr 11) and 0b1111u).toInt(),
|
||||
authoritativeAnswer = (flags.toInt() shr 10) and 0b1 != 0,
|
||||
truncated = (flags.toInt() shr 9) and 0b1 != 0,
|
||||
recursionDesired = (flags.toInt() shr 8) and 0b1 != 0,
|
||||
recursionAvailable = (flags.toInt() shr 7) and 0b1 != 0,
|
||||
answerAuthenticated = (flags.toInt() shr 5) and 0b1 != 0,
|
||||
nonAuthenticatedData = (flags.toInt() shr 4) and 0b1 != 0,
|
||||
responseCode = DnsResponseCode.entries[flags.toInt() and 0b1111]
|
||||
),
|
||||
questions = questions,
|
||||
answers = answers,
|
||||
authorities = authorities,
|
||||
additionals = additionals
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,110 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import com.futo.platformplayer.mdns.Extensions.readDomainName
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
|
||||
enum class QuestionType(val value: UShort) {
|
||||
A(1u),
|
||||
NS(2u),
|
||||
MD(3u),
|
||||
MF(4u),
|
||||
CNAME(5u),
|
||||
SOA(6u),
|
||||
MB(7u),
|
||||
MG(8u),
|
||||
MR(9u),
|
||||
NULL(10u),
|
||||
WKS(11u),
|
||||
PTR(12u),
|
||||
HINFO(13u),
|
||||
MINFO(14u),
|
||||
MX(15u),
|
||||
TXT(16u),
|
||||
RP(17u),
|
||||
AFSDB(18u),
|
||||
SIG(24u),
|
||||
KEY(25u),
|
||||
AAAA(28u),
|
||||
LOC(29u),
|
||||
SRV(33u),
|
||||
NAPTR(35u),
|
||||
KX(36u),
|
||||
CERT(37u),
|
||||
DNAME(39u),
|
||||
APL(42u),
|
||||
DS(43u),
|
||||
SSHFP(44u),
|
||||
IPSECKEY(45u),
|
||||
RRSIG(46u),
|
||||
NSEC(47u),
|
||||
DNSKEY(48u),
|
||||
DHCID(49u),
|
||||
NSEC3(50u),
|
||||
NSEC3PARAM(51u),
|
||||
TSLA(52u),
|
||||
SMIMEA(53u),
|
||||
HIP(55u),
|
||||
CDS(59u),
|
||||
CDNSKEY(60u),
|
||||
OPENPGPKEY(61u),
|
||||
CSYNC(62u),
|
||||
ZONEMD(63u),
|
||||
SVCB(64u),
|
||||
HTTPS(65u),
|
||||
EUI48(108u),
|
||||
EUI64(109u),
|
||||
TKEY(249u),
|
||||
TSIG(250u),
|
||||
URI(256u),
|
||||
CAA(257u),
|
||||
TA(32768u),
|
||||
DLV(32769u),
|
||||
AXFR(252u),
|
||||
IXFR(251u),
|
||||
OPT(41u),
|
||||
MAILB(253u),
|
||||
MALA(254u),
|
||||
All(252u)
|
||||
}
|
||||
|
||||
enum class QuestionClass(val value: UShort) {
|
||||
IN(1u),
|
||||
CS(2u),
|
||||
CH(3u),
|
||||
HS(4u),
|
||||
All(255u)
|
||||
}
|
||||
|
||||
data class DnsQuestion(
|
||||
override val name: String,
|
||||
override val type: Int,
|
||||
override val clazz: Int,
|
||||
val queryUnicast: Boolean
|
||||
) : DnsResourceRecordBase(name, type, clazz) {
|
||||
companion object {
|
||||
fun parse(data: ByteArray, startPosition: Int): Pair<DnsQuestion, Int> {
|
||||
val span = data.asUByteArray()
|
||||
var position = startPosition
|
||||
val qname = span.readDomainName(position).also { position = it.second }
|
||||
val qtype = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
||||
position += 2
|
||||
val qclass = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
||||
position += 2
|
||||
|
||||
return DnsQuestion(
|
||||
name = qname.first,
|
||||
type = qtype.toInt(),
|
||||
queryUnicast = ((qclass.toInt() shr 15) and 0b1) != 0,
|
||||
clazz = qclass.toInt() and 0b111111111111111
|
||||
) to position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open class DnsResourceRecordBase(
|
||||
open val name: String,
|
||||
open val type: Int,
|
||||
open val clazz: Int
|
||||
)
|
|
@ -1,514 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import com.futo.platformplayer.mdns.Extensions.readDomainName
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlin.math.pow
|
||||
import java.net.InetAddress
|
||||
|
||||
data class PTRRecord(val domainName: String)
|
||||
|
||||
data class ARecord(val address: InetAddress)
|
||||
|
||||
data class AAAARecord(val address: InetAddress)
|
||||
|
||||
data class MXRecord(val preference: UShort, val exchange: String)
|
||||
|
||||
data class CNAMERecord(val cname: String)
|
||||
|
||||
data class TXTRecord(val texts: List<String>)
|
||||
|
||||
data class SOARecord(
|
||||
val primaryNameServer: String,
|
||||
val responsibleAuthorityMailbox: String,
|
||||
val serialNumber: Int,
|
||||
val refreshInterval: Int,
|
||||
val retryInterval: Int,
|
||||
val expiryLimit: Int,
|
||||
val minimumTTL: Int
|
||||
)
|
||||
|
||||
data class SRVRecord(val priority: UShort, val weight: UShort, val port: UShort, val target: String)
|
||||
|
||||
data class NSRecord(val nameServer: String)
|
||||
|
||||
data class CAARecord(val flags: Byte, val tag: String, val value: String)
|
||||
|
||||
data class HINFORecord(val cpu: String, val os: String)
|
||||
|
||||
data class RPRecord(val mailbox: String, val txtDomainName: String)
|
||||
|
||||
|
||||
data class AFSDBRecord(val subtype: UShort, val hostname: String)
|
||||
data class LOCRecord(
|
||||
val version: Byte,
|
||||
val size: Double,
|
||||
val horizontalPrecision: Double,
|
||||
val verticalPrecision: Double,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val altitude: Double
|
||||
) {
|
||||
companion object {
|
||||
fun decodeSizeOrPrecision(coded: Byte): Double {
|
||||
val baseValue = (coded.toInt() shr 4) and 0x0F
|
||||
val exponent = coded.toInt() and 0x0F
|
||||
return baseValue * 10.0.pow(exponent.toDouble())
|
||||
}
|
||||
|
||||
fun decodeLatitudeOrLongitude(coded: Int): Double {
|
||||
val arcSeconds = coded / 1E3
|
||||
return arcSeconds / 3600.0
|
||||
}
|
||||
|
||||
fun decodeAltitude(coded: Int): Double {
|
||||
return (coded / 100.0) - 100000.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class NAPTRRecord(
|
||||
val order: UShort,
|
||||
val preference: UShort,
|
||||
val flags: String,
|
||||
val services: String,
|
||||
val regexp: String,
|
||||
val replacement: String
|
||||
)
|
||||
|
||||
data class RRSIGRecord(
|
||||
val typeCovered: UShort,
|
||||
val algorithm: Byte,
|
||||
val labels: Byte,
|
||||
val originalTTL: UInt,
|
||||
val signatureExpiration: UInt,
|
||||
val signatureInception: UInt,
|
||||
val keyTag: UShort,
|
||||
val signersName: String,
|
||||
val signature: ByteArray
|
||||
)
|
||||
|
||||
data class KXRecord(val preference: UShort, val exchanger: String)
|
||||
|
||||
data class CERTRecord(val type: UShort, val keyTag: UShort, val algorithm: Byte, val certificate: ByteArray)
|
||||
|
||||
|
||||
|
||||
data class DNAMERecord(val target: String)
|
||||
|
||||
data class DSRecord(val keyTag: UShort, val algorithm: Byte, val digestType: Byte, val digest: ByteArray)
|
||||
|
||||
data class SSHFPRecord(val algorithm: Byte, val fingerprintType: Byte, val fingerprint: ByteArray)
|
||||
|
||||
data class TLSARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray)
|
||||
|
||||
data class SMIMEARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray)
|
||||
|
||||
data class URIRecord(val priority: UShort, val weight: UShort, val target: String)
|
||||
|
||||
data class NSECRecord(val ownerName: String, val typeBitMaps: List<Pair<Byte, ByteArray>>)
|
||||
data class NSEC3Record(
|
||||
val hashAlgorithm: Byte,
|
||||
val flags: Byte,
|
||||
val iterations: UShort,
|
||||
val salt: ByteArray,
|
||||
val nextHashedOwnerName: ByteArray,
|
||||
val typeBitMaps: List<UShort>
|
||||
)
|
||||
|
||||
data class NSEC3PARAMRecord(val hashAlgorithm: Byte, val flags: Byte, val iterations: UShort, val salt: ByteArray)
|
||||
data class SPFRecord(val texts: List<String>)
|
||||
data class TKEYRecord(
|
||||
val algorithm: String,
|
||||
val inception: UInt,
|
||||
val expiration: UInt,
|
||||
val mode: UShort,
|
||||
val error: UShort,
|
||||
val keyData: ByteArray,
|
||||
val otherData: ByteArray
|
||||
)
|
||||
|
||||
data class TSIGRecord(
|
||||
val algorithmName: String,
|
||||
val timeSigned: UInt,
|
||||
val fudge: UShort,
|
||||
val mac: ByteArray,
|
||||
val originalID: UShort,
|
||||
val error: UShort,
|
||||
val otherData: ByteArray
|
||||
)
|
||||
|
||||
data class OPTRecordOption(val code: UShort, val data: ByteArray)
|
||||
data class OPTRecord(val options: List<OPTRecordOption>)
|
||||
|
||||
class DnsReader(private val data: ByteArray, private var position: Int = 0, private val length: Int = data.size) {
|
||||
|
||||
private val endPosition: Int = position + length
|
||||
|
||||
fun readDomainName(): String {
|
||||
return data.asUByteArray().readDomainName(position).also { position = it.second }.first
|
||||
}
|
||||
|
||||
fun readDouble(): Double {
|
||||
checkRemainingBytes(Double.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).double
|
||||
position += Double.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readInt16(): Short {
|
||||
checkRemainingBytes(Short.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short
|
||||
position += Short.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readInt32(): Int {
|
||||
checkRemainingBytes(Int.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int
|
||||
position += Int.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readInt64(): Long {
|
||||
checkRemainingBytes(Long.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long
|
||||
position += Long.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readSingle(): Float {
|
||||
checkRemainingBytes(Float.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).float
|
||||
position += Float.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readByte(): Byte {
|
||||
checkRemainingBytes(Byte.SIZE_BYTES)
|
||||
return data[position++]
|
||||
}
|
||||
|
||||
fun readBytes(length: Int): ByteArray {
|
||||
checkRemainingBytes(length)
|
||||
return ByteArray(length).also { data.copyInto(it, startIndex = position, endIndex = position + length) }
|
||||
.also { position += length }
|
||||
}
|
||||
|
||||
fun readUInt16(): UShort {
|
||||
checkRemainingBytes(Short.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short.toUShort()
|
||||
position += Short.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readUInt32(): UInt {
|
||||
checkRemainingBytes(Int.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int.toUInt()
|
||||
position += Int.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readUInt64(): ULong {
|
||||
checkRemainingBytes(Long.SIZE_BYTES)
|
||||
val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long.toULong()
|
||||
position += Long.SIZE_BYTES
|
||||
return result
|
||||
}
|
||||
|
||||
fun readString(): String {
|
||||
val length = data[position++].toInt()
|
||||
checkRemainingBytes(length)
|
||||
return String(data, position, length, StandardCharsets.UTF_8).also { position += length }
|
||||
}
|
||||
|
||||
private fun checkRemainingBytes(requiredBytes: Int) {
|
||||
if (position + requiredBytes > endPosition) throw IndexOutOfBoundsException()
|
||||
}
|
||||
|
||||
fun readRPRecord(): RPRecord {
|
||||
return RPRecord(readDomainName(), readDomainName())
|
||||
}
|
||||
|
||||
fun readKXRecord(): KXRecord {
|
||||
val preference = readUInt16()
|
||||
val exchanger = readDomainName()
|
||||
return KXRecord(preference, exchanger)
|
||||
}
|
||||
|
||||
fun readCERTRecord(): CERTRecord {
|
||||
val type = readUInt16()
|
||||
val keyTag = readUInt16()
|
||||
val algorithm = readByte()
|
||||
val certificateLength = readUInt16().toInt() - 5
|
||||
val certificate = readBytes(certificateLength)
|
||||
return CERTRecord(type, keyTag, algorithm, certificate)
|
||||
}
|
||||
|
||||
fun readPTRRecord(): PTRRecord {
|
||||
return PTRRecord(readDomainName())
|
||||
}
|
||||
|
||||
fun readARecord(): ARecord {
|
||||
val address = readBytes(4)
|
||||
return ARecord(InetAddress.getByAddress(address))
|
||||
}
|
||||
|
||||
fun readAAAARecord(): AAAARecord {
|
||||
val address = readBytes(16)
|
||||
return AAAARecord(InetAddress.getByAddress(address))
|
||||
}
|
||||
|
||||
fun readMXRecord(): MXRecord {
|
||||
val preference = readUInt16()
|
||||
val exchange = readDomainName()
|
||||
return MXRecord(preference, exchange)
|
||||
}
|
||||
|
||||
fun readCNAMERecord(): CNAMERecord {
|
||||
return CNAMERecord(readDomainName())
|
||||
}
|
||||
|
||||
fun readTXTRecord(): TXTRecord {
|
||||
val texts = mutableListOf<String>()
|
||||
while (position < endPosition) {
|
||||
val textLength = data[position++].toInt()
|
||||
checkRemainingBytes(textLength)
|
||||
val text = String(data, position, textLength, StandardCharsets.UTF_8)
|
||||
texts.add(text)
|
||||
position += textLength
|
||||
}
|
||||
return TXTRecord(texts)
|
||||
}
|
||||
|
||||
fun readSOARecord(): SOARecord {
|
||||
val primaryNameServer = readDomainName()
|
||||
val responsibleAuthorityMailbox = readDomainName()
|
||||
val serialNumber = readInt32()
|
||||
val refreshInterval = readInt32()
|
||||
val retryInterval = readInt32()
|
||||
val expiryLimit = readInt32()
|
||||
val minimumTTL = readInt32()
|
||||
return SOARecord(primaryNameServer, responsibleAuthorityMailbox, serialNumber, refreshInterval, retryInterval, expiryLimit, minimumTTL)
|
||||
}
|
||||
|
||||
fun readSRVRecord(): SRVRecord {
|
||||
val priority = readUInt16()
|
||||
val weight = readUInt16()
|
||||
val port = readUInt16()
|
||||
val target = readDomainName()
|
||||
return SRVRecord(priority, weight, port, target)
|
||||
}
|
||||
|
||||
fun readNSRecord(): NSRecord {
|
||||
return NSRecord(readDomainName())
|
||||
}
|
||||
|
||||
fun readCAARecord(): CAARecord {
|
||||
val length = readUInt16().toInt()
|
||||
val flags = readByte()
|
||||
val tagLength = readByte().toInt()
|
||||
val tag = String(data, position, tagLength, StandardCharsets.US_ASCII).also { position += tagLength }
|
||||
val valueLength = length - 1 - 1 - tagLength
|
||||
val value = String(data, position, valueLength, StandardCharsets.US_ASCII).also { position += valueLength }
|
||||
return CAARecord(flags, tag, value)
|
||||
}
|
||||
|
||||
fun readHINFORecord(): HINFORecord {
|
||||
val cpuLength = readByte().toInt()
|
||||
val cpu = String(data, position, cpuLength, StandardCharsets.US_ASCII).also { position += cpuLength }
|
||||
val osLength = readByte().toInt()
|
||||
val os = String(data, position, osLength, StandardCharsets.US_ASCII).also { position += osLength }
|
||||
return HINFORecord(cpu, os)
|
||||
}
|
||||
|
||||
fun readAFSDBRecord(): AFSDBRecord {
|
||||
return AFSDBRecord(readUInt16(), readDomainName())
|
||||
}
|
||||
|
||||
fun readLOCRecord(): LOCRecord {
|
||||
val version = readByte()
|
||||
val size = LOCRecord.decodeSizeOrPrecision(readByte())
|
||||
val horizontalPrecision = LOCRecord.decodeSizeOrPrecision(readByte())
|
||||
val verticalPrecision = LOCRecord.decodeSizeOrPrecision(readByte())
|
||||
val latitudeCoded = readInt32()
|
||||
val longitudeCoded = readInt32()
|
||||
val altitudeCoded = readInt32()
|
||||
val latitude = LOCRecord.decodeLatitudeOrLongitude(latitudeCoded)
|
||||
val longitude = LOCRecord.decodeLatitudeOrLongitude(longitudeCoded)
|
||||
val altitude = LOCRecord.decodeAltitude(altitudeCoded)
|
||||
return LOCRecord(version, size, horizontalPrecision, verticalPrecision, latitude, longitude, altitude)
|
||||
}
|
||||
|
||||
fun readNAPTRRecord(): NAPTRRecord {
|
||||
val order = readUInt16()
|
||||
val preference = readUInt16()
|
||||
val flags = readString()
|
||||
val services = readString()
|
||||
val regexp = readString()
|
||||
val replacement = readDomainName()
|
||||
return NAPTRRecord(order, preference, flags, services, regexp, replacement)
|
||||
}
|
||||
|
||||
fun readDNAMERecord(): DNAMERecord {
|
||||
return DNAMERecord(readDomainName())
|
||||
}
|
||||
|
||||
fun readDSRecord(): DSRecord {
|
||||
val keyTag = readUInt16()
|
||||
val algorithm = readByte()
|
||||
val digestType = readByte()
|
||||
val digestLength = readUInt16().toInt() - 4
|
||||
val digest = readBytes(digestLength)
|
||||
return DSRecord(keyTag, algorithm, digestType, digest)
|
||||
}
|
||||
|
||||
fun readSSHFPRecord(): SSHFPRecord {
|
||||
val algorithm = readByte()
|
||||
val fingerprintType = readByte()
|
||||
val fingerprintLength = readUInt16().toInt() - 2
|
||||
val fingerprint = readBytes(fingerprintLength)
|
||||
return SSHFPRecord(algorithm, fingerprintType, fingerprint)
|
||||
}
|
||||
|
||||
fun readTLSARecord(): TLSARecord {
|
||||
val usage = readByte()
|
||||
val selector = readByte()
|
||||
val matchingType = readByte()
|
||||
val dataLength = readUInt16().toInt() - 3
|
||||
val certificateAssociationData = readBytes(dataLength)
|
||||
return TLSARecord(usage, selector, matchingType, certificateAssociationData)
|
||||
}
|
||||
|
||||
fun readSMIMEARecord(): SMIMEARecord {
|
||||
val usage = readByte()
|
||||
val selector = readByte()
|
||||
val matchingType = readByte()
|
||||
val dataLength = readUInt16().toInt() - 3
|
||||
val certificateAssociationData = readBytes(dataLength)
|
||||
return SMIMEARecord(usage, selector, matchingType, certificateAssociationData)
|
||||
}
|
||||
|
||||
fun readURIRecord(): URIRecord {
|
||||
val priority = readUInt16()
|
||||
val weight = readUInt16()
|
||||
val length = readUInt16().toInt()
|
||||
val target = String(data, position, length, StandardCharsets.US_ASCII).also { position += length }
|
||||
return URIRecord(priority, weight, target)
|
||||
}
|
||||
|
||||
fun readRRSIGRecord(): RRSIGRecord {
|
||||
val typeCovered = readUInt16()
|
||||
val algorithm = readByte()
|
||||
val labels = readByte()
|
||||
val originalTTL = readUInt32()
|
||||
val signatureExpiration = readUInt32()
|
||||
val signatureInception = readUInt32()
|
||||
val keyTag = readUInt16()
|
||||
val signersName = readDomainName()
|
||||
val signatureLength = readUInt16().toInt()
|
||||
val signature = readBytes(signatureLength)
|
||||
return RRSIGRecord(
|
||||
typeCovered,
|
||||
algorithm,
|
||||
labels,
|
||||
originalTTL,
|
||||
signatureExpiration,
|
||||
signatureInception,
|
||||
keyTag,
|
||||
signersName,
|
||||
signature
|
||||
)
|
||||
}
|
||||
|
||||
fun readNSECRecord(): NSECRecord {
|
||||
val ownerName = readDomainName()
|
||||
val typeBitMaps = mutableListOf<Pair<Byte, ByteArray>>()
|
||||
while (position < endPosition) {
|
||||
val windowBlock = readByte()
|
||||
val bitmapLength = readByte().toInt()
|
||||
val bitmap = readBytes(bitmapLength)
|
||||
typeBitMaps.add(windowBlock to bitmap)
|
||||
}
|
||||
return NSECRecord(ownerName, typeBitMaps)
|
||||
}
|
||||
|
||||
fun readNSEC3Record(): NSEC3Record {
|
||||
val hashAlgorithm = readByte()
|
||||
val flags = readByte()
|
||||
val iterations = readUInt16()
|
||||
val saltLength = readByte().toInt()
|
||||
val salt = readBytes(saltLength)
|
||||
val hashLength = readByte().toInt()
|
||||
val nextHashedOwnerName = readBytes(hashLength)
|
||||
val bitMapLength = readUInt16().toInt()
|
||||
val typeBitMaps = mutableListOf<UShort>()
|
||||
val endPos = position + bitMapLength
|
||||
while (position < endPos) {
|
||||
typeBitMaps.add(readUInt16())
|
||||
}
|
||||
return NSEC3Record(hashAlgorithm, flags, iterations, salt, nextHashedOwnerName, typeBitMaps)
|
||||
}
|
||||
|
||||
fun readNSEC3PARAMRecord(): NSEC3PARAMRecord {
|
||||
val hashAlgorithm = readByte()
|
||||
val flags = readByte()
|
||||
val iterations = readUInt16()
|
||||
val saltLength = readByte().toInt()
|
||||
val salt = readBytes(saltLength)
|
||||
return NSEC3PARAMRecord(hashAlgorithm, flags, iterations, salt)
|
||||
}
|
||||
|
||||
|
||||
fun readSPFRecord(): SPFRecord {
|
||||
val length = readUInt16().toInt()
|
||||
val texts = mutableListOf<String>()
|
||||
val endPos = position + length
|
||||
while (position < endPos) {
|
||||
val textLength = readByte().toInt()
|
||||
val text = String(data, position, textLength, StandardCharsets.US_ASCII).also { position += textLength }
|
||||
texts.add(text)
|
||||
}
|
||||
return SPFRecord(texts)
|
||||
}
|
||||
|
||||
fun readTKEYRecord(): TKEYRecord {
|
||||
val algorithm = readDomainName()
|
||||
val inception = readUInt32()
|
||||
val expiration = readUInt32()
|
||||
val mode = readUInt16()
|
||||
val error = readUInt16()
|
||||
val keySize = readUInt16().toInt()
|
||||
val keyData = readBytes(keySize)
|
||||
val otherSize = readUInt16().toInt()
|
||||
val otherData = readBytes(otherSize)
|
||||
return TKEYRecord(algorithm, inception, expiration, mode, error, keyData, otherData)
|
||||
}
|
||||
|
||||
fun readTSIGRecord(): TSIGRecord {
|
||||
val algorithmName = readDomainName()
|
||||
val timeSigned = readUInt32()
|
||||
val fudge = readUInt16()
|
||||
val macSize = readUInt16().toInt()
|
||||
val mac = readBytes(macSize)
|
||||
val originalID = readUInt16()
|
||||
val error = readUInt16()
|
||||
val otherSize = readUInt16().toInt()
|
||||
val otherData = readBytes(otherSize)
|
||||
return TSIGRecord(algorithmName, timeSigned, fudge, mac, originalID, error, otherData)
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun readOPTRecord(): OPTRecord {
|
||||
val options = mutableListOf<OPTRecordOption>()
|
||||
while (position < endPosition) {
|
||||
val optionCode = readUInt16()
|
||||
val optionLength = readUInt16().toInt()
|
||||
val optionData = readBytes(optionLength)
|
||||
options.add(OPTRecordOption(optionCode, optionData))
|
||||
}
|
||||
return OPTRecord(options)
|
||||
}
|
||||
}
|
|
@ -1,117 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import com.futo.platformplayer.mdns.Extensions.readDomainName
|
||||
|
||||
enum class ResourceRecordType(val value: UShort) {
|
||||
None(0u),
|
||||
A(1u),
|
||||
NS(2u),
|
||||
MD(3u),
|
||||
MF(4u),
|
||||
CNAME(5u),
|
||||
SOA(6u),
|
||||
MB(7u),
|
||||
MG(8u),
|
||||
MR(9u),
|
||||
NULL(10u),
|
||||
WKS(11u),
|
||||
PTR(12u),
|
||||
HINFO(13u),
|
||||
MINFO(14u),
|
||||
MX(15u),
|
||||
TXT(16u),
|
||||
RP(17u),
|
||||
AFSDB(18u),
|
||||
SIG(24u),
|
||||
KEY(25u),
|
||||
AAAA(28u),
|
||||
LOC(29u),
|
||||
SRV(33u),
|
||||
NAPTR(35u),
|
||||
KX(36u),
|
||||
CERT(37u),
|
||||
DNAME(39u),
|
||||
APL(42u),
|
||||
DS(43u),
|
||||
SSHFP(44u),
|
||||
IPSECKEY(45u),
|
||||
RRSIG(46u),
|
||||
NSEC(47u),
|
||||
DNSKEY(48u),
|
||||
DHCID(49u),
|
||||
NSEC3(50u),
|
||||
NSEC3PARAM(51u),
|
||||
TSLA(52u),
|
||||
SMIMEA(53u),
|
||||
HIP(55u),
|
||||
CDS(59u),
|
||||
CDNSKEY(60u),
|
||||
OPENPGPKEY(61u),
|
||||
CSYNC(62u),
|
||||
ZONEMD(63u),
|
||||
SVCB(64u),
|
||||
HTTPS(65u),
|
||||
EUI48(108u),
|
||||
EUI64(109u),
|
||||
TKEY(249u),
|
||||
TSIG(250u),
|
||||
URI(256u),
|
||||
CAA(257u),
|
||||
TA(32768u),
|
||||
DLV(32769u),
|
||||
AXFR(252u),
|
||||
IXFR(251u),
|
||||
OPT(41u)
|
||||
}
|
||||
|
||||
enum class ResourceRecordClass(val value: UShort) {
|
||||
IN(1u),
|
||||
CS(2u),
|
||||
CH(3u),
|
||||
HS(4u)
|
||||
}
|
||||
|
||||
data class DnsResourceRecord(
|
||||
override val name: String,
|
||||
override val type: Int,
|
||||
override val clazz: Int,
|
||||
val timeToLive: UInt,
|
||||
val cacheFlush: Boolean,
|
||||
val dataPosition: Int = -1,
|
||||
val dataLength: Int = -1,
|
||||
private val data: ByteArray? = null
|
||||
) : DnsResourceRecordBase(name, type, clazz) {
|
||||
|
||||
companion object {
|
||||
fun parse(data: ByteArray, startPosition: Int): Pair<DnsResourceRecord, Int> {
|
||||
val span = data.asUByteArray()
|
||||
var position = startPosition
|
||||
val name = span.readDomainName(position).also { position = it.second }
|
||||
val type = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
||||
position += 2
|
||||
val clazz = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
||||
position += 2
|
||||
val ttl = (span[position].toInt() shl 24 or (span[position + 1].toInt() shl 16) or
|
||||
(span[position + 2].toInt() shl 8) or span[position + 3].toInt()).toUInt()
|
||||
position += 4
|
||||
val rdlength = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
|
||||
val rdposition = position + 2
|
||||
position += 2 + rdlength.toInt()
|
||||
|
||||
return DnsResourceRecord(
|
||||
name = name.first,
|
||||
type = type.toInt(),
|
||||
clazz = clazz.toInt() and 0b1111111_11111111,
|
||||
timeToLive = ttl,
|
||||
cacheFlush = ((clazz.toInt() shr 15) and 0b1) != 0,
|
||||
dataPosition = rdposition,
|
||||
dataLength = rdlength.toInt(),
|
||||
data = data
|
||||
) to position
|
||||
}
|
||||
}
|
||||
|
||||
fun getDataReader(): DnsReader {
|
||||
return DnsReader(data!!, dataPosition, dataLength)
|
||||
}
|
||||
}
|
|
@ -1,208 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class DnsWriter {
|
||||
private val data = mutableListOf<Byte>()
|
||||
private val namePositions = mutableMapOf<String, Int>()
|
||||
|
||||
fun toByteArray(): ByteArray = data.toByteArray()
|
||||
|
||||
fun writePacket(
|
||||
header: DnsPacketHeader,
|
||||
questionCount: Int? = null, questionWriter: ((DnsWriter, Int) -> Unit)? = null,
|
||||
answerCount: Int? = null, answerWriter: ((DnsWriter, Int) -> Unit)? = null,
|
||||
authorityCount: Int? = null, authorityWriter: ((DnsWriter, Int) -> Unit)? = null,
|
||||
additionalsCount: Int? = null, additionalWriter: ((DnsWriter, Int) -> Unit)? = null
|
||||
) {
|
||||
if (questionCount != null && questionWriter == null || questionCount == null && questionWriter != null)
|
||||
throw Exception("When question count is given, question writer should also be given.")
|
||||
if (answerCount != null && answerWriter == null || answerCount == null && answerWriter != null)
|
||||
throw Exception("When answer count is given, answer writer should also be given.")
|
||||
if (authorityCount != null && authorityWriter == null || authorityCount == null && authorityWriter != null)
|
||||
throw Exception("When authority count is given, authority writer should also be given.")
|
||||
if (additionalsCount != null && additionalWriter == null || additionalsCount == null && additionalWriter != null)
|
||||
throw Exception("When additionals count is given, additional writer should also be given.")
|
||||
|
||||
writeHeader(header, questionCount ?: 0, answerCount ?: 0, authorityCount ?: 0, additionalsCount ?: 0)
|
||||
|
||||
repeat(questionCount ?: 0) { questionWriter?.invoke(this, it) }
|
||||
repeat(answerCount ?: 0) { answerWriter?.invoke(this, it) }
|
||||
repeat(authorityCount ?: 0) { authorityWriter?.invoke(this, it) }
|
||||
repeat(additionalsCount ?: 0) { additionalWriter?.invoke(this, it) }
|
||||
}
|
||||
|
||||
fun writeHeader(header: DnsPacketHeader, questionCount: Int, answerCount: Int, authorityCount: Int, additionalsCount: Int) {
|
||||
write(header.identifier)
|
||||
|
||||
var flags: UShort = 0u
|
||||
flags = flags or ((header.queryResponse.toUInt() and 0xFFFFu) shl 15).toUShort()
|
||||
flags = flags or ((header.opcode.toUInt() and 0xFFFFu) shl 11).toUShort()
|
||||
flags = flags or ((if (header.authoritativeAnswer) 1u else 0u) shl 10).toUShort()
|
||||
flags = flags or ((if (header.truncated) 1u else 0u) shl 9).toUShort()
|
||||
flags = flags or ((if (header.recursionDesired) 1u else 0u) shl 8).toUShort()
|
||||
flags = flags or ((if (header.recursionAvailable) 1u else 0u) shl 7).toUShort()
|
||||
flags = flags or ((if (header.answerAuthenticated) 1u else 0u) shl 5).toUShort()
|
||||
flags = flags or ((if (header.nonAuthenticatedData) 1u else 0u) shl 4).toUShort()
|
||||
flags = flags or header.responseCode.value.toUShort()
|
||||
write(flags)
|
||||
|
||||
write(questionCount.toUShort())
|
||||
write(answerCount.toUShort())
|
||||
write(authorityCount.toUShort())
|
||||
write(additionalsCount.toUShort())
|
||||
}
|
||||
|
||||
fun writeDomainName(name: String) {
|
||||
synchronized(namePositions) {
|
||||
val labels = name.split('.')
|
||||
for (label in labels) {
|
||||
val nameAtOffset = name.substring(name.indexOf(label))
|
||||
if (namePositions.containsKey(nameAtOffset)) {
|
||||
val position = namePositions[nameAtOffset]!!
|
||||
val pointer = (0b11000000_00000000 or position).toUShort()
|
||||
write(pointer)
|
||||
return
|
||||
}
|
||||
if (label.isNotEmpty()) {
|
||||
val labelBytes = label.toByteArray(StandardCharsets.UTF_8)
|
||||
val nameStartPos = data.size
|
||||
write(labelBytes.size.toByte())
|
||||
write(labelBytes)
|
||||
namePositions[nameAtOffset] = nameStartPos
|
||||
}
|
||||
}
|
||||
write(0.toByte())
|
||||
}
|
||||
}
|
||||
|
||||
fun write(value: DnsResourceRecord, dataWriter: (DnsWriter) -> Unit) {
|
||||
writeDomainName(value.name)
|
||||
write(value.type.toUShort())
|
||||
val cls = ((if (value.cacheFlush) 1u else 0u) shl 15).toUShort() or value.clazz.toUShort()
|
||||
write(cls)
|
||||
write(value.timeToLive)
|
||||
|
||||
val lengthOffset = data.size
|
||||
write(0.toUShort())
|
||||
dataWriter(this)
|
||||
val rdLength = data.size - lengthOffset - 2
|
||||
val rdLengthBytes = ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN).putShort(rdLength.toShort()).array()
|
||||
data[lengthOffset] = rdLengthBytes[0]
|
||||
data[lengthOffset + 1] = rdLengthBytes[1]
|
||||
}
|
||||
|
||||
fun write(value: DnsQuestion) {
|
||||
writeDomainName(value.name)
|
||||
write(value.type.toUShort())
|
||||
write(((if (value.queryUnicast) 1u else 0u shl 15).toUShort() or value.clazz.toUShort()))
|
||||
}
|
||||
|
||||
fun write(value: Double) {
|
||||
val bytes = ByteBuffer.allocate(Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putDouble(value).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: Short) {
|
||||
val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: Int) {
|
||||
val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: Long) {
|
||||
val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: Float) {
|
||||
val bytes = ByteBuffer.allocate(Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putFloat(value).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: Byte) {
|
||||
data.add(value)
|
||||
}
|
||||
|
||||
fun write(value: ByteArray) {
|
||||
data.addAll(value.asIterable())
|
||||
}
|
||||
|
||||
fun write(value: ByteArray, offset: Int, length: Int) {
|
||||
data.addAll(value.slice(offset until offset + length))
|
||||
}
|
||||
|
||||
fun write(value: UShort) {
|
||||
val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value.toShort()).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: UInt) {
|
||||
val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value.toInt()).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: ULong) {
|
||||
val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value.toLong()).array()
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: String) {
|
||||
val bytes = value.toByteArray(StandardCharsets.UTF_8)
|
||||
write(bytes.size.toByte())
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: PTRRecord) {
|
||||
writeDomainName(value.domainName)
|
||||
}
|
||||
|
||||
fun write(value: ARecord) {
|
||||
val bytes = value.address.address
|
||||
if (bytes.size != 4) throw Exception("Unexpected amount of address bytes.")
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: AAAARecord) {
|
||||
val bytes = value.address.address
|
||||
if (bytes.size != 16) throw Exception("Unexpected amount of address bytes.")
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
fun write(value: TXTRecord) {
|
||||
value.texts.forEach {
|
||||
val bytes = it.toByteArray(StandardCharsets.UTF_8)
|
||||
write(bytes.size.toByte())
|
||||
write(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fun write(value: SRVRecord) {
|
||||
write(value.priority)
|
||||
write(value.weight)
|
||||
write(value.port)
|
||||
writeDomainName(value.target)
|
||||
}
|
||||
|
||||
fun write(value: NSECRecord) {
|
||||
writeDomainName(value.ownerName)
|
||||
value.typeBitMaps.forEach { (windowBlock, bitmap) ->
|
||||
write(windowBlock)
|
||||
write(bitmap.size.toByte())
|
||||
write(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
fun write(value: OPTRecord) {
|
||||
value.options.forEach { option ->
|
||||
write(option.code)
|
||||
write(option.data.size.toUShort())
|
||||
write(option.data)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import android.util.Log
|
||||
|
||||
object Extensions {
|
||||
fun ByteArray.toByteDump(): String {
|
||||
val result = StringBuilder()
|
||||
for (i in indices) {
|
||||
result.append(String.format("%02X ", this[i]))
|
||||
|
||||
if ((i + 1) % 16 == 0 || i == size - 1) {
|
||||
val padding = 3 * (16 - (i % 16 + 1))
|
||||
if (i == size - 1 && (i + 1) % 16 != 0) result.append(" ".repeat(padding))
|
||||
|
||||
result.append("; ")
|
||||
val start = i - (i % 16)
|
||||
val end = minOf(i, size - 1)
|
||||
for (j in start..end) {
|
||||
val ch = if (this[j] in 32..127) this[j].toChar() else '.'
|
||||
result.append(ch)
|
||||
}
|
||||
if (i != size - 1) result.appendLine()
|
||||
}
|
||||
}
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
fun UByteArray.readDomainName(startPosition: Int): Pair<String, Int> {
|
||||
var position = startPosition
|
||||
return readDomainName(position, 0)
|
||||
}
|
||||
|
||||
private fun UByteArray.readDomainName(position: Int, depth: Int = 0): Pair<String, Int> {
|
||||
if (depth > 16) throw Exception("Exceeded maximum recursion depth in DNS packet. Possible circular reference.")
|
||||
|
||||
val domainParts = mutableListOf<String>()
|
||||
var newPosition = position
|
||||
|
||||
while (true) {
|
||||
if (newPosition < 0)
|
||||
println()
|
||||
|
||||
val length = this[newPosition].toUByte()
|
||||
if ((length and 0b11000000u).toUInt() == 0b11000000u) {
|
||||
val offset = (((length and 0b00111111u).toUInt()) shl 8) or this[newPosition + 1].toUInt()
|
||||
val (part, _) = this.readDomainName(offset.toInt(), depth + 1)
|
||||
domainParts.add(part)
|
||||
newPosition += 2
|
||||
break
|
||||
} else if (length.toUInt() == 0u) {
|
||||
newPosition++
|
||||
break
|
||||
} else {
|
||||
newPosition++
|
||||
val part = String(this.asByteArray(), newPosition, length.toInt(), Charsets.UTF_8)
|
||||
domainParts.add(part)
|
||||
newPosition += length.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
return domainParts.joinToString(".") to newPosition
|
||||
}
|
||||
}
|
|
@ -1,495 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.coroutines.*
|
||||
import java.net.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
class MDNSListener {
|
||||
companion object {
|
||||
private val TAG = "MDNSListener"
|
||||
const val MulticastPort = 5353
|
||||
val MulticastAddressIPv4: InetAddress = InetAddress.getByName("224.0.0.251")
|
||||
val MulticastAddressIPv6: InetAddress = InetAddress.getByName("FF02::FB")
|
||||
val MdnsEndpointIPv6: InetSocketAddress = InetSocketAddress(MulticastAddressIPv6, MulticastPort)
|
||||
val MdnsEndpointIPv4: InetSocketAddress = InetSocketAddress(MulticastAddressIPv4, MulticastPort)
|
||||
}
|
||||
|
||||
private val _lockObject = ReentrantLock()
|
||||
private var _receiver4: MulticastSocket? = null
|
||||
private var _receiver6: MulticastSocket? = null
|
||||
private val _senders = mutableListOf<MulticastSocket>()
|
||||
private val _nicMonitor = NICMonitor()
|
||||
private val _serviceRecordAggregator = ServiceRecordAggregator()
|
||||
private var _started = false
|
||||
private var _threadReceiver4: Thread? = null
|
||||
private var _threadReceiver6: Thread? = null
|
||||
private var _scope: CoroutineScope? = null
|
||||
|
||||
var onPacket: ((DnsPacket) -> Unit)? = null
|
||||
var onServicesUpdated: ((List<DnsService>) -> Unit)? = null
|
||||
|
||||
private val _recordLockObject = ReentrantLock()
|
||||
private val _recordsA = mutableListOf<Pair<DnsResourceRecord, ARecord>>()
|
||||
private val _recordsAAAA = mutableListOf<Pair<DnsResourceRecord, AAAARecord>>()
|
||||
private val _recordsPTR = mutableListOf<Pair<DnsResourceRecord, PTRRecord>>()
|
||||
private val _recordsTXT = mutableListOf<Pair<DnsResourceRecord, TXTRecord>>()
|
||||
private val _recordsSRV = mutableListOf<Pair<DnsResourceRecord, SRVRecord>>()
|
||||
private val _services = mutableListOf<BroadcastService>()
|
||||
|
||||
init {
|
||||
_nicMonitor.added = { onNicsAdded(it) }
|
||||
_nicMonitor.removed = { onNicsRemoved(it) }
|
||||
_serviceRecordAggregator.onServicesUpdated = { onServicesUpdated?.invoke(it) }
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (_started) {
|
||||
Logger.i(TAG, "Already started.")
|
||||
return
|
||||
}
|
||||
_started = true
|
||||
|
||||
_scope = CoroutineScope(Dispatchers.IO);
|
||||
|
||||
Logger.i(TAG, "Starting")
|
||||
_lockObject.withLock {
|
||||
val receiver4 = MulticastSocket(null).apply {
|
||||
reuseAddress = true
|
||||
bind(InetSocketAddress(InetAddress.getByName("0.0.0.0"), MulticastPort))
|
||||
}
|
||||
_receiver4 = receiver4
|
||||
|
||||
val receiver6 = MulticastSocket(null).apply {
|
||||
reuseAddress = true
|
||||
bind(InetSocketAddress(InetAddress.getByName("::"), MulticastPort))
|
||||
}
|
||||
_receiver6 = receiver6
|
||||
|
||||
_nicMonitor.start()
|
||||
_serviceRecordAggregator.start()
|
||||
onNicsAdded(_nicMonitor.current)
|
||||
|
||||
_threadReceiver4 = Thread {
|
||||
receiveLoop(receiver4)
|
||||
}.apply { start() }
|
||||
|
||||
_threadReceiver6 = Thread {
|
||||
receiveLoop(receiver6)
|
||||
}.apply { start() }
|
||||
}
|
||||
}
|
||||
|
||||
fun queryServices(names: Array<String>) {
|
||||
if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.")
|
||||
|
||||
val writer = DnsWriter()
|
||||
writer.writePacket(
|
||||
DnsPacketHeader(
|
||||
identifier = 0u,
|
||||
queryResponse = QueryResponse.Query.value.toInt(),
|
||||
opcode = DnsOpcode.StandardQuery.value.toInt(),
|
||||
truncated = false,
|
||||
nonAuthenticatedData = false,
|
||||
recursionDesired = false,
|
||||
answerAuthenticated = false,
|
||||
authoritativeAnswer = false,
|
||||
recursionAvailable = false,
|
||||
responseCode = DnsResponseCode.NoError
|
||||
),
|
||||
questionCount = names.size,
|
||||
questionWriter = { w, i ->
|
||||
w.write(
|
||||
DnsQuestion(
|
||||
name = names[i],
|
||||
type = QuestionType.PTR.value.toInt(),
|
||||
clazz = QuestionClass.IN.value.toInt(),
|
||||
queryUnicast = false
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
send(writer.toByteArray())
|
||||
}
|
||||
|
||||
private fun send(data: ByteArray) {
|
||||
_lockObject.withLock {
|
||||
for (sender in _senders) {
|
||||
try {
|
||||
val endPoint = if (sender.localAddress is Inet4Address) MdnsEndpointIPv4 else MdnsEndpointIPv6
|
||||
sender.send(DatagramPacket(data, data.size, endPoint))
|
||||
} catch (e: Exception) {
|
||||
Logger.i(TAG, "Failed to send on ${sender.localSocketAddress}: ${e.message}.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun queryAllQuestions(names: Array<String>) {
|
||||
if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.")
|
||||
|
||||
val questions = names.flatMap { _serviceRecordAggregator.getAllQuestions(it) }
|
||||
questions.groupBy { it.name }.forEach { (_, questionsForHost) ->
|
||||
val writer = DnsWriter()
|
||||
writer.writePacket(
|
||||
DnsPacketHeader(
|
||||
identifier = 0u,
|
||||
queryResponse = QueryResponse.Query.value.toInt(),
|
||||
opcode = DnsOpcode.StandardQuery.value.toInt(),
|
||||
truncated = false,
|
||||
nonAuthenticatedData = false,
|
||||
recursionDesired = false,
|
||||
answerAuthenticated = false,
|
||||
authoritativeAnswer = false,
|
||||
recursionAvailable = false,
|
||||
responseCode = DnsResponseCode.NoError
|
||||
),
|
||||
questionCount = questionsForHost.size,
|
||||
questionWriter = { w, i -> w.write(questionsForHost[i]) }
|
||||
)
|
||||
send(writer.toByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNicsAdded(nics: List<NetworkInterface>) {
|
||||
_lockObject.withLock {
|
||||
if (!_started) return
|
||||
|
||||
val addresses = nics.flatMap { nic ->
|
||||
nic.interfaceAddresses.map { it.address }
|
||||
.filter { it is Inet4Address || (it is Inet6Address && it.isLinkLocalAddress) }
|
||||
}
|
||||
|
||||
addresses.forEach { address ->
|
||||
Logger.i(TAG, "New address discovered $address")
|
||||
|
||||
try {
|
||||
when (address) {
|
||||
is Inet4Address -> {
|
||||
_receiver4?.let { receiver4 ->
|
||||
//receiver4.setOption(StandardSocketOptions.IP_MULTICAST_IF, NetworkInterface.getByInetAddress(address))
|
||||
receiver4.joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address))
|
||||
}
|
||||
|
||||
val sender = MulticastSocket(null).apply {
|
||||
reuseAddress = true
|
||||
bind(InetSocketAddress(address, MulticastPort))
|
||||
joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address))
|
||||
}
|
||||
_senders.add(sender)
|
||||
}
|
||||
|
||||
is Inet6Address -> {
|
||||
_receiver6?.let { receiver6 ->
|
||||
//receiver6.setOption(StandardSocketOptions.IP_MULTICAST_IF, NetworkInterface.getByInetAddress(address))
|
||||
receiver6.joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), NetworkInterface.getByInetAddress(address))
|
||||
}
|
||||
|
||||
val sender = MulticastSocket(null).apply {
|
||||
reuseAddress = true
|
||||
bind(InetSocketAddress(address, MulticastPort))
|
||||
joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), NetworkInterface.getByInetAddress(address))
|
||||
}
|
||||
_senders.add(sender)
|
||||
}
|
||||
|
||||
else -> throw UnsupportedOperationException("Address type ${address.javaClass.name} is not supported.")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.i(TAG, "Exception occurred when processing added address $address: ${e.message}.")
|
||||
// Close the socket if there was an error
|
||||
(_senders.lastOrNull() as? MulticastSocket)?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nics.isNotEmpty()) {
|
||||
try {
|
||||
updateBroadcastRecords()
|
||||
broadcastRecords()
|
||||
} catch (e: Exception) {
|
||||
Logger.i(TAG, "Exception occurred when broadcasting records: ${e.message}.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNicsRemoved(nics: List<NetworkInterface>) {
|
||||
_lockObject.withLock {
|
||||
if (!_started) return
|
||||
//TODO: Cleanup?
|
||||
}
|
||||
|
||||
if (nics.isNotEmpty()) {
|
||||
try {
|
||||
updateBroadcastRecords()
|
||||
broadcastRecords()
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Exception occurred when broadcasting records", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun receiveLoop(client: DatagramSocket) {
|
||||
Logger.i(TAG, "Started receive loop")
|
||||
|
||||
val buffer = ByteArray(8972)
|
||||
val packet = DatagramPacket(buffer, buffer.size)
|
||||
while (_started) {
|
||||
try {
|
||||
client.receive(packet)
|
||||
handleResult(packet)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "An exception occurred while handling UDP result:", e)
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped receive loop")
|
||||
}
|
||||
|
||||
fun broadcastService(
|
||||
deviceName: String,
|
||||
serviceName: String,
|
||||
port: UShort,
|
||||
ttl: UInt = 120u,
|
||||
weight: UShort = 0u,
|
||||
priority: UShort = 0u,
|
||||
texts: List<String>? = null
|
||||
) {
|
||||
_recordLockObject.withLock {
|
||||
_services.add(
|
||||
BroadcastService(
|
||||
deviceName = deviceName,
|
||||
port = port,
|
||||
priority = priority,
|
||||
serviceName = serviceName,
|
||||
texts = texts,
|
||||
ttl = ttl,
|
||||
weight = weight
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
updateBroadcastRecords()
|
||||
broadcastRecords()
|
||||
}
|
||||
|
||||
private fun updateBroadcastRecords() {
|
||||
_recordLockObject.withLock {
|
||||
_recordsSRV.clear()
|
||||
_recordsPTR.clear()
|
||||
_recordsA.clear()
|
||||
_recordsAAAA.clear()
|
||||
_recordsTXT.clear()
|
||||
|
||||
_services.forEach { service ->
|
||||
val id = UUID.randomUUID().toString()
|
||||
val deviceDomainName = "${service.deviceName}.${service.serviceName}"
|
||||
val addressName = "$id.local"
|
||||
|
||||
_recordsSRV.add(
|
||||
DnsResourceRecord(
|
||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
||||
type = ResourceRecordType.SRV.value.toInt(),
|
||||
timeToLive = service.ttl,
|
||||
name = deviceDomainName,
|
||||
cacheFlush = false
|
||||
) to SRVRecord(
|
||||
target = addressName,
|
||||
port = service.port,
|
||||
priority = service.priority,
|
||||
weight = service.weight
|
||||
)
|
||||
)
|
||||
|
||||
_recordsPTR.add(
|
||||
DnsResourceRecord(
|
||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
||||
type = ResourceRecordType.PTR.value.toInt(),
|
||||
timeToLive = service.ttl,
|
||||
name = service.serviceName,
|
||||
cacheFlush = false
|
||||
) to PTRRecord(
|
||||
domainName = deviceDomainName
|
||||
)
|
||||
)
|
||||
|
||||
val addresses = _nicMonitor.current.flatMap { nic ->
|
||||
nic.interfaceAddresses.map { it.address }
|
||||
}
|
||||
|
||||
addresses.forEach { address ->
|
||||
when (address) {
|
||||
is Inet4Address -> _recordsA.add(
|
||||
DnsResourceRecord(
|
||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
||||
type = ResourceRecordType.A.value.toInt(),
|
||||
timeToLive = service.ttl,
|
||||
name = addressName,
|
||||
cacheFlush = false
|
||||
) to ARecord(
|
||||
address = address
|
||||
)
|
||||
)
|
||||
|
||||
is Inet6Address -> _recordsAAAA.add(
|
||||
DnsResourceRecord(
|
||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
||||
type = ResourceRecordType.AAAA.value.toInt(),
|
||||
timeToLive = service.ttl,
|
||||
name = addressName,
|
||||
cacheFlush = false
|
||||
) to AAAARecord(
|
||||
address = address
|
||||
)
|
||||
)
|
||||
|
||||
else -> Logger.i(TAG, "Invalid address type: $address.")
|
||||
}
|
||||
}
|
||||
|
||||
if (service.texts != null) {
|
||||
_recordsTXT.add(
|
||||
DnsResourceRecord(
|
||||
clazz = ResourceRecordClass.IN.value.toInt(),
|
||||
type = ResourceRecordType.TXT.value.toInt(),
|
||||
timeToLive = service.ttl,
|
||||
name = deviceDomainName,
|
||||
cacheFlush = false
|
||||
) to TXTRecord(
|
||||
texts = service.texts
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun broadcastRecords(questions: List<DnsQuestion>? = null) {
|
||||
val writer = DnsWriter()
|
||||
_recordLockObject.withLock {
|
||||
val recordsA: List<Pair<DnsResourceRecord, ARecord>>
|
||||
val recordsAAAA: List<Pair<DnsResourceRecord, AAAARecord>>
|
||||
val recordsPTR: List<Pair<DnsResourceRecord, PTRRecord>>
|
||||
val recordsTXT: List<Pair<DnsResourceRecord, TXTRecord>>
|
||||
val recordsSRV: List<Pair<DnsResourceRecord, SRVRecord>>
|
||||
|
||||
if (questions != null) {
|
||||
recordsA = _recordsA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
||||
recordsAAAA = _recordsAAAA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
||||
recordsPTR = _recordsPTR.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
||||
recordsSRV = _recordsSRV.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
||||
recordsTXT = _recordsTXT.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
|
||||
} else {
|
||||
recordsA = _recordsA
|
||||
recordsAAAA = _recordsAAAA
|
||||
recordsPTR = _recordsPTR
|
||||
recordsSRV = _recordsSRV
|
||||
recordsTXT = _recordsTXT
|
||||
}
|
||||
|
||||
val answerCount = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size + recordsTXT.size
|
||||
if (answerCount < 1) return
|
||||
|
||||
val txtOffset = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size
|
||||
val srvOffset = recordsA.size + recordsAAAA.size + recordsPTR.size
|
||||
val ptrOffset = recordsA.size + recordsAAAA.size
|
||||
val aaaaOffset = recordsA.size
|
||||
|
||||
writer.writePacket(
|
||||
DnsPacketHeader(
|
||||
identifier = 0u,
|
||||
queryResponse = QueryResponse.Response.value.toInt(),
|
||||
opcode = DnsOpcode.StandardQuery.value.toInt(),
|
||||
truncated = false,
|
||||
nonAuthenticatedData = false,
|
||||
recursionDesired = false,
|
||||
answerAuthenticated = false,
|
||||
authoritativeAnswer = true,
|
||||
recursionAvailable = false,
|
||||
responseCode = DnsResponseCode.NoError
|
||||
),
|
||||
answerCount = answerCount,
|
||||
answerWriter = { w, i ->
|
||||
when {
|
||||
i >= txtOffset -> {
|
||||
val record = recordsTXT[i - txtOffset]
|
||||
w.write(record.first) { it.write(record.second) }
|
||||
}
|
||||
|
||||
i >= srvOffset -> {
|
||||
val record = recordsSRV[i - srvOffset]
|
||||
w.write(record.first) { it.write(record.second) }
|
||||
}
|
||||
|
||||
i >= ptrOffset -> {
|
||||
val record = recordsPTR[i - ptrOffset]
|
||||
w.write(record.first) { it.write(record.second) }
|
||||
}
|
||||
|
||||
i >= aaaaOffset -> {
|
||||
val record = recordsAAAA[i - aaaaOffset]
|
||||
w.write(record.first) { it.write(record.second) }
|
||||
}
|
||||
|
||||
else -> {
|
||||
val record = recordsA[i]
|
||||
w.write(record.first) { it.write(record.second) }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
send(writer.toByteArray())
|
||||
}
|
||||
|
||||
private fun handleResult(result: DatagramPacket) {
|
||||
try {
|
||||
val packet = DnsPacket.parse(result.data)
|
||||
if (packet.questions.isNotEmpty()) {
|
||||
_scope?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
broadcastRecords(packet.questions)
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Broadcasting records failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
_serviceRecordAggregator.add(packet)
|
||||
onPacket?.invoke(packet)
|
||||
} catch (e: Exception) {
|
||||
Logger.v(TAG, "Failed to handle packet: ${Base64.getEncoder().encodeToString(result.data.slice(IntRange(0, result.length - 1)).toByteArray())}", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
_lockObject.withLock {
|
||||
_started = false
|
||||
|
||||
_scope?.cancel()
|
||||
_scope = null
|
||||
|
||||
_nicMonitor.stop()
|
||||
_serviceRecordAggregator.stop()
|
||||
|
||||
_receiver4?.close()
|
||||
_receiver4 = null
|
||||
|
||||
_receiver6?.close()
|
||||
_receiver6 = null
|
||||
|
||||
_senders.forEach { it.close() }
|
||||
_senders.clear()
|
||||
}
|
||||
|
||||
_threadReceiver4?.join()
|
||||
_threadReceiver4 = null
|
||||
|
||||
_threadReceiver6?.join()
|
||||
_threadReceiver6 = null
|
||||
}
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import java.net.NetworkInterface
|
||||
|
||||
class NICMonitor {
|
||||
private val lockObject = Any()
|
||||
private val nics = mutableListOf<NetworkInterface>()
|
||||
private var cts: Job? = null
|
||||
|
||||
val current: List<NetworkInterface>
|
||||
get() = synchronized(nics) { nics.toList() }
|
||||
|
||||
var added: ((List<NetworkInterface>) -> Unit)? = null
|
||||
var removed: ((List<NetworkInterface>) -> Unit)? = null
|
||||
|
||||
fun start() {
|
||||
synchronized(lockObject) {
|
||||
if (cts != null) throw Exception("Already started.")
|
||||
|
||||
cts = CoroutineScope(Dispatchers.Default).launch {
|
||||
loopAsync()
|
||||
}
|
||||
}
|
||||
|
||||
nics.clear()
|
||||
nics.addAll(getCurrentInterfaces().toList())
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
synchronized(lockObject) {
|
||||
cts?.cancel()
|
||||
cts = null
|
||||
}
|
||||
|
||||
synchronized(nics) {
|
||||
nics.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loopAsync() {
|
||||
while (cts?.isActive == true) {
|
||||
try {
|
||||
val currentNics = getCurrentInterfaces().toList()
|
||||
removed?.invoke(nics.filter { k -> currentNics.none { n -> k.name == n.name } })
|
||||
added?.invoke(currentNics.filter { nic -> nics.none { k -> k.name == nic.name } })
|
||||
|
||||
synchronized(nics) {
|
||||
nics.clear()
|
||||
nics.addAll(currentNics)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
// Ignored
|
||||
}
|
||||
delay(5000)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCurrentInterfaces(): List<NetworkInterface> {
|
||||
val nics = NetworkInterface.getNetworkInterfaces().toList()
|
||||
.filter { it.isUp && !it.isLoopback }
|
||||
|
||||
return if (nics.isNotEmpty()) nics else NetworkInterface.getNetworkInterfaces().toList()
|
||||
.filter { it.isUp }
|
||||
}
|
||||
}
|
|
@ -1,71 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import java.lang.Thread.sleep
|
||||
|
||||
class ServiceDiscoverer(names: Array<String>, private val _onServicesUpdated: (List<DnsService>) -> Unit) {
|
||||
private val _names: Array<String>
|
||||
private var _listener: MDNSListener? = null
|
||||
private var _started = false
|
||||
private var _thread: Thread? = null
|
||||
|
||||
init {
|
||||
if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.")
|
||||
_names = names
|
||||
}
|
||||
|
||||
fun broadcastService(
|
||||
deviceName: String,
|
||||
serviceName: String,
|
||||
port: UShort,
|
||||
ttl: UInt = 120u,
|
||||
weight: UShort = 0u,
|
||||
priority: UShort = 0u,
|
||||
texts: List<String>? = null
|
||||
) {
|
||||
_listener?.let {
|
||||
it.broadcastService(deviceName, serviceName, port, ttl, weight, priority, texts)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
_started = false
|
||||
_listener?.stop()
|
||||
_listener = null
|
||||
_thread?.join()
|
||||
_thread = null
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (_started) {
|
||||
Logger.i(TAG, "Already started.")
|
||||
return
|
||||
}
|
||||
_started = true
|
||||
|
||||
val listener = MDNSListener()
|
||||
_listener = listener
|
||||
listener.onServicesUpdated = { _onServicesUpdated?.invoke(it) }
|
||||
listener.start()
|
||||
|
||||
_thread = Thread {
|
||||
try {
|
||||
sleep(2000)
|
||||
|
||||
while (_started) {
|
||||
listener.queryServices(_names)
|
||||
sleep(2000)
|
||||
listener.queryAllQuestions(_names)
|
||||
sleep(2000)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Exception in loop thread", e)
|
||||
stop()
|
||||
}
|
||||
}.apply { start() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "ServiceDiscoverer"
|
||||
}
|
||||
}
|
|
@ -1,226 +0,0 @@
|
|||
package com.futo.platformplayer.mdns
|
||||
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.InetAddress
|
||||
import java.util.Date
|
||||
|
||||
data class DnsService(
|
||||
var name: String,
|
||||
var target: String,
|
||||
var port: UShort,
|
||||
val addresses: MutableList<InetAddress> = mutableListOf(),
|
||||
val pointers: MutableList<String> = mutableListOf(),
|
||||
val texts: MutableList<String> = mutableListOf()
|
||||
)
|
||||
|
||||
data class CachedDnsAddressRecord(
|
||||
val expirationTime: Date,
|
||||
val address: InetAddress
|
||||
)
|
||||
|
||||
data class CachedDnsTxtRecord(
|
||||
val expirationTime: Date,
|
||||
val texts: List<String>
|
||||
)
|
||||
|
||||
data class CachedDnsPtrRecord(
|
||||
val expirationTime: Date,
|
||||
val target: String
|
||||
)
|
||||
|
||||
data class CachedDnsSrvRecord(
|
||||
val expirationTime: Date,
|
||||
val service: SRVRecord
|
||||
)
|
||||
|
||||
class ServiceRecordAggregator {
|
||||
private val _lockObject = Any()
|
||||
private val _cachedAddressRecords = mutableMapOf<String, MutableList<CachedDnsAddressRecord>>()
|
||||
private val _cachedTxtRecords = mutableMapOf<String, CachedDnsTxtRecord>()
|
||||
private val _cachedPtrRecords = mutableMapOf<String, MutableList<CachedDnsPtrRecord>>()
|
||||
private val _cachedSrvRecords = mutableMapOf<String, CachedDnsSrvRecord>()
|
||||
private val _currentServices = mutableListOf<DnsService>()
|
||||
private var _cts: Job? = null
|
||||
|
||||
var onServicesUpdated: ((List<DnsService>) -> Unit)? = null
|
||||
|
||||
fun start() {
|
||||
synchronized(_lockObject) {
|
||||
if (_cts != null) throw Exception("Already started.")
|
||||
|
||||
_cts = CoroutineScope(Dispatchers.Default).launch {
|
||||
try {
|
||||
while (isActive) {
|
||||
val now = Date()
|
||||
synchronized(_currentServices) {
|
||||
_cachedAddressRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } }
|
||||
_cachedTxtRecords.entries.removeIf { now.after(it.value.expirationTime) }
|
||||
_cachedSrvRecords.entries.removeIf { now.after(it.value.expirationTime) }
|
||||
_cachedPtrRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } }
|
||||
|
||||
val newServices = getCurrentServices()
|
||||
_currentServices.clear()
|
||||
_currentServices.addAll(newServices)
|
||||
}
|
||||
|
||||
onServicesUpdated?.invoke(_currentServices.toList())
|
||||
delay(5000)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unexpected failure in MDNS loop", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
synchronized(_lockObject) {
|
||||
_cts?.cancel()
|
||||
_cts = null
|
||||
}
|
||||
}
|
||||
|
||||
fun add(packet: DnsPacket) {
|
||||
val currentServices: List<DnsService>
|
||||
val dnsResourceRecords = packet.answers + packet.additionals + packet.authorities
|
||||
val txtRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.TXT.value.toInt() }.map { it to it.getDataReader().readTXTRecord() }
|
||||
val aRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.A.value.toInt() }.map { it to it.getDataReader().readARecord() }
|
||||
val aaaaRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.AAAA.value.toInt() }.map { it to it.getDataReader().readAAAARecord() }
|
||||
val srvRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.SRV.value.toInt() }.map { it to it.getDataReader().readSRVRecord() }
|
||||
val ptrRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.PTR.value.toInt() }.map { it to it.getDataReader().readPTRRecord() }
|
||||
|
||||
/*val builder = StringBuilder()
|
||||
builder.appendLine("Received records:")
|
||||
srvRecords.forEach { builder.appendLine("SRV ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: (Port: ${it.second.port}, Target: ${it.second.target}, Priority: ${it.second.priority}, Weight: ${it.second.weight})") }
|
||||
ptrRecords.forEach { builder.appendLine("PTR ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.domainName}") }
|
||||
txtRecords.forEach { builder.appendLine("TXT ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.texts.joinToString(", ")}") }
|
||||
aRecords.forEach { builder.appendLine("A ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") }
|
||||
aaaaRecords.forEach { builder.appendLine("AAAA ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") }
|
||||
Logger.i(TAG, "$builder")*/
|
||||
|
||||
synchronized(this._currentServices) {
|
||||
ptrRecords.forEach { record ->
|
||||
val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() }
|
||||
val newPtrRecord = CachedDnsPtrRecord(Date(System.currentTimeMillis() + record.first.timeToLive.toLong() * 1000L), record.second.domainName)
|
||||
cachedPtrRecord.replaceOrAdd(newPtrRecord) { it.target == record.second.domainName }
|
||||
}
|
||||
|
||||
aRecords.forEach { aRecord ->
|
||||
val cachedARecord = _cachedAddressRecords.getOrPut(aRecord.first.name) { mutableListOf() }
|
||||
val newARecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aRecord.first.timeToLive.toLong() * 1000L), aRecord.second.address)
|
||||
cachedARecord.replaceOrAdd(newARecord) { it.address == newARecord.address }
|
||||
}
|
||||
|
||||
aaaaRecords.forEach { aaaaRecord ->
|
||||
val cachedAaaaRecord = _cachedAddressRecords.getOrPut(aaaaRecord.first.name) { mutableListOf() }
|
||||
val newAaaaRecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aaaaRecord.first.timeToLive.toLong() * 1000L), aaaaRecord.second.address)
|
||||
cachedAaaaRecord.replaceOrAdd(newAaaaRecord) { it.address == newAaaaRecord.address }
|
||||
}
|
||||
|
||||
txtRecords.forEach { txtRecord ->
|
||||
_cachedTxtRecords[txtRecord.first.name] = CachedDnsTxtRecord(Date(System.currentTimeMillis() + txtRecord.first.timeToLive.toLong() * 1000L), txtRecord.second.texts)
|
||||
}
|
||||
|
||||
srvRecords.forEach { srvRecord ->
|
||||
_cachedSrvRecords[srvRecord.first.name] = CachedDnsSrvRecord(Date(System.currentTimeMillis() + srvRecord.first.timeToLive.toLong() * 1000L), srvRecord.second)
|
||||
}
|
||||
|
||||
currentServices = getCurrentServices()
|
||||
this._currentServices.clear()
|
||||
this._currentServices.addAll(currentServices)
|
||||
}
|
||||
|
||||
onServicesUpdated?.invoke(currentServices)
|
||||
}
|
||||
|
||||
fun getAllQuestions(serviceName: String): List<DnsQuestion> {
|
||||
val questions = mutableListOf<DnsQuestion>()
|
||||
synchronized(_currentServices) {
|
||||
val servicePtrRecords = _cachedPtrRecords[serviceName] ?: return emptyList()
|
||||
|
||||
val ptrWithoutSrvRecord = servicePtrRecords.filterNot { _cachedSrvRecords.containsKey(it.target) }.map { it.target }
|
||||
questions.addAll(ptrWithoutSrvRecord.flatMap { s ->
|
||||
listOf(
|
||||
DnsQuestion(
|
||||
name = s,
|
||||
type = QuestionType.SRV.value.toInt(),
|
||||
clazz = QuestionClass.IN.value.toInt(),
|
||||
queryUnicast = false
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
val incompleteCurrentServices = _currentServices.filter { it.addresses.isEmpty() && it.name.endsWith(serviceName) }
|
||||
questions.addAll(incompleteCurrentServices.flatMap { s ->
|
||||
listOf(
|
||||
DnsQuestion(
|
||||
name = s.name,
|
||||
type = QuestionType.TXT.value.toInt(),
|
||||
clazz = QuestionClass.IN.value.toInt(),
|
||||
queryUnicast = false
|
||||
),
|
||||
DnsQuestion(
|
||||
name = s.target,
|
||||
type = QuestionType.A.value.toInt(),
|
||||
clazz = QuestionClass.IN.value.toInt(),
|
||||
queryUnicast = false
|
||||
),
|
||||
DnsQuestion(
|
||||
name = s.target,
|
||||
type = QuestionType.AAAA.value.toInt(),
|
||||
clazz = QuestionClass.IN.value.toInt(),
|
||||
queryUnicast = false
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
return questions
|
||||
}
|
||||
|
||||
private fun getCurrentServices(): MutableList<DnsService> {
|
||||
val currentServices = _cachedSrvRecords.map { (key, value) ->
|
||||
DnsService(
|
||||
name = key,
|
||||
target = value.service.target,
|
||||
port = value.service.port
|
||||
)
|
||||
}.toMutableList()
|
||||
|
||||
currentServices.forEach { service ->
|
||||
_cachedAddressRecords[service.target]?.let {
|
||||
service.addresses.addAll(it.map { record -> record.address })
|
||||
}
|
||||
}
|
||||
|
||||
currentServices.forEach { service ->
|
||||
service.pointers.addAll(_cachedPtrRecords.filter { it.value.any { ptr -> ptr.target == service.name } }.map { it.key })
|
||||
}
|
||||
|
||||
currentServices.forEach { service ->
|
||||
_cachedTxtRecords[service.name]?.let {
|
||||
service.texts.addAll(it.texts)
|
||||
}
|
||||
}
|
||||
|
||||
return currentServices
|
||||
}
|
||||
|
||||
private inline fun <T> MutableList<T>.replaceOrAdd(newElement: T, predicate: (T) -> Boolean) {
|
||||
val index = indexOfFirst(predicate)
|
||||
if (index >= 0) {
|
||||
this[index] = newElement
|
||||
} else {
|
||||
add(newElement)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "ServiceRecordAggregator"
|
||||
}
|
||||
}
|
|
@ -29,7 +29,7 @@ data class ImageVariable(
|
|||
Glide.with(imageView)
|
||||
.load(bitmap)
|
||||
.into(imageView)
|
||||
} else if(resId != null) {
|
||||
} else if(resId != null && resId > 0) {
|
||||
Glide.with(imageView)
|
||||
.load(resId)
|
||||
.into(imageView)
|
||||
|
|
|
@ -113,7 +113,7 @@ class LoginWebViewClient : WebViewClient {
|
|||
//val domainParts = domain!!.split(".");
|
||||
//val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||
val cookieDomain = domain!!.getSubdomainWildcardQuery();
|
||||
if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) })
|
||||
if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || domain.matchesDomain(it) })
|
||||
_authConfig.cookiesToFind?.let { cookiesToFind ->
|
||||
val cookies = cookieString.split(";");
|
||||
for(cookieStr in cookies) {
|
||||
|
|
|
@ -67,7 +67,7 @@ class WebViewRequirementExtractor {
|
|||
if(cookieString != null) {
|
||||
//val domainParts = domain!!.split(".");
|
||||
val cookieDomain = domain!!.getSubdomainWildcardQuery()//"." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||
if(allowedUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) })
|
||||
if(allowedUrls.any { it == "everywhere" || domain.matchesDomain(it) })
|
||||
cookiesToFind?.let { cookiesToFind ->
|
||||
val cookies = cookieString.split(";");
|
||||
for(cookieStr in cookies) {
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
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.HLSVariantSubtitleUrlSource
|
||||
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.toYesNo
|
||||
import com.futo.platformplayer.yesNoToBoolean
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.net.URI
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.text.ifEmpty
|
||||
|
||||
class HLS {
|
||||
companion object {
|
||||
@OptIn(UnstableApi::class)
|
||||
fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist {
|
||||
val baseUrl = URI(sourceUrl).resolve("./").toString()
|
||||
|
||||
|
@ -49,6 +58,31 @@ class HLS {
|
|||
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 {
|
||||
val lines = content.lines()
|
||||
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 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>()
|
||||
if (initSegment != null) {
|
||||
segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
|
||||
}
|
||||
|
||||
var currentSegment: MediaSegment? = null
|
||||
lines.forEach { line ->
|
||||
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> {
|
||||
|
@ -270,7 +322,7 @@ class HLS {
|
|||
val name: String?,
|
||||
val isDefault: Boolean?,
|
||||
val isAutoSelect: Boolean?,
|
||||
val isForced: Boolean?
|
||||
val isForced: Boolean?,
|
||||
) {
|
||||
fun toM3U8Line(): String = buildString {
|
||||
append("#EXT-X-MEDIA:")
|
||||
|
@ -319,30 +371,13 @@ class HLS {
|
|||
|
||||
fun getVideoSources(): List<HLSVariantVideoUrlSource> {
|
||||
return variantPlaylistsRefs.map {
|
||||
var width: Int? = null
|
||||
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)
|
||||
variantReferenceToVariant(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAudioSources(): List<HLSVariantAudioUrlSource> {
|
||||
return mediaRenditions.mapNotNull {
|
||||
if (it.uri == null) {
|
||||
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
|
||||
}
|
||||
return@mapNotNull mediaRenditionToVariant(it)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -368,6 +403,11 @@ class HLS {
|
|||
}
|
||||
}
|
||||
|
||||
data class DecryptionInfo(
|
||||
val keyUrl: String,
|
||||
val iv: String?
|
||||
)
|
||||
|
||||
data class VariantPlaylist(
|
||||
val version: Int?,
|
||||
val targetDuration: Int?,
|
||||
|
@ -376,7 +416,8 @@ class HLS {
|
|||
val programDateTime: ZonedDateTime?,
|
||||
val playlistType: String?,
|
||||
val streamInfo: StreamInfo?,
|
||||
val segments: List<Segment>
|
||||
val segments: List<Segment>,
|
||||
val decryptionInfo: DecryptionInfo? = null
|
||||
) {
|
||||
fun buildM3U8(): String = buildString {
|
||||
append("#EXTM3U\n")
|
||||
|
|
|
@ -21,6 +21,7 @@ class MediaControlReceiver : BroadcastReceiver() {
|
|||
EVENT_NEXT -> onNextReceived.emit();
|
||||
EVENT_PREV -> onPreviousReceived.emit();
|
||||
EVENT_CLOSE -> onCloseReceived.emit();
|
||||
EVENT_BACKGROUND -> onBackgroundReceived.emit();
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
|
@ -38,6 +39,7 @@ class MediaControlReceiver : BroadcastReceiver() {
|
|||
const val EVENT_NEXT = "Next";
|
||||
const val EVENT_PREV = "Prev";
|
||||
const val EVENT_CLOSE = "Close";
|
||||
const val EVENT_BACKGROUND = "Background";
|
||||
|
||||
val onPlayReceived = Event0();
|
||||
val onPauseReceived = Event0();
|
||||
|
@ -48,6 +50,7 @@ class MediaControlReceiver : BroadcastReceiver() {
|
|||
val onLowerVolumeReceived = Event0();
|
||||
|
||||
val onCloseReceived = Event0()
|
||||
val onBackgroundReceived = Event0()
|
||||
|
||||
fun getPlayIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply {
|
||||
this.putExtra(EXTRA_MEDIA_ACTION, EVENT_PLAY);
|
||||
|
@ -64,5 +67,8 @@ class MediaControlReceiver : BroadcastReceiver() {
|
|||
fun getCloseIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply {
|
||||
this.putExtra(EXTRA_MEDIA_ACTION, EVENT_CLOSE);
|
||||
},PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
fun getToBackgroundIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply {
|
||||
this.putExtra(EXTRA_MEDIA_ACTION, EVENT_BACKGROUND);
|
||||
},PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package com.futo.platformplayer.serializers
|
||||
|
||||
import com.futo.platformplayer.sToOffsetDateTimeUTC
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
|
@ -37,7 +38,7 @@ class OffsetDateTimeSerializer : KSerializer<OffsetDateTime> {
|
|||
return OffsetDateTime.MAX;
|
||||
else if(epochSecond < -9999999999)
|
||||
return OffsetDateTime.MIN;
|
||||
return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
||||
return epochSecond.sToOffsetDateTimeUTC()
|
||||
}
|
||||
}
|
||||
class OffsetDateTimeStringSerializer : KSerializer<OffsetDateTime> {
|
||||
|
|
|
@ -29,6 +29,7 @@ import com.futo.platformplayer.activities.CaptchaActivity
|
|||
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity.Companion.settingsActivityClosed
|
||||
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.background.BackgroundWorker
|
||||
|
@ -411,7 +412,15 @@ class StateApp {
|
|||
}
|
||||
|
||||
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 {
|
||||
|
@ -509,22 +518,33 @@ class StateApp {
|
|||
|
||||
//Migration
|
||||
Logger.i(TAG, "MainApp Started: Check [Migrations]");
|
||||
migrateStores(context, listOf(
|
||||
StateSubscriptions.instance.toMigrateCheck(),
|
||||
StatePlaylists.instance.toMigrateCheck()
|
||||
).flatten(), 0);
|
||||
|
||||
scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
migrateStores(context, listOf(
|
||||
StateSubscriptions.instance.toMigrateCheck(),
|
||||
StatePlaylists.instance.toMigrateCheck()
|
||||
).flatten(), 0)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to migrate stores")
|
||||
}
|
||||
}
|
||||
|
||||
if(Settings.instance.subscriptions.fetchOnAppBoot) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Logger.i(TAG, "MainApp Started: Fetch [Subscriptions]");
|
||||
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
|
||||
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
|
||||
val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true };
|
||||
if (isRateLimitReached) {
|
||||
val isBelowRateLimit = !subRequestCounts.any { clientCount ->
|
||||
clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true
|
||||
};
|
||||
if (isBelowRateLimit) {
|
||||
Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}");
|
||||
delay(5000);
|
||||
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
|
||||
StateSubscriptions.instance.updateSubscriptionFeed(scope, false);
|
||||
scopeOrNull?.let {
|
||||
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
|
||||
StateSubscriptions.instance.updateSubscriptionFeed(it, false);
|
||||
}
|
||||
}
|
||||
else
|
||||
Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}");
|
||||
|
@ -675,15 +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)
|
||||
return;
|
||||
val store = managedStores[index];
|
||||
if(store.hasMissingReconstructions())
|
||||
UIDialogs.showMigrateDialog(context, store) {
|
||||
migrateStores(context, managedStores, index + 1);
|
||||
};
|
||||
else
|
||||
if(store.hasMissingReconstructions()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
try {
|
||||
UIDialogs.showMigrateDialog(context, store) {
|
||||
scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
migrateStores(context, managedStores, index + 1);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to migrate store", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to migrate stores", e)
|
||||
}
|
||||
}
|
||||
} else
|
||||
migrateStores(context, managedStores, index + 1);
|
||||
}
|
||||
|
||||
|
@ -703,6 +735,7 @@ class StateApp {
|
|||
|
||||
StatePlayer.instance.closeMediaSession();
|
||||
StateCasting.instance.stop();
|
||||
StateSync.instance.stop();
|
||||
StatePlayer.dispose();
|
||||
Companion.dispose();
|
||||
_fileLogConsumer?.close();
|
||||
|
|
|
@ -383,7 +383,7 @@ class StateDownloads {
|
|||
}
|
||||
private fun validateDownload(videoState: VideoDownload) {
|
||||
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);
|
||||
if(existing != null) {
|
||||
|
|
|
@ -5,7 +5,6 @@ import androidx.collection.LruCache
|
|||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
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.IPluginSourced
|
||||
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.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringArrayStorage
|
||||
import com.futo.platformplayer.stores.StringStorage
|
||||
import com.futo.platformplayer.views.ToastView
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
|
@ -56,7 +54,6 @@ import kotlinx.coroutines.cancel
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.internal.concat
|
||||
import java.lang.Thread.sleep
|
||||
import java.time.OffsetDateTime
|
||||
import kotlin.streams.asSequence
|
||||
|
@ -94,9 +91,11 @@ class StatePlatform {
|
|||
private val _trackerClientPool = PlatformMultiClientPool("Trackers", 1); //Used exclusively for playback trackers
|
||||
private val _liveEventClientPool = PlatformMultiClientPool("LiveEvents", 1); //Used exclusively for live events
|
||||
private val _privateClientPool = PlatformMultiClientPool("Private", 2, true); //Used primarily for calls if in incognito mode
|
||||
private val _instantClientPool = PlatformMultiClientPool("Instant", 1, false, true); //Used for all instant calls
|
||||
|
||||
|
||||
private val _icons : HashMap<String, ImageVariable> = HashMap();
|
||||
private val _iconsByName : HashMap<String, ImageVariable> = HashMap();
|
||||
|
||||
val hasClients: Boolean get() = _availableClients.size > 0;
|
||||
|
||||
|
@ -113,14 +112,14 @@ class StatePlatform {
|
|||
|
||||
Logger.i(StatePlatform::class.java.name, "Fetching video details [${url}]");
|
||||
if(!StateApp.instance.privateMode) {
|
||||
_enabledClients.find { it.isContentDetailsUrl(url) }?.let {
|
||||
_enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let {
|
||||
_mainClientPool.getClientPooled(it).getContentDetails(url)
|
||||
}
|
||||
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
|
||||
}
|
||||
else {
|
||||
Logger.i(TAG, "Fetching details with private client");
|
||||
_enabledClients.find { it.isContentDetailsUrl(url) }?.let {
|
||||
_enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let {
|
||||
_privateClientPool.getClientPooled(it).getContentDetails(url)
|
||||
}
|
||||
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
|
||||
|
@ -192,6 +191,7 @@ class StatePlatform {
|
|||
_availableClients.clear();
|
||||
|
||||
_icons.clear();
|
||||
_iconsByName.clear()
|
||||
_icons[StateDeveloper.DEV_ID] = ImageVariable(null, R.drawable.ic_security_red);
|
||||
|
||||
StatePlugins.instance.updateEmbeddedPlugins(context);
|
||||
|
@ -200,6 +200,8 @@ class StatePlatform {
|
|||
for (plugin in StatePlugins.instance.getPlugins()) {
|
||||
_icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?:
|
||||
ImageVariable(plugin.config.absoluteIconUrl, null);
|
||||
_iconsByName[plugin.config.name.lowercase()] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?:
|
||||
ImageVariable(plugin.config.absoluteIconUrl, null);
|
||||
|
||||
val client = JSClient(context, plugin);
|
||||
client.onCaptchaException.subscribe { c, ex ->
|
||||
|
@ -299,6 +301,15 @@ class StatePlatform {
|
|||
return null;
|
||||
}
|
||||
|
||||
fun getPlatformIconByName(name: String?) : ImageVariable? {
|
||||
if(name == null)
|
||||
return null;
|
||||
val nameLower = name.lowercase()
|
||||
if(_iconsByName.containsKey(nameLower))
|
||||
return _iconsByName[nameLower];
|
||||
return null;
|
||||
}
|
||||
|
||||
fun setPlatformOrder(platformOrder: List<String>) {
|
||||
_platformOrderPersistent.values.clear();
|
||||
_platformOrderPersistent.values.addAll(platformOrder);
|
||||
|
@ -673,12 +684,33 @@ class StatePlatform {
|
|||
return pager;
|
||||
}
|
||||
|
||||
fun searchChannelsAsContent(query: String): IPager<IPlatformContent> {
|
||||
Logger.i(TAG, "Platform - searchChannels");
|
||||
val pagers = mutableMapOf<IPager<IPlatformContent>, Float>();
|
||||
getSortedEnabledClient().parallelStream().forEach {
|
||||
try {
|
||||
if (it.capabilities.hasChannelSearch)
|
||||
pagers.put(it.searchChannelsAsContent(query), 1f);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed search channels", ex)
|
||||
UIDialogs.toast("Failed search channels on [${it.name}]\n(${ex.message})");
|
||||
}
|
||||
};
|
||||
if(pagers.isEmpty())
|
||||
return EmptyPager<IPlatformContent>();
|
||||
|
||||
val pager = MultiDistributionContentPager(pagers);
|
||||
pager.initialize();
|
||||
return pager;
|
||||
}
|
||||
|
||||
|
||||
//Video
|
||||
fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { it.isContentDetailsUrl(url) };
|
||||
fun hasEnabledContentClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) };
|
||||
fun getContentClient(url: String) : IPlatformClient = getContentClientOrNull(url)
|
||||
?: throw NoPlatformClientException("No client enabled that supports this content url (${url})");
|
||||
fun getContentClientOrNull(url: String) : IPlatformClient? = getEnabledClients().find { it.isContentDetailsUrl(url) };
|
||||
fun getContentClientOrNull(url: String) : IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) };
|
||||
fun getContentDetails(url: String, forceRefetch: Boolean = false): Deferred<IPlatformContentDetails> {
|
||||
Logger.i(TAG, "Platform - getContentDetails (${url})");
|
||||
if(forceRefetch)
|
||||
|
@ -719,14 +751,14 @@ class StatePlatform {
|
|||
return client.getContentRecommendations(url);
|
||||
}
|
||||
|
||||
fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { it.isChannelUrl(url) };
|
||||
fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isChannelUrl(url) };
|
||||
fun getChannelClient(url : String, exclude: List<String>? = null) : IPlatformClient = getChannelClientOrNull(url, exclude)
|
||||
?: throw NoPlatformClientException("No client enabled that supports this channel url (${url})");
|
||||
fun getChannelClientOrNull(url : String, exclude: List<String>? = null) : IPlatformClient? =
|
||||
if(exclude == null)
|
||||
getEnabledClients().find { it.isChannelUrl(url) }
|
||||
getEnabledClients().find { _instantClientPool.getClientPooled(it).isChannelUrl(url) }
|
||||
else
|
||||
getEnabledClients().find { !exclude.contains(it.id) && it.isChannelUrl(url) };
|
||||
getEnabledClients().find { !exclude.contains(it.id) && _instantClientPool.getClientPooled(it).isChannelUrl(url) };
|
||||
|
||||
fun getChannel(url: String, updateSubscriptions: Boolean = true): Deferred<IPlatformChannel> {
|
||||
Logger.i(TAG, "Platform - getChannel");
|
||||
|
@ -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 client = if(usePooledClients > 1)
|
||||
_channelClientPool.getClientPooled(baseClient, usePooledClients);
|
||||
|
@ -747,66 +779,75 @@ class StatePlatform {
|
|||
var lastStream: OffsetDateTime? = null;
|
||||
|
||||
val pagerResult: IPager<IPlatformContent>;
|
||||
if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) &&
|
||||
( clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) ||
|
||||
clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS) ||
|
||||
clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE) ||
|
||||
clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS)
|
||||
)) {
|
||||
val toQuery = mutableListOf<String>();
|
||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||
toQuery.add(ResultCapabilities.TYPE_VIDEOS);
|
||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS))
|
||||
toQuery.add(ResultCapabilities.TYPE_STREAMS);
|
||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE))
|
||||
toQuery.add(ResultCapabilities.TYPE_LIVE);
|
||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS))
|
||||
toQuery.add(ResultCapabilities.TYPE_POSTS);
|
||||
if (type == null) {
|
||||
if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) &&
|
||||
( clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) ||
|
||||
clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS) ||
|
||||
clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE) ||
|
||||
clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS)
|
||||
)) {
|
||||
val toQuery = mutableListOf<String>();
|
||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||
toQuery.add(ResultCapabilities.TYPE_VIDEOS);
|
||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS))
|
||||
toQuery.add(ResultCapabilities.TYPE_STREAMS);
|
||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE))
|
||||
toQuery.add(ResultCapabilities.TYPE_LIVE);
|
||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS))
|
||||
toQuery.add(ResultCapabilities.TYPE_POSTS);
|
||||
|
||||
if(isSubscriptionOptimized) {
|
||||
val sub = StateSubscriptions.instance.getSubscription(channelUrl);
|
||||
if(sub != null) {
|
||||
if(!sub.shouldFetchStreams()) {
|
||||
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);
|
||||
}
|
||||
if(!sub.shouldFetchLiveStreams()) {
|
||||
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
|
||||
toQuery.remove(ResultCapabilities.TYPE_STREAMS);
|
||||
}
|
||||
if(!sub.shouldFetchPosts()) {
|
||||
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 5 days, skipping posts [${sub.lastPost.getNowDiffDays()} days ago]");
|
||||
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;
|
||||
if(isSubscriptionOptimized) {
|
||||
val sub = StateSubscriptions.instance.getSubscription(channelUrl);
|
||||
if(sub != null) {
|
||||
if(!sub.shouldFetchStreams()) {
|
||||
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);
|
||||
}
|
||||
if(!sub.shouldFetchLiveStreams()) {
|
||||
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
|
||||
toQuery.remove(ResultCapabilities.TYPE_STREAMS);
|
||||
}
|
||||
if(!sub.shouldFetchPosts()) {
|
||||
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 5 days, skipping posts [${sub.lastPost.getNowDiffDays()} days ago]");
|
||||
toQuery.remove(ResultCapabilities.TYPE_POSTS);
|
||||
}
|
||||
}
|
||||
return@map results;
|
||||
}
|
||||
.asSequence()
|
||||
.toList();
|
||||
|
||||
val pager = MultiChronoContentPager(pagers.toTypedArray());
|
||||
pager.initialize();
|
||||
pagerResult = pager;
|
||||
//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());
|
||||
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
|
||||
val sub = StateSubscriptions.instance.getSubscription(channelUrl);
|
||||
|
@ -858,10 +899,10 @@ class StatePlatform {
|
|||
|
||||
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");
|
||||
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> {
|
||||
val client = getChannelClient(channelUrl);
|
||||
|
@ -913,9 +954,9 @@ class StatePlatform {
|
|||
return urls;
|
||||
}
|
||||
|
||||
fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { it.isPlaylistUrl(url) };
|
||||
fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { it.isPlaylistUrl(url) }
|
||||
fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { it.isPlaylistUrl(url) }
|
||||
fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) };
|
||||
fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) }
|
||||
fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) }
|
||||
?: throw NoPlatformClientException("No client enabled that supports this playlist url (${url})");
|
||||
fun getPlaylist(url: String): IPlatformPlaylistDetails {
|
||||
return getPlaylistClient(url).getPlaylist(url);
|
||||
|
|
|
@ -599,7 +599,7 @@ class StatePlayer {
|
|||
}
|
||||
|
||||
if(_queuePosition < _queue.size) {
|
||||
return _queue[_queuePosition];
|
||||
return getCurrentQueueItem();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
|
|
@ -19,6 +19,7 @@ import com.futo.platformplayer.exceptions.ReconstructionException
|
|||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImportCache
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.sToOffsetDateTimeUTC
|
||||
import com.futo.platformplayer.smartMerge
|
||||
import com.futo.platformplayer.states.StateSubscriptionGroups.Companion
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
|
@ -85,7 +86,7 @@ class StatePlaylists {
|
|||
if(value.isEmpty())
|
||||
return OffsetDateTime.MIN;
|
||||
val tryParse = value.toLongOrNull() ?: 0;
|
||||
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(tryParse), ZoneOffset.UTC);
|
||||
return tryParse.sToOffsetDateTimeUTC();
|
||||
}
|
||||
private fun setWatchLaterReorderTime() {
|
||||
val now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
|
@ -400,12 +401,15 @@ class StatePlaylists {
|
|||
companion object {
|
||||
val TAG = "StatePlaylists";
|
||||
private var _instance : StatePlaylists? = null;
|
||||
private var _lockObject = Object()
|
||||
val instance : StatePlaylists
|
||||
get(){
|
||||
if(_instance == null)
|
||||
_instance = StatePlaylists();
|
||||
return _instance!!;
|
||||
};
|
||||
get() {
|
||||
synchronized(_lockObject) {
|
||||
if (_instance == null)
|
||||
_instance = StatePlaylists();
|
||||
return _instance!!;
|
||||
}
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
_instance?.let {
|
||||
|
|
|
@ -236,7 +236,7 @@ class StatePolycentric {
|
|||
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()
|
||||
|
||||
//TODO: Currently abusing subscription concurrency for parallelism
|
||||
|
@ -248,7 +248,11 @@ class StatePolycentric {
|
|||
|
||||
return@mapNotNull Pair(client, scope.async(Dispatchers.IO) {
|
||||
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) {
|
||||
Logger.e(TAG, "getChannelContent", ex);
|
||||
return@async null;
|
||||
|
|
|
@ -1,232 +1,187 @@
|
|||
package com.futo.platformplayer.states
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.LittleEndianDataInputStream
|
||||
import com.futo.platformplayer.LittleEndianDataOutputStream
|
||||
import android.content.Context
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SyncShowPairingCodeActivity
|
||||
import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.encryption.GEncryptionProvider
|
||||
import com.futo.platformplayer.getConnectedSocket
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.mdns.DnsService
|
||||
import com.futo.platformplayer.mdns.ServiceDiscoverer
|
||||
import com.futo.platformplayer.noise.protocol.DHState
|
||||
import com.futo.platformplayer.noise.protocol.Noise
|
||||
import com.futo.platformplayer.models.HistoryVideo
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.sToOffsetDateTimeUTC
|
||||
import com.futo.platformplayer.smartMerge
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringStringMapStorage
|
||||
import com.futo.platformplayer.stores.StringArrayStorage
|
||||
import com.futo.platformplayer.stores.StringStorage
|
||||
import com.futo.platformplayer.stores.StringStringMapStorage
|
||||
import com.futo.platformplayer.stores.StringTMapStorage
|
||||
import com.futo.platformplayer.sync.SyncSessionData
|
||||
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.SyncService
|
||||
import com.futo.platformplayer.sync.internal.SyncServiceSettings
|
||||
import com.futo.platformplayer.sync.internal.SyncSession
|
||||
import com.futo.platformplayer.sync.internal.SyncSocketSession
|
||||
import com.futo.polycentric.core.base64ToByteArray
|
||||
import com.futo.polycentric.core.toBase64
|
||||
import com.futo.platformplayer.sync.models.SendToDevicePackage
|
||||
import com.futo.platformplayer.sync.models.SyncPlaylistsPackage
|
||||
import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
|
||||
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
|
||||
import com.futo.platformplayer.sync.models.SyncWatchLaterPackage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.time.OffsetDateTime
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
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 var _serverSocket: ServerSocket? = null
|
||||
private var _thread: Thread? = null
|
||||
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
|
||||
var syncService: SyncService? = null
|
||||
private set
|
||||
val deviceRemoved: Event1<String> = Event1()
|
||||
val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2()
|
||||
|
||||
fun hasAuthorizedDevice(): Boolean {
|
||||
synchronized(_sessions) {
|
||||
return _sessions.any{ it.value.connected && it.value.isAuthorized };
|
||||
}
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (_started) {
|
||||
fun start(context: Context) {
|
||||
if (syncService != null) {
|
||||
Logger.i(TAG, "Already started.")
|
||||
return
|
||||
}
|
||||
_started = true
|
||||
|
||||
if (Settings.instance.synchronization.broadcast || Settings.instance.synchronization.connectDiscovered) {
|
||||
_serviceDiscoverer.start()
|
||||
}
|
||||
|
||||
try {
|
||||
val syncKeyPair = Json.decodeFromString<SyncKeyPair>(GEncryptionProvider.instance.decrypt(_syncKeyPair.value))
|
||||
val p = Noise.createDH(dh)
|
||||
p.setPublicKey(syncKeyPair.publicKey.base64ToByteArray(), 0)
|
||||
p.setPrivateKey(syncKeyPair.privateKey.base64ToByteArray(), 0)
|
||||
keyPair = p
|
||||
} catch (e: Throwable) {
|
||||
//Sync key pair non-existing, invalid or lost
|
||||
val p = Noise.createDH(dh)
|
||||
p.generateKeyPair()
|
||||
|
||||
val publicKey = ByteArray(p.publicKeyLength)
|
||||
p.getPublicKey(publicKey, 0)
|
||||
val privateKey = ByteArray(p.privateKeyLength)
|
||||
p.getPrivateKey(privateKey, 0)
|
||||
|
||||
val syncKeyPair = SyncKeyPair(1, publicKey.toBase64(), privateKey.toBase64())
|
||||
_syncKeyPair.setAndSave(GEncryptionProvider.instance.encrypt(Json.encodeToString(syncKeyPair)))
|
||||
|
||||
Logger.e(TAG, "Failed to load existing key pair", e)
|
||||
keyPair = p
|
||||
}
|
||||
|
||||
publicKey = keyPair?.let {
|
||||
val pkey = ByteArray(it.publicKeyLength)
|
||||
it.getPublicKey(pkey, 0)
|
||||
return@let pkey.toBase64()
|
||||
}
|
||||
|
||||
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()
|
||||
syncService = SyncService(
|
||||
SERVICE_NAME,
|
||||
RELAY_SERVER,
|
||||
RELAY_PUBLIC_KEY,
|
||||
APP_ID,
|
||||
StoreBasedSyncDatabaseProvider(),
|
||||
SyncServiceSettings(
|
||||
mdnsBroadcast = Settings.instance.synchronization.broadcast,
|
||||
mdnsConnectDiscovered = Settings.instance.synchronization.connectDiscovered,
|
||||
bindListener = Settings.instance.synchronization.localConnections,
|
||||
connectLastKnown = Settings.instance.synchronization.connectLast,
|
||||
relayHandshakeAllowed = Settings.instance.synchronization.connectThroughRelay,
|
||||
relayPairAllowed = Settings.instance.synchronization.pairThroughRelay,
|
||||
relayEnabled = Settings.instance.synchronization.discoverThroughRelay,
|
||||
relayConnectDirect = Settings.instance.synchronization.connectLocalDirectThroughRelay,
|
||||
relayConnectRelayed = Settings.instance.synchronization.connectThroughRelay
|
||||
)
|
||||
).apply {
|
||||
onAuthorized = { sess, isNewlyAuthorized, isNewSession ->
|
||||
if (isNewSession) {
|
||||
deviceUpdatedOrAdded.emit(sess.remotePublicKey, sess)
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) { checkForSync(sess) }
|
||||
}
|
||||
} 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) {
|
||||
_connectThread = Thread {
|
||||
Log.i(TAG, "Running auto reconnector")
|
||||
|
||||
while (_started) {
|
||||
val authorizedDevices = synchronized(_authorizedDevices) {
|
||||
return@synchronized _authorizedDevices.values
|
||||
}
|
||||
|
||||
val lastKnownMap = synchronized(_lastAddressStorage) {
|
||||
return@synchronized _lastAddressStorage.map.toMap()
|
||||
}
|
||||
|
||||
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)
|
||||
onUnauthorized = { sess ->
|
||||
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||
UIDialogs.showConfirmationDialog(
|
||||
context,
|
||||
"Device Unauthorized: ${sess.displayName}",
|
||||
action = {
|
||||
Logger.i(TAG, "${sess.remotePublicKey} unauthorized received")
|
||||
removeAuthorizedDevice(sess.remotePublicKey)
|
||||
deviceRemoved.emit(sess.remotePublicKey)
|
||||
},
|
||||
cancelAction = {}
|
||||
)
|
||||
}
|
||||
}.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 {
|
||||
val manufacturer = Build.MANUFACTURER.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
|
||||
val model = Build.MODEL
|
||||
|
||||
return if (model.startsWith(manufacturer, ignoreCase = true)) {
|
||||
model.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
|
||||
fun confirmStarted(context: Context, onStarted: () -> Unit, onNotStarted: () -> Unit) {
|
||||
if (syncService == null) {
|
||||
UIDialogs.showConfirmationDialog(context, "Sync has not been enabled yet, would you like to enable sync?", {
|
||||
Settings.instance.synchronization.enabled = true
|
||||
start(context)
|
||||
Settings.instance.save()
|
||||
onStarted.invoke()
|
||||
}, {
|
||||
onNotStarted.invoke()
|
||||
})
|
||||
} else {
|
||||
"$manufacturer $model".replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
|
||||
onStarted.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
fun isConnected(publicKey: String): Boolean {
|
||||
return synchronized(_sessions) {
|
||||
_sessions[publicKey]?.connected ?: false
|
||||
}
|
||||
fun hasAuthorizedDevice(): Boolean {
|
||||
return (syncService?.getAuthorizedDeviceCount() ?: 0) > 0
|
||||
}
|
||||
|
||||
fun isAuthorized(publicKey: String): Boolean {
|
||||
return synchronized(_authorizedDevices) {
|
||||
_authorizedDevices.values.contains(publicKey)
|
||||
}
|
||||
return syncService?.isAuthorized(publicKey) ?: false
|
||||
}
|
||||
|
||||
fun getSession(publicKey: String): SyncSession? {
|
||||
return synchronized(_sessions) {
|
||||
_sessions[publicKey]
|
||||
}
|
||||
}
|
||||
fun getSessions(): List<SyncSession> {
|
||||
return synchronized(_sessions) {
|
||||
return _sessions.values.toList()
|
||||
};
|
||||
return syncService?.getSession(publicKey)
|
||||
}
|
||||
|
||||
fun getAuthorizedSessions(): List<SyncSession> {
|
||||
return synchronized(_sessions) {
|
||||
return _sessions.values.filter { it.isAuthorized }.toList()
|
||||
};
|
||||
return syncService?.getSessions()?.filter { it.isAuthorized }?.toList() ?: listOf()
|
||||
}
|
||||
|
||||
fun getSyncSessionData(key: String): SyncSessionData {
|
||||
|
@ -239,197 +194,267 @@ class StateSync {
|
|||
_syncSessionData.setAndSave(data.publicKey, data);
|
||||
}
|
||||
|
||||
private fun handleServiceUpdated(services: List<DnsService>) {
|
||||
if (!Settings.instance.synchronization.connectDiscovered) {
|
||||
return
|
||||
private fun handleSyncSubscriptionPackage(origin: SyncSession, pack: SyncSubscriptionsPackage) {
|
||||
val added = mutableListOf<Subscription>()
|
||||
for(sub in pack.subscriptions) {
|
||||
if(!StateSubscriptions.instance.isSubscribed(sub.channel)) {
|
||||
val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url);
|
||||
if(sub.creationTime > removalTime) {
|
||||
val newSub = StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime);
|
||||
added.add(newSub);
|
||||
}
|
||||
}
|
||||
}
|
||||
if(added.size > 3)
|
||||
UIDialogs.appToast("${added.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}");
|
||||
else if(added.size > 0)
|
||||
UIDialogs.appToast("Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" +
|
||||
added.map { it.channel.name }.joinToString("\n"));
|
||||
|
||||
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)
|
||||
val authorized = isAuthorized(pkey)
|
||||
if(pack.subscriptionRemovals.isNotEmpty()) {
|
||||
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)) {
|
||||
val now = System.currentTimeMillis()
|
||||
val lastConnectTime = synchronized(_lastConnectTimesMdns) {
|
||||
_lastConnectTimesMdns[pkey] ?: 0
|
||||
private fun handleData(session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
|
||||
val remotePublicKey = session.remotePublicKey
|
||||
when (subOpcode) {
|
||||
GJSyncOpcodes.sendToDevices -> {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
val context = StateApp.instance.contextOrNull;
|
||||
if (context != null && context is MainActivity) {
|
||||
val dataBody = ByteArray(data.remaining());
|
||||
val remainder = data.remaining();
|
||||
data.get(dataBody, 0, remainder);
|
||||
val json = String(dataBody, Charsets.UTF_8);
|
||||
val obj = Json.decodeFromString<SendToDevicePackage>(json);
|
||||
UIDialogs.appToast("Received url from device [${session.remotePublicKey}]:\n{${obj.url}");
|
||||
context.handleUrl(obj.url, obj.position);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
//Connect once every 30 seconds, max
|
||||
if (now - lastConnectTime > 30000) {
|
||||
synchronized(_lastConnectTimesMdns) {
|
||||
_lastConnectTimesMdns[pkey] = now
|
||||
GJSyncOpcodes.syncStateExchange -> {
|
||||
val dataBody = ByteArray(data.remaining());
|
||||
data.get(dataBody);
|
||||
val json = String(dataBody, Charsets.UTF_8);
|
||||
val syncSessionData = Serializer.json.decodeFromString<SyncSessionData>(json);
|
||||
|
||||
Logger.i(TAG, "Received SyncSessionData from $remotePublicKey");
|
||||
|
||||
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 {
|
||||
connect(syncDeviceInfo)
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to connect to $pkey", e)
|
||||
}
|
||||
if(subPackage.subscriptions.size > 0) {
|
||||
val newestSub = subPackage.subscriptions.maxOf { it.creationTime };
|
||||
|
||||
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) {
|
||||
broadcast(SyncSocketSession.Opcode.DATA.value, subOpcode, Json.encodeToString(data));
|
||||
broadcast(Opcode.DATA.value, subOpcode, Json.encodeToString(data));
|
||||
}
|
||||
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) {
|
||||
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()) {
|
||||
try {
|
||||
session.send(opcode, subOpcode, data);
|
||||
|
@ -450,40 +475,17 @@ class StateSync {
|
|||
}
|
||||
|
||||
fun stop() {
|
||||
_started = false
|
||||
_serviceDiscoverer.stop()
|
||||
|
||||
_serverSocket?.close()
|
||||
_serverSocket = null
|
||||
|
||||
//_thread?.join()
|
||||
_thread = null
|
||||
_connectThread = null
|
||||
syncService?.stop()
|
||||
syncService = 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> {
|
||||
synchronized(_authorizedDevices) {
|
||||
return _authorizedDevices.values.toList()
|
||||
}
|
||||
return syncService?.getAllAuthorizedDevices()?.toList() ?: listOf()
|
||||
}
|
||||
|
||||
fun getCachedName(publicKey: String): String? {
|
||||
return synchronized(_nameStorage) {
|
||||
_nameStorage.get(publicKey)
|
||||
}
|
||||
return syncService?.getCachedName(publicKey)
|
||||
}
|
||||
|
||||
suspend fun delete(publicKey: String) {
|
||||
|
@ -500,14 +502,8 @@ class StateSync {
|
|||
session.close()
|
||||
}
|
||||
|
||||
synchronized(_sessions) {
|
||||
_sessions.remove(publicKey)
|
||||
}
|
||||
|
||||
synchronized(_authorizedDevices) {
|
||||
_authorizedDevices.remove(publicKey)
|
||||
}
|
||||
_authorizedDevices.save()
|
||||
syncService?.removeSession(publicKey)
|
||||
syncService?.removeAuthorizedDevice(publicKey)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
deviceRemoved.emit(publicKey)
|
||||
|
@ -516,16 +512,47 @@ class StateSync {
|
|||
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 {
|
||||
val dh = "25519"
|
||||
val pattern = "IK"
|
||||
val cipher = "ChaChaPoly"
|
||||
val hash = "BLAKE2b"
|
||||
var protocolName = "Noise_${pattern}_${dh}_${cipher}_${hash}"
|
||||
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"
|
||||
const val PORT = 12315
|
||||
|
|
|
@ -19,6 +19,11 @@ class CastingDeviceInfoStorage : FragmentedStorageFileJson() {
|
|||
return deviceInfos.toList();
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getDeviceNames() : List<String> {
|
||||
return deviceInfos.map { it.name }.toList();
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addDevice(castingDeviceInfo: CastingDeviceInfo): CastingDeviceInfo {
|
||||
val foundDeviceInfo = deviceInfos.firstOrNull { d -> d.name == castingDeviceInfo.name }
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.futo.platformplayer.stores
|
||||
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.stores.v2.JsonStoreSerializer
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.stores.v2.StoreSerializer
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue