mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-04-30 06:34:34 +02:00
Compare commits
53 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
9e2041521e | ||
![]() |
ee7b89ec6e | ||
![]() |
5b143bdc76 | ||
![]() |
d9d00e452e | ||
![]() |
14500e281c | ||
![]() |
c4623c80ff | ||
![]() |
9e17dce9a9 | ||
![]() |
daa91986ef | ||
![]() |
63761cfc9a | ||
![]() |
d10026acd1 | ||
![]() |
9347351c37 | ||
![]() |
0ef1f2d40f | ||
![]() |
b460f9915d | ||
![]() |
4e195dfbc3 | ||
![]() |
3c7f7bfca7 | ||
![]() |
05230971b3 | ||
![]() |
dccdf72c73 | ||
![]() |
ca15983a72 | ||
![]() |
4b6a2c9829 | ||
![]() |
1755d03a6b | ||
![]() |
869b1fc15e | ||
![]() |
ce2a2f8582 | ||
![]() |
7b355139fb | ||
![]() |
b14518edb1 | ||
![]() |
7d64003d1c | ||
![]() |
0a59e04f19 | ||
![]() |
b57abb646f | ||
![]() |
dd6bde97a9 | ||
![]() |
b545545712 | ||
![]() |
c1993ffa03 | ||
![]() |
7f7ebafa46 | ||
![]() |
b652597924 | ||
![]() |
258fe77928 | ||
![]() |
5a9fcd6fab | ||
![]() |
3c05521a5b | ||
![]() |
034b8b15ae | ||
![]() |
7bd687331b | ||
![]() |
54d58df4b6 | ||
![]() |
9165a9f7cb | ||
![]() |
b556d1e81d | ||
![]() |
7c25678211 | ||
![]() |
c83a9924e2 | ||
![]() |
bbeb9b83a0 | ||
![]() |
06478f3e36 | ||
![]() |
40f20002b2 | ||
![]() |
442272f517 | ||
![]() |
88dae8e9c4 | ||
![]() |
1bbfa7d39e | ||
![]() |
edc2b3d295 | ||
![]() |
0006da7385 | ||
![]() |
b5ac8b3ec6 | ||
![]() |
78f5169880 | ||
![]() |
3361b77aec |
6
.gitmodules
vendored
6
.gitmodules
vendored
@ -88,3 +88,9 @@
|
|||||||
[submodule "app/src/unstable/assets/sources/apple-podcasts"]
|
[submodule "app/src/unstable/assets/sources/apple-podcasts"]
|
||||||
path = app/src/unstable/assets/sources/apple-podcasts
|
path = app/src/unstable/assets/sources/apple-podcasts
|
||||||
url = ../plugins/apple-podcasts.git
|
url = ../plugins/apple-podcasts.git
|
||||||
|
[submodule "app/src/stable/assets/sources/tedtalks"]
|
||||||
|
path = app/src/stable/assets/sources/tedtalks
|
||||||
|
url = ../plugins/tedtalks.git
|
||||||
|
[submodule "app/src/unstable/assets/sources/tedtalks"]
|
||||||
|
path = app/src/unstable/assets/sources/tedtalks
|
||||||
|
url = ../plugins/tedtalks.git
|
||||||
|
@ -197,7 +197,7 @@ dependencies {
|
|||||||
implementation 'org.jsoup:jsoup:1.15.3'
|
implementation 'org.jsoup:jsoup:1.15.3'
|
||||||
implementation 'com.google.android.flexbox:flexbox:3.0.0'
|
implementation 'com.google.android.flexbox:flexbox:3.0.0'
|
||||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||||
implementation 'com.arthenica:ffmpeg-kit-full:5.1'
|
implementation 'com.arthenica:ffmpeg-kit-full:6.0-2.LTS'
|
||||||
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
|
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
|
||||||
implementation 'com.github.dhaval2404:imagepicker:2.1'
|
implementation 'com.github.dhaval2404:imagepicker:2.1'
|
||||||
implementation 'com.google.zxing:core:3.4.1'
|
implementation 'com.google.zxing:core:3.4.1'
|
||||||
|
@ -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
|
||||||
|
}
|
@ -156,7 +156,6 @@
|
|||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.SettingsActivity"
|
android:name=".activities.SettingsActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.DeveloperActivity"
|
android:name=".activities.DeveloperActivity"
|
||||||
|
@ -263,6 +263,10 @@ class PlatformVideoDetails extends PlatformVideo {
|
|||||||
this.rating = obj.rating ?? null; //IRating
|
this.rating = obj.rating ?? null; //IRating
|
||||||
this.subtitles = obj.subtitles ?? [];
|
this.subtitles = obj.subtitles ?? [];
|
||||||
this.isShort = !!obj.isShort ?? false;
|
this.isShort = !!obj.isShort ?? false;
|
||||||
|
|
||||||
|
if (obj.getContentRecommendations) {
|
||||||
|
this.getContentRecommendations = obj.getContentRecommendations
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -216,9 +216,14 @@ private fun ByteArray.toInetAddress(): InetAddress {
|
|||||||
return InetAddress.getByAddress(this);
|
return InetAddress.getByAddress(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
|
||||||
val timeout = 2000
|
val timeout = 2000
|
||||||
|
|
||||||
|
|
||||||
|
val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses;
|
||||||
|
if(addresses.isEmpty())
|
||||||
|
throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})");
|
||||||
|
|
||||||
if (addresses.isEmpty()) {
|
if (addresses.isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
package com.futo.platformplayer
|
package com.futo.platformplayer
|
||||||
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
|
||||||
import com.futo.platformplayer.states.AnnouncementType
|
import com.futo.platformplayer.states.AnnouncementType
|
||||||
import com.futo.platformplayer.states.StateAnnouncement
|
import com.futo.platformplayer.states.StateAnnouncement
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.polycentric.core.ProcessHandle
|
import com.futo.polycentric.core.ProcessHandle
|
||||||
import com.futo.polycentric.core.Store
|
import com.futo.polycentric.core.Store
|
||||||
import com.futo.polycentric.core.SystemState
|
import com.futo.polycentric.core.SystemState
|
||||||
|
import com.futo.polycentric.core.base64UrlToByteArray
|
||||||
import userpackage.Protocol
|
import userpackage.Protocol
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@ -40,6 +40,21 @@ fun Protocol.ImageBundle?.selectHighestResolutionImage(): Protocol.ImageManifest
|
|||||||
return imageManifestsList.filter { it.byteCount < maximumFileSize }.maxByOrNull { abs(it.width * it.height) }
|
return imageManifestsList.filter { it.byteCount < maximumFileSize }.maxByOrNull { abs(it.width * it.height) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun String.getDataLinkFromUrl(): Protocol.URLInfoDataLink? {
|
||||||
|
val urlData = if (this.startsWith("polycentric://")) {
|
||||||
|
this.substring("polycentric://".length)
|
||||||
|
} else this;
|
||||||
|
|
||||||
|
val urlBytes = urlData.base64UrlToByteArray();
|
||||||
|
val urlInfo = Protocol.URLInfo.parseFrom(urlBytes);
|
||||||
|
if (urlInfo.urlType != 4L) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val dataLink = Protocol.URLInfoDataLink.parseFrom(urlInfo.body);
|
||||||
|
return dataLink
|
||||||
|
}
|
||||||
|
|
||||||
fun Protocol.Claim.resolveChannelUrl(): String? {
|
fun Protocol.Claim.resolveChannelUrl(): String? {
|
||||||
return StatePlatform.instance.resolveChannelUrlByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
|
return StatePlatform.instance.resolveChannelUrlByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
|
||||||
}
|
}
|
||||||
@ -47,26 +62,3 @@ fun Protocol.Claim.resolveChannelUrl(): String? {
|
|||||||
fun Protocol.Claim.resolveChannelUrls(): List<String> {
|
fun Protocol.Claim.resolveChannelUrls(): List<String> {
|
||||||
return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
|
return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() {
|
|
||||||
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system))
|
|
||||||
if (!systemState.servers.contains(PolycentricCache.SERVER)) {
|
|
||||||
Logger.w("Backfill", "Polycentric prod server not added, adding it.")
|
|
||||||
addServer(PolycentricCache.SERVER)
|
|
||||||
}
|
|
||||||
|
|
||||||
val exceptions = fullyBackfillServers()
|
|
||||||
for (pair in exceptions) {
|
|
||||||
val server = pair.key
|
|
||||||
val exception = pair.value
|
|
||||||
|
|
||||||
StateAnnouncement.instance.registerAnnouncement(
|
|
||||||
"backfill-failed",
|
|
||||||
"Backfill failed",
|
|
||||||
"Failed to backfill server $server. $exception",
|
|
||||||
AnnouncementType.SESSION_RECURRING
|
|
||||||
);
|
|
||||||
|
|
||||||
Logger.e("Backfill", "Failed to backfill server $server.", exception)
|
|
||||||
}
|
|
||||||
}
|
|
@ -205,7 +205,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
var home = HomeSettings();
|
var home = HomeSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
class HomeSettings {
|
class HomeSettings {
|
||||||
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 5)
|
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 3)
|
||||||
@DropdownFieldOptionsId(R.array.feed_style)
|
@DropdownFieldOptionsId(R.array.feed_style)
|
||||||
var homeFeedStyle: Int = 1;
|
var homeFeedStyle: Int = 1;
|
||||||
|
|
||||||
@ -216,6 +216,11 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
return FeedStyle.THUMBNAIL;
|
return FeedStyle.THUMBNAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@FormField(R.string.show_home_filters, FieldForm.TOGGLE, R.string.show_home_filters_description, 4)
|
||||||
|
var showHomeFilters: Boolean = true;
|
||||||
|
@FormField(R.string.show_home_filters_plugin_names, FieldForm.TOGGLE, R.string.show_home_filters_plugin_names_description, 5)
|
||||||
|
var showHomeFiltersPluginNames: Boolean = false;
|
||||||
|
|
||||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
||||||
var previewFeedItems: Boolean = true;
|
var previewFeedItems: Boolean = true;
|
||||||
|
|
||||||
@ -294,6 +299,9 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.show_subscription_group, FieldForm.TOGGLE, R.string.show_subscription_group_description, 5)
|
@FormField(R.string.show_subscription_group, FieldForm.TOGGLE, R.string.show_subscription_group_description, 5)
|
||||||
var showSubscriptionGroups: Boolean = true;
|
var showSubscriptionGroups: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6)
|
||||||
|
var useSubscriptionExchange: Boolean = false;
|
||||||
|
|
||||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
||||||
var previewFeedItems: Boolean = true;
|
var previewFeedItems: Boolean = true;
|
||||||
|
|
||||||
@ -356,7 +364,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
var playback = PlaybackSettings();
|
var playback = PlaybackSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
class PlaybackSettings {
|
class PlaybackSettings {
|
||||||
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, -1)
|
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, -2)
|
||||||
@DropdownFieldOptionsId(R.array.audio_languages)
|
@DropdownFieldOptionsId(R.array.audio_languages)
|
||||||
var primaryLanguage: Int = 0;
|
var primaryLanguage: Int = 0;
|
||||||
|
|
||||||
@ -380,6 +388,8 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@FormField(R.string.prefer_original_audio, FieldForm.TOGGLE, R.string.prefer_original_audio_description, -1)
|
||||||
|
var preferOriginalAudio: Boolean = true;
|
||||||
|
|
||||||
//= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
|
//= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
|
||||||
|
|
||||||
@ -573,10 +583,15 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
var keepScreenOn: Boolean = true;
|
var keepScreenOn: Boolean = true;
|
||||||
|
|
||||||
@FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 1)
|
@FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 3)
|
||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
var alwaysProxyRequests: Boolean = false;
|
var alwaysProxyRequests: Boolean = false;
|
||||||
|
|
||||||
|
|
||||||
|
@FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4)
|
||||||
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
|
var allowIpv6: Boolean = false;
|
||||||
|
|
||||||
/*TODO: Should we have a different casting quality?
|
/*TODO: Should we have a different casting quality?
|
||||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||||
@ -921,6 +936,15 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.connect_last, FieldForm.TOGGLE, R.string.connect_last_description, 3)
|
@FormField(R.string.connect_last, FieldForm.TOGGLE, R.string.connect_last_description, 3)
|
||||||
var connectLast: Boolean = true;
|
var connectLast: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.discover_through_relay, FieldForm.TOGGLE, R.string.discover_through_relay_description, 3)
|
||||||
|
var discoverThroughRelay: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.pair_through_relay, FieldForm.TOGGLE, R.string.pair_through_relay_description, 3)
|
||||||
|
var pairThroughRelay: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.connect_through_relay, FieldForm.TOGGLE, R.string.connect_through_relay_description, 3)
|
||||||
|
var connectThroughRelay: Boolean = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
|
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
|
||||||
|
@ -5,6 +5,7 @@ import android.app.AlertDialog
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.graphics.drawable.Animatable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.text.Layout
|
import android.text.Layout
|
||||||
import android.text.method.ScrollingMovementMethod
|
import android.text.method.ScrollingMovementMethod
|
||||||
@ -199,16 +200,21 @@ class UIDialogs {
|
|||||||
dialog.show();
|
dialog.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action) {
|
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
||||||
|
return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions);
|
||||||
|
}
|
||||||
|
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
||||||
val builder = AlertDialog.Builder(context);
|
val builder = AlertDialog.Builder(context);
|
||||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
|
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
|
||||||
builder.setView(view);
|
builder.setView(view);
|
||||||
|
builder.setCancelable(defaultCloseAction > -2);
|
||||||
val dialog = builder.create();
|
val dialog = builder.create();
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog);
|
||||||
|
|
||||||
view.findViewById<ImageView>(R.id.dialog_icon).apply {
|
view.findViewById<ImageView>(R.id.dialog_icon).apply {
|
||||||
this.setImageResource(icon);
|
this.setImageResource(icon);
|
||||||
|
if(animated)
|
||||||
|
this.drawable.assume<Animatable, Unit> { it.start() };
|
||||||
}
|
}
|
||||||
view.findViewById<TextView>(R.id.dialog_text).apply {
|
view.findViewById<TextView>(R.id.dialog_text).apply {
|
||||||
this.text = text;
|
this.text = text;
|
||||||
@ -275,6 +281,7 @@ class UIDialogs {
|
|||||||
registerDialogClosed(dialog);
|
registerDialogClosed(dialog);
|
||||||
}
|
}
|
||||||
dialog.show();
|
dialog.show();
|
||||||
|
return dialog;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) {
|
fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) {
|
||||||
|
@ -402,7 +402,7 @@ class UISlideOverlays {
|
|||||||
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
|
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
|
||||||
slideUpMenuOverlay.hide()
|
slideUpMenuOverlay.hide()
|
||||||
} else if (source is IHLSManifestAudioSource) {
|
} else if (source is IHLSManifestAudioSource) {
|
||||||
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, sourceUrl), null)
|
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, sourceUrl), null)
|
||||||
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
|
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
|
||||||
slideUpMenuOverlay.hide()
|
slideUpMenuOverlay.hide()
|
||||||
} else {
|
} else {
|
||||||
@ -1148,7 +1148,7 @@ class UISlideOverlays {
|
|||||||
container.context.getString(R.string.decide_which_buttons_should_be_pinned),
|
container.context.getString(R.string.decide_which_buttons_should_be_pinned),
|
||||||
tag = "",
|
tag = "",
|
||||||
call = {
|
call = {
|
||||||
showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) {
|
showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }, {
|
||||||
val selected = it
|
val selected = it
|
||||||
.map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } }
|
.map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } }
|
||||||
.filter { it != null }
|
.filter { it != null }
|
||||||
@ -1156,7 +1156,7 @@ class UISlideOverlays {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) });
|
onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) });
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
invokeParent = false
|
invokeParent = false
|
||||||
))
|
))
|
||||||
@ -1164,29 +1164,40 @@ class UISlideOverlays {
|
|||||||
|
|
||||||
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() };
|
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() };
|
||||||
}
|
}
|
||||||
|
fun showOrderOverlay(container: ViewGroup, title: String, options: List<Pair<String, Any>>, onOrdered: (List<Any>)->Unit, description: String? = null) {
|
||||||
fun showOrderOverlay(container: ViewGroup, title: String, options: List<Pair<String, Any>>, onOrdered: (List<Any>)->Unit) {
|
|
||||||
val selection: MutableList<Any> = mutableListOf();
|
val selection: MutableList<Any> = mutableListOf();
|
||||||
|
|
||||||
var overlay: SlideUpMenuOverlay? = null;
|
var overlay: SlideUpMenuOverlay? = null;
|
||||||
|
|
||||||
overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true,
|
overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true,
|
||||||
options.map { SlideUpMenuItem(
|
listOf(
|
||||||
|
if(!description.isNullOrEmpty()) SlideUpMenuGroup(container.context, "", description, "", listOf()) else null,
|
||||||
|
).filterNotNull() +
|
||||||
|
(options.map { SlideUpMenuItem(
|
||||||
container.context,
|
container.context,
|
||||||
R.drawable.ic_move_up,
|
R.drawable.ic_move_up,
|
||||||
it.first,
|
it.first,
|
||||||
"",
|
"",
|
||||||
tag = it.second,
|
tag = it.second,
|
||||||
call = {
|
call = {
|
||||||
|
val overlayItem = overlay?.getSlideUpItemByTag(it.second);
|
||||||
if(overlay!!.selectOption(null, it.second, true, true)) {
|
if(overlay!!.selectOption(null, it.second, true, true)) {
|
||||||
if(!selection.contains(it.second))
|
if(!selection.contains(it.second)) {
|
||||||
selection.add(it.second);
|
selection.add(it.second);
|
||||||
} else
|
if(overlayItem != null) {
|
||||||
|
overlayItem.setSubText(selection.indexOf(it.second).toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
selection.remove(it.second);
|
selection.remove(it.second);
|
||||||
|
if(overlayItem != null) {
|
||||||
|
overlayItem.setSubText("");
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
invokeParent = false
|
invokeParent = false
|
||||||
)
|
)
|
||||||
});
|
}));
|
||||||
overlay.onOK.subscribe {
|
overlay.onOK.subscribe {
|
||||||
onOrdered.invoke(selection);
|
onOrdered.invoke(selection);
|
||||||
overlay.hide();
|
overlay.hide();
|
||||||
|
@ -27,14 +27,17 @@ import com.futo.platformplayer.logging.Logger
|
|||||||
import com.futo.platformplayer.models.PlatformVideoWithTime
|
import com.futo.platformplayer.models.PlatformVideoWithTime
|
||||||
import com.futo.platformplayer.others.PlatformLinkMovementMethod
|
import com.futo.platformplayer.others.PlatformLinkMovementMethod
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.File
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.security.SecureRandom
|
||||||
|
import java.time.OffsetDateTime
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.ThreadLocalRandom
|
import java.util.concurrent.ThreadLocalRandom
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
|
import java.util.zip.GZIPOutputStream
|
||||||
|
|
||||||
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
|
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
|
||||||
fun getRandomString(sizeOfRandomString: Int): String {
|
fun getRandomString(sizeOfRandomString: Int): String {
|
||||||
@ -66,7 +69,14 @@ fun warnIfMainThread(context: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun ensureNotMainThread() {
|
fun ensureNotMainThread() {
|
||||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
val isMainLooper = try {
|
||||||
|
Looper.myLooper() == Looper.getMainLooper()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
//Ignore, for unit tests where its not mocked
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMainLooper) {
|
||||||
Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread")
|
Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread")
|
||||||
throw IllegalStateException("Cannot run on main thread")
|
throw IllegalStateException("Cannot run on main thread")
|
||||||
}
|
}
|
||||||
@ -279,3 +289,46 @@ fun ByteBuffer.toUtf8String(): String {
|
|||||||
get(remainingBytes)
|
get(remainingBytes)
|
||||||
return String(remainingBytes, Charsets.UTF_8)
|
return String(remainingBytes, Charsets.UTF_8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun generateReadablePassword(length: Int): String {
|
||||||
|
val validChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"
|
||||||
|
val secureRandom = SecureRandom()
|
||||||
|
val randomBytes = ByteArray(length)
|
||||||
|
secureRandom.nextBytes(randomBytes)
|
||||||
|
val sb = StringBuilder(length)
|
||||||
|
for (byte in randomBytes) {
|
||||||
|
val index = (byte.toInt() and 0xFF) % validChars.length
|
||||||
|
sb.append(validChars[index])
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ByteArray.toGzip(): ByteArray {
|
||||||
|
if (this == null || this.isEmpty()) return ByteArray(0)
|
||||||
|
|
||||||
|
val gzipTimeStart = OffsetDateTime.now();
|
||||||
|
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
GZIPOutputStream(outputStream).use { gzip ->
|
||||||
|
gzip.write(this)
|
||||||
|
}
|
||||||
|
val result = outputStream.toByteArray();
|
||||||
|
Logger.i("Utility", "Gzip compression time: ${gzipTimeStart.getNowDiffMiliseconds()}ms");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ByteArray.fromGzip(): ByteArray {
|
||||||
|
if (this == null || this.isEmpty()) return ByteArray(0)
|
||||||
|
|
||||||
|
val inputStream = ByteArrayInputStream(this)
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
|
||||||
|
GZIPInputStream(inputStream).use { gzip ->
|
||||||
|
val buffer = ByteArray(1024)
|
||||||
|
var bytesRead: Int
|
||||||
|
while (gzip.read(buffer).also { bytesRead = it } != -1) {
|
||||||
|
outputStream.write(buffer, 0, bytesRead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outputStream.toByteArray()
|
||||||
|
}
|
@ -10,11 +10,13 @@ import androidx.appcompat.app.AppCompatActivity
|
|||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.*
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.views.buttons.BigButton
|
import com.futo.platformplayer.views.buttons.BigButton
|
||||||
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput
|
||||||
import com.google.zxing.integration.android.IntentIntegrator
|
import com.google.zxing.integration.android.IntentIntegrator
|
||||||
|
|
||||||
class AddSourceOptionsActivity : AppCompatActivity() {
|
class AddSourceOptionsActivity : AppCompatActivity() {
|
||||||
lateinit var _buttonBack: ImageButton;
|
lateinit var _buttonBack: ImageButton;
|
||||||
|
|
||||||
|
lateinit var _overlayContainer: FrameLayout;
|
||||||
lateinit var _buttonQR: BigButton;
|
lateinit var _buttonQR: BigButton;
|
||||||
lateinit var _buttonBrowse: BigButton;
|
lateinit var _buttonBrowse: BigButton;
|
||||||
lateinit var _buttonURL: BigButton;
|
lateinit var _buttonURL: BigButton;
|
||||||
@ -54,6 +56,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
|||||||
setContentView(R.layout.activity_add_source_options);
|
setContentView(R.layout.activity_add_source_options);
|
||||||
setNavigationBarColorAndIcons();
|
setNavigationBarColorAndIcons();
|
||||||
|
|
||||||
|
_overlayContainer = findViewById(R.id.overlay_container);
|
||||||
_buttonBack = findViewById(R.id.button_back);
|
_buttonBack = findViewById(R.id.button_back);
|
||||||
|
|
||||||
_buttonQR = findViewById(R.id.option_qr);
|
_buttonQR = findViewById(R.id.option_qr);
|
||||||
@ -81,7 +84,25 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_buttonURL.onClick.subscribe {
|
_buttonURL.onClick.subscribe {
|
||||||
UIDialogs.toast(this, getString(R.string.not_implemented_yet));
|
val nameInput = SlideUpMenuTextInput(this, "ex. https://yourplugin.com/config.json");
|
||||||
|
UISlideOverlays.showOverlay(_overlayContainer, "Enter your url", "Install", {
|
||||||
|
|
||||||
|
val content = nameInput.text;
|
||||||
|
|
||||||
|
val url = if (content.startsWith("https://")) {
|
||||||
|
content
|
||||||
|
} else if (content.startsWith("grayjay://plugin/")) {
|
||||||
|
content.substring("grayjay://plugin/".length)
|
||||||
|
} else {
|
||||||
|
UIDialogs.toast(this, getString(R.string.not_a_plugin_url))
|
||||||
|
return@showOverlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(this, AddSourceActivity::class.java).apply {
|
||||||
|
data = Uri.parse(url);
|
||||||
|
};
|
||||||
|
startActivity(intent);
|
||||||
|
}, nameInput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -113,7 +113,7 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = "LoginActivity";
|
private val TAG = "LoginActivity";
|
||||||
private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#_ ]*");
|
private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#:_ ]*");
|
||||||
|
|
||||||
private var _callback: ((SourceAuth?) -> Unit)? = null;
|
private var _callback: ((SourceAuth?) -> Unit)? = null;
|
||||||
|
|
||||||
|
@ -11,16 +11,16 @@ import androidx.appcompat.app.AppCompatActivity
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
|
||||||
import com.futo.platformplayer.polycentric.PolycentricStorage
|
import com.futo.platformplayer.polycentric.PolycentricStorage
|
||||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StatePolycentric
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
import com.futo.platformplayer.views.LoaderView
|
import com.futo.platformplayer.views.LoaderView
|
||||||
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.futo.polycentric.core.ProcessHandle
|
import com.futo.polycentric.core.ProcessHandle
|
||||||
import com.futo.polycentric.core.Store
|
import com.futo.polycentric.core.Store
|
||||||
|
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@ -87,7 +87,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
|||||||
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
|
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
processHandle.addServer(PolycentricCache.SERVER);
|
processHandle.addServer(ApiMethods.SERVER);
|
||||||
processHandle.setUsername(username);
|
processHandle.setUsername(username);
|
||||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
StatePolycentric.instance.setProcessHandle(processHandle);
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
|
@ -12,12 +12,12 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
|
||||||
import com.futo.platformplayer.polycentric.PolycentricStorage
|
import com.futo.platformplayer.polycentric.PolycentricStorage
|
||||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StatePolycentric
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
import com.futo.platformplayer.views.overlays.LoaderOverlay
|
import com.futo.platformplayer.views.overlays.LoaderOverlay
|
||||||
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.futo.polycentric.core.KeyPair
|
import com.futo.polycentric.core.KeyPair
|
||||||
import com.futo.polycentric.core.Process
|
import com.futo.polycentric.core.Process
|
||||||
import com.futo.polycentric.core.ProcessSecret
|
import com.futo.polycentric.core.ProcessSecret
|
||||||
@ -145,7 +145,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
StatePolycentric.instance.setProcessHandle(processHandle);
|
||||||
processHandle.fullyBackfillClient(PolycentricCache.SERVER);
|
processHandle.fullyBackfillClient(ApiMethods.SERVER);
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
|
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
|
||||||
finish();
|
finish();
|
||||||
|
@ -21,10 +21,8 @@ import com.bumptech.glide.Glide
|
|||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.dp
|
import com.futo.platformplayer.dp
|
||||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
|
||||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
|
||||||
import com.futo.platformplayer.polycentric.PolycentricStorage
|
import com.futo.platformplayer.polycentric.PolycentricStorage
|
||||||
import com.futo.platformplayer.selectBestImage
|
import com.futo.platformplayer.selectBestImage
|
||||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
@ -32,8 +30,10 @@ import com.futo.platformplayer.states.StateApp
|
|||||||
import com.futo.platformplayer.states.StatePolycentric
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
import com.futo.platformplayer.views.buttons.BigButton
|
import com.futo.platformplayer.views.buttons.BigButton
|
||||||
import com.futo.platformplayer.views.overlays.LoaderOverlay
|
import com.futo.platformplayer.views.overlays.LoaderOverlay
|
||||||
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.futo.polycentric.core.Store
|
import com.futo.polycentric.core.Store
|
||||||
import com.futo.polycentric.core.SystemState
|
import com.futo.polycentric.core.SystemState
|
||||||
|
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
|
||||||
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
|
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
|
||||||
import com.futo.polycentric.core.toBase64Url
|
import com.futo.polycentric.core.toBase64Url
|
||||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||||
@ -145,7 +145,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
processHandle.fullyBackfillClient(PolycentricCache.SERVER)
|
processHandle.fullyBackfillClient(ApiMethods.SERVER)
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
updateUI();
|
updateUI();
|
||||||
|
@ -100,8 +100,10 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
|
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
|
||||||
val connected = session?.connected ?: false
|
val connected = session?.connected ?: false
|
||||||
syncDeviceView.setLinkType(if (connected) LinkType.Local else LinkType.None)
|
|
||||||
.setName(publicKey)
|
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) "Connected" else "Disconnected")
|
||||||
return syncDeviceView
|
return syncDeviceView
|
||||||
}
|
}
|
||||||
|
@ -109,9 +109,9 @@ class SyncPairActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
StateSync.instance.connect(deviceInfo) { session, complete, message ->
|
StateSync.instance.connect(deviceInfo) { complete, message ->
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
if (complete) {
|
if (complete != null && complete) {
|
||||||
_layoutPairingSuccess.visibility = View.VISIBLE
|
_layoutPairingSuccess.visibility = View.VISIBLE
|
||||||
_layoutPairing.visibility = View.GONE
|
_layoutPairing.visibility = View.GONE
|
||||||
} else {
|
} else {
|
||||||
|
@ -67,7 +67,7 @@ class SyncShowPairingCodeActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val ips = getIPs()
|
val ips = getIPs()
|
||||||
val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT)
|
val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT, StateSync.instance.pairingCode)
|
||||||
val json = Json.encodeToString(selfDeviceInfo)
|
val json = Json.encodeToString(selfDeviceInfo)
|
||||||
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||||
val url = "grayjay://sync/${base64}"
|
val url = "grayjay://sync/${base64}"
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package com.futo.platformplayer.api.media
|
package com.futo.platformplayer.api.media
|
||||||
|
|
||||||
|
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||||
@ -66,6 +67,11 @@ interface IPlatformClient {
|
|||||||
*/
|
*/
|
||||||
fun searchChannels(query: String): IPager<PlatformAuthorLink>;
|
fun searchChannels(query: String): IPager<PlatformAuthorLink>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches for channels and returns a content pager
|
||||||
|
*/
|
||||||
|
fun searchChannelsAsContent(query: String): IPager<IPlatformContent>;
|
||||||
|
|
||||||
|
|
||||||
//Video Pages
|
//Video Pages
|
||||||
/**
|
/**
|
||||||
|
@ -2,7 +2,10 @@ package com.futo.platformplayer.api.media.models
|
|||||||
|
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.models.JSContent
|
||||||
import com.futo.platformplayer.getOrDefault
|
import com.futo.platformplayer.getOrDefault
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
|
||||||
@ -43,3 +46,20 @@ open class PlatformAuthorLink {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IPlatformChannelContent : IPlatformContent {
|
||||||
|
val thumbnail: String?
|
||||||
|
val subscribers: Long?
|
||||||
|
}
|
||||||
|
|
||||||
|
open class JSChannelContent : JSContent, IPlatformChannelContent {
|
||||||
|
override val contentType: ContentType get() = ContentType.CHANNEL
|
||||||
|
override val thumbnail: String?
|
||||||
|
override val subscribers: Long?
|
||||||
|
|
||||||
|
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
|
||||||
|
val contextName = "Channel";
|
||||||
|
thumbnail = obj.getOrDefault<String>(config, "thumbnail", contextName, null)
|
||||||
|
subscribers = if(obj.has("subscribers")) obj.getOrThrow(config,"subscribers", contextName) else null
|
||||||
|
}
|
||||||
|
}
|
@ -12,6 +12,7 @@ enum class ContentType(val value: Int) {
|
|||||||
URL(9),
|
URL(9),
|
||||||
|
|
||||||
NESTED_VIDEO(11),
|
NESTED_VIDEO(11),
|
||||||
|
CHANNEL(60),
|
||||||
|
|
||||||
LOCKED(70),
|
LOCKED(70),
|
||||||
|
|
||||||
|
@ -2,6 +2,8 @@ package com.futo.platformplayer.api.media.models.contents
|
|||||||
|
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonNames
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
interface IPlatformContent {
|
interface IPlatformContent {
|
||||||
|
@ -3,7 +3,7 @@ package com.futo.platformplayer.api.media.models.streams
|
|||||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||||
import com.futo.platformplayer.downloads.VideoLocal
|
import com.futo.platformplayer.downloads.VideoLocal
|
||||||
|
|
||||||
class LocalVideoMuxedSourceDescriptor(
|
class DownloadedVideoMuxedSourceDescriptor(
|
||||||
private val video: VideoLocal
|
private val video: VideoLocal
|
||||||
) : VideoMuxedSourceDescriptor() {
|
) : VideoMuxedSourceDescriptor() {
|
||||||
override val videoSources: Array<IVideoSource> get() = video.videoSource.toTypedArray();
|
override val videoSources: Array<IVideoSource> get() = video.videoSource.toTypedArray();
|
@ -13,7 +13,8 @@ class AudioUrlSource(
|
|||||||
override val codec: String = "",
|
override val codec: String = "",
|
||||||
override val language: String = Language.UNKNOWN,
|
override val language: String = Language.UNKNOWN,
|
||||||
override val duration: Long? = null,
|
override val duration: Long? = null,
|
||||||
override var priority: Boolean = false
|
override var priority: Boolean = false,
|
||||||
|
override var original: Boolean = false
|
||||||
) : IAudioUrlSource, IStreamMetaDataSource{
|
) : IAudioUrlSource, IStreamMetaDataSource{
|
||||||
override var streamMetaData: StreamMetaData? = null;
|
override var streamMetaData: StreamMetaData? = null;
|
||||||
|
|
||||||
@ -36,7 +37,9 @@ class AudioUrlSource(
|
|||||||
source.container,
|
source.container,
|
||||||
source.codec,
|
source.codec,
|
||||||
source.language,
|
source.language,
|
||||||
source.duration
|
source.duration,
|
||||||
|
source.priority,
|
||||||
|
source.original
|
||||||
);
|
);
|
||||||
ret.streamMetaData = streamData;
|
ret.streamMetaData = streamData;
|
||||||
|
|
||||||
|
@ -27,6 +27,7 @@ class HLSVariantAudioUrlSource(
|
|||||||
override val language: String,
|
override val language: String,
|
||||||
override val duration: Long?,
|
override val duration: Long?,
|
||||||
override val priority: Boolean,
|
override val priority: Boolean,
|
||||||
|
override val original: Boolean,
|
||||||
val url: String
|
val url: String
|
||||||
) : IAudioUrlSource {
|
) : IAudioUrlSource {
|
||||||
override fun getAudioUrl(): String {
|
override fun getAudioUrl(): String {
|
||||||
|
@ -8,4 +8,5 @@ interface IAudioSource {
|
|||||||
val language : String;
|
val language : String;
|
||||||
val duration : Long?;
|
val duration : Long?;
|
||||||
val priority: Boolean;
|
val priority: Boolean;
|
||||||
|
val original: Boolean;
|
||||||
}
|
}
|
@ -15,6 +15,7 @@ class LocalAudioSource : IAudioSource, IStreamMetaDataSource {
|
|||||||
override val duration: Long? = null;
|
override val duration: Long? = null;
|
||||||
|
|
||||||
override var priority: Boolean = false;
|
override var priority: Boolean = false;
|
||||||
|
override val original: Boolean = false;
|
||||||
|
|
||||||
val filePath : String;
|
val filePath : String;
|
||||||
val fileSize: Long;
|
val fileSize: Long;
|
||||||
|
@ -10,15 +10,18 @@ import com.futo.polycentric.core.combineHashCodes
|
|||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonNames
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
@kotlinx.serialization.Serializable
|
@kotlinx.serialization.Serializable
|
||||||
open class SerializedPlatformVideo(
|
open class SerializedPlatformVideo(
|
||||||
|
override val contentType: ContentType = ContentType.MEDIA,
|
||||||
override val id: PlatformID,
|
override val id: PlatformID,
|
||||||
override val name: String,
|
override val name: String,
|
||||||
override val thumbnails: Thumbnails,
|
override val thumbnails: Thumbnails,
|
||||||
override val author: PlatformAuthorLink,
|
override val author: PlatformAuthorLink,
|
||||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||||
|
@JsonNames("datetime", "dateTime")
|
||||||
override val datetime: OffsetDateTime? = null,
|
override val datetime: OffsetDateTime? = null,
|
||||||
override val url: String,
|
override val url: String,
|
||||||
override val shareUrl: String = "",
|
override val shareUrl: String = "",
|
||||||
@ -27,7 +30,6 @@ open class SerializedPlatformVideo(
|
|||||||
override val viewCount: Long,
|
override val viewCount: Long,
|
||||||
override val isShort: Boolean = false
|
override val isShort: Boolean = false
|
||||||
) : IPlatformVideo, SerializedPlatformContent {
|
) : IPlatformVideo, SerializedPlatformContent {
|
||||||
override val contentType: ContentType = ContentType.MEDIA;
|
|
||||||
|
|
||||||
override val isLive: Boolean = false;
|
override val isLive: Boolean = false;
|
||||||
|
|
||||||
@ -44,6 +46,7 @@ open class SerializedPlatformVideo(
|
|||||||
companion object {
|
companion object {
|
||||||
fun fromVideo(video: IPlatformVideo) : SerializedPlatformVideo {
|
fun fromVideo(video: IPlatformVideo) : SerializedPlatformVideo {
|
||||||
return SerializedPlatformVideo(
|
return SerializedPlatformVideo(
|
||||||
|
ContentType.MEDIA,
|
||||||
video.id,
|
video.id,
|
||||||
video.name,
|
video.name,
|
||||||
video.thumbnails,
|
video.thumbnails,
|
||||||
|
@ -10,6 +10,7 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
|||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.IPlatformClient
|
import com.futo.platformplayer.api.media.IPlatformClient
|
||||||
import com.futo.platformplayer.api.media.PlatformClientCapabilities
|
import com.futo.platformplayer.api.media.PlatformClientCapabilities
|
||||||
|
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||||
@ -31,6 +32,7 @@ import com.futo.platformplayer.api.media.platforms.js.internal.JSParameterDocs
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.models.IJSContent
|
import com.futo.platformplayer.api.media.platforms.js.models.IJSContent
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
|
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChannel
|
import com.futo.platformplayer.api.media.platforms.js.models.JSChannel
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelContentPager
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager
|
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChapter
|
import com.futo.platformplayer.api.media.platforms.js.models.JSChapter
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.JSComment
|
import com.futo.platformplayer.api.media.platforms.js.models.JSComment
|
||||||
@ -361,6 +363,10 @@ open class JSClient : IPlatformClient {
|
|||||||
return@isBusyWith JSChannelPager(config, this,
|
return@isBusyWith JSChannelPager(config, this,
|
||||||
plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"));
|
plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"));
|
||||||
}
|
}
|
||||||
|
override fun searchChannelsAsContent(query: String): IPager<IPlatformContent> = isBusyWith("searchChannels") {
|
||||||
|
ensureEnabled();
|
||||||
|
return@isBusyWith JSChannelContentPager(config, this, plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"), );
|
||||||
|
}
|
||||||
|
|
||||||
@JSDocs(6, "source.isChannelUrl(url)", "Validates if an channel url is for this platform")
|
@JSDocs(6, "source.isChannelUrl(url)", "Validates if an channel url is for this platform")
|
||||||
@JSDocsParameter("url", "A channel url (May not be your platform)")
|
@JSDocsParameter("url", "A channel url (May not be your platform)")
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package com.futo.platformplayer.api.media.platforms.js.models
|
package com.futo.platformplayer.api.media.platforms.js.models
|
||||||
|
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
|
import com.futo.platformplayer.api.media.models.JSChannelContent
|
||||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
@ -26,6 +27,7 @@ interface IJSContent: IPlatformContent {
|
|||||||
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
|
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
|
||||||
ContentType.PLAYLIST -> JSPlaylist(config, obj);
|
ContentType.PLAYLIST -> JSPlaylist(config, obj);
|
||||||
ContentType.LOCKED -> JSLockedContent(config, obj);
|
ContentType.LOCKED -> JSLockedContent(config, obj);
|
||||||
|
ContentType.CHANNEL -> JSChannelContent(config, obj)
|
||||||
else -> throw NotImplementedError("Unknown content type ${type}");
|
else -> throw NotImplementedError("Unknown content type ${type}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,6 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.engine.V8Plugin
|
|
||||||
|
|
||||||
class JSChannelPager : JSPager<PlatformAuthorLink>, IPager<PlatformAuthorLink> {
|
class JSChannelPager : JSPager<PlatformAuthorLink>, IPager<PlatformAuthorLink> {
|
||||||
|
|
||||||
|
@ -49,8 +49,8 @@ open class JSContent : IPlatformContent, IPluginSourced {
|
|||||||
else
|
else
|
||||||
author = PlatformAuthorLink.UNKNOWN;
|
author = PlatformAuthorLink.UNKNOWN;
|
||||||
|
|
||||||
val datetimeInt = _content.getOrThrow<Int>(config, "datetime", contextName).toLong();
|
val datetimeInt = _content.getOrDefault<Int>(config, "datetime", contextName, null)?.toLong();
|
||||||
if(datetimeInt == 0.toLong())
|
if(datetimeInt == null || datetimeInt == 0.toLong())
|
||||||
datetime = null;
|
datetime = null;
|
||||||
else
|
else
|
||||||
datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
||||||
|
@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js.models
|
|||||||
|
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.api.media.IPluginSourced
|
import com.futo.platformplayer.api.media.IPluginSourced
|
||||||
|
import com.futo.platformplayer.api.media.models.JSChannelContent
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
@ -16,3 +17,13 @@ class JSContentPager : JSPager<IPlatformContent>, IPluginSourced {
|
|||||||
return IJSContent.fromV8(plugin, obj);
|
return IJSContent.fromV8(plugin, obj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class JSChannelContentPager : JSPager<IPlatformContent>, IPluginSourced {
|
||||||
|
override val sourceConfig: SourcePluginConfig get() = config;
|
||||||
|
|
||||||
|
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {}
|
||||||
|
|
||||||
|
override fun convertResult(obj: V8ValueObject): IPlatformContent {
|
||||||
|
return JSChannelContent(config, obj);
|
||||||
|
}
|
||||||
|
}
|
@ -21,6 +21,8 @@ open class JSAudioUrlSource : IAudioUrlSource, JSSource {
|
|||||||
|
|
||||||
override var priority: Boolean = false;
|
override var priority: Boolean = false;
|
||||||
|
|
||||||
|
override var original: Boolean = false;
|
||||||
|
|
||||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_AUDIOURL, plugin, obj) {
|
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_AUDIOURL, plugin, obj) {
|
||||||
val contextName = "AudioUrlSource";
|
val contextName = "AudioUrlSource";
|
||||||
val config = plugin.config;
|
val config = plugin.config;
|
||||||
@ -35,6 +37,7 @@ open class JSAudioUrlSource : IAudioUrlSource, JSSource {
|
|||||||
name = _obj.getOrDefault(config, "name", contextName, "${container} ${bitrate}") ?: "${container} ${bitrate}";
|
name = _obj.getOrDefault(config, "name", contextName, "${container} ${bitrate}") ?: "${container} ${bitrate}";
|
||||||
|
|
||||||
priority = if(_obj.has("priority")) obj.getOrThrow(config, "priority", contextName) else false;
|
priority = if(_obj.has("priority")) obj.getOrThrow(config, "priority", contextName) else false;
|
||||||
|
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getAudioUrl() : String {
|
override fun getAudioUrl() : String {
|
||||||
|
@ -23,6 +23,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
|||||||
override val bitrate: Int;
|
override val bitrate: Int;
|
||||||
override val duration: Long;
|
override val duration: Long;
|
||||||
override val priority: Boolean;
|
override val priority: Boolean;
|
||||||
|
override var original: Boolean = false;
|
||||||
|
|
||||||
override val language: String;
|
override val language: String;
|
||||||
|
|
||||||
@ -45,6 +46,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
|||||||
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
|
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
|
||||||
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
|
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
|
||||||
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
|
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
|
||||||
|
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
|
||||||
hasGenerate = _obj.has("generate");
|
hasGenerate = _obj.has("generate");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,6 +21,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
|
|||||||
override val language: String;
|
override val language: String;
|
||||||
|
|
||||||
override var priority: Boolean = false;
|
override var priority: Boolean = false;
|
||||||
|
override var original: Boolean = false;
|
||||||
|
|
||||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
|
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
|
||||||
val contextName = "HLSAudioSource";
|
val contextName = "HLSAudioSource";
|
||||||
@ -32,6 +33,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
|
|||||||
language = _obj.getOrThrow(config, "language", contextName);
|
language = _obj.getOrThrow(config, "language", contextName);
|
||||||
|
|
||||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||||
|
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
package com.futo.platformplayer.api.media.platforms.local
|
||||||
|
|
||||||
|
class LocalClient {
|
||||||
|
//TODO
|
||||||
|
}
|
@ -0,0 +1,85 @@
|
|||||||
|
package com.futo.platformplayer.api.media.platforms.local.models
|
||||||
|
|
||||||
|
import com.futo.platformplayer.api.media.IPlatformClient
|
||||||
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
|
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.playback.IPlaybackTracker
|
||||||
|
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||||
|
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||||
|
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||||
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||||
|
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
|
||||||
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
|
import com.futo.platformplayer.downloads.VideoLocal
|
||||||
|
import java.io.File
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
class LocalVideoDetails: IPlatformVideoDetails {
|
||||||
|
|
||||||
|
override val contentType: ContentType get() = ContentType.UNKNOWN;
|
||||||
|
|
||||||
|
override val id: PlatformID;
|
||||||
|
override val name: String;
|
||||||
|
override val author: PlatformAuthorLink;
|
||||||
|
|
||||||
|
override val datetime: OffsetDateTime?;
|
||||||
|
|
||||||
|
override val url: String;
|
||||||
|
override val shareUrl: String;
|
||||||
|
override val rating: IRating = RatingLikes(0);
|
||||||
|
override val description: String = "";
|
||||||
|
|
||||||
|
override val video: IVideoSourceDescriptor;
|
||||||
|
override val preview: IVideoSourceDescriptor? = null;
|
||||||
|
override val live: IVideoSource? = null;
|
||||||
|
override val dash: IDashManifestSource? = null;
|
||||||
|
override val hls: IHLSManifestSource? = null;
|
||||||
|
override val subtitles: List<ISubtitleSource> = listOf()
|
||||||
|
|
||||||
|
override val thumbnails: Thumbnails;
|
||||||
|
override val duration: Long;
|
||||||
|
override val viewCount: Long = 0;
|
||||||
|
override val isLive: Boolean = false;
|
||||||
|
override val isShort: Boolean = false;
|
||||||
|
|
||||||
|
constructor(file: File) {
|
||||||
|
id = PlatformID("Local", file.path, "LOCAL")
|
||||||
|
name = file.name;
|
||||||
|
author = PlatformAuthorLink.UNKNOWN;
|
||||||
|
|
||||||
|
url = file.canonicalPath;
|
||||||
|
shareUrl = "";
|
||||||
|
|
||||||
|
duration = 0;
|
||||||
|
thumbnails = Thumbnails(arrayOf());
|
||||||
|
|
||||||
|
datetime = OffsetDateTime.ofInstant(
|
||||||
|
Instant.ofEpochMilli(file.lastModified()),
|
||||||
|
ZoneId.systemDefault()
|
||||||
|
);
|
||||||
|
video = LocalVideoMuxedSourceDescriptor(LocalVideoFileSource(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPlaybackTracker(): IPlaybackTracker? {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
package com.futo.platformplayer.api.media.platforms.local.models
|
||||||
|
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||||
|
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
|
||||||
|
import com.futo.platformplayer.downloads.VideoLocal
|
||||||
|
|
||||||
|
class LocalVideoMuxedSourceDescriptor(
|
||||||
|
private val video: LocalVideoFileSource
|
||||||
|
) : VideoMuxedSourceDescriptor() {
|
||||||
|
override val videoSources: Array<IVideoSource> get() = arrayOf(video);
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
package com.futo.platformplayer.api.media.platforms.local.models
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.provider.MediaStore.Video
|
||||||
|
|
||||||
|
class MediaStoreVideo {
|
||||||
|
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val URI = MediaStore.Files.getContentUri("external");
|
||||||
|
val PROJECTION = arrayOf(Video.Media._ID, Video.Media.TITLE, Video.Media.DURATION, Video.Media.HEIGHT, Video.Media.WIDTH, Video.Media.MIME_TYPE);
|
||||||
|
val ORDER = MediaStore.Video.Media.TITLE;
|
||||||
|
|
||||||
|
fun readMediaStoreVideo(cursor: Cursor) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun query(context: Context, selection: String, args: Array<String>, order: String? = null): Cursor? {
|
||||||
|
val cursor = context.contentResolver.query(URI, PROJECTION, selection, args, order ?: ORDER, null);
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
package com.futo.platformplayer.api.media.platforms.local.models.sources
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.provider.MediaStore.Video
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||||
|
import com.futo.platformplayer.helpers.VideoHelper
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class LocalVideoFileSource: IVideoSource {
|
||||||
|
|
||||||
|
|
||||||
|
override val name: String;
|
||||||
|
override val width: Int;
|
||||||
|
override val height: Int;
|
||||||
|
override val container: String;
|
||||||
|
override val codec: String = ""
|
||||||
|
override val bitrate: Int = 0
|
||||||
|
override val duration: Long;
|
||||||
|
override val priority: Boolean = false;
|
||||||
|
|
||||||
|
constructor(file: File) {
|
||||||
|
name = file.name;
|
||||||
|
width = 0;
|
||||||
|
height = 0;
|
||||||
|
container = VideoHelper.videoExtensionToMimetype(file.extension) ?: "";
|
||||||
|
duration = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -6,7 +6,7 @@ import com.futo.platformplayer.constructs.Event1
|
|||||||
* A RefreshPager represents a pager that can be modified overtime (eg. By getting more results later, by recreating the pager)
|
* A RefreshPager represents a pager that can be modified overtime (eg. By getting more results later, by recreating the pager)
|
||||||
* When the onPagerChanged event is emitted, a new pager instance is passed, or requested via getCurrentPager
|
* When the onPagerChanged event is emitted, a new pager instance is passed, or requested via getCurrentPager
|
||||||
*/
|
*/
|
||||||
interface IRefreshPager<T> {
|
interface IRefreshPager<T>: IPager<T> {
|
||||||
val onPagerChanged: Event1<IPager<T>>;
|
val onPagerChanged: Event1<IPager<T>>;
|
||||||
val onPagerError: Event1<Throwable>;
|
val onPagerError: Event1<Throwable>;
|
||||||
|
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
package com.futo.platformplayer.api.media.structures
|
package com.futo.platformplayer.api.media.structures
|
||||||
|
|
||||||
|
import com.futo.platformplayer.api.media.structures.ReusablePager.Window
|
||||||
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -9,8 +11,8 @@ import com.futo.platformplayer.logging.Logger
|
|||||||
* A "Window" is effectively a pager that just reads previous results from the shared results, but when the end is reached, it will call nextPage on the parent if possible for new results.
|
* A "Window" is effectively a pager that just reads previous results from the shared results, but when the end is reached, it will call nextPage on the parent if possible for new results.
|
||||||
* This allows multiple Windows to exist of the same pager, without messing with position, or duplicate requests
|
* This allows multiple Windows to exist of the same pager, without messing with position, or duplicate requests
|
||||||
*/
|
*/
|
||||||
class ReusablePager<T>: INestedPager<T>, IPager<T> {
|
open class ReusablePager<T>: INestedPager<T>, IReusablePager<T> {
|
||||||
private val _pager: IPager<T>;
|
protected var _pager: IPager<T>;
|
||||||
val previousResults = arrayListOf<T>();
|
val previousResults = arrayListOf<T>();
|
||||||
|
|
||||||
constructor(subPager: IPager<T>) {
|
constructor(subPager: IPager<T>) {
|
||||||
@ -44,7 +46,7 @@ class ReusablePager<T>: INestedPager<T>, IPager<T> {
|
|||||||
return previousResults;
|
return previousResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getWindow(): Window<T> {
|
override fun getWindow(): Window<T> {
|
||||||
return Window(this);
|
return Window(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,3 +98,117 @@ class ReusablePager<T>: INestedPager<T>, IPager<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public class ReusableRefreshPager<T>: INestedPager<T>, IReusablePager<T> {
|
||||||
|
protected var _pager: IRefreshPager<T>;
|
||||||
|
val previousResults = arrayListOf<T>();
|
||||||
|
|
||||||
|
private var _currentPage: IPager<T>;
|
||||||
|
|
||||||
|
|
||||||
|
val onPagerChanged = Event1<IPager<T>>()
|
||||||
|
val onPagerError = Event1<Throwable>()
|
||||||
|
|
||||||
|
constructor(subPager: IRefreshPager<T>) {
|
||||||
|
this._pager = subPager;
|
||||||
|
_currentPage = this;
|
||||||
|
synchronized(previousResults) {
|
||||||
|
previousResults.addAll(subPager.getResults());
|
||||||
|
}
|
||||||
|
_pager.onPagerError.subscribe(onPagerError::emit);
|
||||||
|
_pager.onPagerChanged.subscribe {
|
||||||
|
_currentPage = it;
|
||||||
|
synchronized(previousResults) {
|
||||||
|
previousResults.clear();
|
||||||
|
previousResults.addAll(it.getResults());
|
||||||
|
}
|
||||||
|
|
||||||
|
onPagerChanged.emit(_currentPage);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findPager(query: (IPager<T>) -> Boolean): IPager<T>? {
|
||||||
|
if(query(_pager))
|
||||||
|
return _pager;
|
||||||
|
else if(_pager is INestedPager<*>)
|
||||||
|
return (_pager as INestedPager<T>).findPager(query);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hasMorePages(): Boolean {
|
||||||
|
return _pager.hasMorePages();
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun nextPage() {
|
||||||
|
_pager.nextPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getResults(): List<T> {
|
||||||
|
val results = _pager.getResults();
|
||||||
|
synchronized(previousResults) {
|
||||||
|
previousResults.addAll(results);
|
||||||
|
}
|
||||||
|
return previousResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getWindow(): RefreshWindow<T> {
|
||||||
|
return RefreshWindow(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshWindow<T>: IPager<T>, INestedPager<T>, IRefreshPager<T> {
|
||||||
|
private val _parent: ReusableRefreshPager<T>;
|
||||||
|
private var _position: Int = 0;
|
||||||
|
private var _read: Int = 0;
|
||||||
|
|
||||||
|
private var _currentResults: List<T>;
|
||||||
|
|
||||||
|
override val onPagerChanged = Event1<IPager<T>>();
|
||||||
|
override val onPagerError = Event1<Throwable>();
|
||||||
|
|
||||||
|
|
||||||
|
override fun getCurrentPager(): IPager<T> {
|
||||||
|
return _parent.getWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(parent: ReusableRefreshPager<T>) {
|
||||||
|
_parent = parent;
|
||||||
|
|
||||||
|
synchronized(_parent.previousResults) {
|
||||||
|
_currentResults = _parent.previousResults.toList();
|
||||||
|
_read += _currentResults.size;
|
||||||
|
}
|
||||||
|
parent.onPagerChanged.subscribe(onPagerChanged::emit);
|
||||||
|
parent.onPagerError.subscribe(onPagerError::emit);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override fun hasMorePages(): Boolean {
|
||||||
|
return _parent.previousResults.size > _read || _parent.hasMorePages();
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun nextPage() {
|
||||||
|
synchronized(_parent.previousResults) {
|
||||||
|
if (_parent.previousResults.size <= _read) {
|
||||||
|
_parent.nextPage();
|
||||||
|
_parent.getResults();
|
||||||
|
}
|
||||||
|
_currentResults = _parent.previousResults.drop(_read).toList();
|
||||||
|
_read += _currentResults.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getResults(): List<T> {
|
||||||
|
return _currentResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findPager(query: (IPager<T>) -> Boolean): IPager<T>? {
|
||||||
|
return _parent.findPager(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IReusablePager<T>: IPager<T> {
|
||||||
|
fun getWindow(): IPager<T>;
|
||||||
|
}
|
@ -3,6 +3,7 @@ package com.futo.platformplayer.casting
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
||||||
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
||||||
@ -32,6 +33,7 @@ import java.io.IOException
|
|||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
|
import java.net.Inet4Address
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
@ -90,7 +92,7 @@ class FCastCastingDevice : CastingDevice {
|
|||||||
private var _version: Long = 1;
|
private var _version: Long = 1;
|
||||||
private var _thread: Thread? = null
|
private var _thread: Thread? = null
|
||||||
private var _pingThread: Thread? = null
|
private var _pingThread: Thread? = null
|
||||||
private var _lastPongTime = -1L
|
@Volatile private var _lastPongTime = System.currentTimeMillis()
|
||||||
private var _outputStreamLock = Object()
|
private var _outputStreamLock = Object()
|
||||||
|
|
||||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||||
@ -324,9 +326,9 @@ class FCastCastingDevice : CastingDevice {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
localAddress = _socket?.localAddress;
|
localAddress = _socket?.localAddress
|
||||||
connectionState = CastConnectionState.CONNECTED;
|
_lastPongTime = System.currentTimeMillis()
|
||||||
_lastPongTime = -1L
|
connectionState = CastConnectionState.CONNECTED
|
||||||
|
|
||||||
val buffer = ByteArray(4096);
|
val buffer = ByteArray(4096);
|
||||||
|
|
||||||
@ -402,13 +404,20 @@ class FCastCastingDevice : CastingDevice {
|
|||||||
|
|
||||||
_pingThread = Thread {
|
_pingThread = Thread {
|
||||||
Logger.i(TAG, "Started ping loop.")
|
Logger.i(TAG, "Started ping loop.")
|
||||||
|
|
||||||
while (_scopeIO?.isActive == true) {
|
while (_scopeIO?.isActive == true) {
|
||||||
|
if (connectionState == CastConnectionState.CONNECTED) {
|
||||||
try {
|
try {
|
||||||
send(Opcode.Ping)
|
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) {
|
} catch (e: Throwable) {
|
||||||
Log.w(TAG, "Failed to send ping.")
|
Log.w(TAG, "Failed to send ping.")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_socket?.close()
|
_socket?.close()
|
||||||
_inputStream?.close()
|
_inputStream?.close()
|
||||||
@ -417,21 +426,10 @@ class FCastCastingDevice : CastingDevice {
|
|||||||
Log.w(TAG, "Failed to close socket.", e)
|
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(5000)
|
||||||
|
|
||||||
Thread.sleep(2000)
|
|
||||||
}
|
}
|
||||||
|
Logger.i(TAG, "Stopped ping loop.")
|
||||||
Logger.i(TAG, "Stopped ping loop.");
|
|
||||||
}.apply { start() }
|
}.apply { start() }
|
||||||
} else {
|
} else {
|
||||||
Log.i(TAG, "Thread was still alive, not restarted")
|
Log.i(TAG, "Thread was still alive, not restarted")
|
||||||
|
@ -1,14 +1,18 @@
|
|||||||
package com.futo.platformplayer.casting
|
package com.futo.platformplayer.casting
|
||||||
|
|
||||||
|
import android.app.AlertDialog
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.net.nsd.NsdManager
|
||||||
|
import android.net.nsd.NsdServiceInfo
|
||||||
|
import android.os.Build
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.Xml
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
@ -38,8 +42,6 @@ import com.futo.platformplayer.constructs.Event1
|
|||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.mdns.DnsService
|
|
||||||
import com.futo.platformplayer.mdns.ServiceDiscoverer
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
import com.futo.platformplayer.parsers.HLS
|
import com.futo.platformplayer.parsers.HLS
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
@ -53,7 +55,6 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
@ -68,7 +69,6 @@ class StateCasting {
|
|||||||
private var _started = false;
|
private var _started = false;
|
||||||
|
|
||||||
var devices: HashMap<String, CastingDevice> = hashMapOf();
|
var devices: HashMap<String, CastingDevice> = hashMapOf();
|
||||||
var rememberedDevices: ArrayList<CastingDevice> = arrayListOf();
|
|
||||||
val onDeviceAdded = Event1<CastingDevice>();
|
val onDeviceAdded = Event1<CastingDevice>();
|
||||||
val onDeviceChanged = Event1<CastingDevice>();
|
val onDeviceChanged = Event1<CastingDevice>();
|
||||||
val onDeviceRemoved = Event1<CastingDevice>();
|
val onDeviceRemoved = Event1<CastingDevice>();
|
||||||
@ -82,48 +82,15 @@ class StateCasting {
|
|||||||
private var _audioExecutor: JSRequestExecutor? = null
|
private var _audioExecutor: JSRequestExecutor? = null
|
||||||
private val _client = ManagedHttpClient();
|
private val _client = ManagedHttpClient();
|
||||||
var _resumeCastingDevice: CastingDeviceInfo? = null;
|
var _resumeCastingDevice: CastingDeviceInfo? = null;
|
||||||
val _serviceDiscoverer = ServiceDiscoverer(arrayOf(
|
private var _nsdManager: NsdManager? = null
|
||||||
"_googlecast._tcp.local",
|
|
||||||
"_airplay._tcp.local",
|
|
||||||
"_fastcast._tcp.local",
|
|
||||||
"_fcast._tcp.local"
|
|
||||||
)) { handleServiceUpdated(it) }
|
|
||||||
|
|
||||||
val isCasting: Boolean get() = activeDevice != null;
|
val isCasting: Boolean get() = activeDevice != null;
|
||||||
|
|
||||||
private fun handleServiceUpdated(services: List<DnsService>) {
|
private val _discoveryListeners = mapOf(
|
||||||
for (s in services) {
|
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
|
||||||
//TODO: Addresses IPv4 only?
|
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
|
||||||
val addresses = s.addresses.toTypedArray()
|
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
|
||||||
val port = s.port.toInt()
|
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
|
||||||
var name = s.texts.firstOrNull { it.startsWith("md=") }?.substring("md=".length)
|
)
|
||||||
if (s.name.endsWith("._googlecast._tcp.local")) {
|
|
||||||
if (name == null) {
|
|
||||||
name = s.name.substring(0, s.name.length - "._googlecast._tcp.local".length)
|
|
||||||
}
|
|
||||||
|
|
||||||
addOrUpdateChromeCastDevice(name, addresses, port)
|
|
||||||
} else if (s.name.endsWith("._airplay._tcp.local")) {
|
|
||||||
if (name == null) {
|
|
||||||
name = s.name.substring(0, s.name.length - "._airplay._tcp.local".length)
|
|
||||||
}
|
|
||||||
|
|
||||||
addOrUpdateAirPlayDevice(name, addresses, port)
|
|
||||||
} else if (s.name.endsWith("._fastcast._tcp.local")) {
|
|
||||||
if (name == null) {
|
|
||||||
name = s.name.substring(0, s.name.length - "._fastcast._tcp.local".length)
|
|
||||||
}
|
|
||||||
|
|
||||||
addOrUpdateFastCastDevice(name, addresses, port)
|
|
||||||
} else if (s.name.endsWith("._fcast._tcp.local")) {
|
|
||||||
if (name == null) {
|
|
||||||
name = s.name.substring(0, s.name.length - "._fcast._tcp.local".length)
|
|
||||||
}
|
|
||||||
|
|
||||||
addOrUpdateFastCastDevice(name, addresses, port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleUrl(context: Context, url: String) {
|
fun handleUrl(context: Context, url: String) {
|
||||||
val uri = Uri.parse(url)
|
val uri = Uri.parse(url)
|
||||||
@ -188,30 +155,29 @@ class StateCasting {
|
|||||||
|
|
||||||
Logger.i(TAG, "CastingService starting...");
|
Logger.i(TAG, "CastingService starting...");
|
||||||
|
|
||||||
rememberedDevices.clear();
|
|
||||||
rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) });
|
|
||||||
|
|
||||||
_castServer.start();
|
_castServer.start();
|
||||||
enableDeveloper(true);
|
enableDeveloper(true);
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService started.");
|
Logger.i(TAG, "CastingService started.");
|
||||||
|
|
||||||
|
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun startDiscovering() {
|
fun startDiscovering() {
|
||||||
try {
|
_nsdManager?.apply {
|
||||||
_serviceDiscoverer.start()
|
_discoveryListeners.forEach {
|
||||||
} catch (e: Throwable) {
|
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
|
||||||
Logger.i(TAG, "Failed to start ServiceDiscoverer", e)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun stopDiscovering() {
|
fun stopDiscovering() {
|
||||||
try {
|
_nsdManager?.apply {
|
||||||
_serviceDiscoverer.stop()
|
_discoveryListeners.forEach {
|
||||||
} catch (e: Throwable) {
|
stopServiceDiscovery(it.value)
|
||||||
Logger.i(TAG, "Failed to stop ServiceDiscoverer", e)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,8 +203,82 @@ class StateCasting {
|
|||||||
_castServer.removeAllHandlers();
|
_castServer.removeAllHandlers();
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService stopped.")
|
Logger.i(TAG, "CastingService stopped.")
|
||||||
|
|
||||||
|
_nsdManager = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
|
||||||
|
return object : NsdManager.DiscoveryListener {
|
||||||
|
override fun onDiscoveryStarted(regType: String) {
|
||||||
|
Log.d(TAG, "Service discovery started for $regType")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDiscoveryStopped(serviceType: String) {
|
||||||
|
Log.i(TAG, "Discovery stopped: $serviceType")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceLost(service: NsdServiceInfo) {
|
||||||
|
Log.e(TAG, "service lost: $service")
|
||||||
|
// TODO: Handle service lost, e.g., remove device
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||||
|
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
|
||||||
|
_nsdManager?.stopServiceDiscovery(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||||
|
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
|
||||||
|
_nsdManager?.stopServiceDiscovery(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceFound(service: NsdServiceInfo) {
|
||||||
|
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
|
||||||
|
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
service.hostAddresses.toTypedArray()
|
||||||
|
} else {
|
||||||
|
arrayOf(service.host)
|
||||||
|
}
|
||||||
|
addOrUpdate(service.serviceName, addresses, service.port)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
_nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback {
|
||||||
|
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
|
||||||
|
Log.v(TAG, "onServiceUpdated: $serviceInfo")
|
||||||
|
addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceLost() {
|
||||||
|
Log.v(TAG, "onServiceLost: $service")
|
||||||
|
// TODO: Handle service lost
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
||||||
|
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceInfoCallbackUnregistered() {
|
||||||
|
Log.v(TAG, "onServiceInfoCallbackUnregistered")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
|
||||||
|
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||||
|
Log.v(TAG, "Resolve failed: $errorCode")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||||
|
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
|
||||||
|
addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _castingDialogLock = Any();
|
||||||
|
private var _currentDialog: AlertDialog? = null;
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun connectDevice(device: CastingDevice) {
|
fun connectDevice(device: CastingDevice) {
|
||||||
if (activeDevice == device)
|
if (activeDevice == device)
|
||||||
@ -272,10 +312,41 @@ class StateCasting {
|
|||||||
invokeInMainScopeIfRequired {
|
invokeInMainScopeIfRequired {
|
||||||
StateApp.withContext(false) { context ->
|
StateApp.withContext(false) { context ->
|
||||||
context.let {
|
context.let {
|
||||||
|
Logger.i(TAG, "Casting state changed to ${castConnectionState}");
|
||||||
when (castConnectionState) {
|
when (castConnectionState) {
|
||||||
CastConnectionState.CONNECTED -> UIDialogs.toast(it, "Connected to device")
|
CastConnectionState.CONNECTED -> {
|
||||||
CastConnectionState.CONNECTING -> UIDialogs.toast(it, "Connecting to device...")
|
Logger.i(TAG, "Casting connected to [${device.name}]");
|
||||||
CastConnectionState.DISCONNECTED -> UIDialogs.toast(it, "Disconnected from device")
|
UIDialogs.appToast("Connected to device")
|
||||||
|
synchronized(_castingDialogLock) {
|
||||||
|
if(_currentDialog != null) {
|
||||||
|
_currentDialog?.hide();
|
||||||
|
_currentDialog = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CastConnectionState.CONNECTING -> {
|
||||||
|
Logger.i(TAG, "Casting connecting to [${device.name}]");
|
||||||
|
UIDialogs.toast(it, "Connecting to device...")
|
||||||
|
synchronized(_castingDialogLock) {
|
||||||
|
if(_currentDialog == null) {
|
||||||
|
_currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true,
|
||||||
|
"Connecting to [${device.name}]",
|
||||||
|
"Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2,
|
||||||
|
UIDialogs.Action("Disconnect", {
|
||||||
|
device.stop();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CastConnectionState.DISCONNECTED -> {
|
||||||
|
UIDialogs.toast(it, "Disconnected from device")
|
||||||
|
synchronized(_castingDialogLock) {
|
||||||
|
if(_currentDialog != null) {
|
||||||
|
_currentDialog?.hide();
|
||||||
|
_currentDialog = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -295,9 +366,6 @@ class StateCasting {
|
|||||||
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
|
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
|
||||||
};
|
};
|
||||||
|
|
||||||
addRememberedDevice(device);
|
|
||||||
Logger.i(TAG, "Device added to active discovery. Active discovery now contains ${_storage.getDevicesCount()} devices.")
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
device.start();
|
device.start();
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@ -319,21 +387,22 @@ class StateCasting {
|
|||||||
return addRememberedDevice(device);
|
return addRememberedDevice(device);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
|
fun getRememberedCastingDevices(): List<CastingDevice> {
|
||||||
val deviceInfo = device.getDeviceInfo()
|
return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) }
|
||||||
val foundInfo = _storage.addDevice(deviceInfo)
|
|
||||||
if (foundInfo == deviceInfo) {
|
|
||||||
rememberedDevices.add(device);
|
|
||||||
return foundInfo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return foundInfo;
|
fun getRememberedCastingDeviceNames(): List<String> {
|
||||||
|
return _storage.getDeviceNames()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
|
||||||
|
val deviceInfo = device.getDeviceInfo()
|
||||||
|
return _storage.addDevice(deviceInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeRememberedDevice(device: CastingDevice) {
|
fun removeRememberedDevice(device: CastingDevice) {
|
||||||
val name = device.name ?: return;
|
val name = device.name ?: return
|
||||||
_storage.removeDevice(name);
|
_storage.removeDevice(name)
|
||||||
rememberedDevices.remove(device);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun invokeInMainScopeIfRequired(action: () -> Unit){
|
private fun invokeInMainScopeIfRequired(action: () -> Unit){
|
||||||
|
@ -22,7 +22,6 @@ import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComm
|
|||||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.dp
|
import com.futo.platformplayer.dp
|
||||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.selectBestImage
|
import com.futo.platformplayer.selectBestImage
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
@ -30,6 +29,7 @@ import com.futo.platformplayer.states.StatePolycentric
|
|||||||
import com.futo.polycentric.core.ClaimType
|
import com.futo.polycentric.core.ClaimType
|
||||||
import com.futo.polycentric.core.Store
|
import com.futo.polycentric.core.Store
|
||||||
import com.futo.polycentric.core.SystemState
|
import com.futo.polycentric.core.SystemState
|
||||||
|
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
|
||||||
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
|
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
|
||||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
|
@ -9,7 +9,9 @@ import android.view.View
|
|||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
@ -21,22 +23,21 @@ import com.futo.platformplayer.casting.StateCasting
|
|||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.views.adapters.DeviceAdapter
|
import com.futo.platformplayer.views.adapters.DeviceAdapter
|
||||||
|
import com.futo.platformplayer.views.adapters.DeviceAdapterEntry
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||||
private lateinit var _imageLoader: ImageView;
|
private lateinit var _imageLoader: ImageView;
|
||||||
private lateinit var _buttonClose: Button;
|
private lateinit var _buttonClose: Button;
|
||||||
private lateinit var _buttonAdd: ImageButton;
|
private lateinit var _buttonAdd: LinearLayout;
|
||||||
private lateinit var _buttonScanQR: ImageButton;
|
private lateinit var _buttonScanQR: LinearLayout;
|
||||||
private lateinit var _textNoDevicesFound: TextView;
|
private lateinit var _textNoDevicesFound: TextView;
|
||||||
private lateinit var _textNoDevicesRemembered: TextView;
|
|
||||||
private lateinit var _recyclerDevices: RecyclerView;
|
private lateinit var _recyclerDevices: RecyclerView;
|
||||||
private lateinit var _recyclerRememberedDevices: RecyclerView;
|
|
||||||
private lateinit var _adapter: DeviceAdapter;
|
private lateinit var _adapter: DeviceAdapter;
|
||||||
private lateinit var _rememberedAdapter: DeviceAdapter;
|
private val _devices: MutableSet<String> = mutableSetOf()
|
||||||
private val _devices: ArrayList<CastingDevice> = arrayListOf();
|
private val _rememberedDevices: MutableSet<String> = mutableSetOf()
|
||||||
private val _rememberedDevices: ArrayList<CastingDevice> = arrayListOf();
|
private val _unifiedDevices: MutableList<DeviceAdapterEntry> = mutableListOf()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
@ -45,42 +46,40 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
_imageLoader = findViewById(R.id.image_loader);
|
_imageLoader = findViewById(R.id.image_loader);
|
||||||
_buttonClose = findViewById(R.id.button_close);
|
_buttonClose = findViewById(R.id.button_close);
|
||||||
_buttonAdd = findViewById(R.id.button_add);
|
_buttonAdd = findViewById(R.id.button_add);
|
||||||
_buttonScanQR = findViewById(R.id.button_scan_qr);
|
_buttonScanQR = findViewById(R.id.button_qr);
|
||||||
_recyclerDevices = findViewById(R.id.recycler_devices);
|
_recyclerDevices = findViewById(R.id.recycler_devices);
|
||||||
_recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices);
|
|
||||||
_textNoDevicesFound = findViewById(R.id.text_no_devices_found);
|
_textNoDevicesFound = findViewById(R.id.text_no_devices_found);
|
||||||
_textNoDevicesRemembered = findViewById(R.id.text_no_devices_remembered);
|
|
||||||
|
|
||||||
_adapter = DeviceAdapter(_devices, false);
|
_adapter = DeviceAdapter(_unifiedDevices)
|
||||||
_recyclerDevices.adapter = _adapter;
|
_recyclerDevices.adapter = _adapter;
|
||||||
_recyclerDevices.layoutManager = LinearLayoutManager(context);
|
_recyclerDevices.layoutManager = LinearLayoutManager(context);
|
||||||
|
|
||||||
_rememberedAdapter = DeviceAdapter(_rememberedDevices, true);
|
_adapter.onPin.subscribe { d ->
|
||||||
_rememberedAdapter.onRemove.subscribe { d ->
|
val isRemembered = _rememberedDevices.contains(d.name)
|
||||||
if (StateCasting.instance.activeDevice == d) {
|
val newIsRemembered = !isRemembered
|
||||||
d.stopCasting();
|
if (newIsRemembered) {
|
||||||
|
StateCasting.instance.addRememberedDevice(d)
|
||||||
|
val name = d.name
|
||||||
|
if (name != null) {
|
||||||
|
_rememberedDevices.add(name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
StateCasting.instance.removeRememberedDevice(d)
|
||||||
|
_rememberedDevices.remove(d.name)
|
||||||
|
}
|
||||||
|
updateUnifiedList()
|
||||||
}
|
}
|
||||||
|
|
||||||
StateCasting.instance.removeRememberedDevice(d);
|
//TODO: Integrate remembered into the main list
|
||||||
val index = _rememberedDevices.indexOf(d);
|
//TODO: Add green indicator to indicate a device is oneline
|
||||||
if (index != -1) {
|
//TODO: Add pinning
|
||||||
_rememberedDevices.removeAt(index);
|
//TODO: Implement QR code as an option in add manually
|
||||||
_rememberedAdapter.notifyItemRemoved(index);
|
//TODO: Remove start button
|
||||||
}
|
|
||||||
|
|
||||||
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
|
|
||||||
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
|
|
||||||
};
|
|
||||||
_rememberedAdapter.onConnect.subscribe { _ ->
|
|
||||||
dismiss()
|
|
||||||
UIDialogs.showCastingDialog(context)
|
|
||||||
}
|
|
||||||
_adapter.onConnect.subscribe { _ ->
|
_adapter.onConnect.subscribe { _ ->
|
||||||
dismiss()
|
dismiss()
|
||||||
UIDialogs.showCastingDialog(context)
|
//UIDialogs.showCastingDialog(context)
|
||||||
}
|
}
|
||||||
_recyclerRememberedDevices.adapter = _rememberedAdapter;
|
|
||||||
_recyclerRememberedDevices.layoutManager = LinearLayoutManager(context);
|
|
||||||
|
|
||||||
_buttonClose.setOnClickListener { dismiss(); };
|
_buttonClose.setOnClickListener { dismiss(); };
|
||||||
_buttonAdd.setOnClickListener {
|
_buttonAdd.setOnClickListener {
|
||||||
@ -105,77 +104,112 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
Logger.i(TAG, "Dialog shown.");
|
Logger.i(TAG, "Dialog shown.");
|
||||||
|
|
||||||
StateCasting.instance.startDiscovering()
|
StateCasting.instance.startDiscovering()
|
||||||
|
|
||||||
(_imageLoader.drawable as Animatable?)?.start();
|
(_imageLoader.drawable as Animatable?)?.start();
|
||||||
|
|
||||||
_devices.clear();
|
|
||||||
synchronized(StateCasting.instance.devices) {
|
synchronized(StateCasting.instance.devices) {
|
||||||
_devices.addAll(StateCasting.instance.devices.values);
|
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
|
||||||
}
|
}
|
||||||
|
|
||||||
_rememberedDevices.clear();
|
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
|
||||||
synchronized (StateCasting.instance.rememberedDevices) {
|
updateUnifiedList()
|
||||||
_rememberedDevices.addAll(StateCasting.instance.rememberedDevices);
|
|
||||||
}
|
|
||||||
|
|
||||||
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
|
|
||||||
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
|
|
||||||
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
|
|
||||||
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
|
|
||||||
|
|
||||||
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
|
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
|
||||||
_devices.add(d);
|
val name = d.name
|
||||||
_adapter.notifyItemInserted(_devices.size - 1);
|
if (name != null)
|
||||||
_textNoDevicesFound.visibility = View.GONE;
|
_devices.add(name)
|
||||||
_recyclerDevices.visibility = View.VISIBLE;
|
updateUnifiedList()
|
||||||
};
|
}
|
||||||
|
|
||||||
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
|
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
|
||||||
val index = _devices.indexOf(d);
|
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name }
|
||||||
if (index == -1) {
|
if (index != -1) {
|
||||||
return@subscribe;
|
_unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice)
|
||||||
|
_adapter.notifyItemChanged(index)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_devices[index] = d;
|
|
||||||
_adapter.notifyItemChanged(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
StateCasting.instance.onDeviceRemoved.subscribe(this) { d ->
|
StateCasting.instance.onDeviceRemoved.subscribe(this) { d ->
|
||||||
val index = _devices.indexOf(d);
|
_devices.remove(d.name)
|
||||||
if (index == -1) {
|
updateUnifiedList()
|
||||||
return@subscribe;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_devices.removeAt(index);
|
|
||||||
_adapter.notifyItemRemoved(index);
|
|
||||||
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
|
|
||||||
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
|
|
||||||
};
|
|
||||||
|
|
||||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
|
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
|
||||||
if (connectionState != CastConnectionState.CONNECTED) {
|
if (connectionState == CastConnectionState.CONNECTED) {
|
||||||
return@subscribe;
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
|
||||||
dismiss();
|
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
_adapter.notifyDataSetChanged();
|
|
||||||
_rememberedAdapter.notifyDataSetChanged();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun dismiss() {
|
override fun dismiss() {
|
||||||
super.dismiss();
|
super.dismiss()
|
||||||
|
(_imageLoader.drawable as Animatable?)?.stop()
|
||||||
(_imageLoader.drawable as Animatable?)?.stop();
|
|
||||||
|
|
||||||
StateCasting.instance.stopDiscovering()
|
StateCasting.instance.stopDiscovering()
|
||||||
StateCasting.instance.onDeviceAdded.remove(this);
|
StateCasting.instance.onDeviceAdded.remove(this)
|
||||||
StateCasting.instance.onDeviceChanged.remove(this);
|
StateCasting.instance.onDeviceChanged.remove(this)
|
||||||
StateCasting.instance.onDeviceRemoved.remove(this);
|
StateCasting.instance.onDeviceRemoved.remove(this)
|
||||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
|
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateUnifiedList() {
|
||||||
|
val oldList = ArrayList(_unifiedDevices)
|
||||||
|
val newList = buildUnifiedList()
|
||||||
|
|
||||||
|
val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||||
|
override fun getOldListSize() = oldList.size
|
||||||
|
override fun getNewListSize() = newList.size
|
||||||
|
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||||
|
val oldItem = oldList[oldItemPosition]
|
||||||
|
val newItem = newList[newItemPosition]
|
||||||
|
return oldItem.castingDevice.name == newItem.castingDevice.name
|
||||||
|
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
|
||||||
|
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
|
||||||
|
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
|
||||||
|
}
|
||||||
|
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||||
|
val oldItem = oldList[oldItemPosition]
|
||||||
|
val newItem = newList[newItemPosition]
|
||||||
|
return oldItem.castingDevice.name == newItem.castingDevice.name
|
||||||
|
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
|
||||||
|
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
|
||||||
|
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
_unifiedDevices.clear()
|
||||||
|
_unifiedDevices.addAll(newList)
|
||||||
|
diffResult.dispatchUpdatesTo(_adapter)
|
||||||
|
|
||||||
|
_textNoDevicesFound.visibility = if (_unifiedDevices.isEmpty()) View.VISIBLE else View.GONE
|
||||||
|
_recyclerDevices.visibility = if (_unifiedDevices.isNotEmpty()) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildUnifiedList(): List<DeviceAdapterEntry> {
|
||||||
|
val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name }
|
||||||
|
val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name }
|
||||||
|
|
||||||
|
val unifiedList = mutableListOf<DeviceAdapterEntry>()
|
||||||
|
|
||||||
|
val intersectionNames = _devices.intersect(_rememberedDevices)
|
||||||
|
for (name in intersectionNames) {
|
||||||
|
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, true)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val onlineOnlyNames = _devices - _rememberedDevices
|
||||||
|
for (name in onlineOnlyNames) {
|
||||||
|
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, false, true)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val rememberedOnlyNames = _rememberedDevices - _devices
|
||||||
|
for (name in rememberedOnlyNames) {
|
||||||
|
rememberedDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, false)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return unifiedList
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
@ -10,7 +10,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
|||||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||||
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||||
import com.futo.platformplayer.api.media.models.streams.LocalVideoMuxedSourceDescriptor
|
import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor
|
||||||
import com.futo.platformplayer.api.media.models.streams.LocalVideoUnMuxedSourceDescriptor
|
import com.futo.platformplayer.api.media.models.streams.LocalVideoUnMuxedSourceDescriptor
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||||
@ -57,7 +57,7 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem {
|
|||||||
override val video: IVideoSourceDescriptor get() = if(audioSource.isNotEmpty())
|
override val video: IVideoSourceDescriptor get() = if(audioSource.isNotEmpty())
|
||||||
LocalVideoUnMuxedSourceDescriptor(this)
|
LocalVideoUnMuxedSourceDescriptor(this)
|
||||||
else
|
else
|
||||||
LocalVideoMuxedSourceDescriptor(this);
|
DownloadedVideoMuxedSourceDescriptor(this);
|
||||||
override val preview: IVideoSourceDescriptor? get() = videoSerialized.preview;
|
override val preview: IVideoSourceDescriptor? get() = videoSerialized.preview;
|
||||||
|
|
||||||
override val live: IVideoSource? get() = videoSerialized.live;
|
override val live: IVideoSource? get() = videoSerialized.live;
|
||||||
|
@ -13,7 +13,6 @@ import com.futo.platformplayer.R
|
|||||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||||
import com.futo.platformplayer.dp
|
import com.futo.platformplayer.dp
|
||||||
import com.futo.platformplayer.fixHtmlLinks
|
import com.futo.platformplayer.fixHtmlLinks
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.resolveChannelUrl
|
import com.futo.platformplayer.resolveChannelUrl
|
||||||
import com.futo.platformplayer.selectBestImage
|
import com.futo.platformplayer.selectBestImage
|
||||||
@ -21,6 +20,7 @@ import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
|
|||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.toHumanNumber
|
import com.futo.platformplayer.toHumanNumber
|
||||||
import com.futo.platformplayer.views.platform.PlatformLinkView
|
import com.futo.platformplayer.views.platform.PlatformLinkView
|
||||||
|
import com.futo.polycentric.core.PolycentricProfile
|
||||||
import com.futo.polycentric.core.toName
|
import com.futo.polycentric.core.toName
|
||||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||||
|
|
||||||
@ -134,9 +134,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(!map.containsKey("Harbor"))
|
if(!map.containsKey("Harbor"))
|
||||||
this.context?.let {
|
map.set("Harbor", polycentricProfile.getHarborUrl());
|
||||||
map.set("Harbor", polycentricProfile.getHarborUrl(it));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (map.isNotEmpty())
|
if (map.isNotEmpty())
|
||||||
setLinks(map, if (polycentricProfile.systemState.username.isNotBlank()) polycentricProfile.systemState.username else _lastChannel?.name ?: "")
|
setLinks(map, if (polycentricProfile.systemState.username.isNotBlank()) polycentricProfile.systemState.username else _lastChannel?.name ?: "")
|
||||||
|
@ -29,7 +29,6 @@ import com.futo.platformplayer.engine.exceptions.PluginException
|
|||||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||||
import com.futo.platformplayer.exceptions.ChannelException
|
import com.futo.platformplayer.exceptions.ChannelException
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.FeedView
|
import com.futo.platformplayer.fragment.mainactivity.main.FeedView
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateCache
|
import com.futo.platformplayer.states.StateCache
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
@ -39,6 +38,7 @@ import com.futo.platformplayer.views.FeedStyle
|
|||||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
|
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
|
||||||
|
import com.futo.polycentric.core.PolycentricProfile
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
@ -16,12 +16,12 @@ import com.futo.platformplayer.constructs.Event1
|
|||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.resolveChannelUrl
|
import com.futo.platformplayer.resolveChannelUrl
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||||
import com.futo.platformplayer.views.adapters.viewholders.CreatorViewHolder
|
import com.futo.platformplayer.views.adapters.viewholders.CreatorViewHolder
|
||||||
|
import com.futo.polycentric.core.PolycentricProfile
|
||||||
|
|
||||||
class ChannelListFragment : Fragment, IChannelTabFragment {
|
class ChannelListFragment : Fragment, IChannelTabFragment {
|
||||||
private var _channels: ArrayList<IPlatformChannel> = arrayListOf();
|
private var _channels: ArrayList<IPlatformChannel> = arrayListOf();
|
||||||
|
@ -8,8 +8,8 @@ import android.widget.TextView
|
|||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
|
||||||
import com.futo.platformplayer.views.SupportView
|
import com.futo.platformplayer.views.SupportView
|
||||||
|
import com.futo.polycentric.core.PolycentricProfile
|
||||||
|
|
||||||
|
|
||||||
class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
|
class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
package com.futo.platformplayer.fragment.channel.tab
|
package com.futo.platformplayer.fragment.channel.tab
|
||||||
|
|
||||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
import com.futo.polycentric.core.PolycentricProfile
|
||||||
|
|
||||||
interface IChannelTabFragment {
|
interface IChannelTabFragment {
|
||||||
fun setChannel(channel: IPlatformChannel)
|
fun setChannel(channel: IPlatformChannel)
|
||||||
|
@ -42,7 +42,6 @@ import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
|||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.SearchType
|
import com.futo.platformplayer.models.SearchType
|
||||||
import com.futo.platformplayer.models.Subscription
|
import com.futo.platformplayer.models.Subscription
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
|
||||||
import com.futo.platformplayer.selectBestImage
|
import com.futo.platformplayer.selectBestImage
|
||||||
import com.futo.platformplayer.selectHighestResolutionImage
|
import com.futo.platformplayer.selectHighestResolutionImage
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
@ -55,29 +54,14 @@ import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter
|
|||||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||||
import com.futo.polycentric.core.OwnedClaim
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.futo.polycentric.core.PublicKey
|
import com.futo.polycentric.core.PolycentricProfile
|
||||||
import com.futo.polycentric.core.Store
|
|
||||||
import com.futo.polycentric.core.SystemState
|
|
||||||
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
|
|
||||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||||
import com.google.android.material.tabs.TabLayout
|
import com.google.android.material.tabs.TabLayout
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class PolycentricProfile(
|
|
||||||
val system: PublicKey, val systemState: SystemState, val ownedClaims: List<OwnedClaim>
|
|
||||||
) {
|
|
||||||
fun getHarborUrl(context: Context): String{
|
|
||||||
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system));
|
|
||||||
val url = system.systemToURLInfoSystemLinkUrl(systemState.servers.asIterable());
|
|
||||||
return "https://harbor.social/" + url.substring("polycentric://".length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ChannelFragment : MainFragment() {
|
class ChannelFragment : MainFragment() {
|
||||||
override val isMainView: Boolean = true
|
override val isMainView: Boolean = true
|
||||||
@ -144,15 +128,14 @@ class ChannelFragment : MainFragment() {
|
|||||||
|
|
||||||
private val _onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {}
|
private val _onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {}
|
||||||
|
|
||||||
private val _taskLoadPolycentricProfile: TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>
|
private val _taskLoadPolycentricProfile: TaskHandler<PlatformID, PolycentricProfile?>
|
||||||
private val _taskGetChannel: TaskHandler<String, IPlatformChannel>
|
private val _taskGetChannel: TaskHandler<String, IPlatformChannel>
|
||||||
|
|
||||||
init {
|
init {
|
||||||
inflater.inflate(R.layout.fragment_channel, this)
|
inflater.inflate(R.layout.fragment_channel, this)
|
||||||
_taskLoadPolycentricProfile =
|
_taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>({ fragment.lifecycleScope },
|
||||||
TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>({ fragment.lifecycleScope },
|
|
||||||
{ id ->
|
{ id ->
|
||||||
return@TaskHandler PolycentricCache.instance.getProfileAsync(id)
|
return@TaskHandler ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, id.claimFieldType.toLong(), id.claimType.toLong(), id.value!!)
|
||||||
}).success { setPolycentricProfile(it, animate = true) }.exception<Throwable> {
|
}).success { setPolycentricProfile(it, animate = true) }.exception<Throwable> {
|
||||||
Logger.w(TAG, "Failed to load polycentric profile.", it)
|
Logger.w(TAG, "Failed to load polycentric profile.", it)
|
||||||
}
|
}
|
||||||
@ -328,7 +311,7 @@ class ChannelFragment : MainFragment() {
|
|||||||
_creatorThumbnail.setThumbnail(parameter.thumbnail, true)
|
_creatorThumbnail.setThumbnail(parameter.thumbnail, true)
|
||||||
Glide.with(_imageBanner).clear(_imageBanner)
|
Glide.with(_imageBanner).clear(_imageBanner)
|
||||||
|
|
||||||
loadPolycentricProfile(parameter.id, parameter.url)
|
loadPolycentricProfile(parameter.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
_url = parameter.url
|
_url = parameter.url
|
||||||
@ -342,7 +325,7 @@ class ChannelFragment : MainFragment() {
|
|||||||
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true)
|
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true)
|
||||||
Glide.with(_imageBanner).clear(_imageBanner)
|
Glide.with(_imageBanner).clear(_imageBanner)
|
||||||
|
|
||||||
loadPolycentricProfile(parameter.channel.id, parameter.channel.url)
|
loadPolycentricProfile(parameter.channel.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
_url = parameter.channel.url
|
_url = parameter.channel.url
|
||||||
@ -359,17 +342,9 @@ class ChannelFragment : MainFragment() {
|
|||||||
_tabs.selectTab(_tabs.getTabAt(selectedTabIndex))
|
_tabs.selectTab(_tabs.getTabAt(selectedTabIndex))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadPolycentricProfile(id: PlatformID, url: String) {
|
private fun loadPolycentricProfile(id: PlatformID) {
|
||||||
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(url, true)
|
|
||||||
if (cachedPolycentricProfile != null) {
|
|
||||||
setPolycentricProfile(cachedPolycentricProfile, animate = true)
|
|
||||||
if (cachedPolycentricProfile.expired) {
|
|
||||||
_taskLoadPolycentricProfile.run(id)
|
_taskLoadPolycentricProfile.run(id)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
_taskLoadPolycentricProfile.run(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setLoading(isLoading: Boolean) {
|
private fun setLoading(isLoading: Boolean) {
|
||||||
if (_isLoading == isLoading) {
|
if (_isLoading == isLoading) {
|
||||||
@ -533,20 +508,13 @@ class ChannelFragment : MainFragment() {
|
|||||||
|
|
||||||
private fun setPolycentricProfileOr(url: String, or: () -> Unit) {
|
private fun setPolycentricProfileOr(url: String, or: () -> Unit) {
|
||||||
setPolycentricProfile(null, animate = false)
|
setPolycentricProfile(null, animate = false)
|
||||||
|
|
||||||
val cachedProfile = channel?.let { PolycentricCache.instance.getCachedProfile(url) }
|
|
||||||
if (cachedProfile != null) {
|
|
||||||
setPolycentricProfile(cachedProfile, animate = false)
|
|
||||||
} else {
|
|
||||||
or()
|
or()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun setPolycentricProfile(
|
private fun setPolycentricProfile(
|
||||||
cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean
|
profile: PolycentricProfile?, animate: Boolean
|
||||||
) {
|
) {
|
||||||
val dp35 = 35.dp(resources)
|
val dp35 = 35.dp(resources)
|
||||||
val profile = cachedPolycentricProfile?.profile
|
|
||||||
val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35)?.let {
|
val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35)?.let {
|
||||||
it.toURLInfoSystemLinkUrl(
|
it.toURLInfoSystemLinkUrl(
|
||||||
profile.system.toProto(), it.process, profile.systemState.servers.toList()
|
profile.system.toProto(), it.process, profile.systemState.servers.toList()
|
||||||
|
@ -23,7 +23,6 @@ import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
|||||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
@ -32,6 +31,7 @@ import com.futo.platformplayer.views.adapters.CommentWithReferenceViewHolder
|
|||||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||||
import com.futo.platformplayer.views.overlays.RepliesOverlay
|
import com.futo.platformplayer.views.overlays.RepliesOverlay
|
||||||
import com.futo.polycentric.core.PublicKey
|
import com.futo.polycentric.core.PublicKey
|
||||||
|
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
|
@ -201,11 +201,12 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
|||||||
protected open fun onContentUrlClicked(url: String, contentType: ContentType) {
|
protected open fun onContentUrlClicked(url: String, contentType: ContentType) {
|
||||||
when(contentType) {
|
when(contentType) {
|
||||||
ContentType.MEDIA -> {
|
ContentType.MEDIA -> {
|
||||||
StatePlayer.instance.clearQueue();
|
StatePlayer.instance.clearQueue()
|
||||||
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail();
|
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
|
||||||
};
|
}
|
||||||
ContentType.PLAYLIST -> fragment.navigate<RemotePlaylistFragment>(url);
|
ContentType.PLAYLIST -> fragment.navigate<RemotePlaylistFragment>(url)
|
||||||
ContentType.URL -> fragment.navigate<BrowserFragment>(url);
|
ContentType.URL -> fragment.navigate<BrowserFragment>(url)
|
||||||
|
ContentType.CHANNEL -> fragment.navigate<ChannelFragment>(url)
|
||||||
else -> {};
|
else -> {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,9 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.UISlideOverlays
|
import com.futo.platformplayer.UISlideOverlays
|
||||||
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
@ -17,6 +19,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
|||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
||||||
import com.futo.platformplayer.isHttpUrl
|
import com.futo.platformplayer.isHttpUrl
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.models.SearchType
|
||||||
import com.futo.platformplayer.states.StateMeta
|
import com.futo.platformplayer.states.StateMeta
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.views.FeedStyle
|
import com.futo.platformplayer.views.FeedStyle
|
||||||
@ -83,6 +86,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||||||
private var _filterValues: HashMap<String, List<String>> = hashMapOf();
|
private var _filterValues: HashMap<String, List<String>> = hashMapOf();
|
||||||
private var _enabledClientIds: List<String>? = null;
|
private var _enabledClientIds: List<String>? = null;
|
||||||
private var _channelUrl: String? = null;
|
private var _channelUrl: String? = null;
|
||||||
|
private var _searchType: SearchType? = null;
|
||||||
|
|
||||||
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
|
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
|
||||||
override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar
|
override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar
|
||||||
@ -94,7 +98,13 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||||||
if (channelUrl != null) {
|
if (channelUrl != null) {
|
||||||
StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds)
|
StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds)
|
||||||
} else {
|
} else {
|
||||||
StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
|
when (_searchType)
|
||||||
|
{
|
||||||
|
SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
|
||||||
|
SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query)
|
||||||
|
SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query)
|
||||||
|
else -> throw Exception("Search type must be specified")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
|
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
|
||||||
@ -115,6 +125,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||||||
if(parameter is SuggestionsFragmentData) {
|
if(parameter is SuggestionsFragmentData) {
|
||||||
setQuery(parameter.query, false);
|
setQuery(parameter.query, false);
|
||||||
setChannelUrl(parameter.channelUrl, false);
|
setChannelUrl(parameter.channelUrl, false);
|
||||||
|
setSearchType(parameter.searchType, false)
|
||||||
|
|
||||||
fragment.topBar?.apply {
|
fragment.topBar?.apply {
|
||||||
if (this is SearchTopBarFragment) {
|
if (this is SearchTopBarFragment) {
|
||||||
@ -160,8 +171,14 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||||||
navigate<RemotePlaylistFragment>(it);
|
navigate<RemotePlaylistFragment>(it);
|
||||||
else if(StatePlatform.instance.hasEnabledChannelClient(it))
|
else if(StatePlatform.instance.hasEnabledChannelClient(it))
|
||||||
navigate<ChannelFragment>(it);
|
navigate<ChannelFragment>(it);
|
||||||
else
|
else {
|
||||||
navigate<VideoDetailFragment>(it);
|
val url = it;
|
||||||
|
activity?.let {
|
||||||
|
close()
|
||||||
|
if(it is MainActivity)
|
||||||
|
it.navigate(it.getFragment<VideoDetailFragment>(), url);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
setQuery(it, true);
|
setQuery(it, true);
|
||||||
@ -251,6 +268,15 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setSearchType(searchType: SearchType, updateResults: Boolean = true) {
|
||||||
|
_searchType = searchType
|
||||||
|
|
||||||
|
if (updateResults) {
|
||||||
|
clearResults();
|
||||||
|
loadResults();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun setSortBy(sortBy: String?, updateResults: Boolean = true) {
|
private fun setSortBy(sortBy: String?, updateResults: Boolean = true) {
|
||||||
_sortBy = sortBy;
|
_sortBy = sortBy;
|
||||||
|
|
||||||
|
@ -21,6 +21,8 @@ import com.futo.platformplayer.models.Playlist
|
|||||||
import com.futo.platformplayer.states.StateDownloads
|
import com.futo.platformplayer.states.StateDownloads
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.states.StatePlaylists
|
import com.futo.platformplayer.states.StatePlaylists
|
||||||
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
|
import com.futo.platformplayer.stores.StringStorage
|
||||||
import com.futo.platformplayer.toHumanBytesSize
|
import com.futo.platformplayer.toHumanBytesSize
|
||||||
import com.futo.platformplayer.toHumanDuration
|
import com.futo.platformplayer.toHumanDuration
|
||||||
import com.futo.platformplayer.views.AnyInsertedAdapterView
|
import com.futo.platformplayer.views.AnyInsertedAdapterView
|
||||||
@ -103,12 +105,15 @@ class DownloadsFragment : MainFragment() {
|
|||||||
private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>;
|
private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>;
|
||||||
|
|
||||||
private var lastDownloads: List<VideoLocal>? = null;
|
private var lastDownloads: List<VideoLocal>? = null;
|
||||||
private var ordering: String? = "nameAsc";
|
private var ordering = FragmentedStorage.get<StringStorage>("downloads_ordering")
|
||||||
|
|
||||||
constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) {
|
constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) {
|
||||||
inflater.inflate(R.layout.fragment_downloads, this);
|
inflater.inflate(R.layout.fragment_downloads, this);
|
||||||
_frag = frag;
|
_frag = frag;
|
||||||
|
|
||||||
|
if(ordering.value.isNullOrBlank())
|
||||||
|
ordering.value = "nameAsc";
|
||||||
|
|
||||||
_usageUsed = findViewById(R.id.downloads_usage_used);
|
_usageUsed = findViewById(R.id.downloads_usage_used);
|
||||||
_usageAvailable = findViewById(R.id.downloads_usage_available);
|
_usageAvailable = findViewById(R.id.downloads_usage_available);
|
||||||
_usageProgress = findViewById(R.id.downloads_usage_progress);
|
_usageProgress = findViewById(R.id.downloads_usage_progress);
|
||||||
@ -132,22 +137,23 @@ class DownloadsFragment : MainFragment() {
|
|||||||
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also {
|
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also {
|
||||||
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||||
};
|
};
|
||||||
spinnerSortBy.setSelection(0);
|
val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc");
|
||||||
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||||
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
|
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
|
||||||
when(pos) {
|
when(pos) {
|
||||||
0 -> ordering = "nameAsc"
|
0 -> ordering.setAndSave("nameAsc")
|
||||||
1 -> ordering = "nameDesc"
|
1 -> ordering.setAndSave("nameDesc")
|
||||||
2 -> ordering = "downloadDateAsc"
|
2 -> ordering.setAndSave("downloadDateAsc")
|
||||||
3 -> ordering = "downloadDateDesc"
|
3 -> ordering.setAndSave("downloadDateDesc")
|
||||||
4 -> ordering = "releasedAsc"
|
4 -> ordering.setAndSave("releasedAsc")
|
||||||
5 -> ordering = "releasedDesc"
|
5 -> ordering.setAndSave("releasedDesc")
|
||||||
else -> ordering = null
|
else -> ordering.setAndSave("")
|
||||||
}
|
}
|
||||||
updateContentFilters()
|
updateContentFilters()
|
||||||
}
|
}
|
||||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||||
};
|
};
|
||||||
|
spinnerSortBy.setSelection(Math.max(0, options.indexOf(ordering.value)));
|
||||||
|
|
||||||
_listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded)
|
_listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded)
|
||||||
.asAnyWithTop(findViewById(R.id.downloads_top)) {
|
.asAnyWithTop(findViewById(R.id.downloads_top)) {
|
||||||
@ -229,9 +235,9 @@ class DownloadsFragment : MainFragment() {
|
|||||||
fun filterDownloads(vids: List<VideoLocal>): List<VideoLocal>{
|
fun filterDownloads(vids: List<VideoLocal>): List<VideoLocal>{
|
||||||
var vidsToReturn = vids;
|
var vidsToReturn = vids;
|
||||||
if(!_listDownloadSearch.text.isNullOrEmpty())
|
if(!_listDownloadSearch.text.isNullOrEmpty())
|
||||||
vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) };
|
vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) || it.author.name.contains(_listDownloadSearch.text, true) };
|
||||||
if(!ordering.isNullOrEmpty()) {
|
if(!ordering.value.isNullOrEmpty()) {
|
||||||
vidsToReturn = when(ordering){
|
vidsToReturn = when(ordering.value){
|
||||||
"downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX };
|
"downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX };
|
||||||
"downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN };
|
"downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN };
|
||||||
"nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() }
|
"nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() }
|
||||||
|
@ -3,12 +3,15 @@ package com.futo.platformplayer.fragment.mainactivity.main
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.util.DisplayMetrics
|
||||||
|
import android.view.Display
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.*
|
import android.widget.*
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.recyclerview.widget.RecyclerView.LayoutManager
|
import androidx.recyclerview.widget.RecyclerView.LayoutManager
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
@ -20,6 +23,7 @@ import com.futo.platformplayer.constructs.Event1
|
|||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.views.FeedStyle
|
import com.futo.platformplayer.views.FeedStyle
|
||||||
import com.futo.platformplayer.views.others.ProgressBar
|
import com.futo.platformplayer.views.others.ProgressBar
|
||||||
import com.futo.platformplayer.views.others.TagsView
|
import com.futo.platformplayer.views.others.TagsView
|
||||||
@ -28,7 +32,9 @@ import com.futo.platformplayer.views.adapters.InsertedViewHolder
|
|||||||
import com.futo.platformplayer.views.announcements.AnnouncementView
|
import com.futo.platformplayer.views.announcements.AnnouncementView
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
||||||
@ -68,6 +74,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
|
|
||||||
private val _scrollListener: RecyclerView.OnScrollListener;
|
private val _scrollListener: RecyclerView.OnScrollListener;
|
||||||
private var _automaticNextPageCounter = 0;
|
private var _automaticNextPageCounter = 0;
|
||||||
|
private val _automaticBackoff = arrayOf(0, 500, 1000, 1000, 2000, 5000, 5000, 5000);
|
||||||
|
|
||||||
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, GridLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>? = null) : super(inflater.context) {
|
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, GridLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>? = null) : super(inflater.context) {
|
||||||
this.fragment = fragment;
|
this.fragment = fragment;
|
||||||
@ -182,29 +189,61 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
|
|
||||||
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
|
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
|
||||||
val canScroll = if (recyclerData.results.isEmpty()) false else {
|
val canScroll = if (recyclerData.results.isEmpty()) false else {
|
||||||
|
val height = resources.displayMetrics.heightPixels;
|
||||||
|
|
||||||
val layoutManager = recyclerData.layoutManager
|
val layoutManager = recyclerData.layoutManager
|
||||||
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
|
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
|
||||||
|
val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null;
|
||||||
if (firstVisibleItemPosition != RecyclerView.NO_POSITION) {
|
val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition();
|
||||||
val firstVisibleView = layoutManager.findViewByPosition(firstVisibleItemPosition)
|
val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null;
|
||||||
val itemHeight = firstVisibleView?.height ?: 0
|
val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1;
|
||||||
val occupiedSpace = recyclerData.results.size / recyclerData.layoutManager.spanCount * itemHeight
|
val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows;
|
||||||
val recyclerViewHeight = _recyclerResults.height
|
if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) {
|
||||||
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage occupiedSpace=$occupiedSpace recyclerViewHeight=$recyclerViewHeight")
|
false;
|
||||||
occupiedSpace >= recyclerViewHeight
|
}
|
||||||
|
else if (firstVisibleItemView != null && height != null && rowsHeight < height) {
|
||||||
|
false;
|
||||||
} else {
|
} else {
|
||||||
false
|
true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
|
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
|
||||||
if (!canScroll || filteredResults.isEmpty()) {
|
if (!canScroll || filteredResults.isEmpty()) {
|
||||||
_automaticNextPageCounter++
|
_automaticNextPageCounter++
|
||||||
if(_automaticNextPageCounter <= 4)
|
if(_automaticNextPageCounter < _automaticBackoff.size) {
|
||||||
loadNextPage()
|
if(_automaticNextPageCounter > 0) {
|
||||||
|
val automaticNextPageCounterSaved = _automaticNextPageCounter;
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.Default) {
|
||||||
|
val backoff = _automaticBackoff[Math.min(_automaticBackoff.size - 1, _automaticNextPageCounter)];
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
delay(backoff.toLong());
|
||||||
|
if(automaticNextPageCounterSaved == _automaticNextPageCounter) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
loadNextPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
loadNextPage();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
|
||||||
_automaticNextPageCounter = 0;
|
_automaticNextPageCounter = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fun resetAutomaticNextPageCounter(){
|
||||||
|
_automaticNextPageCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
protected fun setTextCentered(text: String?) {
|
protected fun setTextCentered(text: String?) {
|
||||||
_textCentered.text = text;
|
_textCentered.text = text;
|
||||||
|
@ -5,28 +5,38 @@ import android.os.Bundle
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.allViews
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.*
|
||||||
|
import com.futo.platformplayer.UISlideOverlays.Companion.showOrderOverlay
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
|
import com.futo.platformplayer.api.media.structures.IRefreshPager
|
||||||
|
import com.futo.platformplayer.api.media.structures.IReusablePager
|
||||||
|
import com.futo.platformplayer.api.media.structures.ReusablePager
|
||||||
|
import com.futo.platformplayer.api.media.structures.ReusableRefreshPager
|
||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
|
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateHistory
|
||||||
import com.futo.platformplayer.states.StateMeta
|
import com.futo.platformplayer.states.StateMeta
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
|
import com.futo.platformplayer.states.StatePlugins
|
||||||
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
|
import com.futo.platformplayer.stores.StringArrayStorage
|
||||||
import com.futo.platformplayer.views.FeedStyle
|
import com.futo.platformplayer.views.FeedStyle
|
||||||
import com.futo.platformplayer.views.NoResultsView
|
import com.futo.platformplayer.views.NoResultsView
|
||||||
|
import com.futo.platformplayer.views.ToggleBar
|
||||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||||
import com.futo.platformplayer.views.adapters.InsertedViewHolder
|
import com.futo.platformplayer.views.adapters.InsertedViewHolder
|
||||||
import com.futo.platformplayer.views.announcements.AnnouncementView
|
|
||||||
import com.futo.platformplayer.views.buttons.BigButton
|
import com.futo.platformplayer.views.buttons.BigButton
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
@ -38,6 +48,12 @@ class HomeFragment : MainFragment() {
|
|||||||
|
|
||||||
private var _view: HomeView? = null;
|
private var _view: HomeView? = null;
|
||||||
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
|
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
|
||||||
|
private var _cachedLastPager: IReusablePager<IPlatformContent>? = null
|
||||||
|
|
||||||
|
private var _toggleRecent = false;
|
||||||
|
private var _toggleWatched = false;
|
||||||
|
private var _togglePluginsDisabled = mutableListOf<String>();
|
||||||
|
|
||||||
|
|
||||||
fun reloadFeed() {
|
fun reloadFeed() {
|
||||||
_view?.reloadFeed()
|
_view?.reloadFeed()
|
||||||
@ -63,7 +79,7 @@ class HomeFragment : MainFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
val view = HomeView(this, inflater, _cachedRecyclerData);
|
val view = HomeView(this, inflater, _cachedRecyclerData, _cachedLastPager);
|
||||||
_view = view;
|
_view = view;
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
@ -81,6 +97,7 @@ class HomeFragment : MainFragment() {
|
|||||||
val view = _view;
|
val view = _view;
|
||||||
if (view != null) {
|
if (view != null) {
|
||||||
_cachedRecyclerData = view.recyclerData;
|
_cachedRecyclerData = view.recyclerData;
|
||||||
|
_cachedLastPager = view.lastPager;
|
||||||
view.cleanup();
|
view.cleanup();
|
||||||
_view = null;
|
_view = null;
|
||||||
}
|
}
|
||||||
@ -90,18 +107,32 @@ class HomeFragment : MainFragment() {
|
|||||||
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.home.previewFeedItems);
|
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.home.previewFeedItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@SuppressLint("ViewConstructor")
|
@SuppressLint("ViewConstructor")
|
||||||
class HomeView : ContentFeedView<HomeFragment> {
|
class HomeView : ContentFeedView<HomeFragment> {
|
||||||
override val feedStyle: FeedStyle get() = Settings.instance.home.getHomeFeedStyle();
|
override val feedStyle: FeedStyle get() = Settings.instance.home.getHomeFeedStyle();
|
||||||
|
|
||||||
|
private var _toggleBar: ToggleBar? = null;
|
||||||
|
|
||||||
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
|
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
|
||||||
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar
|
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar
|
||||||
|
|
||||||
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
var lastPager: IReusablePager<IPlatformContent>? = null;
|
||||||
|
|
||||||
|
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null, cachedLastPager: IReusablePager<IPlatformContent>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
||||||
|
lastPager = cachedLastPager
|
||||||
_taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({ fragment.lifecycleScope }, {
|
_taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({ fragment.lifecycleScope }, {
|
||||||
StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope)
|
StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope)
|
||||||
})
|
})
|
||||||
.success { loadedResult(it); }
|
.success {
|
||||||
|
val wrappedPager = if(it is IRefreshPager)
|
||||||
|
ReusableRefreshPager(it);
|
||||||
|
else
|
||||||
|
ReusablePager(it);
|
||||||
|
lastPager = wrappedPager;
|
||||||
|
resetAutomaticNextPageCounter();
|
||||||
|
loadedResult(wrappedPager.getWindow());
|
||||||
|
}
|
||||||
.exception<ScriptCaptchaRequiredException> { }
|
.exception<ScriptCaptchaRequiredException> { }
|
||||||
.exception<ScriptExecutionException> {
|
.exception<ScriptExecutionException> {
|
||||||
Logger.w(ChannelFragment.TAG, "Plugin failure.", it);
|
Logger.w(ChannelFragment.TAG, "Plugin failure.", it);
|
||||||
@ -127,6 +158,8 @@ class HomeFragment : MainFragment() {
|
|||||||
}, fragment);
|
}, fragment);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
initializeToolbarContent();
|
||||||
|
|
||||||
setPreviewsEnabled(Settings.instance.home.previewFeedItems);
|
setPreviewsEnabled(Settings.instance.home.previewFeedItems);
|
||||||
showAnnouncementView()
|
showAnnouncementView()
|
||||||
}
|
}
|
||||||
@ -201,13 +234,119 @@ class HomeFragment : MainFragment() {
|
|||||||
loadResults();
|
loadResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
|
private val _filterLock = Object();
|
||||||
return results.filter { !StateMeta.instance.isVideoHidden(it.url) && !StateMeta.instance.isCreatorHidden(it.author.url) };
|
private var _togglesConfig = FragmentedStorage.get<StringArrayStorage>("home_toggles");
|
||||||
|
fun initializeToolbarContent() {
|
||||||
|
if(_toolbarContentView.allViews.any { it is ToggleBar })
|
||||||
|
_toolbarContentView.removeView(_toolbarContentView.allViews.find { it is ToggleBar });
|
||||||
|
|
||||||
|
if(Settings.instance.home.showHomeFilters) {
|
||||||
|
|
||||||
|
if (!_togglesConfig.any()) {
|
||||||
|
_togglesConfig.set("today", "watched", "plugins");
|
||||||
|
_togglesConfig.save();
|
||||||
|
}
|
||||||
|
_toggleBar = ToggleBar(context).apply {
|
||||||
|
layoutParams =
|
||||||
|
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadResults() {
|
synchronized(_filterLock) {
|
||||||
|
var buttonsPlugins: List<ToggleBar.Toggle> = listOf()
|
||||||
|
buttonsPlugins = (if (_togglesConfig.contains("plugins"))
|
||||||
|
(StatePlatform.instance.getEnabledClients()
|
||||||
|
.filter { it is JSClient && it.enableInHome }
|
||||||
|
.map { plugin ->
|
||||||
|
ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) plugin.name else "", plugin.icon, !fragment._togglePluginsDisabled.contains(plugin.id), { view, active ->
|
||||||
|
var dontSwap = false;
|
||||||
|
if (active) {
|
||||||
|
if (fragment._togglePluginsDisabled.contains(plugin.id))
|
||||||
|
fragment._togglePluginsDisabled.remove(plugin.id);
|
||||||
|
} else {
|
||||||
|
if (!fragment._togglePluginsDisabled.contains(plugin.id)) {
|
||||||
|
val enabledClients = StatePlatform.instance.getEnabledClients();
|
||||||
|
val availableAfterDisable = enabledClients.count { !fragment._togglePluginsDisabled.contains(it.id) && it.id != plugin.id };
|
||||||
|
if(availableAfterDisable > 0)
|
||||||
|
fragment._togglePluginsDisabled.add(plugin.id);
|
||||||
|
else {
|
||||||
|
UIDialogs.appToast("Home needs atleast 1 plugin active");
|
||||||
|
dontSwap = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(!dontSwap)
|
||||||
|
reloadForFilters();
|
||||||
|
else {
|
||||||
|
view.setToggle(!active);
|
||||||
|
}
|
||||||
|
}).withTag("plugins")
|
||||||
|
})
|
||||||
|
else listOf())
|
||||||
|
val buttons = (listOf<ToggleBar.Toggle?>(
|
||||||
|
(if (_togglesConfig.contains("today"))
|
||||||
|
ToggleBar.Toggle("Today", fragment._toggleRecent) { view, active ->
|
||||||
|
fragment._toggleRecent = active; reloadForFilters()
|
||||||
|
}
|
||||||
|
.withTag("today") else null),
|
||||||
|
(if (_togglesConfig.contains("watched"))
|
||||||
|
ToggleBar.Toggle("Unwatched", fragment._toggleWatched) { view, active ->
|
||||||
|
fragment._toggleWatched = active; reloadForFilters()
|
||||||
|
}
|
||||||
|
.withTag("watched") else null),
|
||||||
|
).filterNotNull() + buttonsPlugins)
|
||||||
|
.sortedBy { _togglesConfig.indexOf(it.tag ?: "") } ?: listOf()
|
||||||
|
|
||||||
|
val buttonSettings = ToggleBar.Toggle("", R.drawable.ic_settings, true, { view, active ->
|
||||||
|
showOrderOverlay(_overlayContainer,
|
||||||
|
"Visible home filters",
|
||||||
|
listOf(
|
||||||
|
Pair("Plugins", "plugins"),
|
||||||
|
Pair("Today", "today"),
|
||||||
|
Pair("Watched", "watched")
|
||||||
|
),
|
||||||
|
{
|
||||||
|
val newArray = it.map { it.toString() }.toTypedArray();
|
||||||
|
_togglesConfig.set(*(if (newArray.any()) newArray else arrayOf("none")));
|
||||||
|
_togglesConfig.save();
|
||||||
|
initializeToolbarContent();
|
||||||
|
},
|
||||||
|
"Select which toggles you want to see in order. You can also choose to hide filters in the Grayjay Settings"
|
||||||
|
);
|
||||||
|
}).asButton();
|
||||||
|
|
||||||
|
val buttonsOrder = (buttons + listOf(buttonSettings)).toTypedArray();
|
||||||
|
_toggleBar?.setToggles(*buttonsOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
_toolbarContentView.addView(_toggleBar, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun reloadForFilters() {
|
||||||
|
lastPager?.let { loadedResult(it.getWindow()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
|
||||||
|
return results.filter {
|
||||||
|
if(StateMeta.instance.isVideoHidden(it.url))
|
||||||
|
return@filter false;
|
||||||
|
if(StateMeta.instance.isCreatorHidden(it.author.url))
|
||||||
|
return@filter false;
|
||||||
|
|
||||||
|
if(fragment._toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25)
|
||||||
|
return@filter false;
|
||||||
|
if(fragment._toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0))
|
||||||
|
return@filter false;
|
||||||
|
if(fragment._togglePluginsDisabled.any() && it.id.pluginId != null && fragment._togglePluginsDisabled.contains(it.id.pluginId)) {
|
||||||
|
return@filter false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return@filter true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadResults(withRefetch: Boolean = true) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
_taskGetPager.run(true);
|
_taskGetPager.run(withRefetch);
|
||||||
}
|
}
|
||||||
private fun loadedResult(pager : IPager<IPlatformContent>) {
|
private fun loadedResult(pager : IPager<IPlatformContent>) {
|
||||||
if (pager is EmptyPager<IPlatformContent>) {
|
if (pager is EmptyPager<IPlatformContent>) {
|
||||||
|
@ -326,6 +326,10 @@ class PlaylistFragment : MainFragment() {
|
|||||||
playlist.videos = ArrayList(playlist.videos.filter { it != video });
|
playlist.videos = ArrayList(playlist.videos.filter { it != video });
|
||||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onVideoOptions(video: IPlatformVideo) {
|
||||||
|
UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer);
|
||||||
|
}
|
||||||
override fun onVideoClicked(video: IPlatformVideo) {
|
override fun onVideoClicked(video: IPlatformVideo) {
|
||||||
val playlist = _playlist;
|
val playlist = _playlist;
|
||||||
if (playlist != null) {
|
if (playlist != null) {
|
||||||
|
@ -6,12 +6,17 @@ import android.util.TypedValue
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.AdapterView
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.EditText
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.Spinner
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
import androidx.core.widget.addTextChangedListener
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
@ -21,11 +26,15 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
|||||||
import com.futo.platformplayer.models.Playlist
|
import com.futo.platformplayer.models.Playlist
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.states.StatePlaylists
|
import com.futo.platformplayer.states.StatePlaylists
|
||||||
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
|
import com.futo.platformplayer.stores.StringStorage
|
||||||
|
import com.futo.platformplayer.views.SearchView
|
||||||
import com.futo.platformplayer.views.adapters.*
|
import com.futo.platformplayer.views.adapters.*
|
||||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||||
import com.google.android.material.appbar.AppBarLayout
|
import com.google.android.material.appbar.AppBarLayout
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
|
||||||
class PlaylistsFragment : MainFragment() {
|
class PlaylistsFragment : MainFragment() {
|
||||||
@ -65,6 +74,7 @@ class PlaylistsFragment : MainFragment() {
|
|||||||
private val _fragment: PlaylistsFragment;
|
private val _fragment: PlaylistsFragment;
|
||||||
|
|
||||||
var watchLater: ArrayList<IPlatformVideo> = arrayListOf();
|
var watchLater: ArrayList<IPlatformVideo> = arrayListOf();
|
||||||
|
var allPlaylists: ArrayList<Playlist> = arrayListOf();
|
||||||
var playlists: ArrayList<Playlist> = arrayListOf();
|
var playlists: ArrayList<Playlist> = arrayListOf();
|
||||||
private var _appBar: AppBarLayout;
|
private var _appBar: AppBarLayout;
|
||||||
private var _adapterWatchLater: VideoListHorizontalAdapter;
|
private var _adapterWatchLater: VideoListHorizontalAdapter;
|
||||||
@ -72,12 +82,20 @@ class PlaylistsFragment : MainFragment() {
|
|||||||
private var _layoutWatchlist: ConstraintLayout;
|
private var _layoutWatchlist: ConstraintLayout;
|
||||||
private var _slideUpOverlay: SlideUpMenuOverlay? = null;
|
private var _slideUpOverlay: SlideUpMenuOverlay? = null;
|
||||||
|
|
||||||
|
private var _listPlaylistsSearch: EditText;
|
||||||
|
|
||||||
|
private var _ordering = FragmentedStorage.get<StringStorage>("playlists_ordering")
|
||||||
|
|
||||||
|
|
||||||
constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) {
|
constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) {
|
||||||
_fragment = fragment;
|
_fragment = fragment;
|
||||||
inflater.inflate(R.layout.fragment_playlists, this);
|
inflater.inflate(R.layout.fragment_playlists, this);
|
||||||
|
|
||||||
|
_listPlaylistsSearch = findViewById(R.id.playlists_search);
|
||||||
|
|
||||||
watchLater = ArrayList();
|
watchLater = ArrayList();
|
||||||
playlists = ArrayList();
|
playlists = ArrayList();
|
||||||
|
allPlaylists = ArrayList();
|
||||||
|
|
||||||
val recyclerWatchLater = findViewById<RecyclerView>(R.id.recycler_watch_later);
|
val recyclerWatchLater = findViewById<RecyclerView>(R.id.recycler_watch_later);
|
||||||
|
|
||||||
@ -105,6 +123,7 @@ class PlaylistsFragment : MainFragment() {
|
|||||||
buttonCreatePlaylist.setOnClickListener {
|
buttonCreatePlaylist.setOnClickListener {
|
||||||
_slideUpOverlay = UISlideOverlays.showCreatePlaylistOverlay(findViewById<FrameLayout>(R.id.overlay_create_playlist)) {
|
_slideUpOverlay = UISlideOverlays.showCreatePlaylistOverlay(findViewById<FrameLayout>(R.id.overlay_create_playlist)) {
|
||||||
val playlist = Playlist(it, arrayListOf());
|
val playlist = Playlist(it, arrayListOf());
|
||||||
|
allPlaylists.add(0, playlist);
|
||||||
playlists.add(0, playlist);
|
playlists.add(0, playlist);
|
||||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
||||||
|
|
||||||
@ -120,6 +139,35 @@ class PlaylistsFragment : MainFragment() {
|
|||||||
_appBar = findViewById(R.id.app_bar);
|
_appBar = findViewById(R.id.app_bar);
|
||||||
_layoutWatchlist = findViewById(R.id.layout_watchlist);
|
_layoutWatchlist = findViewById(R.id.layout_watchlist);
|
||||||
|
|
||||||
|
|
||||||
|
_listPlaylistsSearch.addTextChangedListener {
|
||||||
|
updatePlaylistsFiltering();
|
||||||
|
}
|
||||||
|
val spinnerSortBy: Spinner = findViewById(R.id.spinner_sortby);
|
||||||
|
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.playlists_sortby_array)).also {
|
||||||
|
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||||
|
};
|
||||||
|
val options = listOf("nameAsc", "nameDesc", "dateEditAsc", "dateEditDesc", "dateCreateAsc", "dateCreateDesc", "datePlayAsc", "datePlayDesc");
|
||||||
|
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||||
|
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
|
||||||
|
when(pos) {
|
||||||
|
0 -> _ordering.setAndSave("nameAsc")
|
||||||
|
1 -> _ordering.setAndSave("nameDesc")
|
||||||
|
2 -> _ordering.setAndSave("dateEditAsc")
|
||||||
|
3 -> _ordering.setAndSave("dateEditDesc")
|
||||||
|
4 -> _ordering.setAndSave("dateCreateAsc")
|
||||||
|
5 -> _ordering.setAndSave("dateCreateDesc")
|
||||||
|
6 -> _ordering.setAndSave("datePlayAsc")
|
||||||
|
7 -> _ordering.setAndSave("datePlayDesc")
|
||||||
|
else -> _ordering.setAndSave("")
|
||||||
|
}
|
||||||
|
updatePlaylistsFiltering()
|
||||||
|
}
|
||||||
|
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||||
|
};
|
||||||
|
spinnerSortBy.setSelection(Math.max(0, options.indexOf(_ordering.value)));
|
||||||
|
|
||||||
|
|
||||||
findViewById<TextView>(R.id.text_view_all).setOnClickListener { _fragment.navigate<WatchLaterFragment>(context.getString(R.string.watch_later)); };
|
findViewById<TextView>(R.id.text_view_all).setOnClickListener { _fragment.navigate<WatchLaterFragment>(context.getString(R.string.watch_later)); };
|
||||||
StatePlaylists.instance.onWatchLaterChanged.subscribe(this) {
|
StatePlaylists.instance.onWatchLaterChanged.subscribe(this) {
|
||||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
@ -134,10 +182,12 @@ class PlaylistsFragment : MainFragment() {
|
|||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
fun onShown() {
|
fun onShown() {
|
||||||
|
allPlaylists.clear();
|
||||||
playlists.clear()
|
playlists.clear()
|
||||||
playlists.addAll(
|
allPlaylists.addAll(
|
||||||
StatePlaylists.instance.getPlaylists().sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) }
|
StatePlaylists.instance.getPlaylists().sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) }
|
||||||
);
|
);
|
||||||
|
playlists.addAll(filterPlaylists(allPlaylists));
|
||||||
_adapterPlaylist.notifyDataSetChanged();
|
_adapterPlaylist.notifyDataSetChanged();
|
||||||
|
|
||||||
updateWatchLater();
|
updateWatchLater();
|
||||||
@ -157,6 +207,32 @@ class PlaylistsFragment : MainFragment() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updatePlaylistsFiltering() {
|
||||||
|
val toFilter = allPlaylists ?: return;
|
||||||
|
playlists.clear();
|
||||||
|
playlists.addAll(filterPlaylists(toFilter));
|
||||||
|
_adapterPlaylist.notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
private fun filterPlaylists(pls: List<Playlist>): List<Playlist> {
|
||||||
|
var playlistsToReturn = pls;
|
||||||
|
if(!_listPlaylistsSearch.text.isNullOrEmpty())
|
||||||
|
playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) };
|
||||||
|
if(!_ordering.value.isNullOrEmpty()){
|
||||||
|
playlistsToReturn = when(_ordering.value){
|
||||||
|
"nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() }
|
||||||
|
"nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() };
|
||||||
|
"dateEditAsc" -> playlistsToReturn.sortedBy { it.dateUpdate ?: OffsetDateTime.MAX };
|
||||||
|
"dateEditDesc" -> playlistsToReturn.sortedByDescending { it.dateUpdate ?: OffsetDateTime.MIN }
|
||||||
|
"dateCreateAsc" -> playlistsToReturn.sortedBy { it.dateCreation ?: OffsetDateTime.MAX };
|
||||||
|
"dateCreateDesc" -> playlistsToReturn.sortedByDescending { it.dateCreation ?: OffsetDateTime.MIN }
|
||||||
|
"datePlayAsc" -> playlistsToReturn.sortedBy { it.datePlayed ?: OffsetDateTime.MAX };
|
||||||
|
"datePlayDesc" -> playlistsToReturn.sortedByDescending { it.datePlayed ?: OffsetDateTime.MIN }
|
||||||
|
else -> playlistsToReturn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return playlistsToReturn;
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateWatchLater() {
|
private fun updateWatchLater() {
|
||||||
val watchList = StatePlaylists.instance.getWatchLater();
|
val watchList = StatePlaylists.instance.getWatchLater();
|
||||||
if (watchList.isNotEmpty()) {
|
if (watchList.isNotEmpty()) {
|
||||||
@ -164,7 +240,7 @@ class PlaylistsFragment : MainFragment() {
|
|||||||
|
|
||||||
_appBar.let { appBar ->
|
_appBar.let { appBar ->
|
||||||
val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams;
|
val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams;
|
||||||
layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 230.0f, resources.displayMetrics).toInt();
|
layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 315.0f, resources.displayMetrics).toInt();
|
||||||
appBar.layoutParams = layoutParams;
|
appBar.layoutParams = layoutParams;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -172,7 +248,7 @@ class PlaylistsFragment : MainFragment() {
|
|||||||
|
|
||||||
_appBar.let { appBar ->
|
_appBar.let { appBar ->
|
||||||
val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams;
|
val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams;
|
||||||
layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 25.0f, resources.displayMetrics).toInt();
|
layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 110.0f, resources.displayMetrics).toInt();
|
||||||
appBar.layoutParams = layoutParams;
|
appBar.layoutParams = layoutParams;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -33,10 +33,8 @@ import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
|||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
import com.futo.platformplayer.dp
|
import com.futo.platformplayer.dp
|
||||||
import com.futo.platformplayer.fixHtmlWhitespace
|
import com.futo.platformplayer.fixHtmlWhitespace
|
||||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
|
||||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
|
||||||
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
|
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
@ -47,7 +45,6 @@ import com.futo.platformplayer.views.adapters.ChannelTab
|
|||||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView
|
import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView
|
||||||
import com.futo.platformplayer.views.comments.AddCommentView
|
import com.futo.platformplayer.views.comments.AddCommentView
|
||||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||||
import com.futo.platformplayer.views.others.Toggle
|
|
||||||
import com.futo.platformplayer.views.overlays.RepliesOverlay
|
import com.futo.platformplayer.views.overlays.RepliesOverlay
|
||||||
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
|
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
|
||||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||||
@ -57,6 +54,8 @@ import com.futo.polycentric.core.ApiMethods
|
|||||||
import com.futo.polycentric.core.ContentType
|
import com.futo.polycentric.core.ContentType
|
||||||
import com.futo.polycentric.core.Models
|
import com.futo.polycentric.core.Models
|
||||||
import com.futo.polycentric.core.Opinion
|
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.flexbox.FlexboxLayout
|
||||||
import com.google.android.material.imageview.ShapeableImageView
|
import com.google.android.material.imageview.ShapeableImageView
|
||||||
import com.google.android.material.shape.CornerFamily
|
import com.google.android.material.shape.CornerFamily
|
||||||
@ -112,7 +111,7 @@ class PostDetailFragment : MainFragment {
|
|||||||
private var _isLoading = false;
|
private var _isLoading = false;
|
||||||
private var _post: IPlatformPostDetails? = null;
|
private var _post: IPlatformPostDetails? = null;
|
||||||
private var _postOverview: IPlatformPost? = null;
|
private var _postOverview: IPlatformPost? = null;
|
||||||
private var _polycentricProfile: PolycentricCache.CachedPolycentricProfile? = null;
|
private var _polycentricProfile: PolycentricProfile? = null;
|
||||||
private var _version = 0;
|
private var _version = 0;
|
||||||
private var _isRepliesVisible: Boolean = false;
|
private var _isRepliesVisible: Boolean = false;
|
||||||
private var _repliesAnimator: ViewPropertyAnimator? = null;
|
private var _repliesAnimator: ViewPropertyAnimator? = null;
|
||||||
@ -169,7 +168,7 @@ class PostDetailFragment : MainFragment {
|
|||||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
|
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
|
||||||
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
|
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
|
||||||
|
|
||||||
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>(StateApp.instance.scopeGetter, { PolycentricCache.instance.getProfileAsync(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!!) })
|
||||||
.success { it -> setPolycentricProfile(it, animate = true) }
|
.success { it -> setPolycentricProfile(it, animate = true) }
|
||||||
.exception<Throwable> {
|
.exception<Throwable> {
|
||||||
Logger.w(TAG, "Failed to load claims.", it);
|
Logger.w(TAG, "Failed to load claims.", it);
|
||||||
@ -274,7 +273,7 @@ class PostDetailFragment : MainFragment {
|
|||||||
};
|
};
|
||||||
|
|
||||||
_buttonStore.setOnClickListener {
|
_buttonStore.setOnClickListener {
|
||||||
_polycentricProfile?.profile?.systemState?.store?.let {
|
_polycentricProfile?.systemState?.store?.let {
|
||||||
try {
|
try {
|
||||||
val uri = Uri.parse(it);
|
val uri = Uri.parse(it);
|
||||||
val intent = Intent(Intent.ACTION_VIEW);
|
val intent = Intent(Intent.ACTION_VIEW);
|
||||||
@ -334,7 +333,7 @@ class PostDetailFragment : MainFragment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val queryReferencesResponse = ApiMethods.getQueryReferences(PolycentricCache.SERVER, ref, null,null,
|
val queryReferencesResponse = ApiMethods.getQueryReferences(ApiMethods.SERVER, ref, null,null,
|
||||||
arrayListOf(
|
arrayListOf(
|
||||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(
|
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(
|
||||||
ContentType.OPINION.value).setValue(
|
ContentType.OPINION.value).setValue(
|
||||||
@ -604,17 +603,9 @@ class PostDetailFragment : MainFragment {
|
|||||||
|
|
||||||
private fun fetchPolycentricProfile() {
|
private fun fetchPolycentricProfile() {
|
||||||
val author = _post?.author ?: _postOverview?.author ?: return;
|
val author = _post?.author ?: _postOverview?.author ?: return;
|
||||||
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(author.url, true);
|
|
||||||
if (cachedPolycentricProfile != null) {
|
|
||||||
setPolycentricProfile(cachedPolycentricProfile, animate = false);
|
|
||||||
if (cachedPolycentricProfile.expired) {
|
|
||||||
_taskLoadPolycentricProfile.run(author.id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setPolycentricProfile(null, animate = false);
|
setPolycentricProfile(null, animate = false);
|
||||||
_taskLoadPolycentricProfile.run(author.id);
|
_taskLoadPolycentricProfile.run(author.id);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun setChannelMeta(value: IPlatformPost?) {
|
private fun setChannelMeta(value: IPlatformPost?) {
|
||||||
val subscribers = value?.author?.subscribers;
|
val subscribers = value?.author?.subscribers;
|
||||||
@ -639,17 +630,18 @@ class PostDetailFragment : MainFragment {
|
|||||||
_repliesOverlay.cleanup();
|
_repliesOverlay.cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
|
private fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) {
|
||||||
_polycentricProfile = cachedPolycentricProfile;
|
_polycentricProfile = polycentricProfile;
|
||||||
|
|
||||||
if (cachedPolycentricProfile?.profile == null) {
|
val pp = _polycentricProfile;
|
||||||
|
if (pp == null) {
|
||||||
_layoutMonetization.visibility = View.GONE;
|
_layoutMonetization.visibility = View.GONE;
|
||||||
_creatorThumbnail.setHarborAvailable(false, animate, null);
|
_creatorThumbnail.setHarborAvailable(false, animate, null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_layoutMonetization.visibility = View.VISIBLE;
|
_layoutMonetization.visibility = View.VISIBLE;
|
||||||
_creatorThumbnail.setHarborAvailable(true, animate, cachedPolycentricProfile.profile.system.toProto());
|
_creatorThumbnail.setHarborAvailable(true, animate, pp.system.toProto());
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchPost() {
|
private fun fetchPost() {
|
||||||
|
@ -556,7 +556,7 @@ class SourceDetailFragment : MainFragment() {
|
|||||||
Logger.i(TAG, "Downloaded source config ($sourceUrl):\n${configJson}");
|
Logger.i(TAG, "Downloaded source config ($sourceUrl):\n${configJson}");
|
||||||
|
|
||||||
val config = SourcePluginConfig.fromJson(configJson);
|
val config = SourcePluginConfig.fromJson(configJson);
|
||||||
if (config.version <= c.version && config.name != "Youtube") {
|
if (config.version <= c.version) {
|
||||||
Logger.i(TAG, "Plugin is up to date.");
|
Logger.i(TAG, "Plugin is up to date.");
|
||||||
withContext(Dispatchers.Main) { UIDialogs.toast(context.getString(R.string.plugin_is_fully_up_to_date)); };
|
withContext(Dispatchers.Main) { UIDialogs.toast(context.getString(R.string.plugin_is_fully_up_to_date)); };
|
||||||
return@launch;
|
return@launch;
|
||||||
|
@ -256,6 +256,8 @@ class SubscriptionGroupFragment : MainFragment() {
|
|||||||
val sub = StateSubscriptions.instance.getSubscription(sub) ?: StateSubscriptions.instance.getSubscriptionOther(sub);
|
val sub = StateSubscriptions.instance.getSubscription(sub) ?: StateSubscriptions.instance.getSubscriptionOther(sub);
|
||||||
if(sub != null && sub.channel.thumbnail != null) {
|
if(sub != null && sub.channel.thumbnail != null) {
|
||||||
g.image = ImageVariable.fromUrl(sub.channel.thumbnail!!);
|
g.image = ImageVariable.fromUrl(sub.channel.thumbnail!!);
|
||||||
|
if(g.image != null)
|
||||||
|
g.image!!.subscriptionUrl = sub.channel.url;
|
||||||
g.image?.setImageView(_imageGroup);
|
g.image?.setImageView(_imageGroup);
|
||||||
g.image?.setImageView(_imageGroupBackground);
|
g.image?.setImageView(_imageGroupBackground);
|
||||||
break;
|
break;
|
||||||
|
@ -18,6 +18,7 @@ import com.futo.platformplayer.constructs.TaskHandler
|
|||||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||||
import com.futo.platformplayer.exceptions.ChannelException
|
import com.futo.platformplayer.exceptions.ChannelException
|
||||||
import com.futo.platformplayer.exceptions.RateLimitException
|
import com.futo.platformplayer.exceptions.RateLimitException
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment.SubscriptionsFeedView.FeedFilterSettings
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.SearchType
|
import com.futo.platformplayer.models.SearchType
|
||||||
import com.futo.platformplayer.models.SubscriptionGroup
|
import com.futo.platformplayer.models.SubscriptionGroup
|
||||||
@ -56,6 +57,9 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||||||
private var _group: SubscriptionGroup? = null;
|
private var _group: SubscriptionGroup? = null;
|
||||||
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
|
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
|
||||||
|
|
||||||
|
private val _filterLock = Object();
|
||||||
|
private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter");
|
||||||
|
|
||||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||||
super.onShownWithView(parameter, isBack);
|
super.onShownWithView(parameter, isBack);
|
||||||
_view?.onShown();
|
_view?.onShown();
|
||||||
@ -184,8 +188,6 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||||||
return Json.encodeToString(this);
|
return Json.encodeToString(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private val _filterLock = Object();
|
|
||||||
private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter");
|
|
||||||
|
|
||||||
private var _bypassRateLimit = false;
|
private var _bypassRateLimit = false;
|
||||||
private val _lastExceptions: List<Throwable>? = null;
|
private val _lastExceptions: List<Throwable>? = null;
|
||||||
@ -284,13 +286,18 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||||||
fragment.navigate<SubscriptionGroupFragment>(g);
|
fragment.navigate<SubscriptionGroupFragment>(g);
|
||||||
};
|
};
|
||||||
|
|
||||||
synchronized(_filterLock) {
|
synchronized(fragment._filterLock) {
|
||||||
_subscriptionBar?.setToggles(
|
_subscriptionBar?.setToggles(
|
||||||
SubscriptionBar.Toggle(context.getString(R.string.videos), _filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), it); },
|
SubscriptionBar.Toggle(context.getString(R.string.videos), fragment._filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { view, active ->
|
||||||
SubscriptionBar.Toggle(context.getString(R.string.posts), _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); },
|
toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), active); },
|
||||||
SubscriptionBar.Toggle(context.getString(R.string.live), _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); },
|
SubscriptionBar.Toggle(context.getString(R.string.posts), fragment._filterSettings.allowContentTypes.contains(ContentType.POST)) { view, active ->
|
||||||
SubscriptionBar.Toggle(context.getString(R.string.planned), _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); },
|
toggleFilterContentType(ContentType.POST, active); },
|
||||||
SubscriptionBar.Toggle(context.getString(R.string.watched), _filterSettings.allowWatched) { _filterSettings.allowWatched = it; _filterSettings.save(); loadResults(false); }
|
SubscriptionBar.Toggle(context.getString(R.string.live), fragment._filterSettings.allowLive) { view, active ->
|
||||||
|
fragment._filterSettings.allowLive = active; fragment._filterSettings.save(); loadResults(false); },
|
||||||
|
SubscriptionBar.Toggle(context.getString(R.string.planned), fragment._filterSettings.allowPlanned) { view, active ->
|
||||||
|
fragment._filterSettings.allowPlanned = active; fragment._filterSettings.save(); loadResults(false); },
|
||||||
|
SubscriptionBar.Toggle(context.getString(R.string.watched), fragment._filterSettings.allowWatched) { view, active ->
|
||||||
|
fragment._filterSettings.allowWatched = active; fragment._filterSettings.save(); loadResults(false); }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -301,13 +308,13 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||||||
toggleFilterContentType(contentType, isTrue);
|
toggleFilterContentType(contentType, isTrue);
|
||||||
}
|
}
|
||||||
private fun toggleFilterContentType(contentType: ContentType, isTrue: Boolean) {
|
private fun toggleFilterContentType(contentType: ContentType, isTrue: Boolean) {
|
||||||
synchronized(_filterLock) {
|
synchronized(fragment._filterLock) {
|
||||||
if(!isTrue) {
|
if(!isTrue) {
|
||||||
_filterSettings.allowContentTypes.remove(contentType);
|
fragment._filterSettings.allowContentTypes.remove(contentType);
|
||||||
} else if(!_filterSettings.allowContentTypes.contains(contentType)) {
|
} else if(!fragment._filterSettings.allowContentTypes.contains(contentType)) {
|
||||||
_filterSettings.allowContentTypes.add(contentType)
|
fragment._filterSettings.allowContentTypes.add(contentType)
|
||||||
}
|
}
|
||||||
_filterSettings.save();
|
fragment._filterSettings.save();
|
||||||
};
|
};
|
||||||
if(Settings.instance.subscriptions.fetchOnTabOpen) { //TODO: Do this different, temporary workaround
|
if(Settings.instance.subscriptions.fetchOnTabOpen) { //TODO: Do this different, temporary workaround
|
||||||
loadResults(false);
|
loadResults(false);
|
||||||
@ -320,9 +327,9 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||||||
val nowSoon = OffsetDateTime.now().plusMinutes(5);
|
val nowSoon = OffsetDateTime.now().plusMinutes(5);
|
||||||
val filterGroup = subGroup;
|
val filterGroup = subGroup;
|
||||||
return results.filter {
|
return results.filter {
|
||||||
val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType);
|
val allowedContentType = fragment._filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType);
|
||||||
|
|
||||||
if(it is IPlatformVideo && it.duration > 0 && !_filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration))
|
if(it is IPlatformVideo && it.duration > 0 && !fragment._filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration))
|
||||||
return@filter false;
|
return@filter false;
|
||||||
|
|
||||||
//TODO: Check against a sub cache
|
//TODO: Check against a sub cache
|
||||||
@ -331,11 +338,11 @@ class SubscriptionsFeedFragment : MainFragment() {
|
|||||||
|
|
||||||
|
|
||||||
if(it.datetime?.isAfter(nowSoon) == true) {
|
if(it.datetime?.isAfter(nowSoon) == true) {
|
||||||
if(!_filterSettings.allowPlanned)
|
if(!fragment._filterSettings.allowPlanned)
|
||||||
return@filter false;
|
return@filter false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(_filterSettings.allowLive) { //If allowLive, always show live
|
if(fragment._filterSettings.allowLive) { //If allowLive, always show live
|
||||||
if(it is IPlatformVideo && it.isLive)
|
if(it is IPlatformVideo && it.isLive)
|
||||||
return@filter true;
|
return@filter true;
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.*
|
||||||
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
@ -17,6 +18,8 @@ import com.futo.platformplayer.states.StatePlatform
|
|||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.stores.SearchHistoryStorage
|
import com.futo.platformplayer.stores.SearchHistoryStorage
|
||||||
import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter
|
import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter
|
||||||
|
import com.futo.platformplayer.views.others.RadioGroupView
|
||||||
|
import com.futo.platformplayer.views.others.TagsView
|
||||||
|
|
||||||
data class SuggestionsFragmentData(val query: String, val searchType: SearchType, val channelUrl: String? = null);
|
data class SuggestionsFragmentData(val query: String, val searchType: SearchType, val channelUrl: String? = null);
|
||||||
|
|
||||||
@ -27,6 +30,7 @@ class SuggestionsFragment : MainFragment {
|
|||||||
|
|
||||||
private var _recyclerSuggestions: RecyclerView? = null;
|
private var _recyclerSuggestions: RecyclerView? = null;
|
||||||
private var _llmSuggestions: LinearLayoutManager? = null;
|
private var _llmSuggestions: LinearLayoutManager? = null;
|
||||||
|
private var _radioGroupView: RadioGroupView? = null;
|
||||||
private val _suggestions: ArrayList<String> = ArrayList();
|
private val _suggestions: ArrayList<String> = ArrayList();
|
||||||
private var _query: String? = null;
|
private var _query: String? = null;
|
||||||
private var _searchType: SearchType = SearchType.VIDEO;
|
private var _searchType: SearchType = SearchType.VIDEO;
|
||||||
@ -48,14 +52,7 @@ class SuggestionsFragment : MainFragment {
|
|||||||
_adapterSuggestions.onClicked.subscribe { suggestion ->
|
_adapterSuggestions.onClicked.subscribe { suggestion ->
|
||||||
val storage = FragmentedStorage.get<SearchHistoryStorage>();
|
val storage = FragmentedStorage.get<SearchHistoryStorage>();
|
||||||
storage.add(suggestion);
|
storage.add(suggestion);
|
||||||
|
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType, _channelUrl));
|
||||||
if (_searchType == SearchType.CREATOR) {
|
|
||||||
navigate<CreatorSearchResultsFragment>(suggestion);
|
|
||||||
} else if (_searchType == SearchType.PLAYLIST) {
|
|
||||||
navigate<PlaylistSearchResultsFragment>(suggestion);
|
|
||||||
} else {
|
|
||||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, SearchType.VIDEO, _channelUrl));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_adapterSuggestions.onRemove.subscribe { suggestion ->
|
_adapterSuggestions.onRemove.subscribe { suggestion ->
|
||||||
val index = _suggestions.indexOf(suggestion);
|
val index = _suggestions.indexOf(suggestion);
|
||||||
@ -79,6 +76,15 @@ class SuggestionsFragment : MainFragment {
|
|||||||
recyclerSuggestions.adapter = _adapterSuggestions;
|
recyclerSuggestions.adapter = _adapterSuggestions;
|
||||||
_recyclerSuggestions = recyclerSuggestions;
|
_recyclerSuggestions = recyclerSuggestions;
|
||||||
|
|
||||||
|
_radioGroupView = view.findViewById<RadioGroupView>(R.id.radio_group).apply {
|
||||||
|
onSelectedChange.subscribe {
|
||||||
|
if (it.size != 1)
|
||||||
|
_searchType = SearchType.VIDEO
|
||||||
|
else
|
||||||
|
_searchType = (it[0] ?: SearchType.VIDEO) as SearchType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadSuggestions();
|
loadSuggestions();
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
@ -109,25 +115,27 @@ class SuggestionsFragment : MainFragment {
|
|||||||
_channelUrl = null;
|
_channelUrl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_radioGroupView?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true)
|
||||||
|
|
||||||
topBar?.apply {
|
topBar?.apply {
|
||||||
if (this is SearchTopBarFragment) {
|
if (this is SearchTopBarFragment) {
|
||||||
onSearch.subscribe(this) {
|
onSearch.subscribe(this) {
|
||||||
if (_searchType == SearchType.CREATOR) {
|
|
||||||
navigate<CreatorSearchResultsFragment>(it);
|
|
||||||
} else if (_searchType == SearchType.PLAYLIST) {
|
|
||||||
navigate<PlaylistSearchResultsFragment>(it);
|
|
||||||
} else {
|
|
||||||
if(it.isHttpUrl()) {
|
if(it.isHttpUrl()) {
|
||||||
if(StatePlatform.instance.hasEnabledPlaylistClient(it))
|
if(StatePlatform.instance.hasEnabledPlaylistClient(it))
|
||||||
navigate<RemotePlaylistFragment>(it);
|
navigate<RemotePlaylistFragment>(it);
|
||||||
else if(StatePlatform.instance.hasEnabledChannelClient(it))
|
else if(StatePlatform.instance.hasEnabledChannelClient(it))
|
||||||
navigate<ChannelFragment>(it);
|
navigate<ChannelFragment>(it);
|
||||||
else
|
else {
|
||||||
navigate<VideoDetailFragment>(it);
|
val url = it;
|
||||||
|
activity?.let {
|
||||||
|
close()
|
||||||
|
if(it is MainActivity)
|
||||||
|
it.navigate(it.getFragment<VideoDetailFragment>(), url);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
|
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType, _channelUrl));
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onTextChange.subscribe(this) {
|
onTextChange.subscribe(this) {
|
||||||
@ -189,6 +197,7 @@ class SuggestionsFragment : MainFragment {
|
|||||||
super.onDestroyMainView();
|
super.onDestroyMainView();
|
||||||
_getSuggestions.onError.clear();
|
_getSuggestions.onError.clear();
|
||||||
_recyclerSuggestions = null;
|
_recyclerSuggestions = null;
|
||||||
|
_radioGroupView = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
@ -94,12 +94,10 @@ import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
|||||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||||
import com.futo.platformplayer.fixHtmlLinks
|
import com.futo.platformplayer.fixHtmlLinks
|
||||||
import com.futo.platformplayer.fixHtmlWhitespace
|
import com.futo.platformplayer.fixHtmlWhitespace
|
||||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
|
||||||
import com.futo.platformplayer.getNowDiffSeconds
|
import com.futo.platformplayer.getNowDiffSeconds
|
||||||
import com.futo.platformplayer.helpers.VideoHelper
|
import com.futo.platformplayer.helpers.VideoHelper
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.Subscription
|
import com.futo.platformplayer.models.Subscription
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
|
||||||
import com.futo.platformplayer.receivers.MediaControlReceiver
|
import com.futo.platformplayer.receivers.MediaControlReceiver
|
||||||
import com.futo.platformplayer.selectBestImage
|
import com.futo.platformplayer.selectBestImage
|
||||||
import com.futo.platformplayer.states.AnnouncementType
|
import com.futo.platformplayer.states.AnnouncementType
|
||||||
@ -134,6 +132,7 @@ import com.futo.platformplayer.views.behavior.TouchInterceptFrameLayout
|
|||||||
import com.futo.platformplayer.views.casting.CastView
|
import com.futo.platformplayer.views.casting.CastView
|
||||||
import com.futo.platformplayer.views.comments.AddCommentView
|
import com.futo.platformplayer.views.comments.AddCommentView
|
||||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||||
|
import com.futo.platformplayer.views.overlays.ChaptersOverlay
|
||||||
import com.futo.platformplayer.views.overlays.DescriptionOverlay
|
import com.futo.platformplayer.views.overlays.DescriptionOverlay
|
||||||
import com.futo.platformplayer.views.overlays.LiveChatOverlay
|
import com.futo.platformplayer.views.overlays.LiveChatOverlay
|
||||||
import com.futo.platformplayer.views.overlays.QueueEditorOverlay
|
import com.futo.platformplayer.views.overlays.QueueEditorOverlay
|
||||||
@ -149,6 +148,7 @@ import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
|
|||||||
import com.futo.platformplayer.views.pills.RoundButton
|
import com.futo.platformplayer.views.pills.RoundButton
|
||||||
import com.futo.platformplayer.views.pills.RoundButtonGroup
|
import com.futo.platformplayer.views.pills.RoundButtonGroup
|
||||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||||
|
import com.futo.platformplayer.views.segments.ChaptersList
|
||||||
import com.futo.platformplayer.views.segments.CommentsList
|
import com.futo.platformplayer.views.segments.CommentsList
|
||||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||||
import com.futo.platformplayer.views.video.FutoVideoPlayer
|
import com.futo.platformplayer.views.video.FutoVideoPlayer
|
||||||
@ -158,6 +158,8 @@ import com.futo.polycentric.core.ApiMethods
|
|||||||
import com.futo.polycentric.core.ContentType
|
import com.futo.polycentric.core.ContentType
|
||||||
import com.futo.polycentric.core.Models
|
import com.futo.polycentric.core.Models
|
||||||
import com.futo.polycentric.core.Opinion
|
import com.futo.polycentric.core.Opinion
|
||||||
|
import com.futo.polycentric.core.PolycentricProfile
|
||||||
|
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
|
||||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||||
import com.google.protobuf.ByteString
|
import com.google.protobuf.ByteString
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -195,6 +197,8 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
private var _liveChat: LiveChatManager? = null;
|
private var _liveChat: LiveChatManager? = null;
|
||||||
private var _videoResumePositionMilliseconds : Long = 0L;
|
private var _videoResumePositionMilliseconds : Long = 0L;
|
||||||
|
|
||||||
|
private var _chapters: List<IChapter>? = null;
|
||||||
|
|
||||||
private val _player: FutoVideoPlayer;
|
private val _player: FutoVideoPlayer;
|
||||||
private val _cast: CastView;
|
private val _cast: CastView;
|
||||||
private val _playerProgress: PlayerControlView;
|
private val _playerProgress: PlayerControlView;
|
||||||
@ -263,6 +267,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
private val _container_content_liveChat: LiveChatOverlay;
|
private val _container_content_liveChat: LiveChatOverlay;
|
||||||
private val _container_content_browser: WebviewOverlay;
|
private val _container_content_browser: WebviewOverlay;
|
||||||
private val _container_content_support: SupportOverlay;
|
private val _container_content_support: SupportOverlay;
|
||||||
|
private val _container_content_chapters: ChaptersOverlay;
|
||||||
|
|
||||||
private var _container_content_current: View;
|
private var _container_content_current: View;
|
||||||
|
|
||||||
@ -294,7 +299,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
private set;
|
private set;
|
||||||
private var _historicalPosition: Long = 0;
|
private var _historicalPosition: Long = 0;
|
||||||
private var _commentsCount = 0;
|
private var _commentsCount = 0;
|
||||||
private var _polycentricProfile: PolycentricCache.CachedPolycentricProfile? = null;
|
private var _polycentricProfile: PolycentricProfile? = null;
|
||||||
private var _slideUpOverlay: SlideUpMenuOverlay? = null;
|
private var _slideUpOverlay: SlideUpMenuOverlay? = null;
|
||||||
private var _autoplayVideo: IPlatformVideo? = null
|
private var _autoplayVideo: IPlatformVideo? = null
|
||||||
|
|
||||||
@ -374,6 +379,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_container_content_liveChat = findViewById(R.id.videodetail_container_livechat);
|
_container_content_liveChat = findViewById(R.id.videodetail_container_livechat);
|
||||||
_container_content_support = findViewById(R.id.videodetail_container_support);
|
_container_content_support = findViewById(R.id.videodetail_container_support);
|
||||||
_container_content_browser = findViewById(R.id.videodetail_container_webview)
|
_container_content_browser = findViewById(R.id.videodetail_container_webview)
|
||||||
|
_container_content_chapters = findViewById(R.id.videodetail_container_chapters);
|
||||||
|
|
||||||
_addCommentView = findViewById(R.id.add_comment_view);
|
_addCommentView = findViewById(R.id.add_comment_view);
|
||||||
_commentsList = findViewById(R.id.comments_list);
|
_commentsList = findViewById(R.id.comments_list);
|
||||||
@ -398,6 +404,10 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_monetization = findViewById(R.id.monetization);
|
_monetization = findViewById(R.id.monetization);
|
||||||
_player.attachPlayer();
|
_player.attachPlayer();
|
||||||
|
|
||||||
|
_player.onChapterClicked.subscribe {
|
||||||
|
showChaptersUI();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
_buttonSubscribe.onSubscribed.subscribe {
|
_buttonSubscribe.onSubscribed.subscribe {
|
||||||
_slideUpOverlay = UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
|
_slideUpOverlay = UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
|
||||||
@ -409,12 +419,12 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
};
|
};
|
||||||
|
|
||||||
_monetization.onSupportTap.subscribe {
|
_monetization.onSupportTap.subscribe {
|
||||||
_container_content_support.setPolycentricProfile(_polycentricProfile?.profile);
|
_container_content_support.setPolycentricProfile(_polycentricProfile);
|
||||||
switchContentView(_container_content_support);
|
switchContentView(_container_content_support);
|
||||||
};
|
};
|
||||||
|
|
||||||
_monetization.onStoreTap.subscribe {
|
_monetization.onStoreTap.subscribe {
|
||||||
_polycentricProfile?.profile?.systemState?.store?.let {
|
_polycentricProfile?.systemState?.store?.let {
|
||||||
try {
|
try {
|
||||||
val uri = Uri.parse(it);
|
val uri = Uri.parse(it);
|
||||||
val intent = Intent(Intent.ACTION_VIEW);
|
val intent = Intent(Intent.ACTION_VIEW);
|
||||||
@ -579,6 +589,14 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_minimize_title.setOnClickListener { onMaximize.emit(false) };
|
_minimize_title.setOnClickListener { onMaximize.emit(false) };
|
||||||
_minimize_meta.setOnClickListener { onMaximize.emit(false) };
|
_minimize_meta.setOnClickListener { onMaximize.emit(false) };
|
||||||
|
|
||||||
|
_player.onStateChange.subscribe {
|
||||||
|
if (_player.activelyPlaying) {
|
||||||
|
Logger.i(TAG, "Play changed, resetting error counter _didTriggerDatasourceErrorCount = 0 (_player.activelyPlaying: ${_player.activelyPlaying})")
|
||||||
|
_didTriggerDatasourceErrorCount = 0;
|
||||||
|
_didTriggerDatasourceError = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_player.onPlayChanged.subscribe {
|
_player.onPlayChanged.subscribe {
|
||||||
if (StateCasting.instance.activeDevice == null) {
|
if (StateCasting.instance.activeDevice == null) {
|
||||||
handlePlayChanged(it);
|
handlePlayChanged(it);
|
||||||
@ -675,9 +693,17 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_container_content_description.onClose.subscribe { switchContentView(_container_content_main); };
|
_container_content_description.onClose.subscribe { switchContentView(_container_content_main); };
|
||||||
_container_content_liveChat.onClose.subscribe { switchContentView(_container_content_main); };
|
_container_content_liveChat.onClose.subscribe { switchContentView(_container_content_main); };
|
||||||
_container_content_queue.onClose.subscribe { switchContentView(_container_content_main); };
|
_container_content_queue.onClose.subscribe { switchContentView(_container_content_main); };
|
||||||
|
_container_content_queue.onOptions.subscribe {
|
||||||
|
UISlideOverlays.showVideoOptionsOverlay(it, _overlayContainer);
|
||||||
|
}
|
||||||
_container_content_replies.onClose.subscribe { switchContentView(_container_content_main); };
|
_container_content_replies.onClose.subscribe { switchContentView(_container_content_main); };
|
||||||
_container_content_support.onClose.subscribe { switchContentView(_container_content_main); };
|
_container_content_support.onClose.subscribe { switchContentView(_container_content_main); };
|
||||||
_container_content_browser.onClose.subscribe { switchContentView(_container_content_main); };
|
_container_content_browser.onClose.subscribe { switchContentView(_container_content_main); };
|
||||||
|
_container_content_chapters.onClose.subscribe { switchContentView(_container_content_main); };
|
||||||
|
|
||||||
|
_container_content_chapters.onClick.subscribe {
|
||||||
|
handleSeek(it.timeStart.toLong() * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
_description_viewMore.setOnClickListener {
|
_description_viewMore.setOnClickListener {
|
||||||
switchContentView(_container_content_description);
|
switchContentView(_container_content_description);
|
||||||
@ -844,6 +870,22 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_cast.stopAllGestures();
|
_cast.stopAllGestures();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showChaptersUI(){
|
||||||
|
video?.let {
|
||||||
|
try {
|
||||||
|
_chapters?.let {
|
||||||
|
if(it.size == 0)
|
||||||
|
return@let;
|
||||||
|
_container_content_chapters.setChapters(_chapters);
|
||||||
|
switchContentView(_container_content_chapters);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun updateMoreButtons() {
|
fun updateMoreButtons() {
|
||||||
val isLimitedVersion = video?.url != null && StatePlatform.instance.getContentClientOrNull(video!!.url)?.let {
|
val isLimitedVersion = video?.url != null && StatePlatform.instance.getContentClientOrNull(video!!.url)?.let {
|
||||||
if (it is JSClient)
|
if (it is JSClient)
|
||||||
@ -856,6 +898,13 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_slideUpOverlay = it
|
_slideUpOverlay = it
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
_chapters?.let {
|
||||||
|
if(it != null && it.size > 0)
|
||||||
|
RoundButton(context, R.drawable.ic_list, "Chapters", TAG_CHAPTERS) {
|
||||||
|
showChaptersUI();
|
||||||
|
}
|
||||||
|
else null
|
||||||
},
|
},
|
||||||
if(video?.isLive ?: false)
|
if(video?.isLive ?: false)
|
||||||
RoundButton(context, R.drawable.ic_chat, context.getString(R.string.live_chat), TAG_LIVECHAT) {
|
RoundButton(context, R.drawable.ic_chat, context.getString(R.string.live_chat), TAG_LIVECHAT) {
|
||||||
@ -922,7 +971,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
} else if(devices.size == 1){
|
} else if(devices.size == 1){
|
||||||
val device = devices.first();
|
val device = devices.first();
|
||||||
Logger.i(TAG, "Send to device? (public key: ${device.remotePublicKey}): " + videoToSend.url)
|
Logger.i(TAG, "Send to device? (public key: ${device.remotePublicKey}): " + videoToSend.url)
|
||||||
UIDialogs.showConfirmationDialog(context, "Would you like to open\n[${videoToSend.name}]\non ${device.remotePublicKey}" , {
|
UIDialogs.showConfirmationDialog(context, "Would you like to open\n[${videoToSend.name}]\non '${device.displayName}'" , {
|
||||||
Logger.i(TAG, "Send to device confirmed (public key: ${device.remotePublicKey}): " + videoToSend.url)
|
Logger.i(TAG, "Send to device confirmed (public key: ${device.remotePublicKey}): " + videoToSend.url)
|
||||||
|
|
||||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
@ -963,6 +1012,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
throw IllegalStateException("Expected media content, found ${video.contentType}");
|
throw IllegalStateException("Expected media content, found ${video.contentType}");
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
_videoResumePositionMilliseconds = _player.position
|
||||||
setVideoDetails(video);
|
setVideoDetails(video);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1227,16 +1277,8 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_creatorThumbnail.setThumbnail(video.author.thumbnail, false);
|
_creatorThumbnail.setThumbnail(video.author.thumbnail, false);
|
||||||
_channelName.text = video.author.name;
|
_channelName.text = video.author.name;
|
||||||
|
|
||||||
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(video.author.url, true);
|
|
||||||
if (cachedPolycentricProfile != null) {
|
|
||||||
setPolycentricProfile(cachedPolycentricProfile, animate = false);
|
|
||||||
if (cachedPolycentricProfile.expired) {
|
|
||||||
_taskLoadPolycentricProfile.run(video.author.id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setPolycentricProfile(null, animate = false);
|
setPolycentricProfile(null, animate = false);
|
||||||
_taskLoadPolycentricProfile.run(video.author.id);
|
_taskLoadPolycentricProfile.run(video.author.id);
|
||||||
}
|
|
||||||
|
|
||||||
_player.clear();
|
_player.clear();
|
||||||
|
|
||||||
@ -1265,8 +1307,6 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
fun setVideoDetails(videoDetail: IPlatformVideoDetails, newVideo: Boolean = false) {
|
fun setVideoDetails(videoDetail: IPlatformVideoDetails, newVideo: Boolean = false) {
|
||||||
Logger.i(TAG, "setVideoDetails (${videoDetail.name})")
|
Logger.i(TAG, "setVideoDetails (${videoDetail.name})")
|
||||||
_didTriggerDatasourceErrroCount = 0;
|
|
||||||
_didTriggerDatasourceError = false;
|
|
||||||
_autoplayVideo = null
|
_autoplayVideo = null
|
||||||
Logger.i(TAG, "Autoplay video cleared (setVideoDetails)")
|
Logger.i(TAG, "Autoplay video cleared (setVideoDetails)")
|
||||||
|
|
||||||
@ -1277,6 +1317,10 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_lastVideoSource = null;
|
_lastVideoSource = null;
|
||||||
_lastAudioSource = null;
|
_lastAudioSource = null;
|
||||||
_lastSubtitleSource = null;
|
_lastSubtitleSource = null;
|
||||||
|
|
||||||
|
Logger.i(TAG, "_didTriggerDatasourceErrorCount reset to 0 because new video")
|
||||||
|
_didTriggerDatasourceErrorCount = 0;
|
||||||
|
_didTriggerDatasourceError = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoDetail.datetime != null && videoDetail.datetime!! > OffsetDateTime.now())
|
if (videoDetail.datetime != null && videoDetail.datetime!! > OffsetDateTime.now())
|
||||||
@ -1337,10 +1381,12 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
val chapters = null ?: StatePlatform.instance.getContentChapters(video.url);
|
val chapters = null ?: StatePlatform.instance.getContentChapters(video.url);
|
||||||
_player.setChapters(chapters);
|
_player.setChapters(chapters);
|
||||||
_cast.setChapters(chapters);
|
_cast.setChapters(chapters);
|
||||||
|
_chapters = _player.getChapters();
|
||||||
} catch (ex: Throwable) {
|
} catch (ex: Throwable) {
|
||||||
Logger.e(TAG, "Failed to get chapters", ex);
|
Logger.e(TAG, "Failed to get chapters", ex);
|
||||||
_player.setChapters(null);
|
_player.setChapters(null);
|
||||||
_cast.setChapters(null);
|
_cast.setChapters(null);
|
||||||
|
_chapters = null;
|
||||||
|
|
||||||
/*withContext(Dispatchers.Main) {
|
/*withContext(Dispatchers.Main) {
|
||||||
UIDialogs.toast(context, "Failed to get chapters\n" + ex.message);
|
UIDialogs.toast(context, "Failed to get chapters\n" + ex.message);
|
||||||
@ -1379,6 +1425,10 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
updateMoreButtons();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1394,11 +1444,8 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
setTabIndex(2, true)
|
setTabIndex(2, true)
|
||||||
} else {
|
} else {
|
||||||
when (Settings.instance.comments.defaultCommentSection) {
|
when (Settings.instance.comments.defaultCommentSection) {
|
||||||
0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex(
|
0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex(0, true) else setTabIndex(1, true)
|
||||||
0,
|
1 -> setTabIndex(1, true)
|
||||||
true
|
|
||||||
) else setTabIndex(1, true);
|
|
||||||
1 -> setTabIndex(1, true);
|
|
||||||
2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true)
|
2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1436,16 +1483,8 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_buttonSubscribe.setSubscribeChannel(video.author.url);
|
_buttonSubscribe.setSubscribeChannel(video.author.url);
|
||||||
setDescription(video.description.fixHtmlLinks());
|
setDescription(video.description.fixHtmlLinks());
|
||||||
_creatorThumbnail.setThumbnail(video.author.thumbnail, false);
|
_creatorThumbnail.setThumbnail(video.author.thumbnail, false);
|
||||||
|
|
||||||
|
|
||||||
val cachedPolycentricProfile =
|
|
||||||
PolycentricCache.instance.getCachedProfile(video.author.url, true);
|
|
||||||
if (cachedPolycentricProfile != null) {
|
|
||||||
setPolycentricProfile(cachedPolycentricProfile, animate = false);
|
|
||||||
} else {
|
|
||||||
setPolycentricProfile(null, animate = false);
|
setPolycentricProfile(null, animate = false);
|
||||||
_taskLoadPolycentricProfile.run(video.author.id);
|
_taskLoadPolycentricProfile.run(video.author.id);
|
||||||
}
|
|
||||||
|
|
||||||
_platform.setPlatformFromClientID(video.id.pluginId);
|
_platform.setPlatformFromClientID(video.id.pluginId);
|
||||||
val subTitleSegments: ArrayList<String> = ArrayList();
|
val subTitleSegments: ArrayList<String> = ArrayList();
|
||||||
@ -1474,7 +1513,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val queryReferencesResponse = ApiMethods.getQueryReferences(
|
val queryReferencesResponse = ApiMethods.getQueryReferences(
|
||||||
PolycentricCache.SERVER, ref, null, null,
|
ApiMethods.SERVER, ref, null, null,
|
||||||
arrayListOf(
|
arrayListOf(
|
||||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||||
.setFromType(ContentType.OPINION.value).setValue(
|
.setFromType(ContentType.OPINION.value).setValue(
|
||||||
@ -1490,10 +1529,8 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
val likes = queryReferencesResponse.countsList[0];
|
val likes = queryReferencesResponse.countsList[0];
|
||||||
val dislikes = queryReferencesResponse.countsList[1];
|
val dislikes = queryReferencesResponse.countsList[1];
|
||||||
val hasLiked =
|
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
|
||||||
StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
|
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
|
||||||
val hasDisliked =
|
|
||||||
StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
_rating.visibility = View.VISIBLE;
|
_rating.visibility = View.VISIBLE;
|
||||||
@ -1831,7 +1868,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var _didTriggerDatasourceErrroCount = 0;
|
private var _didTriggerDatasourceErrorCount = 0;
|
||||||
private var _didTriggerDatasourceError = false;
|
private var _didTriggerDatasourceError = false;
|
||||||
private fun onDataSourceError(exception: Throwable) {
|
private fun onDataSourceError(exception: Throwable) {
|
||||||
Logger.e(TAG, "onDataSourceError", exception);
|
Logger.e(TAG, "onDataSourceError", exception);
|
||||||
@ -1841,32 +1878,53 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
return;
|
return;
|
||||||
val config = currentVideo.sourceConfig;
|
val config = currentVideo.sourceConfig;
|
||||||
|
|
||||||
if(_didTriggerDatasourceErrroCount <= 3) {
|
if(_didTriggerDatasourceErrorCount <= 3) {
|
||||||
_didTriggerDatasourceError = true;
|
_didTriggerDatasourceError = true;
|
||||||
_didTriggerDatasourceErrroCount++;
|
_didTriggerDatasourceErrorCount++;
|
||||||
|
|
||||||
|
UIDialogs.toast("Detected video error, attempting automatic reload (${_didTriggerDatasourceErrorCount})");
|
||||||
|
Logger.i(TAG, "Block detected, attempting bypass (_didTriggerDatasourceErrorCount = ${_didTriggerDatasourceErrorCount})");
|
||||||
|
|
||||||
UIDialogs.toast("Block detected, attempting bypass");
|
|
||||||
//return;
|
//return;
|
||||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
val newDetails = StatePlatform.instance.getContentDetails(currentVideo.url, true).await();
|
val newDetails = StatePlatform.instance.getContentDetails(currentVideo.url, true).await();
|
||||||
val previousVideoSource = _lastVideoSource;
|
val previousVideoSource = _lastVideoSource;
|
||||||
val previousAudioSource = _lastAudioSource;
|
val previousAudioSource = _lastAudioSource;
|
||||||
|
|
||||||
if (newDetails is IPlatformVideoDetails) {
|
if (newDetails is IPlatformVideoDetails) {
|
||||||
val newVideoSource = if (previousVideoSource != null)
|
val newVideoSource = if (previousVideoSource != null)
|
||||||
VideoHelper.selectBestVideoSource(newDetails.video, previousVideoSource.height * previousVideoSource.width, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS);
|
VideoHelper.selectBestVideoSource(
|
||||||
|
newDetails.video,
|
||||||
|
previousVideoSource.height * previousVideoSource.width,
|
||||||
|
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
|
||||||
|
);
|
||||||
else null;
|
else null;
|
||||||
val newAudioSource = if (previousAudioSource != null)
|
val newAudioSource = if (previousAudioSource != null)
|
||||||
VideoHelper.selectBestAudioSource(newDetails.video, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS, previousAudioSource.language, previousAudioSource.bitrate.toLong());
|
VideoHelper.selectBestAudioSource(
|
||||||
|
newDetails.video,
|
||||||
|
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
|
||||||
|
previousAudioSource.language,
|
||||||
|
previousAudioSource.bitrate.toLong()
|
||||||
|
);
|
||||||
else null;
|
else null;
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
video = newDetails;
|
video = newDetails;
|
||||||
_player.setSource(newVideoSource, newAudioSource, true, true);
|
_player.setSource(newVideoSource, newAudioSource, true, true, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to get video details, attempting retrying without reloading.", e)
|
||||||
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
video?.let {
|
||||||
|
_videoResumePositionMilliseconds = _player.position
|
||||||
|
setVideoDetails(it, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if(_didTriggerDatasourceErrroCount > 3) {
|
}
|
||||||
|
else if(_didTriggerDatasourceErrorCount > 3) {
|
||||||
UIDialogs.showDialog(context, R.drawable.ic_error_pred,
|
UIDialogs.showDialog(context, R.drawable.ic_error_pred,
|
||||||
context.getString(R.string.media_error),
|
context.getString(R.string.media_error),
|
||||||
context.getString(R.string.the_media_source_encountered_an_unauthorized_error_this_might_be_solved_by_a_plugin_reload_would_you_like_to_reload_experimental),
|
context.getString(R.string.the_media_source_encountered_an_unauthorized_error_this_might_be_solved_by_a_plugin_reload_would_you_like_to_reload_experimental),
|
||||||
@ -2590,7 +2648,10 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onChannelClicked.subscribe {
|
onChannelClicked.subscribe {
|
||||||
|
if(it.url.isNotBlank())
|
||||||
fragment.navigate<ChannelFragment>(it)
|
fragment.navigate<ChannelFragment>(it)
|
||||||
|
else
|
||||||
|
UIDialogs.appToast("No author url present");
|
||||||
}
|
}
|
||||||
|
|
||||||
onAddToWatchLaterClicked.subscribe(this) {
|
onAddToWatchLaterClicked.subscribe(this) {
|
||||||
@ -2773,13 +2834,12 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
|
private fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) {
|
||||||
_polycentricProfile = cachedPolycentricProfile;
|
_polycentricProfile = profile
|
||||||
|
|
||||||
val dp_35 = 35.dp(context.resources)
|
val dp_35 = 35.dp(context.resources)
|
||||||
val profile = cachedPolycentricProfile?.profile;
|
|
||||||
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_35 * dp_35)
|
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_35 * dp_35)
|
||||||
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
|
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }
|
||||||
|
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
_creatorThumbnail.setThumbnail(avatar, animate);
|
_creatorThumbnail.setThumbnail(avatar, animate);
|
||||||
@ -2788,12 +2848,12 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
|
_creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
|
||||||
}
|
}
|
||||||
|
|
||||||
val username = cachedPolycentricProfile?.profile?.systemState?.username
|
val username = profile?.systemState?.username
|
||||||
if (username != null) {
|
if (username != null) {
|
||||||
_channelName.text = username
|
_channelName.text = username
|
||||||
}
|
}
|
||||||
|
|
||||||
_monetization.setPolycentricProfile(cachedPolycentricProfile);
|
_monetization.setPolycentricProfile(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setProgressBarOverlayed(isOverlayed: Boolean?) {
|
fun setProgressBarOverlayed(isOverlayed: Boolean?) {
|
||||||
@ -2981,7 +3041,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
Logger.w(TAG, "Failed to load recommendations.", it);
|
Logger.w(TAG, "Failed to load recommendations.", it);
|
||||||
};
|
};
|
||||||
|
|
||||||
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>(StateApp.instance.scopeGetter, { PolycentricCache.instance.getProfileAsync(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!!) })
|
||||||
.success { it -> setPolycentricProfile(it, animate = true) }
|
.success { it -> setPolycentricProfile(it, animate = true) }
|
||||||
.exception<Throwable> {
|
.exception<Throwable> {
|
||||||
Logger.w(TAG, "Failed to load claims.", it);
|
Logger.w(TAG, "Failed to load claims.", it);
|
||||||
@ -3067,6 +3127,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
const val TAG_SHARE = "share";
|
const val TAG_SHARE = "share";
|
||||||
const val TAG_OVERLAY = "overlay";
|
const val TAG_OVERLAY = "overlay";
|
||||||
const val TAG_LIVECHAT = "livechat";
|
const val TAG_LIVECHAT = "livechat";
|
||||||
|
const val TAG_CHAPTERS = "chapters";
|
||||||
const val TAG_OPEN = "open";
|
const val TAG_OPEN = "open";
|
||||||
const val TAG_SEND_TO_DEVICE = "send_to_device";
|
const val TAG_SEND_TO_DEVICE = "send_to_device";
|
||||||
const val TAG_MORE = "MORE";
|
const val TAG_MORE = "MORE";
|
||||||
|
@ -1,14 +1,17 @@
|
|||||||
package com.futo.platformplayer.fragment.mainactivity.main
|
package com.futo.platformplayer.fragment.mainactivity.main
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.graphics.drawable.Animatable
|
import android.graphics.drawable.Animatable
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.setPadding
|
import androidx.core.view.setPadding
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
@ -22,6 +25,7 @@ import com.futo.platformplayer.states.StateDownloads
|
|||||||
import com.futo.platformplayer.states.StatePlaylists
|
import com.futo.platformplayer.states.StatePlaylists
|
||||||
import com.futo.platformplayer.toHumanDuration
|
import com.futo.platformplayer.toHumanDuration
|
||||||
import com.futo.platformplayer.toHumanTime
|
import com.futo.platformplayer.toHumanTime
|
||||||
|
import com.futo.platformplayer.views.SearchView
|
||||||
import com.futo.platformplayer.views.lists.VideoListEditorView
|
import com.futo.platformplayer.views.lists.VideoListEditorView
|
||||||
|
|
||||||
abstract class VideoListEditorView : LinearLayout {
|
abstract class VideoListEditorView : LinearLayout {
|
||||||
@ -37,9 +41,20 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
protected var _buttonExport: ImageButton;
|
protected var _buttonExport: ImageButton;
|
||||||
private var _buttonShare: ImageButton;
|
private var _buttonShare: ImageButton;
|
||||||
private var _buttonEdit: ImageButton;
|
private var _buttonEdit: ImageButton;
|
||||||
|
private var _buttonSearch: ImageButton;
|
||||||
|
|
||||||
|
private var _search: SearchView;
|
||||||
|
|
||||||
private var _onShare: (()->Unit)? = null;
|
private var _onShare: (()->Unit)? = null;
|
||||||
|
|
||||||
|
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) {
|
constructor(inflater: LayoutInflater) : super(inflater.context) {
|
||||||
inflater.inflate(R.layout.fragment_video_list_editor, this);
|
inflater.inflate(R.layout.fragment_video_list_editor, this);
|
||||||
|
|
||||||
@ -57,26 +72,48 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
_buttonDownload.visibility = View.GONE;
|
_buttonDownload.visibility = View.GONE;
|
||||||
_buttonExport = findViewById(R.id.button_export);
|
_buttonExport = findViewById(R.id.button_export);
|
||||||
_buttonExport.visibility = View.GONE;
|
_buttonExport.visibility = View.GONE;
|
||||||
|
_buttonSearch = findViewById(R.id.button_search);
|
||||||
|
|
||||||
|
_search = findViewById(R.id.search_bar);
|
||||||
|
_search.visibility = View.GONE;
|
||||||
|
_search.onSearchChanged.subscribe {
|
||||||
|
updateVideoFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
_buttonSearch.setOnClickListener {
|
||||||
|
if(_search.isVisible) {
|
||||||
|
_search.visibility = View.GONE;
|
||||||
|
_search.textSearch.text = "";
|
||||||
|
updateVideoFilters();
|
||||||
|
_buttonSearch.setImageResource(R.drawable.ic_search);
|
||||||
|
hideSearchKeyboard();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
_search.visibility = View.VISIBLE;
|
||||||
|
_buttonSearch.setImageResource(R.drawable.ic_search_off);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_buttonShare = findViewById(R.id.button_share);
|
_buttonShare = findViewById(R.id.button_share);
|
||||||
val onShare = _onShare;
|
val onShare = _onShare;
|
||||||
if(onShare != null) {
|
if(onShare != null) {
|
||||||
_buttonShare.setOnClickListener { onShare.invoke() };
|
_buttonShare.setOnClickListener { hideSearchKeyboard(); onShare.invoke() };
|
||||||
_buttonShare.visibility = View.VISIBLE;
|
_buttonShare.visibility = View.VISIBLE;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
_buttonShare.visibility = View.GONE;
|
_buttonShare.visibility = View.GONE;
|
||||||
|
|
||||||
buttonPlayAll.setOnClickListener { onPlayAllClick(); };
|
buttonPlayAll.setOnClickListener { hideSearchKeyboard();onPlayAllClick(); hideSearchKeyboard(); };
|
||||||
buttonShuffle.setOnClickListener { onShuffleClick(); };
|
buttonShuffle.setOnClickListener { hideSearchKeyboard();onShuffleClick(); hideSearchKeyboard(); };
|
||||||
|
|
||||||
_buttonEdit.setOnClickListener { onEditClick(); };
|
_buttonEdit.setOnClickListener { hideSearchKeyboard(); onEditClick(); };
|
||||||
setButtonExportVisible(false);
|
setButtonExportVisible(false);
|
||||||
setButtonDownloadVisible(canEdit());
|
setButtonDownloadVisible(canEdit());
|
||||||
|
|
||||||
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
|
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
|
||||||
videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved);
|
videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved);
|
||||||
videoListEditorView.onVideoClicked.subscribe(::onVideoClicked);
|
videoListEditorView.onVideoOptions.subscribe(::onVideoOptions);
|
||||||
|
videoListEditorView.onVideoClicked.subscribe { hideSearchKeyboard(); onVideoClicked(it)};
|
||||||
|
|
||||||
_videoListEditorView = videoListEditorView;
|
_videoListEditorView = videoListEditorView;
|
||||||
}
|
}
|
||||||
@ -84,6 +121,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
fun setOnShare(onShare: (()-> Unit)? = null) {
|
fun setOnShare(onShare: (()-> Unit)? = null) {
|
||||||
_onShare = onShare;
|
_onShare = onShare;
|
||||||
_buttonShare.setOnClickListener {
|
_buttonShare.setOnClickListener {
|
||||||
|
hideSearchKeyboard();
|
||||||
onShare?.invoke();
|
onShare?.invoke();
|
||||||
};
|
};
|
||||||
_buttonShare.visibility = View.VISIBLE;
|
_buttonShare.visibility = View.VISIBLE;
|
||||||
@ -94,6 +132,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
open fun onShuffleClick() { }
|
open fun onShuffleClick() { }
|
||||||
open fun onEditClick() { }
|
open fun onEditClick() { }
|
||||||
open fun onVideoRemoved(video: IPlatformVideo) {}
|
open fun onVideoRemoved(video: IPlatformVideo) {}
|
||||||
|
open fun onVideoOptions(video: IPlatformVideo) {}
|
||||||
open fun onVideoOrderChanged(videos : List<IPlatformVideo>) {}
|
open fun onVideoOrderChanged(videos : List<IPlatformVideo>) {}
|
||||||
open fun onVideoClicked(video: IPlatformVideo) {
|
open fun onVideoClicked(video: IPlatformVideo) {
|
||||||
|
|
||||||
@ -115,7 +154,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
setButtonExportVisible(false);
|
setButtonExportVisible(false);
|
||||||
_buttonDownload.setImageResource(R.drawable.ic_loader_animated);
|
_buttonDownload.setImageResource(R.drawable.ic_loader_animated);
|
||||||
_buttonDownload.drawable.assume<Animatable, Unit> { it.start() };
|
_buttonDownload.drawable.assume<Animatable, Unit> { it.start() };
|
||||||
_buttonDownload.setOnClickListener {
|
_buttonDownload.setOnClickListener { hideSearchKeyboard();
|
||||||
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
|
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
|
||||||
StateDownloads.instance.deleteCachedPlaylist(playlistId);
|
StateDownloads.instance.deleteCachedPlaylist(playlistId);
|
||||||
});
|
});
|
||||||
@ -124,7 +163,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
else if(isDownloaded) {
|
else if(isDownloaded) {
|
||||||
setButtonExportVisible(true)
|
setButtonExportVisible(true)
|
||||||
_buttonDownload.setImageResource(R.drawable.ic_download_off);
|
_buttonDownload.setImageResource(R.drawable.ic_download_off);
|
||||||
_buttonDownload.setOnClickListener {
|
_buttonDownload.setOnClickListener { hideSearchKeyboard();
|
||||||
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
|
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
|
||||||
StateDownloads.instance.deleteCachedPlaylist(playlistId);
|
StateDownloads.instance.deleteCachedPlaylist(playlistId);
|
||||||
});
|
});
|
||||||
@ -133,7 +172,7 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
else {
|
else {
|
||||||
setButtonExportVisible(false);
|
setButtonExportVisible(false);
|
||||||
_buttonDownload.setImageResource(R.drawable.ic_download);
|
_buttonDownload.setImageResource(R.drawable.ic_download);
|
||||||
_buttonDownload.setOnClickListener {
|
_buttonDownload.setOnClickListener { hideSearchKeyboard();
|
||||||
onDownload();
|
onDownload();
|
||||||
//UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer);
|
//UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer);
|
||||||
}
|
}
|
||||||
@ -171,9 +210,22 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
.load(R.drawable.placeholder_video_thumbnail)
|
.load(R.drawable.placeholder_video_thumbnail)
|
||||||
.into(_imagePlaylistThumbnail)
|
.into(_imagePlaylistThumbnail)
|
||||||
}
|
}
|
||||||
|
_loadedVideos = videos;
|
||||||
|
_loadedVideosCanEdit = canEdit;
|
||||||
_videoListEditorView.setVideos(videos, canEdit);
|
_videoListEditorView.setVideos(videos, canEdit);
|
||||||
}
|
}
|
||||||
|
fun filterVideos(videos: List<IPlatformVideo>): List<IPlatformVideo> {
|
||||||
|
var toReturn = videos;
|
||||||
|
val searchStr = _search.textSearch.text
|
||||||
|
if(!searchStr.isNullOrBlank())
|
||||||
|
toReturn = toReturn.filter { it.name.contains(searchStr, true) || it.author.name.contains(searchStr, true) };
|
||||||
|
return toReturn;
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateVideoFilters() {
|
||||||
|
val videos = _loadedVideos ?: return;
|
||||||
|
_videoListEditorView.setVideos(filterVideos(videos), _loadedVideosCanEdit);
|
||||||
|
}
|
||||||
|
|
||||||
protected fun setButtonDownloadVisible(isVisible: Boolean) {
|
protected fun setButtonDownloadVisible(isVisible: Boolean) {
|
||||||
_buttonDownload.visibility = if (isVisible) View.VISIBLE else View.GONE;
|
_buttonDownload.visibility = if (isVisible) View.VISIBLE else View.GONE;
|
||||||
|
@ -103,6 +103,9 @@ class WatchLaterFragment : MainFragment() {
|
|||||||
StatePlaylists.instance.removeFromWatchLater(video, true);
|
StatePlaylists.instance.removeFromWatchLater(video, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
override fun onVideoOptions(video: IPlatformVideo) {
|
||||||
|
UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer);
|
||||||
|
}
|
||||||
|
|
||||||
override fun onVideoClicked(video: IPlatformVideo) {
|
override fun onVideoClicked(video: IPlatformVideo) {
|
||||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||||
|
@ -14,9 +14,9 @@ import com.futo.platformplayer.R
|
|||||||
import com.futo.platformplayer.api.media.IPlatformClient
|
import com.futo.platformplayer.api.media.IPlatformClient
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
|
||||||
import com.futo.platformplayer.models.Playlist
|
import com.futo.platformplayer.models.Playlist
|
||||||
import com.futo.platformplayer.views.casting.CastButton
|
import com.futo.platformplayer.views.casting.CastButton
|
||||||
|
import com.futo.polycentric.core.PolycentricProfile
|
||||||
|
|
||||||
class NavigationTopBarFragment : TopFragment() {
|
class NavigationTopBarFragment : TopFragment() {
|
||||||
private var _buttonBack: ImageButton? = null;
|
private var _buttonBack: ImageButton? = null;
|
||||||
|
@ -9,6 +9,7 @@ import androidx.media3.datasource.ResolvingDataSource
|
|||||||
import androidx.media3.exoplayer.dash.DashMediaSource
|
import androidx.media3.exoplayer.dash.DashMediaSource
|
||||||
import androidx.media3.exoplayer.dash.manifest.DashManifestParser
|
import androidx.media3.exoplayer.dash.manifest.DashManifestParser
|
||||||
import androidx.media3.exoplayer.source.MediaSource
|
import androidx.media3.exoplayer.source.MediaSource
|
||||||
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||||
@ -85,12 +86,17 @@ class VideoHelper {
|
|||||||
|
|
||||||
return selectBestAudioSource((desc as VideoUnMuxedSourceDescriptor).audioSources.toList(), prefContainers, prefLanguage, targetBitrate);
|
return selectBestAudioSource((desc as VideoUnMuxedSourceDescriptor).audioSources.toList(), prefContainers, prefLanguage, targetBitrate);
|
||||||
}
|
}
|
||||||
fun selectBestAudioSource(altSources : Iterable<IAudioSource>, prefContainers : Array<String>, preferredLanguage: String? = null, targetBitrate: Long? = null) : IAudioSource? {
|
fun selectBestAudioSource(sources : Iterable<IAudioSource>, prefContainers : Array<String>, preferredLanguage: String? = null, targetBitrate: Long? = null) : IAudioSource? {
|
||||||
|
val hasPriority = sources.any { it.priority };
|
||||||
|
var altSources = if(hasPriority) sources.filter { it.priority } else sources;
|
||||||
|
val hasOriginal = altSources.any { it.original };
|
||||||
|
if(hasOriginal && Settings.instance.playback.preferOriginalAudio)
|
||||||
|
altSources = altSources.filter { it.original };
|
||||||
val languageToFilter = if(preferredLanguage != null && altSources.any { it.language == preferredLanguage }) {
|
val languageToFilter = if(preferredLanguage != null && altSources.any { it.language == preferredLanguage }) {
|
||||||
preferredLanguage
|
preferredLanguage
|
||||||
} else {
|
} else {
|
||||||
if(altSources.any { it.language == Language.ENGLISH })
|
if(altSources.any { it.language == Language.ENGLISH })
|
||||||
Language.ENGLISH
|
Language.ENGLISH;
|
||||||
else
|
else
|
||||||
Language.UNKNOWN;
|
Language.UNKNOWN;
|
||||||
}
|
}
|
||||||
@ -208,5 +214,38 @@ class VideoHelper {
|
|||||||
}
|
}
|
||||||
else return 0;
|
else return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun mediaExtensionToMimetype(extension: String): String? {
|
||||||
|
return videoExtensionToMimetype(extension) ?: audioExtensionToMimetype(extension);
|
||||||
|
}
|
||||||
|
fun videoExtensionToMimetype(extension: String): String? {
|
||||||
|
val extensionTrimmed = extension.trim('.').lowercase();
|
||||||
|
return when (extensionTrimmed) {
|
||||||
|
"mp4" -> return "video/mp4";
|
||||||
|
"webm" -> return "video/webm";
|
||||||
|
"m3u8" -> return "video/x-mpegURL";
|
||||||
|
"3gp" -> return "video/3gpp";
|
||||||
|
"mov" -> return "video/quicktime";
|
||||||
|
"mkv" -> return "video/x-matroska";
|
||||||
|
"mp4a" -> return "audio/vnd.apple.mpegurl";
|
||||||
|
"mpga" -> return "audio/mpga";
|
||||||
|
"mp3" -> return "audio/mp3";
|
||||||
|
"webm" -> return "audio/webm";
|
||||||
|
"3gp" -> return "audio/3gpp";
|
||||||
|
else -> null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun audioExtensionToMimetype(extension: String): String? {
|
||||||
|
val extensionTrimmed = extension.trim('.').lowercase();
|
||||||
|
return when (extensionTrimmed) {
|
||||||
|
"mkv" -> return "audio/x-matroska";
|
||||||
|
"mp4a" -> return "audio/vnd.apple.mpegurl";
|
||||||
|
"mpga" -> return "audio/mpga";
|
||||||
|
"mp3" -> return "audio/mp3";
|
||||||
|
"webm" -> return "audio/webm";
|
||||||
|
"3gp" -> return "audio/3gpp";
|
||||||
|
else -> null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
package com.futo.platformplayer.images;
|
package com.futo.platformplayer.images;
|
||||||
|
|
||||||
|
import static com.futo.platformplayer.Extensions_PolycentricKt.getDataLinkFromUrl;
|
||||||
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
@ -12,10 +14,14 @@ import com.bumptech.glide.load.model.ModelLoader;
|
|||||||
import com.bumptech.glide.load.model.ModelLoaderFactory;
|
import com.bumptech.glide.load.model.ModelLoaderFactory;
|
||||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory;
|
import com.bumptech.glide.load.model.MultiModelLoaderFactory;
|
||||||
import com.bumptech.glide.signature.ObjectKey;
|
import com.bumptech.glide.signature.ObjectKey;
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache;
|
import com.futo.polycentric.core.ApiMethods;
|
||||||
|
|
||||||
import kotlin.Unit;
|
import kotlin.Unit;
|
||||||
|
import kotlinx.coroutines.CoroutineScopeKt;
|
||||||
import kotlinx.coroutines.Deferred;
|
import kotlinx.coroutines.Deferred;
|
||||||
|
import kotlinx.coroutines.Dispatchers;
|
||||||
|
import userpackage.Protocol;
|
||||||
|
|
||||||
import java.lang.Exception;
|
import java.lang.Exception;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.util.concurrent.CancellationException;
|
import java.util.concurrent.CancellationException;
|
||||||
@ -60,7 +66,14 @@ public class PolycentricModelLoader implements ModelLoader<String, ByteBuffer> {
|
|||||||
@Override
|
@Override
|
||||||
public void loadData(@NonNull Priority priority, @NonNull DataFetcher.DataCallback<? super ByteBuffer> callback) {
|
public void loadData(@NonNull Priority priority, @NonNull DataFetcher.DataCallback<? super ByteBuffer> callback) {
|
||||||
Log.i("PolycentricModelLoader", this._model);
|
Log.i("PolycentricModelLoader", this._model);
|
||||||
_deferred = PolycentricCache.getInstance().getDataAsync(_model);
|
|
||||||
|
Protocol.URLInfoDataLink dataLink = getDataLinkFromUrl(_model);
|
||||||
|
if (dataLink == null) {
|
||||||
|
callback.onLoadFailed(new Exception("Data link cannot be null"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_deferred = ApiMethods.Companion.getDataFromServerAndReassemble(CoroutineScopeKt.CoroutineScope(Dispatchers.getIO()), dataLink);
|
||||||
_deferred.invokeOnCompletion(throwable -> {
|
_deferred.invokeOnCompletion(throwable -> {
|
||||||
if (throwable != null) {
|
if (throwable != null) {
|
||||||
Log.e("PolycentricModelLoader", "getDataAsync failed throwable: " + throwable.toString());
|
Log.e("PolycentricModelLoader", "getDataAsync failed throwable: " + throwable.toString());
|
||||||
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
@ -3,6 +3,7 @@ package com.futo.platformplayer.models
|
|||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||||
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@ -46,6 +47,7 @@ class HistoryVideo {
|
|||||||
val name = str.substring(indexNext + 3);
|
val name = str.substring(indexNext + 3);
|
||||||
|
|
||||||
val video = resolve?.invoke(url) ?: SerializedPlatformVideo(
|
val video = resolve?.invoke(url) ?: SerializedPlatformVideo(
|
||||||
|
ContentType.MEDIA,
|
||||||
id = PlatformID.asUrlID(url),
|
id = PlatformID.asUrlID(url),
|
||||||
name = name,
|
name = name,
|
||||||
thumbnails = Thumbnails(),
|
thumbnails = Thumbnails(),
|
||||||
|
@ -7,6 +7,8 @@ import android.widget.ImageView
|
|||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.futo.platformplayer.PresetImages
|
import com.futo.platformplayer.PresetImages
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateSubscriptions
|
||||||
import kotlinx.serialization.Contextual
|
import kotlinx.serialization.Contextual
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@ -18,7 +20,8 @@ data class ImageVariable(
|
|||||||
@Transient
|
@Transient
|
||||||
@Contextual
|
@Contextual
|
||||||
private val bitmap: Bitmap? = null,
|
private val bitmap: Bitmap? = null,
|
||||||
val presetName: String? = null) {
|
val presetName: String? = null,
|
||||||
|
var subscriptionUrl: String? = null) {
|
||||||
|
|
||||||
@SuppressLint("DiscouragedApi")
|
@SuppressLint("DiscouragedApi")
|
||||||
fun setImageView(imageView: ImageView, fallbackResId: Int = -1) {
|
fun setImageView(imageView: ImageView, fallbackResId: Int = -1) {
|
||||||
@ -33,6 +36,12 @@ data class ImageVariable(
|
|||||||
} else if(!url.isNullOrEmpty()) {
|
} else if(!url.isNullOrEmpty()) {
|
||||||
Glide.with(imageView)
|
Glide.with(imageView)
|
||||||
.load(url)
|
.load(url)
|
||||||
|
.error(if(!subscriptionUrl.isNullOrBlank()) StateSubscriptions.instance.getSubscription(subscriptionUrl!!)?.channel?.thumbnail else null)
|
||||||
|
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
||||||
|
.into(imageView);
|
||||||
|
} else if(!subscriptionUrl.isNullOrEmpty()) {
|
||||||
|
Glide.with(imageView)
|
||||||
|
.load(StateSubscriptions.instance.getSubscription(subscriptionUrl!!)?.channel?.thumbnail)
|
||||||
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
.placeholder(R.drawable.placeholder_channel_thumbnail)
|
||||||
.into(imageView);
|
.into(imageView);
|
||||||
} else if(!presetName.isNullOrEmpty()) {
|
} else if(!presetName.isNullOrEmpty()) {
|
||||||
@ -63,7 +72,13 @@ data class ImageVariable(
|
|||||||
return ImageVariable(null, null, null, str);
|
return ImageVariable(null, null, null, str);
|
||||||
}
|
}
|
||||||
fun fromFile(file: File): ImageVariable {
|
fun fromFile(file: File): ImageVariable {
|
||||||
|
try {
|
||||||
return ImageVariable.fromBitmap(BitmapFactory.decodeFile(file.absolutePath));
|
return ImageVariable.fromBitmap(BitmapFactory.decodeFile(file.absolutePath));
|
||||||
}
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e("ImageVariable", "Unsupported image format? " + ex.message, ex);
|
||||||
|
return fromResource(R.drawable.ic_error_pred);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -119,7 +119,7 @@ class HLS {
|
|||||||
return if (source is IHLSManifestSource) {
|
return if (source is IHLSManifestSource) {
|
||||||
listOf()
|
listOf()
|
||||||
} else if (source is IHLSManifestAudioSource) {
|
} else if (source is IHLSManifestAudioSource) {
|
||||||
listOf(HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, url))
|
listOf(HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, url))
|
||||||
} else {
|
} else {
|
||||||
throw NotImplementedError()
|
throw NotImplementedError()
|
||||||
}
|
}
|
||||||
@ -340,7 +340,7 @@ class HLS {
|
|||||||
|
|
||||||
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
|
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
|
||||||
return@mapNotNull when (it.type) {
|
return@mapNotNull when (it.type) {
|
||||||
"AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri)
|
"AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, false, it.uri)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,353 +0,0 @@
|
|||||||
package com.futo.platformplayer.polycentric
|
|
||||||
|
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
|
||||||
import com.futo.platformplayer.constructs.BatchedTaskHandler
|
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
|
||||||
import com.futo.platformplayer.getNowDiffSeconds
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.resolveChannelUrls
|
|
||||||
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
|
||||||
import com.futo.platformplayer.states.StatePolycentric
|
|
||||||
import com.futo.platformplayer.stores.CachedPolycentricProfileStorage
|
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
|
||||||
import com.futo.polycentric.core.ApiMethods
|
|
||||||
import com.futo.polycentric.core.ContentType
|
|
||||||
import com.futo.polycentric.core.OwnedClaim
|
|
||||||
import com.futo.polycentric.core.PublicKey
|
|
||||||
import com.futo.polycentric.core.SignedEvent
|
|
||||||
import com.futo.polycentric.core.StorageTypeSystemState
|
|
||||||
import com.futo.polycentric.core.SystemState
|
|
||||||
import com.futo.polycentric.core.base64ToByteArray
|
|
||||||
import com.futo.polycentric.core.base64UrlToByteArray
|
|
||||||
import com.futo.polycentric.core.getClaimIfValid
|
|
||||||
import com.futo.polycentric.core.getValidClaims
|
|
||||||
import com.google.protobuf.ByteString
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Deferred
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import userpackage.Protocol
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import kotlin.system.measureTimeMillis
|
|
||||||
|
|
||||||
class PolycentricCache {
|
|
||||||
data class CachedOwnedClaims(val ownedClaims: List<OwnedClaim>?, val creationTime: OffsetDateTime = OffsetDateTime.now()) {
|
|
||||||
val expired get() = creationTime.getNowDiffSeconds() > CACHE_EXPIRATION_SECONDS
|
|
||||||
}
|
|
||||||
@Serializable
|
|
||||||
data class CachedPolycentricProfile(val profile: PolycentricProfile?, @Serializable(with = OffsetDateTimeSerializer::class) val creationTime: OffsetDateTime = OffsetDateTime.now()) {
|
|
||||||
val expired get() = creationTime.getNowDiffSeconds() > CACHE_EXPIRATION_SECONDS
|
|
||||||
}
|
|
||||||
|
|
||||||
private val _cache = hashMapOf<PlatformID, CachedOwnedClaims>()
|
|
||||||
private val _profileCache = hashMapOf<PublicKey, CachedPolycentricProfile>()
|
|
||||||
private val _profileUrlCache: CachedPolycentricProfileStorage;
|
|
||||||
private val _scope = CoroutineScope(Dispatchers.IO);
|
|
||||||
init {
|
|
||||||
Logger.i(TAG, "Initializing Polycentric cache");
|
|
||||||
val time = measureTimeMillis {
|
|
||||||
_profileUrlCache = FragmentedStorage.get<CachedPolycentricProfileStorage>("profileUrlCache")
|
|
||||||
}
|
|
||||||
Logger.i(TAG, "Initialized Polycentric cache (${_profileUrlCache.map.size}, ${time}ms)");
|
|
||||||
}
|
|
||||||
|
|
||||||
private val _taskGetProfile = BatchedTaskHandler<PublicKey, CachedPolycentricProfile>(_scope,
|
|
||||||
{ system ->
|
|
||||||
val signedEventsList = ApiMethods.getQueryLatest(
|
|
||||||
SERVER,
|
|
||||||
system.toProto(),
|
|
||||||
listOf(
|
|
||||||
ContentType.BANNER.value,
|
|
||||||
ContentType.AVATAR.value,
|
|
||||||
ContentType.USERNAME.value,
|
|
||||||
ContentType.DESCRIPTION.value,
|
|
||||||
ContentType.STORE.value,
|
|
||||||
ContentType.SERVER.value,
|
|
||||||
ContentType.STORE_DATA.value,
|
|
||||||
ContentType.PROMOTION_BANNER.value,
|
|
||||||
ContentType.PROMOTION.value,
|
|
||||||
ContentType.MEMBERSHIP_URLS.value,
|
|
||||||
ContentType.DONATION_DESTINATIONS.value
|
|
||||||
)
|
|
||||||
).eventsList.map { e -> SignedEvent.fromProto(e) };
|
|
||||||
|
|
||||||
val signedProfileEvents = signedEventsList.groupBy { e -> e.event.contentType }
|
|
||||||
.map { (_, events) -> events.maxBy { it.event.unixMilliseconds ?: 0 } };
|
|
||||||
|
|
||||||
val storageSystemState = StorageTypeSystemState.create()
|
|
||||||
for (signedEvent in signedProfileEvents) {
|
|
||||||
storageSystemState.update(signedEvent.event)
|
|
||||||
}
|
|
||||||
|
|
||||||
val signedClaimEvents = ApiMethods.getQueryIndex(
|
|
||||||
SERVER,
|
|
||||||
system.toProto(),
|
|
||||||
ContentType.CLAIM.value,
|
|
||||||
limit = 200
|
|
||||||
).eventsList.map { e -> SignedEvent.fromProto(e) };
|
|
||||||
|
|
||||||
val ownedClaims: ArrayList<OwnedClaim> = arrayListOf()
|
|
||||||
for (signedEvent in signedClaimEvents) {
|
|
||||||
if (signedEvent.event.contentType != ContentType.CLAIM.value) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
val response = ApiMethods.getQueryReferences(
|
|
||||||
SERVER,
|
|
||||||
Protocol.Reference.newBuilder()
|
|
||||||
.setReference(signedEvent.toPointer().toProto().toByteString())
|
|
||||||
.setReferenceType(2)
|
|
||||||
.build(),
|
|
||||||
null,
|
|
||||||
Protocol.QueryReferencesRequestEvents.newBuilder()
|
|
||||||
.setFromType(ContentType.VOUCH.value)
|
|
||||||
.build()
|
|
||||||
);
|
|
||||||
|
|
||||||
val ownedClaim = response.itemsList.map { SignedEvent.fromProto(it.event) }.getClaimIfValid(signedEvent);
|
|
||||||
if (ownedClaim != null) {
|
|
||||||
ownedClaims.add(ownedClaim);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Retrieved profile (ownedClaims = $ownedClaims)");
|
|
||||||
val systemState = SystemState.fromStorageTypeSystemState(storageSystemState);
|
|
||||||
return@BatchedTaskHandler CachedPolycentricProfile(PolycentricProfile(system, systemState, ownedClaims));
|
|
||||||
},
|
|
||||||
{ system -> return@BatchedTaskHandler getCachedProfile(system); },
|
|
||||||
{ system, result ->
|
|
||||||
synchronized(_cache) {
|
|
||||||
_profileCache[system] = result;
|
|
||||||
|
|
||||||
if (result.profile != null) {
|
|
||||||
for (claim in result.profile.ownedClaims) {
|
|
||||||
val urls = claim.claim.resolveChannelUrls();
|
|
||||||
for (url in urls)
|
|
||||||
_profileUrlCache.map[url] = result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_profileUrlCache.save();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
private val _batchTaskGetClaims = BatchedTaskHandler<PlatformID, CachedOwnedClaims>(_scope,
|
|
||||||
{ id ->
|
|
||||||
val resolved = if (id.claimFieldType == -1) ApiMethods.getResolveClaim(SERVER, system, id.claimType.toLong(), id.value!!)
|
|
||||||
else ApiMethods.getResolveClaim(SERVER, system, id.claimType.toLong(), id.claimFieldType.toLong(), id.value!!);
|
|
||||||
Logger.v(TAG, "getResolveClaim(url = $SERVER, system = $system, id = $id, claimType = ${id.claimType}, matchAnyField = ${id.value})");
|
|
||||||
val protoEvents = resolved.matchesList.flatMap { arrayListOf(it.claim).apply { addAll(it.proofChainList) } }
|
|
||||||
val resolvedEvents = protoEvents.map { i -> SignedEvent.fromProto(i) };
|
|
||||||
return@BatchedTaskHandler CachedOwnedClaims(resolvedEvents.getValidClaims());
|
|
||||||
},
|
|
||||||
{ id -> return@BatchedTaskHandler getCachedValidClaims(id); },
|
|
||||||
{ id, result ->
|
|
||||||
synchronized(_cache) {
|
|
||||||
_cache[id] = result;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
private val _batchTaskGetData = BatchedTaskHandler<String, ByteBuffer>(_scope,
|
|
||||||
{
|
|
||||||
val dataLink = getDataLinkFromUrl(it) ?: throw Exception("Only URLInfoDataLink is supported");
|
|
||||||
return@BatchedTaskHandler ApiMethods.getDataFromServerAndReassemble(dataLink);
|
|
||||||
},
|
|
||||||
{ return@BatchedTaskHandler null },
|
|
||||||
{ _, _ -> });
|
|
||||||
|
|
||||||
fun getCachedValidClaims(id: PlatformID, ignoreExpired: Boolean = false): CachedOwnedClaims? {
|
|
||||||
if (!StatePolycentric.instance.enabled || id.claimType <= 0) {
|
|
||||||
return CachedOwnedClaims(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(_cache) {
|
|
||||||
val cached = _cache[id]
|
|
||||||
if (cached == null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ignoreExpired && cached.expired) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: Review all return null in this file, perhaps it should be CachedX(null) instead
|
|
||||||
fun getValidClaimsAsync(id: PlatformID): Deferred<CachedOwnedClaims> {
|
|
||||||
if (!StatePolycentric.instance.enabled || id.value == null || id.claimType <= 0) {
|
|
||||||
return _scope.async { CachedOwnedClaims(null) };
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.v(TAG, "getValidClaims (id: $id)")
|
|
||||||
val def = _batchTaskGetClaims.execute(id);
|
|
||||||
def.invokeOnCompletion {
|
|
||||||
if (it == null) {
|
|
||||||
return@invokeOnCompletion
|
|
||||||
}
|
|
||||||
|
|
||||||
handleException(it, handleNetworkException = { /* Do nothing (do not cache) */ }, handleOtherException = {
|
|
||||||
//Cache failed result
|
|
||||||
synchronized(_cache) {
|
|
||||||
_cache[id] = CachedOwnedClaims(null);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
return def;
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDataAsync(url: String): Deferred<ByteBuffer> {
|
|
||||||
StatePolycentric.instance.ensureEnabled()
|
|
||||||
return _batchTaskGetData.execute(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCachedProfile(url: String, ignoreExpired: Boolean = false): CachedPolycentricProfile? {
|
|
||||||
if (!StatePolycentric.instance.enabled) {
|
|
||||||
return CachedPolycentricProfile(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized (_profileCache) {
|
|
||||||
val cached = _profileUrlCache.get(url) ?: return null;
|
|
||||||
if (!ignoreExpired && cached.expired) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCachedProfile(system: PublicKey, ignoreExpired: Boolean = false): CachedPolycentricProfile? {
|
|
||||||
if (!StatePolycentric.instance.enabled) {
|
|
||||||
return CachedPolycentricProfile(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(_profileCache) {
|
|
||||||
val cached = _profileCache[system] ?: return null;
|
|
||||||
if (!ignoreExpired && cached.expired) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getProfileAsync(id: PlatformID, urlNullCache: String? = null): CachedPolycentricProfile? {
|
|
||||||
if (!StatePolycentric.instance.enabled || id.claimType <= 0) {
|
|
||||||
return CachedPolycentricProfile(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
val cachedClaims = getCachedValidClaims(id);
|
|
||||||
if (cachedClaims != null) {
|
|
||||||
if (!cachedClaims.ownedClaims.isNullOrEmpty()) {
|
|
||||||
Logger.v(TAG, "getProfileAsync (id: $id) != null (with cached valid claims)")
|
|
||||||
return getProfileAsync(cachedClaims.ownedClaims.first().system).await();
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Logger.v(TAG, "getProfileAsync (id: $id) no cached valid claims, will be retrieved")
|
|
||||||
|
|
||||||
val claims = getValidClaimsAsync(id).await()
|
|
||||||
if (!claims.ownedClaims.isNullOrEmpty()) {
|
|
||||||
Logger.v(TAG, "getProfileAsync (id: $id) != null (with retrieved valid claims)")
|
|
||||||
return getProfileAsync(claims.ownedClaims.first().system).await()
|
|
||||||
} else {
|
|
||||||
synchronized (_cache) {
|
|
||||||
if (urlNullCache != null) {
|
|
||||||
_profileUrlCache.setAndSave(urlNullCache, CachedPolycentricProfile(null))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getProfileAsync(system: PublicKey): Deferred<CachedPolycentricProfile?> {
|
|
||||||
if (!StatePolycentric.instance.enabled) {
|
|
||||||
return _scope.async { CachedPolycentricProfile(null) };
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "getProfileAsync (system: ${system})")
|
|
||||||
val def = _taskGetProfile.execute(system);
|
|
||||||
def.invokeOnCompletion {
|
|
||||||
if (it == null) {
|
|
||||||
return@invokeOnCompletion
|
|
||||||
}
|
|
||||||
|
|
||||||
handleException(it, handleNetworkException = { /* Do nothing (do not cache) */ }, handleOtherException = {
|
|
||||||
//Cache failed result
|
|
||||||
synchronized(_cache) {
|
|
||||||
val cachedProfile = CachedPolycentricProfile(null);
|
|
||||||
_profileCache[system] = cachedProfile;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
return def;
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleException(e: Throwable, handleNetworkException: () -> Unit, handleOtherException: () -> Unit) {
|
|
||||||
val isNetworkException = when(e) {
|
|
||||||
is java.net.UnknownHostException,
|
|
||||||
is java.net.SocketTimeoutException,
|
|
||||||
is java.net.ConnectException -> true
|
|
||||||
else -> when(e.cause) {
|
|
||||||
is java.net.UnknownHostException,
|
|
||||||
is java.net.SocketTimeoutException,
|
|
||||||
is java.net.ConnectException -> true
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isNetworkException) {
|
|
||||||
handleNetworkException()
|
|
||||||
} else {
|
|
||||||
handleOtherException()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val system = Protocol.PublicKey.newBuilder()
|
|
||||||
.setKeyType(1)
|
|
||||||
.setKey(ByteString.copyFrom("gX0eCWctTm6WHVGot4sMAh7NDAIwWsIM5tRsOz9dX04=".base64ToByteArray())) //Production key
|
|
||||||
//.setKey(ByteString.copyFrom("LeQkzn1j625YZcZHayfCmTX+6ptrzsA+CdAyq+BcEdQ".base64ToByteArray())) //Test key koen-futo
|
|
||||||
.build();
|
|
||||||
|
|
||||||
private const val TAG = "PolycentricCache"
|
|
||||||
const val SERVER = "https://srv1-prod.polycentric.io"
|
|
||||||
private var _instance: PolycentricCache? = null;
|
|
||||||
private val CACHE_EXPIRATION_SECONDS = 60 * 5;
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
val instance: PolycentricCache
|
|
||||||
get(){
|
|
||||||
if(_instance == null)
|
|
||||||
_instance = PolycentricCache();
|
|
||||||
return _instance!!;
|
|
||||||
};
|
|
||||||
|
|
||||||
fun finish() {
|
|
||||||
_instance?.let {
|
|
||||||
_instance = null;
|
|
||||||
it._scope.cancel("PolycentricCache finished");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDataLinkFromUrl(it: String): Protocol.URLInfoDataLink? {
|
|
||||||
val urlData = if (it.startsWith("polycentric://")) {
|
|
||||||
it.substring("polycentric://".length)
|
|
||||||
} else it;
|
|
||||||
|
|
||||||
val urlBytes = urlData.base64UrlToByteArray();
|
|
||||||
val urlInfo = Protocol.URLInfo.parseFrom(urlBytes);
|
|
||||||
if (urlInfo.urlType != 4L) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val dataLink = Protocol.URLInfoDataLink.parseFrom(urlInfo.body);
|
|
||||||
return dataLink
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -40,3 +40,15 @@ class OffsetDateTimeSerializer : KSerializer<OffsetDateTime> {
|
|||||||
return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
class OffsetDateTimeStringSerializer : KSerializer<OffsetDateTime> {
|
||||||
|
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("OffsetDateTime", PrimitiveKind.STRING)
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: OffsetDateTime) {
|
||||||
|
encoder.encodeString(value.toString());
|
||||||
|
}
|
||||||
|
override fun deserialize(decoder: Decoder): OffsetDateTime {
|
||||||
|
val str = decoder.decodeString();
|
||||||
|
|
||||||
|
return OffsetDateTime.parse(str);
|
||||||
|
}
|
||||||
|
}
|
@ -19,10 +19,10 @@ import kotlinx.serialization.json.jsonPrimitive
|
|||||||
class PlatformContentSerializer : JsonContentPolymorphicSerializer<SerializedPlatformContent>(SerializedPlatformContent::class) {
|
class PlatformContentSerializer : JsonContentPolymorphicSerializer<SerializedPlatformContent>(SerializedPlatformContent::class) {
|
||||||
|
|
||||||
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<SerializedPlatformContent> {
|
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<SerializedPlatformContent> {
|
||||||
val obj = element.jsonObject["contentType"];
|
val obj = element.jsonObject["contentType"] ?: element.jsonObject["ContentType"];
|
||||||
|
|
||||||
//TODO: Remove this temporary fallback..at some point
|
//TODO: Remove this temporary fallback..at some point
|
||||||
if(obj == null && element.jsonObject["isLive"]?.jsonPrimitive?.booleanOrNull != null)
|
if(obj == null && (element.jsonObject["isLive"]?.jsonPrimitive?.booleanOrNull ?: element.jsonObject["IsLive"]?.jsonPrimitive?.booleanOrNull) != null)
|
||||||
return SerializedPlatformVideo.serializer();
|
return SerializedPlatformVideo.serializer();
|
||||||
|
|
||||||
if(obj?.jsonPrimitive?.isString != false) {
|
if(obj?.jsonPrimitive?.isString != false) {
|
||||||
|
@ -411,7 +411,7 @@ class StateApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Settings.instance.synchronization.enabled) {
|
if (Settings.instance.synchronization.enabled) {
|
||||||
StateSync.instance.start()
|
StateSync.instance.start(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.onLogSubmitted.subscribe {
|
Logger.onLogSubmitted.subscribe {
|
||||||
@ -519,12 +519,16 @@ class StateApp {
|
|||||||
Logger.i(TAG, "MainApp Started: Fetch [Subscriptions]");
|
Logger.i(TAG, "MainApp Started: Fetch [Subscriptions]");
|
||||||
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
|
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
|
||||||
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
|
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
|
||||||
val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true };
|
val isBelowRateLimit = !subRequestCounts.any { clientCount ->
|
||||||
if (isRateLimitReached) {
|
clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true
|
||||||
|
};
|
||||||
|
if (isBelowRateLimit) {
|
||||||
Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}");
|
Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}");
|
||||||
delay(5000);
|
delay(5000);
|
||||||
|
scopeOrNull?.let {
|
||||||
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
|
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
|
||||||
StateSubscriptions.instance.updateSubscriptionFeed(scope, false);
|
StateSubscriptions.instance.updateSubscriptionFeed(it, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}");
|
Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}");
|
||||||
|
@ -6,7 +6,6 @@ import com.futo.platformplayer.api.media.structures.DedupContentPager
|
|||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
|
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
|
||||||
import com.futo.platformplayer.resolveChannelUrl
|
import com.futo.platformplayer.resolveChannelUrl
|
||||||
import com.futo.platformplayer.serializers.PlatformContentSerializer
|
import com.futo.platformplayer.serializers.PlatformContentSerializer
|
||||||
import com.futo.platformplayer.stores.db.ManagedDBStore
|
import com.futo.platformplayer.stores.db.ManagedDBStore
|
||||||
@ -50,14 +49,7 @@ class StateCache {
|
|||||||
val subs = StateSubscriptions.instance.getSubscriptions();
|
val subs = StateSubscriptions.instance.getSubscriptions();
|
||||||
Logger.i(TAG, "Subscriptions CachePager polycentric urls");
|
Logger.i(TAG, "Subscriptions CachePager polycentric urls");
|
||||||
val allUrls = subs
|
val allUrls = subs
|
||||||
.map {
|
.map { it.channel.url }
|
||||||
val otherUrls = PolycentricCache.instance.getCachedProfile(it.channel.url)?.profile?.ownedClaims?.mapNotNull { c -> c.claim.resolveChannelUrl() } ?: listOf();
|
|
||||||
if(!otherUrls.contains(it.channel.url))
|
|
||||||
return@map listOf(listOf(it.channel.url), otherUrls).flatten();
|
|
||||||
else
|
|
||||||
return@map otherUrls;
|
|
||||||
}
|
|
||||||
.flatten()
|
|
||||||
.distinct()
|
.distinct()
|
||||||
.filter { StatePlatform.instance.hasEnabledChannelClient(it) };
|
.filter { StatePlatform.instance.hasEnabledChannelClient(it) };
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ class StateMeta {
|
|||||||
return when(lastCommentSection.value){
|
return when(lastCommentSection.value){
|
||||||
"Polycentric" -> 0;
|
"Polycentric" -> 0;
|
||||||
"Platform" -> 1;
|
"Platform" -> 1;
|
||||||
else -> 1
|
else -> 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun setLastCommentSection(value: Int) {
|
fun setLastCommentSection(value: Int) {
|
||||||
|
@ -632,6 +632,27 @@ class StatePlatform {
|
|||||||
return pager;
|
return pager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun searchChannelsAsContent(query: String): IPager<IPlatformContent> {
|
||||||
|
Logger.i(TAG, "Platform - searchChannels");
|
||||||
|
val pagers = mutableMapOf<IPager<IPlatformContent>, Float>();
|
||||||
|
getSortedEnabledClient().parallelStream().forEach {
|
||||||
|
try {
|
||||||
|
if (it.capabilities.hasChannelSearch)
|
||||||
|
pagers.put(it.searchChannelsAsContent(query), 1f);
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed search channels", ex)
|
||||||
|
UIDialogs.toast("Failed search channels on [${it.name}]\n(${ex.message})");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if(pagers.isEmpty())
|
||||||
|
return EmptyPager<IPlatformContent>();
|
||||||
|
|
||||||
|
val pager = MultiDistributionContentPager(pagers);
|
||||||
|
pager.initialize();
|
||||||
|
return pager;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
//Video
|
//Video
|
||||||
fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { it.isContentDetailsUrl(url) };
|
fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { it.isContentDetailsUrl(url) };
|
||||||
|
@ -230,17 +230,20 @@ class StatePlaylists {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun broadcastWatchLater(orderOnly: Boolean = false) {
|
public fun getWatchLaterSyncPacket(orderOnly: Boolean = false): SyncWatchLaterPackage{
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
return SyncWatchLaterPackage(
|
||||||
try {
|
|
||||||
StateSync.instance.broadcastJsonData(
|
|
||||||
GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
|
|
||||||
if (orderOnly) listOf() else getWatchLater(),
|
if (orderOnly) listOf() else getWatchLater(),
|
||||||
if (orderOnly) mapOf() else _watchLaterAdds.all(),
|
if (orderOnly) mapOf() else _watchLaterAdds.all(),
|
||||||
if (orderOnly) mapOf() else _watchLaterRemovals.all(),
|
if (orderOnly) mapOf() else _watchLaterRemovals.all(),
|
||||||
getWatchLaterLastReorderTime().toEpochSecond(),
|
getWatchLaterLastReorderTime().toEpochSecond(),
|
||||||
_watchlistOrderStore.values.toList()
|
_watchlistOrderStore.values.toList()
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
private fun broadcastWatchLater(orderOnly: Boolean = false) {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
StateSync.instance.broadcastJsonData(
|
||||||
|
GJSyncOpcodes.syncWatchLater, getWatchLaterSyncPacket(orderOnly)
|
||||||
);
|
);
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Failed to broadcast watch later", e)
|
Logger.w(TAG, "Failed to broadcast watch later", e)
|
||||||
|
@ -21,9 +21,7 @@ import com.futo.platformplayer.api.media.structures.IPager
|
|||||||
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
|
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
|
||||||
import com.futo.platformplayer.awaitFirstDeferred
|
import com.futo.platformplayer.awaitFirstDeferred
|
||||||
import com.futo.platformplayer.dp
|
import com.futo.platformplayer.dp
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
|
||||||
import com.futo.platformplayer.polycentric.PolycentricStorage
|
import com.futo.platformplayer.polycentric.PolycentricStorage
|
||||||
import com.futo.platformplayer.resolveChannelUrl
|
import com.futo.platformplayer.resolveChannelUrl
|
||||||
import com.futo.platformplayer.selectBestImage
|
import com.futo.platformplayer.selectBestImage
|
||||||
@ -33,6 +31,7 @@ import com.futo.polycentric.core.ApiMethods
|
|||||||
import com.futo.polycentric.core.ClaimType
|
import com.futo.polycentric.core.ClaimType
|
||||||
import com.futo.polycentric.core.ContentType
|
import com.futo.polycentric.core.ContentType
|
||||||
import com.futo.polycentric.core.Opinion
|
import com.futo.polycentric.core.Opinion
|
||||||
|
import com.futo.polycentric.core.PolycentricProfile
|
||||||
import com.futo.polycentric.core.ProcessHandle
|
import com.futo.polycentric.core.ProcessHandle
|
||||||
import com.futo.polycentric.core.PublicKey
|
import com.futo.polycentric.core.PublicKey
|
||||||
import com.futo.polycentric.core.SignedEvent
|
import com.futo.polycentric.core.SignedEvent
|
||||||
@ -234,33 +233,6 @@ class StatePolycentric {
|
|||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return Pair(false, listOf(url));
|
return Pair(false, listOf(url));
|
||||||
}
|
}
|
||||||
var polycentricProfile: PolycentricProfile? = null;
|
|
||||||
try {
|
|
||||||
val polycentricCached = PolycentricCache.instance.getCachedProfile(url, cacheOnly)
|
|
||||||
polycentricProfile = polycentricCached?.profile;
|
|
||||||
if (polycentricCached == null && channelId != null) {
|
|
||||||
Logger.i("StateSubscriptions", "Get polycentric profile not cached");
|
|
||||||
if(!cacheOnly) {
|
|
||||||
polycentricProfile = runBlocking { PolycentricCache.instance.getProfileAsync(channelId, if(doCacheNull) url else null) }?.profile;
|
|
||||||
didUpdate = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Logger.i("StateSubscriptions", "Get polycentric profile cached");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch(ex: Throwable) {
|
|
||||||
Logger.w(StateSubscriptions.TAG, "Polycentric getCachedProfile failed for subscriptions", ex);
|
|
||||||
//TODO: Some way to communicate polycentric failing without blocking here
|
|
||||||
}
|
|
||||||
if(polycentricProfile != null) {
|
|
||||||
val urls = polycentricProfile.ownedClaims.groupBy { it.claim.claimType }
|
|
||||||
.mapNotNull { it.value.firstOrNull()?.claim?.resolveChannelUrl() }.toMutableList();
|
|
||||||
if(urls.any { it.equals(url, true) })
|
|
||||||
return Pair(didUpdate, urls);
|
|
||||||
else
|
|
||||||
return Pair(didUpdate, listOf(url) + urls);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
return Pair(didUpdate, listOf(url));
|
return Pair(didUpdate, listOf(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -325,7 +297,7 @@ class StatePolycentric {
|
|||||||
id = PlatformID("polycentric", author, null, ClaimType.POLYCENTRIC.value.toInt()),
|
id = PlatformID("polycentric", author, null, ClaimType.POLYCENTRIC.value.toInt()),
|
||||||
name = systemState.username,
|
name = systemState.username,
|
||||||
url = author,
|
url = author,
|
||||||
thumbnail = systemState.avatar?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(system.toProto(), img.process, listOf(PolycentricCache.SERVER)) },
|
thumbnail = systemState.avatar?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(system.toProto(), img.process, listOf(ApiMethods.SERVER)) },
|
||||||
subscribers = null
|
subscribers = null
|
||||||
),
|
),
|
||||||
msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content,
|
msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content,
|
||||||
@ -349,7 +321,7 @@ class StatePolycentric {
|
|||||||
suspend fun getLikesDislikesReplies(reference: Protocol.Reference): LikesDislikesReplies {
|
suspend fun getLikesDislikesReplies(reference: Protocol.Reference): LikesDislikesReplies {
|
||||||
ensureEnabled()
|
ensureEnabled()
|
||||||
|
|
||||||
val response = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, null,
|
val response = ApiMethods.getQueryReferences(ApiMethods.SERVER, reference, null,
|
||||||
null,
|
null,
|
||||||
listOf(
|
listOf(
|
||||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||||
@ -382,7 +354,7 @@ class StatePolycentric {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val pointer = Protocol.Pointer.parseFrom(reference.reference)
|
val pointer = Protocol.Pointer.parseFrom(reference.reference)
|
||||||
val events = ApiMethods.getEvents(PolycentricCache.SERVER, pointer.system, Protocol.RangesForSystem.newBuilder()
|
val events = ApiMethods.getEvents(ApiMethods.SERVER, pointer.system, Protocol.RangesForSystem.newBuilder()
|
||||||
.addRangesForProcesses(Protocol.RangesForProcess.newBuilder()
|
.addRangesForProcesses(Protocol.RangesForProcess.newBuilder()
|
||||||
.setProcess(pointer.process)
|
.setProcess(pointer.process)
|
||||||
.addRanges(Protocol.Range.newBuilder()
|
.addRanges(Protocol.Range.newBuilder()
|
||||||
@ -400,11 +372,11 @@ class StatePolycentric {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val post = Protocol.Post.parseFrom(ev.content);
|
val post = Protocol.Post.parseFrom(ev.content);
|
||||||
val systemLinkUrl = ev.system.systemToURLInfoSystemLinkUrl(listOf(PolycentricCache.SERVER));
|
val systemLinkUrl = ev.system.systemToURLInfoSystemLinkUrl(listOf(ApiMethods.SERVER));
|
||||||
val dp_25 = 25.dp(StateApp.instance.context.resources)
|
val dp_25 = 25.dp(StateApp.instance.context.resources)
|
||||||
|
|
||||||
val profileEvents = ApiMethods.getQueryLatest(
|
val profileEvents = ApiMethods.getQueryLatest(
|
||||||
PolycentricCache.SERVER,
|
ApiMethods.SERVER,
|
||||||
ev.system.toProto(),
|
ev.system.toProto(),
|
||||||
listOf(
|
listOf(
|
||||||
ContentType.AVATAR.value,
|
ContentType.AVATAR.value,
|
||||||
@ -433,7 +405,7 @@ class StatePolycentric {
|
|||||||
id = PlatformID("polycentric", systemLinkUrl, null, ClaimType.POLYCENTRIC.value.toInt()),
|
id = PlatformID("polycentric", systemLinkUrl, null, ClaimType.POLYCENTRIC.value.toInt()),
|
||||||
name = nameEvent?.event?.lwwElement?.value?.decodeToString() ?: "Unknown",
|
name = nameEvent?.event?.lwwElement?.value?.decodeToString() ?: "Unknown",
|
||||||
url = systemLinkUrl,
|
url = systemLinkUrl,
|
||||||
thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(PolycentricCache.SERVER)) },
|
thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(ApiMethods.SERVER)) },
|
||||||
subscribers = null
|
subscribers = null
|
||||||
),
|
),
|
||||||
msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content,
|
msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content,
|
||||||
@ -445,12 +417,12 @@ class StatePolycentric {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCommentPager(contextUrl: String, reference: Protocol.Reference, extraByteReferences: List<ByteArray>? = null): IPager<IPlatformComment> {
|
suspend fun getCommentPager(contextUrl: String, reference: Reference, extraByteReferences: List<ByteArray>? = null): IPager<IPlatformComment> {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return EmptyPager()
|
return EmptyPager()
|
||||||
}
|
}
|
||||||
|
|
||||||
val response = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, null,
|
val response = ApiMethods.getQueryReferences(ApiMethods.SERVER, reference, null,
|
||||||
Protocol.QueryReferencesRequestEvents.newBuilder()
|
Protocol.QueryReferencesRequestEvents.newBuilder()
|
||||||
.setFromType(ContentType.POST.value)
|
.setFromType(ContentType.POST.value)
|
||||||
.addAllCountLwwElementReferences(arrayListOf(
|
.addAllCountLwwElementReferences(arrayListOf(
|
||||||
@ -486,7 +458,7 @@ class StatePolycentric {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun nextPageAsync() {
|
override suspend fun nextPageAsync() {
|
||||||
val nextPageResponse = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, _cursor,
|
val nextPageResponse = ApiMethods.getQueryReferences(ApiMethods.SERVER, reference, _cursor,
|
||||||
Protocol.QueryReferencesRequestEvents.newBuilder()
|
Protocol.QueryReferencesRequestEvents.newBuilder()
|
||||||
.setFromType(ContentType.POST.value)
|
.setFromType(ContentType.POST.value)
|
||||||
.addAllCountLwwElementReferences(arrayListOf(
|
.addAllCountLwwElementReferences(arrayListOf(
|
||||||
@ -534,7 +506,7 @@ class StatePolycentric {
|
|||||||
return@mapNotNull LazyComment(scope.async(_commentPoolDispatcher){
|
return@mapNotNull LazyComment(scope.async(_commentPoolDispatcher){
|
||||||
Logger.i(TAG, "Fetching comment data for [" + ev.system.key.toBase64() + "]");
|
Logger.i(TAG, "Fetching comment data for [" + ev.system.key.toBase64() + "]");
|
||||||
val profileEvents = ApiMethods.getQueryLatest(
|
val profileEvents = ApiMethods.getQueryLatest(
|
||||||
PolycentricCache.SERVER,
|
ApiMethods.SERVER,
|
||||||
ev.system.toProto(),
|
ev.system.toProto(),
|
||||||
listOf(
|
listOf(
|
||||||
ContentType.AVATAR.value,
|
ContentType.AVATAR.value,
|
||||||
@ -558,7 +530,7 @@ class StatePolycentric {
|
|||||||
|
|
||||||
val unixMilliseconds = ev.unixMilliseconds
|
val unixMilliseconds = ev.unixMilliseconds
|
||||||
//TODO: Don't use single hardcoded sderver here
|
//TODO: Don't use single hardcoded sderver here
|
||||||
val systemLinkUrl = ev.system.systemToURLInfoSystemLinkUrl(listOf(PolycentricCache.SERVER));
|
val systemLinkUrl = ev.system.systemToURLInfoSystemLinkUrl(listOf(ApiMethods.SERVER));
|
||||||
val dp_25 = 25.dp(StateApp.instance.context.resources)
|
val dp_25 = 25.dp(StateApp.instance.context.resources)
|
||||||
return@async PolycentricPlatformComment(
|
return@async PolycentricPlatformComment(
|
||||||
contextUrl = contextUrl,
|
contextUrl = contextUrl,
|
||||||
@ -566,7 +538,7 @@ class StatePolycentric {
|
|||||||
id = PlatformID("polycentric", systemLinkUrl, null, ClaimType.POLYCENTRIC.value.toInt()),
|
id = PlatformID("polycentric", systemLinkUrl, null, ClaimType.POLYCENTRIC.value.toInt()),
|
||||||
name = nameEvent?.event?.lwwElement?.value?.decodeToString() ?: "Unknown",
|
name = nameEvent?.event?.lwwElement?.value?.decodeToString() ?: "Unknown",
|
||||||
url = systemLinkUrl,
|
url = systemLinkUrl,
|
||||||
thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(PolycentricCache.SERVER)) },
|
thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(ApiMethods.SERVER)) },
|
||||||
subscribers = null
|
subscribers = null
|
||||||
),
|
),
|
||||||
msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content,
|
msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content,
|
||||||
|
@ -1,54 +1,17 @@
|
|||||||
package com.futo.platformplayer.states
|
package com.futo.platformplayer.states
|
||||||
|
|
||||||
import com.futo.platformplayer.Settings
|
|
||||||
import com.futo.platformplayer.UIDialogs
|
|
||||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
|
||||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
|
||||||
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
|
|
||||||
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
|
|
||||||
import com.futo.platformplayer.api.media.structures.*
|
|
||||||
import com.futo.platformplayer.api.media.structures.ReusablePager.Companion.asReusable
|
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.constructs.Event1
|
|
||||||
import com.futo.platformplayer.constructs.Event2
|
|
||||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
|
|
||||||
import com.futo.platformplayer.exceptions.ChannelException
|
|
||||||
import com.futo.platformplayer.findNonRuntimeException
|
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
|
||||||
import com.futo.platformplayer.getNowDiffDays
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.Subscription
|
|
||||||
import com.futo.platformplayer.models.SubscriptionGroup
|
import com.futo.platformplayer.models.SubscriptionGroup
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
|
||||||
import com.futo.platformplayer.resolveChannelUrl
|
|
||||||
import com.futo.platformplayer.states.StateHistory.Companion
|
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.stores.StringDateMapStorage
|
import com.futo.platformplayer.stores.StringDateMapStorage
|
||||||
import com.futo.platformplayer.stores.SubscriptionStorage
|
|
||||||
import com.futo.platformplayer.stores.v2.ReconstructStore
|
|
||||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
|
||||||
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm
|
|
||||||
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithms
|
|
||||||
import com.futo.platformplayer.sync.internal.GJSyncOpcodes
|
import com.futo.platformplayer.sync.internal.GJSyncOpcodes
|
||||||
import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
|
import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
|
||||||
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.concurrent.ExecutionException
|
|
||||||
import java.util.concurrent.ForkJoinPool
|
|
||||||
import java.util.concurrent.ForkJoinTask
|
|
||||||
import kotlin.collections.ArrayList
|
|
||||||
import kotlin.coroutines.resumeWithException
|
|
||||||
import kotlin.coroutines.suspendCoroutine
|
|
||||||
import kotlin.streams.asSequence
|
|
||||||
import kotlin.streams.toList
|
|
||||||
import kotlin.system.measureTimeMillis
|
|
||||||
|
|
||||||
/***
|
/***
|
||||||
* Used to maintain subscription groups
|
* Used to maintain subscription groups
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package com.futo.platformplayer.states
|
package com.futo.platformplayer.states
|
||||||
|
|
||||||
|
import SubsExchangeClient
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||||
@ -15,10 +16,10 @@ import com.futo.platformplayer.logging.Logger
|
|||||||
import com.futo.platformplayer.models.ImportCache
|
import com.futo.platformplayer.models.ImportCache
|
||||||
import com.futo.platformplayer.models.Subscription
|
import com.futo.platformplayer.models.Subscription
|
||||||
import com.futo.platformplayer.models.SubscriptionGroup
|
import com.futo.platformplayer.models.SubscriptionGroup
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
|
||||||
import com.futo.platformplayer.resolveChannelUrl
|
import com.futo.platformplayer.resolveChannelUrl
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.stores.StringDateMapStorage
|
import com.futo.platformplayer.stores.StringDateMapStorage
|
||||||
|
import com.futo.platformplayer.stores.StringStorage
|
||||||
import com.futo.platformplayer.stores.StringStringMapStorage
|
import com.futo.platformplayer.stores.StringStringMapStorage
|
||||||
import com.futo.platformplayer.stores.SubscriptionStorage
|
import com.futo.platformplayer.stores.SubscriptionStorage
|
||||||
import com.futo.platformplayer.stores.v2.ReconstructStore
|
import com.futo.platformplayer.stores.v2.ReconstructStore
|
||||||
@ -68,10 +69,24 @@ class StateSubscriptions {
|
|||||||
|
|
||||||
val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>();
|
val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>();
|
||||||
|
|
||||||
|
private val _subsExchangeServer = "https://exchange.grayjay.app/";
|
||||||
|
private val _subscriptionKey = FragmentedStorage.get<StringStorage>("sub_exchange_key");
|
||||||
|
|
||||||
init {
|
init {
|
||||||
global.onUpdateProgress.subscribe { progress, total ->
|
global.onUpdateProgress.subscribe { progress, total ->
|
||||||
onFeedProgress.emit(null, progress, total);
|
onFeedProgress.emit(null, progress, total);
|
||||||
}
|
}
|
||||||
|
if(_subscriptionKey.value.isNullOrBlank())
|
||||||
|
generateNewSubsExchangeKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateNewSubsExchangeKey(){
|
||||||
|
_subscriptionKey.setAndSave(SubsExchangeClient.createPrivateKey());
|
||||||
|
}
|
||||||
|
fun getSubsExchangeClient(): SubsExchangeClient {
|
||||||
|
if(_subscriptionKey.value.isNullOrBlank())
|
||||||
|
throw IllegalStateException("No valid subscription exchange key set");
|
||||||
|
return SubsExchangeClient(_subsExchangeServer, _subscriptionKey.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getOldestUpdateTime(): OffsetDateTime {
|
fun getOldestUpdateTime(): OffsetDateTime {
|
||||||
@ -335,12 +350,6 @@ class StateSubscriptions {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: This causes issues, because what if the profile is not cached yet when the susbcribe button is loaded for example?
|
|
||||||
val cachedProfile = PolycentricCache.instance.getCachedProfile(urls.first(), true)?.profile;
|
|
||||||
if (cachedProfile != null) {
|
|
||||||
return cachedProfile.ownedClaims.any { c -> _subscriptions.hasItem { s -> c.claim.resolveChannelUrl() == s.channel.url } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -366,7 +375,17 @@ class StateSubscriptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null, subGroup: SubscriptionGroup? = null): Pair<IPager<IPlatformContent>, List<Throwable>> {
|
fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null, subGroup: SubscriptionGroup? = null): Pair<IPager<IPlatformContent>, List<Throwable>> {
|
||||||
val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool);
|
var exchangeClient: SubsExchangeClient? = null;
|
||||||
|
if(Settings.instance.subscriptions.useSubscriptionExchange) {
|
||||||
|
try {
|
||||||
|
exchangeClient = getSubsExchangeClient();
|
||||||
|
}
|
||||||
|
catch(ex: Throwable){
|
||||||
|
Logger.e(TAG, "Failed to get subs exchange client: ${ex.message}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool, exchangeClient);
|
||||||
if(onNewCacheHit != null)
|
if(onNewCacheHit != null)
|
||||||
algo.onNewCacheHit.subscribe(onNewCacheHit)
|
algo.onNewCacheHit.subscribe(onNewCacheHit)
|
||||||
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user